Skip to content
Merged
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
17 changes: 17 additions & 0 deletions apps/web/src/app/admin/custom-llms/CustomLlmsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,23 @@ export function CustomLlmsContent() {
}}
/>
</div>
{!editor.credentialsJson.trim() && (
<div className="bg-muted text-muted-foreground mt-2 rounded-md p-3 text-xs">
<p>Add an API key using one of these credential formats:</p>
<pre className="text-foreground mt-2 overflow-x-auto font-mono whitespace-pre-wrap">
{`{
"type": "api_key",
"api_key": "YOUR_API_KEY"
}`}
</pre>
<p className="mt-2">
Use{' '}
<code className="text-foreground">&quot;type&quot;: &quot;x-api-key&quot;</code>{' '}
instead when the provider expects an{' '}
<code className="text-foreground">x-api-key</code> header.
</p>
</div>
)}
</div>

<div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ async function transformRequest(
request: GatewayRequest,
options: Partial<Omit<CustomLlmApiConfig, 'internal_id' | 'base_url'>> = {}
) {
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,
Expand Down Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ function sanitizeJsonRefToolResults(context: TransformRequestContext) {
export function buildDirectProvider(
id: 'custom' | 'experiment',
supportedChatApis: ReadonlyArray<GatewayChatApiKind>,
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ describe('applyReasoningDetailsTransform', () => {
apiUrl: 'https://example.com/v1',
apiUrlOverrides: {},
apiKey: 'test-key',
apiKeyHeader: null,
supportedChatApis: ['chat_completions'],
responseTransforms,
async transformRequest() {},
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/lib/ai-gateway/providers/get-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -158,7 +161,8 @@ async function checkCustomLlm(
? 'responses'
: 'chat_completions',
],
resolvedCustomLlm
resolvedCustomLlm,
apiKeyHeader
),
userByok: null,
bypassAccessCheck: true,
Expand Down Expand Up @@ -258,7 +262,7 @@ export async function getProvider(input: GetProviderInput): Promise<GetProviderR
if (selection?.status === 'active') {
return {
kind: 'provider',
provider: buildDirectProvider('experiment', ['chat_completions'], selection.upstream),
provider: buildDirectProvider('experiment', ['chat_completions'], selection.upstream, null),
userByok: null,
bypassAccessCheck: false,
experiment: {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/partner/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const FRIENDLI_GLM_PROVIDER = {
apiUrl: 'https://api.friendli.ai/serverless/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('FRIENDLI_API_KEY'),
apiKeyHeader: null,
supportedChatApis: [
'chat_completions',
// 'messages', // supported, not tested
Expand Down Expand Up @@ -43,6 +44,7 @@ export const PERPLEXITY_KIMI_PROVIDER = {
apiUrl: 'https://api.perplexity.ai/router/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('PERPLEXITY_API_KEY'),
apiKeyHeader: null,
supportedChatApis: [
'chat_completions',
// 'messages', // supported, not tested
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/provider-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const OPENROUTER = {
apiUrl: 'https://openrouter.ai/api/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('OPENROUTER_API_KEY'),
apiKeyHeader: null,
supportedChatApis: ['chat_completions', 'messages', 'responses'],
responseTransforms: null,
async transformRequest() {},
Expand All @@ -18,6 +19,7 @@ export const ALIBABA = {
apiUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('ALIBABA_API_KEY'),
apiKeyHeader: null,
supportedChatApis: [
'chat_completions',
// 'responses', // supported, not tested
Expand All @@ -33,6 +35,7 @@ export const SEED = {
apiUrl: 'https://ark.ap-southeast.bytepluses.com/api/v3',
apiUrlOverrides: {},
apiKey: getEnvVariable('BYTEDANCE_API_KEY'),
apiKeyHeader: null,
supportedChatApis: [
'chat_completions',
// 'responses', // supported, not tested
Expand Down Expand Up @@ -61,6 +64,7 @@ export const LONGCAT = {
apiUrl: 'https://api.longcat.ai/openai/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('LONGCAT_API_KEY'),
apiKeyHeader: null,
supportedChatApis: ['chat_completions'],
responseTransforms: null,
async transformRequest(context) {
Expand All @@ -79,6 +83,7 @@ export const MARTIAN = {
apiUrl: 'https://api.withmartian.com/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('MARTIAN_API_KEY'),
apiKeyHeader: null,
supportedChatApis: ['chat_completions', 'responses', 'messages'],
responseTransforms: null,
async transformRequest(context) {
Expand All @@ -91,6 +96,7 @@ export const MISTRAL = {
apiUrl: 'https://api.mistral.ai/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('MISTRAL_API_KEY'),
apiKeyHeader: null,
supportedChatApis: [],
responseTransforms: null,
async transformRequest() {},
Expand All @@ -101,6 +107,7 @@ export const STREAMLAKE = {
apiUrl: 'https://vanchin.streamlake.ai/api/gateway/v1/endpoints',
apiUrlOverrides: {},
apiKey: getEnvVariable('STREAMLAKE_API_KEY'),
apiKeyHeader: null,
supportedChatApis: ['chat_completions'],
responseTransforms: null,
async transformRequest(context) {
Expand All @@ -113,6 +120,7 @@ export const VERCEL_AI_GATEWAY = {
apiUrl: 'https://ai-gateway.vercel.sh/v1',
apiUrlOverrides: {},
apiKey: getEnvVariable('VERCEL_AI_GATEWAY_API_KEY'),
apiKeyHeader: null,
supportedChatApis: ['chat_completions', 'messages', 'responses'],
responseTransforms: null,
async transformRequest(context) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' | null;
supportedChatApis: ReadonlyArray<GatewayChatApiKind>;
responseTransforms: ProviderResponseTransforms | null;
transformRequest(context: TransformRequestContext): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {},
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/lib/ai-gateway/providers/upstream-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => {
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/tests/openrouterApi.timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions packages/db/src/schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2087,8 +2087,16 @@ export const CustomLlmApiKeyCredentialsSchema = z.object({

export type CustomLlmApiKeyCredentials = z.infer<typeof CustomLlmApiKeyCredentialsSchema>;

export const CustomLlmXApiKeyCredentialsSchema = z.object({
type: z.literal('x-api-key'),
api_key: z.string().min(1),
});

export type CustomLlmXApiKeyCredentials = z.infer<typeof CustomLlmXApiKeyCredentialsSchema>;

export const CustomLlmCredentialsSchema = z.discriminatedUnion('type', [
CustomLlmApiKeyCredentialsSchema,
CustomLlmXApiKeyCredentialsSchema,
GoogleServiceAccountKeySchema,
]);

Expand Down
2 changes: 2 additions & 0 deletions packages/worker-utils/src/redact-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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]',
Expand Down
1 change: 1 addition & 0 deletions packages/worker-utils/src/redact-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down