diff --git a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts index da78425fc..3bb694443 100644 --- a/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts +++ b/apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts @@ -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)) { @@ -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); } diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts new file mode 100644 index 000000000..df4c547f5 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts @@ -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; + +function toolWith(name: string, parameters: Schema) { + return { type: 'function', function: { name, parameters } }; +} + +function makeRequest(partial: Record): OpenRouterChatCompletionRequest { + return { + model: 'zai/glm-4.6', + messages: [], + ...partial, + } as OpenRouterChatCompletionRequest; +} + +function makeGatewayRequest(partial: Record): 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'); + } + }); +}); diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.ts new file mode 100644 index 000000000..ef0dddb73 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.ts @@ -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 { + 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[] = [schema]; + const visited = new WeakSet(); + 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, + }); +}