Skip to content
Closed
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
132 changes: 131 additions & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
import {
isXaiSchemaTarget,
lookupLocalJsonPointer,
normalizeXaiToolParameters,
XaiToolSchemaCompatibilityError,
} from "./xai-tool-schema";
Expand Down Expand Up @@ -1974,9 +1975,22 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
* shape, so this is Muse-only. Drop only the field the gateway refuses while
* keeping the tool type and every other accepted option intact.
*/
/**
* Muse Spark models served over Responses on Zen Go share the same gateway
* restrictions (probed 2026-08-26 for 1.2, 2026-09-02 for 1.3): plain
* `web_search` must not carry `search_content_types`, tool names are capped at
* 64 chars, and parameter schemas must not be recursive. The predicate matches
* the bare model id with or without a `provider/` namespace prefix.
*/
function isMuseSparkGatewayModel(modelId: unknown): boolean {
const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : "";
const base = normalized.includes("/") ? normalized.split("/").pop() ?? normalized : normalized;
return base === "muse-spark-1.2-contributor" || base === "muse-spark-1.3-contributor";
Comment on lines +1985 to +1988

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Zen Go guards scoped to Zen Go

This predicate checks only the model ID, while its callers run for every noncanonical Responses provider. Consequently, a custom provider serving muse-spark-1.2-contributor or muse-spark-1.3-contributor at a different gateway will also lose long-named and recursive-schema tools even if that gateway accepts them; the restriction was only established for the Zen Go destination. Pass the provider into this guard and require the Zen Go base URL before mutating the tool surface.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}

function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown {
if (!isPlainObject(body)) return body;
if (typeof modelId !== "string" || modelId.trim().toLowerCase() !== "muse-spark-1.2-contributor") return body;
if (!isMuseSparkGatewayModel(modelId)) return body;

const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
let changed = false;
Expand Down Expand Up @@ -2016,6 +2030,120 @@ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknow
return changed ? next : body;
}

/**
* Zen Go rejects function/custom tool names longer than 64 chars
* (`name must be at most 64 characters`), while Codex attaches MCP tools such
* as `mcp__codex_apps__codex_document_control___get_document_tool_schemas`
* (67 chars). Dropping only the over-long declarations (top-level and
* additional_tools) lets the turn proceed with the remaining catalog; the
* model simply cannot be offered those few tools. A tool_choice naming a
* dropped tool falls back to auto to avoid a second 400.
*/
function dropMuseSparkOverlongToolNames(body: unknown, modelId: unknown): unknown {
if (!isPlainObject(body)) return body;
if (!isMuseSparkGatewayModel(modelId)) return body;
const dropped = new Set<string>();
const filterTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
let changed = false;
const kept = tools.filter(tool => {
if (!isPlainObject(tool)) return true;
if (tool.type !== "function" && tool.type !== "custom") return true;
if (typeof tool.name !== "string" || tool.name.length <= 64) return true;
dropped.add(tool.name);
changed = true;
return false;
});
return { tools: changed ? kept : tools, changed };
};
let next: Record<string, unknown> = body;
if (Array.isArray(body.tools)) {
const rewritten = filterTools(body.tools);
if (rewritten.changed) next = { ...next, tools: rewritten.tools };
}
if (Array.isArray(next.input)) {
const input = next.input.map(item => {
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
const rewritten = filterTools(item.tools);
return rewritten.changed ? { ...item, tools: rewritten.tools } : item;
});
if (input.some((item, index) => item !== (next.input as unknown[])[index])) next = { ...next, input };
}
if (dropped.size > 0) {
// eslint-disable-next-line no-console
console.warn(`[opencodex] muse-spark: dropped ${dropped.size} tool(s) with names >64 chars rejected by Zen Go`);
if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) {
next = { ...next, tool_choice: "auto" };
Comment on lines +2074 to +2075

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile allowed_tools after dropping declarations

When the caller uses an allowed_tools selector, the removed function name is nested under tool_choice.tools[], so this direct .name check does nothing; the identical check in the recursive-schema filter has the same problem. The outgoing request therefore retains a selector for an omitted tool—and for the length case still contains the rejected over-64-character name—so Zen Go can return the same 400 this guard was intended to prevent. Reconcile both direct and allowed_tools choices, preserving remaining entries and refusing a required choice when none remain.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}
}
return next === body ? body : next;
}

/**
* Zen Go rejects recursive JSON schemas (`Recursive JSON schemas are not
* currently supported`), which some MCP tools carry via cyclic local `$ref`s.
* Structural identity cycles cannot reach this point (JSON serialization would
* have thrown first), so only the `$ref` graph is checked, reusing the tested
* lookupLocalJsonPointer helper. Tools with cyclic schemas are dropped for
* Muse Spark models only; siblings sharing one `$defs` entry (diamonds) are
* kept. A tool_choice naming a dropped tool falls back to auto.
*/
function schemaRefGraphHasCycle(parameters: unknown): boolean {
if (!isPlainObject(parameters)) return false;
const root: Record<string, unknown> = parameters;
const visit = (node: unknown, stack: string[]): boolean => {
if (Array.isArray(node)) return node.some(child => visit(child, stack));
if (!isPlainObject(node)) return false;
if (typeof node.$ref === "string") {
if (stack.includes(node.$ref)) return true;
// Remote or unresolvable refs cannot be judged locally; leave them alone.
if (!node.$ref.startsWith("#/") && node.$ref !== "#" && node.$ref !== "#/") return false;
const target = lookupLocalJsonPointer(root, node.$ref);
if (target === undefined) return false;
return visit(target, [...stack, node.$ref]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the local-reference walk

For a Muse request with a long but acyclic local $ref chain—for example, thousands of shallow $defs entries that each reference the next—visit recurses once per reference without any depth or node budget. Such JSON remains shallow enough to parse and serialize, but this walk can exhaust Bun's call stack before the upstream request is built, turning a caller-controlled tool schema into a failed request or process crash. Use the bounded/iterative schema-walk pattern already employed by the other adapter schema normalizers.

Useful? React with 👍 / 👎.

}
return Object.values(node).some(child => visit(child, stack));
};
return visit(root, []);
}

function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unknown {
if (!isPlainObject(body)) return body;
if (!isMuseSparkGatewayModel(modelId)) return body;
const dropped = new Set<string>();
const filterTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
let changed = false;
const kept = tools.filter(tool => {
if (!isPlainObject(tool) || tool.type !== "function") return true;
if (!isPlainObject(tool.parameters) || !schemaRefGraphHasCycle(tool.parameters)) return true;
if (typeof tool.name === "string") dropped.add(tool.name);
changed = true;
return false;
});
return { tools: changed ? kept : tools, changed };
};
let next: Record<string, unknown> = body;
if (Array.isArray(body.tools)) {
const rewritten = filterTools(body.tools);
if (rewritten.changed) next = { ...next, tools: rewritten.tools };
}
if (Array.isArray(next.input)) {
const input = next.input.map(item => {
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
const rewritten = filterTools(item.tools);
return rewritten.changed ? { ...item, tools: rewritten.tools } : item;
});
if (input.some((item, index) => item !== (next.input as unknown[])[index])) next = { ...next, input };
}
if (dropped.size > 0) {
// eslint-disable-next-line no-console
console.warn(`[opencodex] muse-spark: dropped ${dropped.size} tool(s) with recursive schemas rejected by Zen Go: ${[...dropped].join(", ")}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not log caller-supplied tool names

Whenever a recursive schema is dropped, this warning writes the exact caller-controlled tool.name field from the request body into persistent process/service logs. MCP tool names can contain server, workspace, tenant, or account-derived identifiers, so this violates the repository's prohibition on logging request-body/account data; emit only the count or use the existing redacted diagnostic mechanism.

AGENTS.md reference: AGENTS.md:L345-L346

Useful? React with 👍 / 👎.

if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) {
next = { ...next, tool_choice: "auto" };
}
}
return next === body ? body : next;
}

/** Replace every `input_image` part under a routed-compaction body with a short marker. */
function stripInputImagesDeep(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stripInputImagesDeep);
Expand Down Expand Up @@ -2238,6 +2366,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = stripOpenAiOnlyWebSearchFields(outBody);
}
outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId);
outBody = dropMuseSparkOverlongToolNames(outBody, parsed.modelId);
outBody = dropMuseSparkRecursiveSchemaTools(outBody, parsed.modelId);
// Last, so promoted namespace children are also cleared of Codex-private fields.
outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
}
Expand Down
22 changes: 21 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,7 +1487,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
- 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence.
- 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update.
*/
modelWireDefaults: { "gpt-5.6-luna": "openai-responses", "muse-spark-1.2-contributor": "openai-responses" },
modelWireDefaults: {
"gpt-5.6-luna": "openai-responses",
"muse-spark-1.2-contributor": "openai-responses",
// 1.3 serves the same Responses-only shape on Zen Go (probed 2026-09-02:
// /chat/completions -> 500, /responses -> 200).
"muse-spark-1.3-contributor": "openai-responses",
},
modelContextWindows: {
"kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW,
// The DeepSeek vision preview id is metadata-only here: the Go roster is
Expand All @@ -1510,6 +1516,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
},
modelReasoningEfforts: {
"gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS,
// Zen Go rejects any other ladder for 1.3 (`reasoning.effort: unknown variant`,
// expected none/minimal/low/medium/high/xhigh — gateway error, probed 2026-09-02).
"muse-spark-1.3-contributor": ["none", "minimal", "low", "medium", "high", "xhigh"],
"glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
"glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
"glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
Expand All @@ -1526,6 +1535,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
// the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
modelReasoningEffortMap: {
"kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP,
// 1.3 has no `max` rung: Codex default-max callers resolve to `xhigh`
// instead of taking a gateway 400.
"muse-spark-1.3-contributor": {
"none": "none",
"minimal": "minimal",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "xhigh",
"max": "xhigh",
},
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
},
Expand Down
87 changes: 87 additions & 0 deletions tests/muse-spark-web-search-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,92 @@ describe("#2617 Muse Spark web_search compatibility", () => {
expect(defaults["muse-spark-1.2-contributor"]).toBe("openai-responses");
// An exact-model allowlist, not a family rule: a sibling must not be dragged along.
expect(defaults["muse-spark-1.2"]).toBeUndefined();
// 1.3 serves the same Responses-only shape on Zen Go (probed: /chat/completions -> 500).
expect(defaults["muse-spark-1.3-contributor"]).toBe("openai-responses");
});

test("1.3 gets the same web_search strip (probed: 1.3 + search_content_types -> 400)", () => {
const body = build("muse-spark-1.3-contributor", { tools: [webSearchTool()] });
const tool = toolsOf(body)[0]!;
expect(tool.type).toBe("web_search");
expect(Object.hasOwn(tool, "search_content_types")).toBe(false);
});
});

/**
* Zen Go rejects function names longer than 64 chars and recursive JSON schemas
* (probed live: `name must be at most 64 characters, got 66` from Codex MCP tools
* such as `muse-spark-web-search-compat`, and `Recursive JSON schemas are not
* currently supported` from cyclic `$defs`). Dropping only the offending
* declarations lets the turn proceed with the remaining catalog instead of
* failing the whole request with a 400.
*/
describe("Muse Spark tool-surface compatibility", () => {
const functionTool = (name: string, parameters: Record<string, unknown> = { type: "object" }) => ({
type: "function",
name,
parameters,
});

test("drops function tools with names longer than 64 chars, keeps a 64-char name", () => {
const body = build("muse-spark-1.3-contributor", {
tools: [functionTool("a".repeat(65)), functionTool("b".repeat(64))],
});
const names = toolsOf(body).map(tool => tool.name);
expect(names).toEqual(["b".repeat(64)]);
});

test("a nested additional_tools declaration is filtered too", () => {
const body = build("muse-spark-1.3-contributor", {
input: [{ type: "additional_tools", tools: [functionTool("c".repeat(66))] }],
});
const item = (body.input as Array<Record<string, unknown>>)[0]!;
expect((item.tools as unknown[])).toEqual([]);
});

test("tool_choice naming a dropped tool falls back to auto", () => {
const longName = "d".repeat(65);
const body = build("muse-spark-1.3-contributor", {
tools: [functionTool(longName)],
tool_choice: { type: "function", name: longName },
});
expect(body.tool_choice).toBe("auto");
});

test("another model on the same provider keeps over-long names untouched", () => {
const body = build("gpt-5.6-luna", { tools: [functionTool("e".repeat(65))] });
expect(toolsOf(body).map(tool => tool.name)).toEqual(["e".repeat(65)]);
});

const cyclicParameters = () => ({
type: "object",
properties: { q: { $ref: "#/$defs/q" } },
$defs: { q: { type: "object", properties: { sub: { $ref: "#/$defs/q" } } } },
});

test("drops function tools with cyclic local $refs", () => {
const body = build("muse-spark-1.3-contributor", {
tools: [functionTool("cyclic_tool", cyclicParameters()), functionTool("fine_tool")],
});
expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]);
});

test("keeps diamond $refs that share one $defs entry without cycling", () => {
const diamond = {
type: "object",
properties: { a: { $ref: "#/$defs/x" }, b: { $ref: "#/$defs/x" } },
$defs: { x: { type: "string" } },
};
const body = build("muse-spark-1.3-contributor", {
tools: [functionTool("diamond_tool", diamond)],
});
expect(toolsOf(body).map(tool => tool.name)).toEqual(["diamond_tool"]);
});

test("another model on the same provider keeps cyclic schemas untouched", () => {
const body = build("gpt-5.6-luna", {
tools: [functionTool("cyclic_tool", cyclicParameters())],
});
expect(toolsOf(body).map(tool => tool.name)).toEqual(["cyclic_tool"]);
});
});