diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts new file mode 100644 index 00000000..4927c381 --- /dev/null +++ b/src/services/api/client.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, mock, test } from 'bun:test' + +mock.module('src/utils/http.js', () => ({ + getAuthHeaders: mock(() => ({})), + getMCPUserAgent: mock(() => 'client-test-agent'), + getUserAgent: mock(() => 'client-test-agent'), + getWebFetchUserAgent: mock(() => 'client-test-agent'), + withOAuth401Retry: mock(async (fn: () => Promise) => fn()), +})) + +describe('resolveAnthropicClientApiKey', () => { + test('does not inherit a local api key when a provider auth token is explicit', async () => { + const { resolveAnthropicClientApiKey } = await import('./client.js') + const getFallbackApiKey = mock(() => 'sk-keychain-fallback') + + const apiKey = resolveAnthropicClientApiKey({ + envAuthToken: 'provider-bearer-token', + envApiKey: undefined, + getFallbackApiKey, + }) + + expect(apiKey).toBeNull() + expect(getFallbackApiKey).not.toHaveBeenCalled() + }) + + test('preserves an explicit api key when the caller opts into dual auth', async () => { + const { resolveAnthropicClientApiKey } = await import('./client.js') + const getFallbackApiKey = mock(() => 'sk-keychain-fallback') + + const apiKey = resolveAnthropicClientApiKey({ + explicitApiKey: 'sk-explicit-api-key', + envAuthToken: 'provider-bearer-token', + getFallbackApiKey, + }) + + expect(apiKey).toBe('sk-explicit-api-key') + expect(getFallbackApiKey).not.toHaveBeenCalled() + }) + + test('falls back to the local api key when no provider auth token is present', async () => { + const { resolveAnthropicClientApiKey } = await import('./client.js') + const getFallbackApiKey = mock(() => 'sk-keychain-fallback') + + const apiKey = resolveAnthropicClientApiKey({ + envAuthToken: undefined, + envApiKey: undefined, + getFallbackApiKey, + }) + + expect(apiKey).toBe('sk-keychain-fallback') + expect(getFallbackApiKey).toHaveBeenCalled() + }) +}) + +describe('getAnthropicClient', () => { + test('passes bearer-token provider auth without an SDK api key', async () => { + const { getAnthropicClient } = await import('./client.js') + const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN + const originalApiKey = process.env.ANTHROPIC_API_KEY + const originalSimple = process.env.CLAUDE_CODE_SIMPLE + + process.env.ANTHROPIC_AUTH_TOKEN = 'provider-bearer-token' + process.env.CLAUDE_CODE_SIMPLE = '1' + delete process.env.ANTHROPIC_API_KEY + + try { + const client = await getAnthropicClient({ + maxRetries: 0, + model: 'claude-sonnet-4-6', + }) + + expect(client.apiKey).toBeNull() + expect(client._options.defaultHeaders).toMatchObject({ + Authorization: 'Bearer provider-bearer-token', + }) + } finally { + if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN + else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken + + if (originalApiKey === undefined) delete process.env.ANTHROPIC_API_KEY + else process.env.ANTHROPIC_API_KEY = originalApiKey + + if (originalSimple === undefined) delete process.env.CLAUDE_CODE_SIMPLE + else process.env.CLAUDE_CODE_SIMPLE = originalSimple + } + }) +}) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index b26e8b3f..62fc6c9b 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -91,6 +91,24 @@ function createStderrLogger(): ClientOptions['logger'] { } } +export function resolveAnthropicClientApiKey({ + explicitApiKey, + envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN, + envApiKey = process.env.ANTHROPIC_API_KEY, + getFallbackApiKey = getAnthropicApiKey, +}: { + explicitApiKey?: string + envAuthToken?: string + envApiKey?: string + getFallbackApiKey?: () => string | null +}): string | null { + if (envAuthToken && !explicitApiKey && !envApiKey) { + return null + } + + return explicitApiKey || getFallbackApiKey() +} + export async function getAnthropicClient({ apiKey, maxRetries, @@ -319,7 +337,7 @@ export async function getAnthropicClient({ ? null : usingOpenAICodex ? OPENAI_OAUTH_DUMMY_KEY - : apiKey || getAnthropicApiKey(), + : resolveAnthropicClientApiKey({ explicitApiKey: apiKey }), authToken: isClaudeAISubscriber() ? getClaudeAIOAuthTokens()?.accessToken : undefined,