Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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))
8 changes: 4 additions & 4 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,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. |
Expand Down
5 changes: 3 additions & 2 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
39 changes: 25 additions & 14 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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,
});
}

Expand Down
6 changes: 5 additions & 1 deletion open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,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,
Expand Down Expand Up @@ -878,12 +879,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 ||
Expand Down
25 changes: 25 additions & 0 deletions open-sse/handlers/chatCore/noAuthEchoModel.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
21 changes: 21 additions & 0 deletions open-sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 21 additions & 3 deletions open-sse/utils/opencodeHeaders.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -51,13 +52,23 @@ 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>,
clientHeaders: Record<string, string>,
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
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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 =
Expand All @@ -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();
}
45 changes: 45 additions & 0 deletions tests/unit/chatcore-noauth-echo-model-10571.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading