diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 548c31908d7..2d4e8a0f815 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,7 @@ Environment variables actively used by code: 2. `/api/v1/route.js` returns a static model list and is not the main models source used by `/v1/models`. 3. Request logger writes full headers/body when enabled; treat log directory as sensitive. 4. Cloud behavior depends on correct `NEXT_PUBLIC_BASE_URL` and cloud endpoint reachability. +5. The OpenCode free-tier fingerprint in `open-sse/utils/opencodeFingerprint.js` rewrites `tools` before the upstream call: a caller tool that already uses a fingerprint name (`read`/`shell`) adopts OpenCode's definition when its parameters can carry them, otherwise it is aliased and restored on the response side. The definitions are a frozen capture of the OpenCode CLI, so `shell`'s description embeds the capture platform (`Linux`/`bash`) and is rewritten for clients on any other platform. ## Operational Verification Checklist diff --git a/open-sse/utils/opencodeFingerprint.js b/open-sse/utils/opencodeFingerprint.js index 184bbe0542c..99b829100f7 100644 --- a/open-sse/utils/opencodeFingerprint.js +++ b/open-sse/utils/opencodeFingerprint.js @@ -5,9 +5,10 @@ * requests must declare OpenCode's own `read` and `shell` tools with their exact * descriptions and parameter schemas; stubbed descriptions, renamed schemas or * tool_choice "none" are answered with 403 FreeTierError. Caller tools are sent - * untouched, except one that already uses an exact fingerprint name with a - * different definition: it travels under an alias and the response side - * restores the caller's original name. + * untouched. A tool that already uses an exact fingerprint name with a + * different definition adopts OpenCode's definition in place whenever its + * arguments can carry them; only a genuinely different schema travels under an + * alias, and the response side restores that caller's original name. */ /** Names required by the upstream free-tier gate. */ @@ -86,10 +87,46 @@ function withToolName(tool, name) { } /** - * Move caller tools off the fingerprint names when their definition differs - * from OpenCode's, so the exact definitions can be appended without a - * duplicate name. Everything else, including case variants such as `Read`, is - * preserved verbatim; upstream accepts them alongside `read`. + * True when `tool` already speaks OpenCode's parameter names for `key`, so the + * fingerprint definition can replace its own without hiding or adding an + * argument the caller's handler expects. + * + * This is the common case, not an edge: OpenCode composes `shell`'s description + * from `process.platform`, so the very same tool run on Windows reports + * "Commands run on Windows using pwsh." while the captured definition says + * "Commands run on Linux using bash." — a two-word difference that an exact + * string compare rejects. + */ +function canNormalizeInPlace(tool, key) { + const def = tool && (chatFunctionOf(tool) || tool); + const props = def?.parameters?.properties; + if (!props || typeof props !== "object" || Array.isArray(props)) return false; + + const wanted = Object.keys(FINGERPRINT_TOOL_DEFINITIONS[key].parameters.properties || {}); + const declared = Object.keys(props); + if (declared.some((name) => !wanted.includes(name))) return false; + return FINGERPRINT_TOOL_DEFINITIONS[key].parameters.required.every((name) => declared.includes(name)); +} + +/** + * Replace `tool`'s definition with OpenCode's while keeping its name, so the + * wire carries a single, gate-satisfying tool instead of a caller tool plus a + * placeholder of the same purpose. + */ +function withFingerprintDefinition(tool, key) { + const { description, parameters } = FINGERPRINT_TOOL_DEFINITIONS[key]; + const fn = chatFunctionOf(tool); + return fn + ? { ...tool, function: { ...fn, description, parameters } } + : { ...tool, description, parameters }; +} + +/** + * Keep caller tools on the fingerprint names whenever their arguments can carry + * OpenCode's, and move the remaining ones off them so the exact definitions can + * be appended without a duplicate name. Everything else, including case + * variants such as `Read`, is preserved verbatim; upstream accepts them + * alongside `read`. * * @param {Array} tools * @returns {{ tools: Array, map: Map }} map: sent name -> original name @@ -122,6 +159,14 @@ export function concealFingerprintToolNames(tools) { continue; } + // Same tool, different wording. Adopt OpenCode's definition in place: the + // caller keeps its own name, so there is no alias to restore, no alias name + // for the model to collide with, and no placeholder to explain away. + if (canNormalizeInPlace(tool, key)) { + out.push(withFingerprintDefinition(tool, key)); + continue; + } + let alias = `${key}_client`; for (let i = 2; taken.has(alias); i++) alias = `${key}_client${i}`; taken.add(alias); @@ -231,8 +276,9 @@ function noteUnavailableTools(body, flat, names) { } /** - * Full request-side pass: alias conflicting caller tools, append OpenCode's - * fingerprint definitions, and tell the model the appended ones are off-limits. + * Full request-side pass: normalize caller tools that collide with a + * fingerprint name, append OpenCode's definitions for any that are missing, and + * tell the model the appended ones are off-limits. * * @param {object} body * @param {boolean} flat - true for Responses tools ({name}), false for chat tools diff --git a/tests/unit/opencode-fingerprint.test.js b/tests/unit/opencode-fingerprint.test.js index 826b93af916..cb16f854375 100644 --- a/tests/unit/opencode-fingerprint.test.js +++ b/tests/unit/opencode-fingerprint.test.js @@ -121,6 +121,130 @@ describe("opencodeFingerprint — request side", () => { }); }); +describe("opencodeFingerprint — in-place normalization", () => { + // OpenCode builds shell's description from process.platform, so the very same + // tool on Windows or macOS never matches the captured Linux definition. + const shellTool = (os, shell, name = "shell") => ({ + type: "function", + name, + description: + "Execute a shell command and return its output. Commands run on " + os + " using " + shell + ". Quote file paths containing spaces or special characters. Prefer dedicated tools over shell commands when possible. When output is large, the full result is saved to a file and a truncated preview is returned. Rely on automatic truncation unless filtering the output is more useful. Commands accept an optional timeout, background commands have no timeout by default. Background commands return immediately, and you will be notified when they complete.", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Shell command string to execute" }, + workdir: { type: "string", description: "Working directory to execute the command in. Defaults to the current working directory. When possible, avoid changing directories in the command and set the working directory here instead." }, + timeout: { type: "integer", minimum: 0, description: "Timeout in milliseconds. Set to 0 to disable the timeout. Defaults to 120000 for foreground commands. Background commands have no timeout by default." }, + background: { type: "boolean", description: "Run the command in the background and return immediately (useful for dev servers and long-running builds). You do not need to use '&' at the end of the command when using this parameter. You will be notified when it completes. DO NOT poll for completion." }, + }, + required: ["command"], + additionalProperties: false, + }, + }); + + const asChat = (tool) => ({ + type: tool.type, + function: { name: tool.name, description: tool.description, parameters: tool.parameters }, + }); + const windowsShell = () => shellTool("Windows", "pwsh"); + + it("keeps the caller's name and adopts OpenCode's definition when arguments line up", () => { + const body = { tools: [windowsShell(), { type: "function", name: "Edit" }] }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["shell", "Edit", "read"]); + expect(map.size).toBe(0); + expect(takeRenamedToolNames(body)).toBeNull(); + expect(body.tools[0].description).toContain("Commands run on Linux using bash."); + expect(body.tools[0].parameters.required).toEqual(["command"]); + // Only the genuinely absent `read` is still advertised as a placeholder. + expect(body.instructions).toBe("Do not call the `read` tool; it is a placeholder that does nothing. Use the other tools you have been given instead."); + }); + + it("normalizes the nested chat shape without adding a system note", () => { + const body = { tools: [asChat(windowsShell())] }; + const map = applyFingerprintTools(body, false); + + expect(namesOf(body.tools)).toEqual(["shell", "read"]); + expect(map.size).toBe(0); + expect(body.messages).toBeUndefined(); + expect(body.tools[0].function.description).toContain("Commands run on Linux using bash."); + }); + + it("wires a real OpenCode client down to exactly read + shell", () => { + const seeded = { tools: [] }; + applyFingerprintTools(seeded, false); + const officialRead = seeded.tools.find((tool) => tool.function?.name === "read"); + + const body = { tools: [officialRead, asChat(windowsShell())] }; + const map = applyFingerprintTools(body, false); + + expect(namesOf(body.tools)).toEqual(["read", "shell"]); + expect(map.size).toBe(0); + expect(body.messages).toBeUndefined(); + }); + + it("no longer invents alias names that a client plugin may already own", () => { + const pluginTool = (name) => ({ + type: "function", + name, + description: "Execute a shell command and return its output.", + parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }, + }); + const body = { tools: [pluginTool("shell_client"), pluginTool("shell_client2"), windowsShell()] }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["shell_client", "shell_client2", "shell", "read"]); + expect(map.size).toBe(0); + }); + + it("still conceals a fingerprint name whose arguments do not line up", () => { + const body = { + tools: [{ + type: "function", + name: "shell", + description: "Run a command", + parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, + }], + }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["shell_client", "read", "shell"]); + expect(map.get("shell_client")).toBe("shell"); + expect(body.instructions).toContain("Do not call the"); + }); + + it("conceals when the caller declares an argument the fingerprint does not", () => { + const body = { + tools: [{ + type: "function", + name: "read", + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" }, encoding: { type: "string" } }, required: ["path"] }, + }], + }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["read_client", "read", "shell"]); + expect(map.get("read_client")).toBe("read"); + }); + + it("conceals when the caller is missing a required fingerprint argument", () => { + const body = { + tools: [{ + type: "function", + name: "read", + description: "Read a file", + parameters: { type: "object", properties: {}, required: [] }, + }], + }; + const map = applyFingerprintTools(body, true); + + expect(namesOf(body.tools)).toEqual(["read_client", "read", "shell"]); + expect(map.get("read_client")).toBe("read"); + }); +}); + describe("opencodeFingerprint — response side", () => { const map = new Map([["bash", "Bash"], ["grep", "Grep"], ["read", "Read"]]);