Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.5.102",
"version": "0.5.103",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
Expand Down
92 changes: 15 additions & 77 deletions open-sse/executors/opencode-zen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}

Expand All @@ -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);

Copy link
Copy Markdown

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 read or shell tool receives a retryable response before succeeding, the first transform renames the caller’s conflicting read or shell tool 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
In `open-sse/executors/opencode-zen.js` at line 233:

**Retries lose tool-name aliases**

When a Zen request with a conflicting caller `read` or `shell` tool receives a retryable response before succeeding, the first transform renames the caller’s conflicting `read` or `shell` tool 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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain alias mapping when the base transform clones the request

When a Zen chat request targets a Kimi or DeepSeek model and declares a caller tool named read or shell, super.transformRequest runs the reasoning injector and returns a new body object. This call records the fingerprint alias map only on out, but chatCore later retrieves it exclusively from the original translatedBody; consequently an upstream read_client/shell_client call is returned to the client without restoration to its declared tool name, preventing the client from dispatching the tool or submitting a matching follow-up result. Record the map on the original request as well, or have the handler read it from the transformed body.

Useful? React with 👍 / 👎.

return out;
}
const normalized = normalizeResponsesInput(out.input);
Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.102",
"version": "0.5.103",
"description": "9Router web dashboard",
"private": true,
"scripts": {
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/opencode-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · 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.

Prompt for AI agents
In `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/);
});
});