From 567fa55dd273080160155a6f7f5796d01ffef262 Mon Sep 17 00:00:00 2001 From: Warexpor ForX Date: Tue, 13 Oct 2026 18:00:00 +0300 Subject: [PATCH] fix(zen): mint descending session ids and fold forced SSE for JSON Responses clients Console's free-tier gate now rejects an x-opencode-session minted ascending; OpenCode's Identifier.create inverts the timestamp for sessions. Inbound session ids are checked in the same direction and replaced otherwise. A non-streaming /v1/responses client on a Zen free model received an HTML 500 because the forced upstream SSE was parsed as JSON; the delivery and continuation paths now fold any event-stream answer through parseStream. --- src/providers/opencode-zen-transport.ts | 35 ++++++++- src/server/responses/adapter-continuation.ts | 2 +- src/server/responses/adapter-delivery.ts | 7 +- .../providers/opencode-zen-free-tier.test.ts | 71 +++++++++++++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/providers/opencode-zen-transport.ts b/src/providers/opencode-zen-transport.ts index 9bb366d371a..60ea202283f 100644 --- a/src/providers/opencode-zen-transport.ts +++ b/src/providers/opencode-zen-transport.ts @@ -65,7 +65,11 @@ function userAgentAcceptsFreeTier(value: string | undefined): boolean { return true; } -/** OpenCode `Identifier.create`: `prefix_` + 6-byte timestamp hex + 14 base62. */ +/** + * OpenCode `Identifier.create`: `prefix_` + 6-byte timestamp hex + 14 base62. + * Sessions are descending ids (timestamp bits inverted); Console's free-tier + * gate rejects a `ses_` id minted ascending (2026-10-13). + */ function createOpenCodeId(prefix: "ses" | "msg"): string { const now = Date.now(); if (now !== idLastMs) { @@ -73,7 +77,8 @@ function createOpenCodeId(prefix: "ses" | "msg"): string { idCounter = 0; } idCounter += 1; - const packed = (BigInt(now) * 0x1000n + BigInt(idCounter)) & ((1n << 48n) - 1n); + const ascending = BigInt(now) * 0x1000n + BigInt(idCounter); + const packed = (prefix === "ses" ? ~ascending : ascending) & ((1n << 48n) - 1n); const hex = packed.toString(16).padStart(12, "0"); const bytes = randomBytes(14); let tail = ""; @@ -81,8 +86,32 @@ function createOpenCodeId(prefix: "ses" | "msg"): string { return `${prefix}_${hex}${tail}`; } +/** Decoded id timestamp must sit within this window of now (inverted for `ses_`). */ +const ID_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const ID_MAX_SKEW_MS = 24 * 60 * 60 * 1000; + +function openCodeIdTimestampMs(value: string, descending: boolean): number { + const raw = BigInt(`0x${value.slice(4, 16)}`); + const mask = (1n << 48n) - 1n; + const packed = descending ? ~raw & mask : raw; + // Only the low 48 bits of ms * 0x1000 survive; restore the high bits from now. + const span = 1n << 48n; + const nowPacked = BigInt(Date.now()) * 0x1000n; + let full = (nowPacked & ~mask) | packed; + if (full > nowPacked + span / 2n) full -= span; + else if (full < nowPacked - span / 2n) full += span; + return Number(full / 0x1000n); +} + export function isOpenCodeZenIdentifier(value: string, prefix: "ses" | "msg"): boolean { - return OC_ID.test(value) && value.startsWith(`${prefix}_`); + if (!OC_ID.test(value) || !value.startsWith(`${prefix}_`)) return false; + const recent = (descending: boolean) => { + const age = Date.now() - openCodeIdTimestampMs(value, descending); + return age <= ID_MAX_AGE_MS && age >= -ID_MAX_SKEW_MS; + }; + // A session minted in the wrong direction is what Console rejects, so it must not pass. + const descending = prefix === "ses"; + return recent(descending) && !recent(!descending); } /** diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index e0e5ae22de2..fc4d455001a 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -683,7 +683,7 @@ export function createAdapterContinuations( // listener that can cancel its reader even while the parser holds the body lock. // The shared signal is not aborted on timeout: this generator must still emit // the error that the already-live Responses bridge turns into a terminal. - if (nextParsed.stream) { + if (nextParsed.stream || (response.headers.get("content-type") ?? "").includes("text/event-stream")) { yield* readResponseStreamWithInactivity( response, upstream.signal, diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 4223dc0d4a1..314ea1344f1 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -203,12 +203,15 @@ export async function deliverAdapterResponse( }); } + // Zen's free tier also forces SSE upstream for a JSON client, so the wire wins over the request. + const upstreamAnswersStream = upstreamRequestsStream + || (upstreamResponse.headers.get("content-type") ?? "").includes("text/event-stream"); if (transportState.activeAdapter.parseResponse - || (upstreamRequestsStream && transportState.activeAdapter.parseStream)) { + || (upstreamAnswersStream && transportState.activeAdapter.parseStream)) { let events: AdapterEvent[]; try { const initialEvents: AdapterEvent[] = []; - if (upstreamRequestsStream && transportState.activeAdapter.parseStream) { + if (upstreamAnswersStream && transportState.activeAdapter.parseStream) { // The canonical ChatGPT adapter coerces its actual upstream request to SSE even for a // JSON client. Routed compaction still owns the non-streaming client response, so fold // that SSE through the adapter instead of handing it to parseResponse as JSON. diff --git a/tests/providers/opencode-zen-free-tier.test.ts b/tests/providers/opencode-zen-free-tier.test.ts index 027506e2fef..69ca701fe3d 100644 --- a/tests/providers/opencode-zen-free-tier.test.ts +++ b/tests/providers/opencode-zen-free-tier.test.ts @@ -12,6 +12,7 @@ import { PROVIDER_REGISTRY, getProviderRegistryEntry } from "../../src/providers import { parseRequest } from "../../src/responses/parser"; import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleResponses } from "../../src/server/responses/core"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -19,7 +20,12 @@ import { withTestTranslatorBudget } from "../helpers/translator-budget"; describe("opencode zen free-tier path", () => { const originalFetch = globalThis.fetch; let releaseSpendHome: (() => void) | undefined; + const savedSocks = { ALL_PROXY: process.env.ALL_PROXY, all_proxy: process.env.all_proxy }; afterEach(() => { + for (const [key, value] of Object.entries(savedSocks)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } // Released first so a failed assertion cannot leave the spend-ledger lease live. releaseSpendHome?.(); releaseSpendHome = undefined; @@ -95,6 +101,33 @@ describe("opencode zen free-tier path", () => { expect(isOpenCodeZenIdentifier(session, "ses")).toBe(true); }); + test("session ids are descending like OpenCode's and ascending ones are replaced", () => { + // Console's free-tier gate rejects an ascending ses_ id (2026-10-13). Pin to the clock + // that minted these ids, captured from OpenCode 1.18.29. + const realNow = Date.now; + Date.now = () => 1_791_902_765_000; + try { + expect(isOpenCodeZenIdentifier("ses_eca460d85ffeIj7pkg4yxND6rK", "ses")).toBe(true); + expect(isOpenCodeZenIdentifier("msg_135b9f297001Gp7pfvGFtRTQPX", "msg")).toBe(true); + expect(isOpenCodeZenIdentifier("ses_135b9f297001Gp7pfvGFtRTQPX", "ses")).toBe(false); + } finally { + Date.now = realNow; + } + + const entry = getProviderRegistryEntry("opencode-free")!; + const mask = (1n << 48n) - 1n; + const ascending = BigInt(Date.now()) * 0x1000n & mask; + const minted = deriveOpenCodeZenSessionId("descending-check"); + const decoded = ~BigInt(`0x${minted.slice(4, 16)}`) & mask; + expect(Math.abs(Number((decoded - ascending) >> 12n))).toBeLessThan(60_000); + const replaced = resolveOpenCodeZenTransport({ + ...providerConfigSeed(entry), + headers: { "x-opencode-session": `ses_${ascending.toString(16).padStart(12, "0")}Ij7pkg4yxND6rK` }, + }); + expect(isOpenCodeZenIdentifier(replaced.headers?.["x-opencode-session"] ?? "", "ses")).toBe(true); + expect(replaced.headers?.["x-opencode-session"]?.startsWith(`ses_${ascending.toString(16)}`)).toBe(false); + }); + test("explicit non-desktop client overrides survive", () => { const entry = getProviderRegistryEntry("opencode-free")!; const provider = resolveOpenCodeZenTransport({ @@ -145,6 +178,9 @@ describe("opencode zen free-tier path", () => { providers: { "opencode-zen": provider }, } as unknown as OcxConfig; + // An ambient SOCKS5 ALL_PROXY routes dispatch around the mocked fetch. + delete process.env.ALL_PROXY; + delete process.env.all_proxy; // A direct handler dispatch skips startServer, so it must hold the spend-ledger lease itself. releaseSpendHome = acquireOwnedSpendHome(); let seen: Headers | undefined; @@ -189,6 +225,41 @@ describe("opencode zen free-tier path", () => { expect(upstream.tools?.some(tool => tool.function?.name === "grep")).toBe(true); }); + test("a non-streaming Responses client folds the SSE Zen free tier is forced onto", async () => { + const entry = getProviderRegistryEntry("opencode-free")!; + const config = { + defaultProvider: "opencode-free", + providers: { "opencode-free": providerConfigSeed(entry) }, + } as unknown as OcxConfig; + delete process.env.ALL_PROXY; + delete process.env.all_proxy; + releaseSpendHome = acquireOwnedSpendHome(); + let upstreamStream: unknown; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + upstreamStream = JSON.parse(typeof init?.body === "string" ? init.body : "{}").stream; + const chunk = (delta: Record, finish: string | null) => + `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", model: "big-pickle", choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`; + return new Response(`${chunk({ role: "assistant", content: "pong" }, null)}${chunk({}, "stop")}data: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + + const res = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "opencode-free/big-pickle", input: "ping", stream: false }), + }), + config, + { model: "", provider: "" }, + ); + expect(upstreamStream).toBe(true); + expect(res.status).toBe(200); + const body = await res.json() as { status?: string; output?: Array<{ content?: Array<{ text?: string }> }> }; + expect(body.status).toBe("completed"); + expect(body.output?.flatMap(item => item.content ?? []).map(part => part.text).join("")).toBe("pong"); + }); + test("paid Zen models do not inherit the free-tier tool catalog", () => { const shaped = applyOpenCodeZenFreeTierBody( { model: "gpt-5.4", messages: [], stream: false },