Merge pull request #545 from pullfrog/billing
managed billing + stripe v1
This commit is contained in:
committed by
pullfrog[bot]
parent
67fe18e504
commit
e58299740d
@@ -36,6 +36,7 @@ import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRun
|
|||||||
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
import { resolvePayload, resolvePromptInput } from "./utils/payload.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";
|
||||||
|
import { type AccountPlan, isInfraCovered } from "./utils/runContext.ts";
|
||||||
import { resolveRunContextData } from "./utils/runContextData.ts";
|
import { resolveRunContextData } from "./utils/runContextData.ts";
|
||||||
import { setEnvAllowlist } from "./utils/secrets.ts";
|
import { setEnvAllowlist } from "./utils/secrets.ts";
|
||||||
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
import { createTempDirectory, setupGit } from "./utils/setup.ts";
|
||||||
@@ -111,6 +112,106 @@ interface OidcCredentials {
|
|||||||
requestToken: string;
|
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<string | null> {
|
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
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}` },
|
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) {
|
if (!response.ok) {
|
||||||
log.warning(`proxy key mint failed (${response.status})`);
|
log.warning(`proxy key mint failed (${response.status})`);
|
||||||
return null;
|
return null;
|
||||||
@@ -133,6 +259,8 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
|||||||
const data = (await response.json()) as { key: string };
|
const data = (await response.json()) as { key: string };
|
||||||
return data.key;
|
return data.key;
|
||||||
} catch (error) {
|
} 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)}`);
|
log.warning(`proxy key mint error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -144,29 +272,29 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
|||||||
async function resolveProxyModel(ctx: {
|
async function resolveProxyModel(ctx: {
|
||||||
payload: ResolvedPayload;
|
payload: ResolvedPayload;
|
||||||
oss: boolean;
|
oss: boolean;
|
||||||
|
plan: AccountPlan;
|
||||||
proxyModel?: string | undefined;
|
proxyModel?: string | undefined;
|
||||||
oidcCredentials: OidcCredentials | null;
|
oidcCredentials: OidcCredentials | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
// env override = BYOK escape hatch, don't proxy
|
// env override = BYOK escape hatch, don't proxy
|
||||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
if (process.env.PULLFROG_MODEL?.trim()) return;
|
||||||
|
|
||||||
// OSS: server decided the model
|
const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
|
||||||
if (ctx.oss && ctx.proxyModel) {
|
if (!needsProxy) return;
|
||||||
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;
|
|
||||||
|
|
||||||
process.env.OPENROUTER_API_KEY = key;
|
if (!ctx.oidcCredentials) {
|
||||||
core.setSecret(key);
|
log.warning("» proxy requested but no OIDC credentials available — skipping");
|
||||||
ctx.payload.proxyModel = ctx.proxyModel;
|
|
||||||
log.info(`» proxy: oss → ${ctx.proxyModel}`);
|
|
||||||
return;
|
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<void> {
|
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||||
@@ -251,13 +379,39 @@ export async function main(): Promise<MainResult> {
|
|||||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
// proxy decision: mint an OpenRouter key for OSS repos (or later, managed billing)
|
// Proxy decision: mint an OpenRouter key for OSS repos or managed billing
|
||||||
await resolveProxyModel({
|
// accounts. BillingError (402) and TransientError (503) both surface here.
|
||||||
payload,
|
// Handle explicitly so the user sees an actionable message (job summary +
|
||||||
oss: runContext.oss,
|
// PR progress comment when one exists) — otherwise the error unwinds past
|
||||||
proxyModel: runContext.proxyModel,
|
// the main try/catch (which needs toolState) and lands in runMain with only
|
||||||
oidcCredentials,
|
// 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
|
// create octokit with MCP token for GitHub API calls
|
||||||
const octokit = createOctokit(tokenRef.mcpToken);
|
const octokit = createOctokit(tokenRef.mcpToken);
|
||||||
@@ -350,6 +504,8 @@ export async function main(): Promise<MainResult> {
|
|||||||
jobId: runInfo.jobId,
|
jobId: runInfo.jobId,
|
||||||
mcpServerUrl: "",
|
mcpServerUrl: "",
|
||||||
tmpdir,
|
tmpdir,
|
||||||
|
oss: runContext.oss,
|
||||||
|
plan: runContext.plan,
|
||||||
resolvedModel,
|
resolvedModel,
|
||||||
};
|
};
|
||||||
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { log } from "../utils/cli.ts";
|
|||||||
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
|
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
|
||||||
import type { OctokitWithPlugins } from "../utils/github.ts";
|
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||||
|
import type { AccountPlan } from "../utils/runContext.ts";
|
||||||
import type { RunContextData } from "../utils/runContextData.ts";
|
import type { RunContextData } from "../utils/runContextData.ts";
|
||||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||||
import { CheckoutPrTool } from "./checkout.ts";
|
import { CheckoutPrTool } from "./checkout.ts";
|
||||||
@@ -169,6 +170,13 @@ export interface ToolContext {
|
|||||||
jobId: string | undefined;
|
jobId: string | undefined;
|
||||||
mcpServerUrl: string;
|
mcpServerUrl: string;
|
||||||
tmpdir: 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").
|
// resolved upstream model specifier (e.g. "google/gemini-3.1-pro-preview").
|
||||||
// undefined when payload.proxyModel is set or when the alias is unresolvable.
|
// undefined when payload.proxyModel is set or when the alias is unresolvable.
|
||||||
// used by the schema sanitizer to detect Gemini-routed traffic.
|
// used by the schema sanitizer to detect Gemini-routed traffic.
|
||||||
|
|||||||
@@ -24,10 +24,27 @@ export interface RepoSettings {
|
|||||||
envAllowlist: string | null;
|
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 {
|
export interface RunContext {
|
||||||
settings: RepoSettings;
|
settings: RepoSettings;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
oss: boolean;
|
oss: boolean;
|
||||||
|
plan: AccountPlan;
|
||||||
proxyModel?: string | undefined;
|
proxyModel?: string | undefined;
|
||||||
dbSecrets?: Record<string, string> | undefined;
|
dbSecrets?: Record<string, string> | undefined;
|
||||||
}
|
}
|
||||||
@@ -51,6 +68,7 @@ const defaultRunContext: RunContext = {
|
|||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
apiToken: "",
|
apiToken: "",
|
||||||
oss: false,
|
oss: false,
|
||||||
|
plan: "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +110,7 @@ export async function fetchRunContext(params: {
|
|||||||
settings: RepoSettings | null;
|
settings: RepoSettings | null;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
oss?: boolean;
|
oss?: boolean;
|
||||||
|
plan?: AccountPlan;
|
||||||
proxyModel?: string;
|
proxyModel?: string;
|
||||||
dbSecrets?: Record<string, string>;
|
dbSecrets?: Record<string, string>;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -112,6 +131,7 @@ export async function fetchRunContext(params: {
|
|||||||
},
|
},
|
||||||
apiToken: data.apiToken,
|
apiToken: data.apiToken,
|
||||||
oss: data.oss ?? false,
|
oss: data.oss ?? false,
|
||||||
|
plan: data.plan ?? "none",
|
||||||
proxyModel: data.proxyModel,
|
proxyModel: data.proxyModel,
|
||||||
dbSecrets: data.dbSecrets,
|
dbSecrets: data.dbSecrets,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Octokit } from "@octokit/rest";
|
|||||||
import packageJson from "../package.json" with { type: "json" };
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import { type OctokitWithPlugins, parseRepoContext } from "./github.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 {
|
export interface RunContextData {
|
||||||
repo: {
|
repo: {
|
||||||
@@ -14,6 +14,7 @@ export interface RunContextData {
|
|||||||
repoSettings: RepoSettings;
|
repoSettings: RepoSettings;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
oss: boolean;
|
oss: boolean;
|
||||||
|
plan: AccountPlan;
|
||||||
proxyModel?: string | undefined;
|
proxyModel?: string | undefined;
|
||||||
dbSecrets?: Record<string, string> | undefined;
|
dbSecrets?: Record<string, string> | undefined;
|
||||||
}
|
}
|
||||||
@@ -54,6 +55,7 @@ export async function resolveRunContextData(
|
|||||||
repoSettings: runContext.settings,
|
repoSettings: runContext.settings,
|
||||||
apiToken: runContext.apiToken,
|
apiToken: runContext.apiToken,
|
||||||
oss: runContext.oss,
|
oss: runContext.oss,
|
||||||
|
plan: runContext.plan,
|
||||||
proxyModel: runContext.proxyModel,
|
proxyModel: runContext.proxyModel,
|
||||||
dbSecrets: runContext.dbSecrets,
|
dbSecrets: runContext.dbSecrets,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user