router: decouple per-run key budget from wallet, add overdraft buffer (#616)
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)
This commit is contained in:
committed by
pullfrog[bot]
parent
9d04cad360
commit
4101df566b
@@ -56,9 +56,21 @@ type OpenCodeConfig = {
|
|||||||
agent?: Record<string, unknown>;
|
agent?: Record<string, unknown>;
|
||||||
model?: string;
|
model?: string;
|
||||||
enabled_providers?: 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;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000;
|
||||||
|
|
||||||
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
||||||
const config: OpenCodeConfig = {
|
const config: OpenCodeConfig = {
|
||||||
permission: {
|
permission: {
|
||||||
@@ -73,6 +85,7 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
|||||||
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||||
},
|
},
|
||||||
agent: buildReviewerAgentConfig(),
|
agent: buildReviewerAgentConfig(),
|
||||||
|
limit: { output: PULLFROG_OPENCODE_OUTPUT_LIMIT },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (model) {
|
if (model) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
|||||||
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
||||||
import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts";
|
import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts";
|
||||||
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
||||||
|
import { isRouterKeylimitExhaustedError } from "./utils/providerErrors.ts";
|
||||||
import { readSummaryFile, seedSummaryFile } from "./utils/prSummary.ts";
|
import { readSummaryFile, seedSummaryFile } from "./utils/prSummary.ts";
|
||||||
import { postReviewCleanup } from "./utils/reviewCleanup.ts";
|
import { postReviewCleanup } from "./utils/reviewCleanup.ts";
|
||||||
import { handleAgentResult } from "./utils/run.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
|
* - `router_requires_card`: user is on Router mode with no card AND no
|
||||||
* wallet balance. Lead with the carrot ($20 free credit), link to
|
* wallet balance. Lead with the carrot ($20 free credit), link to
|
||||||
* `#model-access` where the Add Card flow lives.
|
* `#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
|
* - `needsReauthentication`: issuer requires 3DS on every off-session
|
||||||
* charge. Re-adding the card won't help — the only escape is a manual
|
* charge. Re-adding the card won't help — the only escape is a manual
|
||||||
* top-up where 3DS runs interactively in Stripe Checkout.
|
* top-up where 3DS runs interactively in Stripe Checkout.
|
||||||
@@ -205,6 +214,26 @@ function formatBillingErrorSummary(error: BillingError, owner: string): string {
|
|||||||
].join("\n");
|
].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) {
|
if (error.needsReauthentication) {
|
||||||
const code = error.declineCode ?? "authentication_required";
|
const code = error.declineCode ?? "authentication_required";
|
||||||
return [
|
return [
|
||||||
@@ -887,16 +916,32 @@ export async function main(): Promise<MainResult> {
|
|||||||
killTrackedChildren();
|
killTrackedChildren();
|
||||||
log.error(errorMessage);
|
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
|
// best-effort summary — write the error so it's visible in the Actions summary tab
|
||||||
try {
|
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 usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||||
const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
|
const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||||
await writeSummary(parts.join("\n\n"));
|
await writeSummary(parts.join("\n\n"));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reportErrorToComment({ toolState, error: errorMessage });
|
const commentBody = billingError
|
||||||
|
? formatBillingErrorSummary(billingError, runContext.repo.owner)
|
||||||
|
: errorMessage;
|
||||||
|
await reportErrorToComment({ toolState, error: commentBody });
|
||||||
} catch {
|
} catch {
|
||||||
// error reporting failed, but don't let it mask the original error
|
// error reporting failed, but don't let it mask the original error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { detectProviderError } from "./providerErrors.ts";
|
import { detectProviderError, isRouterKeylimitExhaustedError } from "./providerErrors.ts";
|
||||||
|
|
||||||
describe("detectProviderError", () => {
|
describe("detectProviderError", () => {
|
||||||
describe("false positives previously seen in production", () => {
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,3 +36,24 @@ export function detectProviderError(text: string): string | null {
|
|||||||
}
|
}
|
||||||
return 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user