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
27 changes: 26 additions & 1 deletion apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const provider = {
apiUrl: 'https://openrouter.ai/api/v1',
apiKey: 'test-key',
supportedChatApis: ['chat_completions', 'responses', 'messages'],
responseTransforms: null,
transformRequest: jest.fn(),
} satisfies Provider;

Expand Down Expand Up @@ -291,7 +292,31 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () =>
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({ vercel_request_id: 'iad1::iad1::request-id' })
expect.objectContaining({ vercel_request_id: 'iad1::iad1::request-id' }),
null
);
});

it('passes provider response transforms to the response rewriter', async () => {
const responseTransforms = { thoughtContentMapping: 'extra_content.flags.thought' };
mockedGetProvider.mockResolvedValue({
kind: 'provider',
provider: { ...provider, responseTransforms },
userByok: null,
bypassAccessCheck: false,
});
const { POST } = await import('./route');

const response = await POST(makeRequest(makeBody()) as never);

expect(response.status).toBe(200);
expect(mockedRewriteModelResponse).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
responseTransforms
);
});

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
effectiveModelIdLowerCased,
effectiveProviderContext.provider.id,
requestBodyParsed.kind,
requestLogging
requestLogging,
effectiveProviderContext.provider.responseTransforms
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,56 @@ describe('custom LLM thought signature mapping configuration', () => {
);
});

describe('custom LLM thought content mapping configuration', () => {
const config = {
internal_id: 'upstream-model',
base_url: 'https://llm.example.com/v1',
};

it('accepts a dot-separated property path', () => {
expect(
CustomLlmApiConfigSchema.safeParse({
...config,
thought_content_mapping: 'extra_content.flags.thought',
}).success
).toBe(true);
});

it.each(['', 'extra_content..thought', 'constructor.prototype.thought'])(
'rejects unsafe property path %p',
thought_content_mapping => {
expect(
CustomLlmApiConfigSchema.safeParse({ ...config, thought_content_mapping }).success
).toBe(false);
}
);
});

describe('buildDirectProvider response transforms', () => {
it('exposes the thought content property path without other upstream configuration', () => {
const provider = buildDirectProvider('custom', ['chat_completions'], {
internal_id: 'upstream-model',
base_url: 'https://llm.example.com/v1',
api_key: 'test-key',
thought_content_mapping: 'extra_content.flags.thought',
});

expect(provider.responseTransforms).toEqual({
thoughtContentMapping: 'extra_content.flags.thought',
});
});

it('sets response transforms to null when no mapping is configured', () => {
const provider = buildDirectProvider('custom', ['chat_completions'], {
internal_id: 'upstream-model',
base_url: 'https://llm.example.com/v1',
api_key: 'test-key',
});

expect(provider.responseTransforms).toBeNull();
});
});

describe('buildDirectProvider thought signature mapping', () => {
it('maps assistant tool-call signatures and removes camel-case transport fields', async () => {
const request = makeRequest();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ export function buildDirectProvider(
apiUrl: upstream.base_url,
apiKey: upstream.api_key,
supportedChatApis,
responseTransforms: upstream.thought_content_mapping
? { thoughtContentMapping: upstream.thought_content_mapping }
: null,
async transformRequest(context) {
if (upstream.remove_from_body) {
const body = context.request.body as Record<string, unknown>;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/ai-gateway/providers/get-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ async function checkDirectBYOK(
apiUrl: directByok.base_url,
apiKey: userByok[0].decryptedAPIKey,
supportedChatApis: directByok.supported_chat_apis,
responseTransforms: null,
async transformRequest(context) {
context.request.body.model = directByokModel.id;
directByok.transformRequest(context);
Expand Down
7 changes: 7 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 @@ -9,6 +9,7 @@ export default {
apiUrl: 'https://openrouter.ai/api/v1',
apiKey: getEnvVariable('OPENROUTER_API_KEY'),
supportedChatApis: ['chat_completions', 'messages', 'responses'],
responseTransforms: null,
async transformRequest() {},
},
ALIBABA: {
Expand All @@ -17,6 +18,7 @@ export default {
apiKey: getEnvVariable('ALIBABA_API_KEY'),
// Prompt caching is not supported on the responses API for Alibaba; enabling it is therefore dangerous.
supportedChatApis: ['chat_completions' /*, 'responses'*/],
responseTransforms: null,
async transformRequest(context) {
context.request.body.enable_thinking = !isReasoningExplicitlyDisabled(context.request);
},
Expand All @@ -27,6 +29,7 @@ export default {
apiKey: getEnvVariable('BYTEDANCE_API_KEY'),
// Prompt caching is not supported on the responses API for Bytedance; enabling it is therefore dangerous.
supportedChatApis: ['chat_completions' /*, 'responses'*/],
responseTransforms: null,
async transformRequest(context) {
if (!isReasoningExplicitlyDisabled(context.request)) {
context.request.body.thinking = { type: 'enabled' };
Expand All @@ -49,6 +52,7 @@ export default {
apiUrl: 'https://api.withmartian.com/v1',
apiKey: getEnvVariable('MARTIAN_API_KEY'),
supportedChatApis: ['chat_completions', 'responses', 'messages'],
responseTransforms: null,
async transformRequest(context) {
delete context.request.body.provider;
},
Expand All @@ -58,13 +62,15 @@ export default {
apiUrl: 'https://api.mistral.ai/v1',
apiKey: getEnvVariable('MISTRAL_API_KEY'),
supportedChatApis: [],
responseTransforms: null,
async transformRequest() {},
},
STREAMLAKE: {
id: 'streamlake',
apiUrl: 'https://vanchin.streamlake.ai/api/gateway/v1/endpoints',
apiKey: getEnvVariable('STREAMLAKE_API_KEY'),
supportedChatApis: ['chat_completions'],
responseTransforms: null,
async transformRequest(context) {
delete context.request.body.provider;
},
Expand All @@ -74,6 +80,7 @@ export default {
apiUrl: 'https://ai-gateway.vercel.sh/v1',
apiKey: getEnvVariable('VERCEL_AI_GATEWAY_API_KEY'),
supportedChatApis: ['chat_completions', 'messages', 'responses'],
responseTransforms: null,
async transformRequest(context) {
await applyVercelSettings(context.model, context.request, context.userByok);
},
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ export type TransformRequestContext = {

export type GatewayChatApiKind = GatewayRequest['kind'];

export type ProviderResponseTransforms = {
thoughtContentMapping: string | null;
};

export type Provider = {
id: ProviderId;
apiUrl: string;
apiKey: string;
supportedChatApis: ReadonlyArray<GatewayChatApiKind>;
responseTransforms: ProviderResponseTransforms | null;
transformRequest(context: TransformRequestContext): Promise<void>;
};
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',
apiKey: 'test-api-key',
supportedChatApis: [],
responseTransforms: null,
transformRequest: async () => {},
};

Expand Down
Loading
Loading