fix(opencode): session stability, free-tier routing, and CLI defaults (#10571)

* 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>
This commit is contained in:
CyrixJD115
2026-08-18 08:52:33 -05:00
committed by GitHub
parent c2dbe2f1fb
commit 9222528bdd
14 changed files with 537 additions and 60 deletions

View File

@@ -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 `<alias>/<model>` form in `response.model` so clients validating against `/v1/models` don't warn ([#10571](https://github.com/diegosouzapw/OmniRoute/pull/10571))

View File

@@ -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 `<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 `<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. |

View File

@@ -480,7 +480,8 @@ export class BaseExecutor {
stream = true,
clientHeaders?: Record<string, string> | null,
model?: string,
health?: Record<string, KeyHealth>
health?: Record<string, KeyHealth>,
body?: unknown
): Record<string, string> {
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

View File

@@ -378,7 +378,9 @@ export class OpencodeExecutor extends BaseExecutor {
credentials: ProviderCredentials | null,
stream = true,
clientHeaders?: Record<string, string> | null,
model?: string
model?: string,
_health?: Record<string, unknown>,
body?: unknown
) {
const headers: Record<string, string> = { "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<string, unknown>) : 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,
});
}

View File

@@ -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 ||

View File

@@ -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
* `<alias>/<model>` 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}`;
}

View File

@@ -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,

View File

@@ -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<string, string>, 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<string, string>,
@@ -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<string, string>,
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();
}

View File

@@ -0,0 +1,45 @@
/**
* Regression test for PR #10571 — chatCore auto-echoes the listing-valid
* `<alias>/<model>` 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 <alias>/<model>", () => {
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);
});

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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"
);
});

View File

@@ -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<string, string> = {};
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<string, string> = {};
forwardOpencodeClientHeaders(
headersFirstTurn,
{},
{ cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A }
);
const headersSecondTurn: Record<string, string> = {};
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<string, string> = {};
forwardOpencodeClientHeaders(
headersA,
{},
{ cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A }
);
const headersB: Record<string, string> = {};
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<string, string> = {};
forwardOpencodeClientHeaders(
headersA,
{},
{ cliDefaults: CLI_DEFAULTS, sessionBody: CONVERSATION_A }
);
const headersC: Record<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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"]);
});

View File

@@ -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", () => {