diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 8006cbb2f3..5695cf0e20 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -54,11 +54,6 @@ "count": 3 } }, - "open-sse/handlers/chatCore/codexFailover.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "open-sse/handlers/chatCore/comboContextCache.ts": { "no-restricted-imports": { "count": 1 diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index d73d520650..b7bc008ec3 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -42,8 +42,8 @@ import { errorResponse } from "../utils/error.ts"; import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts"; import * as prl from "../utils/providerRequestLogging.ts"; import { createRequire } from "module"; -// Quota parsing/scheduling extracted to a pure leaf; re-exported for external -// importers (handlers/chatCore/codexQuota.ts + tests). +// Quota parsing/scheduling extracted to a pure leaf; re-exported for the +// Codex account module and tests. export { type CodexQuotaSnapshot, parseCodexQuotaHeaders, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index e8d0c7835b..d93d3d1482 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -334,7 +334,7 @@ import { import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts"; import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts"; import { isCompactResponsesEndpoint } from "../executors/codex.ts"; -import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts"; +import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts"; @@ -656,40 +656,6 @@ export async function handleChatCore({ creds: Record | null | undefined, transport?: string ): void => recordKeyHealthStatusFor(status, creds, log, transport); - const persistCodexQuotaState = async (headers: Record | null, status = 0) => { - const currentConnectionId = getCurrentConnectionId(); - if (provider !== "codex" || !currentConnectionId || !headers) return; - try { - const existingProviderData = - credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" - ? (credentials.providerSpecificData as Record) - : {}; - // Pure payload build extracted to chatCore/codexQuota.ts (#3501). Returns null when the - // response carries no quota headers (nothing to persist). - const built = buildCodexQuotaPersistence({ - headers, - existingProviderData, - modelForScope: model || requestedModel || "", - status, - }); - if (!built) return; - if (built.exhaustionLog) { - log?.debug?.("CODEX", built.exhaustionLog); - } - // Invalidate the preflight cache for this connection so the next - // isModelAvailable check fetches fresh quota data. - if (status === 429) { - invalidateCodexQuotaCache(currentConnectionId); - } - await updateProviderConnection(currentConnectionId, { - providerSpecificData: built.nextProviderData, - }); - credentials.providerSpecificData = built.nextProviderData; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); - } - }; // ── Phase 9.2: Idempotency check ── // Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below, // rather than re-deriving it. (#3821-review LEDGER-6) @@ -3056,6 +3022,29 @@ export async function handleChatCore({ const res = normalizeExecutorResult(rawExecutorResult); trace("post_executor", { status: res?.response?.status }); + if (provider === "codex" && attemptConnectionId) { + try { + const persistedQuota = await persistCodexChildQuotaResponse({ + connectionId: String(attemptConnectionId), + model: modelToCall || model || requestedModel || "", + headers: normalizeHeaders(res.response.headers), + status: res.response.status, + }); + if (persistedQuota) { + execCreds.providerSpecificData = persistedQuota.providerSpecificData; + if (persistedQuota.exhaustionLog) { + log?.debug?.("CODEX", persistedQuota.exhaustionLog); + } + } + if (res.response.status === 429) { + invalidateCodexQuotaCache(String(attemptConnectionId)); + } + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); + } + } + // Track Gemini RPM + RPD request counts for 429 classification if (provider === "gemini") { incrementRequestCount(modelToCall); @@ -3110,29 +3099,15 @@ export async function handleChatCore({ `429 on connection ${String(failedConnectionId).slice(0, 8)} (attempt ${attempts + 1}/${maxAttempts}), rotating account` ); - // Mark only the current Codex model scope as rate-limited. + // Mark only the current Codex model scope as rate-limited. A connection-wide + // cooldown here would let a Spark limit suppress independent Sol/Terra traffic. if (failedConnectionId) { await markCodexScopeRateLimited({ failedConnectionId: String(failedConnectionId), model: modelToCall || model || requestedModel || null, rateLimitedUntil: new Date(Date.now() + (retryAfterMs || 60_000)).toISOString(), - credentials, + credentials: execCreds || credentials, }); - // Fix B: also persist the cooldown to - // `provider_connections.rate_limited_until`. Without this, - // the Codex 429 cascade survives the current request (via - // `markCodexScopeRateLimited`'s in-memory Map) but is lost - // on process restart — the same exhausted Codex key is - // re-picked on the very next request. Mirrors - // `open-sse/executors/antigravity.ts:343`. - // Best-effort: never crash the chat path on DB write failure. - try { - const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); - const untilMs = Date.now() + (retryAfterMs || 60_000); - setConnectionRateLimitUntil(String(failedConnectionId), untilMs); - } catch { - // ignore — best effort - } if (!codexExcludedIds.includes(String(failedConnectionId))) { codexExcludedIds.push(String(failedConnectionId)); } @@ -3869,8 +3844,6 @@ export async function handleChatCore({ } } - await persistCodexQuotaState(normalizeHeaders(providerResponse.headers), providerResponse.status); - // Check provider response - return error info for fallback handling providerFailure: if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false); diff --git a/open-sse/handlers/chatCore/codexFailover.ts b/open-sse/handlers/chatCore/codexFailover.ts index f552af7375..d48cb7d4bc 100644 --- a/open-sse/handlers/chatCore/codexFailover.ts +++ b/open-sse/handlers/chatCore/codexFailover.ts @@ -1,42 +1,29 @@ -import { getCodexModelScope } from "../../config/codexQuotaScopes.ts"; -import { updateProviderConnection } from "@/lib/db/providers"; -import { getCachedProviderConnectionById } from "@/lib/localDb"; +import { persistCodexChildCooldown } from "../../services/codexAccount/index.ts"; type CodexFailoverCredentials = { connectionId?: string | null; providerSpecificData?: unknown; }; -function asProviderData(value: unknown): Record { - return value && typeof value === "object" ? (value as Record) : {}; -} - export async function markCodexScopeRateLimited(params: { failedConnectionId: string; model: string | null; rateLimitedUntil: string; credentials?: CodexFailoverCredentials | null; }): Promise { - const connection = await getCachedProviderConnectionById(params.failedConnectionId).catch(() => null); - const existingProviderData = connection - ? asProviderData(connection.providerSpecificData) - : asProviderData(params.credentials?.providerSpecificData); - const existingScopeMap = asProviderData(existingProviderData.codexScopeRateLimitedUntil); - const nextProviderData = { - ...existingProviderData, - codexScopeRateLimitedUntil: { - ...existingScopeMap, - [getCodexModelScope(params.model || "")]: params.rateLimitedUntil, - }, - }; + const persisted = params.model + ? await persistCodexChildCooldown({ + connectionId: params.failedConnectionId, + model: params.model, + rateLimitedUntil: params.rateLimitedUntil, + }).catch(() => null) + : null; - updateProviderConnection(params.failedConnectionId, { - ...(connection ? { providerSpecificData: nextProviderData } : {}), - lastError: "429 rate limited — codex account rotation", - errorCode: 429, - }).catch(() => {}); - - if (params.credentials && String(params.credentials.connectionId) === params.failedConnectionId) { - params.credentials.providerSpecificData = nextProviderData; + if ( + persisted && + params.credentials && + String(params.credentials.connectionId) === params.failedConnectionId + ) { + params.credentials.providerSpecificData = persisted.providerSpecificData; } } diff --git a/open-sse/handlers/chatCore/codexQuota.ts b/open-sse/handlers/chatCore/codexQuota.ts deleted file mode 100644 index 7bc6eac851..0000000000 --- a/open-sse/handlers/chatCore/codexQuota.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * chatCore Codex quota-persistence builder (Quality Gate v2 / Fase 9 — chatCore god-file - * decomposition, #3501). - * - * Pure core of handleChatCore's persistCodexQuotaState: turns the upstream Codex quota response - * headers into the next `providerSpecificData` payload (the codexQuotaState snapshot, plus — on a - * 429 whose dual-window usage is past the exhaustion threshold — the per-scope cooldown timestamp, - * the exhausted window, and the debug-log message). The handler keeps the impure parts byte- - * identically: the DB write (updateProviderConnection), the preflight-cache invalidation on every - * 429, the credentials mutation, and emitting the returned log line. - */ - -import { - parseCodexQuotaHeaders, - getCodexModelScope, - getCodexDualWindowCooldownMs, -} from "../../executors/codex.ts"; - -export type CodexQuotaPersistence = { - /** The merged providerSpecificData to persist (existing data + codexQuotaState [+ 429 cooldown]). */ - nextProviderData: Record; - /** The CODEX debug-log message to emit when a 429 exhausted a window, else null. */ - exhaustionLog: string | null; -}; - -/** - * Build the providerSpecificData update for a Codex quota response. Returns null when the response - * carries no quota headers (nothing to persist). Pure: a function of the headers, the existing - * provider data, the model used for scope resolution, and the upstream status. - */ -export function buildCodexQuotaPersistence(opts: { - headers: Record; - existingProviderData: Record; - modelForScope: string; - status: number; -}): CodexQuotaPersistence | null { - const { headers, existingProviderData, modelForScope, status } = opts; - - const quota = parseCodexQuotaHeaders(headers); - if (!quota) return null; - - const scope = getCodexModelScope(modelForScope); - const quotaState = { - usage5h: quota.usage5h, - limit5h: quota.limit5h, - resetAt5h: quota.resetAt5h, - usage7d: quota.usage7d, - limit7d: quota.limit7d, - resetAt7d: quota.resetAt7d, - scope, - updatedAt: new Date().toISOString(), - }; - - const nextProviderData: Record = { - ...existingProviderData, - codexQuotaState: quotaState, - }; - - let exhaustionLog: string | null = null; - - // T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking. - // Use dual-window cooldown to distinguish short-term and weekly Codex exhaustion. - if (status === 429) { - const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota); - if (cooldownMs > 0) { - const scopeUntil = new Date(Date.now() + cooldownMs).toISOString(); - const scopeMapRaw = - existingProviderData && - typeof existingProviderData === "object" && - existingProviderData.codexScopeRateLimitedUntil && - typeof existingProviderData.codexScopeRateLimitedUntil === "object" - ? existingProviderData.codexScopeRateLimitedUntil - : {}; - - nextProviderData.codexScopeRateLimitedUntil = { - ...(scopeMapRaw as Record), - [scope]: scopeUntil, - }; - nextProviderData.codexExhaustedWindow = exhaustedWindow; - exhaustionLog = `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}`; - } - } - - return { nextProviderData, exhaustionLog }; -} diff --git a/open-sse/services/codexAccount/index.ts b/open-sse/services/codexAccount/index.ts new file mode 100644 index 0000000000..b9c82838e7 --- /dev/null +++ b/open-sse/services/codexAccount/index.ts @@ -0,0 +1,189 @@ +import { getCodexModelScope } from "../../config/codexQuotaScopes.ts"; +import { + getCodexChildQuotaHydration, + getEarliestCodexChildCooldown, + inspectCodexAccount, +} from "./state.ts"; +import type { + CodexAccount, + CodexAccountConnection, + CodexAccountPool, + CodexChildAccount, + CodexParentAccount, + CodexAccountPoolProjection, + CodexQuotaWindowSnapshot, +} from "./types.ts"; + +function createParentAccount(connection: CodexAccountConnection): CodexParentAccount { + return { + kind: "parent", + key: { parentConnectionId: connection.id, scope: null }, + connectionId: connection.id, + scope: null, + connection, + }; +} + +function createChildAccount( + connection: CodexAccountConnection, + scope: CodexChildAccount["scope"] +): CodexChildAccount { + return { + kind: "child", + key: { parentConnectionId: connection.id, scope }, + connectionId: connection.id, + scope, + connection, + }; +} + +/** Build one parent and two virtual children around a single DB connection. */ +export function createCodexAccountPool(connection: CodexAccountConnection): CodexAccountPool { + const parent = createParentAccount(connection); + const codex = createChildAccount(connection, "codex"); + const spark = createChildAccount(connection, "spark"); + return { + parent, + children: [codex, spark], + accounts: [parent, codex, spark], + }; +} + +/** Project one persisted connection into the safe parent/child account read model. */ +export function projectCodexAccountPool( + connection: CodexAccountConnection, + now = Date.now() +): CodexAccountPoolProjection { + const pool = createCodexAccountPool(connection); + const children = pool.children.map((child) => { + const state = inspectCodexAccount(pool, child, now); + const hydration = getCodexChildQuotaHydration(child); + const quotaWindow = (window: "5h" | "7d"): CodexQuotaWindowSnapshot | null => { + const quota = hydration.quotaState; + if (!quota) return null; + const usage = quota[window === "5h" ? "usage5h" : "usage7d"]; + const limit = quota[window === "5h" ? "limit5h" : "limit7d"]; + const resetAt = quota[window === "5h" ? "resetAt5h" : "resetAt7d"] ?? null; + if (typeof usage !== "number" && typeof limit !== "number" && !resetAt) return null; + return { + usage: typeof usage === "number" ? usage : null, + limit: typeof limit === "number" ? limit : null, + resetAt, + usedPercentage: + typeof usage === "number" && typeof limit === "number" && limit > 0 + ? (usage / limit) * 100 + : null, + }; + }; + const cooldownActive = Boolean( + state.rateLimitedUntil && new Date(state.rateLimitedUntil).getTime() > now + ); + const exhaustedWindow = hydration.exhaustedWindow; + const exhaustedResetAt = + exhaustedWindow === "5h" + ? hydration.quotaState?.resetAt5h + : exhaustedWindow === "7d" + ? hydration.quotaState?.resetAt7d + : null; + const exhaustionActive = Boolean( + exhaustedWindow && exhaustedResetAt && new Date(exhaustedResetAt).getTime() > now + ); + const unavailable = cooldownActive || exhaustionActive; + return { + key: child.key, + unavailable, + cooldown: { + active: cooldownActive, + rateLimitedUntil: cooldownActive ? state.rateLimitedUntil : null, + }, + quota: { + exhaustedWindow: exhaustionActive ? exhaustedWindow : null, + observedAt: hydration.quotaState?.observedAt ?? null, + windows: { "5h": quotaWindow("5h"), "7d": quotaWindow("7d") }, + }, + }; + }) as [CodexAccountPoolProjection["children"][0], CodexAccountPoolProjection["children"][1]]; + const limitedChildCount = children.filter((child) => child.unavailable).length; + return { + parentConnectionId: connection.id, + aggregate: { + status: + limitedChildCount === 0 + ? "available" + : limitedChildCount === children.length + ? "fully_limited" + : "partially_limited", + limitedChildCount, + }, + children, + }; +} + +/** Resolve the scoped child whose quota owns a nonblank model, or the parent otherwise. */ +export function resolveCodexAccount( + pool: CodexAccountPool, + model: string | null | undefined +): CodexAccount { + if (typeof model !== "string" || model.trim().length === 0) return pool.parent; + const scope = getCodexModelScope(model); + return pool.children.find((account) => account.scope === scope) || pool.parent; +} + +function inspectResolvedCodexChild( + connection: CodexAccountConnection, + model: string | null | undefined, + now = Date.now() +) { + const pool = createCodexAccountPool(connection); + const state = inspectCodexAccount(pool, resolveCodexAccount(pool, model), now); + return state.kind === "child" ? state : null; +} + +/** Return whether the requested model's virtual child is currently unavailable. */ +export function isCodexChildUnavailable( + connection: CodexAccountConnection, + model: string | null | undefined, + now = Date.now() +): boolean { + return inspectResolvedCodexChild(connection, model, now)?.unavailable ?? false; +} + +/** Return the active cooldown for the requested model's virtual child. */ +export function getCodexChildCooldown( + connection: CodexAccountConnection, + model: string | null | undefined, + now = Date.now() +): string | null { + return inspectResolvedCodexChild(connection, model, now)?.rateLimitedUntil ?? null; +} + +export { + getCodexAccountPoolState, + getCodexChildQuotaHydration, + getCodexParentAccountDiagnostic, + getEarliestCodexChildCooldown, + inspectCodexAccount, +} from "./state.ts"; +export { persistCodexChildCooldown } from "./write.ts"; +export type { PersistCodexChildCooldownResult } from "./write.ts"; +export { persistCodexChildQuotaResponse } from "./quota.ts"; +export type { PersistCodexChildQuotaResult } from "./quota.ts"; +export type { + CodexAccount, + CodexAccountConnection, + CodexAccountKey, + CodexAccountPool, + CodexChildAccount, + CodexAccountPoolState, + CodexAccountPoolStatus, + CodexAccountState, + CodexChildAccountState, + CodexChildCooldown, + CodexChildQuotaHydration, + CodexAccountPoolProjection, + CodexChildAccountProjection, + CodexQuotaWindowSnapshot, + CodexParentAccount, + CodexParentAccountDiagnostic, + CodexPersistedQuotaState, +} from "./types.ts"; diff --git a/open-sse/services/codexAccount/quota.ts b/open-sse/services/codexAccount/quota.ts new file mode 100644 index 0000000000..3317ebd5be --- /dev/null +++ b/open-sse/services/codexAccount/quota.ts @@ -0,0 +1,71 @@ +import { + getCodexDualWindowCooldownMs, + getCodexModelScope, + parseCodexQuotaHeaders, +} from "../../executors/codex.ts"; +import { updateCodexScopedQuotaState } from "@/lib/db/providers"; +import type { CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; + +export interface PersistCodexChildQuotaResult { + readonly scope: CodexQuotaScope; + readonly providerSpecificData: Record; + readonly exhaustionLog: string | null; +} + +/** Parse and atomically persist one virtual child's quota response evidence. */ +export async function persistCodexChildQuotaResponse(params: { + connectionId: string; + model: string; + headers: Record; + status: number; + fallbackRateLimitedUntil?: string | null; +}): Promise { + if (params.model.trim().length === 0) return null; + const quota = parseCodexQuotaHeaders(params.headers); + if (!quota) return null; + + const scope = getCodexModelScope(params.model); + const quotaState = { + usage5h: quota.usage5h, + limit5h: quota.limit5h, + resetAt5h: quota.resetAt5h, + usage7d: quota.usage7d, + limit7d: quota.limit7d, + resetAt7d: quota.resetAt7d, + observedAt: new Date().toISOString(), + }; + let exhaustedWindow: "5h" | "7d" | undefined; + let rateLimitedUntil: string | undefined; + + if (params.status === 429) { + const exhausted = getCodexDualWindowCooldownMs(quota); + if (exhausted.cooldownMs > 0 && exhausted.window !== "none") { + exhaustedWindow = exhausted.window; + rateLimitedUntil = + exhausted.window === "7d" ? (quota.resetAt7d ?? undefined) : (quota.resetAt5h ?? undefined); + } else if (params.fallbackRateLimitedUntil) { + rateLimitedUntil = params.fallbackRateLimitedUntil; + } + } + + const providerSpecificData = await updateCodexScopedQuotaState(params.connectionId, scope, { + quotaState, + exhaustedWindow: exhaustedWindow ?? null, + ...(rateLimitedUntil + ? { + rateLimitedUntil, + rateLimitSource: exhaustedWindow ? ("quota_reset" as const) : ("fallback" as const), + } + : {}), + }); + if (!providerSpecificData) return null; + + return { + scope, + providerSpecificData, + exhaustionLog: + exhaustedWindow && rateLimitedUntil + ? `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${rateLimitedUntil}` + : null, + }; +} diff --git a/open-sse/services/codexAccount/state.ts b/open-sse/services/codexAccount/state.ts new file mode 100644 index 0000000000..4777985781 --- /dev/null +++ b/open-sse/services/codexAccount/state.ts @@ -0,0 +1,180 @@ +import { getCodexModelScope, type CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; +import type { + CodexAccountConnection, + CodexAccountPool, + CodexAccountPoolState, + CodexChildAccount, + CodexChildAccountState, + CodexChildCooldown, + CodexChildQuotaHydration, + CodexPersistedQuotaState, + CodexParentAccount, + CodexParentAccountDiagnostic, + CodexAccountState, + CodexAccount, +} from "./types.ts"; + +const CODEX_SCOPES: readonly CodexQuotaScope[] = ["codex", "spark"]; + +type LegacyStateOwner = Pick; + +function asRecord(value: unknown): Readonly> { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Readonly>) + : {}; +} + +function getLegacyCooldownMap(connection: LegacyStateOwner): Readonly> { + return asRecord(connection.providerSpecificData.codexScopeRateLimitedUntil); +} + +function getLegacyCooldown(account: CodexChildAccount): string | null { + const value = getLegacyCooldownMap(account.connection)[account.scope]; + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function asQuotaState(value: unknown): CodexPersistedQuotaState | null { + const record = asRecord(value); + return Object.keys(record).length > 0 ? (record as CodexPersistedQuotaState) : null; +} + +function asExhaustedWindow(value: unknown): "5h" | "7d" | null { + return value === "5h" || value === "7d" ? value : null; +} + +/** Decode persisted quota facts for exactly one virtual child. */ +export function getCodexChildQuotaHydration(account: CodexChildAccount): CodexChildQuotaHydration { + const data = account.connection.providerSpecificData; + const scopedQuota = asQuotaState(asRecord(data.codexQuotaStateByScope)[account.scope]); + const legacyQuota = asRecord(data.codexQuotaState); + const matchingLegacyQuota = + legacyQuota.scope === account.scope ? asQuotaState(legacyQuota) : null; + const exhaustedByScope = asRecord(data.codexExhaustedWindowByScope); + const scopedExhaustedWindow = asExhaustedWindow(exhaustedByScope[account.scope]); + const legacyExhaustedWindow = matchingLegacyQuota + ? asExhaustedWindow(data.codexExhaustedWindow) + : null; + + return { + scope: account.scope, + quotaState: scopedQuota ?? matchingLegacyQuota, + exhaustedWindow: scopedExhaustedWindow ?? legacyExhaustedWindow, + rateLimitedUntil: getLegacyCooldown(account), + }; +} + +function parseFutureTimestamp(value: string | null, nowMs: number): number | null { + if (!value) return null; + const timestampMs = new Date(value).getTime(); + return Number.isFinite(timestampMs) && timestampMs > nowMs ? timestampMs : null; +} + +function resolveChild(pool: CodexAccountPool, model: string): CodexChildAccount { + const scope = getCodexModelScope(model); + return pool.children.find((account) => account.scope === scope) ?? pool.children[0]; +} + +/** Inspect the read-only parent aggregate without exposing legacy storage parsing. */ +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexParentAccount, + nowMs?: number +): CodexAccountPoolState; +/** Inspect one scoped child without exposing legacy storage parsing. */ +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexChildAccount, + nowMs?: number +): CodexChildAccountState; +/** Inspect a runtime-selected parent or child account. */ +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexAccount, + nowMs?: number +): CodexAccountState; +export function inspectCodexAccount( + pool: CodexAccountPool, + account: CodexParentAccount | CodexChildAccount, + nowMs = Date.now() +): CodexAccountPoolState | CodexChildAccountState { + if (account.connectionId !== pool.parent.connectionId) { + throw new Error("Codex account does not belong to this pool"); + } + if (account.kind === "parent") return getCodexAccountPoolState(pool, nowMs); + const rateLimitedUntil = getLegacyCooldown(account); + return { + kind: "child", + scope: account.scope, + rateLimitedUntil, + unavailable: parseFutureTimestamp(rateLimitedUntil, nowMs) !== null, + }; +} + +/** Return the earliest active child cooldown for a model across account pools. */ +export function getEarliestCodexChildCooldown( + pools: readonly CodexAccountPool[], + model: string | null | undefined, + nowMs = Date.now() +): CodexChildCooldown | null { + if (typeof model !== "string" || model.trim().length === 0) return null; + let earliest: CodexChildCooldown | null = null; + let earliestMs = Infinity; + for (const pool of pools) { + const child = resolveChild(pool, model); + const until = getLegacyCooldown(child); + const timestampMs = parseFutureTimestamp(until, nowMs); + if (timestampMs !== null && timestampMs < earliestMs && until !== null) { + earliest = { account: child, until }; + earliestMs = timestampMs; + } + } + return earliest; +} + +/** Build one parent-only diagnostic from virtual child state. */ +export function getCodexParentAccountDiagnostic( + pool: CodexAccountPool, + nowMs = Date.now() +): CodexParentAccountDiagnostic { + const state = getCodexAccountPoolState(pool, nowMs); + const retryTimestamps = pool.children + .map((child) => parseFutureTimestamp(getLegacyCooldown(child), nowMs)) + .filter((value): value is number => value !== null); + const observedScopeCount = pool.children.filter( + (child) => getCodexChildQuotaHydration(child).quotaState !== null + ).length; + return { + status: state.status, + limitedScopeCount: state.limitedScopes.length, + cooldown: { + coolingDown: state.status === "fully_limited", + soonestRetryAfterMs: + retryTimestamps.length > 0 ? Math.max(0, Math.min(...retryTimestamps) - nowMs) : 0, + }, + quota: { observedScopeCount }, + }; +} + +/** Aggregate the two virtual child states as a read-only parent view. */ +export function getCodexAccountPoolState( + pool: CodexAccountPool, + nowMs = Date.now() +): CodexAccountPoolState { + const limitedScopes = CODEX_SCOPES.filter((scope) => { + const child = pool.children.find((account) => account.scope === scope); + if (!child) return false; + const until = getLegacyCooldown(child); + return parseFutureTimestamp(until, nowMs) !== null; + }); + + return { + kind: "parent", + status: + limitedScopes.length === 0 + ? "available" + : limitedScopes.length === CODEX_SCOPES.length + ? "fully_limited" + : "partially_limited", + limitedScopes, + }; +} diff --git a/open-sse/services/codexAccount/types.ts b/open-sse/services/codexAccount/types.ts new file mode 100644 index 0000000000..bb1b0ae050 --- /dev/null +++ b/open-sse/services/codexAccount/types.ts @@ -0,0 +1,125 @@ +import type { CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; + +/** The persisted Codex connection that owns credentials and provider state. */ +export interface CodexAccountConnection { + readonly id: string; + readonly provider: string; + readonly providerSpecificData: Readonly>; +} + +/** Structured identity for a virtual account; it can never be confused with a DB ID. */ +export interface CodexAccountKey { + readonly parentConnectionId: string; + readonly scope: TScope; +} + +interface CodexAccountBase { + readonly key: CodexAccountKey; + /** The actual persisted connection ID. Children never get a synthetic DB ID. */ + readonly connectionId: string; + readonly connection: CodexAccountConnection; +} + +/** The runtime view of the persisted credential-owning connection. */ +export interface CodexParentAccount extends CodexAccountBase { + readonly kind: "parent"; + readonly scope: null; +} + +/** One virtual runtime quota/cooldown child of the persisted connection. */ +export interface CodexChildAccount extends CodexAccountBase { + readonly kind: "child"; + readonly scope: CodexQuotaScope; +} + +export type CodexAccount = CodexParentAccount | CodexChildAccount; + +export interface CodexAccountPool { + readonly parent: CodexParentAccount; + readonly children: readonly [CodexChildAccount, CodexChildAccount]; + readonly accounts: readonly [CodexParentAccount, CodexChildAccount, CodexChildAccount]; +} + +export type CodexAccountPoolStatus = "available" | "partially_limited" | "fully_limited"; + +export interface CodexAccountPoolState { + readonly kind: "parent"; + readonly status: CodexAccountPoolStatus; + readonly limitedScopes: readonly CodexQuotaScope[]; +} + +export interface CodexChildAccountState { + readonly kind: "child"; + readonly scope: CodexQuotaScope; + readonly unavailable: boolean; + readonly rateLimitedUntil: string | null; +} + +export type CodexAccountState = CodexAccountPoolState | CodexChildAccountState; + +export interface CodexQuotaWindowSnapshot { + readonly usage: number | null; + readonly limit: number | null; + readonly resetAt: string | null; + readonly usedPercentage: number | null; +} + +export interface CodexChildAccountProjection { + readonly key: CodexAccountKey; + readonly unavailable: boolean; + readonly cooldown: { + readonly active: boolean; + readonly rateLimitedUntil: string | null; + }; + readonly quota: { + readonly exhaustedWindow: "5h" | "7d" | null; + readonly observedAt: string | null; + readonly windows: { + readonly "5h": CodexQuotaWindowSnapshot | null; + readonly "7d": CodexQuotaWindowSnapshot | null; + }; + }; +} + +export interface CodexAccountPoolProjection { + readonly parentConnectionId: string; + readonly aggregate: { + readonly status: CodexAccountPoolStatus; + readonly limitedChildCount: number; + }; + readonly children: readonly [CodexChildAccountProjection, CodexChildAccountProjection]; +} + +export interface CodexPersistedQuotaState { + readonly usage5h?: number; + readonly limit5h?: number; + readonly resetAt5h?: string | null; + readonly usage7d?: number; + readonly limit7d?: number; + readonly resetAt7d?: string | null; + readonly observedAt?: string | null; +} + +export interface CodexChildQuotaHydration { + readonly scope: CodexQuotaScope; + readonly quotaState: CodexPersistedQuotaState | null; + readonly exhaustedWindow: "5h" | "7d" | null; + readonly rateLimitedUntil: string | null; +} + +export interface CodexParentAccountDiagnostic { + readonly status: CodexAccountPoolStatus; + readonly limitedScopeCount: number; + readonly cooldown: { + readonly coolingDown: boolean; + readonly soonestRetryAfterMs: number; + }; + readonly quota: { + readonly observedScopeCount: number; + }; +} + +export interface CodexChildCooldown { + readonly account: CodexChildAccount; + readonly until: string; +} diff --git a/open-sse/services/codexAccount/write.ts b/open-sse/services/codexAccount/write.ts new file mode 100644 index 0000000000..f6f6e065b7 --- /dev/null +++ b/open-sse/services/codexAccount/write.ts @@ -0,0 +1,23 @@ +import { getCodexModelScope, type CodexQuotaScope } from "../../config/codexQuotaScopes.ts"; +import { updateCodexScopeCooldown } from "@/lib/db/providers"; + +export interface PersistCodexChildCooldownResult { + readonly scope: CodexQuotaScope; + readonly providerSpecificData: Record; +} + +/** Persist one virtual child's cooldown without mutating parent-level health state. */ +export async function persistCodexChildCooldown(params: { + connectionId: string; + model: string; + rateLimitedUntil: string; +}): Promise { + if (params.model.trim().length === 0) return null; + const scope = getCodexModelScope(params.model); + const providerSpecificData = await updateCodexScopeCooldown( + params.connectionId, + scope, + params.rateLimitedUntil + ); + return providerSpecificData ? { scope, providerSpecificData } : null; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails.tsx new file mode 100644 index 0000000000..02cbb8ecd8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { CodexAccountPoolProjection } from "@omniroute/open-sse/services/codexAccount/index.ts"; +import { useLocale, useTranslations } from "next-intl"; + +export interface CodexAccountDetailsProps { + pool: CodexAccountPoolProjection; +} + +function formatQuota( + window: CodexAccountPoolProjection["children"][number]["quota"]["windows"]["5h"], + usedLabel: string +): string { + if (!window) return "—"; + if (window.usedPercentage !== null) return `${Math.round(window.usedPercentage)}% ${usedLabel}`; + if (window.usage !== null && window.limit !== null) return `${window.usage}/${window.limit}`; + return "—"; +} + +export default function CodexAccountDetails({ pool }: CodexAccountDetailsProps) { + const t = useTranslations("providers"); + const locale = useLocale(); + const statusLabels = { + available: t("codexPoolAvailable"), + partially_limited: t("codexPoolPartiallyLimited"), + fully_limited: t("codexPoolFullyLimited"), + }; + return ( +
+
+ {t("codexQuotaPools")} + + {statusLabels[pool.aggregate.status]} ·{" "} + {t("codexPoolLimited", { count: pool.aggregate.limitedChildCount })} + +
+
+ {pool.children.map((child) => ( +
+
+ {child.key.scope === "codex" ? "Codex" : "Spark"} + + {child.quota.exhaustedWindow + ? t("codexPoolQuotaExhausted") + : child.cooldown.active + ? t("codexPoolCoolingDown") + : t("codexPoolAvailable")} + +
+
+ 5h: {formatQuota(child.quota.windows["5h"], t("codexPoolUsed"))} + 7d: {formatQuota(child.quota.windows["7d"], t("codexPoolUsed"))} +
+ {child.cooldown.rateLimitedUntil ? ( +
+ {t("codexPoolUntil", { + value: new Intl.DateTimeFormat(locale, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(child.cooldown.rateLimitedUntil)), + })} +
+ ) : null} +
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 02f1d27267..ddfe36ba2e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -17,6 +17,8 @@ import { } from "@/lib/providers/codexFastTier"; import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers"; import { getCodexPlanLabel } from "../codexPlanLabel"; +import type { CodexAccountPoolProjection } from "@omniroute/open-sse/services/codexAccount/index.ts"; +import CodexAccountDetails from "./CodexAccountDetails"; import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle"; // --------------------------------------------------------------------------- @@ -48,6 +50,7 @@ export interface ConnectionRowConnection { proxyEnabled?: boolean; perKeyProxyEnabled?: boolean; quotaVisible?: boolean; + codexAccountPool?: CodexAccountPoolProjection; } export interface ConnectionRowProps { @@ -963,6 +966,9 @@ export default function ConnectionRow({ + {isCodex && connection.codexAccountPool ? ( + + ) : null} ); } diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 6941d126d0..77c102bb0a 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -26,6 +26,7 @@ import { } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli"; +import { projectCodexAccountPool } from "@omniroute/open-sse/services/codexAccount/index.ts"; import { normalizeProviderSpecificData, sanitizeProviderSpecificDataForResponse, @@ -64,16 +65,31 @@ export async function GET(request: Request) { const revealKeys = isApiKeyRevealEnabled(); // Hide or mask sensitive fields - const safeConnections = connections.map((c) => ({ - ...c, - apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined, - accessToken: undefined, - refreshToken: undefined, - idToken: undefined, - providerSpecificData: c.providerSpecificData + const safeConnections = connections.map((c) => { + const providerSpecificData = c.providerSpecificData ? sanitizeProviderSpecificDataForResponse(c.providerSpecificData) - : undefined, - })); + : undefined; + return { + ...c, + apiKey: revealKeys ? c.apiKey : c.apiKey ? maskStoredApiKey(c.apiKey) : undefined, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + providerSpecificData, + ...(c.provider === "codex" + ? { + codexAccountPool: projectCodexAccountPool( + { + id: c.id, + provider: c.provider, + providerSpecificData: c.providerSpecificData ?? {}, + }, + Date.now() + ), + } + : {}), + }; + }); return NextResponse.json({ connections: safeConnections, total }); } catch (error) { diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 89a925236d..908411cf3f 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -26,7 +26,17 @@ import { getLatestQuotaSnapshotsForConnection, } from "@/lib/db/quotaSnapshots"; import { recordProviderQuotaResetEventIfChanged } from "@/lib/db/quotaResetEvents"; -import { getCodexQuotaWindowFilterForModel } from "@omniroute/open-sse/config/codexQuotaScopes.ts"; +import { + CODEX_SPARK_QUOTA_SESSION, + CODEX_SPARK_QUOTA_WEEKLY, + getCodexQuotaWindowFilterForModel, +} from "@omniroute/open-sse/config/codexQuotaScopes.ts"; +import { + createCodexAccountPool, + getCodexChildQuotaHydration, + resolveCodexAccount, + type CodexPersistedQuotaState, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -295,6 +305,88 @@ function isAntigravityQuotaExhausted( ); } +function remainingPercent(usage: unknown, limit: unknown): number | null { + const used = Number(usage); + const total = Number(limit); + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) return null; + return clampPercent(((total - used) / total) * 100); +} + +function mergeCodexPersistedQuota( + entry: QuotaCacheEntry, + scope: "codex" | "spark", + quotaState: CodexPersistedQuotaState +): void { + const sessionKey = scope === "spark" ? CODEX_SPARK_QUOTA_SESSION : "session"; + const weeklyKey = scope === "spark" ? CODEX_SPARK_QUOTA_WEEKLY : "weekly"; + const sessionRemaining = remainingPercent(quotaState.usage5h, quotaState.limit5h); + const weeklyRemaining = remainingPercent(quotaState.usage7d, quotaState.limit7d); + if (sessionRemaining !== null) { + entry.quotas[sessionKey] = { + remainingPercentage: sessionRemaining, + resetAt: quotaState.resetAt5h ?? null, + }; + } + if (weeklyRemaining !== null) { + entry.quotas[weeklyKey] = { + remainingPercentage: weeklyRemaining, + resetAt: quotaState.resetAt7d ?? null, + }; + } +} + +/** Overlay one Codex child's persisted quota facts into the existing request cache. */ +export function hydrateCodexQuotaCacheForRequest( + connection: { + id: string; + provider: string; + providerSpecificData?: Readonly> | null; + }, + requestedModel: string | null +): void { + if (connection.provider !== "codex" || !requestedModel?.trim()) return; + const pool = createCodexAccountPool({ + id: connection.id, + provider: connection.provider, + providerSpecificData: connection.providerSpecificData ?? {}, + }); + const account = resolveCodexAccount(pool, requestedModel); + if (account.kind !== "child") return; + const hydration = getCodexChildQuotaHydration(account); + if (!hydration.quotaState) return; + + const { cache } = getState(); + const entry = cache.get(connection.id) || + hydrateQuotaCacheFromSnapshots(connection.id) || { + connectionId: connection.id, + provider: connection.provider, + quotas: {}, + fetchedAt: Date.now(), + exhausted: false, + nextResetAt: null, + }; + mergeCodexPersistedQuota(entry, hydration.scope, hydration.quotaState); + let exhaustedResetAt: string | null = null; + if (hydration.exhaustedWindow) { + const windowName = + hydration.scope === "spark" + ? hydration.exhaustedWindow === "5h" + ? CODEX_SPARK_QUOTA_SESSION + : CODEX_SPARK_QUOTA_WEEKLY + : hydration.exhaustedWindow === "5h" + ? "session" + : "weekly"; + const window = entry.quotas[windowName]; + if (window) { + entry.quotas[windowName] = { ...window, remainingPercentage: 0 }; + exhaustedResetAt = window.resetAt; + } + } + entry.exhausted = isExhausted(entry.quotas); + if (exhaustedResetAt) entry.nextResetAt = exhaustedResetAt; + cache.set(connection.id, entry); +} + function isCodexQuotaExhausted( connectionId: string, entry: QuotaCacheEntry, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 802e133ff3..329df3d883 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "صديق مفتوح المصدر", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference تدعم OmniRoute كصديق للمصادر المفتوحة", "kimiPartnerLinkNote": "رابط شريك — يدعم OmniRoute دون أي تكلفة إضافية عليك", + "codexQuotaPools": "مجموعات حصص Codex", + "codexPoolAvailable": "متاح", + "codexPoolPartiallyLimited": "محدود جزئيًا", + "codexPoolFullyLimited": "محدود بالكامل", + "codexPoolLimited": "{count} محدود", + "codexPoolQuotaExhausted": "نفدت الحصة", + "codexPoolCoolingDown": "في فترة تهدئة", + "codexPoolUsed": "مُستخدم", + "codexPoolUntil": "حتى {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "التراجع المجهول", "anonymousFallbackDesc": "عند استنفاد جميع الاتصالات المكونة (الحصة، الاعتمادات، أو انتهاء الصلاحية)، استخدم مؤقتًا المستوى بدون مفتاح لهذا المزود. قم بإيقاف التشغيل لتخطي هذا المزود بدلاً من إرسال طلبات مجهولة — يُوصى بذلك عندما يرفض المستوى بدون مفتاح هذه الطلبات (401).", "anonymousFallbackEnabled": "تم تمكين النسخة الاحتياطية المجهولة لـ {provider}", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 43bf476660..7c7754b05e 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Açıq Mənbə Dostu", "cheaperInferenceSupporterTooltip": "Cheaper Inference OmniRoute-u Açıq Mənbə Dostu kimi dəstəkləyir", "kimiPartnerLinkNote": "Tərəfdaş linki — sizə heç bir əlavə xərc olmadan OmniRoute-u dəstəkləyir", + "codexQuotaPools": "Codex kvota hovuzları", + "codexPoolAvailable": "Əlçatandır", + "codexPoolPartiallyLimited": "Qismən məhduddur", + "codexPoolFullyLimited": "Tam məhduddur", + "codexPoolLimited": "{count} məhduddur", + "codexPoolQuotaExhausted": "Kvota tükənib", + "codexPoolCoolingDown": "Gözləmə müddətindədir", + "codexPoolUsed": "istifadə edilib", + "codexPoolUntil": "{value} tarixinədək", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonim ehtiyat", "anonymousFallbackDesc": "Bütün konfiqurasiya olunmuş bağlantılar tükəndikdə (kvota, kreditlər və ya müddət), müvəqqəti olaraq bu təminatçının açarsız səviyyəsini istifadə edin. Anonim sorğular göndərmək əvəzinə bu təminatçını atlamaq üçün söndürün — açarsız səviyyə onları rədd etdikdə (401) tövsiyə olunur.", "anonymousFallbackEnabled": "{provider} üçün anonim ehtiyat aktivdir", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index f48ddf2bd1..463943e63a 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Приятел на отворения код", "cheaperInferenceSupporterTooltip": "Cheaper Inference подкрепя OmniRoute като приятел на отворения код", "kimiPartnerLinkNote": "Партньорска връзка — поддържа OmniRoute без допълнителни разходи за вас", + "codexQuotaPools": "Пулове с квоти на Codex", + "codexPoolAvailable": "Наличен", + "codexPoolPartiallyLimited": "Частично ограничен", + "codexPoolFullyLimited": "Напълно ограничен", + "codexPoolLimited": "{count} ограничени", + "codexPoolQuotaExhausted": "Квотата е изчерпана", + "codexPoolCoolingDown": "В период на изчакване", + "codexPoolUsed": "използвано", + "codexPoolUntil": "До {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Анонимен резервен вариант", "anonymousFallbackDesc": "Когато всички конфигурирани връзки са изчерпани (квота, кредити или изтичане), временно използвайте безключовия слой на този доставчик. Изключете, за да пропуснете този доставчик вместо да изпращате анонимни заявки — препоръчително, когато безключовият слой ги отхвърля (401).", "anonymousFallbackEnabled": "Анонимен резервен вариант е активиран за {provider}", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 6a1f14d740..fe486a3ffd 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "ওপেন সোর্স বন্ধু", "cheaperInferenceSupporterTooltip": "Cheaper Inference একজন ওপেন সোর্স বন্ধু হিসেবে OmniRoute-কে সমর্থন করে", "kimiPartnerLinkNote": "পার্টনার লিঙ্ক — আপনার কোনো অতিরিক্ত খরচ ছাড়াই OmniRoute-কে সমর্থন করে", + "codexQuotaPools": "Codex কোটা পুল", + "codexPoolAvailable": "উপলভ্য", + "codexPoolPartiallyLimited": "আংশিকভাবে সীমিত", + "codexPoolFullyLimited": "সম্পূর্ণ সীমিত", + "codexPoolLimited": "{count}টি সীমিত", + "codexPoolQuotaExhausted": "কোটা শেষ", + "codexPoolCoolingDown": "কুলডাউনে আছে", + "codexPoolUsed": "ব্যবহৃত", + "codexPoolUntil": "{value} পর্যন্ত", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "অজ্ঞাত ফালব্যাক", "anonymousFallbackDesc": "যখন সমস্ত কনফিগার করা সংযোগ শেষ হয়ে যায় (কোটা, ক্রেডিট, বা মেয়াদ শেষ), এই প্রদানকারীর কীবিহীন স্তরটি অস্থায়ীভাবে ব্যবহার করুন। অজ্ঞাত অনুরোধ পাঠানোর পরিবর্তে এই প্রদানকারীটি বাদ দিতে বন্ধ করুন — যখন কীবিহীন স্তর সেগুলি প্রত্যাখ্যান করে (401) তখন এটি সুপারিশ করা হয়।", "anonymousFallbackEnabled": "{provider} এর জন্য অজ্ঞাত ফFallback সক্রিয় করা হয়েছে", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index f5cd6c68bf..22139ce32a 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Přítel open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference podporuje OmniRoute jako přítel open source", "kimiPartnerLinkNote": "Partnerský odkaz — podporuje OmniRoute bez jakýchkoli dalších nákladů pro vás", + "codexQuotaPools": "Fondy kvót Codex", + "codexPoolAvailable": "Dostupný", + "codexPoolPartiallyLimited": "Částečně omezený", + "codexPoolFullyLimited": "Plně omezený", + "codexPoolLimited": "Omezeno: {count}", + "codexPoolQuotaExhausted": "Kvóta vyčerpána", + "codexPoolCoolingDown": "Probíhá čekací lhůta", + "codexPoolUsed": "využito", + "codexPoolUntil": "Do {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonymní záložní řešení", "anonymousFallbackDesc": "Když jsou všechny nakonfigurované připojení vyčerpány (kvóta, kredity nebo expirace), dočasně použijte bezklíčovou úroveň tohoto poskytovatele. Vypněte, abyste tohoto poskytovatele přeskočili místo odesílání anonymních požadavků — doporučeno, když bezklíčová úroveň je odmítá (401).", "anonymousFallbackEnabled": "Anonymní záložní možnost povolena pro {provider}", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 0bbdd87e7f..cbd74f9a49 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Open source-ven", "cheaperInferenceSupporterTooltip": "Cheaper Inference støtter OmniRoute som open source-ven", "kimiPartnerLinkNote": "Partnerlink — understøtter OmniRoute uden ekstra omkostninger for dig", + "codexQuotaPools": "Codex-kvotepuljer", + "codexPoolAvailable": "Tilgængelig", + "codexPoolPartiallyLimited": "Delvist begrænset", + "codexPoolFullyLimited": "Fuldt begrænset", + "codexPoolLimited": "{count} begrænset", + "codexPoolQuotaExhausted": "Kvoten er opbrugt", + "codexPoolCoolingDown": "I nedkølingsperiode", + "codexPoolUsed": "brugt", + "codexPoolUntil": "Indtil {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonym fallback", "anonymousFallbackDesc": "Når alle konfigurerede forbindelser er udtømt (kvote, kreditter eller udløb), brug midlertidigt denne udbyders nøgleløse niveau. Sluk for at springe denne udbyder over i stedet for at sende anonyme anmodninger - anbefales når det nøgleløse niveau afviser dem (401).", "anonymousFallbackEnabled": "Anonym fallback aktiveret for {provider}", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c2eb87f956..fb4e14b485 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Open-Source-Freund", "cheaperInferenceSupporterTooltip": "Cheaper Inference unterstützt OmniRoute als Open-Source-Freund", "kimiPartnerLinkNote": "Partnerlink — unterstützt OmniRoute ohne zusätzliche Kosten für Sie", + "codexQuotaPools": "Codex-Kontingentpools", + "codexPoolAvailable": "Verfügbar", + "codexPoolPartiallyLimited": "Teilweise eingeschränkt", + "codexPoolFullyLimited": "Vollständig eingeschränkt", + "codexPoolLimited": "{count} eingeschränkt", + "codexPoolQuotaExhausted": "Kontingent aufgebraucht", + "codexPoolCoolingDown": "In Abklingzeit", + "codexPoolUsed": "verwendet", + "codexPoolUntil": "Bis {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonymer Fallback", "anonymousFallbackDesc": "Wenn alle konfigurierten Verbindungen erschöpft sind (Kontingent, Guthaben oder Ablauf), verwenden Sie vorübergehend die schlüssellose Stufe dieses Anbieters. Deaktivieren Sie dies, um diesen Anbieter zu überspringen, anstatt anonyme Anfragen zu senden – empfohlen, wenn die schlüssellose Stufe diese ablehnt (401).", "anonymousFallbackEnabled": "Anonymer Fallback für {provider} aktiviert", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c93cd4a008..8147dfcb65 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6287,6 +6287,15 @@ "cheaperInferenceSupporterBadge": "Open Source Friend", "cheaperInferenceSupporterTooltip": "Cheaper Inference backs OmniRoute as an Open Source Friend", "kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you", + "codexQuotaPools": "Codex quota pools", + "codexPoolAvailable": "Available", + "codexPoolPartiallyLimited": "Partially limited", + "codexPoolFullyLimited": "Fully limited", + "codexPoolLimited": "{count} limited", + "codexPoolQuotaExhausted": "Quota exhausted", + "codexPoolCoolingDown": "Cooling down", + "codexPoolUsed": "used", + "codexPoolUntil": "Until {value}", "anonymousFallbackTitle": "Anonymous fallback", "anonymousFallbackDesc": "When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401).", "anonymousFallbackEnabled": "Anonymous fallback enabled for {provider}", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7a9635ca4d..6978c01a41 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Amigo del código abierto", "cheaperInferenceSupporterTooltip": "Cheaper Inference apoya a OmniRoute como amigo del código abierto", "kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you", + "codexQuotaPools": "Grupos de cuotas de Codex", + "codexPoolAvailable": "Disponible", + "codexPoolPartiallyLimited": "Limitado parcialmente", + "codexPoolFullyLimited": "Limitado por completo", + "codexPoolLimited": "{count} limitados", + "codexPoolQuotaExhausted": "Cuota agotada", + "codexPoolCoolingDown": "En espera", + "codexPoolUsed": "usado", + "codexPoolUntil": "Hasta {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Recaudación anónima", "anonymousFallbackDesc": "Cuando todas las conexiones configuradas están agotadas (cuota, créditos o expiración), utiliza temporalmente el nivel sin clave de este proveedor. Desactiva para omitir este proveedor en lugar de enviar solicitudes anónimas — recomendado cuando el nivel sin clave las rechaza (401).", "anonymousFallbackEnabled": "Fallback anónimo habilitado para {provider}", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index f75eafbeb4..9962332860 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "دوست متن‌باز", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference از OmniRoute به‌عنوان دوست متن‌باز حمایت می‌کند", "kimiPartnerLinkNote": "لینک همکاری — پشتیبانی از OmniRoute بدون هزینه اضافی برای شما", + "codexQuotaPools": "مخزن‌های سهمیه Codex", + "codexPoolAvailable": "در دسترس", + "codexPoolPartiallyLimited": "تا حدی محدود", + "codexPoolFullyLimited": "کاملاً محدود", + "codexPoolLimited": "{count} مورد محدود", + "codexPoolQuotaExhausted": "سهمیه تمام شده است", + "codexPoolCoolingDown": "در دوره انتظار", + "codexPoolUsed": "مصرف‌شده", + "codexPoolUntil": "تا {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "پشتیبانی ناشناس", "anonymousFallbackDesc": "زمانی که تمام اتصالات پیکربندی‌شده تمام شده‌اند (سهمیه، اعتبار یا انقضا)، به‌طور موقت از سطح بدون کلید این ارائه‌دهنده استفاده کنید. برای رد کردن این ارائه‌دهنده به‌جای ارسال درخواست‌های ناشناس خاموش کنید — این کار زمانی توصیه می‌شود که سطح بدون کلید آن‌ها را رد کند (401).", "anonymousFallbackEnabled": "پشتیبانی ناشناس برای {provider} فعال شد", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 3f2cf48e9f..6b882ce670 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Avoimen lähdekoodin ystävä", "cheaperInferenceSupporterTooltip": "Cheaper Inference tukee OmniRoutea avoimen lähdekoodin ystävänä", "kimiPartnerLinkNote": "Kumppanilinkki — tukee OmniRoutea ilman lisäkustannuksia sinulle", + "codexQuotaPools": "Codex-kiintiöpoolit", + "codexPoolAvailable": "Käytettävissä", + "codexPoolPartiallyLimited": "Osittain rajoitettu", + "codexPoolFullyLimited": "Täysin rajoitettu", + "codexPoolLimited": "{count} rajoitettua", + "codexPoolQuotaExhausted": "Kiintiö käytetty loppuun", + "codexPoolCoolingDown": "Jäähdytysjaksolla", + "codexPoolUsed": "käytetty", + "codexPoolUntil": "{value} asti", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonyymi varajärjestelmä", "anonymousFallbackDesc": "Kun kaikki määritetyt yhteydet on käytetty loppuun (kiintiö, krediitit tai vanhentuminen), käytä väliaikaisesti tämän tarjoajan avaimettomaa tasoa. Poista käytöstä tämän tarjoajan ohittamiseksi sen sijaan, että lähetät nimettömiä pyyntöjä — suositellaan, kun avaimeton taso hylkää ne (401).", "anonymousFallbackEnabled": "Anonyymi varajärjestelmä käytössä {provider} varten", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5e930cae8e..84ffa972ec 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Ami open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference soutient OmniRoute en tant qu'ami open source", "kimiPartnerLinkNote": "Lien partenaire — soutient OmniRoute sans frais supplémentaires pour vous", + "codexQuotaPools": "Pools de quotas Codex", + "codexPoolAvailable": "Disponible", + "codexPoolPartiallyLimited": "Partiellement limité", + "codexPoolFullyLimited": "Entièrement limité", + "codexPoolLimited": "{count} limités", + "codexPoolQuotaExhausted": "Quota épuisé", + "codexPoolCoolingDown": "En période d'attente", + "codexPoolUsed": "utilisé", + "codexPoolUntil": "Jusqu'à {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback anonyme", "anonymousFallbackDesc": "Lorsque toutes les connexions configurées sont épuisées (quota, crédits ou expiration), utilisez temporairement le niveau sans clé de ce fournisseur. Désactivez cette option pour ignorer ce fournisseur au lieu d'envoyer des requêtes anonymes — recommandé lorsque le niveau sans clé les rejette (401).", "anonymousFallbackEnabled": "Fallback anonyme activé pour {provider}", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index db05acce8e..eb23ac5ebd 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "ઓપન સોર્સ મિત્ર", "cheaperInferenceSupporterTooltip": "Cheaper Inference ઓપન સોર્સ મિત્ર તરીકે OmniRoute ને સમર્થન આપે છે", "kimiPartnerLinkNote": "પાર્ટનર લિંક — તમારા માટે કોઈ વધારાના ખર્ચ વિના OmniRoute ને સપોર્ટ કરે છે", + "codexQuotaPools": "Codex ક્વોટા પૂલ", + "codexPoolAvailable": "ઉપલબ્ધ", + "codexPoolPartiallyLimited": "આંશિક રીતે મર્યાદિત", + "codexPoolFullyLimited": "સંપૂર્ણ રીતે મર્યાદિત", + "codexPoolLimited": "{count} મર્યાદિત", + "codexPoolQuotaExhausted": "ક્વોટા સમાપ્ત", + "codexPoolCoolingDown": "વિરામ અવધિમાં", + "codexPoolUsed": "વપરાયેલ", + "codexPoolUntil": "{value} સુધી", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "ગૂઢ ફોલબેક", "anonymousFallbackDesc": "જ્યારે તમામ કન્ફિગર કરેલ કનેક્શનનો ઉપયોગ થઈ જાય છે (ક્વોટા, ક્રેડિટ, અથવા સમાપ્તી), ત્યારે આ પ્રદાતા ની કીલેસ ટિયરનો તાત્કાલિક ઉપયોગ કરો. અનામિક વિનંતીઓ મોકલવા માટે આ પ્રદાતાને છોડી દેવા માટે બંધ કરો - જ્યારે કીલેસ ટિયર તેમને નકારી દે ત્યારે ભલામણ કરવામાં આવે છે (401).", "anonymousFallbackEnabled": "{provider} માટે અજ્ઞાત ફોલબેક સક્રિય છે", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 32066ceb33..764e30527a 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "ידיד קוד פתוח", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference תומכת ב-OmniRoute כידידת קוד פתוח", "kimiPartnerLinkNote": "קישור שותף — תומך ב-OmniRoute ללא עלות נוספת עבורך", + "codexQuotaPools": "מאגרי מכסות Codex", + "codexPoolAvailable": "זמין", + "codexPoolPartiallyLimited": "מוגבל חלקית", + "codexPoolFullyLimited": "מוגבל לחלוטין", + "codexPoolLimited": "{count} מוגבלים", + "codexPoolQuotaExhausted": "המכסה נוצלה", + "codexPoolCoolingDown": "בתקופת המתנה", + "codexPoolUsed": "בשימוש", + "codexPoolUntil": "עד {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "נפילה אנונימית", "anonymousFallbackDesc": "כאשר כל החיבורים המוגדרים נוצלו (מכסה, אשראי או תאריך תפוגה), השתמש זמנית בשכבת ללא מפתח של ספק זה. כבה כדי לדלג על ספק זה במקום לשלוח בקשות אנונימיות - מומלץ כאשר שכבת ללא מפתח דוחה אותן (401).", "anonymousFallbackEnabled": "גיבוי אנונימי מופעל עבור {provider}", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 4ee268acf7..6591311189 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "ओपन सोर्स मित्र", "cheaperInferenceSupporterTooltip": "Cheaper Inference एक ओपन सोर्स मित्र के रूप में OmniRoute का समर्थन करता है", "kimiPartnerLinkNote": "पार्टनर लिंक — बिना किसी अतिरिक्त लागत के OmniRoute का समर्थन करता है", + "codexQuotaPools": "Codex कोटा पूल", + "codexPoolAvailable": "उपलब्ध", + "codexPoolPartiallyLimited": "आंशिक रूप से सीमित", + "codexPoolFullyLimited": "पूरी तरह सीमित", + "codexPoolLimited": "{count} सीमित", + "codexPoolQuotaExhausted": "कोटा समाप्त", + "codexPoolCoolingDown": "कूलडाउन जारी", + "codexPoolUsed": "उपयोग किया गया", + "codexPoolUntil": "{value} तक", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "गुमनाम बैकअप", "anonymousFallbackDesc": "जब सभी कॉन्फ़िगर की गई कनेक्शन समाप्त हो जाते हैं (कोटा, क्रेडिट, या समाप्ति), तो अस्थायी रूप से इस प्रदाता की कीलेस श्रेणी का उपयोग करें। इस प्रदाता को छोड़ने के लिए बंद करें बजाय गुमनाम अनुरोध भेजने के — जब कीलेस श्रेणी उन्हें अस्वीकार करती है (401) तो यह अनुशंसित है।", "anonymousFallbackEnabled": "{provider} के लिए गुमनाम फॉलबैक सक्षम किया गया", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 93bbcff7af..e5ccd837b3 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Nyílt forráskódú barát", "cheaperInferenceSupporterTooltip": "A Cheaper Inference nyílt forráskódú barátként támogatja az OmniRoute-ot", "kimiPartnerLinkNote": "Partnerhivatkozás — az Ön számára további költség nélkül támogatja az OmniRoute-ot", + "codexQuotaPools": "Codex-kvótakészletek", + "codexPoolAvailable": "Elérhető", + "codexPoolPartiallyLimited": "Részben korlátozott", + "codexPoolFullyLimited": "Teljesen korlátozott", + "codexPoolLimited": "{count} korlátozott", + "codexPoolQuotaExhausted": "A kvóta kimerült", + "codexPoolCoolingDown": "Várakozási időszakban", + "codexPoolUsed": "felhasználva", + "codexPoolUntil": "Eddig: {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Névtelen visszaesés", "anonymousFallbackDesc": "Amikor az összes konfigurált kapcsolat kimerült (kvóta, kreditek vagy lejárat), ideiglenesen használja ezt a szolgáltató kulcs nélküli szintjét. Kapcsolja ki, hogy kihagyja ezt a szolgáltatót a névtelen kérések küldése helyett — ajánlott, ha a kulcs nélküli szint elutasítja őket (401).", "anonymousFallbackEnabled": "Névtelen visszaesés engedélyezve a(z) {provider} számára", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index bf366dd1e5..352daafd83 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Teman Open Source", "cheaperInferenceSupporterTooltip": "Cheaper Inference mendukung OmniRoute sebagai Teman Open Source", "kimiPartnerLinkNote": "Tautan mitra — mendukung OmniRoute tanpa biaya tambahan bagi Anda", + "codexQuotaPools": "Kumpulan kuota Codex", + "codexPoolAvailable": "Tersedia", + "codexPoolPartiallyLimited": "Dibatasi sebagian", + "codexPoolFullyLimited": "Dibatasi sepenuhnya", + "codexPoolLimited": "{count} dibatasi", + "codexPoolQuotaExhausted": "Kuota habis", + "codexPoolCoolingDown": "Dalam masa tunggu", + "codexPoolUsed": "terpakai", + "codexPoolUntil": "Hingga {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback anonim", "anonymousFallbackDesc": "Ketika semua koneksi yang dikonfigurasi habis (kuota, kredit, atau masa berlaku), gunakan sementara tingkat tanpa kunci penyedia ini. Matikan untuk melewati penyedia ini alih-alih mengirim permintaan anonim — disarankan ketika tingkat tanpa kunci menolak permintaan tersebut (401).", "anonymousFallbackEnabled": "Fallback anonim diaktifkan untuk {provider}", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index ca21aecc96..19f45ac1ca 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Teman Open Source", "cheaperInferenceSupporterTooltip": "Cheaper Inference mendukung OmniRoute sebagai Teman Open Source", "kimiPartnerLinkNote": "Tautan mitra — mendukung OmniRoute tanpa biaya tambahan bagi Anda", + "codexQuotaPools": "Kumpulan kuota Codex", + "codexPoolAvailable": "Tersedia", + "codexPoolPartiallyLimited": "Dibatasi sebagian", + "codexPoolFullyLimited": "Dibatasi sepenuhnya", + "codexPoolLimited": "{count} dibatasi", + "codexPoolQuotaExhausted": "Kuota habis", + "codexPoolCoolingDown": "Dalam masa tunggu", + "codexPoolUsed": "terpakai", + "codexPoolUntil": "Hingga {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback anonim", "anonymousFallbackDesc": "Ketika semua koneksi yang dikonfigurasi habis (kuota, kredit, atau masa berlaku), gunakan sementara tingkat tanpa kunci penyedia ini. Matikan untuk melewati penyedia ini alih-alih mengirim permintaan anonim — disarankan ketika tingkat tanpa kunci menolak permintaan tersebut (401).", "anonymousFallbackEnabled": "Fallback anonim diaktifkan untuk {provider}", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 27212b1eb3..7332c3206b 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Amico open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference sostiene OmniRoute come amico open source", "kimiPartnerLinkNote": "Link partner — supporta OmniRoute senza costi aggiuntivi per te", + "codexQuotaPools": "Pool di quote Codex", + "codexPoolAvailable": "Disponibile", + "codexPoolPartiallyLimited": "Parzialmente limitato", + "codexPoolFullyLimited": "Completamente limitato", + "codexPoolLimited": "{count} limitati", + "codexPoolQuotaExhausted": "Quota esaurita", + "codexPoolCoolingDown": "In attesa", + "codexPoolUsed": "utilizzato", + "codexPoolUntil": "Fino a {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback anonimo", "anonymousFallbackDesc": "Quando tutte le connessioni configurate sono esaurite (quota, crediti o scadenza), utilizza temporaneamente il livello senza chiave di questo fornitore. Disattiva per saltare questo fornitore invece di inviare richieste anonime — consigliato quando il livello senza chiave le rifiuta (401).", "anonymousFallbackEnabled": "Fallback anonimo abilitato per {provider}", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ea2b51f9d3..a8c37c9eec 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "オープンソースフレンド", "cheaperInferenceSupporterTooltip": "Cheaper Inference は OmniRoute をオープンソースフレンドとして支援しています", "kimiPartnerLinkNote": "パートナーリンク — 追加費用なしで OmniRoute をサポートします", + "codexQuotaPools": "Codex クォータプール", + "codexPoolAvailable": "利用可能", + "codexPoolPartiallyLimited": "一部制限中", + "codexPoolFullyLimited": "すべて制限中", + "codexPoolLimited": "{count} 件が制限中", + "codexPoolQuotaExhausted": "クォータを使い切りました", + "codexPoolCoolingDown": "クールダウン中", + "codexPoolUsed": "使用済み", + "codexPoolUntil": "{value} まで", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "匿名フォールバック", "anonymousFallbackDesc": "すべての設定された接続が使い果たされた場合(クォータ、クレジット、または有効期限)、このプロバイダーのキーなしティアを一時的に使用します。このプロバイダーをスキップするにはオフにしてください。匿名リクエストを送信する代わりに、キーなしティアがそれらを拒否する場合(401)に推奨されます。", "anonymousFallbackEnabled": "{provider}の匿名フォールバックが有効になりました", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 63f33c3b78..a003916a18 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "오픈 소스 친구", "cheaperInferenceSupporterTooltip": "Cheaper Inference는 오픈 소스 친구로서 OmniRoute를 후원합니다", "kimiPartnerLinkNote": "파트너 링크 — 추가 비용 없이 OmniRoute를 지원합니다", + "codexQuotaPools": "Codex 할당량 풀", + "codexPoolAvailable": "사용 가능", + "codexPoolPartiallyLimited": "일부 제한됨", + "codexPoolFullyLimited": "모두 제한됨", + "codexPoolLimited": "{count}개 제한됨", + "codexPoolQuotaExhausted": "할당량 소진", + "codexPoolCoolingDown": "대기 시간 적용 중", + "codexPoolUsed": "사용됨", + "codexPoolUntil": "{value}까지", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "익명 대체", "anonymousFallbackDesc": "모든 구성된 연결이 소진되면(쿼터, 크레딧 또는 만료), 이 공급자의 키 없는 계층을 임시로 사용합니다. 익명 요청을 보내는 대신 이 공급자를 건너뛰려면 끄세요. 키 없는 계층이 요청을 거부할 때(401) 권장됩니다.", "anonymousFallbackEnabled": "{provider}에 대한 익명 대체가 활성화되었습니다.", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 4f79317243..d7d3d22e29 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "ओपन सोर्स मित्र", "cheaperInferenceSupporterTooltip": "Cheaper Inference ओपन सोर्स मित्र म्हणून OmniRoute ला पाठिंबा देते", "kimiPartnerLinkNote": "भागीदार लिंक — तुमच्यासाठी कोणत्याही अतिरिक्त खर्चाशिवाय OmniRoute ला सपोर्ट करते", + "codexQuotaPools": "Codex कोटा पूल", + "codexPoolAvailable": "उपलब्ध", + "codexPoolPartiallyLimited": "अंशतः मर्यादित", + "codexPoolFullyLimited": "पूर्णपणे मर्यादित", + "codexPoolLimited": "{count} मर्यादित", + "codexPoolQuotaExhausted": "कोटा संपला", + "codexPoolCoolingDown": "प्रतीक्षा कालावधीत", + "codexPoolUsed": "वापरले", + "codexPoolUntil": "{value} पर्यंत", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "अज्ञात बॅकअप", "anonymousFallbackDesc": "जेव्हा सर्व कॉन्फिगर केलेले कनेक्शन संपतात (कोटा, क्रेडिट्स, किंवा कालावधी), तेव्हा तात्पुरते या प्रदात्याचा कीलेस स्तर वापरा. गुप्त विनंत्या पाठविण्याऐवजी या प्रदात्याला वगळण्यासाठी बंद करा — जेव्हा कीलेस स्तर त्यांना नकार देतो (401) तेव्हा शिफारस केले जाते.", "anonymousFallbackEnabled": "{provider} साठी गुप्तFallback सक्षम आहे", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index a3b3cb299b..3f7482ff8f 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Rakan Sumber Terbuka", "cheaperInferenceSupporterTooltip": "Cheaper Inference menyokong OmniRoute sebagai Rakan Sumber Terbuka", "kimiPartnerLinkNote": "Pautan rakan kongsi — menyokong OmniRoute tanpa kos tambahan kepada anda", + "codexQuotaPools": "Kumpulan kuota Codex", + "codexPoolAvailable": "Tersedia", + "codexPoolPartiallyLimited": "Dihadkan sebahagian", + "codexPoolFullyLimited": "Dihadkan sepenuhnya", + "codexPoolLimited": "{count} dihadkan", + "codexPoolQuotaExhausted": "Kuota telah habis", + "codexPoolCoolingDown": "Dalam tempoh menunggu", + "codexPoolUsed": "digunakan", + "codexPoolUntil": "Sehingga {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback Tanpa Nama", "anonymousFallbackDesc": "Apabila semua sambungan yang dikonfigurasikan habis (kuota, kredit, atau tamat tempoh), gunakan sementara tier tanpa kunci penyedia ini. Matikan untuk mengabaikan penyedia ini daripada menghantar permintaan tanpa nama — disyorkan apabila tier tanpa kunci menolak permintaan tersebut (401).", "anonymousFallbackEnabled": "Fallback tanpa nama diaktifkan untuk {provider}", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 3e1b2d34c2..5355f29c17 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Opensourcevriend", "cheaperInferenceSupporterTooltip": "Cheaper Inference steunt OmniRoute als opensourcevriend", "kimiPartnerLinkNote": "Partnerlink — ondersteunt OmniRoute zonder extra kosten voor u", + "codexQuotaPools": "Codex-quotapools", + "codexPoolAvailable": "Beschikbaar", + "codexPoolPartiallyLimited": "Gedeeltelijk beperkt", + "codexPoolFullyLimited": "Volledig beperkt", + "codexPoolLimited": "{count} beperkt", + "codexPoolQuotaExhausted": "Quota opgebruikt", + "codexPoolCoolingDown": "In afkoelperiode", + "codexPoolUsed": "gebruikt", + "codexPoolUntil": "Tot {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonieme fallback", "anonymousFallbackDesc": "Wanneer alle geconfigureerde verbindingen zijn uitgeput (quota, tegoeden of vervaldatum), gebruik tijdelijk de keyless-laag van deze provider. Zet uit om deze provider over te slaan in plaats van anonieme verzoeken te verzenden - aanbevolen wanneer de keyless-laag deze afwijst (401).", "anonymousFallbackEnabled": "Anonieme fallback ingeschakeld voor {provider}", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index f8309f8536..77a2074b7e 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Åpen kildekode-venn", "cheaperInferenceSupporterTooltip": "Cheaper Inference støtter OmniRoute som åpen kildekode-venn", "kimiPartnerLinkNote": "Partnerlenke — støtter OmniRoute uten ekstra kostnad for deg", + "codexQuotaPools": "Codex-kvotepuljer", + "codexPoolAvailable": "Tilgjengelig", + "codexPoolPartiallyLimited": "Delvis begrenset", + "codexPoolFullyLimited": "Fullstendig begrenset", + "codexPoolLimited": "{count} begrenset", + "codexPoolQuotaExhausted": "Kvoten er oppbrukt", + "codexPoolCoolingDown": "I nedkjølingsperiode", + "codexPoolUsed": "brukt", + "codexPoolUntil": "Til {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonym fallback", "anonymousFallbackDesc": "Når alle konfigurerte tilkoblinger er brukt opp (kvote, kreditter eller utløp), bruk midlertidig denne leverandørens nøkkelløse nivå. Slå av for å hoppe over denne leverandøren i stedet for å sende anonyme forespørsel — anbefales når det nøkkelløse nivået avviser dem (401).", "anonymousFallbackEnabled": "Anonym fallback aktivert for {provider}", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index c8048289c4..cfe0a6e006 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Kaibigan ng Open Source", "cheaperInferenceSupporterTooltip": "Sinusuportahan ng Cheaper Inference ang OmniRoute bilang Kaibigan ng Open Source", "kimiPartnerLinkNote": "Link ng kasosyo — sumusuporta sa OmniRoute nang walang karagdagang gastos sa iyo", + "codexQuotaPools": "Mga pool ng quota ng Codex", + "codexPoolAvailable": "Magagamit", + "codexPoolPartiallyLimited": "Bahagyang limitado", + "codexPoolFullyLimited": "Ganap na limitado", + "codexPoolLimited": "{count} limitado", + "codexPoolQuotaExhausted": "Ubos na ang quota", + "codexPoolCoolingDown": "Nasa panahon ng paghihintay", + "codexPoolUsed": "nagamit", + "codexPoolUntil": "Hanggang {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonymous fallback", "anonymousFallbackDesc": "Kapag naubos na ang lahat ng nakatakdang koneksyon (quota, kredito, o pag-expire), pansamantalang gamitin ang keyless tier ng provider na ito. Patayin upang laktawan ang provider na ito sa halip na magpadala ng mga hindi nagpapakilalang kahilingan — inirerekomenda kapag tinanggihan ng keyless tier ang mga ito (401).", "anonymousFallbackEnabled": "Naka-enable ang anonymous fallback para sa {provider}", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 660d2fa395..04590ea8ad 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Przyjaciel open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference wspiera OmniRoute jako przyjaciel open source", "kimiPartnerLinkNote": "Link partnerski — wspiera OmniRoute bez żadnych dodatkowych kosztów dla Ciebie", + "codexQuotaPools": "Pule limitów Codex", + "codexPoolAvailable": "Dostępna", + "codexPoolPartiallyLimited": "Częściowo ograniczona", + "codexPoolFullyLimited": "Całkowicie ograniczona", + "codexPoolLimited": "Ograniczone: {count}", + "codexPoolQuotaExhausted": "Limit wyczerpany", + "codexPoolCoolingDown": "W okresie oczekiwania", + "codexPoolUsed": "wykorzystano", + "codexPoolUntil": "Do {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonimowe zapasowe", "anonymousFallbackDesc": "Gdy wszystkie skonfigurowane połączenia są wyczerpane (kwota, kredyty lub wygaśnięcie), tymczasowo użyj bezkluczowego poziomu tego dostawcy. Wyłącz, aby pominąć tego dostawcę zamiast wysyłać anonimowe żądania — zalecane, gdy bezkluczowy poziom je odrzuca (401).", "anonymousFallbackEnabled": "Anonimowe przełączanie włączone dla {provider}", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index ef36e19471..a4f8a5f3cf 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -6270,6 +6270,27 @@ "kimiOfficialSupporterTooltip": "A Kimi (Moonshot AI) é parceira oficial de lançamento do OmniRoute", "cheaperInferenceSupporterBadge": "Amigo do Código Aberto", "cheaperInferenceSupporterTooltip": "A Cheaper Inference apoia o OmniRoute como amiga do código aberto", + "codexQuotaPools": "Pools de cotas do Codex", + "codexPoolAvailable": "Disponível", + "codexPoolPartiallyLimited": "Parcialmente limitado", + "codexPoolFullyLimited": "Totalmente limitado", + "codexPoolLimited": "{count} limitados", + "codexPoolQuotaExhausted": "Cota esgotada", + "codexPoolCoolingDown": "Em período de espera", + "codexPoolUsed": "usado", + "codexPoolUntil": "Até {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "kimiPartnerLinkNote": "Link de parceria — apoia o OmniRoute sem custo extra para você", "anonymousFallbackTitle": "Fallback anônimo", "anonymousFallbackDesc": "Quando todas as conexões configuradas estiverem esgotadas (cota, créditos ou expiração), use temporariamente a camada sem chave deste provedor. Desative para ignorar este provedor em vez de enviar solicitações anônimas — recomendado quando a camada sem chave as rejeita (401).", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 45afde064c..9935bfe7ef 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Amigo do Código Aberto", "cheaperInferenceSupporterTooltip": "A Cheaper Inference apoia o OmniRoute como amiga do código aberto", "kimiPartnerLinkNote": "Link de parceiro — apoia o OmniRoute sem custos adicionais para si", + "codexQuotaPools": "Pools de quotas do Codex", + "codexPoolAvailable": "Disponível", + "codexPoolPartiallyLimited": "Parcialmente limitado", + "codexPoolFullyLimited": "Totalmente limitado", + "codexPoolLimited": "{count} limitados", + "codexPoolQuotaExhausted": "Quota esgotada", + "codexPoolCoolingDown": "Em período de espera", + "codexPoolUsed": "utilizado", + "codexPoolUntil": "Até {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback anónimo", "anonymousFallbackDesc": "Quando todas as conexões configuradas estiverem esgotadas (quota, créditos ou expiração), use temporariamente o nível sem chave deste fornecedor. Desative para ignorar este fornecedor em vez de enviar pedidos anónimos — recomendado quando o nível sem chave os rejeita (401).", "anonymousFallbackEnabled": "Fallback anónimo ativado para {provider}", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 40328b5ea2..cec84fa369 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Prieten open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference susține OmniRoute ca prieten open source", "kimiPartnerLinkNote": "Link de partener — susține OmniRoute fără costuri suplimentare pentru dvs.", + "codexQuotaPools": "Grupuri de cote Codex", + "codexPoolAvailable": "Disponibil", + "codexPoolPartiallyLimited": "Limitat parțial", + "codexPoolFullyLimited": "Limitat complet", + "codexPoolLimited": "{count} limitate", + "codexPoolQuotaExhausted": "Cota a fost epuizată", + "codexPoolCoolingDown": "În perioada de așteptare", + "codexPoolUsed": "utilizat", + "codexPoolUntil": "Până la {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Fallback anonim", "anonymousFallbackDesc": "Când toate conexiunile configurate sunt epuizate (cota, credite sau expirare), folosiți temporar nivelul fără cheie al acestui furnizor. Dezactivați pentru a sări peste acest furnizor în loc de a trimite cereri anonime — recomandat atunci când nivelul fără cheie le respinge (401).", "anonymousFallbackEnabled": "Fallback anonim activat pentru {provider}", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index d2ceb4e4fb..ba9a4e812e 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Друг открытого кода", "cheaperInferenceSupporterTooltip": "Cheaper Inference поддерживает OmniRoute как друг открытого кода", "kimiPartnerLinkNote": "Партнерская ссылка — поддерживает OmniRoute без дополнительных затрат с вашей стороны", + "codexQuotaPools": "Пулы квот Codex", + "codexPoolAvailable": "Доступен", + "codexPoolPartiallyLimited": "Частично ограничен", + "codexPoolFullyLimited": "Полностью ограничен", + "codexPoolLimited": "Ограничено: {count}", + "codexPoolQuotaExhausted": "Квота исчерпана", + "codexPoolCoolingDown": "В периоде ожидания", + "codexPoolUsed": "использовано", + "codexPoolUntil": "До {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Анонимный резервный вариант", "anonymousFallbackDesc": "Когда все настроенные соединения исчерпаны (квота, кредиты или срок действия), временно используйте безключевой уровень этого провайдера. Выключите, чтобы пропустить этого провайдера вместо отправки анонимных запросов — рекомендуется, когда безключевой уровень их отклоняет (401).", "anonymousFallbackEnabled": "Анонимный резервный вариант включен для {provider}", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 61e20724cf..fbd89bef06 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Priateľ open source", "cheaperInferenceSupporterTooltip": "Cheaper Inference podporuje OmniRoute ako priateľ open source", "kimiPartnerLinkNote": "Partnerský odkaz — podporuje OmniRoute bez akýchkoľvek dodatočných nákladov pre vás", + "codexQuotaPools": "Fondy kvót Codex", + "codexPoolAvailable": "Dostupný", + "codexPoolPartiallyLimited": "Čiastočne obmedzený", + "codexPoolFullyLimited": "Úplne obmedzený", + "codexPoolLimited": "Obmedzené: {count}", + "codexPoolQuotaExhausted": "Kvóta vyčerpaná", + "codexPoolCoolingDown": "V čakacej lehote", + "codexPoolUsed": "využité", + "codexPoolUntil": "Do {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonymný záložný systém", "anonymousFallbackDesc": "Keď sú všetky nakonfigurované pripojenia vyčerpané (kvóta, kredity alebo vypršanie platnosti), dočasne použite bezkľúčovú úroveň tohto poskytovateľa. Vypnite, aby ste preskočili tohto poskytovateľa namiesto odosielania anonymných požiadaviek — odporúča sa, keď bezkľúčová úroveň ich odmieta (401).", "anonymousFallbackEnabled": "Anonymný záložný režim povolený pre {provider}", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index ed67f44d8b..75b165386a 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Öppen källkod-vän", "cheaperInferenceSupporterTooltip": "Cheaper Inference stödjer OmniRoute som öppen källkod-vän", "kimiPartnerLinkNote": "Partnerlänk — stöder OmniRoute utan extra kostnad för dig", + "codexQuotaPools": "Codex-kvotpooler", + "codexPoolAvailable": "Tillgänglig", + "codexPoolPartiallyLimited": "Delvis begränsad", + "codexPoolFullyLimited": "Helt begränsad", + "codexPoolLimited": "{count} begränsade", + "codexPoolQuotaExhausted": "Kvoten är förbrukad", + "codexPoolCoolingDown": "I vänteperiod", + "codexPoolUsed": "använt", + "codexPoolUntil": "Till {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonym fallback", "anonymousFallbackDesc": "När alla konfigurerade anslutningar är uttömda (kvot, krediter eller utgång), använd tillfälligt denna leverantörs nyckellösa nivå. Stäng av för att hoppa över denna leverantör istället för att skicka anonyma förfrågningar — rekommenderas när den nyckellösa nivån avvisar dem (401).", "anonymousFallbackEnabled": "Anonym fallback aktiverad för {provider}", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 21502538ee..15f8f0e290 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Rafiki wa Chanzo Huria", "cheaperInferenceSupporterTooltip": "Cheaper Inference inaunga mkono OmniRoute kama Rafiki wa Chanzo Huria", "kimiPartnerLinkNote": "Kiungo cha mshirika — kinaunga mkono OmniRoute bila gharama ya ziada kwako", + "codexQuotaPools": "Makundi ya mgao wa Codex", + "codexPoolAvailable": "Inapatikana", + "codexPoolPartiallyLimited": "Imewekewa kikomo kwa sehemu", + "codexPoolFullyLimited": "Imewekewa kikomo kikamilifu", + "codexPoolLimited": "{count} zimewekewa kikomo", + "codexPoolQuotaExhausted": "Mgao umeisha", + "codexPoolCoolingDown": "Katika kipindi cha kusubiri", + "codexPoolUsed": "imetumika", + "codexPoolUntil": "Hadi {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Kurejea kwa Kijakazi", "anonymousFallbackDesc": "Wakati muunganisho wote uliowekwa umepita (kikomo, mikopo, au muda wa kumalizika), tumia muda huu kiwango kisicho na funguo cha mtoa huduma huyu. Zima ili kupuuza mtoa huduma huyu badala ya kutuma maombi yasiyo na utambulisho — inapendekezwa wakati kiwango kisicho na funguo kinapokataa maombi hayo (401).", "anonymousFallbackEnabled": "Fallback isiyojulikana imewezeshwa kwa {provider}", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 5b8e2e395a..d9bb96f0f4 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "திறந்த மூல நண்பர்", "cheaperInferenceSupporterTooltip": "Cheaper Inference ஒரு திறந்த மூல நண்பராக OmniRoute-ஐ ஆதரிக்கிறது", "kimiPartnerLinkNote": "பங்குதாரர் இணைப்பு — உங்களுக்கு எந்த கூடுதல் கட்டணமும் இன்றி OmniRoute-ஐ ஆதரிக்கிறது", + "codexQuotaPools": "Codex ஒதுக்கீட்டுத் தொகுப்புகள்", + "codexPoolAvailable": "கிடைக்கிறது", + "codexPoolPartiallyLimited": "பகுதியளவு கட்டுப்படுத்தப்பட்டது", + "codexPoolFullyLimited": "முழுமையாகக் கட்டுப்படுத்தப்பட்டது", + "codexPoolLimited": "{count} கட்டுப்படுத்தப்பட்டவை", + "codexPoolQuotaExhausted": "ஒதுக்கீடு தீர்ந்தது", + "codexPoolCoolingDown": "காத்திருப்பு காலத்தில் உள்ளது", + "codexPoolUsed": "பயன்படுத்தப்பட்டது", + "codexPoolUntil": "{value} வரை", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "அறியப்படாத மாற்று", "anonymousFallbackDesc": "எல்லா கட்டமைக்கப்பட்ட இணைப்புகள் முடிந்தால் (கோட்டா, கிரெடிட்கள், அல்லது காலாவதி), இந்த வழங்குநரின் விசையில்லா நிலையை தற்காலிகமாக பயன்படுத்தவும். இந்த வழங்குநரை தவிர்க்க மாறி அனான்மா கோரிக்கைகளை அனுப்பாமல் выключить செய்யவும் — விசையில்லா நிலை அவற்றை நிராகரிக்கும் போது (401) பரிந்துரைக்கப்படுகிறது.", "anonymousFallbackEnabled": "{provider} க்கான அங்கீகாரம் இல்லாத மாற்று செயல்படுத்தப்பட்டது", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index a9a45379d1..65aa08d437 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "ఓపెన్ సోర్స్ స్నేహితుడు", "cheaperInferenceSupporterTooltip": "Cheaper Inference ఓపెన్ సోర్స్ స్నేహితురాలిగా OmniRoute కు మద్దతు ఇస్తోంది", "kimiPartnerLinkNote": "భాగస్వామి లింక్ — మీకు ఎటువంటి అదనపు ఖర్చు లేకుండా OmniRouteకు మద్దతు ఇస్తుంది", + "codexQuotaPools": "Codex కోటా పూల్‌లు", + "codexPoolAvailable": "అందుబాటులో ఉంది", + "codexPoolPartiallyLimited": "పాక్షికంగా పరిమితం", + "codexPoolFullyLimited": "పూర్తిగా పరిమితం", + "codexPoolLimited": "{count} పరిమితం", + "codexPoolQuotaExhausted": "కోటా అయిపోయింది", + "codexPoolCoolingDown": "నిరీక్షణ వ్యవధిలో ఉంది", + "codexPoolUsed": "ఉపయోగించబడింది", + "codexPoolUntil": "{value} వరకు", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "అనామక ఫాల్బ్యాక్", "anonymousFallbackDesc": "అన్ని కాన్ఫిగర్ చేసిన కనెక్షన్లు ముగిసినప్పుడు (కోటా, క్రెడిట్స్, లేదా కాలం ముగిసినప్పుడు), తాత్కాలికంగా ఈ ప్రొవైడర్ యొక్క కీ లెస్ టియర్‌ను ఉపయోగించండి. అనామక అభ్యర్థనలను పంపించకుండా ఈ ప్రొవైడర్‌ను దాటించడానికి ఆపివేయండి — కీ లెస్ టియర్ వాటిని తిరస్కరించినప్పుడు (401) సిఫారసు చేయబడింది.", "anonymousFallbackEnabled": "{provider} కోసం అనామక ఫాల్బ్యాక్ ప్రారంభించబడింది", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 95b09b243b..ed1e5d4063 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "เพื่อนโอเพนซอร์ส", "cheaperInferenceSupporterTooltip": "Cheaper Inference สนับสนุน OmniRoute ในฐานะเพื่อนโอเพนซอร์ส", "kimiPartnerLinkNote": "ลิงก์พันธมิตร — สนับสนุน OmniRoute โดยไม่มีค่าใช้จ่ายเพิ่มเติมสำหรับคุณ", + "codexQuotaPools": "พูลโควตา Codex", + "codexPoolAvailable": "พร้อมใช้งาน", + "codexPoolPartiallyLimited": "ถูกจำกัดบางส่วน", + "codexPoolFullyLimited": "ถูกจำกัดทั้งหมด", + "codexPoolLimited": "ถูกจำกัด {count} รายการ", + "codexPoolQuotaExhausted": "โควตาหมดแล้ว", + "codexPoolCoolingDown": "อยู่ในช่วงพัก", + "codexPoolUsed": "ใช้แล้ว", + "codexPoolUntil": "จนถึง {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "การสำรองข้อมูลแบบไม่ระบุชื่อ", "anonymousFallbackDesc": "เมื่อการเชื่อมต่อที่กำหนดทั้งหมดหมดลง (โควตา, เครดิต, หรือหมดอายุ) ให้ใช้ชั้นที่ไม่มีคีย์ของผู้ให้บริการนี้ชั่วคราว ปิดเพื่อข้ามผู้ให้บริการนี้แทนที่จะส่งคำขอแบบไม่ระบุชื่อ — แนะนำเมื่อชั้นที่ไม่มีคีย์ปฏิเสธคำขอเหล่านั้น (401).", "anonymousFallbackEnabled": "เปิดใช้งานการสำรองข้อมูลแบบไม่ระบุชื่อสำหรับ {provider}", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 4b0086bc69..5ef5816345 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Açık Kaynak Dostu", "cheaperInferenceSupporterTooltip": "Cheaper Inference, OmniRoute'u Açık Kaynak Dostu olarak destekliyor", "kimiPartnerLinkNote": "Ortaklık bağlantısı — size hiçbir ek ücret ödetmeden OmniRoute'u destekler", + "codexQuotaPools": "Codex kota havuzları", + "codexPoolAvailable": "Kullanılabilir", + "codexPoolPartiallyLimited": "Kısmen sınırlı", + "codexPoolFullyLimited": "Tamamen sınırlı", + "codexPoolLimited": "{count} sınırlı", + "codexPoolQuotaExhausted": "Kota tükendi", + "codexPoolCoolingDown": "Bekleme süresinde", + "codexPoolUsed": "kullanıldı", + "codexPoolUntil": "{value} tarihine kadar", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Anonim yedekleme", "anonymousFallbackDesc": "Tüm yapılandırılmış bağlantılar tükendiğinde (kota, kredi veya süresi dolmuş), bu sağlayıcının anahtarsız katmanını geçici olarak kullanın. Anahtarsız katmanın bunları reddettiği (401) durumlarda, anonim istek göndermek yerine bu sağlayıcıyı atlamak için kapatın — önerilir.", "anonymousFallbackEnabled": "{provider} için anonim geri dönüş etkinleştirildi", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 581b3a2ba2..9fe5efbd52 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Друг відкритого коду", "cheaperInferenceSupporterTooltip": "Cheaper Inference підтримує OmniRoute як друг відкритого коду", "kimiPartnerLinkNote": "Партнерське посилання — підтримує OmniRoute без додаткових витрат для вас", + "codexQuotaPools": "Пули квот Codex", + "codexPoolAvailable": "Доступний", + "codexPoolPartiallyLimited": "Частково обмежений", + "codexPoolFullyLimited": "Повністю обмежений", + "codexPoolLimited": "Обмежено: {count}", + "codexPoolQuotaExhausted": "Квоту вичерпано", + "codexPoolCoolingDown": "У періоді очікування", + "codexPoolUsed": "використано", + "codexPoolUntil": "До {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "Анонімний резервний варіант", "anonymousFallbackDesc": "Коли всі налаштовані з'єднання вичерпані (квота, кредити або термін дії), тимчасово використовуйте безключовий рівень цього постачальника. Вимкніть, щоб пропустити цього постачальника замість надсилання анонімних запитів — рекомендовано, коли безключовий рівень їх відхиляє (401).", "anonymousFallbackEnabled": "Анонімний резервний варіант увімкнено для {provider}", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 091968e447..22deb83299 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "اوپن سورس دوست", "cheaperInferenceSupporterTooltip": "‏Cheaper Inference اوپن سورس دوست کے طور پر OmniRoute کی حمایت کرتی ہے", "kimiPartnerLinkNote": "پارٹنر لنک — آپ کے لیے بغیر کسی اضافی قیمت کے OmniRoute کو سپورٹ کرتا ہے", + "codexQuotaPools": "Codex کوٹا پولز", + "codexPoolAvailable": "دستیاب", + "codexPoolPartiallyLimited": "جزوی طور پر محدود", + "codexPoolFullyLimited": "مکمل طور پر محدود", + "codexPoolLimited": "{count} محدود", + "codexPoolQuotaExhausted": "کوٹا ختم ہو گیا", + "codexPoolCoolingDown": "وقفۂ انتظار میں", + "codexPoolUsed": "استعمال شدہ", + "codexPoolUntil": "{value} تک", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "نامعلوم متبادل", "anonymousFallbackDesc": "جب تمام کنفیگر کردہ کنکشنز ختم ہو جائیں (کوٹہ، کریڈٹس، یا میعاد)، اس فراہم کنندہ کی بغیر کلید کی سطح کو عارضی طور پر استعمال کریں۔ اس فراہم کنندہ کو چھوڑنے کے لیے بند کریں بجائے اس کے کہ گمنام درخواستیں بھیجیں — جب بغیر کلید کی سطح انہیں مسترد کرتی ہے (401) تو یہ تجویز کردہ ہے۔", "anonymousFallbackEnabled": "{provider} کے لیے نامعلوم متبادل فعال ہے", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 1691040f80..eb1cb41b6e 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "Người bạn mã nguồn mở", "cheaperInferenceSupporterTooltip": "Cheaper Inference hỗ trợ OmniRoute với tư cách là người bạn mã nguồn mở", "kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you", + "codexQuotaPools": "Nhóm hạn mức Codex", + "codexPoolAvailable": "Khả dụng", + "codexPoolPartiallyLimited": "Bị giới hạn một phần", + "codexPoolFullyLimited": "Bị giới hạn hoàn toàn", + "codexPoolLimited": "{count} mục bị giới hạn", + "codexPoolQuotaExhausted": "Đã hết hạn mức", + "codexPoolCoolingDown": "Đang trong thời gian chờ", + "codexPoolUsed": "đã dùng", + "codexPoolUntil": "Đến {value}", + "ccAliasSectionTitle": "Hiển thị trong Claude Code (claude/…)", + "ccAliasSectionHint": "Công bố các mô hình của nhà cung cấp này dưới dạng id phản chiếu claude/<provider>/<model> để tính năng khám phá mô hình qua gateway của Claude Code có thể liệt kê chúng. Mặc định tắt — bật lên sẽ nhân đôi số mục trong danh mục với mọi client.", + "ccAliasProviderLevelLabel": "Mặc định của nhà cung cấp", + "ccAliasModelOverridesLabel": "Ghi đè theo từng mô hình", + "ccAliasModelOverrideAriaLabel": "Ghi đè cho {modelId}", + "ccAliasStateInherit": "Kế thừa", + "ccAliasStateOn": "Bật", + "ccAliasStateOff": "Tắt", + "ccAliasAddModelPlaceholder": "Id mô hình (ví dụ: gpt-4o)", + "ccAliasAddModelButton": "Thêm ghi đè", + "ccAliasLoadError": "Không tải được cài đặt bí danh khám phá: {error}", + "ccAliasSaveError": "Không lưu được cài đặt bí danh khám phá: {error}", "anonymousFallbackTitle": "Dự phòng ẩn danh", "anonymousFallbackDesc": "Khi tất cả kết nối đã cấu hình đều cạn kiệt (hạn ngạch, tín dụng hoặc hết hạn), hãy tạm thời sử dụng tầng không cần khóa của nhà cung cấp này. Tắt tùy chọn này để bỏ qua nhà cung cấp thay vì gửi yêu cầu ẩn danh — khuyến nghị khi tầng không cần khóa từ chối các yêu cầu đó (401).", "anonymousFallbackEnabled": "Đã bật dự phòng ẩn danh cho {provider}", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 18a1ea316f..31d1a87e10 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -6282,6 +6282,27 @@ "cheaperInferenceSupporterBadge": "开源好友", "cheaperInferenceSupporterTooltip": "Cheaper Inference 作为开源好友支持 OmniRoute", "kimiPartnerLinkNote": "合作伙伴链接 — 支持 OmniRoute,您无需承担额外费用", + "codexQuotaPools": "Codex 配额池", + "codexPoolAvailable": "可用", + "codexPoolPartiallyLimited": "部分受限", + "codexPoolFullyLimited": "全部受限", + "codexPoolLimited": "{count} 个受限", + "codexPoolQuotaExhausted": "配额已用尽", + "codexPoolCoolingDown": "冷却中", + "codexPoolUsed": "已使用", + "codexPoolUntil": "截至 {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "匿名回退", "anonymousFallbackDesc": "当所有配置的连接耗尽(配额、积分或到期)时,临时使用此提供者的无密钥层。关闭以跳过此提供者,而不是发送匿名请求 — 当无密钥层拒绝它们时(401)建议使用。", "anonymousFallbackEnabled": "为 {provider} 启用匿名回退", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 7465d3e9f6..6bc5f1954d 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -6271,6 +6271,27 @@ "cheaperInferenceSupporterBadge": "開源好友", "cheaperInferenceSupporterTooltip": "Cheaper Inference 作為開源好友支持 OmniRoute", "kimiPartnerLinkNote": "合作夥伴連結 — 支援 OmniRoute,您無需額外付費", + "codexQuotaPools": "Codex 配額集區", + "codexPoolAvailable": "可用", + "codexPoolPartiallyLimited": "部分受限", + "codexPoolFullyLimited": "全部受限", + "codexPoolLimited": "{count} 個受限", + "codexPoolQuotaExhausted": "配額已用盡", + "codexPoolCoolingDown": "冷卻中", + "codexPoolUsed": "已使用", + "codexPoolUntil": "截至 {value}", + "ccAliasSectionTitle": "__MISSING__:Expose in Claude Code (claude/…)", + "ccAliasSectionHint": "__MISSING__:Advertise this provider's models under claude/<provider>/<model> mirror ids so Claude Code's gateway model discovery can list them. Off by default — enabling this doubles catalog entries for all clients.", + "ccAliasProviderLevelLabel": "__MISSING__:Provider default", + "ccAliasModelOverridesLabel": "__MISSING__:Per-model overrides", + "ccAliasModelOverrideAriaLabel": "__MISSING__:Override for {modelId}", + "ccAliasStateInherit": "__MISSING__:Inherit", + "ccAliasStateOn": "__MISSING__:On", + "ccAliasStateOff": "__MISSING__:Off", + "ccAliasAddModelPlaceholder": "__MISSING__:Model id (e.g. gpt-4o)", + "ccAliasAddModelButton": "__MISSING__:Add override", + "ccAliasLoadError": "__MISSING__:Failed to load discovery-alias settings: {error}", + "ccAliasSaveError": "__MISSING__:Failed to save discovery-alias setting: {error}", "anonymousFallbackTitle": "匿名後備", "anonymousFallbackDesc": "當所有配置的連接耗盡(配額、積分或到期)時,暫時使用此提供者的無密鑰層級。關閉以跳過此提供者,而不是發送匿名請求 — 當無密鑰層級拒絕它們(401)時建議這樣做。", "anonymousFallbackEnabled": "為 {provider} 啟用匿名後備", diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index a800ee7852..571ec2787b 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -890,6 +890,11 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { ); } +export { + updateCodexScopedQuotaState, + updateCodexScopeCooldown, +} from "./providers/codexAccountState"; + /** * Atomic conditional clear of recoverable error state on a connection row. * diff --git a/src/lib/db/providers/codexAccountState.ts b/src/lib/db/providers/codexAccountState.ts new file mode 100644 index 0000000000..77600de3bd --- /dev/null +++ b/src/lib/db/providers/codexAccountState.ts @@ -0,0 +1,119 @@ +import { backupDbFile } from "../backup"; +import { getDbInstance, rowToCamel } from "../core"; +import { invalidateDbCache } from "../readCache"; +import { toRecord } from "./columns"; + +type JsonRecord = Record; + +interface StatementLike { + get: (...params: unknown[]) => TRow | undefined; + run: (...params: unknown[]) => { changes?: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; + transaction: (fn: () => T) => () => T; +} + +type CodexScopedQuotaPatch = { + quotaState?: JsonRecord; + exhaustedWindow?: "5h" | "7d" | null; + rateLimitedUntil?: string; + rateLimitSource?: "fallback" | "quota_reset"; +}; + +/** + * Atomically merge one virtual Codex child's quota evidence into its persisted parent. + * The transaction reads the latest row so sibling child state cannot be lost. + */ +export async function updateCodexScopedQuotaState( + id: string, + scope: "codex" | "spark", + patch: CodexScopedQuotaPatch +): Promise { + const db = getDbInstance() as unknown as DbLike; + const candidate = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id); + if (toRecord(candidate).provider !== "codex") return null; + + backupDbFile("pre-write"); + const persisted = db.transaction(() => { + const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id); + if (!existing) return null; + + const existingRecord = toRecord(rowToCamel(existing)); + if (existingRecord.provider !== "codex") return null; + const providerSpecificData = toRecord(existingRecord.providerSpecificData); + const nextProviderSpecificData: JsonRecord = { ...providerSpecificData }; + + if (patch.quotaState) { + const quotaByScope = toRecord(providerSpecificData.codexQuotaStateByScope); + nextProviderSpecificData.codexQuotaStateByScope = { + ...quotaByScope, + [scope]: patch.quotaState, + }; + nextProviderSpecificData.codexQuotaState = { + ...patch.quotaState, + scope, + updatedAt: patch.quotaState.observedAt, + }; + } + + if (patch.exhaustedWindow !== undefined) { + const exhaustedByScope = { ...toRecord(providerSpecificData.codexExhaustedWindowByScope) }; + if (patch.exhaustedWindow) exhaustedByScope[scope] = patch.exhaustedWindow; + else delete exhaustedByScope[scope]; + nextProviderSpecificData.codexExhaustedWindowByScope = exhaustedByScope; + if (patch.exhaustedWindow) { + nextProviderSpecificData.codexExhaustedWindow = patch.exhaustedWindow; + } else { + delete nextProviderSpecificData.codexExhaustedWindow; + } + } + + if (patch.rateLimitedUntil) { + const scopeCooldowns = toRecord(providerSpecificData.codexScopeRateLimitedUntil); + const sourceByScope = toRecord(providerSpecificData.codexScopeRateLimitSource); + const existingCooldownMs = + typeof scopeCooldowns[scope] === "string" + ? new Date(scopeCooldowns[scope] as string).getTime() + : NaN; + const existingIsAuthoritative = + sourceByScope[scope] === "quota_reset" && + patch.rateLimitSource !== "quota_reset" && + Number.isFinite(existingCooldownMs) && + existingCooldownMs > Date.now(); + nextProviderSpecificData.codexScopeRateLimitedUntil = { + ...scopeCooldowns, + [scope]: existingIsAuthoritative ? scopeCooldowns[scope] : patch.rateLimitedUntil, + }; + nextProviderSpecificData.codexScopeRateLimitSource = { + ...sourceByScope, + [scope]: existingIsAuthoritative + ? sourceByScope[scope] + : (patch.rateLimitSource ?? "fallback"), + }; + } + + db.prepare( + `UPDATE provider_connections + SET provider_specific_data = ?, updated_at = ? + WHERE id = ?` + ).run(JSON.stringify(nextProviderSpecificData), new Date().toISOString(), id); + return nextProviderSpecificData; + })(); + + if (persisted) invalidateDbCache("connections"); + return persisted; +} + +/** Persist one child cooldown through the shared scoped quota-state transaction. */ +export async function updateCodexScopeCooldown( + id: string, + scope: "codex" | "spark", + rateLimitedUntil: string +): Promise { + return updateCodexScopedQuotaState(id, scope, { + rateLimitedUntil, + rateLimitSource: "fallback", + }); +} diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index 44acfdffc6..4cc18b27fd 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -1,3 +1,7 @@ +import { + createCodexAccountPool, + getCodexParentAccountDiagnostic, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import type { AdaptiveAdmissionPublicSnapshot } from "@omniroute/open-sse/services/admission/runtime.ts"; type JsonRecord = Record; @@ -130,7 +134,13 @@ interface BuildHealthPayloadOptions { buildSha?: string | null; catalogCount?: number; settings: { setupComplete?: boolean } | null | undefined; - connections: Array<{ provider?: string; isActive?: boolean | null; rateLimitedUntil?: unknown }>; + connections: Array<{ + id?: string; + provider?: string; + isActive?: boolean | null; + rateLimitedUntil?: unknown; + providerSpecificData?: Readonly> | null; + }>; circuitBreakers: CircuitBreakerStatus[]; rateLimitStatus: JsonRecord; learnedLimits: JsonRecord; @@ -273,6 +283,53 @@ export function summarizeConnectionCooldown( return summary; } +export interface CodexAccountPoolsSummary { + total: number; + available: number; + partiallyLimited: number; + fullyLimited: number; + quotaObserved: number; + soonestRetryAfterMs: number; +} + +export function summarizeCodexAccountPools( + connections: BuildHealthPayloadOptions["connections"], + nowMs: number +): CodexAccountPoolsSummary { + const summary: CodexAccountPoolsSummary = { + total: 0, + available: 0, + partiallyLimited: 0, + fullyLimited: 0, + quotaObserved: 0, + soonestRetryAfterMs: 0, + }; + for (const connection of connections) { + if (connection.provider !== "codex" || !connection.id) continue; + const diagnostic = getCodexParentAccountDiagnostic( + createCodexAccountPool({ + id: connection.id, + provider: connection.provider, + providerSpecificData: connection.providerSpecificData ?? {}, + }), + nowMs + ); + summary.total += 1; + if (diagnostic.status === "available") summary.available += 1; + else if (diagnostic.status === "partially_limited") summary.partiallyLimited += 1; + else summary.fullyLimited += 1; + if (diagnostic.quota.observedScopeCount > 0) summary.quotaObserved += 1; + if ( + diagnostic.cooldown.soonestRetryAfterMs > 0 && + (summary.soonestRetryAfterMs === 0 || + diagnostic.cooldown.soonestRetryAfterMs < summary.soonestRetryAfterMs) + ) { + summary.soonestRetryAfterMs = diagnostic.cooldown.soonestRetryAfterMs; + } + } + return summary; +} + export function buildHealthPayload({ appVersion, catalogCount = 0, @@ -333,7 +390,9 @@ export function buildHealthPayload({ }; } - const connectionHealth = summarizeConnectionCooldown(connections, Date.now()); + const nowMs = Date.now(); + const connectionHealth = summarizeConnectionCooldown(connections, nowMs); + const codexAccountPools = summarizeCodexAccountPools(connections, nowMs); const configuredProviders = new Set( connections.map((connection) => connection.provider).filter(Boolean) @@ -372,6 +431,7 @@ export function buildHealthPayload({ providerBreakers, providerHealth, connectionHealth, + codexAccountPools, providerSummary: { catalogCount, configuredCount: configuredProviders.size, diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 015df460f7..05786ab7aa 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -302,6 +302,10 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec if (Object.keys(record).length === 0) return undefined; const sanitized: JsonRecord = { ...record }; + delete sanitized.accessToken; + delete sanitized.refreshToken; + delete sanitized.idToken; + delete sanitized.apiKey; delete sanitized.consoleApiKey; delete sanitized.secretAccessKey; delete sanitized.awsSecretAccessKey; diff --git a/src/lib/usage/resilienceExplain.ts b/src/lib/usage/resilienceExplain.ts index 6b01e3bd09..e4f3ef8195 100644 --- a/src/lib/usage/resilienceExplain.ts +++ b/src/lib/usage/resilienceExplain.ts @@ -4,6 +4,11 @@ import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; import { getProviderConnections } from "@/lib/db/providers"; import { getCircuitBreaker } from "@/shared/utils/circuitBreaker"; import { getModelLockoutInfo } from "@omniroute/open-sse/services/accountFallback.ts"; +import { + createCodexAccountPool, + inspectCodexAccount, + resolveCodexAccount, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import type { ResilienceAccountExplanation, ResilienceExplainState, @@ -73,22 +78,6 @@ function isTerminalStatus(status: string): boolean { return status === "credits_exhausted" || status === "banned" || status === "expired"; } -function getCodexModelScope(model: string | null | undefined): "gpt-5" | "gpt-5-codex" { - const normalized = String(model || "").toLowerCase(); - return normalized.includes("codex") ? "gpt-5-codex" : "gpt-5"; -} - -function getCodexScopeRateLimitedUntil( - providerSpecificData: unknown, - model: string | null | undefined -): string | null { - if (!model) return null; - const data = asRecord(providerSpecificData); - const scopeMap = asRecord(data.codexScopeRateLimitedUntil); - const value = scopeMap[getCodexModelScope(model)]; - return toStringOrNull(value); -} - function buildProviderExplanation(provider: string): { provider: ResilienceProviderExplanation; skipReason: ResilienceSkipReason | null; @@ -274,10 +263,20 @@ function accountReason( }; } - const codexUntil = + const codexPool = options.provider === "codex" - ? getCodexScopeRateLimitedUntil(connection.providerSpecificData, options.model) + ? createCodexAccountPool({ + id: connectionId, + provider: options.provider, + providerSpecificData: asRecord(connection.providerSpecificData), + }) : null; + const codexAccount = codexPool ? resolveCodexAccount(codexPool, options.model) : null; + const codexState = + codexPool && codexAccount?.kind === "child" + ? inspectCodexAccount(codexPool, codexAccount, options.now) + : null; + const codexUntil = codexState?.kind === "child" ? codexState.rateLimitedUntil : null; const codexCooldownMs = retryAfter(codexUntil, options.now); if (codexCooldownMs !== null && codexCooldownMs > 0) { return { @@ -288,7 +287,7 @@ function accountReason( connectionId, message: `Codex scope for ${options.model} is in cooldown until ${codexUntil}.`, retryAfterMs: codexCooldownMs, - evidence: { rateLimitedUntil: codexUntil, scope: getCodexModelScope(options.model) }, + evidence: { rateLimitedUntil: codexUntil, scope: codexState?.scope ?? null }, }, }; } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 2a4d572b01..96a0de2761 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -32,6 +32,7 @@ import { DEFAULT_QUOTA_THRESHOLD_PERCENT, getQuotaCache, getQuotaWindowStatus, + hydrateCodexQuotaCacheForRequest, isQuotaExhaustedForRequest, } from "@/domain/quotaCache"; import { getQuotaScopeLabelForProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; @@ -86,6 +87,11 @@ import { toCodexBaseQuotaWindowName, toCodexScopedQuotaWindowName, } from "@omniroute/open-sse/config/codexQuotaScopes.ts"; +import { + getCodexChildCooldown, + isCodexChildUnavailable, + persistCodexChildCooldown, +} from "@omniroute/open-sse/services/codexAccount/index.ts"; import { getProviderById, getProviderAlias, @@ -333,44 +339,6 @@ function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: Json return uniqueWindows(windows); } -function getCodexScopeRateLimitedUntil( - providerSpecificData: JsonRecord, - model: string | null -): string | null { - if (!model) return null; - const scope = getCodexModelScope(model); - const scopeMap = asRecord(providerSpecificData.codexScopeRateLimitedUntil); - const value = scopeMap[scope]; - return typeof value === "string" && value.trim().length > 0 ? value : null; -} -function isCodexScopeUnavailable( - connection: ProviderConnectionView, - model: string | null -): boolean { - const until = getCodexScopeRateLimitedUntil(connection.providerSpecificData, model); - if (!until) return false; - return new Date(until).getTime() > Date.now(); -} -function getEarliestCodexScopeRateLimitedUntil( - connections: ProviderConnectionView[], - model: string | null -): string | null { - let earliest: string | null = null; - let earliestMs = Infinity; - - for (const conn of connections) { - const until = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model); - if (!until) continue; - const ms = new Date(until).getTime(); - if (!Number.isFinite(ms) || ms <= Date.now()) continue; - if (ms < earliestMs) { - earliest = until; - earliestMs = ms; - } - } - - return earliest; -} function normalizeStatus(value: string | null): string { return (value || "").trim().toLowerCase(); } @@ -940,6 +908,15 @@ async function markQuotaPreflightAccountUnavailable( requestedModel: string | null ): Promise { const unavailableUntil = quotaPreflightUnavailableUntil(preflight.resetAt ?? null); + if (provider === "codex" && requestedModel?.trim()) { + await persistCodexChildCooldown({ + connectionId, + model: requestedModel, + rateLimitedUntil: unavailableUntil, + }); + return unavailableUntil; + } + const percentLabel = Number.isFinite(preflight.quotaPercent) ? `${Math.round((preflight.quotaPercent as number) * 100)}%` : "exhausted"; @@ -1280,6 +1257,11 @@ export async function getProviderCredentials( } } + const isCodexScopeUnavailable = ( + connection: ProviderConnectionView, + model: string | null + ): boolean => provider === "codex" && isCodexChildUnavailable(connection, model); + // #5903: an active session-affinity pin outranks a per-request reset-aware // forcedConnectionId (see sessionAffinityPin leaf for the full rationale). if (!options.lease) { @@ -1547,7 +1529,7 @@ export async function getProviderCredentials( : ` → ${c.id?.slice(0, 8)} | skipped terminal status=${c.testStatus}` ); } else if (codexScopeLimited) { - const scopeUntil = getCodexScopeRateLimitedUntil(c.providerSpecificData, requestedModel); + const scopeUntil = getCodexChildCooldown(c, requestedModel); log.debug( "AUTH", allowSuppressedConnections @@ -1570,9 +1552,7 @@ export async function getProviderCredentials( const connectionCooldownMs = parseFutureDateMs(connection.rateLimitedUntil); const codexScopeCooldownMs = provider === "codex" - ? parseFutureDateMs( - getCodexScopeRateLimitedUntil(connection.providerSpecificData, requestedModel) - ) + ? parseFutureDateMs(getCodexChildCooldown(connection, requestedModel)) : null; const modelLockout = requestedModel ? getModelLockoutInfo(provider, connection.id, requestedModel) @@ -1584,12 +1564,7 @@ export async function getProviderCredentials( ? Date.now() + modelLockout.remainingMs : null; - return { - connection, - connectionCooldownMs, - codexScopeCooldownMs, - retryableModelCooldownMs, - }; + return { connection, connectionCooldownMs, codexScopeCooldownMs, retryableModelCooldownMs }; }); const cooldownCandidates = cooldownStates @@ -1665,6 +1640,12 @@ export async function getProviderCredentials( }> = []; const quotaResults = new Map(); + if (provider === "codex") { + for (const connection of availableConnections) { + hydrateCodexQuotaCacheForRequest(connection, requestedModel); + } + } + if (!bypassQuotaPolicy) { policyEligibleConnections = availableConnections.filter((connection) => { const evaluation = evaluateQuotaLimitPolicy(provider, connection, requestedModel); @@ -2414,11 +2395,8 @@ export async function markAccountUnavailable( } // T09: Codex scope-aware lockout guard (codex vs spark independent pools). - if (provider === "codex" && model) { - const scopeRateLimitedUntil = getCodexScopeRateLimitedUntil( - conn?.providerSpecificData || {}, - model - ); + if (provider === "codex" && typeof model === "string" && model.trim().length > 0) { + const scopeRateLimitedUntil = conn ? getCodexChildCooldown(conn, model) : null; if (scopeRateLimitedUntil && new Date(scopeRateLimitedUntil).getTime() > Date.now()) { log.info( "AUTH", @@ -2791,26 +2769,22 @@ export async function markAccountUnavailable( const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; // T09: Codex per-scope lockout (do not block the whole account globally). - if (provider === "codex" && status === 429 && model && conn) { + if ( + provider === "codex" && + status === 429 && + typeof model === "string" && + model.trim().length > 0 && + conn + ) { const scope = getCodexModelScope(model); - const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil); - const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model); - const scopeRateLimitedUntil = persistedScopeUntil || getUnavailableUntil(cooldownMs); + const scopeRateLimitedUntil = + getCodexChildCooldown(conn, model) || getUnavailableUntil(cooldownMs); const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0); - await updateProviderConnection(connectionId, { - testStatus: "unavailable", - lastError: errorMsg, - errorCode: status, - lastErrorAt: new Date().toISOString(), - backoffLevel: newBackoffLevel ?? backoffLevel, - providerSpecificData: { - ...conn.providerSpecificData, - codexScopeRateLimitedUntil: { - ...existingScopeMap, - [scope]: scopeRateLimitedUntil, - }, - }, + await persistCodexChildCooldown({ + connectionId, + model, + rateLimitedUntil: scopeRateLimitedUntil, }); if (scopeCooldownMs > 0) { @@ -2824,6 +2798,12 @@ export async function markAccountUnavailable( return { shouldFallback: true, cooldownMs: scopeCooldownMs }; } + // A Codex quota response without a model cannot be assigned to either virtual child. + // Preserve failover without inventing a third parent-level quota/cooldown state. + if (provider === "codex" && status === 429) { + return { shouldFallback: true, cooldownMs }; + } + const baseUpdate = { lastError: errorMsg, lastErrorType: providerErrorType, diff --git a/stryker.conf.json b/stryker.conf.json index 61c50f3db9..08c35843f5 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -106,6 +106,7 @@ "tests/unit/chat-helpers.test.ts", "tests/unit/chat-route-coverage.test.ts", "tests/unit/chat-route-edge-cases.test.ts", + "tests/unit/chatcore-codex-account-pool.test.ts", "tests/unit/chatcore-compression-integration.test.ts", "tests/unit/chatcore-executor-helpers.test.ts", "tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts", @@ -333,6 +334,7 @@ "tests/unit/settings/authz-bypass.test.ts", "tests/unit/skip-provider-breaker-consumer-2743.test.ts", "tests/unit/sse-auth-antigravity-credits.test.ts", + "tests/unit/sse-auth-codex-account-pool.test.ts", "tests/unit/sse-auth-resource-404.test.ts", "tests/unit/sse-auth.test.ts", "tests/unit/db/stats-dbstat-optional.test.ts", diff --git a/tests/integration/codex-account-pool-restart-http.test.ts b/tests/integration/codex-account-pool-restart-http.test.ts new file mode 100644 index 0000000000..66018cc93f --- /dev/null +++ b/tests/integration/codex-account-pool-restart-http.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-pool-http-")); +const PHASE_SCRIPT = path.join( + process.cwd(), + "tests/integration/fixtures/codex-account-pool-restart-phase.ts" +); + +type PhaseResult = { + phase: "before" | "after"; + connectionId: string; + upstreamModels: string[]; +}; + +function runPhase(phase: PhaseResult["phase"], connectionId?: string): PhaseResult { + const result = spawnSync(process.execPath, ["--import", "tsx/esm", PHASE_SCRIPT], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DATA_DIR: TEST_DATA_DIR, + CODEX_RESTART_PHASE: phase, + ...(connectionId ? { CODEX_EXPECTED_CONNECTION_ID: connectionId } : {}), + }, + maxBuffer: 50 * 1024 * 1024, + }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + const line = result.stdout.split("\n").find((entry) => entry.startsWith("CODEX_RESTART_RESULT=")); + assert.ok(line, result.stdout); + return JSON.parse(line.slice("CODEX_RESTART_RESULT=".length)) as PhaseResult; +} + +test("Codex Spark cooldown survives a fresh process without creating child connections", () => { + try { + const before = runPhase("before"); + assert.ok(before.upstreamModels.length > 0); + + const after = runPhase("after", before.connectionId); + assert.equal(after.connectionId, before.connectionId); + assert.deepEqual(after.upstreamModels, ["gpt-5.5"]); + } finally { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); diff --git a/tests/integration/fixtures/codex-account-pool-restart-phase.ts b/tests/integration/fixtures/codex-account-pool-restart-phase.ts new file mode 100644 index 0000000000..038f3a55f6 --- /dev/null +++ b/tests/integration/fixtures/codex-account-pool-restart-phase.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; + +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = "codex-pool-http-e2e-secret-123456"; +process.env.REQUIRE_API_KEY = "false"; +process.env.OMNIROUTE_LOG_REQUEST_SHAPE = "0"; + +const providersDb = await import("../../../src/lib/db/providers.ts"); +const chatRoute = await import("../../../src/app/api/v1/chat/completions/route.ts"); + +const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; +const phase = process.env.CODEX_RESTART_PHASE; +const expectedId = process.env.CODEX_EXPECTED_CONNECTION_ID; +const originalFetch = globalThis.fetch; +const upstreamModels: string[] = []; + +async function readIncomingBody(request: http.IncomingMessage) { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function bridgeRouteResponse(response: Response, outgoing: http.ServerResponse) { + outgoing.writeHead(response.status, Object.fromEntries(response.headers.entries())); + if (!response.body) { + outgoing.end(); + return; + } + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!outgoing.write(value)) await once(outgoing, "drain"); + } + outgoing.end(); + } finally { + reader.releaseLock(); + } +} + +async function startRouteServer() { + const server = http.createServer(async (incoming, outgoing) => { + try { + if (incoming.method !== "POST" || incoming.url !== "/v1/chat/completions") { + outgoing.writeHead(404).end(); + return; + } + const body = await readIncomingBody(incoming); + const address = server.address(); + assert(address && typeof address !== "string"); + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) { + if (Array.isArray(value)) value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, value); + } + const request = new Request(`http://127.0.0.1:${address.port}${incoming.url}`, { + method: "POST", + headers, + body, + }); + await bridgeRouteResponse(await chatRoute.POST(request), outgoing); + } catch { + outgoing.writeHead(500, { "content-type": "text/plain" }); + outgoing.end("internal test route error"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string"); + return { server, url: `http://127.0.0.1:${address.port}/v1/chat/completions` }; +} + +async function closeServer(server: http.Server) { + if (!server.listening) return; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} + +function successResponsesSse(model: string) { + return ( + [ + { + type: "response.created", + response: { + id: `resp-${model}`, + object: "response", + status: "in_progress", + model, + output: [], + }, + }, + { + type: "response.completed", + response: { + id: `resp-${model}`, + object: "response", + status: "completed", + model, + output: [], + }, + }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join("") + "data: [DONE]\n\n" + ); +} + +async function requestModel(url: string, model: string) { + return originalFetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model, + stream: true, + messages: [{ role: "user", content: "Say hello" }], + }), + }); +} + +if (phase !== "before" && phase !== "after") { + throw new Error("CODEX_RESTART_PHASE must be before or after"); +} + +let connectionId: string; +if (phase === "before") { + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-pool-restart", + email: "codex-pool-restart@example.test", + accessToken: "mock-codex-access-token", + refreshToken: "mock-codex-refresh-token", + tokenType: "Bearer", + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + connectionId = connection.id; +} else { + const inventory = await providersDb.getProviderConnections({ provider: "codex" }); + assert.equal(inventory.length, 1); + assert.equal(inventory[0].id, expectedId); + connectionId = inventory[0].id; +} + +const resetAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); +globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url !== CODEX_RESPONSES_URL) return originalFetch(input, init); + const requestBody = JSON.parse(String(init?.body || "{}")) as { model?: string }; + const model = String(requestBody.model || ""); + upstreamModels.push(model); + if (model.includes("spark")) { + return new Response( + JSON.stringify({ error: { message: "Spark quota exhausted", type: "rate_limit_error" } }), + { + status: 429, + headers: { + "content-type": "application/json", + "x-codex-5h-usage": "100", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": resetAt, + "x-codex-7d-usage": "1", + "x-codex-7d-limit": "1000", + "x-codex-7d-reset-at": new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }, + } + ); + } + return new Response(successResponsesSse(model), { + status: 200, + headers: { "content-type": "text/event-stream; charset=utf-8" }, + }); +}; + +let server: http.Server | undefined; +try { + const started = await startRouteServer(); + server = started.server; + if (phase === "before") { + const spark = await requestModel(started.url, "codex/gpt-5.3-codex-spark"); + await spark.text(); + assert.ok(upstreamModels.length > 0); + assert.equal( + upstreamModels.every((model) => model === "gpt-5.3-codex-spark"), + true + ); + } else { + const spark = await requestModel(started.url, "codex/gpt-5.3-codex-spark"); + await spark.text(); + assert.equal( + upstreamModels.length, + 0, + "fresh process must restore Spark cooldown before fetch" + ); + + const normal = await requestModel(started.url, "codex/gpt-5.5"); + const body = await normal.text(); + assert.equal(normal.status, 200, body); + assert.match(body, /"model":"gpt-5.5"/); + assert.deepEqual(upstreamModels, ["gpt-5.5"]); + } + + const inventory = await providersDb.getProviderConnections({ provider: "codex" }); + assert.deepEqual( + inventory.map((connection) => connection.id), + [connectionId] + ); + console.log(`CODEX_RESTART_RESULT=${JSON.stringify({ phase, connectionId, upstreamModels })}`); +} finally { + globalThis.fetch = originalFetch; + if (server) await closeServer(server); +} diff --git a/tests/unit/chatcore-codex-account-pool.test.ts b/tests/unit/chatcore-codex-account-pool.test.ts new file mode 100644 index 0000000000..a828d7357c --- /dev/null +++ b/tests/unit/chatcore-codex-account-pool.test.ts @@ -0,0 +1,315 @@ +// @ts-nocheck +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-chatcore-codex-pool-")); +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 auth = await import("../../src/sse/services/auth.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); + +const originalFetch = globalThis.fetch; + +function noopLog() { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + }; +} + +function toPlainHeaders(headers) { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers.entries()); + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)]) + ); +} + +function buildResponsesResponse(text = "ok") { + return new Response( + JSON.stringify({ + id: "resp_123", + object: "response", + status: "completed", + model: "gpt-5.1-codex", + output: [ + { + id: "msg_123", + type: "message", + role: "assistant", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + usage: { + input_tokens: 4, + output_tokens: 2, + total_tokens: 6, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function waitForAsyncSideEffects() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +async function invokeChatCore({ + body, + provider = "codex", + model, + endpoint = "/v1/responses", + credentials, + responseFactory, + connectionId = null, + isCombo = false, +}: { + body: unknown; + provider?: string; + model: string; + endpoint?: string; + credentials: Record; + responseFactory: (captured: unknown, calls: unknown[]) => Response; + connectionId?: string | null; + isCombo?: boolean; +}) { + const calls: unknown[] = []; + globalThis.fetch = async (url, init = {}) => { + const headers = toPlainHeaders(init.headers); + const captured = { + url: String(url), + method: init.method || "GET", + headers, + body: init.body ? JSON.parse(String(init.body)) : null, + }; + calls.push(captured); + return responseFactory(captured, calls); + }; + + try { + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider, model, extendedContext: false }, + credentials, + log: noopLog(), + clientRawRequest: { + endpoint, + body: structuredClone(body), + headers: new Headers({ accept: "application/json" }), + }, + connectionId, + userAgent: "unit-test", + isCombo, + }); + await waitForAsyncSideEffects(); + return { calls, result }; + } finally { + globalThis.fetch = originalFetch; + } +} + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + await waitForAsyncSideEffects(); + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + await waitForAsyncSideEffects(); + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("chatCore persists child cooldown for each rotated Codex attempt", async () => { + const first = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-rotation-first@example.com", + accessToken: "codex-rotation-first", + isActive: true, + providerSpecificData: {}, + }); + const second = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-rotation-second@example.com", + accessToken: "codex-rotation-second", + isActive: true, + providerSpecificData: {}, + }); + const liveCredentials = { + accessToken: "codex-rotation-first", + connectionId: first.id, + providerSpecificData: {}, + }; + + const { result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.3-codex-spark", + endpoint: "/v1/responses", + connectionId: first.id, + credentials: liveCredentials, + body: { + model: "gpt-5.3-codex-spark", + input: "rotate twice", + stream: false, + }, + responseFactory() { + return new Response( + JSON.stringify({ error: { message: "The usage limit has been reached" } }), + { status: 429, headers: { "Content-Type": "application/json", "Retry-After": "60" } } + ); + }, + }); + const firstPersisted = await providersDb.getProviderConnectionById(first.id); + const secondPersisted = await providersDb.getProviderConnectionById(second.id); + + assert.equal(result.success, false); + assert.equal(result.status, 429); + assert.equal( + typeof firstPersisted.providerSpecificData.codexScopeRateLimitedUntil.spark, + "string" + ); + assert.equal( + typeof secondPersisted.providerSpecificData.codexScopeRateLimitedUntil.spark, + "string" + ); + assert.equal(liveCredentials.connectionId, second.id); +}); + +test("chatCore retains exact quota resets from intermediate rotated Codex 429s", async () => { + const first = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-exact-reset-first@example.com", + accessToken: "codex-exact-reset-first", + isActive: true, + providerSpecificData: {}, + }); + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-exact-reset-second@example.com", + accessToken: "codex-exact-reset-second", + isActive: true, + providerSpecificData: {}, + }); + const exactReset = new Date(Date.now() + 300_000).toISOString(); + const weeklyReset = new Date(Date.now() + 3_600_000).toISOString(); + const { result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.3-codex-spark", + endpoint: "/v1/responses", + connectionId: first.id, + isCombo: true, + credentials: { + accessToken: "codex-exact-reset-first", + connectionId: first.id, + providerSpecificData: {}, + }, + body: { + model: "gpt-5.3-codex-spark", + input: "persist exact reset before rotation", + stream: false, + }, + responseFactory(_captured: unknown, calls: unknown[]) { + if (calls.length < 4) { + return new Response(JSON.stringify({ error: { message: "Codex quota exceeded" } }), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "60", + "x-codex-5h-usage": "100", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": exactReset, + "x-codex-7d-usage": "10", + "x-codex-7d-limit": "100", + "x-codex-7d-reset-at": weeklyReset, + }, + }); + } + return buildResponsesResponse("rotated account succeeded"); + }, + }); + const persisted = await providersDb.getProviderConnectionById(first.id); + + assert.ok(persisted); + assert.equal(result.success, true); + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.spark, exactReset); + assert.equal(persisted.providerSpecificData.codexExhaustedWindowByScope.spark, "5h"); + assert.equal(persisted.providerSpecificData.codexQuotaStateByScope.spark.resetAt5h, exactReset); +}); + +test("chatCore keeps a Codex Spark 429 scoped so Sol remains selectable", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "codex-scope@example.com", + accessToken: "codex-scope-token", + isActive: true, + providerSpecificData: {}, + }); + + const { result } = await invokeChatCore({ + provider: "codex", + model: "gpt-5.3-codex-spark", + endpoint: "/v1/responses", + connectionId: connection.id, + credentials: { + accessToken: "codex-scope-token", + connectionId: connection.id, + providerSpecificData: {}, + }, + body: { + model: "gpt-5.3-codex-spark", + input: "scope this cooldown", + stream: false, + }, + responseFactory() { + return new Response( + JSON.stringify({ error: { message: "The usage limit has been reached" } }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "60", + }, + } + ); + }, + }); + + const updated = await providersDb.getProviderConnectionById(connection.id); + const sparkSelected = await auth.getProviderCredentials( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + const solSelected = await auth.getProviderCredentials("codex", null, null, "gpt-5.6-sol"); + + assert.equal(result.success, false); + assert.equal(result.status, 429); + assert.equal(updated.rateLimitedUntil, undefined); + assert.equal(typeof updated.providerSpecificData.codexScopeRateLimitedUntil.spark, "string"); + assert.equal(sparkSelected.allRateLimited, true); + assert.equal(solSelected.connectionId, connection.id); +}); diff --git a/tests/unit/chatcore-codex-quota.test.ts b/tests/unit/chatcore-codex-quota.test.ts deleted file mode 100644 index 04582356b4..0000000000 --- a/tests/unit/chatcore-codex-quota.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// tests/unit/chatcore-codex-quota.test.ts -// Characterization of buildCodexQuotaPersistence — the pure core of handleChatCore's -// persistCodexQuotaState, extracted during the chatCore god-file decomposition (#3501). Locks the -// shape of the persisted providerSpecificData: the codexQuotaState snapshot, the existing-data -// passthrough, and the 429 dual-window exhaustion fields (codexScopeRateLimitedUntil / -// codexExhaustedWindow) plus the debug-log message. The handler keeps the DB write, the -// preflight-cache invalidation, and the log emission; this function only builds the data. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { buildCodexQuotaPersistence } from "../../open-sse/handlers/chatCore/codexQuota.ts"; -import { getCodexModelScope } from "../../open-sse/executors/codex.ts"; - -const MODEL = "gpt-5-codex"; -const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -function quotaHeaders(over: Record = {}) { - return { - "x-codex-5h-usage": "50", - "x-codex-5h-limit": "100", - "x-codex-5h-reset-at": "2999-01-01T00:00:00.000Z", - "x-codex-7d-usage": "10", - "x-codex-7d-limit": "100", - "x-codex-7d-reset-at": "2999-01-08T00:00:00.000Z", - ...over, - }; -} - -test("returns null when the response carries no codex quota headers", () => { - assert.equal( - buildCodexQuotaPersistence({ headers: {}, existingProviderData: {}, modelForScope: MODEL, status: 200 }), - null - ); - assert.equal( - buildCodexQuotaPersistence({ headers: { "content-type": "application/json" }, existingProviderData: {}, modelForScope: MODEL, status: 200 }), - null - ); -}); - -test("builds codexQuotaState (parsed numbers + scope + updatedAt) and preserves existing provider data", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders(), - existingProviderData: { keepMe: "yes", apiKeyHealth: { primary: {} } }, - modelForScope: MODEL, - status: 200, - }); - assert.ok(built); - const qs = built.nextProviderData.codexQuotaState as Record; - assert.equal(qs.usage5h, 50); - assert.equal(qs.limit5h, 100); - assert.equal(qs.usage7d, 10); - assert.equal(qs.limit7d, 100); - assert.equal(qs.scope, getCodexModelScope(MODEL)); - assert.match(String(qs.updatedAt), ISO); - // existing keys passed through, not dropped - assert.equal(built.nextProviderData.keepMe, "yes"); - assert.deepEqual(built.nextProviderData.apiKeyHealth, { primary: {} }); - // non-429 → no exhaustion fields, no log - assert.equal(built.exhaustionLog, null); - assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined); - assert.equal(built.nextProviderData.codexExhaustedWindow, undefined); -}); - -test("429 with a near-exhausted 5h window records the per-scope cooldown + window + log", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders({ "x-codex-5h-usage": "100" }), // ratio 1.0 >= 0.95, reset far in the future - existingProviderData: {}, - modelForScope: MODEL, - status: 429, - }); - assert.ok(built); - assert.equal(built.nextProviderData.codexExhaustedWindow, "5h"); - const scope = getCodexModelScope(MODEL); - const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record; - assert.ok(scopeMap[scope]?.startsWith("2999-01-01T00:00:00")); - assert.match( - String(built.exhaustionLog), - /^Quota exhaustion on 5h window, cooldown until 2999-01-01T00:00:00/ - ); -}); - -test("429 merges into an existing codexScopeRateLimitedUntil map without dropping other scopes", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders({ "x-codex-5h-usage": "100" }), - existingProviderData: { codexScopeRateLimitedUntil: { "other-scope": "2999-12-31T00:00:00.000Z" } }, - modelForScope: MODEL, - status: 429, - }); - assert.ok(built); - const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record; - assert.equal(scopeMap["other-scope"], "2999-12-31T00:00:00.000Z"); - assert.ok(scopeMap[getCodexModelScope(MODEL)]); -}); - -test("429 below the exhaustion threshold builds the snapshot but no cooldown / no log", () => { - const built = buildCodexQuotaPersistence({ - headers: quotaHeaders({ "x-codex-5h-usage": "1", "x-codex-7d-usage": "1" }), // ratios well under 0.95 - existingProviderData: {}, - modelForScope: MODEL, - status: 429, - }); - assert.ok(built); - assert.ok(built.nextProviderData.codexQuotaState); - assert.equal(built.exhaustionLog, null); - assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined); - assert.equal(built.nextProviderData.codexExhaustedWindow, undefined); -}); diff --git a/tests/unit/codex-account-cooldown-write.test.ts b/tests/unit/codex-account-cooldown-write.test.ts new file mode 100644 index 0000000000..c24347afb7 --- /dev/null +++ b/tests/unit/codex-account-cooldown-write.test.ts @@ -0,0 +1,318 @@ +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-codex-cooldown-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-cooldown-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const codexAccount = await import("../../open-sse/services/codexAccount/index.ts"); +const codexFailover = await import("../../open-sse/handlers/chatCore/codexFailover.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +interface SeededConnection { + id: string; + testStatus?: unknown; + rateLimitedUntil?: unknown; + lastError?: unknown; + errorCode?: unknown; + backoffLevel?: unknown; + providerSpecificData: Record; +} + +interface PersistedConnection extends SeededConnection { + providerSpecificData: { + codexScopeRateLimitedUntil: Record; + codexScopeRateLimitSource?: unknown; + codexQuotaStateByScope?: unknown; + codexQuotaState?: unknown; + codexExhaustedWindowByScope?: unknown; + unrelated?: unknown; + }; +} + +async function seedCodexConnection(): Promise { + return providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-cooldown-writer", + email: "codex-cooldown@example.com", + apiKey: null, + accessToken: "codex-cooldown-access", + refreshToken: "codex-cooldown-refresh", + providerSpecificData: { + unrelated: { retained: true }, + }, + }) as unknown as Promise; +} + +async function readConnection(id: string): Promise { + const connection = await providersDb.getProviderConnectionById(id); + assert.ok(connection); + return connection as unknown as PersistedConnection; +} + +test.beforeEach(resetStorage); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("persisting Codex and Spark child cooldowns retains sibling and unrelated state", async () => { + const connection = await seedCodexConnection(); + const codexUntil = new Date(Date.now() + 60_000).toISOString(); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + const parentBefore = await readConnection(connection.id); + + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: codexUntil, + }); + const result = await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + }); + const persisted = await readConnection(connection.id); + + assert.deepEqual(result.providerSpecificData.codexScopeRateLimitedUntil, { + codex: codexUntil, + spark: sparkUntil, + }); + assert.deepEqual(persisted.providerSpecificData.codexScopeRateLimitedUntil, { + codex: codexUntil, + spark: sparkUntil, + }); + assert.deepEqual(persisted.providerSpecificData.unrelated, { retained: true }); + assert.equal(persisted.testStatus, parentBefore.testStatus); + assert.equal(persisted.rateLimitedUntil, parentBefore.rateLimitedUntil); + assert.equal(persisted.errorCode, parentBefore.errorCode); + assert.equal(persisted.backoffLevel, parentBefore.backoffLevel); +}); + +test("chatCore failover mirrors persisted child state into the failed credential snapshot", async () => { + const connection = await seedCodexConnection(); + const parentBefore = await readConnection(connection.id); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + const credentials = { + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData, + }; + + await codexFailover.markCodexScopeRateLimited({ + failedConnectionId: connection.id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + credentials, + }); + const persisted = await readConnection(connection.id); + + assert.equal(persisted.testStatus, parentBefore.testStatus); + assert.equal(persisted.rateLimitedUntil, parentBefore.rateLimitedUntil); + assert.equal(persisted.lastError, parentBefore.lastError); + assert.equal(persisted.errorCode, parentBefore.errorCode); + assert.equal(persisted.backoffLevel, parentBefore.backoffLevel); + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.spark, sparkUntil); + assert.deepEqual(credentials.providerSpecificData, persisted.providerSpecificData); +}); + +function quotaHeaders(resetAt5h: string, resetAt7d: string, weeklyUsage = "10") { + return { + "x-codex-5h-usage": "95", + "x-codex-5h-limit": "100", + "x-codex-5h-reset-at": resetAt5h, + "x-codex-7d-usage": weeklyUsage, + "x-codex-7d-limit": "100", + "x-codex-7d-reset-at": resetAt7d, + }; +} + +test("Codex and Spark quota responses retain independent scoped snapshots across restart", async () => { + const connection = await seedCodexConnection(); + const codexReset5h = new Date(Date.now() + 60_000).toISOString(); + const codexReset7d = new Date(Date.now() + 600_000).toISOString(); + const sparkReset5h = new Date(Date.now() + 120_000).toISOString(); + const sparkReset7d = new Date(Date.now() + 1_200_000).toISOString(); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(codexReset5h, codexReset7d), + status: 200, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + headers: quotaHeaders(sparkReset5h, sparkReset7d), + status: 200, + }); + + core.resetDbInstance(); + const persisted = await readConnection(connection.id); + const byScope = persisted.providerSpecificData.codexQuotaStateByScope as Record< + string, + Record + >; + + assert.equal(byScope.codex.resetAt5h, codexReset5h); + assert.equal(byScope.spark.resetAt5h, sparkReset5h); + assert.equal( + (persisted.providerSpecificData.codexQuotaState as Record).scope, + "spark" + ); + assert.deepEqual(persisted.providerSpecificData.unrelated, { retained: true }); +}); + +test("concurrent Codex and Spark quota responses retain both scoped snapshots", async () => { + const connection = await seedCodexConnection(); + const codexReset5h = new Date(Date.now() + 60_000).toISOString(); + const sparkReset5h = new Date(Date.now() + 120_000).toISOString(); + const reset7d = new Date(Date.now() + 600_000).toISOString(); + + await Promise.all([ + codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(codexReset5h, reset7d), + status: 200, + }), + codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + headers: quotaHeaders(sparkReset5h, reset7d), + status: 200, + }), + ]); + const persisted = await readConnection(connection.id); + const byScope = persisted.providerSpecificData.codexQuotaStateByScope as Record< + string, + Record + >; + + assert.equal(byScope.codex.resetAt5h, codexReset5h); + assert.equal(byScope.spark.resetAt5h, sparkReset5h); +}); + +test("header-derived exhausted reset survives fallback cooldown persistence", async () => { + const connection = await seedCodexConnection(); + const exactReset5h = new Date(Date.now() + 30_000).toISOString(); + const reset7d = new Date(Date.now() + 600_000).toISOString(); + const fallbackUntil = new Date(Date.now() + 60_000).toISOString(); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(exactReset5h, reset7d), + status: 429, + }); + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: fallbackUntil, + }); + const persisted = await readConnection(connection.id); + + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.codex, exactReset5h); + assert.equal( + (persisted.providerSpecificData.codexExhaustedWindowByScope as Record).codex, + "5h" + ); + assert.equal( + (persisted.providerSpecificData.codexScopeRateLimitSource as Record).codex, + "quota_reset" + ); +}); + +test("a successful quota observation clears earlier exhaustion for only that child", async () => { + const connection = await seedCodexConnection(); + const futureReset5h = new Date(Date.now() + 60_000).toISOString(); + const futureReset7d = new Date(Date.now() + 600_000).toISOString(); + + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(futureReset5h, futureReset7d), + status: 429, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + headers: quotaHeaders(futureReset5h, futureReset7d), + status: 429, + }); + await codexAccount.persistCodexChildQuotaResponse({ + connectionId: connection.id, + model: "gpt-5.5", + headers: quotaHeaders(futureReset5h, futureReset7d), + status: 200, + }); + + const persisted = await readConnection(connection.id); + const exhaustedByScope = persisted.providerSpecificData.codexExhaustedWindowByScope as Record< + string, + unknown + >; + + assert.equal(exhaustedByScope.codex, undefined); + assert.equal(exhaustedByScope.spark, "5h"); +}); + +test("a newer fallback supersedes an expired authoritative reset", async () => { + const connection = await seedCodexConnection(); + const expiredReset = new Date(Date.now() - 60_000).toISOString(); + const fallbackUntil = new Date(Date.now() + 60_000).toISOString(); + await providersDb.updateCodexScopedQuotaState(connection.id, "codex", { + rateLimitedUntil: expiredReset, + rateLimitSource: "quota_reset", + }); + + await codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: fallbackUntil, + }); + const persisted = await readConnection(connection.id); + + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.codex, fallbackUntil); + assert.equal( + (persisted.providerSpecificData.codexScopeRateLimitSource as Record).codex, + "fallback" + ); +}); + +test("concurrent Codex and Spark child cooldown writes retain both scopes", async () => { + const connection = await seedCodexConnection(); + const codexUntil = new Date(Date.now() + 60_000).toISOString(); + const sparkUntil = new Date(Date.now() + 120_000).toISOString(); + + await Promise.all([ + codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.5", + rateLimitedUntil: codexUntil, + }), + codexAccount.persistCodexChildCooldown({ + connectionId: connection.id, + model: "gpt-5.3-codex-spark", + rateLimitedUntil: sparkUntil, + }), + ]); + const persisted = await readConnection(connection.id); + + assert.deepEqual(persisted.providerSpecificData.codexScopeRateLimitedUntil, { + codex: codexUntil, + spark: sparkUntil, + }); + assert.deepEqual(persisted.providerSpecificData.unrelated, { retained: true }); +}); diff --git a/tests/unit/codex-account-pool.test.ts b/tests/unit/codex-account-pool.test.ts new file mode 100644 index 0000000000..c11cc8db2e --- /dev/null +++ b/tests/unit/codex-account-pool.test.ts @@ -0,0 +1,279 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const codexAccount = await import("../../open-sse/services/codexAccount/index.ts"); + +const SPARK_MODEL = "gpt-5.3-codex-spark"; +const SOL_MODEL = "gpt-5.5"; + +function futureTimestamp(offsetMs = 60_000): string { + return new Date(Date.now() + offsetMs).toISOString(); +} + +test("one persisted parent creates same-interface parent, Codex, and Spark accounts", () => { + const connection = { + id: "codex-parent-1", + provider: "codex", + providerSpecificData: { + accessToken: "must remain on the parent connection", + }, + }; + + const pool = codexAccount.createCodexAccountPool(connection); + + assert.equal(pool.accounts.length, 3); + assert.equal(pool.parent.scope, null); + assert.equal(pool.parent.kind, "parent"); + assert.deepEqual( + pool.children.map((account) => account.scope), + ["codex", "spark"] + ); + for (const account of pool.accounts) { + assert.strictEqual(account.connection, connection); + assert.equal(account.connectionId, connection.id); + assert.deepEqual(account.key.parentConnectionId, connection.id); + assert.equal("accessToken" in account, false); + assert.equal("id" in account, false); + } + assert.deepEqual( + pool.children.map((account) => account.key.scope), + ["codex", "spark"] + ); +}); + +test("model resolution selects a scoped child and blank models resolve to the parent", () => { + const pool = codexAccount.createCodexAccountPool({ + id: "codex-parent-2", + provider: "codex", + providerSpecificData: {}, + }); + + assert.equal(codexAccount.resolveCodexAccount(pool, SPARK_MODEL).scope, "spark"); + assert.equal(codexAccount.resolveCodexAccount(pool, SOL_MODEL).scope, "codex"); + assert.equal(codexAccount.resolveCodexAccount(pool, null).kind, "parent"); + assert.equal(codexAccount.resolveCodexAccount(pool, undefined).kind, "parent"); + assert.equal(codexAccount.resolveCodexAccount(pool, " ").kind, "parent"); +}); + +test("quota hydration reads scoped facts without leaking legacy singleton state", () => { + const sparkResetAt = futureTimestamp(90_000); + const pool = codexAccount.createCodexAccountPool({ + id: "connection-quota-hydration", + provider: "codex", + providerSpecificData: { + codexQuotaStateByScope: { + codex: { usage5h: 25, limit5h: 100, resetAt5h: futureTimestamp(30_000) }, + }, + codexExhaustedWindowByScope: { codex: "5h" }, + codexQuotaState: { + scope: "spark", + usage5h: 100, + limit5h: 100, + resetAt5h: sparkResetAt, + }, + codexExhaustedWindow: "7d", + }, + }); + + const codex = codexAccount.getCodexChildQuotaHydration(pool.children[0]); + const spark = codexAccount.getCodexChildQuotaHydration(pool.children[1]); + + assert.equal(codex.quotaState?.usage5h, 25); + assert.equal(codex.exhaustedWindow, "5h"); + assert.equal(spark.quotaState?.usage5h, 100); + assert.equal(spark.exhaustedWindow, "7d"); +}); + +test("parent inspection is an aggregate of child cooldowns", () => { + const pool = codexAccount.createCodexAccountPool({ + id: "codex-parent-3", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: futureTimestamp() }, + }, + }); + + const parentState = codexAccount.inspectCodexAccount(pool, pool.parent); + assert.equal(parentState.kind, "parent"); + if (parentState.kind === "parent") { + assert.equal(parentState.status, "partially_limited"); + assert.deepEqual(parentState.limitedScopes, ["spark"]); + } + + const sparkState = codexAccount.inspectCodexAccount(pool, pool.children[1]); + assert.equal(sparkState.kind, "child"); + if (sparkState.kind === "child") { + assert.equal(sparkState.scope, "spark"); + assert.equal(sparkState.unavailable, true); + } +}); + +test("earliest scoped cooldown identifies the child and parent connection", () => { + const earlier = futureTimestamp(30_000); + const later = futureTimestamp(60_000); + const pools = [ + codexAccount.createCodexAccountPool({ + id: "codex-parent-4", + provider: "codex", + providerSpecificData: { codexScopeRateLimitedUntil: { spark: later } }, + }), + codexAccount.createCodexAccountPool({ + id: "codex-parent-5", + provider: "codex", + providerSpecificData: { codexScopeRateLimitedUntil: { spark: earlier } }, + }), + ]; + + const earliest = codexAccount.getEarliestCodexChildCooldown(pools, SPARK_MODEL); + assert.equal(earliest?.account.connectionId, "codex-parent-5"); + assert.equal(earliest?.account.scope, "spark"); + assert.equal(earliest?.until, earlier); + assert.equal(codexAccount.getEarliestCodexChildCooldown(pools, " "), null); +}); + +test("account inspection rejects an account from a different parent pool", () => { + const first = codexAccount.createCodexAccountPool({ + id: "codex-parent-5a", + provider: "codex", + providerSpecificData: {}, + }); + const second = codexAccount.createCodexAccountPool({ + id: "codex-parent-5b", + provider: "codex", + providerSpecificData: {}, + }); + + assert.throws( + () => codexAccount.inspectCodexAccount(first, second.children[0]), + /does not belong to this pool/ + ); +}); + +test("expired and invalid legacy timestamps are not active cooldowns", () => { + const pool = codexAccount.createCodexAccountPool({ + id: "codex-parent-6", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { + codex: new Date(Date.now() - 60_000).toISOString(), + spark: "not-a-timestamp", + }, + }, + }); + + const codexState = codexAccount.inspectCodexAccount(pool, pool.children[0]); + const sparkState = codexAccount.inspectCodexAccount(pool, pool.children[1]); + assert.equal(codexState.kind, "child"); + assert.equal(sparkState.kind, "child"); + if (codexState.kind === "child") assert.equal(codexState.unavailable, false); + if (sparkState.kind === "child") assert.equal(sparkState.unavailable, false); + assert.equal(codexAccount.inspectCodexAccount(pool, pool.parent).status, "available"); +}); + +test("projects quota exhaustion and active cooldown as distinct child facts", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const sparkCooldown = "2026-01-01T01:00:00.000Z"; + const projected = codexAccount.projectCodexAccountPool( + { + id: "codex-projection", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: sparkCooldown }, + codexQuotaStateByScope: { + codex: { + usage5h: 100, + limit5h: 100, + resetAt5h: "2026-01-01T02:00:00.000Z", + observedAt: "2025-12-31T23:59:00.000Z", + }, + spark: { usage7d: 80, limit7d: 100, resetAt7d: "2026-01-02T00:00:00.000Z" }, + }, + codexExhaustedWindowByScope: { codex: "5h" }, + }, + }, + now + ); + + assert.equal(projected.parentConnectionId, "codex-projection"); + assert.equal(projected.aggregate.status, "fully_limited"); + assert.equal(projected.aggregate.limitedChildCount, 2); + assert.deepEqual( + projected.children.map((child) => child.key), + [ + { parentConnectionId: "codex-projection", scope: "codex" }, + { parentConnectionId: "codex-projection", scope: "spark" }, + ] + ); + assert.equal("connectionId" in projected.children[0], false); + assert.deepEqual( + projected.children.map((child) => ({ + unavailable: child.unavailable, + cooldown: child.cooldown, + exhaustedWindow: child.quota.exhaustedWindow, + })), + [ + { + unavailable: true, + cooldown: { active: false, rateLimitedUntil: null }, + exhaustedWindow: "5h", + }, + { + unavailable: true, + cooldown: { active: true, rateLimitedUntil: sparkCooldown }, + exhaustedWindow: null, + }, + ] + ); + assert.equal(projected.children[0].quota.windows["5h"]?.usedPercentage, 100); +}); + +test("projects neither exhaustion nor an expired cooldown as unavailable", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const projected = codexAccount.projectCodexAccountPool( + { + id: "codex-available-projection", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: "2025-12-31T23:59:00.000Z" }, + }, + }, + now + ); + + assert.equal(projected.aggregate.status, "available"); + assert.equal(projected.aggregate.limitedChildCount, 0); + assert.equal(projected.children[1].unavailable, false); + assert.deepEqual(projected.children[1].cooldown, { + active: false, + rateLimitedUntil: null, + }); +}); + +test("projects an exhausted window as available after its reset passes", () => { + const now = Date.parse("2026-01-01T00:00:00.000Z"); + const projected = codexAccount.projectCodexAccountPool( + { + id: "codex-expired-quota-projection", + provider: "codex", + providerSpecificData: { + codexScopeRateLimitedUntil: { codex: "2025-12-31T23:59:59.000Z" }, + codexQuotaStateByScope: { + codex: { + usage5h: 100, + limit5h: 100, + resetAt5h: "2025-12-31T23:59:59.000Z", + observedAt: "2025-12-31T18:59:00.000Z", + }, + }, + codexExhaustedWindowByScope: { codex: "5h" }, + }, + }, + now + ); + + assert.equal(projected.aggregate.status, "available"); + assert.equal(projected.aggregate.limitedChildCount, 0); + assert.equal(projected.children[0].unavailable, false); + assert.equal(projected.children[0].quota.exhaustedWindow, null); + assert.equal(projected.children[0].quota.windows["5h"]?.resetAt, "2025-12-31T23:59:59.000Z"); +}); diff --git a/tests/unit/codex-executor-split.test.ts b/tests/unit/codex-executor-split.test.ts index eac6b2eea0..5e542da13f 100644 --- a/tests/unit/codex-executor-split.test.ts +++ b/tests/unit/codex-executor-split.test.ts @@ -6,7 +6,7 @@ import { dirname, join } from "node:path"; // Split-guard for the codex executor quota extraction. // The pure quota-snapshot parsing + reset/cooldown scheduling lives in codex/quota.ts. -// Host re-exports the 4 public symbols (chatCore/codexQuota.ts + tests import them). +// Host re-exports the 4 public symbols for the Codex account module and tests. const HERE = dirname(fileURLToPath(import.meta.url)); const EXE = join(HERE, "../../open-sse/executors"); const HOST = join(EXE, "codex.ts"); diff --git a/tests/unit/codex-quota-selection-hydration.test.ts b/tests/unit/codex-quota-selection-hydration.test.ts index 2a63ce2aea..124c863fcd 100644 --- a/tests/unit/codex-quota-selection-hydration.test.ts +++ b/tests/unit/codex-quota-selection-hydration.test.ts @@ -82,3 +82,106 @@ test("Codex selection ignores hydrated Spark-only exhaustion for normal Codex mo assert.equal(normalSelected.connectionId, connectionId); assert.equal(sparkSelected.allRateLimited, true); }); + +test("Codex selection hydrates authoritative scoped quota metadata after restart", async () => { + const sparkResetAt = futureIso(180_000); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-authoritative-scoped-restart", + apiKey: null, + accessToken: "codex-authoritative-scoped-access", + refreshToken: "codex-authoritative-scoped-refresh", + isActive: true, + testStatus: "active", + providerSpecificData: { + codexQuotaStateByScope: { + codex: { + usage5h: 20, + limit5h: 100, + resetAt5h: futureIso(60_000), + usage7d: 30, + limit7d: 100, + resetAt7d: futureIso(120_000), + observedAt: new Date().toISOString(), + }, + spark: { + usage5h: 80, + limit5h: 100, + resetAt5h: sparkResetAt, + usage7d: 20, + limit7d: 100, + resetAt7d: futureIso(240_000), + observedAt: new Date().toISOString(), + }, + }, + codexExhaustedWindowByScope: { spark: "5h" }, + codexScopeRateLimitSource: { spark: "quota_reset" }, + }, + }); + const connectionId = (connection as { id: string }).id; + + quotaCache.__clearForTests(); + + const normalSelected = await auth.getProviderCredentials("codex", null, null, "codex/gpt-5.5"); + const sparkSelected = await auth.getProviderCredentials( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + + assert.equal(normalSelected.connectionId, connectionId); + assert.equal(sparkSelected.allRateLimited, true); + assert.equal(sparkSelected.retryAfter, sparkResetAt); + assert.equal( + quotaCache.getQuotaWindowStatus(connectionId, "session", 100)?.reachedThreshold, + false + ); + assert.equal( + quotaCache.getQuotaWindowStatus(connectionId, "gpt_5_3_codex_spark_session", 100) + ?.reachedThreshold, + true + ); +}); + +test("legacy Codex quota metadata hydrates only its embedded child scope", async () => { + const sparkResetAt = futureIso(180_000); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-legacy-scoped-restart", + apiKey: null, + accessToken: "codex-legacy-scoped-access", + refreshToken: "codex-legacy-scoped-refresh", + isActive: true, + testStatus: "active", + providerSpecificData: { + codexQuotaState: { + scope: "spark", + usage5h: 100, + limit5h: 100, + resetAt5h: sparkResetAt, + usage7d: 10, + limit7d: 100, + resetAt7d: futureIso(240_000), + observedAt: new Date().toISOString(), + }, + codexExhaustedWindow: "5h", + }, + }); + const connectionId = (connection as { id: string }).id; + + quotaCache.__clearForTests(); + + const normalSelected = await auth.getProviderCredentials("codex", null, null, "codex/gpt-5.5"); + const sparkSelected = await auth.getProviderCredentials( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + + assert.equal(normalSelected.connectionId, connectionId); + assert.equal(sparkSelected.allRateLimited, true); +}); diff --git a/tests/unit/observability-payloads.test.ts b/tests/unit/observability-payloads.test.ts index a56d1554c4..297d92d6ec 100644 --- a/tests/unit/observability-payloads.test.ts +++ b/tests/unit/observability-payloads.test.ts @@ -83,6 +83,69 @@ test("buildTelemetryPayload exposes totalRequests alias plus quota/session signa assert.equal(payload.quotaMonitor.exhausted, 1); }); +test("buildHealthPayload reports Codex persisted parents through aggregate child state", () => { + const now = Date.now(); + const partialUntil = new Date(now + 60_000).toISOString(); + const fullUntil = new Date(now + 120_000).toISOString(); + const payload = buildHealthPayload({ + appVersion: "1.2.3", + settings: { setupComplete: true }, + connections: [ + { + id: "codex-partial", + provider: "codex", + isActive: true, + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: partialUntil }, + codexQuotaStateByScope: { spark: { usage5h: 100, limit5h: 100 } }, + }, + }, + { + id: "codex-full", + provider: "codex", + isActive: true, + providerSpecificData: { + codexScopeRateLimitedUntil: { codex: fullUntil, spark: fullUntil }, + codexQuotaStateByScope: { + codex: { usage5h: 100, limit5h: 100 }, + spark: { usage5h: 100, limit5h: 100 }, + }, + }, + }, + { id: "openai", provider: "openai", isActive: true }, + ], + circuitBreakers: [], + rateLimitStatus: {}, + learnedLimits: {}, + lockouts: {}, + localProviders: {}, + inflightRequests: 0, + quotaMonitorSummary: { + active: 0, + alerting: 0, + exhausted: 0, + errors: 0, + statusCounts: { starting: 0, idle: 0, healthy: 0, warning: 0, exhausted: 0, error: 0 }, + byProvider: {}, + }, + quotaMonitorMonitors: [], + activeSessions: [], + }); + + assert.equal(payload.codexAccountPools.total, 2); + assert.equal(payload.codexAccountPools.available, 0); + assert.equal(payload.codexAccountPools.partiallyLimited, 1); + assert.equal(payload.codexAccountPools.fullyLimited, 1); + assert.equal(payload.codexAccountPools.quotaObserved, 2); + assert.ok(payload.codexAccountPools.soonestRetryAfterMs > 0); + assert.ok(payload.codexAccountPools.soonestRetryAfterMs <= 60_000); + assert.equal(payload.connectionHealth.codex, undefined); + assert.equal(payload.providerSummary.configuredCount, 2); + assert.equal(payload.quotaMonitor.active, 0); + assert.ok(payload.sessions); + assert.deepEqual(payload.rateLimitStatus, {}); +}); + test("buildHealthPayload keeps legacy aliases and adds session/quota observability blocks", () => { const payload = buildHealthPayload({ appVersion: "1.2.3", diff --git a/tests/unit/providers-route-codex-account-pool.test.ts b/tests/unit/providers-route-codex-account-pool.test.ts new file mode 100644 index 0000000000..1ee23a74fd --- /dev/null +++ b/tests/unit/providers-route-codex-account-pool.test.ts @@ -0,0 +1,115 @@ +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"; + +import { makeManagementSessionRequest } from "../helpers/managementSession.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-provider-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.ALLOW_API_KEY_REVEAL = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const providersRoute = await import("../../src/app/api/providers/route.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("GET keeps one parent row and projects raw Codex state without exposing credentials", async () => { + const cooldown = new Date(Date.now() + 60_000).toISOString(); + const codex = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "Codex parent", + apiKey: "codex-api-secret", + accessToken: "codex-access-secret", + refreshToken: "codex-refresh-secret", + idToken: "codex-id-secret", + providerSpecificData: { + consoleApiKey: "nested-secret", + accessToken: "nested-access-secret", + codexScopeRateLimitedUntil: { spark: cooldown }, + codexQuotaStateByScope: { + spark: { + usage5h: 100, + limit5h: 100, + resetAt5h: cooldown, + observedAt: "2026-01-01T00:00:00.000Z", + }, + }, + codexExhaustedWindowByScope: { spark: "5h" }, + }, + }); + const openai = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "OpenAI parent", + apiKey: "openai-api-secret", + }); + + const response = await providersRoute.GET( + await makeManagementSessionRequest("http://localhost/api/providers") + ); + const body = (await response.json()) as { + connections: Array>; + total: number; + }; + + assert.equal(response.status, 200); + assert.equal(body.total, 2); + assert.equal(body.connections.length, 2); + assert.deepEqual( + new Set(body.connections.map((connection) => connection.id)), + new Set([codex.id, openai.id]) + ); + + const codexRow = body.connections.find((connection) => connection.id === codex.id); + const openaiRow = body.connections.find((connection) => connection.id === openai.id); + assert.ok(codexRow); + assert.ok(openaiRow); + assert.equal("codexAccountPool" in openaiRow, false); + + const pool = codexRow.codexAccountPool as { + parentConnectionId: string; + aggregate: { status: string; limitedChildCount: number }; + children: Array>; + }; + assert.equal(pool.parentConnectionId, codex.id); + assert.equal(pool.children.length, 2); + assert.deepEqual( + pool.children.map((child) => child.key), + [ + { parentConnectionId: codex.id, scope: "codex" }, + { parentConnectionId: codex.id, scope: "spark" }, + ] + ); + const spark = pool.children[1] as { + unavailable: boolean; + cooldown: { active: boolean; rateLimitedUntil: string | null }; + quota: { exhaustedWindow: string | null }; + }; + assert.equal(spark.unavailable, true); + assert.equal(spark.cooldown.active, true); + assert.equal(spark.cooldown.rateLimitedUntil, cooldown); + assert.equal(spark.quota.exhaustedWindow, "5h"); + assert.equal("connectionId" in pool.children[0], false); + + const serialized = JSON.stringify(body); + for (const secret of [ + "codex-api-secret", + "codex-access-secret", + "codex-refresh-secret", + "codex-id-secret", + "nested-secret", + "nested-access-secret", + "openai-api-secret", + ]) { + assert.equal(serialized.includes(secret), false, `response leaked ${secret}`); + } + const safeProviderData = codexRow.providerSpecificData as Record; + assert.equal("consoleApiKey" in safeProviderData, false); +}); diff --git a/tests/unit/request-defaults-store-session.test.ts b/tests/unit/request-defaults-store-session.test.ts index 96ea50f202..ecbc65683a 100644 --- a/tests/unit/request-defaults-store-session.test.ts +++ b/tests/unit/request-defaults-store-session.test.ts @@ -135,9 +135,13 @@ test("normalizeProviderSpecificData trims OpenRouter preset and clears empty val assert.equal(ignored?.tag, "primary"); }); -test("sanitizeProviderSpecificDataForResponse removes quota scraping cookies", () => { +test("sanitizeProviderSpecificDataForResponse removes credentials and quota scraping cookies", () => { const sanitized = sanitizeProviderSpecificDataForResponse({ opencodeGoWorkspaceId: "workspace-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + apiKey: "api-key", opencodeGoAuthCookie: "auth-cookie", ollamaCloudUsageCookie: "ollama-cookie", usageCookie: "fallback-cookie", diff --git a/tests/unit/resilience-explain-codex-account.test.ts b/tests/unit/resilience-explain-codex-account.test.ts new file mode 100644 index 0000000000..eb9b59d806 --- /dev/null +++ b/tests/unit/resilience-explain-codex-account.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { inspectTargetResilience } from "../../src/lib/usage/resilienceExplain.ts"; + +const NOW = Date.now(); +const SPARK_UNTIL = new Date(NOW + 60_000).toISOString(); +const CONNECTION = { + id: "codex-parent", + provider: "codex", + testStatus: "active", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: SPARK_UNTIL }, + }, +}; + +async function inspect(model: string | null) { + return inspectTargetResilience({ + provider: "codex", + model, + now: NOW, + providerConnections: [CONNECTION], + }); +} + +test("resilience explanation resolves Codex cooldown through the requested virtual child", async () => { + const spark = await inspect("gpt-5.3-codex-spark"); + const normal = await inspect("gpt-5.5"); + const parent = await inspect(null); + + assert.equal( + spark.skipReasons.some((reason) => reason.code === "codex_scope_cooldown"), + true + ); + assert.equal( + normal.skipReasons.some((reason) => reason.code === "codex_scope_cooldown"), + false + ); + assert.equal( + parent.skipReasons.some((reason) => reason.code === "codex_scope_cooldown"), + false + ); +}); diff --git a/tests/unit/sse-auth-codex-account-pool.test.ts b/tests/unit/sse-auth-codex-account-pool.test.ts new file mode 100644 index 0000000000..581efd1f33 --- /dev/null +++ b/tests/unit/sse-auth-codex-account-pool.test.ts @@ -0,0 +1,311 @@ +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-sse-auth-codex-pool-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET ||= "sse-auth-codex-pool-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function futureIso(ms = 60_000) { + return new Date(Date.now() + ms).toISOString(); +} + +async function seedCodexConnection(overrides: Record) { + return providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + apiKey: null, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + ...overrides, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Codex Spark preflight cooldown leaves normal models on the same parent selectable", async () => { + const resetAt = futureIso(120_000); + const connection = await seedCodexConnection({ + name: "codex-scoped-preflight", + email: "codex-preflight@example.com", + accessToken: "codex-preflight-access", + refreshToken: "codex-preflight-refresh", + providerSpecificData: { + quotaPreflightEnabled: true, + }, + }); + const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); + quotaPreflight.registerQuotaFetcher("codex", async (_connectionId, credentials) => { + const isSpark = String((credentials as { requestedModel?: unknown }).requestedModel).includes( + "spark" + ); + return { + used: isSpark ? 100 : 20, + total: 100, + percentUsed: isSpark ? 1 : 0.2, + resetAt: isSpark ? resetAt : null, + windows: { + session: { percentUsed: isSpark ? 1 : 0.2, resetAt: isSpark ? resetAt : null }, + }, + }; + }); + + const spark = await auth.getProviderCredentialsWithQuotaPreflight( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + const normal = await auth.getProviderCredentialsWithQuotaPreflight( + "codex", + null, + null, + "gpt-5.5" + ); + const persisted = await providersDb.getProviderConnectionById(connection.id); + + assert.equal(spark.allRateLimited, true); + assert.equal(normal.connectionId, connection.id); + assert.equal(persisted.rateLimitedUntil, undefined); + assert.equal(persisted.testStatus, "active"); + assert.equal(persisted.providerSpecificData.codexScopeRateLimitedUntil.spark, resetAt); +}); + +test("Codex preflight skips a blocked parent and selects a healthy sibling parent", async () => { + const resetAt = futureIso(120_000); + const blocked = await seedCodexConnection({ + name: "codex-preflight-blocked-parent", + email: "codex-preflight-blocked@example.com", + accessToken: "codex-preflight-blocked-access", + refreshToken: "codex-preflight-blocked-refresh", + priority: 1, + providerSpecificData: { quotaPreflightEnabled: true }, + }); + const healthy = await seedCodexConnection({ + name: "codex-preflight-healthy-parent", + email: "codex-preflight-healthy@example.com", + accessToken: "codex-preflight-healthy-access", + refreshToken: "codex-preflight-healthy-refresh", + priority: 2, + providerSpecificData: { quotaPreflightEnabled: true }, + }); + const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); + const preflightCalls: string[] = []; + quotaPreflight.registerQuotaFetcher("codex", async (connectionId) => { + preflightCalls.push(connectionId); + return { + used: connectionId === blocked.id ? 100 : 20, + total: 100, + percentUsed: connectionId === blocked.id ? 1 : 0.2, + resetAt: connectionId === blocked.id ? resetAt : null, + windows: { + session: { + percentUsed: connectionId === blocked.id ? 1 : 0.2, + resetAt: connectionId === blocked.id ? resetAt : null, + }, + }, + }; + }); + + const selected = await auth.getProviderCredentialsWithQuotaPreflight( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + const blockedAfter = await providersDb.getProviderConnectionById(blocked.id); + + assert.equal(selected.connectionId, healthy.id); + assert.deepEqual(preflightCalls, [blocked.id, healthy.id]); + assert.equal(blockedAfter.rateLimitedUntil, undefined); + assert.equal(blockedAfter.testStatus, "active"); + assert.equal(blockedAfter.providerSpecificData.codexScopeRateLimitedUntil.spark, resetAt); +}); + +test("Codex preflight returns allRateLimited only after checking every exhausted parent", async () => { + const resetAt = futureIso(120_000); + const first = await seedCodexConnection({ + name: "codex-preflight-exhausted-first", + email: "codex-preflight-exhausted-first@example.com", + accessToken: "codex-preflight-exhausted-first-access", + refreshToken: "codex-preflight-exhausted-first-refresh", + priority: 1, + providerSpecificData: { quotaPreflightEnabled: true }, + }); + const second = await seedCodexConnection({ + name: "codex-preflight-exhausted-second", + email: "codex-preflight-exhausted-second@example.com", + accessToken: "codex-preflight-exhausted-second-access", + refreshToken: "codex-preflight-exhausted-second-refresh", + priority: 2, + providerSpecificData: { quotaPreflightEnabled: true }, + }); + const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); + const preflightCalls: string[] = []; + quotaPreflight.registerQuotaFetcher("codex", async (connectionId) => { + preflightCalls.push(connectionId); + return { + used: 100, + total: 100, + percentUsed: 1, + resetAt, + windows: { session: { percentUsed: 1, resetAt } }, + }; + }); + + const selected = await auth.getProviderCredentialsWithQuotaPreflight( + "codex", + null, + null, + "gpt-5.3-codex-spark" + ); + const firstAfter = await providersDb.getProviderConnectionById(first.id); + const secondAfter = await providersDb.getProviderConnectionById(second.id); + + assert.equal(selected.allRateLimited, true); + assert.deepEqual(preflightCalls, [first.id, second.id]); + for (const connection of [firstAfter, secondAfter]) { + assert.equal(connection.rateLimitedUntil, undefined); + assert.equal(connection.testStatus, "active"); + assert.equal(connection.providerSpecificData.codexScopeRateLimitedUntil.spark, resetAt); + } +}); + +test("getProviderCredentials reports cooldown only from the forced Codex parent", async () => { + const earlierRetryAfter = futureIso(60_000); + const forcedRetryAfter = futureIso(120_000); + await seedCodexConnection({ + name: "codex-earlier-spark-cooldown", + email: "codex-earlier@example.com", + accessToken: "codex-earlier-access", + refreshToken: "codex-earlier-refresh", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: earlierRetryAfter }, + }, + }); + const forced = await seedCodexConnection({ + name: "codex-forced-spark-cooldown", + email: "codex-forced@example.com", + accessToken: "codex-forced-access", + refreshToken: "codex-forced-refresh", + providerSpecificData: { + codexScopeRateLimitedUntil: { spark: forcedRetryAfter }, + }, + }); + + const selected = await auth.getProviderCredentials("codex", null, null, "codex-spark-mini", { + forcedConnectionId: forced.id, + }); + + assert.equal(selected.allRateLimited, true); + assert.equal(selected.connectionsCount, 1); + assert.equal(selected.retryAfter, forcedRetryAfter); +}); + +test("Codex parent authentication failures block both virtual children without child rows", async () => { + const connection = await seedCodexConnection({ + name: "codex-parent-auth-failure", + email: "codex-parent-auth-failure@example.com", + accessToken: "codex-parent-auth-access", + refreshToken: "codex-parent-auth-refresh", + }); + + const unavailable = await auth.markAccountUnavailable( + connection.id, + 401, + "invalid authentication token", + "codex", + "gpt-5.3-codex-spark" + ); + const spark = await auth.getProviderCredentials("codex", null, null, "gpt-5.3-codex-spark"); + const normal = await auth.getProviderCredentials("codex", null, null, "gpt-5.5"); + const inventory = await providersDb.getProviderConnections({ provider: "codex" }); + + assert.equal(unavailable.shouldFallback, true); + assert.equal(spark, null); + assert.equal(normal, null); + assert.deepEqual( + inventory.map((item) => item.id), + [connection.id] + ); +}); + +test("markAccountUnavailable stores Codex scope-specific cooldowns without a global rate limit", async () => { + const connection = await seedCodexConnection({ + name: "codex-scope", + email: "codex@example.com", + accessToken: "codex-access", + refreshToken: "codex-refresh", + }); + const parentBefore = await providersDb.getProviderConnectionById(connection.id); + + const result = await auth.markAccountUnavailable( + connection.id, + 429, + "quota reached", + "codex", + "codex-spark-mini" + ); + const updated = await providersDb.getProviderConnectionById(connection.id); + const selected = await auth.getProviderCredentials("codex", null, null, "codex-spark-mini"); + const normalSelected = await auth.getProviderCredentials("codex", null, null, "gpt-5.3-codex"); + + assert.equal(result.shouldFallback, true); + assert.ok(result.cooldownMs > 0); + assert.equal(updated.testStatus, parentBefore.testStatus); + assert.equal(updated.rateLimitedUntil, parentBefore.rateLimitedUntil); + assert.equal(updated.lastError, parentBefore.lastError); + assert.equal(updated.errorCode, parentBefore.errorCode); + assert.equal(updated.backoffLevel, parentBefore.backoffLevel); + assert.ok(updated.providerSpecificData.codexScopeRateLimitedUntil.spark); + assert.equal(selected.allRateLimited, true); + assert.equal(normalSelected.connectionId, connection.id); +}); + +test("markAccountUnavailable keeps model-less Codex 429 state off the parent", async () => { + const connection = await seedCodexConnection({ + name: "codex-model-less-429", + email: "codex-model-less@example.com", + accessToken: "codex-model-less-access", + refreshToken: "codex-model-less-refresh", + }); + const parentBefore = await providersDb.getProviderConnectionById(connection.id); + + const result = await auth.markAccountUnavailable( + connection.id, + 429, + "quota reached without model metadata", + "codex", + null + ); + const updated = await providersDb.getProviderConnectionById(connection.id); + + assert.equal(result.shouldFallback, true); + assert.ok(result.cooldownMs > 0); + assert.equal(updated.testStatus, parentBefore.testStatus); + assert.equal(updated.rateLimitedUntil, parentBefore.rateLimitedUntil); + assert.equal(updated.lastError, parentBefore.lastError); + assert.equal(updated.errorCode, parentBefore.errorCode); + assert.equal(updated.backoffLevel, parentBefore.backoffLevel); + assert.deepEqual(updated.providerSpecificData, parentBefore.providerSpecificData); +}); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index 5abea8e67c..593988132a 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -1428,36 +1428,6 @@ test("Codex quota policy keeps normal and Spark windows separate", async () => { assert.match(String(sparkSelected.lastError), /configured quota threshold/i); }); -test("markAccountUnavailable stores Codex scope-specific cooldowns without a global rate limit", async () => { - const connection = await seedConnection("codex", { - authType: "oauth", - name: "codex-scope", - email: "codex@example.com", - apiKey: null, - accessToken: "codex-access", - refreshToken: "codex-refresh", - }); - - const result = await auth.markAccountUnavailable( - connection.id, - 429, - "quota reached", - "codex", - "codex-spark-mini" - ); - const updated = await providersDb.getProviderConnectionById(connection.id); - const selected = await auth.getProviderCredentials("codex", null, null, "codex-spark-mini"); - const normalSelected = await auth.getProviderCredentials("codex", null, null, "gpt-5.3-codex"); - - assert.equal(result.shouldFallback, true); - assert.ok(result.cooldownMs > 0); - assert.equal(updated.testStatus, "unavailable"); - assert.equal(updated.rateLimitedUntil, undefined); - assert.ok(updated.providerSpecificData.codexScopeRateLimitedUntil.spark); - assert.equal(selected.allRateLimited, true); - assert.equal(normalSelected.connectionId, connection.id); -}); - test("markAccountUnavailable returns without fallback on bad requests", async () => { const connection = await seedConnection("openai", { name: "bad-request-no-fallback", diff --git a/tests/unit/ui/codex-account-details.test.tsx b/tests/unit/ui/codex-account-details.test.tsx new file mode 100644 index 0000000000..1a2d5ceeee --- /dev/null +++ b/tests/unit/ui/codex-account-details.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { CodexAccountPoolProjection } from "../../../open-sse/services/codexAccount/index.ts"; + +const messages: Record = { + codexQuotaPools: "Codex quota pools", + codexPoolAvailable: "Available", + codexPoolPartiallyLimited: "Partially limited", + codexPoolFullyLimited: "Fully limited", + codexPoolLimited: "{count} limited", + codexPoolQuotaExhausted: "Quota exhausted", + codexPoolCoolingDown: "Cooling down", + codexPoolUsed: "used", + codexPoolUntil: "Until {value}", +}; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string, values?: Record) => { + let value = messages[key] ?? key; + for (const [name, replacement] of Object.entries(values ?? {})) { + value = value.replace(`{${name}}`, String(replacement)); + } + return value; + }, +})); + +import CodexAccountDetails from "../../../src/app/(dashboard)/dashboard/providers/[id]/components/CodexAccountDetails"; + +const mounted: Array<() => void> = []; + +function renderPool(pool: CodexAccountPoolProjection): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push(() => act(() => root.unmount())); + act(() => root.render()); + return container; +} + +function quota(exhaustedWindow: "5h" | "7d" | null = null) { + return { + exhaustedWindow, + observedAt: null, + windows: { "5h": null, "7d": null }, + }; +} + +afterEach(() => { + while (mounted.length) mounted.pop()?.(); + document.body.innerHTML = ""; +}); + +describe("CodexAccountDetails", () => { + it("labels exhaustion and cooldown separately without child operations or identifiers", () => { + const cooldown = "2026-01-01T01:00:00.000Z"; + const container = renderPool({ + parentConnectionId: "parent-secret-id", + aggregate: { status: "fully_limited", limitedChildCount: 2 }, + children: [ + { + key: { parentConnectionId: "parent-secret-id", scope: "codex" }, + unavailable: true, + cooldown: { active: false, rateLimitedUntil: null }, + quota: quota("5h"), + }, + { + key: { parentConnectionId: "parent-secret-id", scope: "spark" }, + unavailable: true, + cooldown: { active: true, rateLimitedUntil: cooldown }, + quota: quota(), + }, + ], + }); + + expect(container.textContent).toContain("Codex quota pools"); + expect(container.textContent).toContain("Fully limited · 2 limited"); + expect(container.textContent).toContain("Quota exhausted"); + expect(container.textContent).toContain("Cooling down"); + expect(container.textContent).not.toContain("parent-secret-id"); + expect( + container.querySelectorAll("button, input, [role='button'], [role='checkbox']") + ).toHaveLength(0); + }); + + it("prioritizes quota exhaustion when both facts apply and keeps neither available", () => { + const cooldown = "2026-01-01T01:00:00.000Z"; + const container = renderPool({ + parentConnectionId: "parent-2", + aggregate: { status: "partially_limited", limitedChildCount: 1 }, + children: [ + { + key: { parentConnectionId: "parent-2", scope: "codex" }, + unavailable: true, + cooldown: { active: true, rateLimitedUntil: cooldown }, + quota: quota("7d"), + }, + { + key: { parentConnectionId: "parent-2", scope: "spark" }, + unavailable: false, + cooldown: { active: false, rateLimitedUntil: null }, + quota: quota(), + }, + ], + }); + + expect(container.textContent).toContain("Partially limited · 1 limited"); + expect(container.textContent?.match(/Quota exhausted/g)).toHaveLength(1); + expect(container.textContent?.match(/Available/g)).toHaveLength(1); + expect(container.textContent).not.toContain("Cooling down"); + expect(container.textContent).toContain("Until "); + }); +}); diff --git a/tests/unit/ui/connection-row-codex-account-pool.test.tsx b/tests/unit/ui/connection-row-codex-account-pool.test.tsx new file mode 100644 index 0000000000..b9914102a3 --- /dev/null +++ b/tests/unit/ui/connection-row-codex-account-pool.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useLocale: () => "en", + useTranslations: () => (key: string, values?: Record) => { + const labels: Record = { + codexQuotaPools: "Codex quota pools", + codexPoolAvailable: "Available", + codexPoolPartiallyLimited: "Partially limited", + codexPoolFullyLimited: "Fully limited", + codexPoolLimited: "{count} limited", + codexPoolQuotaExhausted: "Quota exhausted", + codexPoolCoolingDown: "Cooling down", + codexPoolUsed: "used", + codexPoolUntil: "Until {value}", + }; + let value = labels[key] ?? key; + for (const [name, replacement] of Object.entries(values ?? {})) { + value = value.replace(`{${name}}`, String(replacement)); + } + return value; + }, +})); + +import ConnectionRow, { + type ConnectionRowConnection, +} from "@/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow"; + +const cleanupCallbacks: Array<() => void> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (cleanupCallbacks.length) cleanupCallbacks.pop()?.(); + document.body.innerHTML = ""; +}); + +describe("ConnectionRow Codex account pool", () => { + it("renders two non-actionable children beneath exactly one parent operation set", () => { + const onEdit = vi.fn(); + const onDelete = vi.fn(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + cleanupCallbacks.push(() => act(() => root.unmount())); + + const connection: ConnectionRowConnection = { + id: "codex-parent-id", + provider: "codex", + name: "Codex parent", + testStatus: "active", + isActive: true, + priority: 1, + codexAccountPool: { + parentConnectionId: "codex-parent-id", + aggregate: { status: "available", limitedChildCount: 0 }, + children: [ + { + key: { parentConnectionId: "codex-parent-id", scope: "codex" }, + unavailable: false, + cooldown: { active: false, rateLimitedUntil: null }, + quota: { + exhaustedWindow: null, + observedAt: null, + windows: { "5h": null, "7d": null }, + }, + }, + { + key: { parentConnectionId: "codex-parent-id", scope: "spark" }, + unavailable: false, + cooldown: { active: false, rateLimitedUntil: null }, + quota: { + exhaustedWindow: null, + observedAt: null, + windows: { "5h": null, "7d": null }, + }, + }, + ], + }, + }; + + act(() => { + root.render( + React.createElement(ConnectionRow, { + connection, + isOAuth: true, + isCodex: true, + isFirst: true, + isLast: true, + onMoveUp: () => {}, + onMoveDown: () => {}, + onToggleActive: () => {}, + onToggleRateLimit: () => {}, + onRetest: () => {}, + onEdit, + onDelete, + } as never) + ); + }); + + expect(container.textContent).toContain("Codex quota pools"); + expect(container.textContent).toContain("Codex"); + expect(container.textContent).toContain("Spark"); + expect(container.textContent).not.toContain("codex-parent-id"); + expect(container.querySelectorAll("button[title='edit']")).toHaveLength(1); + expect(container.querySelectorAll("button[title='delete']")).toHaveLength(1); + + act(() => { + (container.querySelector("button[title='edit']") as HTMLButtonElement).click(); + (container.querySelector("button[title='delete']") as HTMLButtonElement).click(); + }); + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledTimes(1); + }); +});