Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions open-sse/config/providerErrorRules.ts
Original file line number Diff line number Diff line change
@@ -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: <model>=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<string, string>;
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<string, ProviderErrorRule[]>([
["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<string, string> | 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;
}
26 changes: 25 additions & 1 deletion open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> | 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;
Expand Down
2 changes: 2 additions & 0 deletions open-sse/services/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/models-catalog-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});

Expand Down
88 changes: 88 additions & 0 deletions tests/unit/provider-error-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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: <model>=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);
});