diff --git a/main.ts b/main.ts index d1a0c86..a673a8a 100644 --- a/main.ts +++ b/main.ts @@ -36,6 +36,7 @@ import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRun import { resolvePayload, resolvePromptInput } from "./utils/payload.ts"; import { postReviewCleanup } from "./utils/reviewCleanup.ts"; import { handleAgentResult } from "./utils/run.ts"; +import { type AccountPlan, isInfraCovered } from "./utils/runContext.ts"; import { resolveRunContextData } from "./utils/runContextData.ts"; import { setEnvAllowlist } from "./utils/secrets.ts"; import { createTempDirectory, setupGit } from "./utils/setup.ts"; @@ -111,6 +112,106 @@ interface OidcCredentials { requestToken: string; } +/** + * 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. + */ +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. + */ +class TransientError extends Error { + constructor(message: string) { + super(message); + this.name = "TransientError"; + } +} + +/** + * Render a BillingError as user-facing markdown (shared between GH job summary + * and the PR progress comment). Branches: + * + * - `router_requires_card`: the user is on Router mode with no card AND + * no wallet balance. Points at the add-card flow in the console. + * - `needsReauthentication` (Stripe `authentication_required` decline): the + * issuer requires a 3D Secure challenge on each off-session charge — + * re-adding the card won't help because the issuer's policy persists + * across credentials. The escape valve is a manual top-up from the + * dashboard, where 3DS runs interactively inside Stripe Checkout. + * - default: generic "manage billing" with the declineCode appended if + * classified (insufficient_funds, lost_card, generic_decline, etc). + */ +function formatBillingErrorSummary(error: BillingError): string { + if (error.code === "router_requires_card") { + return [ + "### ⛔ Pullfrog Router requires a card", + "", + "This run was going to use Pullfrog Router, which bills at raw OpenRouter cost and needs a card on file. Runs won't proceed until a card is added.", + "", + "[Add a card →](https://pullfrog.com/console#model-access) — your first $20 of Router usage is free.", + ].join("\n"); + } + + if (error.needsReauthentication) { + return [ + "### ❌ Pullfrog billing error — card requires 3DS on every charge", + "", + `Your card issuer requires a 3D Secure challenge on each off-session charge (\`${error.declineCode ?? "authentication_required"}\`), which we can't run from the agent. Top up your Router credit balance manually — 3DS runs interactively in Stripe Checkout, and subsequent runs draw from the prepaid balance without triggering another off-session charge.`, + "", + "[Top up your Router credit balance →](https://pullfrog.com/console)", + ].join("\n"); + } + + const codeSuffix = error.declineCode ? ` (\`${error.declineCode}\`)` : ""; + return `### ❌ Pullfrog billing error\n\n${error.message}${codeSuffix}\n\n[Manage billing →](https://pullfrog.com/console)`; +} + +/** + * Render a TransientError as user-facing markdown. Distinct framing from + * BillingError so the user doesn't read "❌" and assume their card failed. + */ +function formatTransientErrorSummary(error: TransientError): string { + return [ + "### ⚠️ Pullfrog temporarily unavailable", + "", + error.message, + "", + "This is typically transient — the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com).", + ].join("\n"); +} + async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise { try { process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl; @@ -125,6 +226,31 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise< headers: { Authorization: `Bearer ${oidcToken}` }, }); + if (response.status === 402) { + const body = (await response.json().catch(() => null)) as { + error?: string; + code?: string; + declineCode?: string; + needsReauthentication?: boolean; + } | null; + throw new BillingError(body?.error ?? "insufficient balance", { + code: body?.code ?? null, + declineCode: body?.declineCode ?? null, + needsReauthentication: body?.needsReauthentication ?? false, + }); + } + + // 503 = transient sync issue (partial OpenRouter failure, DB flake, + // in-flight top-up). Not the user's fault — TransientError renders a + // "temporarily unavailable" summary instead of the "billing error" + // label that BillingError uses. + if (response.status === 503) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + throw new TransientError( + body?.error ?? "billing service temporarily unavailable — retry shortly" + ); + } + if (!response.ok) { log.warning(`proxy key mint failed (${response.status})`); return null; @@ -133,6 +259,8 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise< const data = (await response.json()) as { key: string }; return data.key; } catch (error) { + if (error instanceof BillingError) throw error; + if (error instanceof TransientError) throw error; log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`); return null; } finally { @@ -144,29 +272,29 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise< async function resolveProxyModel(ctx: { payload: ResolvedPayload; oss: boolean; + plan: AccountPlan; proxyModel?: string | undefined; oidcCredentials: OidcCredentials | null; }): Promise { // env override = BYOK escape hatch, don't proxy if (process.env.PULLFROG_MODEL?.trim()) return; - // OSS: server decided the model - if (ctx.oss && ctx.proxyModel) { - if (!ctx.oidcCredentials) { - log.warning("» oss repo but no OIDC credentials available — skipping proxy"); - return; - } - const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials }); - if (!key) return; + const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel; + if (!needsProxy) return; - process.env.OPENROUTER_API_KEY = key; - core.setSecret(key); - ctx.payload.proxyModel = ctx.proxyModel; - log.info(`» proxy: oss → ${ctx.proxyModel}`); + if (!ctx.oidcCredentials) { + log.warning("» proxy requested but no OIDC credentials available — skipping"); return; } - // managed billing will add its path here later + const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials }); + if (!key) return; + + process.env.OPENROUTER_API_KEY = key; + core.setSecret(key); + ctx.payload.proxyModel = ctx.proxyModel; + const label = ctx.oss ? "oss" : "router"; + log.info(`» proxy: ${label} → ${ctx.proxyModel}`); } async function writeJobSummary(toolState: ToolState): Promise { @@ -251,13 +379,39 @@ export async function main(): Promise { delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; } - // proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing) - await resolveProxyModel({ - payload, - oss: runContext.oss, - proxyModel: runContext.proxyModel, - oidcCredentials, - }); + // Proxy decision: mint an OpenRouter key for OSS repos or managed billing + // accounts. BillingError (402) and TransientError (503) both surface here. + // Handle explicitly so the user sees an actionable message (job summary + + // PR progress comment when one exists) — otherwise the error unwinds past + // the main try/catch (which needs toolState) and lands in runMain with only + // a generic core.setFailed. + try { + await resolveProxyModel({ + payload, + oss: runContext.oss, + plan: runContext.plan, + proxyModel: runContext.proxyModel, + oidcCredentials, + }); + } catch (error) { + if (error instanceof BillingError) { + const summary = formatBillingErrorSummary(error); + await writeSummary(summary).catch(() => {}); + // Mirror to the PR progress comment if the trigger created one + // (mention / PR event). Without this, auto-reload declines are only + // visible in the job summary — users rarely open that, so the agent + // just appears to silently stop mid-run. + await reportErrorToComment({ toolState, error: summary }).catch(() => {}); + throw error; + } + if (error instanceof TransientError) { + const summary = formatTransientErrorSummary(error); + await writeSummary(summary).catch(() => {}); + await reportErrorToComment({ toolState, error: summary }).catch(() => {}); + throw error; + } + throw error; + } // create octokit with MCP token for GitHub API calls const octokit = createOctokit(tokenRef.mcpToken); @@ -350,6 +504,8 @@ export async function main(): Promise { jobId: runInfo.jobId, mcpServerUrl: "", tmpdir, + oss: runContext.oss, + plan: runContext.plan, resolvedModel, }; await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema }); diff --git a/mcp/server.ts b/mcp/server.ts index e8eecd8..f29979c 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -12,6 +12,7 @@ import { log } from "../utils/cli.ts"; import type { DiffCoverageState } from "../utils/diffCoverage.ts"; import type { OctokitWithPlugins } from "../utils/github.ts"; import type { ResolvedPayload } from "../utils/payload.ts"; +import type { AccountPlan } from "../utils/runContext.ts"; import type { RunContextData } from "../utils/runContextData.ts"; import type { TodoTracker } from "../utils/todoTracking.ts"; import { CheckoutPrTool } from "./checkout.ts"; @@ -169,6 +170,13 @@ export interface ToolContext { jobId: string | undefined; mcpServerUrl: string; tmpdir: string; + // repo-level OSS flag + account-level billing plan. together they decide + // whether pullfrog is paying for marginal infra — see isInfraCovered in + // utils/runContext.ts. plan gating for things like update_learnings is + // enforced server-side via 402, so we pass plan along mostly for future + // use / observability. see wiki/pricing.md. + oss: boolean; + plan: AccountPlan; // resolved upstream model specifier (e.g. "google/gemini-3.1-pro-preview"). // undefined when payload.proxyModel is set or when the alias is unresolvable. // used by the schema sanitizer to detect Gemini-routed traffic. diff --git a/utils/runContext.ts b/utils/runContext.ts index 94c9924..5c7f747 100644 --- a/utils/runContext.ts +++ b/utils/runContext.ts @@ -24,10 +24,27 @@ export interface RepoSettings { envAllowlist: string | null; } +/** + * Account-level billing plan. Orthogonal to repo-level OSS status. Mirrors + * the server's `AccountPlan` in `utils/billing.ts`. `"none"` = free tier, + * `"payg"` = card on file / pay-as-you-go. + */ +export type AccountPlan = "none" | "payg"; + +/** + * "Is Pullfrog absorbing marginal infra cost for this repo?" — composite + * predicate over the two orthogonal dimensions (repo-level OSS, account-level + * plan). Mirrors `isInfraCovered` in the server's `utils/billing.ts`. + */ +export function isInfraCovered(params: { isOss: boolean; plan: AccountPlan }): boolean { + return params.isOss || params.plan === "payg"; +} + export interface RunContext { settings: RepoSettings; apiToken: string; oss: boolean; + plan: AccountPlan; proxyModel?: string | undefined; dbSecrets?: Record | undefined; } @@ -51,6 +68,7 @@ const defaultRunContext: RunContext = { settings: defaultSettings, apiToken: "", oss: false, + plan: "none", }; /** @@ -92,6 +110,7 @@ export async function fetchRunContext(params: { settings: RepoSettings | null; apiToken: string; oss?: boolean; + plan?: AccountPlan; proxyModel?: string; dbSecrets?: Record; } | null; @@ -112,6 +131,7 @@ export async function fetchRunContext(params: { }, apiToken: data.apiToken, oss: data.oss ?? false, + plan: data.plan ?? "none", proxyModel: data.proxyModel, dbSecrets: data.dbSecrets, }; diff --git a/utils/runContextData.ts b/utils/runContextData.ts index 921d5a5..d9b68ce 100644 --- a/utils/runContextData.ts +++ b/utils/runContextData.ts @@ -3,7 +3,7 @@ import type { Octokit } from "@octokit/rest"; import packageJson from "../package.json" with { type: "json" }; import { log } from "./cli.ts"; import { type OctokitWithPlugins, parseRepoContext } from "./github.ts"; -import { fetchRunContext, type RepoSettings } from "./runContext.ts"; +import { type AccountPlan, fetchRunContext, type RepoSettings } from "./runContext.ts"; export interface RunContextData { repo: { @@ -14,6 +14,7 @@ export interface RunContextData { repoSettings: RepoSettings; apiToken: string; oss: boolean; + plan: AccountPlan; proxyModel?: string | undefined; dbSecrets?: Record | undefined; } @@ -54,6 +55,7 @@ export async function resolveRunContextData( repoSettings: runContext.settings, apiToken: runContext.apiToken, oss: runContext.oss, + plan: runContext.plan, proxyModel: runContext.proxyModel, dbSecrets: runContext.dbSecrets, };