From c0b902d217b173d74efee5f683123e2cb2cabf29 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:41:48 +0000 Subject: [PATCH 1/2] feat(ai-gateway): support x-api-key credentials Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../admin/custom-llms/CustomLlmsContent.tsx | 17 +++++++++++++++++ .../custom-llm/google-service-account.test.ts | 6 ++++++ .../experiments/build-direct-provider.ts | 4 +++- .../lib/ai-gateway/providers/get-provider.ts | 7 +++++-- .../web/src/lib/ai-gateway/providers/types.ts | 2 ++ .../ai-gateway/providers/upstream-request.ts | 6 +++++- .../src/tests/openrouterApi.timeout.test.ts | 19 +++++++++++++++++++ packages/db/src/schema-types.ts | 8 ++++++++ .../worker-utils/src/redact-headers.test.ts | 2 ++ packages/worker-utils/src/redact-headers.ts | 1 + 10 files changed, 68 insertions(+), 4 deletions(-) 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/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.ts b/apps/web/src/lib/ai-gateway/experiments/build-direct-provider.ts index 7f58ccd620..2ffea55bc3 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' ): 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/get-provider.ts b/apps/web/src/lib/ai-gateway/providers/get-provider.ts index 579b24107d..21e713289a 100644 --- a/apps/web/src/lib/ai-gateway/providers/get-provider.ts +++ b/apps/web/src/lib/ai-gateway/providers/get-provider.ts @@ -137,8 +137,10 @@ async function checkCustomLlm( } let apiKey: string; - if (parsedCredentials.data.type === 'api_key') { + let apiKeyHeader: 'x-api-key' | undefined; + 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' : undefined; } else { apiKey = await getGoogleServiceAccountAccessToken(parsedCredentials.data); } @@ -158,7 +160,8 @@ async function checkCustomLlm( ? 'responses' : 'chat_completions', ], - resolvedCustomLlm + resolvedCustomLlm, + apiKeyHeader ), userByok: null, bypassAccessCheck: true, diff --git a/apps/web/src/lib/ai-gateway/providers/types.ts b/apps/web/src/lib/ai-gateway/providers/types.ts index 4549412cc0..a402721e5a 100644 --- a/apps/web/src/lib/ai-gateway/providers/types.ts +++ b/apps/web/src/lib/ai-gateway/providers/types.ts @@ -53,6 +53,8 @@ export type Provider = { apiUrl: string; apiUrlOverrides: ProviderApiUrlOverrides; apiKey: string; + /** Uses bearer authorization unless the provider requires an x-api-key header. */ + apiKeyHeader?: 'x-api-key'; supportedChatApis: ReadonlyArray; responseTransforms: ProviderResponseTransforms | null; transformRequest(context: TransformRequestContext): Promise; 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', From 0896b29fc0bf7e0c59d24314b6b4f1b289158d69 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:10:42 +0000 Subject: [PATCH 2/2] refactor(ai-gateway): make API key header nullable Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../api/openrouter/[...path]/route.test.ts | 1 + .../experiments/build-direct-provider.test.ts | 49 ++++++++++++------- .../experiments/build-direct-provider.ts | 2 +- .../apply-provider-specific-logic.test.ts | 1 + .../lib/ai-gateway/providers/get-provider.ts | 7 +-- .../ai-gateway/providers/partner/providers.ts | 2 + .../providers/provider-definitions.ts | 8 +++ .../web/src/lib/ai-gateway/providers/types.ts | 2 +- .../upstream-request.generation.test.ts | 1 + 9 files changed, 51 insertions(+), 22 deletions(-) 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/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 2ffea55bc3..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 @@ -91,7 +91,7 @@ export function buildDirectProvider( id: 'custom' | 'experiment', supportedChatApis: ReadonlyArray, upstream: ResolvedExperimentUpstream, - apiKeyHeader?: 'x-api-key' + apiKeyHeader: 'x-api-key' | null ): Provider { return { id, 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 21e713289a..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,10 +138,10 @@ async function checkCustomLlm( } let apiKey: string; - let apiKeyHeader: 'x-api-key' | undefined; + 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' : undefined; + apiKeyHeader = parsedCredentials.data.type === 'x-api-key' ? 'x-api-key' : null; } else { apiKey = await getGoogleServiceAccountAccessToken(parsedCredentials.data); } @@ -261,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 () => {},