sanitize mcp schemas for Gemini; fix gpt-codex-mini alias; add matrix filter

Gemini's generateContent API rejects arktype's `{anyOf:[{enum:[...]}]}` string-enum
encoding, `$schema` metadata, and `anyOf` with sibling fields. Port the old
sanitizer back as an isolated module (action/mcp/geminiSanitizer.ts) and gate it
on `isGeminiRouted(ctx)` so non-gemini routes see the original schema. Wires
`resolvedModel` onto ToolContext so the sanitizer can see the upstream specifier.

Also bumps `openai/gpt-codex-mini` alias from the deprecated `codex-mini-latest`
to `gpt-5.1-codex-mini`, matching the openrouter resolve.

Adds a `filter` workflow_dispatch input + MATRIX_FILTER env that restricts the
models-live matrix to aliases matching a substring, so we can iterate on a
single provider (e.g. `filter=gemini`) without paying to run every model.
This commit is contained in:
Colin McDonnell
2026-04-16 23:09:32 +00:00
committed by pullfrog[bot]
parent a71567af90
commit c608051b79
6 changed files with 152 additions and 10 deletions
+1
View File
@@ -343,6 +343,7 @@ export async function main(): Promise<MainResult> {
jobId: runInfo.jobId,
mcpServerUrl: "",
tmpdir,
resolvedModel,
};
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
toolContext.mcpServerUrl = mcpHttpServer.url;
+126
View File
@@ -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<string, unknown>;
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<string, unknown>;
// 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<string, unknown> = { ...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<string, unknown> = {};
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<string, unknown> = {};
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<any>): StandardSchemaV1<any> {
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<any>;
}
export function sanitizeToolForGemini<T extends Tool<any, any>>(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");
}
+4
View File
@@ -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;
+4 -2
View File
@@ -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 = <const params>(
@@ -61,9 +62,10 @@ export const execute = <T, R extends Record<string, any> | string>(
return _fn;
};
export const addTools = (_ctx: ToolContext, server: FastMCP<any>, tools: Tool<any, any>[]) => {
export const addTools = (ctx: ToolContext, server: FastMCP<any>, tools: Tool<any, any>[]) => {
const shouldSanitize = isGeminiRouted(ctx);
for (const tool of tools) {
server.addTool(tool);
server.addTool(shouldSanitize ? sanitizeToolForGemini(tool) : tool);
}
return server;
};
+1 -1
View File
@@ -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: {
+16 -7
View File
@@ -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));