b9383bbcfd
the `» provider error detected (...)` excerpt was `chunk.substring(0, 500)`
— the head of whatever stderr buffer node delivered. on big writes that's
the front of an mcp tool-schema dump, not the matched error text. label
was correct (regex.test on the whole chunk), excerpt was misleading.
introduce findProviderErrorMatch(text) that returns { label, excerpt }
where excerpt is a windowed slice centered on the regex match index:
the matched line plus 1 line before and 2 lines after, hard-capped at
600 bytes. detectProviderError stays as a thin wrapper for label-only
callers. both opencode and claude harnesses log match.excerpt instead
of chunk.substring(0, 500).
regression tests cover the multi-line buffer case, surrounding-line
context, byte-cap fallback to matched-line-only, and head truncation
of a single oversize line.
134 lines
6.3 KiB
TypeScript
134 lines
6.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[] = [
|
||
// auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
|
||
// payloads carry `x-ratelimit-*` response headers in the dump, and the
|
||
// free-form rate-limit regex below would otherwise win on word-boundary
|
||
// matches inside header names. canonical 401 messages: OpenRouter returns
|
||
// `{"error":{"message":"User not found","code":401}}` for disabled or
|
||
// invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging).
|
||
{ regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" },
|
||
{ regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" },
|
||
{ regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" },
|
||
{ regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" },
|
||
{ regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" },
|
||
{ 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" },
|
||
];
|
||
|
||
/**
|
||
* Result of a provider-error scan: the classification label plus a
|
||
* human-readable excerpt centered on the matched line. The excerpt is what
|
||
* gets surfaced in `» provider error detected (...)` log lines — see
|
||
* `extractExcerpt` for the windowing/byte-cap policy.
|
||
*/
|
||
export type ProviderErrorMatch = {
|
||
label: string;
|
||
excerpt: string;
|
||
};
|
||
|
||
// roughly half a wide terminal line by 4–5 lines of context; large enough
|
||
// to capture a structured error payload (request id, retry-after, model)
|
||
// plus its immediate stack/headers, small enough to not flood the log.
|
||
const EXCERPT_MAX_BYTES = 600;
|
||
const LINES_BEFORE = 1;
|
||
const LINES_AFTER = 2;
|
||
|
||
export function findProviderErrorMatch(text: string): ProviderErrorMatch | null {
|
||
for (const entry of PROVIDER_ERROR_PATTERNS) {
|
||
const m = entry.regex.exec(text);
|
||
if (!m) continue;
|
||
return { label: entry.label, excerpt: extractExcerpt(text, m.index) };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function detectProviderError(text: string): string | null {
|
||
return findProviderErrorMatch(text)?.label ?? null;
|
||
}
|
||
|
||
/**
|
||
* Slice a context window around `matchIndex`: the matched line plus
|
||
* `LINES_BEFORE`/`LINES_AFTER` neighbours. If the windowed slice exceeds
|
||
* `EXCERPT_MAX_BYTES` (giant adjacent lines, e.g. JSON tool-schema dumps),
|
||
* fall back to the matched line alone, head-truncated if still too long.
|
||
* Replaces the old `chunk.substring(0, 500)` head-anchored excerpt which
|
||
* surfaced whatever happened to be at the front of the stderr buffer
|
||
* instead of the error itself. See issue #703.
|
||
*/
|
||
function extractExcerpt(text: string, matchIndex: number): string {
|
||
const lineStart = text.lastIndexOf("\n", matchIndex - 1) + 1;
|
||
const lineEndRaw = text.indexOf("\n", matchIndex);
|
||
const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
|
||
|
||
let start = lineStart;
|
||
for (let i = 0; i < LINES_BEFORE && start > 0; i++) {
|
||
const prev = text.lastIndexOf("\n", start - 2);
|
||
start = prev < 0 ? 0 : prev + 1;
|
||
}
|
||
|
||
let end = lineEnd;
|
||
for (let i = 0; i < LINES_AFTER && end < text.length; i++) {
|
||
const next = text.indexOf("\n", end + 1);
|
||
end = next < 0 ? text.length : next;
|
||
}
|
||
|
||
let excerpt = text.slice(start, end);
|
||
if (excerpt.length > EXCERPT_MAX_BYTES) {
|
||
excerpt = text.slice(lineStart, lineEnd);
|
||
if (excerpt.length > EXCERPT_MAX_BYTES) excerpt = excerpt.slice(0, EXCERPT_MAX_BYTES);
|
||
}
|
||
return excerpt.trim();
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|