From fec8dc2be9bd621348109fc92e32d6f709359081 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:35:15 +0200 Subject: [PATCH] fix(opencode): record the free-tier refusal instead of counting it as success (#14011) An OpenCode Zen free-tier 403 ("free tier can only be used from within OpenCode") reached the end of the executor loop unrecognized: nothing was persisted about it, and the account that had just been refused was marked successful, which clears the failure history driving its cooldown backoff. A refusal was therefore improving the rotation health of the account it hit. The refusal is now recognized by its own predicate, returned unchanged without rotating (it is request-scoped, so every sibling account returns the same verdict), and classified as a non-banning routing error, so the connection records lastErrorType/lastError/errorCode and stays active. The account-health reset is also reserved for HTTP successes at both call sites in the loop, since the same reset ran on any status the loop did not handle in a dedicated branch. Co-authored-by: Max --- .../fixes/14011-opencode-free-tier-refusal.md | 1 + open-sse/executors/opencode.ts | 59 +++-- open-sse/executors/opencodeAccountHealth.ts | 48 +++++ open-sse/executors/opencodeGeoBlock.ts | 51 +++++ open-sse/services/errorClassifier.ts | 31 +++ src/sse/services/auth.ts | 5 + ...free-tier-refusal-no-model-lockout.test.ts | 105 +++++++++ ...encode-free-tier-refusal-predicate.test.ts | 134 ++++++++++++ ...pencode-free-tier-refusal-rotation.test.ts | 202 ++++++++++++++++++ 9 files changed, 605 insertions(+), 31 deletions(-) create mode 100644 changelog.d/fixes/14011-opencode-free-tier-refusal.md create mode 100644 open-sse/executors/opencodeAccountHealth.ts create mode 100644 tests/unit/opencode-free-tier-refusal-no-model-lockout.test.ts create mode 100644 tests/unit/opencode-free-tier-refusal-predicate.test.ts create mode 100644 tests/unit/opencode-free-tier-refusal-rotation.test.ts diff --git a/changelog.d/fixes/14011-opencode-free-tier-refusal.md b/changelog.d/fixes/14011-opencode-free-tier-refusal.md new file mode 100644 index 0000000000..f116006545 --- /dev/null +++ b/changelog.d/fixes/14011-opencode-free-tier-refusal.md @@ -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 diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index ac4996d5a7..01909f4f5e 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -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, @@ -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, @@ -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 @@ -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; @@ -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?.( @@ -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})` @@ -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 @@ -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", @@ -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 @@ -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); } diff --git a/open-sse/executors/opencodeAccountHealth.ts b/open-sse/executors/opencodeAccountHealth.ts new file mode 100644 index 0000000000..da435e721d --- /dev/null +++ b/open-sse/executors/opencodeAccountHealth.ts @@ -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); +} diff --git a/open-sse/executors/opencodeGeoBlock.ts b/open-sse/executors/opencodeGeoBlock.ts index dfd5cf4e56..c1a2811cf0 100644 --- a/open-sse/executors/opencodeGeoBlock.ts +++ b/open-sse/executors/opencodeGeoBlock.ts @@ -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", @@ -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); +} diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index 54d4d23ee1..095d329a44 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -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 ( @@ -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; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 9ced1c5045..d4f5fd31c3 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -79,6 +79,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"; @@ -2679,6 +2680,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 diff --git a/tests/unit/opencode-free-tier-refusal-no-model-lockout.test.ts b/tests/unit/opencode-free-tier-refusal-no-model-lockout.test.ts new file mode 100644 index 0000000000..50cdfe0113 --- /dev/null +++ b/tests/unit/opencode-free-tier-refusal-no-model-lockout.test.ts @@ -0,0 +1,105 @@ +/** + * A free-tier refusal must not be recorded as this model being forbidden. + * + * The executor already answers the refusal request-scoped: every sibling account returns + * the same verdict for the same request, so the account it landed on is not the reason. + * The per-model lockout arm (#3027/#12242) nevertheless fired on any 403 from a + * passthrough provider, writing `Model forbidden (per-model access/subscription)` on + * the connection and taking that account out of rotation for the model. One refusal per + * account is enough to empty the pool, after which requests that would have been served + * are answered "no active credentials" instead. + */ +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-freetier-lockout-")); +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 accountFallback = await import("../../open-sse/services/accountFallback.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +const FREE_TIER_BODY = + '{"error":{"type":"FreeTierError","message":"Error from provider (Console): OpenCode\'s free tier can only be used from within OpenCode"}}'; +const FORBIDDEN_BODY = '{"error":{"message":"Model not available on your plan"}}'; +const MODEL = "nemotron-3.5-lightning-free"; + +async function connection(provider = "opencode") { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + accountFallback.clearAllModelLockouts(); + const conn = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + apiKey: "", + isActive: true, + testStatus: "active", + }); + return String(conn.id); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("a free-tier refusal leaves the model usable on the account it landed on", async () => { + const connId = await connection(); + await auth.markAccountUnavailable(connId, 403, FREE_TIER_BODY, "opencode", MODEL); + + assert.equal( + accountFallback.getModelLockoutInfo("opencode", connId, MODEL), + null, + "the refusal says nothing about this account and this model" + ); + const conn = await providersDb.getProviderConnectionById(connId); + assert.notEqual( + conn?.lastErrorType, + "forbidden", + "the connection must not carry a per-model access error" + ); + // Skipping the arm must not drop the request into a worse one: the connection stays + // usable and uncooled, which is the whole point of answering the refusal request-scoped. + assert.notEqual(conn?.testStatus, "unavailable", "the connection must stay usable"); + assert.ok( + !conn?.rateLimitedUntil || new Date(conn.rateLimitedUntil).getTime() <= Date.now(), + "the connection must not be put on cooldown" + ); +}); + +test("the recognition is scoped to the opencode family", async () => { + // classifyProviderError scopes the same sentence to opencode* providers; the early + // return mirrors that, so a foreign provider echoing it keeps the existing behaviour + // and is still taken out of rotation. + const connId = await connection("groq"); + await auth.markAccountUnavailable(connId, 403, FREE_TIER_BODY, "groq", MODEL); + const conn = await providersDb.getProviderConnectionById(connId); + assert.equal(conn?.testStatus, "unavailable", "the early return must not fire here"); +}); + +test("an unrelated 403 on the same provider still locks the model out", async () => { + const connId = await connection(); + await auth.markAccountUnavailable(connId, 403, FORBIDDEN_BODY, "opencode", MODEL); + + assert.notEqual( + accountFallback.getModelLockoutInfo("opencode", connId, MODEL), + null, + "the existing per-model arm must keep firing for a real per-model refusal" + ); +}); + +test("a 402 on the same provider still locks the model out", async () => { + // The arm this guard sits next to handles 402 as well as 403, and the early return + // fires on neither 402 nor a non-refusal body. Pinned so a later widening of the + // recognition cannot silently swallow the per-model credit path. + const connId = await connection(); + // Same body as the refusal, different status: if the recognition were ever widened to + // 402, the early return would fire here and this case would fail. + await auth.markAccountUnavailable(connId, 402, FREE_TIER_BODY, "opencode", MODEL); + assert.notEqual(accountFallback.getModelLockoutInfo("opencode", connId, MODEL), null); +}); diff --git a/tests/unit/opencode-free-tier-refusal-predicate.test.ts b/tests/unit/opencode-free-tier-refusal-predicate.test.ts new file mode 100644 index 0000000000..0e3928d19b --- /dev/null +++ b/tests/unit/opencode-free-tier-refusal-predicate.test.ts @@ -0,0 +1,134 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + isOpencodeFreeTierRefusal, + isOpencodeFreeTierRefusalForProvider, + isOpencodeGeoBlocked, + isOpencodeUserBlocked, +} from "../../open-sse/executors/opencodeGeoBlock.ts"; +import { + classifyProviderError, + PROVIDER_ERROR_TYPES, +} from "../../open-sse/services/errorClassifier.ts"; + +// Verbatim upstream refusal (2026-09-17): the free tier rejects a request whose +// client identity or request shape does not match the OpenCode client contract. +const REFUSAL_BODY = JSON.stringify({ + type: "error", + error: { + type: "FreeTierError", + message: + "Error from provider (Console): OpenCode's free tier can only be used from within OpenCode", + }, +}); +// What the error parser hands to the classifier: the relayed message alone. +const RELAYED_MESSAGE = + "Error from provider (Console): OpenCode's free tier can only be used from within OpenCode"; +const AUTH_BODY = JSON.stringify({ error: { message: "invalid api key", type: "auth_error" } }); + +describe("isOpencodeFreeTierRefusal", () => { + it("matches the upstream refusal body on 403", () => { + assert.strictEqual(isOpencodeFreeTierRefusal(403, REFUSAL_BODY), true); + }); + it("matches the relayed message alone (no type field available)", () => { + assert.strictEqual(isOpencodeFreeTierRefusal(403, RELAYED_MESSAGE), true); + }); + it("classifies 451 exactly like 403 (one predicate, no status special case)", () => { + assert.strictEqual(isOpencodeFreeTierRefusal(451, REFUSAL_BODY), true); + assert.strictEqual(isOpencodeFreeTierRefusal(451, AUTH_BODY), false); + }); + it("matches regardless of case", () => { + assert.strictEqual(isOpencodeFreeTierRefusal(403, REFUSAL_BODY.toUpperCase()), true); + }); + it("leaves a geo-blocked body to the geo predicate even with the token present", () => { + const geo = JSON.stringify({ + error: { type: "FreeTierError", message: "not available in your country" }, + }); + assert.strictEqual(isOpencodeGeoBlocked(403, geo), true); + assert.strictEqual(isOpencodeFreeTierRefusal(403, geo), false); + }); + it("leaves a user_blocked body to its own predicate", () => { + const blocked = JSON.stringify({ + error: { type: "FreeTierError", message: "[user_blocked] egress refused" }, + }); + assert.strictEqual(isOpencodeUserBlocked(403, blocked), true); + assert.strictEqual(isOpencodeFreeTierRefusal(403, blocked), false); + }); + it("rejects a keyed fingerprint 1010 body even with the token present", () => { + assert.strictEqual( + isOpencodeFreeTierRefusal(403, `{"error_code":1010,"error":{"type":"FreeTierError"}}`), + false + ); + }); + it("rejects 403 without the signal", () => { + assert.strictEqual(isOpencodeFreeTierRefusal(403, AUTH_BODY), false); + }); + it("rejects statuses other than 403 and 451", () => { + for (const status of [200, 400, 401, 426, 429, 500]) { + assert.strictEqual(isOpencodeFreeTierRefusal(status, REFUSAL_BODY), false); + } + }); + it("rejects empty and null bodies", () => { + assert.strictEqual(isOpencodeFreeTierRefusal(403, ""), false); + assert.strictEqual(isOpencodeFreeTierRefusal(403, null), false); + }); +}); + +// The executor predicate reads the whole upstream body; the classifier only ever +// receives the relayed message (parseUpstreamError keeps `error.type` aside). Both +// must agree on every vector, or one layer silently stops recognizing the refusal. +describe("free-tier refusal parity: executor predicate, classifier and auth guard", () => { + const VECTORS: Array<{ label: string; body: string; refusal: boolean }> = [ + { label: "full upstream body", body: REFUSAL_BODY, refusal: true }, + { label: "relayed message only", body: RELAYED_MESSAGE, refusal: true }, + { label: "plain auth refusal", body: AUTH_BODY, refusal: false }, + { label: "empty body", body: "", refusal: false }, + ]; + for (const vector of VECTORS) { + it(`agrees on ${vector.label}`, () => { + assert.strictEqual(isOpencodeFreeTierRefusal(403, vector.body), vector.refusal); + const classified = classifyProviderError(403, vector.body, "opencode"); + assert.strictEqual( + classified === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR, + vector.refusal, + "classifier must recognize exactly the vectors the executor predicate recognizes" + ); + // Third consumer: the auth guard that keeps the refusal off the account and the + // model. It wraps the predicate with the same provider scope the classifier uses, + // so all three have to agree or the refusal is handled at one layer and not another. + assert.strictEqual( + isOpencodeFreeTierRefusalForProvider("opencode", 403, vector.body), + vector.refusal, + "auth guard must recognize exactly the same vectors" + ); + }); + } +}); + +describe("free-tier refusal classification scope", () => { + for (const provider of ["opencode", "opencode-zen", "opencode-go"]) { + it(`classifies the refusal for ${provider} as a non-banning routing error`, () => { + assert.strictEqual( + classifyProviderError(403, RELAYED_MESSAGE, provider), + PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR + ); + }); + } + it("leaves the same sentence unclassified for another provider", () => { + assert.strictEqual(classifyProviderError(403, RELAYED_MESSAGE, "groq"), null); + assert.strictEqual( + isOpencodeFreeTierRefusalForProvider("groq", 403, RELAYED_MESSAGE), + false, + "the auth guard carries the same provider scope as the classifier" + ); + }); + it("does not recognize a 402, which stays a per-model credit failure", () => { + assert.strictEqual( + isOpencodeFreeTierRefusalForProvider("opencode", 402, RELAYED_MESSAGE), + false + ); + }); + it("keeps an ordinary api-key 403 unclassified", () => { + assert.strictEqual(classifyProviderError(403, AUTH_BODY, "opencode"), null); + }); +}); diff --git a/tests/unit/opencode-free-tier-refusal-rotation.test.ts b/tests/unit/opencode-free-tier-refusal-rotation.test.ts new file mode 100644 index 0000000000..3964bcff00 --- /dev/null +++ b/tests/unit/opencode-free-tier-refusal-rotation.test.ts @@ -0,0 +1,202 @@ +import { describe, it, beforeEach, afterEach, before, after } from "node:test"; +import assert from "node:assert"; +import net from "node:net"; +import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; +import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; +import { resetDbInstance } from "../../src/lib/db/core.ts"; + +// Upstream free-tier refusal: the request identity/shape is rejected, the account +// is not. Rotating cannot help (every account gets the same verdict from the same +// request), and the refusal must never improve the account's rotation health — +// markSuccess resets the failure history that drives the cooldown backoff. +const REFUSAL_BODY = JSON.stringify({ + type: "error", + error: { + type: "FreeTierError", + message: + "Error from provider (Console): OpenCode's free tier can only be used from within OpenCode", + }, +}); +const REAL_400_BODY = JSON.stringify({ + error: { type: "invalid_request_error", message: "max_tokens is too large" }, +}); + +const log: ExecutorLog = { debug() {}, info() {}, warn() {}, error() {} }; +const FPS = ["a", "b", "c"].map((c) => c.repeat(32)); +const servers: net.Server[] = []; +const ports: number[] = []; + +function listen(server: net.Server): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve((server.address() as net.AddressInfo).port)); + }); +} + +before(async () => { + for (let i = 0; i < FPS.length; i++) { + const server = net.createServer((s) => s.destroy()); + servers.push(server); + ports.push(await listen(server)); + } +}); + +after(() => { + servers.forEach((s) => s.close()); + resetDbInstance(); +}); + +function credentialsFor(count: number): ProviderCredentials { + const fingerprints = FPS.slice(0, count); + return { + apiKey: null, + accessToken: null, + connectionId: "noauth", + providerSpecificData: { + fingerprints, + accountProxies: fingerprints.map((fp, i) => ({ + fingerprint: fp, + proxy: { type: "http", host: "127.0.0.1", port: ports[i] }, + })), + }, + }; +} + +type AccountsProbe = Array<{ + fingerprint: string; + cooldownUntil: number; + consecutiveFails: number; +}>; + +function accountsOf(exec: OpencodeExecutor): AccountsProbe { + return (exec as unknown as { accounts: AccountsProbe }).accounts; +} + +describe("OpencodeExecutor free-tier refusal", () => { + let originalFetch: typeof globalThis.fetch; + let observed: string[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + observed = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function installFetch(plan: Array<{ status: number; body?: string }>) { + let call = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push(resolved.proxyUrl ? new URL(resolved.proxyUrl).port : "direct"); + const step = plan[Math.min(call, plan.length - 1)]; + call++; + return new Response(step.body ?? JSON.stringify({ ok: true }), { + status: step.status, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + } + + async function run(exec: OpencodeExecutor, creds: ProviderCredentials) { + const result = (await exec.execute({ + model: "muse-spark-1.3-contributor-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: creds, + log, + })) as { response: Response }; + return result.response; + } + + // Materialize the account list, then set the health the refusal must not touch. + async function warmUp(exec: OpencodeExecutor, creds: ProviderCredentials) { + installFetch([{ status: 200 }]); + const warm = await run(exec, creds); + await warm.body?.cancel(); + observed = []; + for (const account of accountsOf(exec)) account.consecutiveFails = 2; + } + + for (const status of [403, 451]) { + it(`${status}: returned as-is, no rotation, account health untouched`, async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const creds = credentialsFor(3); + await warmUp(exec, creds); + installFetch([{ status, body: REFUSAL_BODY }, { status: 200 }]); + + const response = await run(exec, creds); + + assert.strictEqual(response.status, status); + assert.strictEqual(await response.text(), REFUSAL_BODY, "upstream body preserved"); + assert.strictEqual(observed.length, 1, "no rotation: every account gets the same verdict"); + for (const account of accountsOf(exec)) { + assert.strictEqual(account.consecutiveFails, 2, "never marked success"); + assert.strictEqual(account.cooldownUntil, 0, "no cooldown: the account is not at fault"); + } + }); + } + + it("a single account behaves the same (no special case)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const creds = credentialsFor(1); + await warmUp(exec, creds); + installFetch([{ status: 403, body: REFUSAL_BODY }, { status: 200 }]); + + const response = await run(exec, creds); + + assert.strictEqual(response.status, 403); + assert.strictEqual(observed.length, 1); + assert.strictEqual(accountsOf(exec)[0].consecutiveFails, 2); + }); + + it("a 400 carrying a real upstream error no longer marks the account successful", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const creds = credentialsFor(2); + await warmUp(exec, creds); + installFetch([{ status: 400, body: REAL_400_BODY }]); + + const response = await run(exec, creds); + + assert.strictEqual(response.status, 400); + assert.strictEqual(await response.text(), REAL_400_BODY); + for (const account of accountsOf(exec)) { + assert.strictEqual(account.consecutiveFails, 2, "a rejected request is not a success"); + } + }); + + it("an unmatched refusal status no longer marks the account successful", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const creds = credentialsFor(2); + await warmUp(exec, creds); + installFetch([ + { status: 401, body: JSON.stringify({ error: { message: "invalid api key" } }) }, + ]); + + const response = await run(exec, creds); + + assert.strictEqual(response.status, 401); + await response.body?.cancel(); + for (const account of accountsOf(exec)) { + assert.strictEqual(account.consecutiveFails, 2); + } + }); + + it("a successful response still clears the account's failure history", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + const creds = credentialsFor(2); + await warmUp(exec, creds); + installFetch([{ status: 200 }]); + + const response = await run(exec, creds); + + assert.strictEqual(response.status, 200); + await response.body?.cancel(); + const served = accountsOf(exec).filter((a) => a.consecutiveFails === 0); + assert.strictEqual(served.length, 1, "the account that served is reset"); + }); +});