fix: don't reuse disabled proxy key on workflow re-runs; non-fatal title-gen errors (#636)
* fix: don't reuse disabled proxy key on workflow re-runs; non-fatal title-gen errors Three small surgical fixes addressing run https://github.com/pullfrog/app/actions/runs/25580969379: 1. **`/api/proxy-token` idempotency now checks `finalizedAt`.** GitHub re-runs share the same `run_id` (only `run_attempt` increments), so attempt N+1's action calls /api/proxy-token and inherits attempt N's `proxyKeyId`. The `workflow_run.completed` webhook between attempts retires that key on OpenRouter (`disableKey`), so attempt N+1 was getting back a disabled key and OpenRouter responded with `401 User not found` on every call. Falling through when finalized routes through the same billing gate (`handleRouterBilling` balance check), so no new attack surface. 2. **OpenCode title-gen / small-model errors no longer fatal.** OpenCode auto-spawns a small `agent=title small=true` background call at session start to name the thread, defaulting to `anthropic/claude-haiku-4.5` (anomalyco/opencode#1243). Pre-fix, the wrapper's `error` event handler treated any `type=error` as fatal, so a cosmetic title failure killed the run before primary inference even started. Now: stderr matching `small=true` sets a one-shot suppression flag for the next stdout `error` event, which is logged as a warning instead. 3. **Provider-error classifier puts auth patterns above rate-limit.** OpenRouter 401 payloads bundle `x-ratelimit-*` response headers, and the loose `\brate[_ ]limit/i` pattern was winning. Added 401/403 status, `User not found`, `Invalid authentication`, `No auth credentials found` patterns ahead of rate-limit. Updated the existing 401-headers regression test to assert correct auth classification rather than `null`. * opencode: correlate small-model error suppression by message, not by next-event Pullfrog self-review on #636 flagged a real concurrency hole. OpenCode forks the title-gen call (`session/prompt.ts:1452-1457` via `Effect.forkIn(scope)`) so it races primary inference. The previous one-shot `suppressNextErrorEvent` boolean had no per-call correlation: it was consumed by whichever stdout `type=error` event landed next, regardless of which subagent produced it. Under concurrent failures, a primary-agent error landing first could be silently downgraded to a warning while the small-model error then propagated fatally — the inverse of the bug the suppression was meant to prevent. Replaced the boolean with a `Set<string>` of pending small-model error messages. stderr extracts the inner `"message":"..."` from any classified provider error tagged `small=true`; the stdout `error` handler suppresses only when `event.error.data.message` matches a pending entry. Set is capped at 32 entries so a long stream of small-model failures can't wedge memory. Also corrected the comment that referenced "session summarizer" — verified in opencode source that summarize() does NOT use `small: true`; only the title generator does today (only `small: true` match in the codebase). * revert: drop opencode title-gen suppression We have no evidence — and can't construct a realistic scenario — where title-gen fails on an otherwise-successful run. Title-gen and primary share the same OPENROUTER_API_KEY and hit the same proxy/upstream; whatever breaks one breaks the other. The original repro on run 25580969379 is fully explained by the stale proxy key (fix #1) — title-gen happened to be the first call that surfaced the auth error, but every subsequent primary call would have died the same way. Suppression code adds complexity (cross-stream correlation logic, message matching, set capping) and a real failure mode of its own (a small-model error with a unique message could mask an unrelated primary error landing shortly after). Net negative. Removing.
This commit is contained in:
committed by
pullfrog[bot]
parent
3d393c36a3
commit
5f3e46c42d
@@ -7,7 +7,13 @@ describe("detectProviderError", () => {
|
||||
expect(detectProviderError("commit f609cc89e84596ab125d60dac568bfb2ef398396 429")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for x-ratelimit-* response headers in 401 error JSON", () => {
|
||||
it("classifies 401 + x-ratelimit-* headers as auth, not rate-limited", () => {
|
||||
// OpenRouter 401 responses bundle `x-ratelimit-*` rate-limit headers
|
||||
// alongside the auth error. the auth patterns must win — pre-fix this
|
||||
// got tagged as `rate limited` because of the loose `\brate[_ ]limit`
|
||||
// match against header names like `ratelimit-limit-requests`. note: in
|
||||
// OpenRouter's actual format the header name is `ratelimit` (one word),
|
||||
// but the dumped JSON sometimes contains `rate-limit` separators too.
|
||||
const stderr = JSON.stringify({
|
||||
error: { name: "APIError", statusCode: 401, message: "Invalid authentication credentials" },
|
||||
headers: {
|
||||
@@ -16,7 +22,7 @@ describe("detectProviderError", () => {
|
||||
"x-ratelimit-reset-tokens": "2025-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
expect(detectProviderError(stderr)).toBeNull();
|
||||
expect(detectProviderError(stderr)).toBe("auth error (401)");
|
||||
});
|
||||
|
||||
it("returns null for INTERNAL_SERVER_ERROR substring", () => {
|
||||
@@ -29,6 +35,37 @@ describe("detectProviderError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth errors", () => {
|
||||
it("detects 401 / 403 status codes as auth errors", () => {
|
||||
expect(detectProviderError('{"statusCode": 401}')).toBe("auth error (401)");
|
||||
expect(detectProviderError('{"statusCode": 403}')).toBe("auth error (403)");
|
||||
expect(detectProviderError("status_code: 401")).toBe("auth error (401)");
|
||||
});
|
||||
|
||||
it("detects OpenRouter 'User not found' (disabled/invalid key)", () => {
|
||||
// bare `"code":401` lacks a status-key prefix so the 401 status pattern
|
||||
// intentionally doesn't fire; the User-not-found pattern catches it.
|
||||
expect(detectProviderError('{"error":{"message":"User not found","code":401}}')).toBe(
|
||||
"auth error (invalid/disabled key)"
|
||||
);
|
||||
expect(detectProviderError("APIError: User not found.")).toBe(
|
||||
"auth error (invalid/disabled key)"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'Invalid authentication' phrasing", () => {
|
||||
expect(detectProviderError("Invalid authentication credentials")).toBe(
|
||||
"auth error (invalid credentials)"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'No auth credentials found' phrasing", () => {
|
||||
expect(detectProviderError("AI_APICallError: No auth credentials found")).toBe(
|
||||
"auth error (missing credentials)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real provider errors", () => {
|
||||
it("detects 429 only when adjacent to a status key", () => {
|
||||
expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)");
|
||||
|
||||
@@ -6,6 +6,17 @@ type ProviderErrorPattern = { regex: RegExp; label: string };
|
||||
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)" },
|
||||
|
||||
Reference in New Issue
Block a user