From 5f3e46c42dd38551546efed3199eba030e57b884 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Fri, 8 May 2026 23:00:41 +0000 Subject: [PATCH] fix: don't reuse disabled proxy key on workflow re-runs; non-fatal title-gen errors (#636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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` 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. --- utils/providerErrors.test.ts | 41 ++++++++++++++++++++++++++++++++++-- utils/providerErrors.ts | 11 ++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/utils/providerErrors.test.ts b/utils/providerErrors.test.ts index bdc4d39..45421e5 100644 --- a/utils/providerErrors.test.ts +++ b/utils/providerErrors.test.ts @@ -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)"); diff --git a/utils/providerErrors.ts b/utils/providerErrors.ts index 6a6b962..c2d40c0 100644 --- a/utils/providerErrors.ts +++ b/utils/providerErrors.ts @@ -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)" },