From 822aa958d19b535f8b02c19bb4e8aa4cf0cdd533 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 19 Sep 2026 10:10:43 +0700 Subject: [PATCH] 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. --- open-sse/executors/opencode.js | 72 +----- open-sse/handlers/chatCore.js | 7 +- .../handlers/chatCore/nonStreamingHandler.js | 3 +- .../handlers/chatCore/sseToJsonHandler.js | 9 +- open-sse/translator/index.js | 8 +- open-sse/utils/opencodeFingerprint.js | 232 ++++++++++++++++++ tests/unit/opencode-fingerprint.test.js | 191 ++++++++++++++ tests/unit/opencode-session.test.js | 87 ++++--- 8 files changed, 506 insertions(+), 103 deletions(-) create mode 100644 open-sse/utils/opencodeFingerprint.js create mode 100644 tests/unit/opencode-fingerprint.test.js diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 507c3718..4623dcf6 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -6,6 +6,7 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { isMuseSparkModel } from "../providers/models/helpers.js"; +import { applyFingerprintTools } from "../utils/opencodeFingerprint.js"; import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; import { normalizeResponsesInput, @@ -24,68 +25,6 @@ export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/; const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; -// OpenCode free tier requires both 'bash' and 'read' in tools payload. -// Injected as cloaked decoy tools so external CLI tools (e.g. Claude Code's Bash/Read) -// take precedence while satisfying upstream verification. -const OPENCODE_DECOY_CHAT_TOOLS = [ - { - type: "function", - function: { - name: "bash", - description: "This tool is currently unavailable and must not be used.", - parameters: { type: "object", properties: {} }, - }, - }, - { - type: "function", - function: { - name: "read", - description: "This tool is currently unavailable and must not be used.", - parameters: { type: "object", properties: {} }, - }, - }, -]; - -const OPENCODE_DECOY_RESPONSES_TOOLS = [ - { - type: "function", - name: "bash", - description: "This tool is currently unavailable and must not be used.", - parameters: { type: "object", properties: {} }, - }, - { - type: "function", - name: "read", - description: "This tool is currently unavailable and must not be used.", - parameters: { type: "object", properties: {} }, - }, -]; - -function cloakOpencodeTools(body, isResponses) { - if (!body || typeof body !== "object") return; - if (isResponses) { - if (!Array.isArray(body.tools)) body.tools = []; - const names = new Set(body.tools.map((t) => t.name || t.function?.name)); - for (const tool of OPENCODE_DECOY_RESPONSES_TOOLS) { - if (!names.has(tool.name)) body.tools.push({ ...tool }); - } - if (!body.tool_choice) body.tool_choice = "auto"; - } else { - const hasTools = Array.isArray(body.tools) && body.tools.length > 0; - if (!hasTools) { - body.tools = OPENCODE_DECOY_CHAT_TOOLS.map((t) => ({ ...t, function: { ...t.function } })); - if (!body.tool_choice) body.tool_choice = "none"; - } else { - const names = new Set(body.tools.map((t) => t.function?.name || t.name)); - for (const tool of OPENCODE_DECOY_CHAT_TOOLS) { - if (!names.has(tool.function.name)) { - body.tools.push({ ...tool, function: { ...tool.function } }); - } - } - } - } -} - function hasValidOpencodeVersion(ua) { const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i); if (!m) return false; @@ -499,11 +438,12 @@ export class OpenCodeExecutor extends BaseExecutor { body.store = false; normalizeResponsesTools(body); sanitizeResponsesItems(body); - if (!Array.isArray(body.tools) || body.tools.length === 0) { - cloakOpencodeTools(body, true); - } + // Free-tier fingerprint tools are required even when an agent client + // already supplied tools. ZCode/Claude Code requests normally have + // non-empty tool arrays; skipping cloaking here triggers 403 FreeTierError. + applyFingerprintTools(body, true); } else if (body && typeof body === "object") { - cloakOpencodeTools(body, false); + applyFingerprintTools(body, false); } return injectReasoningContent({ provider: this.provider, model, body }); } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 85c6fc4a..56a9d69f 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -20,6 +20,7 @@ import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js"; import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; import { dedupeTools } from "../utils/toolDeduper.js"; +import { takeRenamedToolNames } from "../utils/opencodeFingerprint.js"; import { injectCaveman } from "../rtk/caveman.js"; import { injectPonytail } from "../rtk/ponytail.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; @@ -376,6 +377,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred providerHeaders = result.headers; finalBody = result.transformedBody; providerResponseFormat = result.responseFormat || targetFormat; + const renamedToolNames = takeRenamedToolNames(translatedBody); + if (renamedToolNames?.size) { + toolNameMap = new Map([...(toolNameMap || []), ...renamedToolNames]); + } reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); @@ -480,7 +485,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Provider forced streaming but client wants JSON if (!clientRequestedStreaming && providerRequiresStreaming) { - const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog }); + const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, toolNameMap, trackDone, appendLog }); if (result) { streamController.handleComplete(); return result; } } diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index d7f4f3fe..19018147 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -10,6 +10,7 @@ import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js"; import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js"; +import { restoreToolNames } from "../../utils/opencodeFingerprint.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; function parseToolArguments(value) { @@ -397,7 +398,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m return { success: true, - response: new Response(JSON.stringify(translatedResponse), { + response: new Response(JSON.stringify(restoreToolNames(translatedResponse, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 6801be89..cc7c9aac 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -1,4 +1,5 @@ import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js"; +import { restoreToolNames } from "../../utils/opencodeFingerprint.js"; import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { FORMATS } from "../../translator/formats.js"; @@ -179,7 +180,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { * Handle case: provider forced streaming but client wants JSON. * Supports both Codex/Responses API SSE and standard Chat Completions SSE. */ -export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, trackDone, appendLog, reqTag, log }) { +export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, toolNameMap, trackDone, appendLog, reqTag, log }) { const contentType = providerResponse.headers.get("content-type") || ""; const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider)); if (!isSSE) return null; // not handled here @@ -225,7 +226,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta // Client is Responses API → return as-is if (sourceFormat === FORMATS.OPENAI_RESPONSES) { - return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + return { success: true, response: new Response(JSON.stringify(restoreToolNames(jsonResponse, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; } // Build client-format response. @@ -281,7 +282,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta }; } - return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalResp, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; } catch (err) { console.error("[ChatCore] Responses API SSE→JSON failed:", err); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); @@ -351,7 +352,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta ? chatCompletionToResponses(parsed, customToolNames) : parsed; - return { success: true, response: new Response(JSON.stringify(finalBody), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; } catch (err) { console.error("[ChatCore] Chat Completions SSE→JSON failed:", err); return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 2fcbcbf8..8a1add5f 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -2,6 +2,7 @@ import { FORMATS } from "./formats.js"; import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js"; import { prepareClaudeRequest } from "./formats/claude.js"; import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js"; +import { restoreToolNames } from "../utils/opencodeFingerprint.js"; import { filterToOpenAIFormat } from "./formats/openai.js"; import { normalizeThinkingConfig } from "../services/provider.js"; import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js"; @@ -166,7 +167,7 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { // even when no format conversion is needed, so streamed tool_use blocks must // be decloaked here or the client sees an unknown ("_ide"-suffixed) tool. if (sourceFormat === targetFormat) { - return [decloakStreamChunk(chunk, state?.toolNameMap)]; + return [restoreToolNames(decloakStreamChunk(chunk, state?.toolNameMap), state?.toolNameMap)]; } let results = [chunk]; @@ -179,7 +180,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`); if (directFn) { const converted = directFn(chunk, state); - return converted ? (Array.isArray(converted) ? converted : [converted]) : []; + const directResults = converted ? (Array.isArray(converted) ? converted : [converted]) : []; + return restoreToolNames(directResults, state?.toolNameMap); } // Step 1: target -> openai (if target is not openai) @@ -210,6 +212,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { } } + results = restoreToolNames(results, state?.toolNameMap); + // Attach OpenAI intermediate results for logging if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) { results._openaiIntermediate = openaiResults; diff --git a/open-sse/utils/opencodeFingerprint.js b/open-sse/utils/opencodeFingerprint.js new file mode 100644 index 00000000..db7ba03a --- /dev/null +++ b/open-sse/utils/opencodeFingerprint.js @@ -0,0 +1,232 @@ +/** + * Helpers for the OpenCode Zen free-tier client fingerprint. + * + * Live upstream probes show that free-tier requests must include the lowercase + * file-search quartet (bash/glob/grep/read). Agent clients such as Claude Code + * may declare the same tools with different casing, so those case variants must + * be renamed instead of duplicated. The response side restores the caller's + * original spelling so downstream clients still recognise their own tool calls. + */ + +/** Canonical names required by the upstream free-tier gate. */ +export const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"]; + +// Request body -> names renamed for that request. transformRequest() mutates the +// same body object that chatCore passed into the executor, so a WeakMap keeps the +// mapping request-local without putting transport metadata on the wire. +const renamedToolNames = new WeakMap(); + +/** Canonical lowercase name when `name` is a quartet member; "" otherwise. */ +export function fingerprintToolKey(name) { + const lower = String(name ?? "").trim().toLowerCase(); + return OPENCODE_FINGERPRINT_TOOLS.includes(lower) ? lower : ""; +} + +/** Read a tool name from either flat ({name}) or chat ({function:{name}}) shape. */ +function toolNameOf(tool) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return ""; + if (typeof tool.name === "string" && tool.name.trim()) return tool.name.trim(); + const fn = tool.function; + if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") { + return fn.name.trim(); + } + return ""; +} + +/** + * Canonicalise only the fingerprint quartet and remove duplicate quartet + * variants. Non-fingerprint tools are preserved verbatim, including tools whose + * names differ only by case; they are outside OpenCode's fingerprint contract. + * + * @param {Array} tools + * @returns {{ tools: Array, map: Map }} map: sent name -> original name + */ +export function concealFingerprintToolNames(tools) { + const map = new Map(); + if (!Array.isArray(tools) || tools.length === 0) return { tools, map }; + + const seenQuartet = new Set(); + const out = []; + for (const tool of tools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + out.push(tool); + continue; + } + + const current = toolNameOf(tool); + const key = fingerprintToolKey(current); + if (!key) { + out.push(tool); + continue; + } + + // `Bash` + `bash` is rejected upstream as a duplicate. Keep exactly one + // declaration for each quartet member. + if (seenQuartet.has(key)) continue; + seenQuartet.add(key); + + if (current !== key) { + map.set(key, current); + const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) + ? tool.function + : null; + out.push(fn ? { ...tool, function: { ...fn, name: key } } : { ...tool, name: key }); + } else { + out.push(tool); + } + } + return { tools: out, map }; +} + +/** Append only genuinely missing quartet declarations. */ +export function appendMissingFingerprintTools(tools, flat) { + const list = Array.isArray(tools) ? tools : []; + for (const name of OPENCODE_FINGERPRINT_TOOLS) { + if (list.some((tool) => fingerprintToolKey(toolNameOf(tool)) === name)) continue; + list.push(flat ? { + type: "function", + name, + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + } : { + type: "function", + function: { + name, + description: "This tool is currently unavailable and must not be used.", + parameters: { type: "object", properties: {} }, + }, + }); + } + return list; +} + +/** Point an explicit tool_choice at a quartet member after canonicalisation. */ +export function retargetToolChoice(body, map) { + if (!body || typeof body !== "object" || !map?.size) return; + const choice = body.tool_choice; + if (!choice || typeof choice !== "object" || Array.isArray(choice)) return; + + if (typeof choice.name === "string") { + const key = fingerprintToolKey(choice.name); + if (key && map.has(key)) body.tool_choice = { ...choice, name: key }; + return; + } + + const fn = choice.function; + if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") { + const key = fingerprintToolKey(fn.name); + if (key && map.has(key)) { + body.tool_choice = { ...choice, function: { ...fn, name: key } }; + } + } +} + +/** + * Full request-side pass: canonicalise quartet case variants, remove duplicate + * quartet declarations, append missing members and preserve the legacy + * tool_choice defaults used by the OpenCode executor. + * + * @param {object} body + * @param {boolean} flat - true for Responses tools ({name}), false for chat tools + * @returns {Map} map: sent name -> original name + */ +export function applyFingerprintTools(body, flat) { + if (!body || typeof body !== "object") return new Map(); + + const hadClientTools = Array.isArray(body.tools) && body.tools.length > 0; + const { tools, map } = concealFingerprintToolNames(body.tools); + body.tools = appendMissingFingerprintTools(tools, flat); + retargetToolChoice(body, map); + + // Preserve the existing executor semantics. Responses uses auto when the + // fingerprint helper supplies tools; chat requests with no caller tools use + // none so the injected decoys cannot be selected. + if (!body.tool_choice) { + if (flat) body.tool_choice = "auto"; + else if (!hadClientTools) body.tool_choice = "none"; + } + + recordRenamedToolNames(body, map); + return map; +} + +/** Store the rename map for `body`. */ +export function recordRenamedToolNames(body, map) { + if (!body || typeof body !== "object" || !map?.size) return; + renamedToolNames.set(body, map); +} + +/** Retrieve the rename map for `body`. */ +export function takeRenamedToolNames(body) { + if (!body || typeof body !== "object") return null; + return renamedToolNames.get(body) || null; +} + +// Response side ------------------------------------------------------------- + +/** Restore caller tool spellings in supported response/event shapes. */ +export function restoreToolNames(payload, map) { + if (!map?.size || !payload) return payload; + if (Array.isArray(payload)) return payload.map((item) => restoreToolNames(item, map)); + if (typeof payload !== "object") return payload; + + let out = payload; + const put = (key, value) => { + if (out === payload) out = { ...payload }; + out[key] = value; + }; + + // Claude streaming content_block_start event. + if (payload.type === "content_block_start") { + const block = payload.content_block; + if (block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)) { + put("content_block", { ...block, name: map.get(block.name) }); + } + } + + // Claude non-streaming message body. + if (Array.isArray(payload.content)) { + put("content", payload.content.map((block) => + block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name) + ? { ...block, name: map.get(block.name) } + : block)); + } + + // OpenAI Chat Completions, both streaming delta and JSON message shapes. + if (Array.isArray(payload.choices)) { + put("choices", payload.choices.map((choice) => { + let changed = false; + const next = { ...choice }; + for (const holder of ["delta", "message"]) { + const value = choice?.[holder]; + if (!value || !Array.isArray(value.tool_calls) || value.tool_calls.length === 0) continue; + const calls = value.tool_calls.map((call) => { + const name = call?.function?.name; + if (typeof name === "string" && map.has(name)) { + changed = true; + return { ...call, function: { ...call.function, name: map.get(name) } }; + } + return call; + }); + next[holder] = { ...value, tool_calls: calls }; + } + return changed ? next : choice; + })); + } + + // OpenAI Responses final JSON body. + if (Array.isArray(payload.output)) { + put("output", payload.output.map((item) => + item?.type === "function_call" && typeof item.name === "string" && map.has(item.name) + ? { ...item, name: map.get(item.name) } + : item)); + } + + // OpenAI Responses SSE events such as response.output_item.added/done. + const item = payload.item; + if (item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)) { + put("item", { ...item, name: map.get(item.name) }); + } + + return out; +} diff --git a/tests/unit/opencode-fingerprint.test.js b/tests/unit/opencode-fingerprint.test.js new file mode 100644 index 00000000..35036699 --- /dev/null +++ b/tests/unit/opencode-fingerprint.test.js @@ -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(""); + }); +}); diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js index 3e50b9fe..e8161f7e 100644 --- a/tests/unit/opencode-session.test.js +++ b/tests/unit/opencode-session.test.js @@ -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 () => {