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(auth):** answer a paused no-auth provider with a retryable cooldown ([#15402](https://github.com/diegosouzapw/OmniRoute/pull/15402)) — thanks @maxmad64bis
22 changes: 22 additions & 0 deletions open-sse/services/opencodeFreeTierSkip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,25 @@ export function isOpencodeFreeTierSkipped(
export function clearOpencodeFreeTierSkips(): void {
skips.clear();
}

/**
* 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.
*/
export function getOpencodeFreeTierSkipRemainingMs(
provider: string | null | undefined,
now: number = Date.now()
): 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;
}
return until - now;
}
16 changes: 9 additions & 7 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import {
persistAntigravityFamilyCooldownIfQuota,
} from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts";
import { markQuotaPreflightAccountUnavailable } from "./quotaPreflightUnavailable.ts";
import { buildNoAuthModelCooldown } from "./noAuthModelCooldown.ts";
import { buildNoAuthModelCooldown, pauseCooldownIfPaused } from "./noAuthModelCooldown.ts";
import { getCreditsMode } from "@omniroute/open-sse/services/antigravityCredits.ts";
import { preferAntigravityConnectionsWithStoredProject } from "@omniroute/open-sse/services/antigravityProjectPersistence.ts";
import {
Expand Down Expand Up @@ -784,17 +784,19 @@ function isAnonymousFallbackOnlyProvider(providerId: string): boolean {
async function maybeSyntheticNoAuthFallback(
providerId: string,
excludedConnectionIds: Set<string>,
allowedConnections: string[] | null = null
allowedConnections: string[] | null = null,
pauseAsCooldown = false
) {
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;
// #14313: a free-tier refusal just paused this keyless path — do not re-select
// the synthetic noauth connection until the short TTL expires.
if (isOpencodeFreeTierSkipped(providerId)) {
log.info("AUTH", `${providerId} | no-auth fallback skipped (OpenCode free-tier pause)`);
if (pauseAsCooldown) {
const paused = pauseCooldownIfPaused(providerId, SYNTHETIC_NOAUTH_CONNECTION_ID);
if (paused) return paused;
} else if (isOpencodeFreeTierSkipped(providerId)) {
log.info("AUTH", `${providerId} | no-auth fallback skipped (OpenCode free-tier pause)`); // #14313
return null;
}
if (
Expand Down Expand Up @@ -1244,7 +1246,7 @@ export async function getProviderCredentials(
)
: null;
}
return await maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth);
return await maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth, null, true);
}
}

Expand Down
68 changes: 60 additions & 8 deletions src/sse/services/noAuthModelCooldown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatRetryAfter } from "@omniroute/open-sse/services/accountFallback.ts";
import { getOpencodeFreeTierSkipRemainingMs } from "@omniroute/open-sse/services/opencodeFreeTierSkip.ts";
import * as log from "../utils/logger";

interface ActiveModelLockout {
Expand All @@ -21,20 +22,71 @@ export function buildNoAuthModelCooldown(
lockout: ActiveModelLockout,
connectionId: string
) {
const retryAfter = new Date(Date.now() + lockout.remainingMs).toISOString();
return buildNoAuthCooldownEnvelope({
provider,
connectionId,
remainingMs: lockout.remainingMs,
cooldownScope: "model",
cooldownModel: model,
lastError: null,
logDetail: `model ${model} locked (${lockout.reason})`,
});
}

/**
* Paused no-auth provider: the shared synthetic connection is temporarily out
* while a provider-scoped refusal pause covers it. Returns the same retryable
* envelope shape as the model lockout above (429 + Retry-After) scoped to the
* 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);
if (remainingMs === null) return null;
if (remainingMs <= 0) return null;
return buildNoAuthPauseCooldown(provider, remainingMs, connectionId);
}

function buildNoAuthPauseCooldown(provider: string, remainingMs: number, connectionId: string) {
return buildNoAuthCooldownEnvelope({
provider,
connectionId,
remainingMs,
cooldownScope: "connection",
cooldownModel: null,
lastError: "The shared no-auth connection is in a refusal pause",
logDetail: "shared connection paused",
});
}

function buildNoAuthCooldownEnvelope({
provider,
connectionId,
remainingMs,
cooldownScope,
cooldownModel,
lastError,
logDetail,
}: {
provider: string;
connectionId: string;
remainingMs: number;
cooldownScope: string;
cooldownModel: string | null;
lastError: string | null;
logDetail: string;
}) {
const retryAfter = new Date(Date.now() + remainingMs).toISOString();
const retryAfterHuman = formatRetryAfter(retryAfter);
log.warn(
"AUTH",
`${provider} | ${connectionId} model ${model} locked (${lockout.reason}), ${retryAfterHuman}`
);
log.warn("AUTH", `${provider} | ${connectionId} ${logDetail}, ${retryAfterHuman}`);
return {
allRateLimited: true,
retryAfter,
retryAfterHuman,
lastError: null,
lastError,
lastErrorCode: 429,
cooldownScope: "model",
cooldownModel: model,
cooldownScope,
cooldownModel,
connectionsCount: 1,
};
}
2 changes: 2 additions & 0 deletions stryker.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"tests/unit/intelligent-routing-weight-edit.test.ts",
"tests/unit/noauth-model-lockout-retryable.test.ts",
"tests/unit/noauth-refresh-guard.test.ts",
"tests/unit/noauth-pause-retryable-cooldown.test.ts",
"tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts",
"tests/unit/7993-noauth-proxy-routing.test.ts",
"tests/unit/8200-perplexity-web-401-cooldown.test.ts",
Expand Down Expand Up @@ -534,6 +535,7 @@
"tests/unit/muse-code-oauth.test.ts",
"tests/unit/noauth-model-lockout-retryable.test.ts",
"tests/unit/noauth-refresh-guard.test.ts",
"tests/unit/noauth-pause-retryable-cooldown.test.ts",
"tests/unit/model-not-found-must-not-poison-credential.test.ts",
"tests/unit/codex-reasoning-replay-rejection.test.ts",
"tests/unit/opencode-free-tier-combo-scoped-failure-14313.test.ts",
Expand Down
Loading
Loading