Skip to content
Open
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
107 changes: 29 additions & 78 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, isOpencodeFreeTierModel } from "../providers/models/helpers.js";
import { applyFingerprintTools } from "../utils/opencodeFingerprint.js";
import {
normalizeResponsesInput,
clampResponsesCallId,
Expand All @@ -14,20 +15,19 @@ const SESSION_FIELD = "_opencodeZenSession";
const MAX_SESSION_LENGTH = 256;

const RESPONSES_BASE_URL = "https://opencode.ai/zen/v1/responses";
const FREE_RESPONSES_BASE_URL = "https://opencode.ai/inference/v1/responses";
const FREE_CHAT_BASE_URL = "https://opencode.ai/inference/v1/chat/completions";
const MAX_TOOL_NAME_LEN = 128;
const OPENCODE_UA = "opencode/1.18.31";
// OpenCode v2 UA shape: opencode/<channel>/<version>/<client>. Upstream rejects
// the v1 "opencode/1.18.x" form with 403 FreeTierError since 2026-10-14.
const OPENCODE_UA = "opencode/latest/2.0.26/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 +84,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 @@ -239,6 +184,11 @@ export class OpenCodeZenExecutor extends DefaultExecutor {
}

buildUrl(model, stream, urlIndex = 0, credentials = null) {
// Free-tier models on opencode-zen authenticate as `Bearer public`, which
// upstream moved from /zen/v1 to /inference/v1 in OpenCode v2.
if (isOpencodeFreeTierModel(model)) {
return isResponsesModel(model) ? FREE_RESPONSES_BASE_URL : FREE_CHAT_BASE_URL;
}
// Muse Spark lives on /responses even when a stale runtimeTransport leaks in.
if (isResponsesModel(model)) return RESPONSES_BASE_URL;
return super.buildUrl(model, stream, urlIndex, credentials);
Expand Down Expand Up @@ -271,24 +221,24 @@ export class OpenCodeZenExecutor extends DefaultExecutor {
const lower = {};
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
const downstreamUa = lower["user-agent"] || "";
// Free-tier gate: spoof the official client UA.
// Free-tier gate: spoof the official v2 client UA and session header mirrors.
headers["User-Agent"] = hasValidOpencodeVersion(downstreamUa) ? downstreamUa : OPENCODE_UA;
headers["x-opencode-client"] = lower["x-opencode-client"] || "desktop";
headers["x-opencode-client"] = lower["x-opencode-client"] || OPENCODE_CLIENT;
headers["x-opencode-project"] = lower["x-opencode-project"] || "global";
const prepared = credentials?.[SESSION_FIELD];
if (prepared) {
headers[SESSION_HEADER] = prepared;
} else {
const fallback = this.prepareRequestCredentials({ credentials });
headers[SESSION_HEADER] = fallback[SESSION_FIELD];
}
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;
if (isOpencodeFreeTierModel(model)) {
// Free-tier models are gated behind the attested free-tier identity even
// on the keyed lane: a real oc_sk_* key answers 403 FreeTierError, while
// `Bearer public` + session/request ids succeed. Paid models keep the
// connection key untouched.
// `Bearer public` + v2 session headers succeed on /inference/v1. Upstream
// rejects x-opencode-request (v1 only) with 403 FreeTierError.
headers["Authorization"] = "Bearer public";
headers["x-opencode-request"] = generateRequestId();
delete headers["x-api-key"];
delete headers["x-opencode-request"];
}
return headers;
}
Expand All @@ -298,7 +248,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);
return out;
}
const normalized = normalizeResponsesInput(out.input);
Expand All @@ -322,9 +272,10 @@ 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;
}
}

53 changes: 46 additions & 7 deletions tests/unit/opencode-zen-free-tier.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,35 @@ describe("isOpencodeFreeTierModel", () => {
});
});

describe("OpenCodeZenExecutor free-tier auth", () => {
it("sends Bearer public + session + request ids for free-tier models", () => {
const headers = headersFor("mimo-v2.6-flash-free");
describe("OpenCodeZenExecutor free-tier auth and v2 fingerprint", () => {
it("routes -free models to /inference/v1 and keeps paid models on /zen/v1", () => {
const executor = new OpenCodeZenExecutor();
expect(executor.buildUrl("mimo-v2.6-flash-free", true)).toBe(
"https://opencode.ai/inference/v1/chat/completions",
);
expect(executor.buildUrl("muse-spark-1.3-contributor-free", true)).toBe(
"https://opencode.ai/inference/v1/responses",
);
expect(executor.buildUrl("big-pickle", true)).toBe(
"https://opencode.ai/zen/v1/chat/completions",
);
expect(executor.buildUrl("muse-spark-1.3", true)).toBe(
"https://opencode.ai/zen/v1/responses",
);
});

it("sends Bearer public + v2 session headers and omits x-opencode-request for free-tier models", () => {
const headers = headersFor("mimo-v2.6-flash-free", {
rawHeaders: { "x-opencode-request": "msg_0ae8d9cd3001swxaFbM248jcIF" },
});
expect(headers["Authorization"]).toBe("Bearer public");
expect(headers["x-opencode-session"]).toMatch(OPENCODE_SESSION_RE);
expect(headers["x-opencode-request"]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
expect(headers["x-opencode-client"]).toBe("desktop");
expect(headers["User-Agent"]).toMatch(/opencode\/\d+\.\d+/i);
expect(headers["x-opencode-session-id"]).toBe(headers["x-opencode-session"]);
expect(headers["x-session-affinity"]).toBe(headers["x-opencode-session"]);
expect(headers["x-session-id"]).toBe(headers["x-opencode-session"]);
expect(headers["x-opencode-request"]).toBeUndefined();
expect(headers["x-opencode-client"]).toBe("cli");
expect(headers["User-Agent"]).toBe("opencode/latest/2.0.26/cli");
});

it("does the same for responses-endpoint free models", () => {
Expand All @@ -74,7 +95,9 @@ describe("OpenCodeZenExecutor free-tier auth", () => {
{},
);
expect(headers["Authorization"]).toBe("Bearer public");
expect(headers["x-opencode-request"]).toMatch(/^msg_/);
expect(headers["x-opencode-request"]).toBeUndefined();
expect(headers["x-opencode-client"]).toBe("cli");
expect(headers["User-Agent"]).toBe("opencode/latest/2.0.26/cli");
});

it("keeps the connection key for paid models", () => {
Expand All @@ -83,4 +106,20 @@ describe("OpenCodeZenExecutor free-tier auth", () => {
expect(headers["x-opencode-request"]).toBeUndefined();
expect(headers["x-opencode-session"]).toMatch(OPENCODE_SESSION_RE);
});

it("injects verbatim read/shell fingerprint tools for chat and responses payloads", () => {
const executor = new OpenCodeZenExecutor();
const chatOut = executor.transformRequest("mimo-v2.6-flash-free", {
messages: [{ role: "user", content: "hi" }],
});
expect(chatOut.tools.map((t) => t.function?.name)).toEqual(["read", "shell"]);
expect(chatOut.tools[0].function.parameters.required).toEqual(["path"]);

const responsesOut = executor.transformRequest("muse-spark-1.3-contributor-free", {
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
});
expect(responsesOut.tools.map((t) => t.name)).toEqual(["read", "shell"]);
expect(responsesOut.tools[0].parameters.required).toEqual(["path"]);
});
});