0a64659ee7
* refactor: extract helpers out of action/main.ts so non-orchestration churn stops touching the file main.ts had grown to ~1240 lines holding ~500 lines of helpers that have nothing to do with the resolver pipeline — billing-error UI/copy, proxy minting, summary/learnings persistence, log formatters, end-of-run cleanup waterfalls. any PR adding a new billing code branch or a new log line was forced to edit main.ts, and since main.ts is in ALWAYS_RUN_ALL the entire 52-job LLM CI matrix fired on what should have been a 0-job change (e.g. #748). extractions: - action/utils/billingErrors.ts — BillingError, TransientError, the format*Summary renderers, billingConsoleUrl - action/utils/proxy.ts — mintProxyKey, buildProxyTokenHeaders, resolveProxyModel, plus runProxyResolution wrapper that renders + rethrows BillingError/TransientError before the outer catch - action/utils/prSummary.ts — fetchPreviousSnapshot, persistSummary co-located with the existing seed/read file helpers - action/utils/learnings.ts — persistLearnings co-located with the existing seed/read file helpers - action/utils/runStartupLog.ts — resolveOutputSchema + logRunStartup (the model/agent/push/shell/timeout block) - action/utils/runErrorRenderer.ts — renderRunError classifies (BillingError reclassify / hang detect / API-key auth) and emits {summary, comment} markdown bodies - action/utils/runLifecycle.ts — persistRunArtifacts, finalizeSuccessRun, writeRunErrorOutputs — the three end-of-run cleanup phases shared between the success path and the error catch path main.ts is now ~570 lines — the irreducible orchestrator: disposables (`await using` for tokenRef / gitAuthServer / mcpHttpServer), the toolContext construction, the agent-timeout race, the catch/finally shape, and the named phase calls. behavior is preserved verbatim (verified: pnpm -r typecheck + pnpm test 695/695 pass, action/test 596/596 pass). wiki/main.md gets a new "file layout" section describing the split. AGENTS.md gets a single line pointing future edits at the helpers instead of main.ts. * anneal: address review findings - restore MainResult.result?: string (accidental removal in initial commit; field was unused in current code but is part of the exported interface surface — keep the diff truly behavior-preserving) - move resolveOutputSchema from runStartupLog.ts to payload.ts (it's an action-input resolver alongside resolvePromptInput / resolvePayload, not a log helper — was placed in runStartupLog.ts for matrix-churn pragmatism but the domain fit is in payload.ts) - un-export resolveProxyModel (only used internally by runProxyResolution in proxy.ts; no external importer) - fix runErrorRenderer.ts JSDoc "Three classifications" → four (Billing, hang, API-key, default) - expand runLifecycle.ts module banner to note that finalizeSuccessRun calls persistRunArtifacts first, and to explain why the catch path splits writeRunErrorOutputs + persistRunArtifacts - update billingErrors.ts header to point at proxy.ts and runErrorRenderer.ts as the actual origin sites (was stale "main.ts") - expand proxy.ts header to spell out the runProxyResolution entrypoint contract (was stale "main.ts can render") - update wiki/main.md resolver chain + dependency table to name runProxyResolution as the actual call site and document the early BillingError/TransientError rendering branch - update wiki/main.md file-layout table to lead with runProxyResolution and describe mintProxyKey/buildProxyTokenHeaders/resolveProxyModel as internal helpers (was implying they were public surface)
180 lines
7.6 KiB
TypeScript
180 lines
7.6 KiB
TypeScript
/**
|
|
* Billing-error classification + user-facing copy for `/api/proxy-token`
|
|
* failures and OpenRouter mid-run exhaustion. Two error classes (Billing vs.
|
|
* Transient) keep the framing honest: a card decline is *not* the same UX as
|
|
* a 503 from the proxy service. Both originate in `utils/proxy.ts` (mint
|
|
* failures) and `utils/runErrorRenderer.ts` (mid-run keylimit reclassify).
|
|
*
|
|
* Renderers return markdown bodies that are written into both the GitHub
|
|
* Actions job summary and the PR progress comment.
|
|
*
|
|
* Lives outside `main.ts` so adding a new error `code` branch is a one-file
|
|
* edit that does not retrigger the full LLM CI matrix (`action/main.ts` is
|
|
* in `action/test/coverage.ts::ALWAYS_RUN_ALL`).
|
|
*/
|
|
|
|
/**
|
|
* Billing-layer error surfaced from `/api/proxy-token` as a 402. User-actionable
|
|
* — distinct from TransientError (503 / transient sync issue) so the job
|
|
* summary + PR comment can use affirmative "you need to do X" copy rather than
|
|
* the ambiguous "billing error" label that makes transient outages look like
|
|
* the user's fault.
|
|
*
|
|
* `code` is a server-side discriminator: `router_requires_card` (no card + no
|
|
* wallet balance on Router), or null for unclassified. `declineCode` is
|
|
* Stripe's more specific sub-reason on `card_declined` (e.g.
|
|
* `insufficient_funds`, `lost_card`). `needsReauthentication` is the 3DS case
|
|
* broken out for convenience.
|
|
*/
|
|
export class BillingError extends Error {
|
|
code: string | null;
|
|
declineCode: string | null;
|
|
needsReauthentication: boolean;
|
|
|
|
constructor(
|
|
message: string,
|
|
opts: {
|
|
code?: string | null;
|
|
declineCode?: string | null;
|
|
needsReauthentication?: boolean;
|
|
} = {}
|
|
) {
|
|
super(message);
|
|
this.name = "BillingError";
|
|
this.code = opts.code ?? null;
|
|
this.declineCode = opts.declineCode ?? null;
|
|
this.needsReauthentication = opts.needsReauthentication ?? false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transient service failures from `/api/proxy-token` (503: partial OpenRouter
|
|
* usage sync, DB flake, in-flight payment intent). Not the user's fault — the
|
|
* summary uses "temporarily unavailable" framing, and the non-zero exit lets
|
|
* GH Actions apply whatever retry policy the workflow has configured.
|
|
*/
|
|
export class TransientError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "TransientError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deep link into the right console section for the failing account. Anchors
|
|
* are defined in `app/console/[owner]/page.tsx` (`#billing`, `#model-access`).
|
|
* `owner` is the GitHub login of the repo's account — i.e. the org or user
|
|
* that pays for this repo's runs, which is the right scope for billing.
|
|
*/
|
|
function billingConsoleUrl(owner: string, anchor: "billing" | "model-access"): string {
|
|
return `https://pullfrog.com/console/${encodeURIComponent(owner)}#${anchor}`;
|
|
}
|
|
|
|
/**
|
|
* Render a BillingError as user-facing markdown (shared between GH job summary
|
|
* and the PR progress comment). Goals:
|
|
*
|
|
* - quiet, not alarmist — bold first line instead of an `### ❌` H3, since
|
|
* the comment already has Pullfrog branding in the footer
|
|
* - actionable — every branch ends in a single CTA deep-linked to the
|
|
* correct section of the owner's console
|
|
* - honest — say what actually went wrong (card declined vs. balance
|
|
* empty vs. 3DS required), don't lump them under "billing error"
|
|
*
|
|
* Branches:
|
|
* - `router_requires_card`: user is on Router mode with no card AND no
|
|
* wallet balance (signup credit exhausted or not granted). Frame as
|
|
* "add a card to continue", 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.
|
|
* - `declineCode` set: Stripe declined a real charge. Show the sub-code
|
|
* so support can act on it; tell the user we'll retry on next dispatch.
|
|
* - default: balance hit zero with no in-flight charge (auto-reload off
|
|
* or amount below threshold). Direct them to top up or enable auto-reload.
|
|
*/
|
|
export function formatBillingErrorSummary(error: BillingError, owner: string): string {
|
|
if (error.code === "router_requires_card") {
|
|
return [
|
|
"**Add a card to start using Pullfrog Router.**",
|
|
"",
|
|
"Router proxies OpenRouter at raw cost — no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
|
|
"",
|
|
`[Add a card →](${billingConsoleUrl(owner, "model-access")})`,
|
|
].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 [
|
|
`**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
|
|
"",
|
|
"Pullfrog can't complete a 3DS challenge from inside a workflow. Top up your Router balance once in Stripe Checkout — subsequent runs draw from the prepaid balance without re-triggering 3DS.",
|
|
"",
|
|
`[Top up balance →](${billingConsoleUrl(owner, "billing")})`,
|
|
].join("\n");
|
|
}
|
|
|
|
if (error.declineCode) {
|
|
return [
|
|
`**Your card was declined** (\`${error.declineCode}\`).`,
|
|
"",
|
|
"Update your payment method and Pullfrog will retry on the next run.",
|
|
"",
|
|
`[Update payment method →](${billingConsoleUrl(owner, "billing")})`,
|
|
].join("\n");
|
|
}
|
|
|
|
return [
|
|
"**Your Pullfrog balance is empty.**",
|
|
"",
|
|
"Top up your balance or enable auto-reload to keep runs flowing.",
|
|
"",
|
|
`[Manage billing →](${billingConsoleUrl(owner, "billing")})`,
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* Render a TransientError as user-facing markdown. Distinct framing from
|
|
* BillingError so the user doesn't read an alarm and assume their card
|
|
* failed — this branch is "our fault, retry shortly", not theirs.
|
|
*/
|
|
export function formatTransientErrorSummary(error: TransientError, owner: string): string {
|
|
return [
|
|
"**Pullfrog billing is temporarily unavailable.**",
|
|
"",
|
|
error.message,
|
|
"",
|
|
`Usually transient — the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com) or [your console](${billingConsoleUrl(owner, "billing")}).`,
|
|
].join("\n");
|
|
}
|