From 9222528bdd3b62573247bf4a8c6a86dcd6bf5540 Mon Sep 17 00:00:00 2001 From: CyrixJD115 <129636984+CyrixJD115@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:52:33 -0500 Subject: [PATCH] fix(opencode): session stability, free-tier routing, and CLI defaults (#10571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(opencode): session stability, free-tier routing, and CLI defaults - Wire generateSessionId() into opencodeHeaders so x-opencode-session is a deterministic fingerprint instead of randomUUID() per request, enabling upstream prompt caching across a conversation - Thread request body through buildHeaders() so session fingerprint has access to model, system, messages, and tools - Default CLI header synthesis to ON (opt-out via false), align values with 9router proven defaults (opencode/desktop/global) - Auto-echo listing-valid model names for noAuth providers so response.model matches /v1/models listing - Short-circuit free-tier model resolution to opencode provider first to prevent prefix inference misrouting when catalog is unreachable * fix(opencode): make free-tier default flip self-consistent + add coverage PR #10571 flipped OPENCODE_SYNTHESIZE_CLI_HEADERS to on-by-default and changed the synthesized UA/client/project default values, but shipped with 2 broken assertions in the existing #5997 regression test and no coverage for the new session-fingerprinting, free-tier routing, or noAuth echoModel logic (Hard Rule #18). - Update tests/unit/opencode-cli-headers-synthesis-5997.test.ts to match the new on-by-default behavior and new default values; add an explicit opt-out coverage test so the forward-only path is still guarded. - Fix 20 further test failures in tests/unit/opencode-executor.test.ts and tests/unit/refactor-buildHeaders-opencode.test.ts caused by the same default flip (pin OPENCODE_SYNTHESIZE_CLI_HEADERS=false for the characterization suites that predate #10571; use a genuinely CLI-looking UA where the preserved-UA test requires one). - Fix a real bug found via TDD while adding the mandated free-tier routing regression test: the big-pickle/*-free short-circuit in open-sse/services/model.ts checked activeProviders?.has("opencode") literally, but getActiveProviderSet() canonicalizes every connection's provider id through resolveProviderAlias(), which rewrites "opencode" to "opencode-zen" via a manual override — so an active no-auth opencode connection could never satisfy the check. Now checks both opencode-family candidate ids. Proven with a test that fails on the original code and passes with the fix (both connections active with a stale synced catalog omitting big-pickle). - Extract the noAuth-provider echoModel aliasing in chatCore.ts into a pure, directly-testable helper (open-sse/handlers/chatCore/noAuthEchoModel.ts), matching the existing chatCore god-file decomposition pattern. - Add regression tests for generateSessionId()-based x-opencode-session fingerprinting (stable within a conversation, changes on model/message changes), the free-tier routing short-circuit, and the noAuth echoModel aliasing. - Add the changelog.d/ fragment and sync docs/reference/ENVIRONMENT.md's OPENCODE_SYNTHESIZE_CLI_HEADERS/OPENCODE_USER_AGENT/OPENCODE_CLIENT/ OPENCODE_PROJECT rows to the new defaults. Does NOT resolve whether flipping OPENCODE_SYNTHESIZE_CLI_HEADERS's default was the right call, and does NOT touch the separate open PR #10357 which flips the same flag with a different literal default value - that decision is left to the maintainer at merge time. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- ...ode-session-stability-free-tier-routing.md | 1 + docs/reference/ENVIRONMENT.md | 8 +- open-sse/executors/base.ts | 5 +- open-sse/executors/opencode.ts | 39 ++-- open-sse/handlers/chatCore.ts | 6 +- open-sse/handlers/chatCore/noAuthEchoModel.ts | 25 +++ open-sse/services/model.ts | 21 +++ open-sse/utils/opencodeHeaders.ts | 24 ++- .../chatcore-noauth-echo-model-10571.test.ts | 45 +++++ ...pencode-cli-headers-synthesis-5997.test.ts | 61 ++++-- tests/unit/opencode-executor.test.ts | 15 ++ ...ee-tier-routing-shortcircuit-10571.test.ts | 121 ++++++++++++ ...-session-fingerprint-headers-10571.test.ts | 175 ++++++++++++++++++ .../refactor-buildHeaders-opencode.test.ts | 51 +++-- 14 files changed, 537 insertions(+), 60 deletions(-) create mode 100644 changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md create mode 100644 open-sse/handlers/chatCore/noAuthEchoModel.ts create mode 100644 tests/unit/chatcore-noauth-echo-model-10571.test.ts create mode 100644 tests/unit/opencode-free-tier-routing-shortcircuit-10571.test.ts create mode 100644 tests/unit/opencode-session-fingerprint-headers-10571.test.ts diff --git a/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md b/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md new file mode 100644 index 0000000000..bdfe33165a --- /dev/null +++ b/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md @@ -0,0 +1 @@ +- **fix(providers):** OpenCode `x-opencode-session` now derives a stable, conversation-scoped fingerprint via `generateSessionId()` instead of a fresh random UUID per request, so upstream prompt caching can hit across requests in the same conversation; bare `big-pickle`/`*-free` model ids now keep routing to an active opencode-family connection even when its synced catalog is temporarily stale; and bare requests to no-auth catalog providers (e.g. `opencode`) now echo the listing-valid `/` form in `response.model` so clients validating against `/v1/models` don't warn ([#10571](https://github.com/diegosouzapw/OmniRoute/pull/10571)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index dac6106abd..43079a4875 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -313,10 +313,10 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. | | `OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` cookie used for dashboard quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | -| `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `false` | `open-sse/executors/opencode.ts` | Opt-in: synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). Off by default (forward-only is safer). | -| `OPENCODE_USER_AGENT` | `opencode-cli/1.0.0` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | -| `OPENCODE_CLIENT` | `cli` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | -| `OPENCODE_PROJECT` | `default` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | +| `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `true` | `open-sse/executors/opencode.ts` | Synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). On by default since #10571; opt out with `false`/`0`/`no`/`off`. | +| `OPENCODE_USER_AGENT` | `opencode` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | +| `OPENCODE_CLIENT` | `desktop` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | +| `OPENCODE_PROJECT` | `global` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | | `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. | | `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index b8094e7182..c91cb5cbb7 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -480,7 +480,8 @@ export class BaseExecutor { stream = true, clientHeaders?: Record | null, model?: string, - health?: Record + health?: Record, + body?: unknown ): Record { void clientHeaders; void model; @@ -799,7 +800,7 @@ export class BaseExecutor { activeCredentials ); const url = this.buildUrl(model, stream, urlIndex, requestCredentials); - const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model); + const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model, undefined, body); applyConfiguredUserAgent(headers, requestCredentials?.providerSpecificData); // Strip OpenAI SDK (X-Stainless-*) metadata + normalize SDK-derived User-Agent diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index b16a4a5688..6f7a08fbaf 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -378,7 +378,9 @@ export class OpencodeExecutor extends BaseExecutor { credentials: ProviderCredentials | null, stream = true, clientHeaders?: Record | null, - model?: string + model?: string, + _health?: Record, + body?: unknown ) { const headers: Record = { "Content-Type": "application/json" }; // #8467: honor Extra API Keys rotation via BaseExecutor.resolveEffectiveKey. @@ -403,16 +405,12 @@ export class OpencodeExecutor extends BaseExecutor { headers["Accept"] = "text/event-stream"; } - // Opt-in (#5997): synthesize OpenCode CLI identity headers the client did not send. - // Cloudflare in front of opencode.ai/zen/go 403s server-side (VPS) requests lacking - // CLI identity, but the forward-only default is deliberate — fabricating a WRONG - // value risks upstream rejection (#5720 regressed with "opencode/local"), and this - // is deployment-specific. So it stays OFF by default and the VPS operator enables it - // with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied - // headers take precedence, EXCEPT User-Agent: a non-CLI client UA (curl/SDK) is - // replaced with the synthesized CLI UA because opencode.ai's free tier rejects - // generic client UAs from datacenter IPs (FreeUsageLimitError 429). - const synthesizeCli = /^(1|true|yes|on)$/i.test( + // Synthesize OpenCode CLI identity headers by default so Cloudflare in front of + // opencode.ai/zen doesn't 429 VPS requests lacking CLI identity. Opt-out via + // OPENCODE_SYNTHESIZE_CLI_HEADERS=false. Client-supplied headers always win; + // User-Agent is replaced with the CLI UA unless the client already sends one that + // looks like the OpenCode CLI. Default values match 9router's proven defaults. + const synthesizeCli = !/^(0|false|no|off)$/i.test( process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "" ); const cliDefaults = synthesizeCli @@ -423,17 +421,30 @@ export class OpencodeExecutor extends BaseExecutor { userAgent: process.env[envUAKey]?.trim() || process.env.OPENCODE_USER_AGENT?.trim() || - "opencode-cli/1.0.0", - client: process.env.OPENCODE_CLIENT?.trim() || "cli", - project: process.env.OPENCODE_PROJECT?.trim() || "default", + "opencode", + client: process.env.OPENCODE_CLIENT?.trim() || "desktop", + project: process.env.OPENCODE_PROJECT?.trim() || "global", }; })() : undefined; if (clientHeaders || cliDefaults) { + const b = body && typeof body === "object" ? (body as Record) : null; forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, { synthesizeRequestId: true, cliDefaults, + sessionBody: b + ? { + model: typeof b.model === "string" ? b.model : undefined, + system: b.system, + messages: Array.isArray(b.messages) + ? (b.messages as Array<{ role?: string; content?: unknown }>) + : undefined, + tools: Array.isArray(b.tools) + ? (b.tools as Array<{ name?: string; function?: { name?: string } }>) + : undefined, + } + : undefined, }); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 351453c5b0..3c8b286d90 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -166,6 +166,7 @@ import { buildCapabilityMismatchMessage, } from "@/shared/constants/capabilities/capabilityFilter.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; +import { resolveNoAuthEchoModel } from "./chatCore/noAuthEchoModel.ts"; import { REASONING_BUFFER_MIN_TRIGGER, buildReasoningProbeTruncatedResponse, @@ -895,12 +896,15 @@ export async function handleChatCore({ const isCodexResponsesEcho = (isResponsesEndpoint || sourceFormat === FORMATS.OPENAI_RESPONSES) && isCodexOriginatedHeaders(clientRawRequest?.headers); - const echoModel = + let echoModel = (settings.echoRequestedModelName === true || isCodexResponsesEcho) && typeof requestedModel === "string" && requestedModel ? requestedModel : null; + // Auto-echo the listing-valid form for bare requests to noAuth catalog + // providers so clients validating response.model against /v1/models don't warn. + echoModel = resolveNoAuthEchoModel(requestedModel, provider) ?? echoModel; const detailedLoggingEnabled = !noLogEnabled && (settings.call_log_pipeline_enabled === true || diff --git a/open-sse/handlers/chatCore/noAuthEchoModel.ts b/open-sse/handlers/chatCore/noAuthEchoModel.ts new file mode 100644 index 0000000000..76993cefdb --- /dev/null +++ b/open-sse/handlers/chatCore/noAuthEchoModel.ts @@ -0,0 +1,25 @@ +/** + * chatCore noAuth-provider echoModel aliasing (PR #10571). + * + * Pure helper extracted from chatCore: for a bare (unprefixed) requested model + * routed to a no-auth catalog provider (e.g. `opencode`), returns the + * `/` listing-valid form so that clients validating + * `response.model` against the provider's entry in `/v1/models` (which lists + * models under the provider's alias prefix) don't warn/reject. Returns null + * when the request does not match that shape, leaving any existing echoModel + * decision (e.g. the #1311 opt-in echo) untouched. + */ +import { REGISTRY } from "../../config/providerRegistry.ts"; +import { isNoAuthProviderKey } from "@/shared/utils/noAuthProviders.ts"; + +export function resolveNoAuthEchoModel( + requestedModel: unknown, + provider: string | null | undefined +): string | null { + if (typeof requestedModel !== "string" || !requestedModel) return null; + if (requestedModel.includes("/")) return null; + if (!isNoAuthProviderKey(provider)) return null; + + const alias = (provider && REGISTRY[provider]?.alias) || provider; + return `${alias}/${requestedModel}`; +} diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 9465529a27..9df7d14aaf 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -645,6 +645,27 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: } } + // Opencode free-tier models always route to opencode when active — prevents + // prefix inference from misrouting -free names to other providers when the + // live catalog is temporarily unreachable. + // + // A literal `activeProviders?.has("opencode")` check is unreachable in + // practice: `getActiveProviderSet()` canonicalizes every connection's + // provider id through `resolveProviderAlias()`, and the manual override + // above (`ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"`) rewrites any + // "opencode" id to "opencode-zen" before it ever reaches the active set — + // so an active no-auth opencode connection never appears as "opencode". + // Check both opencode-family canonical ids that catalog this model id. + if (modelId === "big-pickle" || modelId.endsWith("-free")) { + const candidates = MODEL_TO_PROVIDERS.get(modelId) || []; + const activeOpencodeCandidate = candidates.find( + (p) => (p === "opencode" || p === "opencode-zen") && activeProviders?.has(p) + ); + if (activeOpencodeCandidate) { + return { provider: activeOpencodeCandidate, model: modelId, extendedContext }; + } + } + const candidateProviders = getInferredProvidersForModel(modelId, activeSyncedProviders); const { providers, excludedProviders } = await reconcileInferredProvidersWithActiveCatalog( candidateProviders, diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 81d9a9f79f..398d53c011 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { setUserAgentHeader } from "../executors/base.ts"; +import { generateSessionId } from "../services/sessionManager.ts"; /** * Header keys that are forwarded from the client to the upstream provider. @@ -51,6 +52,10 @@ function findHeader(headers: Record, name: string): string | und * that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the * synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs * from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229) + * @param options.sessionBody - Request body fields used to generate a + * conversation-stable session fingerprint (model, system, messages, 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. */ export function forwardOpencodeClientHeaders( headers: Record, @@ -58,6 +63,12 @@ export function forwardOpencodeClientHeaders( options?: { synthesizeRequestId?: boolean; cliDefaults?: { userAgent: string; client: string; project: string }; + sessionBody?: { + model?: string; + system?: unknown; + messages?: Array<{ role?: string; content?: unknown }>; + tools?: Array<{ name?: string; function?: { name?: string } }>; + }; } ): void { // 1. Forward User-Agent @@ -98,7 +109,7 @@ export function forwardOpencodeClientHeaders( // 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects // on VPS egress, for any key the client did not supply (#5997). if (options?.cliDefaults) { - applyCliDefaults(headers, options.cliDefaults); + applyCliDefaults(headers, options.cliDefaults, options.sessionBody); } } @@ -113,7 +124,13 @@ export function forwardOpencodeClientHeaders( */ function applyCliDefaults( headers: Record, - cliDefaults: { userAgent: string; client: string; project: string } + cliDefaults: { userAgent: string; client: string; project: string }, + sessionBody?: { + model?: string; + system?: unknown; + messages?: Array<{ role?: string; content?: unknown }>; + tools?: Array<{ name?: string; function?: { name?: string } }>; + } ): void { const existingUa = headers["User-Agent"] || headers["user-agent"]; const clientUaIsCliLike = @@ -124,5 +141,6 @@ function applyCliDefaults( headers["x-opencode-client"] ||= cliDefaults.client; headers["x-opencode-project"] ||= cliDefaults.project; headers["x-opencode-request"] ||= randomUUID(); - headers["x-opencode-session"] ||= randomUUID(); + headers["x-opencode-session"] ||= + generateSessionId(sessionBody ?? null) || randomUUID(); } diff --git a/tests/unit/chatcore-noauth-echo-model-10571.test.ts b/tests/unit/chatcore-noauth-echo-model-10571.test.ts new file mode 100644 index 0000000000..c8a03c240e --- /dev/null +++ b/tests/unit/chatcore-noauth-echo-model-10571.test.ts @@ -0,0 +1,45 @@ +/** + * Regression test for PR #10571 — chatCore auto-echoes the listing-valid + * `/` form in the response `model` field for bare (unprefixed) + * requests routed to a no-auth catalog provider (e.g. `opencode`), so clients + * that validate `response.model` against the provider's entry in + * `/v1/models` (which lists models under the provider's alias prefix) don't + * warn/reject. + * + * `resolveNoAuthEchoModel()` (`open-sse/handlers/chatCore/noAuthEchoModel.ts`) + * is a pure extraction of the inline logic chatCore.ts wires into its + * `echoModel` computation. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveNoAuthEchoModel } from "../../open-sse/handlers/chatCore/noAuthEchoModel.ts"; +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; + +test("aliases a bare model routed to a no-auth provider to /", () => { + const alias = REGISTRY["opencode"]?.alias; + assert.ok(alias, "opencode must declare an alias in the registry for this test to be meaningful"); + assert.equal(resolveNoAuthEchoModel("big-pickle", "opencode"), `${alias}/big-pickle`); +}); + +test("is a no-op (returns null) for an unregistered provider id", () => { + assert.equal(resolveNoAuthEchoModel("some-model", "provider-with-no-registry-entry"), null); +}); + +test("is a no-op (returns null) for a non-noAuth provider", () => { + assert.equal(resolveNoAuthEchoModel("gpt-5.5", "openai"), null); +}); + +test("is a no-op (returns null) when the requested model already has a provider prefix", () => { + assert.equal(resolveNoAuthEchoModel("opencode/big-pickle", "opencode"), null); +}); + +test("is a no-op (returns null) for empty/non-string requested model", () => { + assert.equal(resolveNoAuthEchoModel("", "opencode"), null); + assert.equal(resolveNoAuthEchoModel(null, "opencode"), null); + assert.equal(resolveNoAuthEchoModel(undefined, "opencode"), null); +}); + +test("is a no-op (returns null) for a null/undefined provider", () => { + assert.equal(resolveNoAuthEchoModel("big-pickle", null), null); + assert.equal(resolveNoAuthEchoModel("big-pickle", undefined), null); +}); diff --git a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts index a652f8dbae..02ed7df03e 100644 --- a/tests/unit/opencode-cli-headers-synthesis-5997.test.ts +++ b/tests/unit/opencode-cli-headers-synthesis-5997.test.ts @@ -14,11 +14,16 @@ * Client-supplied values always take precedence (defaults only fill gaps), and the * UA/client/project defaults are env-overridable. * - * The executor-level synthesis is OPT-IN via `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`: - * the forward-only default is deliberate (fabricating a WRONG value risks upstream - * rejection — #5720 regressed with "opencode/local"), and this is deployment-specific - * (the owner asked for it to stay off-by-default pending live validation). With the flag - * off, buildHeaders keeps the historical forward-only behavior. + * PR #10571 flips the executor-level synthesis to ON BY DEFAULT (previously it was + * OPT-IN via `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`, per an earlier #5997 decision to + * stay off-by-default pending live validation, out of concern that a wrong fabricated + * value risks upstream rejection — #5720 regressed with "opencode/local"). It also + * changes the synthesized default values themselves (userAgent "opencode-cli/1.0.0" → + * "opencode", client "cli" → "desktop", project "default" → "global") to match + * 9router's defaults. Flipping the on/off default is a deployment-behavior decision + * this PR did NOT get explicit owner sign-off for — see the PR discussion for #10571 + * (this test file only asserts what the shipped code actually does; it does not bless + * the decision to flip the default). Opt-out is now `OPENCODE_SYNTHESIZE_CLI_HEADERS=false`. */ import { test } from "node:test"; import assert from "node:assert/strict"; @@ -27,8 +32,15 @@ import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// Values passed explicitly to forwardOpencodeClientHeaders()'s `cliDefaults` option in +// the tests below — these are caller-supplied, independent of OpencodeExecutor's own +// env-driven defaults (covered separately by the OPENCODE_DEFAULTS constant + the +// OpencodeExecutor.buildHeaders tests further down). const CLI_DEFAULTS = { userAgent: "opencode-cli/1.0.0", client: "cli", project: "default" }; +// PR #10571's new synthesized defaults for OpencodeExecutor.buildHeaders() itself. +const OPENCODE_DEFAULTS = { userAgent: "opencode", client: "desktop", project: "global" }; + function withEnv(key: string, value: string | undefined, fn: () => void) { const saved = process.env[key]; try { @@ -86,8 +98,32 @@ test("forwardOpencodeClientHeaders: without cliDefaults, no synthesis (DefaultEx assert.equal(headers["x-opencode-project"], undefined); }); -test("OpencodeExecutor.buildHeaders: forward-only by default — no fabrication when flag is off [#5997]", () => { +test("OpencodeExecutor.buildHeaders: synthesizes CLI defaults by default — flag unset [#10571]", () => { withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", undefined, () => { + const executor = new OpencodeExecutor("opencode-go"); + const headers = executor.buildHeaders(null, true, null, "glm-5.2"); + assert.equal(headers["User-Agent"], OPENCODE_DEFAULTS.userAgent); + assert.equal(headers["x-opencode-client"], OPENCODE_DEFAULTS.client); + assert.equal(headers["x-opencode-project"], OPENCODE_DEFAULTS.project); + assert.match(headers["x-opencode-request"] ?? "", UUID_RE); + }); +}); + +test("OpencodeExecutor.buildHeaders: synthesizes CLI defaults with flag explicitly on + no client headers [#5997]", () => { + withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", "true", () => { + const executor = new OpencodeExecutor("opencode-go"); + const headers = executor.buildHeaders(null, true, null, "glm-5.2"); + + assert.equal(headers["User-Agent"], OPENCODE_DEFAULTS.userAgent); + assert.equal(headers["x-opencode-client"], OPENCODE_DEFAULTS.client); + assert.equal(headers["x-opencode-project"], OPENCODE_DEFAULTS.project); + assert.match(headers["x-opencode-request"] ?? "", UUID_RE); + assert.match(headers["x-opencode-session"] ?? "", UUID_RE); + }); +}); + +test("OpencodeExecutor.buildHeaders: forward-only — no fabrication when flag is explicitly off [#10571 opt-out]", () => { + withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", "false", () => { const executor = new OpencodeExecutor("opencode-go"); const headers = executor.buildHeaders(null, true, null, "glm-5.2"); assert.equal(headers["User-Agent"], undefined); @@ -96,19 +132,6 @@ test("OpencodeExecutor.buildHeaders: forward-only by default — no fabrication }); }); -test("OpencodeExecutor.buildHeaders: synthesizes CLI defaults with flag on + no client headers [#5997]", () => { - withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", "true", () => { - const executor = new OpencodeExecutor("opencode-go"); - const headers = executor.buildHeaders(null, true, null, "glm-5.2"); - - assert.equal(headers["User-Agent"], "opencode-cli/1.0.0"); - assert.equal(headers["x-opencode-client"], "cli"); - assert.equal(headers["x-opencode-project"], "default"); - assert.match(headers["x-opencode-request"] ?? "", UUID_RE); - assert.match(headers["x-opencode-session"] ?? "", UUID_RE); - }); -}); - test("OpencodeExecutor.buildHeaders: OPENCODE_GO_USER_AGENT env overrides the default UA (flag on) [#5997]", () => { withEnv("OPENCODE_SYNTHESIZE_CLI_HEADERS", "true", () => { withEnv("OPENCODE_GO_USER_AGENT", "opencode-cli/2.5.0", () => { diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index e8713a9300..ef190193e3 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -36,6 +36,7 @@ describe("OpencodeExecutor", () => { let originalFetch; let originalZenModels; let originalGoModels; + let originalSynthesizeCliHeaders; beforeEach(() => { zenExecutor = new OpencodeExecutor("opencode-zen"); @@ -44,6 +45,15 @@ describe("OpencodeExecutor", () => { originalFetch = globalThis.fetch; originalZenModels = [...(PROVIDER_MODELS["opencode-zen"] || [])]; originalGoModels = [...(PROVIDER_MODELS["opencode-go"] || [])]; + // This suite characterizes header/URL-building behavior that predates PR #10571's + // CLI-identity synthesis default flip. #10571 turned synthesis ON by default, which + // would fabricate User-Agent / x-opencode-* values these tests deliberately assert are + // ABSENT (forward-only contract). Pin the flag off here so this suite keeps + // characterizing the forward-only path; the on-by-default synthesis path itself is + // covered by tests/unit/opencode-cli-headers-synthesis-5997.test.ts and + // tests/unit/opencode-session-fingerprint-headers-10571.test.ts. + originalSynthesizeCliHeaders = process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS; + process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = "false"; globalThis.fetch = (async (url, options) => { fetchCalls.push({ url, options }); return createMockResponse(); @@ -54,6 +64,11 @@ describe("OpencodeExecutor", () => { globalThis.fetch = originalFetch; PROVIDER_MODELS["opencode-zen"] = originalZenModels; PROVIDER_MODELS["opencode-go"] = originalGoModels; + if (originalSynthesizeCliHeaders === undefined) { + delete process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS; + } else { + process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = originalSynthesizeCliHeaders; + } }); describe("execute", () => { diff --git a/tests/unit/opencode-free-tier-routing-shortcircuit-10571.test.ts b/tests/unit/opencode-free-tier-routing-shortcircuit-10571.test.ts new file mode 100644 index 0000000000..76bfd17922 --- /dev/null +++ b/tests/unit/opencode-free-tier-routing-shortcircuit-10571.test.ts @@ -0,0 +1,121 @@ +/** + * Regression test for PR #10571 — bare "big-pickle" / "*-free" model ids must + * keep routing to an opencode-family provider (opencode / opencode-zen) when + * an opencode connection is active, even if the live-synced catalog for that + * connection is temporarily stale/incomplete and would otherwise exclude it + * during the normal live-catalog reconciliation step. + * + * This also regression-guards a bug found while writing this test: the + * short-circuit as originally shipped checked `activeProviders?.has("opencode")` + * literally. `getActiveProviderSet()` canonicalizes every connection's + * provider id through `resolveProviderAlias()`, and a manual override in + * `open-sse/services/model.ts` (`ALIAS_TO_PROVIDER_ID["opencode"] = + * "opencode-zen"`) rewrites any "opencode" id to "opencode-zen" before it + * ever reaches the active set — so a real active no-auth "opencode" + * connection NEVER appears as "opencode" in `activeProviders`, making the + * literal check unreachable. The fix checks every opencode-family candidate + * (`opencode` and `opencode-zen`) that actually catalogs the model id. + * + * Without the short-circuit (or with the original unreachable literal + * check), an active opencode connection + active opencode-zen connection + * both carrying a synced catalog that omits "big-pickle" makes + * `getModelInfoCore("big-pickle", null)` return + * `{ provider: null, errorType: "model_not_found" }` instead of routing to + * opencode — this test proves the short-circuit prevents exactly that. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-opencode-free-routing-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const { getModelInfoCore } = await import("../../open-sse/services/model.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("bare big-pickle routes to an opencode-family provider when an opencode connection is active", async () => { + await providersDb.createProviderConnection({ + provider: "opencode", + authType: "apikey", + name: "opencode-active-big-pickle", + isActive: true, + testStatus: "unknown", + }); + + const info = await getModelInfoCore("big-pickle", null); + assert.ok( + info.provider === "opencode" || info.provider === "opencode-zen", + `expected an opencode-family provider, got ${info.provider}` + ); + assert.equal(info.model, "big-pickle"); +}); + +test("bare deepseek-v4-flash-free (-free suffix) routes to an opencode-family provider when active", async () => { + await providersDb.createProviderConnection({ + provider: "opencode", + authType: "apikey", + name: "opencode-active-free-suffix", + isActive: true, + testStatus: "unknown", + }); + + const info = await getModelInfoCore("deepseek-v4-flash-free", null); + assert.ok( + info.provider === "opencode" || info.provider === "opencode-zen", + `expected an opencode-family provider, got ${info.provider}` + ); + assert.equal(info.model, "deepseek-v4-flash-free"); +}); + +test("big-pickle still resolves to opencode when BOTH opencode + opencode-zen connections are active but their synced catalogs are stale and omit big-pickle [core regression]", async () => { + const connOc = await providersDb.createProviderConnection({ + provider: "opencode", + authType: "apikey", + name: "opencode-stale-catalog", + isActive: true, + testStatus: "unknown", + }); + const connZen = await providersDb.createProviderConnection({ + provider: "opencode-zen", + authType: "apikey", + name: "opencode-zen-stale-catalog", + isActive: true, + testStatus: "unknown", + }); + + // Simulate a live catalog sync that is stale/incomplete for both + // connections — neither includes "big-pickle" — which would otherwise + // make the live-catalog reconciliation step EXCLUDE both providers. + await modelsDb.replaceSyncedAvailableModelsForConnection("opencode", connOc.id, [ + { id: "some-other-model", name: "Some Other Model" }, + ]); + await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-zen", connZen.id, [ + { id: "some-other-model-2", name: "Some Other Model 2" }, + ]); + + const info = await getModelInfoCore("big-pickle", null); + assert.notEqual( + info.provider, + null, + "the free-tier short-circuit must bypass live-catalog exclusion for big-pickle/-free ids" + ); + assert.ok( + info.provider === "opencode" || info.provider === "opencode-zen", + `expected an opencode-family provider, got ${info.provider}` + ); + assert.equal(info.model, "big-pickle"); + assert.equal( + "errorType" in info ? info.errorType : undefined, + undefined, + "must not return a model_not_found error" + ); +}); diff --git a/tests/unit/opencode-session-fingerprint-headers-10571.test.ts b/tests/unit/opencode-session-fingerprint-headers-10571.test.ts new file mode 100644 index 0000000000..692435daaa --- /dev/null +++ b/tests/unit/opencode-session-fingerprint-headers-10571.test.ts @@ -0,0 +1,175 @@ +/** + * Regression test for PR #10571 — `x-opencode-session` must be a STABLE, + * conversation-scoped fingerprint (via `generateSessionId()`) instead of a + * fresh random UUID on every request, so upstream prompt caching can hit + * across requests belonging to the same conversation. + * + * `open-sse/utils/opencodeHeaders.ts::applyCliDefaults` now derives + * `x-opencode-session` from `generateSessionId(sessionBody)` + * (`open-sse/services/sessionManager.ts`) when a `sessionBody` is supplied, + * falling back to `randomUUID()` only when no fingerprint can be derived + * (e.g. an empty/missing body). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { forwardOpencodeClientHeaders } from "../../open-sse/utils/opencodeHeaders.ts"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SESSION_HASH_RE = /^[0-9a-f]{16}$/i; + +const CLI_DEFAULTS = { userAgent: "opencode", client: "desktop", project: "global" }; + +const CONVERSATION_A = { + model: "big-pickle", + messages: [{ role: "user", content: "Hello there" }], +}; + +const CONVERSATION_A_SECOND_TURN = { + model: "big-pickle", + messages: [ + { role: "user", content: "Hello there" }, + { role: "assistant", content: "Hi! How can I help?" }, + { role: "user", content: "What's the weather?" }, + ], +}; + +const CONVERSATION_B_DIFFERENT_MODEL = { + model: "deepseek-v4-flash-free", + messages: [{ role: "user", content: "Hello there" }], +}; + +const CONVERSATION_C_DIFFERENT_FIRST_MESSAGE = { + model: "big-pickle", + messages: [{ role: "user", content: "A completely different opening message" }], +}; + +test("x-opencode-session is a stable fingerprint hash (not a random UUID) when sessionBody is provided", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } + ); + assert.match(headers["x-opencode-session"] ?? "", SESSION_HASH_RE); + assert.doesNotMatch( + headers["x-opencode-session"] ?? "", + UUID_RE, + "must not be a random UUID when a fingerprint can be derived" + ); +}); + +test("x-opencode-session stays STABLE across requests in the same conversation (same model + growing message history keeps the first-user-message fingerprint)", () => { + const headersFirstTurn: Record = {}; + forwardOpencodeClientHeaders( + headersFirstTurn, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } + ); + + const headersSecondTurn: Record = {}; + forwardOpencodeClientHeaders( + headersSecondTurn, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A_SECOND_TURN } + ); + + assert.equal( + headersFirstTurn["x-opencode-session"], + headersSecondTurn["x-opencode-session"], + "same conversation (same model + same first user message) must yield the same session id across turns" + ); +}); + +test("x-opencode-session CHANGES when the model differs", () => { + const headersA: Record = {}; + forwardOpencodeClientHeaders( + headersA, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } + ); + + const headersB: Record = {}; + forwardOpencodeClientHeaders( + headersB, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_B_DIFFERENT_MODEL } + ); + + assert.notEqual( + headersA["x-opencode-session"], + headersB["x-opencode-session"], + "a different model must produce a different session id" + ); +}); + +test("x-opencode-session CHANGES when the first user message (conversation identity) differs", () => { + const headersA: Record = {}; + forwardOpencodeClientHeaders( + headersA, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } + ); + + const headersC: Record = {}; + forwardOpencodeClientHeaders( + headersC, + {}, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_C_DIFFERENT_FIRST_MESSAGE } + ); + + assert.notEqual( + headersA["x-opencode-session"], + headersC["x-opencode-session"], + "a different conversation (different first user message) must produce a different session id" + ); +}); + +test("x-opencode-session falls back to a random UUID when no sessionBody is provided", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders(headers, {}, { cliDefaults: CLI_DEFAULTS }); + assert.match(headers["x-opencode-session"] ?? "", UUID_RE); +}); + +test("client-supplied x-opencode-session always wins over the derived fingerprint", () => { + const headers: Record = {}; + forwardOpencodeClientHeaders( + headers, + { "x-opencode-session": "client-supplied-session-id" }, + { cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A } + ); + assert.equal(headers["x-opencode-session"], "client-supplied-session-id"); +}); + +test("OpencodeExecutor.buildHeaders derives a stable x-opencode-session from the request body across calls with the same conversation", () => { + const executor = new OpencodeExecutor("opencode-go"); + const headersFirst = executor.buildHeaders(null, true, null, "big-pickle", undefined, { + model: "big-pickle", + messages: [{ role: "user", content: "Same conversation" }], + }); + const headersSecond = executor.buildHeaders(null, true, null, "big-pickle", undefined, { + model: "big-pickle", + messages: [ + { role: "user", content: "Same conversation" }, + { role: "assistant", content: "..." }, + { role: "user", content: "follow-up" }, + ], + }); + + assert.match(headersFirst["x-opencode-session"] ?? "", SESSION_HASH_RE); + assert.equal(headersFirst["x-opencode-session"], headersSecond["x-opencode-session"]); +}); + +test("OpencodeExecutor.buildHeaders derives a DIFFERENT x-opencode-session for a different conversation body", () => { + const executor = new OpencodeExecutor("opencode-go"); + const headersA = executor.buildHeaders(null, true, null, "big-pickle", undefined, { + model: "big-pickle", + messages: [{ role: "user", content: "Conversation one" }], + }); + const headersB = executor.buildHeaders(null, true, null, "big-pickle", undefined, { + model: "big-pickle", + messages: [{ role: "user", content: "Conversation two, totally different" }], + }); + + assert.notEqual(headersA["x-opencode-session"], headersB["x-opencode-session"]); +}); diff --git a/tests/unit/refactor-buildHeaders-opencode.test.ts b/tests/unit/refactor-buildHeaders-opencode.test.ts index 8b5ad1a852..6c16d9d763 100644 --- a/tests/unit/refactor-buildHeaders-opencode.test.ts +++ b/tests/unit/refactor-buildHeaders-opencode.test.ts @@ -75,30 +75,47 @@ test("OpencodeExecutor.buildHeaders: Content-Type always application/json", () = assert.equal(headers["Content-Type"], "application/json"); }); -test("OpencodeExecutor.buildHeaders: omits User-Agent when no client UA (forward-only, not fabricated)", () => { - // Forward-only contract (see opencode-executor.test.ts): opencode client identity headers - // are opencode-internal — inventing them risks upstream rejection, so we never fabricate a - // default. A pure dedup refactor (#5720) briefly regressed this by defaulting to - // "opencode/local"; the executor forwards a client-sent User-Agent but adds none of its own. - const executor = new OpencodeExecutor("opencode"); - const headers = executor.buildHeaders({ apiKey: "key-1" }, true); - assert.equal(headers["User-Agent"], undefined); +test("OpencodeExecutor.buildHeaders: omits User-Agent when no client UA and synthesis is explicitly off", () => { + // Forward-only contract (see opencode-executor.test.ts) when the operator opts OUT via + // OPENCODE_SYNTHESIZE_CLI_HEADERS=false. PR #10571 flipped the default to ON (see + // tests/unit/opencode-cli-headers-synthesis-5997.test.ts) — the forward-only path is now + // opt-out rather than the default. + const saved = process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS; + process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = "false"; + try { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["User-Agent"], undefined); + } finally { + if (saved === undefined) delete process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS; + else process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = saved; + } }); -test("OpencodeExecutor.buildHeaders: preserves client User-Agent when provided", () => { +test("OpencodeExecutor.buildHeaders: preserves an opencode-cli-like client User-Agent when provided", () => { + // Since #10571 flips CLI-header synthesis to on-by-default, a non-CLI-looking client UA + // (e.g. "opencode/1.17.12") is now REPLACED by the synthesized default (see the + // #5997/#10571 non-CLI-UA-replaced test in opencode-cli-headers-synthesis-5997.test.ts). + // Only a UA that already looks like the real OpenCode CLI ("opencode-cli/…") is preserved. const executor = new OpencodeExecutor("opencode"); const headers = executor.buildHeaders({ apiKey: "key-1" }, true, { - "User-Agent": "opencode/1.17.12", + "User-Agent": "opencode-cli/1.17.12", }); - assert.equal(headers["User-Agent"], "opencode/1.17.12"); + assert.equal(headers["User-Agent"], "opencode-cli/1.17.12"); }); -test("OpencodeExecutor.buildHeaders: omits x-opencode-client when absent (forward-only, not fabricated)", () => { - // x-opencode-client / x-opencode-project valid values are opencode-internal; fabricating a - // default ("cli") risks upstream rejection, so they stay forward-only (see opencode-executor.test.ts). - const executor = new OpencodeExecutor("opencode"); - const headers = executor.buildHeaders({ apiKey: "key-1" }, true); - assert.equal(headers["x-opencode-client"], undefined); +test("OpencodeExecutor.buildHeaders: omits x-opencode-client when absent and synthesis is explicitly off", () => { + // x-opencode-client / x-opencode-project fabrication is opt-out (see above) since #10571. + const saved = process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS; + process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = "false"; + try { + const executor = new OpencodeExecutor("opencode"); + const headers = executor.buildHeaders({ apiKey: "key-1" }, true); + assert.equal(headers["x-opencode-client"], undefined); + } finally { + if (saved === undefined) delete process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS; + else process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = saved; + } }); test("OpencodeExecutor.buildHeaders: preserves x-opencode-client from client headers", () => {