diff --git a/agents/opencode.ts b/agents/opencode.ts index 89739d3..0fba5e0 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -56,9 +56,21 @@ type OpenCodeConfig = { agent?: Record; model?: string; enabled_providers?: string[]; + /** + * OpenCode's `limit.output` controls the per-inference `max_tokens` the agent + * reserves with the upstream model. OpenCode defaults to 32_000 (sized for + * long-running TUI sessions where a human user might want big outputs). + * Pullfrog runs are headless and short — typical outputs are 1-3K tokens — + * so we override to a much smaller value. This drastically reduces the + * upfront budget reservation OpenRouter requires per call, which is what + * lets low-wallet runs actually start. + */ + limit?: { output?: number }; [key: string]: unknown; }; +const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000; + function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string { const config: OpenCodeConfig = { permission: { @@ -73,6 +85,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s [pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }, }, agent: buildReviewerAgentConfig(), + limit: { output: PULLFROG_OPENCODE_OUTPUT_LIMIT }, }; if (model) { diff --git a/main.ts b/main.ts index 00760b6..6919eec 100644 --- a/main.ts +++ b/main.ts @@ -35,6 +35,7 @@ import { executeLifecycleHook } from "./utils/lifecycle.ts"; import { normalizeEnv } from "./utils/normalizeEnv.ts"; import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts"; import { resolvePayload, resolvePromptInput } from "./utils/payload.ts"; +import { isRouterKeylimitExhaustedError } from "./utils/providerErrors.ts"; import { readSummaryFile, seedSummaryFile } from "./utils/prSummary.ts"; import { postReviewCleanup } from "./utils/reviewCleanup.ts"; import { handleAgentResult } from "./utils/run.ts"; @@ -186,6 +187,14 @@ function billingConsoleUrl(owner: string, anchor: "billing" | "model-access"): s * - `router_requires_card`: user is on Router mode with no card AND no * wallet balance. Lead with the carrot ($20 free credit), link to * `#model-access` where the Add Card flow lives. + * - `router_balance_exhausted`: user has a card on file but auto-reload is + * disabled and they've spent past their $5 overdraft buffer. Frame as + * "balance ran out" and surface both remediation paths (top up, or flip + * on auto-reload). + * - `router_keylimit_exhausted`: OpenRouter rejected mid-run because the + * per-run key budget was exhausted while the agent was working. The + * wallet is now negative; same remediation as `router_balance_exhausted` + * but framed for the after-the-fact case ("this run was cut short"). * - `needsReauthentication`: issuer requires 3DS on every off-session * charge. Re-adding the card won't help — the only escape is a manual * top-up where 3DS runs interactively in Stripe Checkout. @@ -205,6 +214,26 @@ function formatBillingErrorSummary(error: BillingError, owner: string): string { ].join("\n"); } + if (error.code === "router_balance_exhausted") { + return [ + "**Your Pullfrog Router balance is exhausted.**", + "", + "You have a card on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.", + "", + `[Top up balance →](${billingConsoleUrl(owner, "billing")}) · [Enable auto-reload →](${billingConsoleUrl(owner, "model-access")})`, + ].join("\n"); + } + + if (error.code === "router_keylimit_exhausted") { + return [ + "**This run was cut short — your Pullfrog Router balance ran out mid-run.**", + "", + "OpenRouter stopped the agent because the per-run budget was exhausted. Your wallet is now negative; top up or enable auto-reload to keep runs flowing.", + "", + `[Top up balance →](${billingConsoleUrl(owner, "billing")}) · [Enable auto-reload →](${billingConsoleUrl(owner, "model-access")})`, + ].join("\n"); + } + if (error.needsReauthentication) { const code = error.declineCode ?? "authentication_required"; return [ @@ -887,16 +916,32 @@ export async function main(): Promise { killTrackedChildren(); log.error(errorMessage); + // Reclassify OpenRouter "key budget exhausted" mid-run errors as + // BillingError. The agent runtime surfaces this as a generic APIError, + // but it's a Pullfrog billing concern — the user's Router wallet ran + // out partway through the run. Route through the same formatBillingErrorSummary + // path as proxy-token 402s so the user gets actionable copy + a top-up + // CTA on both the job summary and the PR progress comment, instead of + // a generic "❌ Pullfrog failed" stack-trace dump. + const billingError = isRouterKeylimitExhaustedError(errorMessage) + ? new BillingError(errorMessage, { code: "router_keylimit_exhausted" }) + : null; + // best-effort summary — write the error so it's visible in the Actions summary tab try { - const errorSummary = `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``; + const errorSummary = billingError + ? formatBillingErrorSummary(billingError, runContext.repo.owner) + : `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``; const usageSummary = formatUsageSummary(toolState.usageEntries); const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean); await writeSummary(parts.join("\n\n")); } catch {} try { - await reportErrorToComment({ toolState, error: errorMessage }); + const commentBody = billingError + ? formatBillingErrorSummary(billingError, runContext.repo.owner) + : errorMessage; + await reportErrorToComment({ toolState, error: commentBody }); } catch { // error reporting failed, but don't let it mask the original error } diff --git a/utils/providerErrors.test.ts b/utils/providerErrors.test.ts index 3bc3ea5..6371a69 100644 --- a/utils/providerErrors.test.ts +++ b/utils/providerErrors.test.ts @@ -1,4 +1,4 @@ -import { detectProviderError } from "./providerErrors.ts"; +import { detectProviderError, isRouterKeylimitExhaustedError } from "./providerErrors.ts"; describe("detectProviderError", () => { describe("false positives previously seen in production", () => { @@ -78,3 +78,39 @@ describe("detectProviderError", () => { }); }); }); + +describe("isRouterKeylimitExhaustedError", () => { + it("matches the canonical OpenRouter mid-run error", () => { + expect( + isRouterKeylimitExhaustedError( + "APIError: This request requires more credits, or fewer max_tokens. " + + "You requested up to 32000 tokens, but can only afford 22800. " + + "To increase, visit https://openrouter.ai/settings/keys and create a key with a higher total limit" + ) + ).toBe(true); + }); + + it("matches the 'requires more credits' phrasing on its own", () => { + expect( + isRouterKeylimitExhaustedError("This request requires more credits, or fewer max_tokens.") + ).toBe(true); + }); + + it("matches the 'requested up to ... can only afford' phrasing on its own", () => { + expect( + isRouterKeylimitExhaustedError("You requested up to 8000 tokens but can only afford 1234") + ).toBe(true); + }); + + it("does not match generic out-of-credit text", () => { + expect(isRouterKeylimitExhaustedError("Your account has insufficient credits")).toBe(false); + expect(isRouterKeylimitExhaustedError("rate_limit_exceeded")).toBe(false); + expect(isRouterKeylimitExhaustedError('{"limit": 0}')).toBe(false); + }); + + it("does not match unrelated mentions of max_tokens", () => { + expect(isRouterKeylimitExhaustedError("max_tokens parameter must be a positive integer")).toBe( + false + ); + }); +}); diff --git a/utils/providerErrors.ts b/utils/providerErrors.ts index aece9ca..cc7ba00 100644 --- a/utils/providerErrors.ts +++ b/utils/providerErrors.ts @@ -36,3 +36,24 @@ export function detectProviderError(text: string): string | null { } 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); +}