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:
232
open-sse/utils/opencodeFingerprint.js
Normal file
232
open-sse/utils/opencodeFingerprint.js
Normal file
@@ -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<string,string> }} 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<string,string>} 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;
|
||||
}
|
||||
Reference in New Issue
Block a user