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:
191
tests/unit/opencode-fingerprint.test.js
Normal file
191
tests/unit/opencode-fingerprint.test.js
Normal 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("");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user