Files
OmniRoute/open-sse/config/providerErrorRules.ts
Diego Rodrigues de Sa e Souza 929caeb910 Release v3.8.15 (#3373)
* chore(release): open v3.8.15 development cycle

Version bump 3.8.14 -> 3.8.15 (root + electron + open-sse + openapi + lockfiles)
and seed the v3.8.15 changelog placeholder (root + 41 i18n mirrors).

* fix(catalog): add getTokenLimit fallback for combo targets with unknown context (#3369)

Integrated into release/v3.8.15. Fixes applied on the contributor's branch: removed duplicate JSDoc opening in accountFallback.ts and dropped a test asserting unreachable catalog behavior (models with no registry/spec/synced source are filtered before the getTokenLimit fallback at catalog.ts:499).

* fix(combo): add 429 to PROVIDER_FAILURE_ERROR_CODES to prevent infinite retry loop (#3366)

Integrated into release/v3.8.15. Comment block reconciled on the contributor's branch to remove the contradictory 'intentionally excluded' text that remained from the original code.

* fix(auto-combo): include no-auth providers declaratively (#3365)

Integrated into release/v3.8.15. Cleanup applied on contributor's branch: removed duplicate migration 095 (already exists from PR #3338), reverted CHANGELOG.md and i18n changelogs to release versions (release process owns these), dropped package version-bump noise from stale fork base. Core feature — declarative no-auth via serviceKinds metadata, declarative VEO as 'video' provider, anonymousFallback flag for opencode-zen/opencode-go — integrated cleanly.

* fix(migrations): restore 095_provider_node_custom_headers migration

The squash merge of PR #3365 accidentally deleted this migration because
the cleanup commit on the contributor's branch included 'git rm' for the
file (which was a duplicate on their branch). The migration was merged
in v3.8.14 via PR #3338 and must be present in the release branch.

Restoring from git history.

* fix: update Command Code base URL from /alpha/ to /provider/v1/ (#3372)

Integrated into release/v3.8.15.

* feat(error-rules): provider-specific error classification with scope (#3370)

Integrated into release/v3.8.15. PR has genuine value beyond #3369: (1) getProviderErrorRuleMatch now accepts native Headers objects from fetch(); (2) checkFallbackError also uses the provider rule registry — the real end-to-end wiring in the combo fallback path; (3) S4 end-to-end test proving the wiring fires. Merge commit on contributor branch resolved the add/add conflict by taking the #3370 version throughout.

* fix(auto-combo): validate web-session credentials (#3371)

Integrated into release/v3.8.15. Core feature: provider-aware web-session credential validation — hasUsableWebSessionCredential() replaces the broad Object.keys check in virtualFactory.ts, ensuring only sessions with the required storageKeys are included in auto-combo. Cleanup: removed duplicate 095 migration, reverted CHANGELOG/i18n, dropped package bump noise.

* fix(migrations): restore 095_provider_node_custom_headers (deleted again by #3371 squash)

Same issue as after #3365: git rm in the contributor cleanup commit
was included in the squash, deleting this migration from release.
Permanent fix needed: use 'git checkout origin/release -- <file>'
instead of 'git rm' when cleaning up duplicate files in contributor branches.

* fix(kiro): probe Windows %APPDATA%\kiro\storage.db in auto-import (#3363) (#3375)

Integrated into release/v3.8.15. Test fix applied: kiro-windows-auto-import-3363.test.ts now sets DATA_DIR to a fresh temp dir before importing app modules, ensuring isAuthRequired() sees an empty settings DB (no password → auth not required). This fixed test 4 (synthetic SQLite) which was getting 401 due to settings DB state leakage.

* chore(release): finalize v3.8.15 changelog — 2026-06-07

---------

Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Muhammad Nabil Muyassar Rahman <65392758+TapZe@users.noreply.github.com>
Co-authored-by: kiro-agent[bot] <245459735+kiro-agent[bot]@users.noreply.github.com>
2026-06-07 12:16:33 -03:00

147 lines
5.8 KiB
TypeScript

/**
* 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: Headers | Record<string, string> | null | undefined,
body?: unknown
): ProviderErrorRuleMatch | null {
if (!provider) return null;
const rules = providerRuleRegistry.get(provider);
if (!rules) return null;
// Normalize headers: accept either a `Headers` object (from `fetch()`) or
// a plain record. Provider rules access headers via plain object indexing.
const safeHeaders: Record<string, string> = !headers
? {}
: typeof (headers as Headers).get === "function"
? Object.fromEntries((headers as Headers).entries())
: (headers as Record<string, string>);
for (const rule of rules) {
const match = rule.match({ status, headers: safeHeaders, body });
if (match) return match;
}
return null;
}