Repository navigation
fix(opencode-zen): align free-tier v2 fingerprint with PR #4860 #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
18f484d
7158dd6
fafc9e5
6552c2f
46daceb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto"; | |
| import { DefaultExecutor } from "./default.js"; | ||
| import { resolveSessionId } from "../utils/sessionManager.js"; | ||
| import { isMuseSparkModel } from "../providers/models/helpers.js"; | ||
| import { applyFingerprintTools } from "../utils/opencodeFingerprint.js"; | ||
| import { | ||
| normalizeResponsesInput, | ||
| clampResponsesCallId, | ||
|
|
@@ -15,19 +16,14 @@ const MAX_SESSION_LENGTH = 256; | |
|
|
||
| const RESPONSES_BASE_URL = "https://opencode.ai/zen/v1/responses"; | ||
| const MAX_TOOL_NAME_LEN = 128; | ||
| const OPENCODE_UA = "opencode/1.18.31"; | ||
| const OPENCODE_UA = "opencode/latest/2.0.29/cli"; | ||
| const OPENCODE_CLIENT = "cli"; | ||
| export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/; | ||
| const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; | ||
| // Free-tier fingerprint (mirrors opencode executor, PR #4132): upstream 403s | ||
| // requests without the file-search quartet and without stream:true. | ||
| const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"]; | ||
|
|
||
| function hasValidOpencodeVersion(ua) { | ||
| const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i); | ||
| if (!m) return false; | ||
| const major = parseInt(m[1], 10); | ||
| const minor = parseInt(m[2], 10); | ||
| return major > 1 || (major === 1 && minor >= 17); | ||
| const m = String(ua || "").match(/^opencode\/[a-z0-9-]+\/(\d+)\.\d+\.\d+\/[a-z0-9-]+$/i); | ||
| return !!m && parseInt(m[1], 10) >= 2; | ||
| } | ||
|
|
||
| function unstableRandom() { | ||
|
|
@@ -84,61 +80,6 @@ export function translateSessionId(sessionId, clientTool = "") { | |
| return `ses_${timeHex}${randomPart}`; | ||
| } | ||
|
|
||
| function toolNameOf(tool) { | ||
| if (!tool || typeof tool !== "object" || Array.isArray(tool)) return ""; | ||
| const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null; | ||
| const raw = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : ""); | ||
| return raw.trim(); | ||
| } | ||
|
|
||
| function ensureChatFingerprintTools(body) { | ||
| if (!body || typeof body !== "object") return; | ||
| const present = new Set(); | ||
| if (Array.isArray(body.tools)) { | ||
| for (const tool of body.tools) { | ||
| const name = toolNameOf(tool); | ||
| if (name) present.add(name); | ||
| } | ||
| } else { | ||
| body.tools = []; | ||
| } | ||
| for (const name of OPENCODE_FINGERPRINT_TOOLS) { | ||
| if (present.has(name)) continue; | ||
| body.tools.push({ | ||
| type: "function", | ||
| function: { | ||
| name, | ||
| description: `OpenCode built-in ${name} tool`, | ||
| parameters: { type: "object", properties: {} }, | ||
| }, | ||
| }); | ||
| present.add(name); | ||
| } | ||
| } | ||
|
|
||
| function ensureResponsesFingerprintTools(body) { | ||
| if (!body || typeof body !== "object") return; | ||
| const present = new Set(); | ||
| if (Array.isArray(body.tools)) { | ||
| for (const tool of body.tools) { | ||
| const name = toolNameOf(tool); | ||
| if (name) present.add(name); | ||
| } | ||
| } else { | ||
| body.tools = []; | ||
| } | ||
| for (const name of OPENCODE_FINGERPRINT_TOOLS) { | ||
| if (present.has(name)) continue; | ||
| body.tools.push({ | ||
| type: "function", | ||
| name, | ||
| description: `OpenCode built-in ${name} tool`, | ||
| parameters: { type: "object", properties: {} }, | ||
| }); | ||
| present.add(name); | ||
| } | ||
| } | ||
|
|
||
| function normalizeSession(value) { | ||
| if (typeof value !== "string") return null; | ||
| const normalized = value.trim(); | ||
|
|
@@ -273,17 +214,14 @@ export class OpenCodeZenExecutor extends DefaultExecutor { | |
| const downstreamUa = lower["user-agent"] || ""; | ||
| // Free-tier gate: spoof the official client UA. | ||
| headers["User-Agent"] = hasValidOpencodeVersion(downstreamUa) ? downstreamUa : OPENCODE_UA; | ||
| headers["x-opencode-client"] = lower["x-opencode-client"] || "desktop"; | ||
| const prepared = credentials?.[SESSION_FIELD]; | ||
| if (prepared) { | ||
| headers[SESSION_HEADER] = prepared; | ||
| headers["x-opencode-session-id"] = prepared; | ||
| return headers; | ||
| } | ||
|
|
||
| const fallback = this.prepareRequestCredentials({ credentials }); | ||
| headers[SESSION_HEADER] = fallback[SESSION_FIELD]; | ||
| headers["x-opencode-session-id"] = fallback[SESSION_FIELD]; | ||
| headers["x-opencode-client"] = lower["x-opencode-client"] || OPENCODE_CLIENT; | ||
| headers["x-opencode-project"] = lower["x-opencode-project"] || "global"; | ||
| const session = credentials?.[SESSION_FIELD] | ||
| || this.prepareRequestCredentials({ credentials })[SESSION_FIELD]; | ||
| headers[SESSION_HEADER] = session; | ||
| headers["x-opencode-session-id"] = session; | ||
| headers["x-session-affinity"] = session; | ||
| headers["x-session-id"] = session; | ||
| return headers; | ||
| } | ||
|
|
||
|
|
@@ -292,7 +230,7 @@ export class OpenCodeZenExecutor extends DefaultExecutor { | |
| // Free-tier gate: upstream 403s stream:false even when everything else is valid. | ||
| if (out && typeof out === "object") out.stream = true; | ||
| if (!isResponsesModel(model || body?.model)) { | ||
| ensureChatFingerprintTools(out); | ||
| if (out && typeof out === "object") applyFingerprintTools(out, false); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Zen chat request targets a Kimi or DeepSeek model and declares a caller tool named Useful? React with 👍 / 👎. |
||
| return out; | ||
| } | ||
| const normalized = normalizeResponsesInput(out.input); | ||
|
|
@@ -316,9 +254,9 @@ export class OpenCodeZenExecutor extends DefaultExecutor { | |
| delete out.reasoning_effort; | ||
| out.stream = true; | ||
| out.store = false; | ||
| ensureResponsesFingerprintTools(out); | ||
| normalizeResponsesTools(out); | ||
| sanitizeResponsesItems(out); | ||
| applyFingerprintTools(out, true); | ||
| return out; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -326,3 +326,43 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => { | |
| expect(PROVIDERS.opencode?.forceStream).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
| describe("OpenCode Zen (opencode-zen) v2 Free-Tier Fingerprint", () => { | ||
| it("sends the v2 CLI User-Agent, client header, and mirrored session headers", () => { | ||
| const executor = getExecutor("opencode-zen"); | ||
| const headers = executor.buildHeaders({ | ||
| rawHeaders: { "user-agent": "opencode/1.18.31" }, | ||
| }); | ||
| const session = headers["x-opencode-session"]; | ||
| expect(headers["User-Agent"]).toBe("opencode/latest/2.0.29/cli"); | ||
| expect(headers["x-opencode-client"]).toBe("cli"); | ||
| expect(headers["x-opencode-project"]).toBe("global"); | ||
| expect(session).toMatch(OPENCODE_SESSION_RE); | ||
| expect(headers["x-opencode-session-id"]).toBe(session); | ||
| expect(headers["x-session-affinity"]).toBe(session); | ||
| expect(headers["x-session-id"]).toBe(session); | ||
| }); | ||
|
|
||
| it("applies the verbatim read/shell fingerprint tools and aliases conflicts on opencode-zen", () => { | ||
| const executor = getExecutor("opencode-zen"); | ||
| const chat = executor.transformRequest("mimo-v2.6-flash-free", { | ||
| messages: [{ role: "user", content: "hi" }], | ||
| tools: [{ type: "function", function: { name: "read", description: "caller read" } }], | ||
| }); | ||
| expect(chat.stream).toBe(true); | ||
| expect(chat.tools.map((t) => t.function?.name)).toEqual(["read_client", "read", "shell"]); | ||
| expect(chat.tools[1].function.parameters.required).toEqual(["path"]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Broken tool schemas pass fingerprint test When an emitted Assert the complete expected canonical definitions for the appended Also at Prompt for AI agentsIn `tests/unit/opencode-session.test.js` at line 355:
**Broken tool schemas pass fingerprint test**
When an emitted `read` tool retains `required: ["path"]` but has an incorrect description or other schema fields, the Zen fingerprint test checks only that the appended `read` tool requires `path`; a wrong description or other incorrect schema fields still pass both endpoint checks, so the test reports broken fingerprints as verbatim.
Assert the complete expected canonical definitions for the appended `read` and `shell` tools on both endpoint paths.
Also at `tests/unit/opencode-session.test.js:365`. |
||
| expect(chat.messages[0].role).toBe("system"); | ||
|
|
||
| const responses = executor.transformRequest("muse-spark-1.3-contributor-free", { | ||
| input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], | ||
| tools: [{ type: "function", name: "custom_search", description: "search", parameters: { type: "object", properties: {} } }], | ||
| }); | ||
| expect(responses.stream).toBe(true); | ||
| expect(responses.tools.map((t) => t.name)).toEqual(["custom_search", "read", "shell"]); | ||
| expect(responses.tools[1].parameters.required).toEqual(["path"]); | ||
| expect(responses.instructions).toMatch(/Do not call the `read` or `shell` tools/); | ||
| }); | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium · Retries lose tool-name aliases
When a Zen request with a conflicting caller
readorshelltool receives a retryable response before succeeding, the first transform renames the caller’s conflictingreadorshelltool and records the alias map. A retry transforms that already-mutated body, finds no conflict, and replaces the recorded map with an empty one; the final response then exposes the alias instead of the caller’s tool name.Preserve the original alias map across retries, or make retries transform a fresh copy of the original request body.
Also at
open-sse/executors/opencode-zen.js:259.Prompt for AI agents