Skip to content
Closed
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
1 change: 1 addition & 0 deletions changelog.d/fixes/16571-opencode-free-tier-standalone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(opencode):** OpenCode Free (`opencode`) and OpenCode Zen free models now answer when OmniRoute is used standalone, without the OpenCode CLI in the loop. Two legs of the upstream client contract were missing: the `tools` list is a fingerprint rather than a non-empty check — the lowercase quartet `bash`/`glob`/`grep`/`read` must be declared, while a lone `_noop` placeholder, a single client tool or the capitalised `Bash` spelling are all answered `403 FreeTierError` — and the `x-opencode-session` value is decoded, so an id that only matched the shape (the previous `12 arbitrary hex` render) is now re-minted in the client's own descending layout, staying stable per conversation so prompt caching still hits. The caller's own tools are kept as they are; the quartet is added beside them, and `opencode-go` plus paid models are untouched.
3 changes: 1 addition & 2 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,7 @@ export class OpencodeExecutor extends BaseExecutor {
cliDefaults,
keepAgentUserAgent: this._surface() === "go" && !gatedScope, // #15311
sessionBody: projectOpencodeSessionBody(body),
reencodeSession: gatedScope, // #16571: the free tier decodes the session's value
});
}

Expand All @@ -1369,8 +1370,6 @@ export class OpencodeExecutor extends BaseExecutor {
// to overwrite is accepted on that surface (measured 2026-09-17, 200 on
// muse-spark-1.3-contributor-free via /v1/responses).

void model;

return headers;
}

Expand Down
49 changes: 44 additions & 5 deletions open-sse/executors/opencodeFreeTierContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,32 @@ const PLACEHOLDER_TOOL_DESCRIPTION =
"Do not call this tool. It exists only for API compatibility and must never be invoked.";
const PLACEHOLDER_TOOL_PARAMETERS = { type: "object", properties: {} } as const;

/**
* The lowercase tool quartet the zen surface fingerprints.
*
* The upstream does not merely require `tools` to be non-empty: it inspects the declared
* names and casing. Measured live on 2026-10-02/03 across `/chat/completions` and
* `/responses` on 9 gated free models (121 records, independently reproduced in #15322):
* an empty list, a lone `_noop`, a single client tool, and the capitalised `Bash` spelling
* are all answered 403 FreeTierError, while `bash + read` — the smallest passing pair — and
* this quartet are answered 200. Order is irrelevant and extra tools alongside the quartet
* are accepted, which is why the caller's own entries are never renamed away: the four
* names are only ADDED when missing, so a client that spells one differently (`Bash` from
* Claude Code) keeps its own entry and gains the canonical one next to it.
*/
export const OPENCODE_FREE_TIER_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"] as const;

/** The quartet entries a body is still missing, caller entries untouched. */
function missingFingerprintToolNames(existing: ReadonlySet<string>): string[] {
return OPENCODE_FREE_TIER_FINGERPRINT_TOOLS.filter((name) => !existing.has(name));
}

/**
* Bring a free-tier request up to the upstream contract, without overriding anything the
* caller already decided: client tools are kept as they are, and the placeholder tool is
* only added when the caller sent none or when client-supplied tools do not yet carry the
* required placeholder tool. Idempotent.
* caller already decided: client tools are kept as they are, and the entries the upstream
* fingerprints are only added when the caller's own list does not spell them exactly — the
* lowercase quartet first (see `OPENCODE_FREE_TIER_FINGERPRINT_TOOLS`), then the resolved
* placeholder names. Idempotent.
*
* The placeholder differs per surface: Chat Completions takes the nested function shape,
* the Responses surface takes the flat one. Neither carries a `tool_choice` — the upstream
Expand All @@ -223,7 +244,16 @@ export function applyFreeTierRequestContract<T>(

const existingNames = new Set(clientToolNamesOf(next));
const baseNames = placeholderNames.length > 0 ? placeholderNames : [PLACEHOLDER_TOOL_NAME];
const namesToAdd = baseNames.filter((name) => !existingNames.has(name));
// The quartet goes in first: it is what the upstream fingerprints, so it is added whenever
// the caller's own list does not spell the four names exactly (a `Bash` from Claude Code
// counts as missing `bash`). The placeholder names stay additive afterwards, deduplicated
// against the quartet and against each other.
const namesToAdd: string[] = [];
for (const name of [...missingFingerprintToolNames(existingNames), ...baseNames]) {
if (existingNames.has(name)) continue;
existingNames.add(name);
namesToAdd.push(name);
}

if (namesToAdd.length === 0) return next as T;

Expand Down Expand Up @@ -292,7 +322,16 @@ export function mergeClientToolsWithObserved<T>(
const own = session ? getObservedToolNames(provider, model, session) : null;
const observed = own ?? getObservedToolNames(provider, model) ?? configured;
const have = new Set(clientToolNamesOf(body));
const missing = observed.filter((name) => !have.has(name));
// The quartet is mandatory on this surface too, and the first dispatch carried it whether
// or not the client did — so a refusal on the client's own names is retried with the quartet
// restored first, then the observed names, deduplicated in order.
const missing = missingFingerprintToolNames(have);
const declared = new Set([...have, ...missing]);
for (const name of observed) {
if (declared.has(name)) continue;
declared.add(name);
missing.push(name);
}
if (missing.length === 0) return body;
const next: Record<string, unknown> = { ...(record as Record<string, unknown>) };
if (requestFormat === "openai-responses") {
Expand Down
5 changes: 5 additions & 0 deletions open-sse/executors/opencodeFreeTierRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
configuredPlaceholderToolNames,
isGatedFreeTierRequest,
mergeClientToolsWithObserved,
OPENCODE_FREE_TIER_FINGERPRINT_TOOLS,
type OpencodeSurface,
} from "./opencodeFreeTierContract.ts";
import { resolvePlaceholderNames } from "./opencodeToolObservation.ts";
Expand Down Expand Up @@ -98,6 +99,10 @@ function addsNothingBeyondFirstDispatch(
): boolean {
const sent = new Set<string>([
...ctx.clientToolNames,
// The first dispatch always carried the quartet: the contract adds it to every gated
// body, so a retry that would only restore it re-sends a shape the upstream has just
// answered — nothing new to learn, one wasted round trip.
...OPENCODE_FREE_TIER_FINGERPRINT_TOOLS,
...resolvePlaceholderNames(ctx.provider, model, ctx.clientSession, configured),
]);
return toolNamesOf(merged).every((name) => sent.has(name));
Expand Down
79 changes: 71 additions & 8 deletions open-sse/utils/opencodeHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ export const OPENCODE_SESSION_PATTERN = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
/** Same shape for the request id, which the upstream accepts but does not validate. */
export const OPENCODE_REQUEST_PATTERN = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/;

/**
* Same shape, and the VALUE the zen free tier decodes back.
*
* The 12 hex digits are read through the client's `Identifier.descending()` layout, where the
* low 12 bits carry `0xFFF - counter` (counter is the within-millisecond sequence). Only a
* low-12 landing in `0xFC0-0xFFF` — counter 0-63 — passes; a shape-valid id from another
* scheme (raw hash, ascending id, counter out of window) is answered `403 FreeTierError` like
* a missing one (measured 2026-10-13/14, #16328). `OPENCODE_SESSION_PATTERN` alone cannot
* tell the two apart, so the gated path checks this one.
*/
export const OPENCODE_SESSION_VALUE_PATTERN = /^ses_[0-9a-f]{9}f[c-f][0-9a-f][0-9A-Za-z]{14}$/;

/** Whether a session id carries the encoding the zen free tier decodes (not just its shape). */
export function isCanonicalOpencodeSessionValue(sessionId: string | null | undefined): boolean {
return OPENCODE_SESSION_VALUE_PATTERN.test(String(sessionId ?? "").trim());
}

const MINIMUM_USER_AGENT_MINOR = 17;
const USER_AGENT_VERSION_RE = /opencode\/(?:[a-z]+\/)?v?(\d+)\.(\d+)/i;

Expand Down Expand Up @@ -99,19 +116,51 @@ function base62From(bytes: Buffer, length: number): string {
return Array.from(bytes.subarray(0, length), (byte) => BASE62[byte % 62]).join("");
}

/** Counter OpenCode's `Identifier.descending()` inverts; 0-63 is the window the gate accepts. */
const OPENCODE_SESSION_COUNTER = 1n;
/** The inverted 48-bit field the 12 hex digits render. */
const OPENCODE_SESSION_LOW_48_MASK = 0xffffffffffffn;
/** 36 bits of timestamp: 48 bits minus the 12 the counter owns. */
const OPENCODE_SESSION_TS36_MASK = 0xfffffffffn;

/**
* Render the 12 hex digits the way the client's `Identifier.descending()` does:
* `~(timestamp36 << 12 | counter)` over 48 bits, most-significant nibble first.
*
* The counter is pinned to 1 — the deterministic stand-in for the client's per-millisecond
* sequence — so the whole id stays a pure function of the seed and one conversation keeps
* one upstream session (prompt-cache affinity). The timestamp half is taken from the top 36
* bits of the seed's hash: the upstream does not check that the timestamp is a real clock
* reading, only the counter window the inverted low 12 bits land in.
*/
function descendingTimeHex(bytes: Buffer): string {
const ts36 =
((BigInt(bytes[0]) << 28n) |
(BigInt(bytes[1]) << 20n) |
(BigInt(bytes[2]) << 12n) |
(BigInt(bytes[3]) << 4n) |
(BigInt(bytes[4]) >> 4n)) &
OPENCODE_SESSION_TS36_MASK;
const value = ~((ts36 << 12n) | OPENCODE_SESSION_COUNTER) & OPENCODE_SESSION_LOW_48_MASK;
return value.toString(16).padStart(12, "0");
}

/**
* Render an id in the canonical OpenCode shape (`<prefix>` + 12 hex + 14 base62).
*
* The upstream checks the shape and not the value: 12 arbitrary hex digits pass, so
* there is no need to reproduce the client's own id algorithm (timestamp plus counter).
* With a seed the result is deterministic, which is what keeps a conversation on one
* upstream session — and therefore keeps prompt caching warm — across requests.
* For `ses_` the hex half must also carry the client's `Identifier.descending()` encoding,
* because the zen free tier decodes it and refuses a value outside the counter window
* (`OPENCODE_SESSION_VALUE_PATTERN`). With a seed the result is deterministic, which is what
* keeps a conversation on one upstream session — and therefore keeps prompt caching warm —
* across requests. `msg_` ids are not value-checked upstream, so they keep the plain render.
*/
function canonicalId(prefix: "ses_" | "msg_", seed?: string): string {
const bytes = seed
? createHash("sha256").update(`opencode\u0000${prefix}\u0000${seed}`).digest()
: randomBytes(32);
return `${prefix}${bytes.subarray(0, 6).toString("hex")}${base62From(bytes.subarray(6), 14)}`;
const timePart =
prefix === "ses_" ? descendingTimeHex(bytes) : bytes.subarray(0, 6).toString("hex");
return `${prefix}${timePart}${base62From(bytes.subarray(6), 14)}`;
}

/**
Expand Down Expand Up @@ -175,6 +224,11 @@ function findHeader(headers: Record<string, string>, name: string): string | und
* conversation-stable session fingerprint (model, system, messages or input, tools).
* When provided, x-opencode-session is a deterministic hash instead of a random
* UUID, so upstream prompt caching hits across requests in the same conversation.
* @param options.reencodeSession - Zen free tier only (#16571/#16328): the upstream decodes
* `x-opencode-session` and refuses a value outside the client's counter window, so a
* caller value that only matches the SHAPE is re-minted from its own value (same
* conversation, same session, canonical encoding). Gated surfaces only: `opencode-go` and
* paid models keep the caller's session untouched (#15311).
*/
export function forwardOpencodeClientHeaders(
headers: Record<string, string>,
Expand All @@ -185,6 +239,7 @@ export function forwardOpencodeClientHeaders(
keepAgentUserAgent?: boolean;
sessionBody?: OpencodeSessionBody;
forwardUserAgent?: boolean;
reencodeSession?: boolean;
}
): void {
// 1. Forward User-Agent (opt-out for generic providers, #15632)
Expand Down Expand Up @@ -221,7 +276,8 @@ export function forwardOpencodeClientHeaders(
headers,
options.cliDefaults,
options.sessionBody,
options.keepAgentUserAgent === true
options.keepAgentUserAgent === true,
options.reencodeSession === true
);
}
}
Expand Down Expand Up @@ -266,7 +322,8 @@ function applyCliDefaults(
headers: Record<string, string>,
cliDefaults: { userAgent: string; client: string; project: string },
sessionBody?: OpencodeSessionBody,
keepAgentUserAgent = false
keepAgentUserAgent = false,
reencodeSession = false
): void {
if (!keepsClientUserAgent(headers["User-Agent"] || headers["user-agent"], keepAgentUserAgent)) {
setUserAgentHeader(headers, cliDefaults.userAgent);
Expand All @@ -281,9 +338,15 @@ function applyCliDefaults(
clientRequestId && OPENCODE_REQUEST_PATTERN.test(clientRequestId)
? clientRequestId
: canonicalId("msg_", clientRequestId || undefined);
// On the gated path the shape is not enough: a client session whose encoded value sits
// outside the counter window is refused by the upstream exactly like a missing one, so it
// is re-minted from the client's own value (one conversation, one stable session).
const clientSessionId = headers["x-opencode-session"]?.trim();
const keepsClientSession = reencodeSession
? isCanonicalOpencodeSessionValue(clientSessionId)
: Boolean(clientSessionId && OPENCODE_SESSION_PATTERN.test(clientSessionId));
headers["x-opencode-session"] = clientSessionId
? OPENCODE_SESSION_PATTERN.test(clientSessionId)
? keepsClientSession
? clientSessionId
: canonicalId("ses_", clientSessionId)
: canonicalId("ses_", generateSessionId(sessionBody ?? null) ?? undefined);
Expand Down
12 changes: 8 additions & 4 deletions tests/unit/opencode-free-tier-refusal-rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,12 @@ describe("OpencodeExecutor free-tier refusal retry with observed tools", () => {
assert.strictEqual(seenTools.length, 2, "exactly one retry dispatch");
assert.deepEqual(
seenTools[0],
["glob", "read", "bash"],
"first dispatch keeps client tools first, then the configured placeholders (#14156)"
["glob", "read", "bash", "grep"],
"first dispatch keeps client tools first, then the required quartet and the placeholders (#14156)"
);
assert.deepEqual(
seenTools[1],
["glob", "read", "edit", "write", "bash"],
["glob", "read", "bash", "grep", "edit", "write"],
"retry appends observed names after client tools"
);
await result.response.body?.cancel();
Expand Down Expand Up @@ -345,7 +345,11 @@ describe("OpencodeExecutor free-tier refusal retry with observed tools", () => {
});

assert.strictEqual(result.response.status, 403);
assert.deepEqual(seenTools, [["glob", "read", "edit", "write"]], "one dispatch, no duplicate");
assert.deepEqual(
seenTools,
[["glob", "read", "bash", "grep", "edit", "write"]],
"one dispatch, no duplicate"
);
});

// Retry refusal: the ORIGINAL 403 is propagated and the store is untouched.
Expand Down
Loading