From c0668828ee3ba198088154a1a3ea9101394f1d82 Mon Sep 17 00:00:00 2001 From: Michael Z Freeman Date: Wed, 26 Aug 2026 14:56:01 +0100 Subject: [PATCH 1/2] fix: normalize Azure Model Router tool schemas --- src/adapters/openai-chat.ts | 46 ++++++++++++++++-- tests/azure-model-router-tool-schema.test.ts | 51 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 tests/azure-model-router-tool-schema.test.ts diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index c877a1b357..0acdf6a1b4 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -922,6 +922,37 @@ function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean { || baseUrl === "https://opencode.ai/zen/go/v1"; } +/** Azure Model Router (and Gemini-in-the-pool) 400s Codex MCP schemas whose root is a union. */ +const AZURE_CHAT_FORBIDDEN_ROOT_KEYS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const; + +function isAzureOpenAiChatTarget(provider: OcxProviderConfig): boolean { + try { + const host = new URL(provider.baseUrl).hostname.toLowerCase(); + return host.endsWith(".openai.azure.com") + || host.endsWith(".cognitiveservices.azure.com") + || host.endsWith(".services.ai.azure.com") + || host.endsWith(".ai.azure.com"); + } catch { + return false; + } +} + +/** + * Azure Foundry Model Router validates every function schema against the strictest model in + * the pool (Gemini-shaped): root must be {type:"object"} with no oneOf/anyOf/allOf/enum/ + * const/not. Codex App MCP tools such as mcp__codex_app__automation_update ship a root + * union, which 400s the whole turn. Flatten like Zen, then strip leftover forbidden keys. + */ +function sanitizeAzureChatToolParameters(parameters: unknown): Record { + const root = ensureZenRootObjectSchema(parameters); + for (const key of AZURE_CHAT_FORBIDDEN_ROOT_KEYS) delete root[key]; + root.type = "object"; + if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) { + root.properties = {}; + } + return root; +} + function isXaiSchemaTarget(provider: OcxProviderConfig): boolean { try { // Public api.x.ai accepts native root object unions. Only the Grok CLI proxy @@ -1249,17 +1280,22 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { const base = toolsToChatFormat(parsed, provider); - if (!base || !shouldSanitizeZenToolParameters(provider)) return base; + const azureChat = isAzureOpenAiChatTarget(provider); + const zenChat = shouldSanitizeZenToolParameters(provider); + if (!base || (!zenChat && !azureChat)) return base; return base.map(tool => { if (!tool || typeof tool !== "object") return tool; const functionDef = (tool as { function?: Record }).function; if (!functionDef || typeof functionDef !== "object") return tool; + const parameters = azureChat + ? sanitizeAzureChatToolParameters(functionDef.parameters ?? {}) + : ensureZenRootObjectSchema(functionDef.parameters ?? {}); + const nextFunction = { ...functionDef, parameters }; + // strict: true plus a flattened schema is rejected by Gemini-in-the-pool routers. + if (azureChat) delete nextFunction.strict; return { ...tool, - function: { - ...functionDef, - parameters: ensureZenRootObjectSchema(functionDef.parameters ?? {}), - }, + function: nextFunction, }; }); } diff --git a/tests/azure-model-router-tool-schema.test.ts b/tests/azure-model-router-tool-schema.test.ts new file mode 100644 index 0000000000..352c000682 --- /dev/null +++ b/tests/azure-model-router-tool-schema.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const toolSchema = { + oneOf: [ + { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + ], + enum: ["invalid-at-root"], + const: "invalid-at-root", + not: { type: "null" }, +}; + +function requestFor(provider: OcxProviderConfig): Record { + const parsed: OcxParsedRequest = { + modelId: "model-router", + context: { + messages: [{ role: "user", content: "Use the tool", timestamp: 0 }], + tools: [{ name: "automation_update", namespace: "mcp__codex_app", description: "Update an automation", parameters: toolSchema, strict: true }], + }, + stream: false, + options: {}, + }; + return JSON.parse(createOpenAIChatAdapter(provider).buildRequest(parsed).body) as Record; +} + +describe("Azure Model Router tool schemas", () => { + test("flattens forbidden root composition and removes strict", () => { + const body = requestFor({ + adapter: "openai-chat", + baseUrl: "https://example.openai.azure.com/openai/v1", + apiKey: "test-key", + authMode: "key", + }); + const fn = ((body.tools as Array<{ function: Record }>)[0]).function; + const parameters = fn.parameters as Record; + + expect(parameters.type).toBe("object"); + expect(parameters.properties).toMatchObject({ id: { type: "string" }, name: { type: "string" } }); + for (const key of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) expect(parameters[key]).toBeUndefined(); + expect(fn.strict).toBeUndefined(); + }); + + test("leaves non-Azure OpenAI-compatible tool schemas unchanged", () => { + const body = requestFor({ adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "test-key", authMode: "key" }); + const fn = ((body.tools as Array<{ function: Record }>)[0]).function; + expect(fn.parameters).toEqual({ ...toolSchema, type: "object" }); + expect(fn.strict).toBe(true); + }); +}); From f64ef6dab1a9199f880a9306409b9d469b1ccebe Mon Sep 17 00:00:00 2001 From: Michael Z Freeman Date: Wed, 26 Aug 2026 15:01:31 +0100 Subject: [PATCH 2/2] fix: preserve tool function schema typing --- src/adapters/openai-chat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 0acdf6a1b4..b1d35b42ed 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1290,7 +1290,7 @@ function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxPro const parameters = azureChat ? sanitizeAzureChatToolParameters(functionDef.parameters ?? {}) : ensureZenRootObjectSchema(functionDef.parameters ?? {}); - const nextFunction = { ...functionDef, parameters }; + const nextFunction: Record = { ...functionDef, parameters }; // strict: true plus a flattened schema is rejected by Gemini-in-the-pool routers. if (azureChat) delete nextFunction.strict; return {