diff --git a/changelog.d/fixes/15443-opencode-pause-per-model.md b/changelog.d/fixes/15443-opencode-pause-per-model.md new file mode 100644 index 000000000000..358d3e665488 --- /dev/null +++ b/changelog.d/fixes/15443-opencode-pause-per-model.md @@ -0,0 +1 @@ +- **fix(auth):** limit the no-auth free-tier pause to the refused model so sibling models stay eligible ([#15443](https://github.com/diegosouzapw/OmniRoute/pull/15443)) — thanks @maxmad64bis diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 7ad2ad01e256..f3267771f7e4 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_10_03_15443_per_model_pause": "Per-model pause own growth: src/sse/services/auth.ts 3623->3640 (+17 = one optional model param + one relayed pause call + one model-aware skip read + four Prettier-split model-threading callsites, irreducible wiring; a shared-helper variant was tried and measured +14 net, reverted). Covered by tests/unit/opencode-free-tier-per-model-pause.test.ts (14). PR #15443.", "_rebaseline_2026_09_29_14973_cursor_conversation_cache": "PR #14973 own growth: open-sse/utils/cursorAgentProtobuf.ts 1619->1622 (+3 over the 1619-line tip after #15036 = the single-line import of the new open-sse/utils/cursorAgentProtobuf/ttft.ts, the TtftBreakdownDelta member of DecodedDelta, and the one-line pushTtftBreakdown call in decodeAgentServerMessage; the turn_ended entry is rewritten at the same length to keep its end signal when the usage body is malformed). The ttft_breakdown decoder, its type and the safely() telemetry helper live in ttft.ts, under cap. open-sse/executors/cursor.ts stays under its 1868 ceiling. Covered by tests/unit/cursor-conversation-cache.test.ts.", "_rebaseline_2026_09_29_15036_cursor_read_range": "PR #15036 (QuangBlue) own growth: open-sse/utils/cursorAgentProtobuf.ts 1588->1619 (+31 = ReadArgs offset/limit field constants, the two optional fields on the exec_read event type, and createReadExecEvent, which decodes them next to the shared path-event decoder it reuses and cannot move out without exporting the file's private varint helpers). The bridge-side range check lives in open-sse/executors/cursor/builtinToolBridge.ts (under cap). Covered by tests/unit/cursor-agent-exec-router.test.ts and tests/unit/cursor-builtin-tool-bridge.test.ts.", "_rebaseline_2026_09_29_15050_timing_safe_env_key": "PR #15050 (HouMinXi) own growth: src/sse/services/auth.ts 3622->3623 (+1 = the timingSafeCompare import; the env passthrough-key check in isValidApiKey swaps `===` for the constant-time helper on its existing line). Irreducible. Covered by tests/unit/secret-compare-constant-time.test.ts.", @@ -563,7 +564,7 @@ "src/shared/constants/providers/apikey/gateways.ts": 1544, "src/shared/services/cliRuntime.ts": 1296, "src/sse/handlers/chat.ts": 2586, - "src/sse/services/auth.ts": 3623, + "src/sse/services/auth.ts": 3640, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656, "open-sse/services/autoCombo/virtualFactory.ts": 1258, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e618526b9d2f..27981208a51c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4076,7 +4076,7 @@ async function handleChatCoreInner({ errorConnectionId === "noauth" && isOpencodeFreeTierRefusalForProvider(provider, statusCode, message) ) { - noteOpencodeFreeTierSkip(provider); + noteOpencodeFreeTierSkip(provider, Date.now(), undefined, targetModel); } } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) { // Google regional refusal: account-independent, non-terminal; park the connection diff --git a/open-sse/services/autoCombo/resilienceCandidateFilter.ts b/open-sse/services/autoCombo/resilienceCandidateFilter.ts index 9a0b168ace42..8aaf9a1f3024 100644 --- a/open-sse/services/autoCombo/resilienceCandidateFilter.ts +++ b/open-sse/services/autoCombo/resilienceCandidateFilter.ts @@ -87,7 +87,7 @@ export function filterResilienceBlockedCandidates(); function isOpencodeProvider(provider: string | null | undefined): boolean { return typeof provider === "string" && provider.toLowerCase().startsWith("opencode"); } +function normalizeModel(model: string | null | undefined): string | null { + if (typeof model !== "string") return null; + const trimmed = model.trim().toLowerCase(); + return trimmed.length === 0 ? null : trimmed; +} + +function buildSkipKey(provider: string, model: string | null | undefined): string { + const providerKey = provider.toLowerCase(); + const normalized = normalizeModel(model); + return normalized === null ? providerKey : `${providerKey}\n${normalized}`; +} + +function readEntry(key: string, now: number): number | null { + const until = skips.get(key); + if (until === undefined) return null; + if (until <= now) { + skips.delete(key); + return null; + } + return until; +} + +/** Drop every entry the reader below would already treat as gone. */ +function evictExpiredEntries(now: number): void { + for (const [storedKey, until] of skips) { + if (until <= now) skips.delete(storedKey); + } +} + +function evictOldestEntries(): void { + while (skips.size >= MAX_SKIP_ENTRIES) { + const oldest = skips.keys().next(); + if (oldest.done) break; + skips.delete(oldest.value); + } +} + /** * Record a free-tier refusal pause for an opencode* provider. Non-opencode * providers are ignored (the free-tier signal is scoped to that family). + * With a model, only that model pauses; without one, the whole provider + * pauses, as before. */ export function noteOpencodeFreeTierSkip( provider: string | null | undefined, now: number = Date.now(), - ttlMs: number = DEFAULT_SKIP_TTL_MS + ttlMs: number = DEFAULT_SKIP_TTL_MS, + model?: string | null ): void { if (!isOpencodeProvider(provider)) return; - skips.set(String(provider).toLowerCase(), now + ttlMs); + const key = buildSkipKey(String(provider), model); + if (skips.size >= MAX_SKIP_ENTRIES && !skips.has(key)) { + evictExpiredEntries(now); + evictOldestEntries(); + } + skips.set(key, now + ttlMs); } -/** True while an active free-tier skip covers this opencode* provider. */ +/** + * True while an active free-tier skip covers this opencode* provider + model + * pair. A provider-wide pause covers every model; without a model, any live + * pause for the provider counts, so callers that cannot name a model keep + * the previous fail-closed behaviour. + */ export function isOpencodeFreeTierSkipped( provider: string | null | undefined, - now: number = Date.now() + now: number = Date.now(), + model?: string | null ): boolean { if (!isOpencodeProvider(provider)) return false; - const key = String(provider).toLowerCase(); - const until = skips.get(key); - if (until === undefined) return false; - if (until <= now) { - skips.delete(key); - return false; + const providerKey = String(provider).toLowerCase(); + const normalized = normalizeModel(model); + if (normalized !== null) { + if (readEntry(`${providerKey}\n${normalized}`, now) !== null) return true; + return readEntry(providerKey, now) !== null; + } + if (readEntry(providerKey, now) !== null) return true; + const prefix = `${providerKey}\n`; + for (const [storedKey, until] of skips) { + if (!storedKey.startsWith(prefix)) continue; + if (until <= now) { + skips.delete(storedKey); + continue; + } + return true; } - return true; + return false; } /** Test helper — drop every active skip. */ @@ -56,23 +127,39 @@ export function clearOpencodeFreeTierSkips(): void { } /** - * Remaining pause time in ms for an opencode* provider, or null when no - * active pause covers it. Read-only companion to `isOpencodeFreeTierSkipped`: - * a single map lookup per call; an expired entry is dropped lazily, so a - * null/negative remainder hands the connection back instead of a zero-second - * cooldown. + * Remaining pause time in ms for an opencode* provider + model pair, or null + * when no active pause covers it. Read-only companion to + * `isOpencodeFreeTierSkipped`: an expired entry is dropped lazily, so a + * null remainder hands the connection back instead of a zero-second + * cooldown. Without a model, reports the longest live remainder for the + * provider, so the caller waits out the worst case instead of retrying + * into a still-paused model. */ export function getOpencodeFreeTierSkipRemainingMs( provider: string | null | undefined, - now: number = Date.now() + now: number = Date.now(), + model?: string | null ): number | null { if (!isOpencodeProvider(provider)) return null; - const key = String(provider).toLowerCase(); - const until = skips.get(key); - if (until === undefined) return null; - if (until <= now) { - skips.delete(key); - return null; + const providerKey = String(provider).toLowerCase(); + const normalized = normalizeModel(model); + if (normalized !== null) { + const exact = readEntry(`${providerKey}\n${normalized}`, now); + if (exact !== null) return exact - now; + const wide = readEntry(providerKey, now); + return wide === null ? null : wide - now; + } + const wide = readEntry(providerKey, now); + let longest = wide === null ? null : wide - now; + const prefix = `${providerKey}\n`; + for (const [storedKey, until] of skips) { + if (!storedKey.startsWith(prefix)) continue; + if (until <= now) { + skips.delete(storedKey); + continue; + } + const remaining = until - now; + if (longest === null || remaining > longest) longest = remaining; } - return until - now; + return longest; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 89f8fdbfff63..14d11f22b62f 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -785,7 +785,8 @@ async function maybeSyntheticNoAuthFallback( providerId: string, excludedConnectionIds: Set, allowedConnections: string[] | null = null, - pauseAsCooldown = false + pauseAsCooldown = false, + requestedModelForPause?: string | null ) { if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null; // #9057: a restricted key must NOT reach free providers (OpenCode Free, etc.) through the @@ -793,9 +794,13 @@ async function maybeSyntheticNoAuthFallback( if (!allowlistPermitsSyntheticNoAuth(allowedConnections)) return null; if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null; if (pauseAsCooldown) { - const paused = pauseCooldownIfPaused(providerId, SYNTHETIC_NOAUTH_CONNECTION_ID); + const paused = pauseCooldownIfPaused( + providerId, + SYNTHETIC_NOAUTH_CONNECTION_ID, + requestedModelForPause + ); if (paused) return paused; - } else if (isOpencodeFreeTierSkipped(providerId)) { + } else if (isOpencodeFreeTierSkipped(providerId, Date.now(), requestedModelForPause)) { log.info("AUTH", `${providerId} | no-auth fallback skipped (OpenCode free-tier pause)`); // #14313 return null; } @@ -1246,7 +1251,13 @@ export async function getProviderCredentials( ) : null; } - return await maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth, null, true); + return await maybeSyntheticNoAuthFallback( + resolvedId, + excludedForNoAuth, + null, + true, + requestedModel + ); } } @@ -1470,7 +1481,9 @@ export async function getProviderCredentials( const syntheticFallback = await maybeSyntheticNoAuthFallback( resolvedId, excludedConnectionIds, - allowedConnections + allowedConnections, + false, + requestedModel ); if (syntheticFallback) return syntheticFallback; return buildAllExpiredCredentials(terminalConnections); @@ -1479,7 +1492,9 @@ export async function getProviderCredentials( const syntheticFallback = await maybeSyntheticNoAuthFallback( resolvedId, excludedConnectionIds, - allowedConnections + allowedConnections, + false, + requestedModel ); if (syntheticFallback) return syntheticFallback; const jinaEnvCredentials = buildJinaEnvCredentials(resolvedId, { @@ -1742,7 +1757,9 @@ export async function getProviderCredentials( const syntheticFallback = await maybeSyntheticNoAuthFallback( resolvedId, excludedConnectionIds, - allowedConnections + allowedConnections, + false, + requestedModel ); if (syntheticFallback) return syntheticFallback; diff --git a/src/sse/services/noAuthModelCooldown.ts b/src/sse/services/noAuthModelCooldown.ts index 8c30a4ccc4e2..cbb0d08ad4c5 100644 --- a/src/sse/services/noAuthModelCooldown.ts +++ b/src/sse/services/noAuthModelCooldown.ts @@ -40,8 +40,12 @@ export function buildNoAuthModelCooldown( * connection — or null when no active pause covers the provider (including an * expired pause, whose entry the reader drops), so selection keeps hydrating. */ -export function pauseCooldownIfPaused(provider: string, connectionId: string) { - const remainingMs = getOpencodeFreeTierSkipRemainingMs(provider); +export function pauseCooldownIfPaused( + provider: string, + connectionId: string, + model?: string | null +) { + const remainingMs = getOpencodeFreeTierSkipRemainingMs(provider, Date.now(), model); if (remainingMs === null) return null; if (remainingMs <= 0) return null; return buildNoAuthPauseCooldown(provider, remainingMs, connectionId); diff --git a/tests/unit/opencode-free-tier-per-model-pause.test.ts b/tests/unit/opencode-free-tier-per-model-pause.test.ts new file mode 100644 index 000000000000..693c9094689f --- /dev/null +++ b/tests/unit/opencode-free-tier-per-model-pause.test.ts @@ -0,0 +1,162 @@ +/** + * A free-tier pause covers one provider + model pair: refusing model A must + * not block sibling model B on the same provider, in selection or in the + * auto-combo candidate filter. + * + * A refusal recorded without a model keeps the previous behaviour and covers + * the whole provider. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + noteOpencodeFreeTierSkip, + isOpencodeFreeTierSkipped, + getOpencodeFreeTierSkipRemainingMs, + clearOpencodeFreeTierSkips, +} = await import("../../open-sse/services/opencodeFreeTierSkip.ts"); +const { filterResilienceBlockedCandidates } = + await import("../../open-sse/services/autoCombo/resilienceCandidateFilter.ts"); +const { pauseCooldownIfPaused } = await import("../../src/sse/services/noAuthModelCooldown.ts"); + +const PROVIDER = "opencode"; +const MODEL_A = "nemotron-3.5-lightning-free"; +const MODEL_B = "muse-spark-1.3-contributor-free"; +const TTL_MS = 60_000; + +test.after(() => { + clearOpencodeFreeTierSkips(); +}); + +test.beforeEach(() => { + clearOpencodeFreeTierSkips(); +}); + +test("a refusal on one model leaves a sibling model on the same provider served", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, MODEL_B), false); + assert.equal(getOpencodeFreeTierSkipRemainingMs(PROVIDER, now + 10, MODEL_B), null); +}); + +test("a refused model stays paused until its pause expires", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, MODEL_A), true); + assert.equal(getOpencodeFreeTierSkipRemainingMs(PROVIDER, now + 10, MODEL_A), TTL_MS - 10); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + TTL_MS, MODEL_A), false); + assert.equal(getOpencodeFreeTierSkipRemainingMs(PROVIDER, now + TTL_MS, MODEL_A), null); +}); + +test("the auto-combo filter drops only the refused model candidate", () => { + noteOpencodeFreeTierSkip(PROVIDER, Date.now(), TTL_MS, MODEL_A); + const pool = [ + { provider: PROVIDER, connectionId: "noauth", model: MODEL_A }, + { provider: PROVIDER, connectionId: "noauth", model: MODEL_B }, + ]; + const filtered = filterResilienceBlockedCandidates(pool, new Map()); + assert.deepEqual( + filtered.map((candidate) => candidate.model), + [MODEL_B], + "only the refused model candidate is dropped while its pause is active" + ); +}); + +test("the free-fast pool serves the sibling model after a single refusal", () => { + noteOpencodeFreeTierSkip(PROVIDER, Date.now(), TTL_MS, MODEL_A); + const pool = [ + { provider: PROVIDER, connectionId: "noauth", model: MODEL_A }, + { provider: PROVIDER, connectionId: "noauth", model: MODEL_B }, + ]; + const filtered = filterResilienceBlockedCandidates(pool, new Map()); + assert.ok( + filtered.some((candidate) => candidate.model === MODEL_B), + "the sibling model stays eligible after a refusal on another model" + ); + assert.ok( + !filtered.some((candidate) => candidate.model === MODEL_A), + "the refused model stays out of the pool while its pause is active" + ); +}); + +test("the no-auth pause envelope fires for the refused model only", () => { + noteOpencodeFreeTierSkip(PROVIDER, Date.now(), TTL_MS, MODEL_A); + assert.ok(pauseCooldownIfPaused(PROVIDER, "noauth", MODEL_A), "refused model answers a cooldown"); + assert.equal( + pauseCooldownIfPaused(PROVIDER, "noauth", MODEL_B), + null, + "sibling model is not covered by the pause" + ); +}); + +test("a provider-wide pause still covers every model", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, MODEL_A), true); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, MODEL_B), true); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10), true); +}); + +test("a read without a model sees a model-scoped pause", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10), true); + assert.ok(getOpencodeFreeTierSkipRemainingMs(PROVIDER, now + 10) !== null); +}); + +test("a model-scoped pause expires and hands the model back", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, 50, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 51, MODEL_A), false); + assert.equal(getOpencodeFreeTierSkipRemainingMs(PROVIDER, now + 51, MODEL_A), null); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 51), false); +}); + +test("a foreign provider records nothing, with or without a model", () => { + noteOpencodeFreeTierSkip("groq", 1_000_000, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped("groq", 1_000_010, MODEL_A), false); + assert.equal(getOpencodeFreeTierSkipRemainingMs("groq", 1_000_010, MODEL_A), null); +}); + +test("an unknown model is served", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, "some-model-never-paused"), false); +}); + +test("model matching ignores case and surrounding whitespace", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, ` ${MODEL_A.toUpperCase()} `), true); +}); + +test("a blank or non-string model behaves like no model", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, MODEL_A); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, " "), true); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, 42 as unknown as string), true); +}); + +test("the entry count stays bounded under many model pauses", () => { + const now = 1_000_000; + const totalWrites = 600; + for (let i = 0; i < totalWrites; i += 1) { + noteOpencodeFreeTierSkip(PROVIDER, now, TTL_MS, `model-${i}`); + } + assert.equal( + isOpencodeFreeTierSkipped(PROVIDER, now + 10, "model-0"), + false, + "the oldest entry is evicted once the bound is reached" + ); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 10, `model-${totalWrites - 1}`), true); +}); + +test("expired entries are purged before live ones are evicted", () => { + const now = 1_000_000; + noteOpencodeFreeTierSkip(PROVIDER, now, 10, "expired-model"); + for (let i = 0; i < 600; i += 1) { + noteOpencodeFreeTierSkip(PROVIDER, now + 20, TTL_MS, `live-model-${i}`); + } + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 30, "expired-model"), false); + assert.equal(isOpencodeFreeTierSkipped(PROVIDER, now + 30, "live-model-599"), true); +});