diff --git a/apps/desktop/src/main/connection-ipc.test.ts b/apps/desktop/src/main/connection-ipc.test.ts index d9b0168d..44a00f38 100644 --- a/apps/desktop/src/main/connection-ipc.test.ts +++ b/apps/desktop/src/main/connection-ipc.test.ts @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto'; import { CONNECTION_FETCH_TIMEOUT_MS, _clearModelsCache, + buildAuthHeadersForWire, classifyHttpError, extractIds, extractModelIds, @@ -275,6 +276,23 @@ describe('getCacheKey', () => { }); }); +describe('buildAuthHeadersForWire', () => { + it('adds Bearer auth for remote Anthropic-compatible gateways', () => { + expect(buildAuthHeadersForWire('anthropic', 'sk-ant-test', 'https://api.nagara.top')).toEqual({ + 'x-api-key': 'sk-ant-test', + 'anthropic-version': '2023-06-01', + authorization: 'Bearer sk-ant-test', + }); + }); + + it('keeps localhost Anthropic proxies on x-api-key only', () => { + expect(buildAuthHeadersForWire('anthropic', 'sk-ant-test', 'http://localhost:4000')).toEqual({ + 'x-api-key': 'sk-ant-test', + 'anthropic-version': '2023-06-01', + }); + }); +}); + // --------------------------------------------------------------------------- // connection:v1:test — bad payload returns IPC_BAD_INPUT // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index e026cacd..5bc64912 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -5,6 +5,7 @@ import { ERROR_CODES, type SupportedOnboardingProvider, type WireApi, + buildAuthHeadersForWire as buildSharedAuthHeadersForWire, canonicalBaseUrl, ensureVersionedBase, isSupportedOnboardingProvider, @@ -159,21 +160,13 @@ function buildEndpointForWire( export function buildAuthHeadersForWire( wire: WireApi, apiKey: string, + baseUrl?: string, extraHeaders?: Record, ): Record { - if (apiKey.length === 0) { - // Keyless provider (e.g. IP-whitelisted proxy) — skip auth, keep extras. - const base = wire === 'anthropic' ? { 'anthropic-version': '2023-06-01' } : {}; - return { ...base, ...(extraHeaders ?? {}) }; - } - const base = - wire === 'anthropic' - ? { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - } - : { authorization: `Bearer ${apiKey}` }; - return { ...base, ...(extraHeaders ?? {}) }; + return buildSharedAuthHeadersForWire(wire, apiKey, { + ...(baseUrl !== undefined ? { baseUrl } : {}), + ...(extraHeaders !== undefined ? { extraHeaders } : {}), + }); } function buildModelsEndpoint( @@ -409,7 +402,12 @@ function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestE async function runProviderTest(creds: ActiveProviderCredentials): Promise { const { url } = buildEndpointForWire(creds.wire, creds.baseUrl); - const headers = buildAuthHeadersForWire(creds.wire, creds.apiKey, creds.httpHeaders); + const headers = buildAuthHeadersForWire( + creds.wire, + creds.apiKey, + creds.baseUrl, + creds.httpHeaders, + ); let res: Response; try { @@ -637,7 +635,7 @@ export function registerConnectionIpc(): void { if (cached !== null) return { ok: true, models: cached }; const { url } = buildEndpointForWire(entry.wire, entry.baseUrl); - const headers = buildAuthHeadersForWire(entry.wire, apiKey, entry.httpHeaders); + const headers = buildAuthHeadersForWire(entry.wire, apiKey, entry.baseUrl, entry.httpHeaders); let res: Response; try { @@ -702,7 +700,12 @@ export function registerConnectionIpc(): void { } const { url } = buildEndpointForWire(payload.wire, payload.baseUrl); - const headers = buildAuthHeadersForWire(payload.wire, payload.apiKey, payload.httpHeaders); + const headers = buildAuthHeadersForWire( + payload.wire, + payload.apiKey, + payload.baseUrl, + payload.httpHeaders, + ); let res: Response; try { diff --git a/packages/providers/src/index.test.ts b/packages/providers/src/index.test.ts index f18a11b2..fad2a764 100644 --- a/packages/providers/src/index.test.ts +++ b/packages/providers/src/index.test.ts @@ -197,4 +197,75 @@ describe('complete', () => { expect(result.content).toBe('ok'); }); + + it('adds Bearer auth for remote Anthropic-compatible gateways', async () => { + getModelMock.mockReturnValue(undefined); + completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => { + expect(opts.apiKey).toBe('sk-ant-test'); + expect(opts.headers).toEqual({ authorization: 'Bearer sk-ant-test' }); + return { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + api: 'anthropic-messages', + provider: 'claude-code-imported', + model: 'claude-opus-4-6', + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: 'stop', + timestamp: Date.now(), + }; + }); + + const result = await complete( + { provider: 'claude-code-imported', modelId: 'claude-opus-4-6' }, + [{ role: 'user', content: 'hi' }], + { + apiKey: 'sk-ant-test', + wire: 'anthropic', + baseUrl: 'https://api.nagara.top', + }, + ); + + expect(result.content).toBe('ok'); + }); + + it('does not add Bearer auth for localhost Anthropic proxies', async () => { + getModelMock.mockReturnValue(undefined); + completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => { + expect(opts.headers).toBeUndefined(); + return { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + api: 'anthropic-messages', + provider: 'claude-local', + model: 'claude-sonnet-4-6', + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: 'stop', + timestamp: Date.now(), + }; + }); + + await complete( + { provider: 'claude-local', modelId: 'claude-sonnet-4-6' }, + [{ role: 'user', content: 'hi' }], + { + apiKey: 'sk-ant-test', + wire: 'anthropic', + baseUrl: 'http://localhost:4000', + }, + ); + }); }); diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 1d1e7f1c..1f8434a8 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -6,7 +6,13 @@ * Tier 1 implementations: minimum viable. Tier 2 features tracked separately. */ -import { type ChatMessage, CodesignError, ERROR_CODES, type ModelRef } from '@open-codesign/shared'; +import { + type ChatMessage, + CodesignError, + ERROR_CODES, + type ModelRef, + shouldMirrorBearerForAnthropic, +} from '@open-codesign/shared'; /** Subset of pi-ai's `ThinkingLevel` we expose. Maps directly to its `reasoning` * field, which Anthropic adapters translate to extended-thinking effort/budget @@ -242,7 +248,16 @@ export async function complete( if (opts.signal !== undefined) piOpts.signal = opts.signal; if (opts.maxTokens !== undefined) piOpts.maxTokens = opts.maxTokens; if (opts.reasoning !== undefined) piOpts.reasoning = opts.reasoning; - if (opts.httpHeaders !== undefined) piOpts.headers = opts.httpHeaders; + const headers = + opts.wire === 'anthropic' && + opts.apiKey.length > 0 && + shouldMirrorBearerForAnthropic(opts.baseUrl) + ? { + ...(opts.httpHeaders ?? {}), + authorization: opts.httpHeaders?.['authorization'] ?? `Bearer ${opts.apiKey}`, + } + : opts.httpHeaders; + if (headers !== undefined) piOpts.headers = headers; const result = await pi.completeSimple(piModel, toPiContext(messages, piModel), piOpts); diff --git a/packages/providers/src/validate.test.ts b/packages/providers/src/validate.test.ts index 25ad3fa1..c3ea091c 100644 --- a/packages/providers/src/validate.test.ts +++ b/packages/providers/src/validate.test.ts @@ -143,6 +143,19 @@ describe('pingProvider', () => { await pingProvider('anthropic', 'sk-ant-test', 'https://api.anthropic.com/v1/messages'); }); + it('adds Bearer auth for remote Anthropic-compatible gateways', async () => { + mockFetch(async (url, init) => { + expect(url).toBe('https://api.nagara.top/v1/models'); + const headers = (init?.headers ?? {}) as Record; + expect(headers['x-api-key']).toBe('sk-ant-test'); + expect(headers['authorization']).toBe('Bearer sk-ant-test'); + expect(headers['anthropic-version']).toBe('2023-06-01'); + return new Response(JSON.stringify({ data: [{ id: 'claude-opus-4-6' }] }), { status: 200 }); + }); + const result = await pingProvider('anthropic', 'sk-ant-test', 'https://api.nagara.top'); + expect(result).toEqual({ ok: true, modelCount: 1 }); + }); + it('strips /v1/responses suffix', async () => { mockFetch(async (url) => { expect(url).toBe('https://api.example.com/v1/models'); diff --git a/packages/providers/src/validate.ts b/packages/providers/src/validate.ts index bd69c769..e17a977f 100644 --- a/packages/providers/src/validate.ts +++ b/packages/providers/src/validate.ts @@ -2,6 +2,7 @@ import { CodesignError, ERROR_CODES, type SupportedOnboardingProvider, + buildAuthHeadersForWire, isSupportedOnboardingProvider, stripInferenceEndpointSuffix, } from '@open-codesign/shared'; @@ -33,24 +34,30 @@ function endpoint(provider: SupportedOnboardingProvider, baseUrl?: string): Prov const root = baseUrl ? normalizeValidateBaseUrl(baseUrl) : 'https://api.anthropic.com'; return { url: `${root}/v1/models`, - headers: (apiKey) => ({ - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - }), + headers: (apiKey) => + buildAuthHeadersForWire('anthropic', apiKey, { + baseUrl: root, + }), }; } case 'openai': { const root = baseUrl ? normalizeValidateBaseUrl(baseUrl) : 'https://api.openai.com'; return { url: `${root}/v1/models`, - headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), + headers: (apiKey) => + buildAuthHeadersForWire('openai-chat', apiKey, { + baseUrl: root, + }), }; } case 'openrouter': { const root = baseUrl ? normalizeValidateBaseUrl(baseUrl) : 'https://openrouter.ai/api'; return { url: `${root}/v1/models`, - headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), + headers: (apiKey) => + buildAuthHeadersForWire('openai-chat', apiKey, { + baseUrl: root, + }), }; } } diff --git a/packages/shared/src/auth-headers.ts b/packages/shared/src/auth-headers.ts new file mode 100644 index 00000000..f8f13369 --- /dev/null +++ b/packages/shared/src/auth-headers.ts @@ -0,0 +1,48 @@ +import { stripInferenceEndpointSuffix } from './base-url'; +import type { WireApi } from './config'; + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']); +const ANTHROPIC_API_HOST = 'api.anthropic.com'; + +function hostnameFor(baseUrl: string): string | null { + try { + return new URL(stripInferenceEndpointSuffix(baseUrl)).hostname.toLowerCase(); + } catch { + return null; + } +} + +export function shouldMirrorBearerForAnthropic(baseUrl?: string): boolean { + if (baseUrl === undefined || baseUrl.length === 0) return false; + const hostname = hostnameFor(baseUrl); + if (hostname === null) return false; + return hostname !== ANTHROPIC_API_HOST && !LOCAL_HOSTS.has(hostname); +} + +export function buildAuthHeadersForWire( + wire: WireApi, + apiKey: string, + options: { + baseUrl?: string; + extraHeaders?: Record; + } = {}, +): Record { + const { baseUrl, extraHeaders } = options; + if (apiKey.length === 0) { + const base = wire === 'anthropic' ? { 'anthropic-version': '2023-06-01' } : {}; + return { ...base, ...(extraHeaders ?? {}) }; + } + + if (wire === 'anthropic') { + const base: Record = { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }; + if (shouldMirrorBearerForAnthropic(baseUrl)) { + base['authorization'] = `Bearer ${apiKey}`; + } + return { ...base, ...(extraHeaders ?? {}) }; + } + + return { authorization: `Bearer ${apiKey}`, ...(extraHeaders ?? {}) }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5d2995f2..eb7ac6a7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -258,6 +258,8 @@ export { ProxyPresetIdSchema, getPresetById, } from './proxy-presets'; + +export { buildAuthHeadersForWire, shouldMirrorBearerForAnthropic } from './auth-headers'; export type { ProxyPresetId } from './proxy-presets'; export {