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 () => {},