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',