fix(opencode): cloak Responses requests that already have tools

Free-tier Zen models reject Responses requests with 403 FreeTierError
when client tools are present but the fingerprint quartet is missing.
Apply the fingerprint tools to every OpenCode request, canonicalise
case variants of the quartet (Bash->bash) without duplication, and
restore the caller's original spellings on the response side via a
request-local WeakMap threaded through the existing toolNameMap.
This commit is contained in:
Aaron
2026-09-19 10:10:43 +07:00
committed by decolua
parent 49185137b8
commit 822aa958d1
8 changed files with 506 additions and 103 deletions

View File

@@ -0,0 +1,191 @@
import { describe, it, expect } from "vitest";
import {
applyFingerprintTools,
concealFingerprintToolNames,
appendMissingFingerprintTools,
fingerprintToolKey,
restoreToolNames,
takeRenamedToolNames,
OPENCODE_FINGERPRINT_TOOLS,
} from "open-sse/utils/opencodeFingerprint.js";
const CC_TOOLS = ["Task", "Bash", "Glob", "Grep", "Read", "Edit", "Write", "WebFetch"];
const flat = (names) => names.map((name) => ({ type: "function", name }));
const chat = (names) => names.map((name) => ({ type: "function", function: { name } }));
describe("opencodeFingerprint — request side", () => {
it("renames capitalised quartet members to lowercase", () => {
const body = { tools: flat(CC_TOOLS) };
const map = applyFingerprintTools(body, true);
const names = body.tools.map((tool) => tool.name);
expect(names).toContain("bash");
expect(names).not.toContain("Bash");
expect(names).toContain("Edit");
expect(map.get("bash")).toBe("Bash");
});
it("removes quartet case duplicates without dropping unrelated case variants", () => {
const body = { tools: flat(["Bash", "bash", "Glob", "grep", "Read", "Foo", "foo"]) };
applyFingerprintTools(body, true);
const names = body.tools.map((tool) => tool.name);
expect(names.filter((name) => name === "bash")).toHaveLength(1);
expect(names).toContain("Foo");
expect(names).toContain("foo");
});
it("preserves tool count when a complete quartet is only renamed", () => {
const body = { tools: flat(CC_TOOLS) };
applyFingerprintTools(body, true);
expect(body.tools).toHaveLength(CC_TOOLS.length);
});
it("handles the nested chat shape without dropping .function", () => {
const body = { tools: chat(CC_TOOLS) };
applyFingerprintTools(body, false);
const names = body.tools.map((tool) => tool.function.name);
expect(names).toContain("bash");
expect(names).not.toContain("Bash");
expect(body.tools[1].function.name).toBe("bash");
});
it("injects all four fingerprint tools when the body carries no tools", () => {
const body = { tools: [] };
applyFingerprintTools(body, true);
expect(body.tools.map((tool) => tool.name).sort()).toEqual([...OPENCODE_FINGERPRINT_TOOLS].sort());
expect(body.tool_choice).toBe("auto");
});
it("preserves the chat no-tool default tool_choice=none", () => {
const body = {};
applyFingerprintTools(body, false);
expect(body.tools.map((tool) => tool.function.name)).toEqual(OPENCODE_FINGERPRINT_TOOLS);
expect(body.tool_choice).toBe("none");
});
it("does not invent a chat tool_choice when the caller already supplied tools", () => {
const body = { tools: chat(["Edit"]) };
applyFingerprintTools(body, false);
expect(body.tool_choice).toBeUndefined();
});
it("appends only genuinely missing quartet members", () => {
const body = { tools: flat(["Bash", "Read", "terminal"]) };
applyFingerprintTools(body, true);
const names = body.tools.map((tool) => tool.name);
expect(names).toContain("glob");
expect(names).toContain("grep");
expect(names).toContain("terminal");
expect(body.tools).toHaveLength(5);
});
it("retargets flat tool_choice that points at a renamed tool", () => {
const body = { tools: flat(CC_TOOLS), tool_choice: { type: "function", name: "Bash" } };
applyFingerprintTools(body, true);
expect(body.tool_choice.name).toBe("bash");
});
it("retargets nested tool_choice that points at a renamed tool", () => {
const body = {
tools: chat(CC_TOOLS),
tool_choice: { type: "function", function: { name: "Read" } },
};
applyFingerprintTools(body, false);
expect(body.tool_choice.function.name).toBe("read");
});
it("records the rename map against the body for the response side", () => {
const body = { tools: flat(CC_TOOLS) };
const map = applyFingerprintTools(body, true);
expect(takeRenamedToolNames(body)).toBe(map);
});
it("never throws on malformed tools", () => {
for (const tools of [null, undefined, "nope", [null, 42, []], [{}, { name: "" }]]) {
expect(() => concealFingerprintToolNames(tools)).not.toThrow();
expect(() => appendMissingFingerprintTools(tools, true)).not.toThrow();
}
});
});
describe("opencodeFingerprint — response side", () => {
const map = new Map([["bash", "Bash"], ["grep", "Grep"], ["read", "Read"]]);
it("restores names in Claude content_block_start chunks", () => {
const chunk = {
type: "content_block_start",
content_block: { type: "tool_use", name: "bash", id: "t1" },
};
const out = restoreToolNames(chunk, map);
expect(out.content_block.name).toBe("Bash");
expect(chunk.content_block.name).toBe("bash");
});
it("recursively restores streaming chunks inside arrays", () => {
const chunks = [{
choices: [{ delta: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } }],
}];
const out = restoreToolNames(chunks, map);
expect(out[0].choices[0].delta.tool_calls[0].function.name).toBe("Grep");
});
it("restores names in Claude non-streaming bodies", () => {
const body = { type: "message", content: [{ type: "tool_use", name: "bash", input: {} }] };
expect(restoreToolNames(body, map).content[0].name).toBe("Bash");
});
it("restores names in Chat Completions message and delta shapes", () => {
const body = {
choices: [
{ message: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } },
{ delta: { tool_calls: [{ function: { name: "read", arguments: "{}" } }] } },
],
};
const out = restoreToolNames(body, map);
expect(out.choices[0].message.tool_calls[0].function.name).toBe("Grep");
expect(out.choices[1].delta.tool_calls[0].function.name).toBe("Read");
});
it("restores names in Responses final output items", () => {
const body = { output: [{ type: "function_call", name: "bash", call_id: "c1" }] };
expect(restoreToolNames(body, map).output[0].name).toBe("Bash");
});
it("restores names in Responses streaming output_item events", () => {
const event = {
type: "response.output_item.added",
item: { type: "function_call", name: "read", call_id: "c1" },
};
expect(restoreToolNames(event, map).item.name).toBe("Read");
});
it("is a no-op without a map or with an empty map", () => {
const body = { choices: [{ message: { tool_calls: [{ function: { name: "bash" } }] } }] };
expect(restoreToolNames(body, null)).toBe(body);
expect(restoreToolNames(body, new Map())).toBe(body);
});
it("leaves unknown tool names untouched", () => {
const body = { output: [{ type: "function_call", name: "Edit" }] };
expect(restoreToolNames(body, map).output[0].name).toBe("Edit");
});
});
describe("fingerprintToolKey", () => {
it("maps quartet case/whitespace variants and rejects other tools", () => {
expect(fingerprintToolKey("Bash")).toBe("bash");
expect(fingerprintToolKey(" bash ")).toBe("bash");
expect(fingerprintToolKey("GLOB")).toBe("glob");
expect(fingerprintToolKey("Read")).toBe("read");
expect(fingerprintToolKey("Edit")).toBe("");
expect(fingerprintToolKey("terminal")).toBe("");
expect(fingerprintToolKey(null)).toBe("");
});
});

View File

@@ -277,39 +277,68 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => {
expect(second).toBe(first);
});
it("cloaks free-tier requests with bash and read decoy tools", () => {
it("applies the full lowercase free-tier fingerprint quartet", () => {
const executor = getExecutor("opencode");
const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", {
messages: [{ role: "user", content: "hi" }],
});
expect(chatNoTools.stream).toBe(true);
expect(chatNoTools.tool_choice).toBe("none");
expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual([
"bash", "glob", "grep", "read",
]);
const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", {
messages: [{ role: "user", content: "hi" }],
tools: [
{ type: "function", function: { name: "Bash", description: "Claude Code tool" } },
{ type: "function", function: { name: "Glob", description: "Claude Code tool" } },
{ type: "function", function: { name: "Grep", description: "Claude Code tool" } },
{ type: "function", function: { name: "Read", description: "Claude Code tool" } },
],
tool_choice: "auto",
});
expect(chatWithTools.tool_choice).toBe("auto");
expect(chatWithTools.tools.map((t) => t.function?.name)).toEqual([
"bash", "glob", "grep", "read",
]);
const chatPartial = executor.transformRequest("nemotron-3-ultra-free", {
messages: [{ role: "user", content: "hi" }],
tools: [
{ type: "function", function: { name: "bash", description: "existing" } },
{ type: "function", function: { name: "read", description: "existing" } },
],
});
expect(chatPartial.tools.map((t) => t.function?.name)).toEqual([
"bash", "read", "glob", "grep",
]);
expect(chatPartial.tools[0].function.description).toBe("existing");
});
it("cloaks Muse Responses requests even when the client already supplies tools", () => {
const executor = getExecutor("opencode");
// Case 1: no tools sent by client -> injects bash + read with tool_choice none
const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", {
messages: [{ role: "user", content: "hi" }],
});
expect(chatNoTools.stream).toBe(true);
expect(chatNoTools.tool_choice).toBe("none");
expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual(["bash", "read"]);
// Case 2: external CLI tools (e.g. Claude Code Bash) -> preserves Bash, appends read
const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", {
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "Bash", description: "Claude Code tool" } }],
const transformed = executor.transformRequest("muse-spark-1.3-contributor-free(xhigh)", {
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
tools: [{
type: "function",
name: "zcode_search",
description: "client-provided tool",
parameters: { type: "object", properties: {} },
}],
tool_choice: "auto",
});
expect(chatWithTools.tool_choice).toBe("auto");
const names = chatWithTools.tools.map((t) => t.function?.name);
expect(names).toContain("Bash");
reasoning_effort: "xhigh",
}, true, {});
expect(transformed.stream).toBe(true);
expect(transformed.reasoning?.effort).toBe("xhigh");
const names = transformed.tools.map((tool) => tool.name);
expect(names).toContain("zcode_search");
expect(names).toContain("bash");
expect(names).toContain("read");
// Case 3: already has both bash and read -> do not insert anything
const chatFull = executor.transformRequest("nemotron-3-ultra-free", {
messages: [{ role: "user", content: "hi" }],
tools: [
{ type: "function", function: { name: "bash", description: "existing" } },
{ type: "function", function: { name: "read", description: "existing" } },
],
});
expect(chatFull.tools.length).toBe(2);
expect(chatFull.tools[0].function.description).toBe("existing");
expect(names.filter((name) => name === "bash")).toHaveLength(1);
expect(names.filter((name) => name === "read")).toHaveLength(1);
});
it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => {