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/15443-opencode-pause-per-model.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion open-sse/services/autoCombo/resilienceCandidateFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function filterResilienceBlockedCandidates<T extends ResilienceFilterCand
// synthetic candidate for a short TTL (never model lockout / cooldown).
if (
isModelLocked(candidate.provider, SYNTHETIC_NOAUTH_CONNECTION_ID, candidate.model) ||
isOpencodeFreeTierSkipped(candidate.provider)
isOpencodeFreeTierSkipped(candidate.provider, Date.now(), candidate.model)
) {
changed = true;
recordAutoExclusion(
Expand Down
139 changes: 113 additions & 26 deletions open-sse/services/opencodeFreeTierSkip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,120 @@
* The refusal is request-scoped (never a model lockout / connection cooldown),
* but re-picking the same noauth candidate on every subsequent auto-combo turn
* only burns latency until the operator adjusts the request shape. This is an
* in-process, provider-keyed TTL skip — not resilience state: keyed OpenCode
* in-process, provider + model keyed TTL skip — not resilience state: keyed OpenCode
* connections keep their own account selection and are never dropped here.
*
* A refusal recorded with a model pauses that model only; sibling models on
* the same provider stay eligible. A refusal recorded without a model keeps
* the previous behaviour and covers the whole provider.
*
* Default TTL ~3 minutes: long enough to stop a tight retry loop, short enough
* that a fixed request shape becomes eligible again without operator action.
*/

const DEFAULT_SKIP_TTL_MS = 3 * 60 * 1000;

/** provider id (lowercase, opencode*) → skip-until epoch ms. */
/** Upper bound on live entries; every entry has a deletion path (see below). */
const MAX_SKIP_ENTRIES = 512;

/**
* Pause key: provider id (lowercase, opencode*) alone for a provider-wide
* pause, or provider + model (both normalized) for a model-scoped pause.
* A single flat map keeps insertion order, which doubles as eviction order.
*/
const skips = new Map<string, number>();

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. */
Expand All @@ -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;
}
31 changes: 24 additions & 7 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,17 +785,22 @@ async function maybeSyntheticNoAuthFallback(
providerId: string,
excludedConnectionIds: Set<string>,
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
// synthetic "noauth" connection unless its allowedConnections names it.
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;
}
Expand Down Expand Up @@ -1246,7 +1251,13 @@ export async function getProviderCredentials(
)
: null;
}
return await maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth, null, true);
return await maybeSyntheticNoAuthFallback(
resolvedId,
excludedForNoAuth,
null,
true,
requestedModel
);
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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, {
Expand Down Expand Up @@ -1742,7 +1757,9 @@ export async function getProviderCredentials(
const syntheticFallback = await maybeSyntheticNoAuthFallback(
resolvedId,
excludedConnectionIds,
allowedConnections
allowedConnections,
false,
requestedModel
);
if (syntheticFallback) return syntheticFallback;

Expand Down
8 changes: 6 additions & 2 deletions src/sse/services/noAuthModelCooldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading