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 () => {