diff --git a/src/services/clients/claude/index.mjs b/src/services/clients/claude/index.mjs index 02b9b1117..3d315da6b 100644 --- a/src/services/clients/claude/index.mjs +++ b/src/services/clients/claude/index.mjs @@ -1,7 +1,7 @@ // https://github.com/Explosion-Scratch/claude-unofficial-api /* eslint-disable */ -import { fetchSSE } from '../../../utils/fetch-sse' +import { fetchSSE } from '../../../utils/fetch-sse.mjs' import { isEmpty } from 'lodash-es' /** @@ -190,7 +190,11 @@ export class Claude { if (organizations.error) { throw new Error(organizations.error) } - this.organizationId = organizations[0].uuid + // Accounts linked to the Anthropic Console also list an API-only organization, which + // claude.ai denies chat access to, so prefer the organization that can chat + const organization = + organizations.find((org) => org.capabilities?.includes('chat')) || organizations[0] + this.organizationId = organization.uuid this.recent_conversations = await this.getConversations() this.ready = true } @@ -348,6 +352,9 @@ export class Claude { }, ) const json = await response.json() + if (!Array.isArray(json)) { + throw new Error(json?.error?.message || JSON.stringify(json)) + } return json.map((convo) => new Conversation(this, { conversationId: convo.uuid, ...convo })) } /** diff --git a/tests/unit/services/clients/claude.test.mjs b/tests/unit/services/clients/claude.test.mjs new file mode 100644 index 000000000..4bacaa8ab --- /dev/null +++ b/tests/unit/services/clients/claude.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import Claude from '../../../../src/services/clients/claude/index.mjs' + +const API_ORG = { uuid: 'api-org', capabilities: ['api', 'customer_terms:standard'] } +const CHAT_ORG = { uuid: 'chat-org', capabilities: ['chat', 'claude_pro'] } + +function createClient(routes) { + const requests = [] + const fetch = async (url) => { + requests.push(url) + const path = new URL(url).pathname + if (!(path in routes)) throw new Error('unexpected request ' + path) + return new Response(JSON.stringify(routes[path])) + } + return { client: new Claude({ sessionKey: 'sk-ant-sid02-test', fetch }), requests } +} + +test('Claude init prefers the organization with chat capability', async () => { + const { client, requests } = createClient({ + '/api/organizations': [API_ORG, CHAT_ORG], + '/api/organizations/chat-org/chat_conversations': [{ uuid: 'c1', name: 'First' }], + }) + + await client.init() + + assert.equal(client.organizationId, 'chat-org') + assert.deepEqual( + client.recent_conversations.map((c) => c.conversationId), + ['c1'], + ) + assert.ok(requests.every((url) => !url.includes('api-org'))) +}) + +test('Claude init falls back to the first organization when none has chat capability', async () => { + const { client } = createClient({ + '/api/organizations': [{ uuid: 'first-org' }, { uuid: 'second-org', capabilities: ['api'] }], + '/api/organizations/first-org/chat_conversations': [], + }) + + await client.init() + + assert.equal(client.organizationId, 'first-org') +}) + +test('Claude init reports the claude.ai error when conversations are denied', async () => { + const { client } = createClient({ + '/api/organizations': [API_ORG], + '/api/organizations/api-org/chat_conversations': { + type: 'error', + error: { type: 'permission_error', message: 'Permission denied.' }, + }, + }) + + await assert.rejects(client.init(), { message: 'Permission denied.' }) +}) + +test('Claude init reports the raw response when conversations are not a list', async () => { + const { client } = createClient({ + '/api/organizations': [CHAT_ORG], + '/api/organizations/chat-org/chat_conversations': { data: [] }, + }) + + await assert.rejects(client.init(), { message: '{"data":[]}' }) +})