From 819bb5aafb7fd32b15fab07f08c1b11ed7085c21 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:10:33 +0000 Subject: [PATCH 1/5] fix(ai-gateway): rewrite JSON Schema oneOf as anyOf when routed to friendli Friendli does not support the JSON Schema oneOf keyword. For chat completions requests whose provider.order includes friendli, downgrade every oneOf in tool parameters and response_format schemas to anyOf. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../apply-provider-specific-logic.ts | 12 ++ .../src/lib/ai-gateway/schema-rewrite.test.ts | 198 ++++++++++++++++++ apps/web/src/lib/ai-gateway/schema-rewrite.ts | 65 ++++++ 3 files changed, 275 insertions(+) create mode 100644 apps/web/src/lib/ai-gateway/schema-rewrite.test.ts create mode 100644 apps/web/src/lib/ai-gateway/schema-rewrite.ts 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 c21c4c216..3082bdb3d 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 @@ -32,6 +32,7 @@ import { } from '@/lib/ai-gateway/providers/openrouter/request-helpers'; import { isQwenExplicitCacheModel, isQwenModel } from '@/lib/ai-gateway/providers/qwen'; import { logChatCompletionsOneOfSchemas } from '@/lib/ai-gateway/schema-logging'; +import { rewriteChatCompletionsOneOfAsAnyOf } from '@/lib/ai-gateway/schema-rewrite'; export function getPreferredProviderOrder(requestedModel: string): string[] { if (isClaudeModel(requestedModel)) { @@ -155,6 +156,17 @@ 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 ( + requestToMutate.kind === 'chat_completions' && + requestToMutate.body.provider?.order?.includes( + OpenRouterInferenceProviderIdSchema.enum.friendli + ) + ) { + 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..a5828d6f2 --- /dev/null +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from '@jest/globals'; +import { rewriteChatCompletionsOneOfAsAnyOf } from '@/lib/ai-gateway/schema-rewrite'; +import type { OpenRouterChatCompletionRequest } from '@/lib/ai-gateway/providers/openrouter/types'; + +function makeRequest( + partial: Partial +): OpenRouterChatCompletionRequest { + return { + model: 'zai/glm-4.6', + messages: [], + ...partial, + } as OpenRouterChatCompletionRequest; +} + +describe('rewriteChatCompletionsOneOfAsAnyOf', () => { + it('rewrites oneOf as anyOf in tool function parameters', () => { + const request = makeRequest({ + tools: [ + { + type: 'function', + function: { + name: 'get_weather', + parameters: { + type: 'object', + oneOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + }, + ], + }); + + rewriteChatCompletionsOneOfAsAnyOf(request); + + const parameters = request.tools![0].function + .parameters as Record; + 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 request = makeRequest({ + response_format: { + type: 'json_schema', + json_schema: { + name: 'result', + schema: { + type: 'object', + oneOf: [{ type: 'string' }], + }, + }, + } as OpenRouterChatCompletionRequest['response_format'], + }); + + rewriteChatCompletionsOneOfAsAnyOf(request); + + const schema = (request.response_format as Record & { + json_schema: { schema: Record }; + }).json_schema.schema; + expect(schema).not.toHaveProperty('oneOf'); + expect(schema.anyOf).toEqual([{ type: 'string' }]); + }); + + it('rewrites nested oneOf keywords', () => { + const request = makeRequest({ + tools: [ + { + type: 'function', + function: { + name: 'search', + parameters: { + type: 'object', + properties: { + filter: { + oneOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + oneOf: [{ type: 'object' }], + }, + }, + }, + ], + }); + + rewriteChatCompletionsOneOfAsAnyOf(request); + + const parameters = request.tools![0].function + .parameters as Record; + expect(parameters).not.toHaveProperty('oneOf'); + expect(parameters.anyOf).toEqual([{ type: 'object' }]); + expect(parameters.properties.filter).not.toHaveProperty('oneOf'); + expect(parameters.properties.filter.anyOf).toEqual([ + { type: 'string' }, + { type: 'number' }, + ]); + }); + + it('leaves schemas without oneOf untouched', () => { + const request = makeRequest({ + tools: [ + { + type: 'function', + function: { + name: 'ping', + parameters: { + type: 'object', + properties: { host: { type: 'string' } }, + }, + }, + }, + ], + }); + + rewriteChatCompletionsOneOfAsAnyOf(request); + + const parameters = request.tools![0].function + .parameters as Record; + expect(parameters).not.toHaveProperty('anyOf'); + expect(parameters).not.toHaveProperty('oneOf'); + }); + + it('preserves other keywords alongside the rewrite', () => { + const request = makeRequest({ + tools: [ + { + type: 'function', + function: { + name: 'run', + parameters: { + type: 'object', + required: ['mode'], + oneOf: [{ type: 'string' }], + }, + }, + }, + ], + }); + + rewriteChatCompletionsOneOfAsAnyOf(request); + + const parameters = request.tools![0].function + .parameters as Record; + 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 request = makeRequest({ + tools: [ + { + type: 'function', + function: { + name: 'merge', + parameters: { + type: 'object', + anyOf: [{ type: 'boolean' }], + oneOf: [{ type: 'string' }], + }, + }, + }, + ], + }); + + rewriteChatCompletionsOneOfAsAnyOf(request); + + const parameters = request.tools![0].function + .parameters as Record; + 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: Record = { type: 'object', oneOf: [] }; + const child: Record = { type: 'string' }; + schema.properties = { child }; + child.parent = schema; + + const request = makeRequest({ + tools: [ + { + type: 'function', + function: { name: 'circular', parameters: schema }, + }, + ], + }); + + expect(() => rewriteChatCompletionsOneOfAsAnyOf(request)).not.toThrow(); + expect(schema).not.toHaveProperty('oneOf'); + expect(schema).toHaveProperty('anyOf'); + }); +}); 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..1e18260cb --- /dev/null +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.ts @@ -0,0 +1,65 @@ +import type { OpenRouterChatCompletionRequest } from '@/lib/ai-gateway/providers/openrouter/types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * 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. + */ +function rewriteOneOfAsAnyOf(schema: unknown): void { + if (!isRecord(schema)) return; + + const pending: Record[] = [schema]; + const visited = new WeakSet(); + + 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]; + } + } + if (isRecord(nestedValue)) { + pending.push(nestedValue); + } + } + } +} + +/** + * 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`. + */ +export function rewriteChatCompletionsOneOfAsAnyOf( + request: OpenRouterChatCompletionRequest +): void { + if (Array.isArray(request.tools)) { + for (const tool of request.tools) { + if (!isRecord(tool) || tool.type !== 'function' || !isRecord(tool.function)) continue; + rewriteOneOfAsAnyOf(tool.function.parameters); + } + } + + const responseFormat = request.response_format; + if ( + isRecord(responseFormat) && + responseFormat.type === 'json_schema' && + isRecord(responseFormat.json_schema) + ) { + rewriteOneOfAsAnyOf(responseFormat.json_schema.schema); + } +} From f28b1bd247e1d931bfa89cde1e85d0131597b417 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:14:49 +0000 Subject: [PATCH 2/5] style(ai-gateway): oxfmt schema-rewrite files Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../src/lib/ai-gateway/schema-rewrite.test.ts | 28 ++++++++----------- apps/web/src/lib/ai-gateway/schema-rewrite.ts | 4 +-- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts index a5828d6f2..f54e6e49f 100644 --- a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts @@ -31,8 +31,7 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function - .parameters as Record; + const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('oneOf'); expect(parameters).toHaveProperty('anyOf'); expect(parameters.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]); @@ -54,9 +53,11 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { rewriteChatCompletionsOneOfAsAnyOf(request); - const schema = (request.response_format as Record & { - json_schema: { schema: Record }; - }).json_schema.schema; + const schema = ( + request.response_format as Record & { + json_schema: { schema: Record }; + } + ).json_schema.schema; expect(schema).not.toHaveProperty('oneOf'); expect(schema.anyOf).toEqual([{ type: 'string' }]); }); @@ -84,15 +85,11 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function - .parameters as Record; + const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('oneOf'); expect(parameters.anyOf).toEqual([{ type: 'object' }]); expect(parameters.properties.filter).not.toHaveProperty('oneOf'); - expect(parameters.properties.filter.anyOf).toEqual([ - { type: 'string' }, - { type: 'number' }, - ]); + expect(parameters.properties.filter.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]); }); it('leaves schemas without oneOf untouched', () => { @@ -113,8 +110,7 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function - .parameters as Record; + const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('anyOf'); expect(parameters).not.toHaveProperty('oneOf'); }); @@ -138,8 +134,7 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function - .parameters as Record; + const parameters = request.tools![0].function.parameters as Record; expect(parameters.type).toBe('object'); expect(parameters.required).toEqual(['mode']); expect(parameters.anyOf).toEqual([{ type: 'string' }]); @@ -164,8 +159,7 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function - .parameters as Record; + const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('oneOf'); expect(parameters.anyOf).toEqual([{ type: 'boolean' }, { type: 'string' }]); }); diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.ts index 1e18260cb..868124279 100644 --- a/apps/web/src/lib/ai-gateway/schema-rewrite.ts +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.ts @@ -44,9 +44,7 @@ function rewriteOneOfAsAnyOf(schema: unknown): void { * chat completions request — both tool function `parameters` and the * `response_format.json_schema.schema`. */ -export function rewriteChatCompletionsOneOfAsAnyOf( - request: OpenRouterChatCompletionRequest -): void { +export function rewriteChatCompletionsOneOfAsAnyOf(request: OpenRouterChatCompletionRequest): void { if (Array.isArray(request.tools)) { for (const tool of request.tools) { if (!isRecord(tool) || tool.type !== 'function' || !isRecord(tool.function)) continue; From 2a851babfac27d349f535600be9259b9f7b71b92 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:22:15 +0000 Subject: [PATCH 3/5] fix(ai-gateway): resolve typecheck errors in schema-rewrite tests Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../src/lib/ai-gateway/schema-rewrite.test.ts | 146 +++++------------- 1 file changed, 36 insertions(+), 110 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts index f54e6e49f..147487ae3 100644 --- a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from '@jest/globals'; import { rewriteChatCompletionsOneOfAsAnyOf } from '@/lib/ai-gateway/schema-rewrite'; import type { OpenRouterChatCompletionRequest } from '@/lib/ai-gateway/providers/openrouter/types'; -function makeRequest( - partial: Partial -): OpenRouterChatCompletionRequest { +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: [], @@ -14,152 +18,82 @@ function makeRequest( describe('rewriteChatCompletionsOneOfAsAnyOf', () => { it('rewrites oneOf as anyOf in tool function parameters', () => { - const request = makeRequest({ - tools: [ - { - type: 'function', - function: { - name: 'get_weather', - parameters: { - type: 'object', - oneOf: [{ type: 'string' }, { type: 'number' }], - }, - }, - }, - ], - }); + const parameters: Schema = { + type: 'object', + oneOf: [{ type: 'string' }, { type: 'number' }], + }; + const request = makeRequest({ tools: [toolWith('get_weather', parameters)] }); rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function.parameters as Record; 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: { - type: 'object', - oneOf: [{ type: 'string' }], - }, - }, + json_schema: { name: 'result', schema }, } as OpenRouterChatCompletionRequest['response_format'], }); rewriteChatCompletionsOneOfAsAnyOf(request); - const schema = ( - request.response_format as Record & { - json_schema: { schema: Record }; - } - ).json_schema.schema; expect(schema).not.toHaveProperty('oneOf'); expect(schema.anyOf).toEqual([{ type: 'string' }]); }); it('rewrites nested oneOf keywords', () => { - const request = makeRequest({ - tools: [ - { - type: 'function', - function: { - name: 'search', - parameters: { - type: 'object', - properties: { - filter: { - oneOf: [{ type: 'string' }, { type: 'number' }], - }, - }, - oneOf: [{ type: 'object' }], - }, - }, - }, - ], - }); + 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); - const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('oneOf'); expect(parameters.anyOf).toEqual([{ type: 'object' }]); - expect(parameters.properties.filter).not.toHaveProperty('oneOf'); - expect(parameters.properties.filter.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]); + expect(filter).not.toHaveProperty('oneOf'); + expect(filter.anyOf).toEqual([{ type: 'string' }, { type: 'number' }]); }); it('leaves schemas without oneOf untouched', () => { - const request = makeRequest({ - tools: [ - { - type: 'function', - function: { - name: 'ping', - parameters: { - type: 'object', - properties: { host: { type: 'string' } }, - }, - }, - }, - ], - }); + const parameters: Schema = { type: 'object', properties: { host: { type: 'string' } } }; + const request = makeRequest({ tools: [toolWith('ping', parameters)] }); rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('anyOf'); expect(parameters).not.toHaveProperty('oneOf'); }); it('preserves other keywords alongside the rewrite', () => { - const request = makeRequest({ - tools: [ - { - type: 'function', - function: { - name: 'run', - parameters: { - type: 'object', - required: ['mode'], - oneOf: [{ type: 'string' }], - }, - }, - }, - ], - }); + const parameters: Schema = { type: 'object', required: ['mode'], oneOf: [{ type: 'string' }] }; + const request = makeRequest({ tools: [toolWith('run', parameters)] }); rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function.parameters as Record; 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 request = makeRequest({ - tools: [ - { - type: 'function', - function: { - name: 'merge', - parameters: { - type: 'object', - anyOf: [{ type: 'boolean' }], - oneOf: [{ type: 'string' }], - }, - }, - }, - ], - }); + const parameters: Schema = { + type: 'object', + anyOf: [{ type: 'boolean' }], + oneOf: [{ type: 'string' }], + }; + const request = makeRequest({ tools: [toolWith('merge', parameters)] }); rewriteChatCompletionsOneOfAsAnyOf(request); - const parameters = request.tools![0].function.parameters as Record; expect(parameters).not.toHaveProperty('oneOf'); expect(parameters.anyOf).toEqual([{ type: 'boolean' }, { type: 'string' }]); }); @@ -171,19 +105,11 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { }); it('does not loop forever on a circular schema', () => { - const schema: Record = { type: 'object', oneOf: [] }; - const child: Record = { type: 'string' }; + const schema: Schema = { type: 'object', oneOf: [] }; + const child: Schema = { type: 'string' }; schema.properties = { child }; child.parent = schema; - - const request = makeRequest({ - tools: [ - { - type: 'function', - function: { name: 'circular', parameters: schema }, - }, - ], - }); + const request = makeRequest({ tools: [toolWith('circular', schema)] }); expect(() => rewriteChatCompletionsOneOfAsAnyOf(request)).not.toThrow(); expect(schema).not.toHaveProperty('oneOf'); From a9f77049ae97069a92e61843860b8a713538a09e Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:40:01 +0000 Subject: [PATCH 4/5] refactor(ai-gateway): encapsulate Friendli request detection and add logging Refactor the schema rewriting logic to use a dedicated type guard for detecting Friendli chat completions requests. This improves readability and type safety in the provider logic. Additionally, update the `oneOf` to `anyOf` rewriting process to return the count of rewritten schemas and include optional logging to track when these transformations occur in production. - Add `isFriendliChatCompletionsRequest` type guard - Update `rewriteChatCompletionsOneOfAsAnyOf` to support optional logging - Add unit tests for the new type guard and logging behavior Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../apply-provider-specific-logic.ts | 12 +- .../src/lib/ai-gateway/schema-rewrite.test.ts | 116 +++++++++++++++++- apps/web/src/lib/ai-gateway/schema-rewrite.ts | 70 +++++++++-- 3 files changed, 181 insertions(+), 17 deletions(-) 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 c34efccbf..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,7 +31,10 @@ import { scrubOpenCodeSpecificProperties, } from '@/lib/ai-gateway/providers/openrouter/request-helpers'; import { isQwenExplicitCacheModel, isQwenModel } from '@/lib/ai-gateway/providers/qwen'; -import { rewriteChatCompletionsOneOfAsAnyOf } from '@/lib/ai-gateway/schema-rewrite'; +import { + rewriteChatCompletionsOneOfAsAnyOf, + isFriendliChatCompletionsRequest, +} from '@/lib/ai-gateway/schema-rewrite'; export function getPreferredProviderOrder(requestedModel: string): string[] { if (isClaudeModel(requestedModel)) { @@ -153,12 +156,7 @@ export function applyProviderSpecificLogic( // Friendli does not support JSON Schema `oneOf`, so downgrade every `oneOf` // to `anyOf` for any chat completions request routed through it. - if ( - requestToMutate.kind === 'chat_completions' && - requestToMutate.body.provider?.order?.includes( - OpenRouterInferenceProviderIdSchema.enum.friendli - ) - ) { + if (isFriendliChatCompletionsRequest(requestToMutate)) { rewriteChatCompletionsOneOfAsAnyOf(requestToMutate.body); } diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts index 147487ae3..df4c547f5 100644 --- a/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from '@jest/globals'; -import { rewriteChatCompletionsOneOfAsAnyOf } from '@/lib/ai-gateway/schema-rewrite'; -import type { OpenRouterChatCompletionRequest } from '@/lib/ai-gateway/providers/openrouter/types'; +import { + rewriteChatCompletionsOneOfAsAnyOf, + isFriendliChatCompletionsRequest, +} from '@/lib/ai-gateway/schema-rewrite'; +import type { + GatewayRequest, + OpenRouterChatCompletionRequest, +} from '@/lib/ai-gateway/providers/openrouter/types'; type Schema = Record; @@ -16,6 +22,10 @@ function makeRequest(partial: Record): OpenRouterChatCompletion } 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 = { @@ -116,3 +126,105 @@ describe('rewriteChatCompletionsOneOfAsAnyOf', () => { 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 index 868124279..52b4cbccc 100644 --- a/apps/web/src/lib/ai-gateway/schema-rewrite.ts +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.ts @@ -1,22 +1,55 @@ -import type { OpenRouterChatCompletionRequest } from '@/lib/ai-gateway/providers/openrouter/types'; +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. + * schemas cannot loop forever. Returns the number of `oneOf` keywords removed. */ -function rewriteOneOfAsAnyOf(schema: unknown): void { - if (!isRecord(schema)) return; +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(); @@ -31,24 +64,33 @@ function rewriteOneOfAsAnyOf(schema: unknown): void { 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`. + * `response_format.json_schema.schema`. Logs once per request, but only when at + * least one `oneOf` was actually rewritten. */ -export function rewriteChatCompletionsOneOfAsAnyOf(request: OpenRouterChatCompletionRequest): void { +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; - rewriteOneOfAsAnyOf(tool.function.parameters); + rewritten += rewriteOneOfAsAnyOf(tool.function.parameters); } } @@ -58,6 +100,18 @@ export function rewriteChatCompletionsOneOfAsAnyOf(request: OpenRouterChatComple responseFormat.type === 'json_schema' && isRecord(responseFormat.json_schema) ) { - rewriteOneOfAsAnyOf(responseFormat.json_schema.schema); + rewritten += rewriteOneOfAsAnyOf(responseFormat.json_schema.schema); + } + + if (rewritten === 0) return; + + try { + log('Rewrote JSON Schema oneOf as anyOf for Friendli', { + event: 'ai_gateway_chat_completions_one_of_rewritten', + model: request.model, + count: rewritten, + }); + } catch { + // Diagnostics must never interrupt request forwarding. } } From 15fa7c0e9634eb9ac16b4bce67487cc0a86283dc Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:58:33 +0000 Subject: [PATCH 5/5] refactor(ai-gateway): remove unnecessary try-catch around logging Remove the try-catch block around the Friendli schema rewrite log to simplify the code, as the logging utility is expected to be safe. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- apps/web/src/lib/ai-gateway/schema-rewrite.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/ai-gateway/schema-rewrite.ts b/apps/web/src/lib/ai-gateway/schema-rewrite.ts index 52b4cbccc..ef0dddb73 100644 --- a/apps/web/src/lib/ai-gateway/schema-rewrite.ts +++ b/apps/web/src/lib/ai-gateway/schema-rewrite.ts @@ -105,13 +105,9 @@ export function rewriteChatCompletionsOneOfAsAnyOf( if (rewritten === 0) return; - try { - log('Rewrote JSON Schema oneOf as anyOf for Friendli', { - event: 'ai_gateway_chat_completions_one_of_rewritten', - model: request.model, - count: rewritten, - }); - } catch { - // Diagnostics must never interrupt request forwarding. - } + log('Rewrote JSON Schema oneOf as anyOf for Friendli', { + event: 'ai_gateway_chat_completions_one_of_rewritten', + model: request.model, + count: rewritten, + }); }