diff --git a/main.ts b/main.ts index fc5c77d..c6e3c64 100644 --- a/main.ts +++ b/main.ts @@ -343,6 +343,7 @@ export async function main(): Promise { jobId: runInfo.jobId, mcpServerUrl: "", tmpdir, + resolvedModel, }; await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema }); toolContext.mcpServerUrl = mcpHttpServer.url; diff --git a/mcp/geminiSanitizer.ts b/mcp/geminiSanitizer.ts new file mode 100644 index 0000000..3349c25 --- /dev/null +++ b/mcp/geminiSanitizer.ts @@ -0,0 +1,126 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { Tool } from "fastmcp"; +import type { ToolContext } from "./server.ts"; + +// ── gemini schema sanitizer ──────────────────────────────────────────────────── +// +// gemini's generateContent API expects an OpenAPI 3.0 Schema subset, not full +// JSON Schema. arktype emits constructs that gemini rejects with errors like: +// - "any_of[0].enum: only allowed for STRING type" +// - "parameters.type schema didn't specify the schema type field" +// - "anyOf must be the only field in a schema node" +// +// specific transforms applied here: +// 1. collapse `{anyOf: [{enum:["a"]}, {enum:["b"]}]}` (arktype's string-enum +// encoding) into the direct form gemini wants: `{type:"string", enum:[...]}`. +// also handles `{const:"a"}` variants defensively (arktype 2.x may emit these). +// 2. when `anyOf` / `oneOf` can't be collapsed but has sibling fields +// (e.g. `type`, `description`, `items`), strip those siblings — gemini +// rejects `anyOf` alongside any peer keywords. see opencode #14659. +// 3. drop `$schema` metadata and rename `$defs` -> `definitions` (draft-07 +// compatibility; gemini doesn't understand `$defs`). +// +// gated to gemini-routed traffic via `isGeminiRouted()` so other providers +// continue to see the original (untransformed) schema. + +function parseStringEnumBranch(item: unknown): { values: string[] } | null { + if (!item || typeof item !== "object") return null; + const record = item as Record; + if (Array.isArray(record.enum)) { + const strings = record.enum.filter((v): v is string => typeof v === "string"); + return strings.length === record.enum.length && strings.length > 0 ? { values: strings } : null; + } + if (typeof record.const === "string") { + return { values: [record.const] }; + } + return null; +} + +function collapseStringUnion(branches: unknown[]): { type: "string"; enum: string[] } | null { + const values: string[] = []; + for (const item of branches) { + const parsed = parseStringEnumBranch(item); + if (!parsed) return null; + values.push(...parsed.values); + } + if (values.length === 0) return null; + return { type: "string", enum: [...new Set(values)] }; +} + +/** + * Recursively transform a JSON schema to gemini's stricter subset. + * See module header for the exact transforms applied. + */ +export function sanitizeForGemini(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return schema; + if (Array.isArray(schema)) return schema.map(sanitizeForGemini); + + const source = schema as Record; + + // case 1: collapsible string-enum union → `{type:"string", enum:[...]}` + for (const unionKey of ["anyOf", "oneOf"] as const) { + const branches = source[unionKey]; + if (Array.isArray(branches) && branches.length > 0) { + const collapsed = collapseStringUnion(branches); + if (collapsed) { + const result: Record = { ...collapsed }; + if (typeof source.description === "string") result.description = source.description; + return result; + } + } + } + + // case 2: non-collapsible anyOf/oneOf → strip sibling fields (gemini rule) + if (Array.isArray(source.anyOf) || Array.isArray(source.oneOf)) { + const result: Record = {}; + if (Array.isArray(source.anyOf)) result.anyOf = source.anyOf.map(sanitizeForGemini); + if (Array.isArray(source.oneOf)) result.oneOf = source.oneOf.map(sanitizeForGemini); + return result; + } + + // case 3: generic pass — drop $schema, rename $defs, recurse + const sanitized: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (key === "$schema") continue; + if (key === "$defs") { + sanitized.definitions = sanitizeForGemini(value); + continue; + } + sanitized[key] = sanitizeForGemini(value); + } + return sanitized; +} + +/** + * Wraps a StandardSchemaV1 so its `toJsonSchema()` output is sanitized + * for gemini. other methods on the schema are passed through unchanged. + */ +export function wrapSchemaForGemini(schema: StandardSchemaV1): StandardSchemaV1 { + const originalToJsonSchema = (schema as any).toJsonSchema?.bind(schema); + if (!originalToJsonSchema) return schema; + return new Proxy(schema, { + get(target, prop) { + if (prop === "toJsonSchema") { + return () => sanitizeForGemini(originalToJsonSchema()); + } + return (target as any)[prop]; + }, + }) as StandardSchemaV1; +} + +export function sanitizeToolForGemini>(tool: T): T { + if (!tool.parameters) return tool; + return { ...tool, parameters: wrapSchemaForGemini(tool.parameters) } as T; +} + +/** + * true when the effective upstream model is served by google's generative + * language API — directly (`google/*`), via opencode (`opencode/gemini-*`), + * or via openrouter (`openrouter/google/gemini-*`). slug-substring match + * works because every gemini route's model id contains "gemini". + */ +export function isGeminiRouted(ctx: ToolContext): boolean { + const effective = ctx.payload.proxyModel ?? ctx.resolvedModel ?? ctx.payload.model; + if (!effective) return false; + return effective.toLowerCase().includes("gemini"); +} diff --git a/mcp/server.ts b/mcp/server.ts index 5010ded..e8004ef 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -149,6 +149,10 @@ export interface ToolContext { jobId: string | undefined; mcpServerUrl: string; tmpdir: string; + // resolved upstream model specifier (e.g. "google/gemini-3.1-pro-preview"). + // undefined when payload.proxyModel is set or when the alias is unresolvable. + // used by the schema sanitizer to detect Gemini-routed traffic. + resolvedModel: string | undefined; } const mcpPortStart = 3764; diff --git a/mcp/shared.ts b/mcp/shared.ts index cce2b14..01a02fd 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { encode as toonEncode } from "@toon-format/toon"; import type { FastMCP, Tool } from "fastmcp"; import { formatJsonValue, log } from "../utils/cli.ts"; +import { isGeminiRouted, sanitizeToolForGemini } from "./geminiSanitizer.ts"; import type { ToolContext } from "./server.ts"; export const tool = ( @@ -61,9 +62,10 @@ export const execute = | string>( return _fn; }; -export const addTools = (_ctx: ToolContext, server: FastMCP, tools: Tool[]) => { +export const addTools = (ctx: ToolContext, server: FastMCP, tools: Tool[]) => { + const shouldSanitize = isGeminiRouted(ctx); for (const tool of tools) { - server.addTool(tool); + server.addTool(shouldSanitize ? sanitizeToolForGemini(tool) : tool); } return server; }; diff --git a/models.ts b/models.ts index 539cb52..d9688c4 100644 --- a/models.ts +++ b/models.ts @@ -86,7 +86,7 @@ export const providers = { }, "gpt-codex-mini": { displayName: "GPT Codex Mini", - resolve: "openai/codex-mini-latest", + resolve: "openai/gpt-5.1-codex-mini", openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini", }, o3: { diff --git a/test/list-aliases.ts b/test/list-aliases.ts index a82152c..0f4062e 100644 --- a/test/list-aliases.ts +++ b/test/list-aliases.ts @@ -3,7 +3,12 @@ * matrix job. `agent` is auto-derived from the alias provider and matches the * harness the runtime would pick in production. * - * usage: `node action/test/list-aliases.ts` + * set MATRIX_FILTER to a substring to restrict the matrix to matching aliases + * — useful for iterating on a single provider without paying for every model. + * + * usage: + * node action/test/list-aliases.ts + * MATRIX_FILTER=gemini node action/test/list-aliases.ts */ import { modelAliases } from "../models.ts"; @@ -11,11 +16,15 @@ function agentForSlug(slug: string): "claude" | "opencode" { return slug.startsWith("anthropic/") ? "claude" : "opencode"; } -const matrix = modelAliases.map((alias) => ({ - slug: alias.slug, - agent: agentForSlug(alias.slug), - // readable display name (GHA renders slashes awkwardly in matrix job titles) - name: alias.slug.replace("/", "-"), -})); +const filter = process.env.MATRIX_FILTER?.trim() ?? ""; + +const matrix = modelAliases + .filter((alias) => (filter ? alias.slug.toLowerCase().includes(filter.toLowerCase()) : true)) + .map((alias) => ({ + slug: alias.slug, + agent: agentForSlug(alias.slug), + // readable display name (GHA renders slashes awkwardly in matrix job titles) + name: alias.slug.replace("/", "-"), + })); process.stdout.write(JSON.stringify(matrix));