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
46 changes: 41 additions & 5 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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
Expand Down Expand Up @@ -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<string, unknown> }).function;
if (!functionDef || typeof functionDef !== "object") return tool;
const parameters = azureChat
? sanitizeAzureChatToolParameters(functionDef.parameters ?? {})
: ensureZenRootObjectSchema(functionDef.parameters ?? {});
const nextFunction: Record<string, unknown> = { ...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,
};
});
}
Expand Down
51 changes: 51 additions & 0 deletions tests/azure-model-router-tool-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown>;
}

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<string, unknown> }>)[0]).function;
const parameters = fn.parameters as Record<string, unknown>;

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<string, unknown> }>)[0]).function;
expect(fn.parameters).toEqual({ ...toolSchema, type: "object" });
expect(fn.strict).toBe(true);
});
});
Loading