diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts new file mode 100644 index 00000000000..2bd1ca1e7c8 --- /dev/null +++ b/open-sse/config/providerErrorRules.ts @@ -0,0 +1,140 @@ +/** + * Provider-specific error rules. + * + * Different providers expose different quota signals: + * - Opencode: account-wide quota. A 429 with `x-ratelimit-remaining-requests: 0` + * means the whole organization is out — we must lock the connection, not + * a specific model, so the combo router falls back to a different provider. + * - Minimax: per-model quota. A 429 with `x-model-quota-remaining: =0` + * means only that model is locked — the rest of the connection stays healthy. + * + * New providers register a `ProviderErrorRule[]` in `providerRuleRegistry`. Rules + * are evaluated BEFORE the global ERROR_RULES in classifyError. If no rule + * matches, behavior falls through to the existing global text/status rules. + * + * Adding a new provider = create one ProviderErrorRule[] and register it below. + * No changes to classifyError, lockModel, or updateProviderConnection needed. + */ + +import type { ConfiguredErrorReason } from "./errorConfig.ts"; + +export type ProviderErrorRule = { + id: string; + match: (ctx: { + status: number; + headers: Record; + body: unknown; + }) => ProviderErrorRuleMatch | null; +}; + +export type ProviderErrorRuleMatch = { + reason: ConfiguredErrorReason; + /** Default "provider" — lock the whole connection so other providers take over. */ + scope: "model" | "provider" | "connection"; + /** Optional explicit cooldown; falls back to the existing per-reason defaults. */ + cooldownMs?: number; +}; + +// ─── Opencode ────────────────────────────────────────────────────────────── +// Opencode Go uses an account-wide quota. The body usually says "rate limit +// reached" but the presence of `x-ratelimit-remaining-requests: 0` is the +// tell. Without this rule, an exhausted org quota would be classified as +// RATE_LIMIT_EXCEEDED (~5s cooldown), causing the combo to keep retrying +// every model on the same provider until the 5h window resets. +function buildOpencodeRules(): ProviderErrorRule[] { + return [ + { + id: "opencode-quota-exhausted-headers", + match: ({ status, headers }) => { + if (status !== 429) return null; + const remainingRequests = headers["x-ratelimit-remaining-requests"]; + if (remainingRequests === "0") { + return { reason: "quota_exhausted", scope: "provider" }; + } + const remainingTokens = headers["x-ratelimit-remaining-tokens"]; + if (remainingTokens === "0") { + return { reason: "quota_exhausted", scope: "provider" }; + } + return null; + }, + }, + { + id: "opencode-quota-exhausted-body", + match: ({ status, body }) => { + if (status !== 429) return null; + const text = JSON.stringify(body ?? "").toLowerCase(); + if ( + text.includes("organization_quota_exceeded") || + text.includes("account_quota_exceeded") || + text.includes("plan_limit_reached") + ) { + return { reason: "quota_exhausted", scope: "provider" }; + } + return null; + }, + }, + ]; +} + +// ─── Minimax ──────────────────────────────────────────────────────────────── +// Minimax returns per-model quota info via custom headers. The body is generic +// "rate limit exceeded" so we MUST read the headers. Other models on the same +// connection stay healthy; only the named model gets locked. +function buildMinimaxRules(): ProviderErrorRule[] { + return [ + { + id: "minimax-per-model-quota", + match: ({ status, headers }) => { + if (status !== 429) return null; + // Header pattern: "x-model-quota-remaining: haiku=0,sonnet=42,opus=100" + const headerVal = headers["x-model-quota-remaining"]; + if (!headerVal) return null; + // If any model reports 0 remaining, the request was rejected for that + // model. We classify as quota_exhausted so lockModel is called with + // scope=model instead of poisoning the whole connection. + const exhausted = headerVal + .split(",") + .some((pair) => pair.split("=")[1]?.trim() === "0"); + if (exhausted) { + return { reason: "quota_exhausted", scope: "model" }; + } + return null; + }, + }, + ]; +} + +/** + * Global registry. Provider name → ordered list of rules (first match wins). + * Add new providers here; the matcher in classifyError will pick them up + * automatically. + */ +export const providerRuleRegistry = new Map([ + ["opencode", buildOpencodeRules()], + ["opencode-go", buildOpencodeRules()], + ["opencode-cli", buildOpencodeRules()], + ["minimax", buildMinimaxRules()], + ["minimax-passthrough", buildMinimaxRules()], +]); + +/** + * Returns the first matching rule for a provider, or null if none match. + * Callers use this to (a) classify the reason and (b) decide whether to + * lock just the model or the whole connection. + */ +export function getProviderErrorRuleMatch( + provider: string | null | undefined, + status: number, + headers: Record | null | undefined, + body?: unknown +): ProviderErrorRuleMatch | null { + if (!provider) return null; + const rules = providerRuleRegistry.get(provider); + if (!rules) return null; + const safeHeaders = headers ?? {}; + for (const rule of rules) { + const match = rule.match({ status, headers: safeHeaders, body }); + if (match) return match; + } + return null; +} diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 140317964aa..07c2f715060 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -12,6 +12,7 @@ import { matchErrorRuleByText, matchErrorRuleByStatus, } from "../config/errorConfig.ts"; +import { getProviderErrorRuleMatch } from "../config/providerErrorRules.ts"; import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, @@ -1009,8 +1010,31 @@ export function classifyErrorText(errorText: unknown): RateLimitReasonValue { /** * Classify HTTP status + error text into RateLimitReason + * + * If context (provider, headers, body) is supplied, provider-specific rules + * are evaluated FIRST. A provider like Opencode can signal account-wide quota + * exhaustion via `x-ratelimit-remaining-requests: 0` even when the body says + * "rate limit" — without context, classifyError falls through to the global + * text rules and misclassifies as RATE_LIMIT_EXCEEDED. With context, the + * provider rule takes precedence. */ -export function classifyError(status: number, errorText: unknown): RateLimitReasonValue { +export function classifyError( + status: number, + errorText: unknown, + context?: { provider?: string | null; headers?: Record | null; body?: unknown } +): RateLimitReasonValue { + // Provider-specific rules take priority — they have the most accurate signal + // (e.g. `x-ratelimit-remaining-requests: 0` is irrefutable account exhaustion). + if (context?.provider) { + const match = getProviderErrorRuleMatch( + context.provider, + status, + context.headers ?? null, + context.body + ); + if (match) return match.reason; + } + // Text classification takes priority (more specific) const textReason = classifyErrorText(errorText); if (textReason !== RateLimitReason.UNKNOWN) return textReason; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index ddaa7b5077a..7c4154129aa 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -3890,6 +3890,8 @@ export async function handleComboChat({ fallbackResult.shouldFallback && (fallbackResult.reason === RateLimitReason.MODEL_CAPACITY || errorText.toLowerCase().includes("context") || + errorText.toLowerCase().includes("prompt") || + errorText.toLowerCase().includes("token") || errorText.toLowerCase().includes("malformed") || errorText.toLowerCase().includes("invalid") || errorText.toLowerCase().includes("bad request")) diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 928d52cc5cd..17596a0e7c9 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -496,7 +496,8 @@ export async function getUnifiedModelsResponse( const specContext = isPositiveFiniteNumber(spec?.contextWindow) ? spec.contextWindow : undefined; - const contextLength = syncedContext ?? registryContext ?? specContext; + const contextLength = syncedContext ?? registryContext ?? specContext ?? + (getTokenLimit(providerId, modelId) || undefined); const maxInputTokens = isPositiveFiniteNumber(synced?.limit_input) ? synced.limit_input : contextLength; diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 3d392c7d4a0..a1dce714074 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -1416,7 +1416,7 @@ test("v1 models catalog computes combo context_length from known targets when so assert.equal( comboModel.context_length, 128000, - "combo context_length should be the MIN of known target model limits, ignoring unknown targets" + "combo context_length should be the MIN of known target model limits, ignoring targets with no registry/spec/synced source" ); }); diff --git a/tests/unit/provider-error-rules.test.ts b/tests/unit/provider-error-rules.test.ts new file mode 100644 index 00000000000..fd507af98d6 --- /dev/null +++ b/tests/unit/provider-error-rules.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +/** + * Provider-specific error rules extend the global ERROR_RULES with per-provider + * signatures: headers, body markers, and a lock scope that tells the fallback + * engine whether to lock the model, the connection, or the entire provider. + * + * Each provider has its own quota model: + * - Opencode: account-wide quota. A 429 with `x-ratelimit-remaining-requests: 0` + * means the ORG is out, not just this model. + * - Minimax: per-model quota. A 429 with `x-model-quota-remaining: =0` + * means only that specific model is locked. + * - Anything else: falls back to global ERROR_RULES. + */ + +const { classifyError } = await import( + "../../open-sse/services/accountFallback.ts" +); +const { RateLimitReason } = await import( + "../../open-sse/config/constants.ts" +); + +test("S1: Opencode 429 with x-ratelimit-remaining-requests=0 → QUOTA_EXHAUSTED, not RATE_LIMIT_EXCEEDED", () => { + // Opencode uses account-wide quota. The header `x-ratelimit-remaining-requests: 0` + // signals the whole org is out, so this MUST classify as QUOTA_EXHAUSTED so the + // engine locks the provider connection (not just the model), forcing fallback to + // a different provider. + const reason = classifyError(429, "Rate limit reached", { + provider: "opencode", + headers: { "x-ratelimit-remaining-requests": "0" }, + body: { error: { message: "Rate limit reached" } }, + }); + assert.equal( + reason, + RateLimitReason.QUOTA_EXHAUSTED, + "Opencode account-wide quota exhaustion must classify as QUOTA_EXHAUSTED so the connection is locked, not the model" + ); +}); + +test("S2: Minimax 429 with x-model-quota-remaining header → QUOTA_EXHAUSTED with model scope", async () => { + // Minimax uses per-model quota. The header `x-model-quota-remaining: haiku=0` + // signals ONLY that model is locked; other models on the same connection must + // remain available. classifyError returns the reason; the caller (combo.ts) + // reads the scope from providerRuleMatch to decide lockModel vs updateProviderConnection. + const { providerRuleRegistry, getProviderErrorRuleMatch } = await import( + "../../open-sse/config/providerErrorRules.ts" + ); + + // The registry must be loaded for minimax + const minimaxRules = providerRuleRegistry.get("minimax"); + assert.ok( + minimaxRules && minimaxRules.length > 0, + "minimax must be registered in the provider rule registry" + ); + + // The match function returns { reason, scope } for the given provider + status + headers + const match = getProviderErrorRuleMatch("minimax", 429, { + "x-model-quota-remaining": "haiku=0", + }); + assert.ok(match, "minimax must have a rule that matches 429 + per-model quota header"); + assert.equal(match.reason, "quota_exhausted"); + assert.equal( + match.scope, + "model", + "Minimax per-model quota must scope the lock to the model only" + ); +}); + +test("S3: Regression — provider with no rules falls back to global ERROR_RULES unchanged", () => { + // A provider not in the registry (e.g. "unknown-vendor") must NOT cause + // classifyError to crash or return a different result. It must behave + // identically to the pre-feature implementation: global text/status rules. + const reason = classifyError(429, "rate limit reached", { + provider: "unknown-vendor", + headers: {}, + body: null, + }); + assert.equal( + reason, + RateLimitReason.RATE_LIMIT_EXCEEDED, + "Unknown providers must fall through to global rules without modification" + ); + + // And without any context at all (old call sites), still works. + const reasonNoCtx = classifyError(429, "rate limit reached"); + assert.equal(reasonNoCtx, RateLimitReason.RATE_LIMIT_EXCEEDED); +});