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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
scrubOpenCodeSpecificProperties,
} from '@/lib/ai-gateway/providers/openrouter/request-helpers';
import { isQwenExplicitCacheModel, isQwenModel } from '@/lib/ai-gateway/providers/qwen';
import {
rewriteChatCompletionsOneOfAsAnyOf,
isFriendliChatCompletionsRequest,
} from '@/lib/ai-gateway/schema-rewrite';

export function getPreferredProviderOrder(requestedModel: string): string[] {
if (isClaudeModel(requestedModel)) {
Expand Down Expand Up @@ -150,6 +154,12 @@ export function applyProviderSpecificLogic(
applyPreferredProvider(requestedModel, requestToMutate.body);
}

// Friendli does not support JSON Schema `oneOf`, so downgrade every `oneOf`
// to `anyOf` for any chat completions request routed through it.
if (isFriendliChatCompletionsRequest(requestToMutate)) {
rewriteChatCompletionsOneOfAsAnyOf(requestToMutate.body);
}

if (isKimiModel(requestedModel)) {
applyMoonshotModelSettings(requestToMutate);
}
Expand Down
230 changes: 230 additions & 0 deletions apps/web/src/lib/ai-gateway/schema-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, expect, it } from '@jest/globals';
import {
rewriteChatCompletionsOneOfAsAnyOf,
isFriendliChatCompletionsRequest,
} from '@/lib/ai-gateway/schema-rewrite';
import type {
GatewayRequest,
OpenRouterChatCompletionRequest,
} from '@/lib/ai-gateway/providers/openrouter/types';

type Schema = Record<string, unknown>;

function toolWith(name: string, parameters: Schema) {
return { type: 'function', function: { name, parameters } };
}

function makeRequest(partial: Record<string, unknown>): OpenRouterChatCompletionRequest {
return {
model: 'zai/glm-4.6',
messages: [],
...partial,
} as OpenRouterChatCompletionRequest;
}

function makeGatewayRequest(partial: Record<string, unknown>): GatewayRequest {
return { kind: 'chat_completions', body: makeRequest(partial) };
}

describe('rewriteChatCompletionsOneOfAsAnyOf', () => {
it('rewrites oneOf as anyOf in tool function parameters', () => {
const parameters: Schema = {
type: 'object',
oneOf: [{ type: 'string' }, { type: 'number' }],
};
const request = makeRequest({ tools: [toolWith('get_weather', parameters)] });

rewriteChatCompletionsOneOfAsAnyOf(request);

expect(parameters).not.toHaveProperty('oneOf');
expect(parameters).toHaveProperty('anyOf');
expect(parameters.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]);
});

it('rewrites oneOf as anyOf in the response_format schema', () => {
const schema: Schema = { type: 'object', oneOf: [{ type: 'string' }] };
const request = makeRequest({
response_format: {
type: 'json_schema',
json_schema: { name: 'result', schema },
} as OpenRouterChatCompletionRequest['response_format'],
});

rewriteChatCompletionsOneOfAsAnyOf(request);

expect(schema).not.toHaveProperty('oneOf');
expect(schema.anyOf).toEqual([{ type: 'string' }]);
});

it('rewrites nested oneOf keywords', () => {
const filter: Schema = { oneOf: [{ type: 'string' }, { type: 'number' }] };
const parameters: Schema = {
type: 'object',
properties: { filter },
oneOf: [{ type: 'object' }],
};
const request = makeRequest({ tools: [toolWith('search', parameters)] });

rewriteChatCompletionsOneOfAsAnyOf(request);

expect(parameters).not.toHaveProperty('oneOf');
expect(parameters.anyOf).toEqual([{ type: 'object' }]);
expect(filter).not.toHaveProperty('oneOf');
expect(filter.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]);
});

it('leaves schemas without oneOf untouched', () => {
const parameters: Schema = { type: 'object', properties: { host: { type: 'string' } } };
const request = makeRequest({ tools: [toolWith('ping', parameters)] });

rewriteChatCompletionsOneOfAsAnyOf(request);

expect(parameters).not.toHaveProperty('anyOf');
expect(parameters).not.toHaveProperty('oneOf');
});

it('preserves other keywords alongside the rewrite', () => {
const parameters: Schema = { type: 'object', required: ['mode'], oneOf: [{ type: 'string' }] };
const request = makeRequest({ tools: [toolWith('run', parameters)] });

rewriteChatCompletionsOneOfAsAnyOf(request);

expect(parameters.type).toBe('object');
expect(parameters.required).toEqual(['mode']);
expect(parameters.anyOf).toEqual([{ type: 'string' }]);
});

it('merges into an existing anyOf instead of overwriting it', () => {
const parameters: Schema = {
type: 'object',
anyOf: [{ type: 'boolean' }],
oneOf: [{ type: 'string' }],
};
const request = makeRequest({ tools: [toolWith('merge', parameters)] });

rewriteChatCompletionsOneOfAsAnyOf(request);

expect(parameters).not.toHaveProperty('oneOf');
expect(parameters.anyOf).toEqual([{ type: 'boolean' }, { type: 'string' }]);
});

it('handles a request with no tools or response_format', () => {
const request = makeRequest({});

expect(() => rewriteChatCompletionsOneOfAsAnyOf(request)).not.toThrow();
});

it('does not loop forever on a circular schema', () => {
const schema: Schema = { type: 'object', oneOf: [] };
const child: Schema = { type: 'string' };
schema.properties = { child };
child.parent = schema;
const request = makeRequest({ tools: [toolWith('circular', schema)] });

expect(() => rewriteChatCompletionsOneOfAsAnyOf(request)).not.toThrow();
expect(schema).not.toHaveProperty('oneOf');
expect(schema).toHaveProperty('anyOf');
});
});

describe('rewriteChatCompletionsOneOfAsAnyOf logging', () => {
it('logs once when a oneOf is rewritten', () => {
const calls: Array<{ message: string; details: unknown }> = [];
const log = (message: string, details: unknown) => calls.push({ message, details });
const parameters: Schema = { type: 'object', oneOf: [{ type: 'string' }] };
const request = makeRequest({ tools: [toolWith('get_weather', parameters)] });

rewriteChatCompletionsOneOfAsAnyOf(request, log);

expect(calls).toHaveLength(1);
expect(calls[0].details).toEqual({
event: 'ai_gateway_chat_completions_one_of_rewritten',
model: 'zai/glm-4.6',
count: 1,
});
});

it('logs once even when multiple schemas across tools are rewritten', () => {
const calls: unknown[] = [];
const log = (_message: string, _details: unknown) => calls.push(_details);
const request = makeRequest({
tools: [
toolWith('a', { type: 'object', oneOf: [{ type: 'string' }] }),
toolWith('b', { type: 'object', oneOf: [{ type: 'number' }, { type: 'boolean' }] }),
],
response_format: {
type: 'json_schema',
json_schema: { name: 'r', schema: { type: 'object', oneOf: [{ type: 'string' }] } },
} as OpenRouterChatCompletionRequest['response_format'],
});

rewriteChatCompletionsOneOfAsAnyOf(request, log);

expect(calls).toHaveLength(1);
const details = calls[0] as { count: number };
expect(details.count).toBe(3);
});

it('does not log when nothing is rewritten', () => {
const calls: unknown[] = [];
const log = (_message: string, _details: unknown) => calls.push(_details);
const request = makeRequest({
tools: [toolWith('ping', { type: 'object', properties: { host: { type: 'string' } } })],
});

rewriteChatCompletionsOneOfAsAnyOf(request, log);

expect(calls).toHaveLength(0);
});
});

describe('isFriendliChatCompletionsRequest', () => {
it('returns true when friendli is in provider.order', () => {
const request = makeGatewayRequest({ provider: { order: ['friendli', 'novita'] } });

expect(isFriendliChatCompletionsRequest(request)).toBe(true);
});

it('returns true when friendli is not the first entry', () => {
const request = makeGatewayRequest({ provider: { order: ['novita', 'friendli'] } });

expect(isFriendliChatCompletionsRequest(request)).toBe(true);
});

it('returns false when friendli is absent from provider.order', () => {
const request = makeGatewayRequest({ provider: { order: ['novita', 'z-ai'] } });

expect(isFriendliChatCompletionsRequest(request)).toBe(false);
});

it('returns false when there is no provider config', () => {
const request = makeGatewayRequest({});

expect(isFriendliChatCompletionsRequest(request)).toBe(false);
});

it('returns false when provider has no order', () => {
const request = makeGatewayRequest({ provider: { only: ['friendli'] } });

expect(isFriendliChatCompletionsRequest(request)).toBe(false);
});

it('returns false for non-chat-completions requests', () => {
const request: GatewayRequest = {
kind: 'responses',
body: { model: 'zai/glm-4.6', input: '' },
} as GatewayRequest;

expect(isFriendliChatCompletionsRequest(request)).toBe(false);
});

it('narrows the request body type when true', () => {
const request = makeGatewayRequest({ provider: { order: ['friendli'] } });

if (isFriendliChatCompletionsRequest(request)) {
expect(request.body.provider?.order).toEqual(['friendli']);
} else {
throw new Error('expected narrowing to succeed');
}
});
});
113 changes: 113 additions & 0 deletions apps/web/src/lib/ai-gateway/schema-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type {
GatewayRequest,
OpenRouterChatCompletionRequest,
} from '@/lib/ai-gateway/providers/openrouter/types';
import { OpenRouterInferenceProviderIdSchema } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { warnExceptInTest } from '@/lib/utils.server';

export type FriendliChatCompletionsRequest = {
kind: 'chat_completions';
body: OpenRouterChatCompletionRequest;
};

type OneOfRewriteLogDetails = {
event: 'ai_gateway_chat_completions_one_of_rewritten';
model: string;
count: number;
};

type OneOfRewriteLogger = (message: string, details: OneOfRewriteLogDetails) => void;

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/**
* Whether a gateway request is a chat completions request routed through the
* Friendli inference provider (i.e. `friendli` appears in `provider.order`).
* Friendli does not support JSON Schema `oneOf`, so such requests need
* rewriting before they are forwarded.
*/
export function isFriendliChatCompletionsRequest(
request: GatewayRequest
): request is FriendliChatCompletionsRequest {
if (request.kind !== 'chat_completions') return false;
const order = request.body.provider?.order;
return Array.isArray(order) && order.includes(OpenRouterInferenceProviderIdSchema.enum.friendli);
}

/**
* Recursively rewrites every JSON Schema `oneOf` keyword as `anyOf`, mutating
* the schema in place. Friendli does not support `oneOf`, so requests routed to
* it must downgrade those keywords to the `anyOf` Friendli understands.
*
* Cycles are guarded against with a visited set so recursive or circular
* schemas cannot loop forever. Returns the number of `oneOf` keywords removed.
*/
function rewriteOneOfAsAnyOf(schema: unknown): number {
if (!isRecord(schema)) return 0;

const pending: Record<string, unknown>[] = [schema];
const visited = new WeakSet<object>();
let rewritten = 0;

while (pending.length > 0) {
const value = pending.pop();
if (!value || visited.has(value)) continue;
visited.add(value);

for (const [key, nestedValue] of Object.entries(value)) {
if (key === 'oneOf') {
const oneOf = value.oneOf;
delete value.oneOf;
if (Array.isArray(oneOf)) {
const existingAnyOf = Array.isArray(value.anyOf) ? value.anyOf : [];
value.anyOf = [...existingAnyOf, ...oneOf];
}
rewritten += 1;
}
if (isRecord(nestedValue)) {
pending.push(nestedValue);
}
}
}

return rewritten;
}

/**
* Rewrites all `oneOf` keywords as `anyOf` in the JSON Schemas attached to a
* chat completions request — both tool function `parameters` and the
* `response_format.json_schema.schema`. Logs once per request, but only when at
* least one `oneOf` was actually rewritten.
*/
export function rewriteChatCompletionsOneOfAsAnyOf(
request: OpenRouterChatCompletionRequest,
log: OneOfRewriteLogger = warnExceptInTest
): void {
let rewritten = 0;

if (Array.isArray(request.tools)) {
for (const tool of request.tools) {
if (!isRecord(tool) || tool.type !== 'function' || !isRecord(tool.function)) continue;
rewritten += rewriteOneOfAsAnyOf(tool.function.parameters);
}
}

const responseFormat = request.response_format;
if (
isRecord(responseFormat) &&
responseFormat.type === 'json_schema' &&
isRecord(responseFormat.json_schema)
) {
rewritten += rewriteOneOfAsAnyOf(responseFormat.json_schema.schema);
}

if (rewritten === 0) return;

log('Rewrote JSON Schema oneOf as anyOf for Friendli', {
event: 'ai_gateway_chat_completions_one_of_rewritten',
model: request.model,
count: rewritten,
});
}