Files
shockbot/utils/providerErrors.ts
T
Colin McDonnell ec43c0e0d1 router: fix bugs from PR #616 review (#625)
Three real defects flagged in the post-merge review of #616, plus one cheap
hardening:

1. OpenCode `limit.output` override was a silent no-op on opencode-ai@1.1.56.
   Top-level `limit.output` has no read site in OpenCode (verified against
   the v1.1.56 source: `OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
   || 32_000` in session/llm.ts; per-model `model.limit.output` has its own
   scope). Plumbed via `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX=5000` env var
   on the OpenCode spawn instead. Drops dead `OpenCodeConfig.limit?` type
   field and the corresponding config write in `buildSecurityConfig`. This
   was the headline mechanism of #616 — without the env var, the upfront
   `max_tokens` reservation stayed at 32_000 and low-wallet runs continued
   failing the way #616 was supposed to prevent.

2. Phantom auto-reload buffer for detached-card accounts. DELETE
   /payment-method clears `stripeCustomerId` but leaves `autoReloadEnabled`
   intact, so an account with welcome-credit residue and a detached card
   could mint a key with `keyLimitCents = balance + autoReloadAmountCents`
   ($50 default, schema-cap $100K) of free spend headroom we have no way
   to bill. Conjunctive `account.autoReloadEnabled && hasCard` in the
   buffer selection closes this. Defense-in-depth follow-up worth doing:
   clear `autoReloadEnabled` in the card-detach handler.

3. The autoReloadEnabled 402 branch fired for phase-1 noop paths
   (`!stripeCustomerId`, `reloadAmountCents < 50`, `balance >= threshold`)
   where `result.failure == null`, returning `"insufficient balance"` with
   no actionable code. Gated on `result.status === "failed"` so non-charge
   paths fall through to the `hasCard` / no-card branches and emit
   `router_balance_exhausted` / `router_requires_card` instead.

4. (cheap) `ROUTER_KEYLIMIT_EXHAUSTED_PATTERN` now uses `/is` instead of
   `/i` so `.*?` crosses newlines. Defends the BillingError reclassification
   against any upstream layer that wraps the OpenRouter error onto multiple
   lines. Trivial.

Test plan: 488/488 unit tests pass (1 new test for newline regex behavior).
2026-05-08 21:02:38 +00:00

65 lines
3.3 KiB
TypeScript

type ProviderErrorPattern = { regex: RegExp; label: string };
// status codes are only treated as provider errors when they are adjacent to
// a recognised status key. this rejects commit SHAs that happen to contain
// "429", version strings, file hashes, etc.
const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [
{ regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" },
{ regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" },
{ regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" },
// matches `rate limit`, `rate limited`, `rate limits exceeded`,
// `rate_limit_error`, `rate_limit_exceeded`. the leading `\b` + `[_ ]`
// separator rejects `x-ratelimit-*` / `anthropic-ratelimit-*` response
// headers (no separator between "rate" and "limit") which routinely
// appear in dumped 401 / 4xx error JSON.
{ regex: /\brate[_ ]limit/i, label: "rate limited" },
{ regex: /\bRESOURCE_EXHAUSTED\b/, label: "quota exhausted" },
// Google gRPC `INTERNAL` status. word-boundary anchors reject
// `INTERNAL_SERVER_ERROR` (HTTP 500 message that may appear in unrelated
// log lines) and identifiers like `INTERNALS`.
{ regex: /\bINTERNAL\b/, label: "provider internal error" },
{ regex: /\bUNAVAILABLE\b/, label: "provider unavailable" },
// matches `quota`, `insufficient_quota`, `quota_exceeded`, `quotaExceeded`.
// word-character lookarounds would reject `_quota` / `quotaX`; `quota` is
// specific enough that a plain substring match is safe.
{ regex: /quota/i, label: "quota error" },
// explicit zero-quota response, e.g. `{"limit": 0}`. the `\b` anchor
// around `limit` rejects keys like `time_limit` or `field_limit`.
{ regex: /["']?\blimit\b["']?\s*:\s*0\b/, label: "zero quota" },
];
export function detectProviderError(text: string): string | null {
for (const entry of PROVIDER_ERROR_PATTERNS) {
if (entry.regex.test(text)) return entry.label;
}
return null;
}
/**
* OpenRouter's response when the per-run key's remaining budget can't cover
* the agent's `max_tokens` reservation. Distinct from a generic provider error
* because it's a Pullfrog billing concern, not an upstream outage — the user's
* Router wallet ran out (or the key budget was undersized at mint time and the
* agent ran out of headroom partway through).
*
* Match must be specific to this exact OpenRouter error class. Generic "credits"
* or "limit" text shows up in unrelated errors and would mis-classify them.
*
* Sample:
* `APIError: This request requires more credits, or fewer max_tokens.
* You requested up to 32000 tokens, but can only afford 22800.`
*/
// `/s` (dotAll) lets `.*?` cross newlines so we still detect the error if any
// upstream layer reformats the message onto multiple lines. Without it, a
// single inserted `\n` would silently bypass the BillingError reclassification
// and the user would see the generic `❌ Pullfrog failed` dump instead of the
// actionable top-up CTA.
const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN =
/requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
export function isRouterKeylimitExhaustedError(text: string): boolean {
return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
}