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
1 change: 1 addition & 0 deletions changelog.d/fixes/14011-opencode-free-tier-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(opencode):** an OpenCode free-tier refusal no longer counts as a healthy response and no longer takes the account it landed on out of rotation for that model: the 403 is recorded on the connection instead of staying unclassified, it stops clearing the refused account's failure history, and the request comes back without a pointless hop across accounts that would all get the same verdict. Every sibling account returns the same answer to the same request, so one refusal per account would otherwise empty the pool and leave later requests answered "no active credentials" ([#14011](https://github.com/diegosouzapw/OmniRoute/pull/14011)) — thanks @maxmad64bis
59 changes: 28 additions & 31 deletions open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,18 @@ import {
type AccountProxyConfig,
type RotatableAccount,
pickAccount as pickRotatableAccount,
markCooldown as markAccountCooldown,
markSuccess as markAccountSuccess,
maskAccountId,
isNetworkErrorRotatable,
isEmptyUpstreamRejection,
extractChatcmplId,
} from "./accountRotation.ts";
import { isOpencodeGeoBlocked, proxyKeyOf, isOpencodeUserBlocked } from "./opencodeGeoBlock.ts";
import { markCooldown, markOutcome, noteResponseServed } from "./opencodeAccountHealth.ts";
import {
isOpencodeFreeTierRefusal,
isOpencodeGeoBlocked,
proxyKeyOf,
isOpencodeUserBlocked,
} from "./opencodeGeoBlock.ts";
import {
guardResponsesStall,
isResponsesFirstByteTimeout,
Expand All @@ -41,13 +45,7 @@ import {
sleepAbortable,
transientRetryDelayMs,
} from "./opencodeTransientFailure.ts";
import {
hasProxyRefusals,
isProxyAvoided,
noteProxyRefusal,
noteProxyServed,
proxyEgressKey,
} from "../utils/proxyRefusalMemory.ts";
import { isProxyAvoided, noteProxyRefusal, proxyEgressKey } from "../utils/proxyRefusalMemory.ts";
import {
isNetworkRotationSharedEgressGuardEnabled,
isProxySkipRecentlyFailedEnabled,
Expand Down Expand Up @@ -372,20 +370,6 @@ export class OpencodeExecutor extends BaseExecutor {
return pickRotatableAccount(this.accounts, this, isReady);
}

private markCooldown(
account: OpencodeAccountState,
kind: "transient" | "terminal" = "transient"
): void {
markAccountCooldown(account, kind);
}

private markSuccess(account: OpencodeAccountState): void {
markAccountSuccess(account);
// A response came back through this proxy: it is usable again for every refusal kind.
// Nothing is held unless PROXY_SKIP_RECENTLY_FAILED was on, so this costs no flag read.
if (hasProxyRefusals()) noteProxyServed(proxyEgressKey(account.proxy));
}

/**
* Rewrite muse-spark's bogus `finish_reason:"length"` (see the
* normalizeMuseSparkFinishReason note) to `"stop"` on both streaming and
Expand Down Expand Up @@ -736,7 +720,7 @@ export class OpencodeExecutor extends BaseExecutor {
// outage; proxied and proxy-less accounts rotate alike. A client abort never rotates.
if (stallWindowMs > 0 && (isResponsesFirstByteTimeout(err) || input.signal?.aborted)) {
if (input.signal?.aborted) throw err;
this.markCooldown(account);
markCooldown(account);
const stallKey = proxyKeyOf(account.proxy);
if (stallKey !== null) geoTriedProxyKeys.add(stallKey);
else directTried = true;
Expand All @@ -757,7 +741,7 @@ export class OpencodeExecutor extends BaseExecutor {
// silently either way: logged before rotating, skipping, or rethrowing.
if (!isNetworkErrorRotatable(account)) {
if (sharedEgressGuardEnabled) {
this.markCooldown(account);
markCooldown(account);
sharedEgressDown = true;
lastSharedEgressError = err;
log?.warn?.(
Expand All @@ -772,7 +756,7 @@ export class OpencodeExecutor extends BaseExecutor {
);
throw err;
}
this.markCooldown(account);
markCooldown(account);
log?.warn?.(
"OPENCODE",
`${cid}network error on account ${masked}, rotating to next… (${reason})`
Expand All @@ -787,7 +771,7 @@ export class OpencodeExecutor extends BaseExecutor {

const status = result.response.status;
if (status === 429) {
this.markCooldown(account);
markCooldown(account);
// The provider refused through this member: set it aside beyond the account
// cooldown. A direct account has a null key and is never set aside.
const setAsideMs = skipRecentlyFailed
Expand Down Expand Up @@ -866,7 +850,7 @@ export class OpencodeExecutor extends BaseExecutor {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
this.markCooldown(account);
markCooldown(account);
const rotate = userBlockedRotations === 0 && this.accounts.length > 1;
log?.warn?.(
"OPENCODE",
Expand All @@ -877,6 +861,19 @@ export class OpencodeExecutor extends BaseExecutor {
abandonedResponse = result.response;
continue;
}
// Free-tier refusal: upstream rejected the REQUEST (client identity or
// request shape), not this account. Every sibling account gets the same
// verdict from the same request, so rotating only adds latency; and the
// refusal must not touch account health — markSuccess would revive an
// evicted account. Return it untouched, health and cooldown unchanged.
if (bodyText !== null && isOpencodeFreeTierRefusal(status, bodyText)) {
log?.warn?.(
"OPENCODE",
`${cid}free-tier refusal ${status} on account ${masked} (proxy ${proxyKeyOf(account.proxy) ?? "direct"}), returning it unchanged (request-scoped, no rotation)`
);
noteResponseServed(account);
return result;
}
}

// Empty upstream rejection (malformed 400: no error field, no real
Expand Down Expand Up @@ -904,11 +901,11 @@ export class OpencodeExecutor extends BaseExecutor {
}
// A 400 carrying a real error (or non-empty content): propagate
// immediately, untouched — same as before this change.
this.markSuccess(account);
markOutcome(account, result.response);
return result;
}

this.markSuccess(account);
markOutcome(account, result.response);
return this.normalizeMuseSparkResponse(input, result);
}

Expand Down
48 changes: 48 additions & 0 deletions open-sse/executors/opencodeAccountHealth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* opencodeAccountHealth.ts — rotation-health writes for the opencode executor loop.
*
* Extracted from the executor so the rules that decide when an account's failure
* history moves live in one place, next to their rationale, instead of being
* inlined at every call site in the rotation loop.
*/
import {
type RotatableAccount,
markCooldown as markAccountCooldown,
markSuccess as markAccountSuccess,
} from "./accountRotation.ts";
import { hasProxyRefusals, noteProxyServed, proxyEgressKey } from "../utils/proxyRefusalMemory.ts";

type ProxiedAccount = RotatableAccount & { proxy: { host: string; port: number } | null };

export function markCooldown(
account: ProxiedAccount,
kind: "transient" | "terminal" = "transient"
): void {
markAccountCooldown(account, kind);
}

/**
* A response came back through this proxy: it is usable again for every refusal kind.
* True of any received response, including a refusal — which is why it is split from
* markSuccess, whose account-health reset must stay reserved for real successes.
* Nothing is held unless PROXY_SKIP_RECENTLY_FAILED was on, so this costs no flag read.
*/
export function noteResponseServed(account: ProxiedAccount): void {
if (hasProxyRefusals()) noteProxyServed(proxyEgressKey(account.proxy));
}

export function markSuccess(account: ProxiedAccount): void {
markAccountSuccess(account);
noteResponseServed(account);
}

/**
* markSuccess clears the account's failure history, so calling it on a refusal erases
* the cooldown backoff a healthy rotation had earned. Only an HTTP success says the
* account served; anything else keeps its history and only records that the proxy
* carried a response.
*/
export function markOutcome(account: ProxiedAccount, response: Response): void {
if (response.ok) markSuccess(account);
else noteResponseServed(account);
}
51 changes: 51 additions & 0 deletions open-sse/executors/opencodeGeoBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
// same way. Literal exact token only; `user-blocked` / `user blocked` are
// unobserved phrasings (fail closed).
const USER_BLOCKED_SIGNAL = "user_blocked";
// Free-tier refusal (observed 2026-09-17): upstream rejects a request whose client
// identity or request shape does not match the OpenCode client contract. Two
// signals, both observed on the same response: the machine token in `error.type`,
// and the relayed sentence in `error.message`. The sentence matters on its own
// because the shared error parser keeps `error.type` aside, so the classifier only
// ever sees the message. Both are exact substrings; no looser phrasing is
// recognized (fail closed).
const FREE_TIER_SIGNALS = ["freetiererror", "free tier can only be used"];
const GEO_SIGNALS = [
"not available in your country",
"not available in your region",
Expand Down Expand Up @@ -63,7 +71,50 @@ export function isOpencodeUserBlocked(status: number, bodyText: string | null):
return text.toLowerCase().includes(USER_BLOCKED_SIGNAL);
}

/**
* 403 or 451 refusing the request itself (client identity or request shape), not
* the account: every account gets the same verdict from the same request, so this
* is never a rotation signal and never an account-health signal. More specific
* refusals win: a fingerprint rejection, a geo block or a `user_blocked` body is
* left to its own predicate.
*/
export function isOpencodeFreeTierRefusal(status: number, bodyText: string | null): boolean {
if (status !== 403 && status !== 451) return false;
const text = String(bodyText || "");
if (
isFingerprintRejection(text) ||
isOpencodeGeoBlocked(status, text) ||
isOpencodeUserBlocked(status, text)
) {
return false;
}
const lower = text.toLowerCase();
return FREE_TIER_SIGNALS.some((signal) => lower.includes(signal));
}

export function proxyKeyOf(proxy: { host: string; port: number } | null): string | null {
if (!proxy) return null;
return `${proxy.host}:${proxy.port}`;
}

/**
* Whether this provider and response are an OpenCode free-tier refusal.
*
* Scoped to the opencode family the same way `classifyProviderError` scopes it, so a
* foreign provider echoing the same sentence keeps its existing handling.
*
* Callers use this to decide that nothing about the refusal belongs on the account or the
* model: the refusal is scoped to the REQUEST. Every sibling account returns the same
* verdict for it, and the same account answers 200 once the request matches the upstream
* contract. Writing a cooldown, a lockout or an error state would be wrong twice over —
* the model is not forbidden, and one refusal per account empties the pool until the
* provider answers "no active credentials" for requests that would have been served.
*/
export function isOpencodeFreeTierRefusalForProvider(
provider: string | null | undefined,
status: number,
bodyText: string | null
): boolean {
if (!provider || !provider.toLowerCase().startsWith("opencode")) return false;
return isOpencodeFreeTierRefusal(status, bodyText);
}
31 changes: 31 additions & 0 deletions open-sse/services/errorClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ export function isGeoBlockedError(errorMessage: string): boolean {
// classified as an egress-fixable geo block, or it would get the non-terminal
// 24h exclusion treatment instead of that provider's own (possibly terminal)
// path.
// OpenCode Zen free-tier refusal. Mirrors isOpencodeFreeTierRefusal in
// open-sse/executors/opencodeGeoBlock.ts, which must stay a leaf module (no
// imports) while this file pulls the registry and the DB — the same mirroring the
// Cloudflare 1010 check uses. The parity test pins both to one vector table.
// Only the relayed sentence is reachable here: parseUpstreamError hands the
// classifier `error.message` and keeps `error.type` aside, so matching the
// machine token alone would never fire. The token stays in the list for callers
// that pass the whole body.
const FREE_TIER_REFUSAL_SIGNALS = ["freetiererror", "free tier can only be used"];

function isOpencodeFreeTierProvider(provider?: string | null): boolean {
return (provider || "").toLowerCase().startsWith("opencode");
}

function isFreeTierClientRefusal(bodyStr: string): boolean {
const lower = bodyStr.toLowerCase();
return FREE_TIER_REFUSAL_SIGNALS.some((signal) => lower.includes(signal));
}

function isGeoBlockEligibleProvider(provider?: string | null): boolean {
const p = (provider || "").toLowerCase();
if (
Expand Down Expand Up @@ -442,6 +461,18 @@ export function classifyProviderError(
return PROVIDER_ERROR_TYPES.FORBIDDEN;
}

// The free tier refuses the REQUEST (client identity or request shape), not the
// account: the same credential succeeds on a compliant request, and every
// sibling account gets the same verdict. FORBIDDEN would ban the connection
// permanently and GEO_BLOCKED would park a healthy account for 24h, so neither
// fits. PROJECT_ROUTE_ERROR records the refusal (lastErrorType/lastError/
// errorCode) and explicitly does not ban — matching how a recoverable
// project-config 403 is handled above. Must precede the apikey short-circuit
// below, which would otherwise drop this refusal as unclassified.
if (isOpencodeFreeTierProvider(provider) && isFreeTierClientRefusal(bodyStr)) {
return PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR;
}

if (provider && getProviderCategory(provider) === "apikey") {
return null;
}
Expand Down
5 changes: 5 additions & 0 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
isProviderModelUnsupported400,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isSharedWalletCredits402 } from "@omniroute/open-sse/services/accountFallback/sharedWalletCredits.ts";
import { isOpencodeFreeTierRefusalForProvider } from "@omniroute/open-sse/executors/opencodeGeoBlock.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/errorSanitization.ts";
Expand Down Expand Up @@ -2678,6 +2679,10 @@ export async function markAccountUnavailable(
return { shouldFallback: true, cooldownMs: 0 };
}

// Request-scoped refusal: nothing about it belongs on this account or this model.
if (isOpencodeFreeTierRefusalForProvider(provider, status, errorText))
return { shouldFallback: true, cooldownMs: 0 };

// ─── Anti-Thundering Herd Guard ─────────────────────────────────
// If this connection was ALREADY marked unavailable by a prior concurrent
// request (within the mutex window), skip re-marking to avoid resetting
Expand Down
Loading
Loading