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:
committed by
pullfrog[bot]
parent
a71567af90
commit
c608051b79
@@ -343,6 +343,7 @@ export async function main(): Promise<MainResult> {
|
|||||||
jobId: runInfo.jobId,
|
jobId: runInfo.jobId,
|
||||||
mcpServerUrl: "",
|
mcpServerUrl: "",
|
||||||
tmpdir,
|
tmpdir,
|
||||||
|
resolvedModel,
|
||||||
};
|
};
|
||||||
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
||||||
toolContext.mcpServerUrl = mcpHttpServer.url;
|
toolContext.mcpServerUrl = mcpHttpServer.url;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -149,6 +149,10 @@ export interface ToolContext {
|
|||||||
jobId: string | undefined;
|
jobId: string | undefined;
|
||||||
mcpServerUrl: string;
|
mcpServerUrl: string;
|
||||||
tmpdir: 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;
|
const mcpPortStart = 3764;
|
||||||
|
|||||||
+4
-2
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import type { FastMCP, Tool } from "fastmcp";
|
import type { FastMCP, Tool } from "fastmcp";
|
||||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||||
|
import { isGeminiRouted, sanitizeToolForGemini } from "./geminiSanitizer.ts";
|
||||||
import type { ToolContext } from "./server.ts";
|
import type { ToolContext } from "./server.ts";
|
||||||
|
|
||||||
export const tool = <const params>(
|
export const tool = <const params>(
|
||||||
@@ -61,9 +62,10 @@ export const execute = <T, R extends Record<string, any> | string>(
|
|||||||
return _fn;
|
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) {
|
for (const tool of tools) {
|
||||||
server.addTool(tool);
|
server.addTool(shouldSanitize ? sanitizeToolForGemini(tool) : tool);
|
||||||
}
|
}
|
||||||
return server;
|
return server;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const providers = {
|
|||||||
},
|
},
|
||||||
"gpt-codex-mini": {
|
"gpt-codex-mini": {
|
||||||
displayName: "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",
|
openRouterResolve: "openrouter/openai/gpt-5.1-codex-mini",
|
||||||
},
|
},
|
||||||
o3: {
|
o3: {
|
||||||
|
|||||||
+11
-2
@@ -3,7 +3,12 @@
|
|||||||
* matrix job. `agent` is auto-derived from the alias provider and matches the
|
* matrix job. `agent` is auto-derived from the alias provider and matches the
|
||||||
* harness the runtime would pick in production.
|
* 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";
|
import { modelAliases } from "../models.ts";
|
||||||
|
|
||||||
@@ -11,7 +16,11 @@ function agentForSlug(slug: string): "claude" | "opencode" {
|
|||||||
return slug.startsWith("anthropic/") ? "claude" : "opencode";
|
return slug.startsWith("anthropic/") ? "claude" : "opencode";
|
||||||
}
|
}
|
||||||
|
|
||||||
const matrix = modelAliases.map((alias) => ({
|
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,
|
slug: alias.slug,
|
||||||
agent: agentForSlug(alias.slug),
|
agent: agentForSlug(alias.slug),
|
||||||
// readable display name (GHA renders slashes awkwardly in matrix job titles)
|
// readable display name (GHA renders slashes awkwardly in matrix job titles)
|
||||||
|
|||||||
Reference in New Issue
Block a user