diff --git a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx index 3f5843f06e..7a4106e8d4 100644 --- a/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx +++ b/apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx @@ -443,6 +443,23 @@ export function CustomLlmsContent() { }} /> + {!editor.credentialsJson.trim() && ( +
+

Add an API key using one of these credential formats:

+
+                    {`{
+  "type": "api_key",
+  "api_key": "YOUR_API_KEY"
+}`}
+                  
+

+ Use{' '} + "type": "x-api-key"{' '} + instead when the provider expects an{' '} + x-api-key header. +

+
+ )}
diff --git a/apps/web/src/app/api/openrouter/[...path]/route.test.ts b/apps/web/src/app/api/openrouter/[...path]/route.test.ts index a7b2cfb8b9..0e453a418a 100644 --- a/apps/web/src/app/api/openrouter/[...path]/route.test.ts +++ b/apps/web/src/app/api/openrouter/[...path]/route.test.ts @@ -156,6 +156,7 @@ const provider = { apiUrl: 'https://openrouter.ai/api/v1', apiUrlOverrides: {}, apiKey: 'test-key', + apiKeyHeader: null, supportedChatApis: ['chat_completions', 'responses', 'messages'], responseTransforms: null, transformRequest: jest.fn(), diff --git a/apps/web/src/lib/ai-gateway/custom-llm/google-service-account.test.ts b/apps/web/src/lib/ai-gateway/custom-llm/google-service-account.test.ts index b1476b4fce..2829787057 100644 --- a/apps/web/src/lib/ai-gateway/custom-llm/google-service-account.test.ts +++ b/apps/web/src/lib/ai-gateway/custom-llm/google-service-account.test.ts @@ -42,6 +42,12 @@ describe('CustomLlmCredentialsSchema and CustomLlmDefinitionSchema', () => { ).toBe(true); }); + it('accepts x-api-key credentials', () => { + expect( + CustomLlmCredentialsSchema.safeParse({ type: 'x-api-key', api_key: 'partner-token' }).success + ).toBe(true); + }); + it('accepts Google Cloud service account authentication', () => { expect(CustomLlmCredentialsSchema.safeParse(serviceAccount('private-key')).success).toBe(true); }); diff --git a/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.test.ts b/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.test.ts index 25a1b4012d..af212808ac 100644 --- a/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.test.ts +++ b/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.test.ts @@ -12,12 +12,17 @@ async function transformRequest( request: GatewayRequest, options: Partial> = {} ) { - const provider = buildDirectProvider('custom', ['chat_completions'], { - internal_id: 'upstream-model', - base_url: 'https://llm.example.com/v1', - api_key: 'test-key', - ...options, - }); + const provider = buildDirectProvider( + 'custom', + ['chat_completions'], + { + internal_id: 'upstream-model', + base_url: 'https://llm.example.com/v1', + api_key: 'test-key', + ...options, + }, + null + ); await provider.transformRequest({ provider, @@ -98,22 +103,32 @@ describe('custom LLM Gemini reasoning transform configuration', () => { describe('buildDirectProvider response transforms', () => { it('enables Gemini thought content rewriting when the transform is enabled', () => { - const provider = buildDirectProvider('custom', ['chat_completions'], { - internal_id: 'upstream-model', - base_url: 'https://llm.example.com/v1', - api_key: 'test-key', - reasoning_details_transform: ReasoningDetailsTransform.GeminiThought, - }); + const provider = buildDirectProvider( + 'custom', + ['chat_completions'], + { + internal_id: 'upstream-model', + base_url: 'https://llm.example.com/v1', + api_key: 'test-key', + reasoning_details_transform: ReasoningDetailsTransform.GeminiThought, + }, + null + ); expect(provider.responseTransforms).toBe(ReasoningDetailsTransform.GeminiThought); }); it('sets response transforms to null when the transform is not enabled', () => { - const provider = buildDirectProvider('custom', ['chat_completions'], { - internal_id: 'upstream-model', - base_url: 'https://llm.example.com/v1', - api_key: 'test-key', - }); + const provider = buildDirectProvider( + 'custom', + ['chat_completions'], + { + internal_id: 'upstream-model', + base_url: 'https://llm.example.com/v1', + api_key: 'test-key', + }, + null + ); expect(provider.responseTransforms).toBeNull(); }); diff --git a/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.ts b/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.ts index 7f58ccd620..1768e2a5d2 100644 --- a/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.ts +++ b/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.ts @@ -90,13 +90,15 @@ function sanitizeJsonRefToolResults(context: TransformRequestContext) { export function buildDirectProvider( id: 'custom' | 'experiment', supportedChatApis: ReadonlyArray, - upstream: ResolvedExperimentUpstream + upstream: ResolvedExperimentUpstream, + apiKeyHeader: 'x-api-key' | null ): Provider { return { id, apiUrl: upstream.base_url, apiUrlOverrides: {}, apiKey: upstream.api_key, + apiKeyHeader, supportedChatApis, responseTransforms: upstream.reasoning_details_transform ?? null, async transformRequest(context) { diff --git a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts index c74addf5ae..d5709061cd 100644 --- a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.test.ts @@ -95,6 +95,7 @@ describe('applyReasoningDetailsTransform', () => { apiUrl: 'https://example.com/v1', apiUrlOverrides: {}, apiKey: 'test-key', + apiKeyHeader: null, supportedChatApis: ['chat_completions'], responseTransforms, async transformRequest() {}, diff --git a/apps/web/src/lib/ai-gateway/providers/get-provider.ts b/apps/web/src/lib/ai-gateway/providers/get-provider.ts index 579b24107d..af3375d3c3 100644 --- a/apps/web/src/lib/ai-gateway/providers/get-provider.ts +++ b/apps/web/src/lib/ai-gateway/providers/get-provider.ts @@ -90,6 +90,7 @@ async function checkDirectBYOK( apiUrl: directByok.base_url, apiUrlOverrides: directByok.base_url_overrides, apiKey: userByok[0].decryptedAPIKey, + apiKeyHeader: null, supportedChatApis: directByok.supported_chat_apis, responseTransforms: null, async transformRequest(context) { @@ -137,8 +138,10 @@ async function checkCustomLlm( } let apiKey: string; - if (parsedCredentials.data.type === 'api_key') { + let apiKeyHeader: 'x-api-key' | null = null; + if (parsedCredentials.data.type === 'api_key' || parsedCredentials.data.type === 'x-api-key') { apiKey = parsedCredentials.data.api_key; + apiKeyHeader = parsedCredentials.data.type === 'x-api-key' ? 'x-api-key' : null; } else { apiKey = await getGoogleServiceAccountAccessToken(parsedCredentials.data); } @@ -158,7 +161,8 @@ async function checkCustomLlm( ? 'responses' : 'chat_completions', ], - resolvedCustomLlm + resolvedCustomLlm, + apiKeyHeader ), userByok: null, bypassAccessCheck: true, @@ -258,7 +262,7 @@ export async function getProvider(input: GetProviderInput): Promise; responseTransforms: ProviderResponseTransforms | null; transformRequest(context: TransformRequestContext): Promise; diff --git a/apps/web/src/lib/ai-gateway/providers/upstream-request.generation.test.ts b/apps/web/src/lib/ai-gateway/providers/upstream-request.generation.test.ts index 5efbd2858b..6a7d633b9c 100644 --- a/apps/web/src/lib/ai-gateway/providers/upstream-request.generation.test.ts +++ b/apps/web/src/lib/ai-gateway/providers/upstream-request.generation.test.ts @@ -20,6 +20,7 @@ const provider: Provider = { apiUrl: 'https://openrouter.example/api/v1', apiUrlOverrides: {}, apiKey: 'test-api-key', + apiKeyHeader: null, supportedChatApis: [], responseTransforms: null, transformRequest: async () => {}, diff --git a/apps/web/src/lib/ai-gateway/providers/upstream-request.ts b/apps/web/src/lib/ai-gateway/providers/upstream-request.ts index 7021ad6dd8..bc362f573d 100644 --- a/apps/web/src/lib/ai-gateway/providers/upstream-request.ts +++ b/apps/web/src/lib/ai-gateway/providers/upstream-request.ts @@ -218,7 +218,11 @@ export async function upstreamRequest({ for (const [key, value] of Object.entries(ATTRIBUTION_HEADERS)) { headers.set(key, value); } - headers.set('Authorization', `Bearer ${provider.apiKey}`); + if (provider.apiKeyHeader === 'x-api-key') { + headers.set('x-api-key', provider.apiKey); + } else { + headers.set('Authorization', `Bearer ${provider.apiKey}`); + } headers.set('Content-Type', 'application/json'); Object.entries(extraHeaders).forEach(([key, value]) => { diff --git a/apps/web/src/tests/openrouterApi.timeout.test.ts b/apps/web/src/tests/openrouterApi.timeout.test.ts index 154fde5c69..a436e70d74 100644 --- a/apps/web/src/tests/openrouterApi.timeout.test.ts +++ b/apps/web/src/tests/openrouterApi.timeout.test.ts @@ -70,6 +70,25 @@ describe('upstreamRequest timeout', () => { } ); + it('uses x-api-key instead of authorization when configured by the provider', async () => { + const mockFetch = jest.fn().mockResolvedValue(new Response('{}')); + global.fetch = mockFetch; + + const result = await upstreamRequest({ + chatApi: 'chat_completions', + search: '', + method: 'POST', + body: { model: 'test-model', messages: [{ role: 'user', content: 'test' }] }, + extraHeaders: {}, + provider: { ...OPENROUTER, apiKey: 'custom-key', apiKeyHeader: 'x-api-key' }, + }); + + expect(result.type).toBe('success'); + const headers = mockFetch.mock.calls[0]?.[1]?.headers as Headers; + expect(headers.get('x-api-key')).toBe('custom-key'); + expect(headers.has('authorization')).toBe(false); + }); + it('reports a client disconnect instead of an upstream disconnect when the caller aborts', async () => { const controller = new AbortController(); controller.abort(); diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index e9e7c2aa30..69ee2ff034 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -2087,8 +2087,16 @@ export const CustomLlmApiKeyCredentialsSchema = z.object({ export type CustomLlmApiKeyCredentials = z.infer; +export const CustomLlmXApiKeyCredentialsSchema = z.object({ + type: z.literal('x-api-key'), + api_key: z.string().min(1), +}); + +export type CustomLlmXApiKeyCredentials = z.infer; + export const CustomLlmCredentialsSchema = z.discriminatedUnion('type', [ CustomLlmApiKeyCredentialsSchema, + CustomLlmXApiKeyCredentialsSchema, GoogleServiceAccountKeySchema, ]); diff --git a/packages/worker-utils/src/redact-headers.test.ts b/packages/worker-utils/src/redact-headers.test.ts index 550491957a..92f69d6748 100644 --- a/packages/worker-utils/src/redact-headers.test.ts +++ b/packages/worker-utils/src/redact-headers.test.ts @@ -6,6 +6,7 @@ describe('redactSensitiveHeaders', () => { const input = { authorization: 'Bearer secret-jwt', 'proxy-authorization': 'Basic cHJveHk6c2VjcmV0', + 'x-api-key': 'provider-secret', cookie: 'session=abc123', 'set-cookie': 'session=abc123; Path=/', 'x-gitlab-token': 'glpat-secret', @@ -20,6 +21,7 @@ describe('redactSensitiveHeaders', () => { expect(result).toEqual({ authorization: '[REDACTED]', 'proxy-authorization': '[REDACTED]', + 'x-api-key': '[REDACTED]', cookie: '[REDACTED]', 'set-cookie': '[REDACTED]', 'x-gitlab-token': '[REDACTED]', diff --git a/packages/worker-utils/src/redact-headers.ts b/packages/worker-utils/src/redact-headers.ts index b3c2372ac6..a9463bf07a 100644 --- a/packages/worker-utils/src/redact-headers.ts +++ b/packages/worker-utils/src/redact-headers.ts @@ -3,6 +3,7 @@ const SENSITIVE_HEADERS = new Set([ 'proxy-authorization', 'cookie', 'set-cookie', + 'x-api-key', 'x-gitlab-token', 'x-hub-signature', 'x-hub-signature-256',