Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/desktop/src/main/connection-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto';
import {
CONNECTION_FETCH_TIMEOUT_MS,
_clearModelsCache,
buildAuthHeadersForWire,
classifyHttpError,
extractIds,
extractModelIds,
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
35 changes: 19 additions & 16 deletions apps/desktop/src/main/connection-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ERROR_CODES,
type SupportedOnboardingProvider,
type WireApi,
buildAuthHeadersForWire as buildSharedAuthHeadersForWire,
canonicalBaseUrl,
ensureVersionedBase,
isSupportedOnboardingProvider,
Expand Down Expand Up @@ -159,21 +160,13 @@ function buildEndpointForWire(
export function buildAuthHeadersForWire(
wire: WireApi,
apiKey: string,
baseUrl?: string,
extraHeaders?: Record<string, string>,
): Record<string, string> {
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(
Expand Down Expand Up @@ -409,7 +402,12 @@ function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestE

async function runProviderTest(creds: ActiveProviderCredentials): Promise<ConnectionTestResponse> {
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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions packages/providers/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
);
});
});
19 changes: 17 additions & 2 deletions packages/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
13 changes: 13 additions & 0 deletions packages/providers/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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');
Expand Down
19 changes: 13 additions & 6 deletions packages/providers/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CodesignError,
ERROR_CODES,
type SupportedOnboardingProvider,
buildAuthHeadersForWire,
isSupportedOnboardingProvider,
stripInferenceEndpointSuffix,
} from '@open-codesign/shared';
Expand Down Expand Up @@ -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,
}),
};
}
}
Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/auth-headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
} = {},
): Record<string, string> {
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<string, string> = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
if (shouldMirrorBearerForAnthropic(baseUrl)) {
base['authorization'] = `Bearer ${apiKey}`;
}
return { ...base, ...(extraHeaders ?? {}) };
}

return { authorization: `Bearer ${apiKey}`, ...(extraHeaders ?? {}) };
}
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ export {
ProxyPresetIdSchema,
getPresetById,
} from './proxy-presets';

export { buildAuthHeadersForWire, shouldMirrorBearerForAnthropic } from './auth-headers';
export type { ProxyPresetId } from './proxy-presets';

export {
Expand Down
Loading