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,
|
||||
mcpServerUrl: "",
|
||||
tmpdir,
|
||||
resolvedModel,
|
||||
};
|
||||
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
||||
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;
|
||||
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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user