4101df566b
Replaces today's `keyLimitUsd = min(walletBalance, $25)` with population-aware buffers so users can use 100% of their credits before being paywalled, and opaque mid-run "more credits" failures (e.g. https://github.com/pullfrog/app/actions/runs/25531633203) get a clear PR comment instead of a generic stack-trace dump. Policy matrix: - Auto-reload accounts: `wallet + autoReloadAmountCents` (default $50, no cap) - Card + no-autoreload: `wallet + $5` overdraft buffer - No card: `wallet` (no buffer; existing zero-balance 402 stays) - OSS: `$10` (unchanged) Removes the $25 per-run cap entirely. Long Build runs at high-balance accounts no longer silently cap at $25. Other changes: - Classify mid-run OpenRouter "requires more credits, or fewer max_tokens" errors as `router_keylimit_exhausted` BillingError so users get an actionable PR comment. - Override OpenCode `max_tokens: 32000` default to `5000` via OpenCodeConfig.limit.output. Drops Opus per-call upfront budget reservation from ~$2.40 to ~$0.38 — what makes low-wallet runs viable at all. - Switch `findInitialComment` and `findExistingPaywallComment` to GraphQL `issueOrPullRequest(number:) { comments(last: 100) }` (single round trip, actually returns newest-100; REST listComments doesn't support sort/direction). Also fixes a latent `comments.find()` returning the OLDEST match instead of the most recent — now selects max(databaseId). - Wrap `syncAccountUsage` in `prisma.$transaction` with `SELECT ... FOR UPDATE` on the account row. Pre/post-balance reads inside the transaction enable deterministic low-balance edge detection (currently logs; will push the outreach.low_balance task once #592 lands). Plan: .cursor/plans/router-low-balance-paywall.plan.md (in companion wiki-billing branch)
60 lines
3.0 KiB
TypeScript
60 lines
3.0 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.`
|
|
*/
|
|
const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN =
|
|
/requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/i;
|
|
|
|
export function isRouterKeylimitExhaustedError(text: string): boolean {
|
|
return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
|
|
}
|