From 4a927012e1fd6c9cb5246eaf04a595d27d341733 Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Tue, 22 Sep 2026 10:49:52 -0700 Subject: [PATCH 1/2] Allow bounded paid overdrafts and preserve completed Smart results --- migrations/postgres/0014_paid_overdraft.sql | 2 + src/http/spending-classification.ts | 6 +- src/index.ts | 2 +- src/pages.ts | 18 ++++-- src/pricingui.ts | 2 +- tests/spending.e2e.test.ts | 65 ++++++++++++++++++++- 6 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 migrations/postgres/0014_paid_overdraft.sql diff --git a/migrations/postgres/0014_paid_overdraft.sql b/migrations/postgres/0014_paid_overdraft.sql new file mode 100644 index 0000000..ff27668 --- /dev/null +++ b/migrations/postgres/0014_paid_overdraft.sql @@ -0,0 +1,2 @@ +ALTER TABLE app_accounts DROP CONSTRAINT app_accounts_balance_check; +ALTER TABLE app_accounts DROP CONSTRAINT app_accounts_paid_balance_check; diff --git a/src/http/spending-classification.ts b/src/http/spending-classification.ts index 3d204f5..ea41d2d 100644 --- a/src/http/spending-classification.ts +++ b/src/http/spending-classification.ts @@ -38,7 +38,7 @@ export async function spendingClassification(request: Request, env: AppEnv & Par INSERT INTO app_usage(id,account_id,agent_id,items,credits,status,created_at,paid_credits,usage_type,metering_mode,idempotency_key,classifications) SELECT ?,id,agent_id,?,?::bigint,'pending',?, GREATEST(0,?::bigint-(balance-paid_balance)),?,'tokens',?,? - FROM owner WHERE balance>0 AND balance>=? ON CONFLICT DO NOTHING RETURNING * + FROM owner WHERE balance>0 AND (funded OR balance>=?) ON CONFLICT DO NOTHING RETURNING * ), debited AS ( UPDATE app_accounts a SET balance=a.balance-h.credits,paid_balance=a.paid_balance-h.paid_credits FROM held h WHERE a.id=h.account_id RETURNING a.id @@ -55,7 +55,7 @@ export async function spendingClassification(request: Request, env: AppEnv & Par if (!reason) throw new SpendingError(401, "invalid_api_key", "This workspace API key is not valid."); if (!["pending", "connected"].includes(reason.status)) throw new SpendingError(403, "inactive_api_key", "This workspace API key is paused or revoked."); if (reason.request_id) throw new SpendingError(409, "duplicate_request", "This workspace already admitted the idempotency key.", { requestId: reason.request_id }); - throw new SpendingError(402, "insufficient_balance", reason.billing_hold ? "Workspace billing is awaiting review; funds remain held." : "The workspace needs enough available balance for the maximum input tokens and possible Smart escalations. Unused funds are released after the request.", { requiredUsd: quote / 100000 }); + throw new SpendingError(402, "insufficient_balance", reason.billing_hold ? "Workspace billing is awaiting review; funds remain held." : "The workspace cannot fund a new request. Add funds to clear any debt or cover the free-credit reservation, or wait for in-flight reservations to settle.", { requiredUsd: quote / 100000 }); } const meter = newMeter(); const permit = result.funded ? new Permit(limits.paidRequest, Date.now() + 90000) : undefined; @@ -64,7 +64,7 @@ export async function spendingClassification(request: Request, env: AppEnv & Par let response: Response; try { response = await worker.fetch(request, env as Env, ctx, { meter, account: { id: result.account_id, multiplier: result.funded ? 10 : 1 }, funded: result.funded }); - if (permit?.error) response = errorResponse(permit.error); + if (permit?.error && !(response.ok && permit.error.code === "request_spending_limit")) response = errorResponse(permit.error); } catch (error) { response = error instanceof SpendingError ? errorResponse(error) : Response.json({ error: "Classification failed." }, { status: 502 }); } diff --git a/src/index.ts b/src/index.ts index 6727c68..6ffcf64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2133,7 +2133,7 @@ const worker = { escalationFailed = r.escalationFailed; fallbackDecisions = r.fallbackDecisions; } else ({ results, escalationFailed } = await classifyMany(env, inputs, labels, tier, instructions, multi, meter, layaPlan, layaTiming, layaRun)); - if (meter.permit?.error) throw meter.permit.error; + if (meter.permit?.error && !(execution?.funded && meter.permit.error.code === "request_spending_limit")) throw meter.permit.error; } catch (e) { const spending = e instanceof SpendingError ? e : meter.permit?.error; if (spending) { diff --git a/src/pages.ts b/src/pages.ts index 17a9b40..6bf4fa5 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -456,13 +456,18 @@ PRO Billing https://classifier.dev/app/plans Usage is charged to the workspace balance at the published input-token and escalation prices. - Smart costs the same as Fast when no escalation is needed. Usage stops when - the balance reaches zero; there are no automatic top-ups. + Smart costs the same as Fast when no escalation is needed. A paid request + admitted with a positive available balance can finish and leave a negative + balance. New requests stop at zero or below until funds are added. There are + no automatic top-ups. Funded workspaces skip the shared free pool and proxy checks. Each request - has a default $10 provider-cost ceiling, bounded by the available workspace - balance at the published customer prices. Unused reservations are released - after inference; missing input-token measurements remain held for billing review. + has a default $10 provider-cost ceiling. Its maximum customer charge is held + atomically before inference, so concurrent keys cannot repeatedly overdraw. + Unused reservations are released after inference; missing input-token + measurements remain held for billing review. If further Smart reviews cannot + fit the provider ceiling, completed classifications are returned with + escalation_failed; only successful reviews are billed. ENTERPRISE @@ -497,7 +502,8 @@ USAGE PRICES Output tokens are free. Input usage includes text, labels and instructions processed by the base classifier. Retries, fallback routing and Smart model - tokens add no separate charges. Usage stops when your balance runs out. + tokens add no separate charges. Paid requests already admitted can finish and + leave a negative balance. New requests require a positive available balance. INCLUDED ON EVERY PLAN diff --git a/src/pricingui.ts b/src/pricingui.ts index 05de578..69dec40 100644 --- a/src/pricingui.ts +++ b/src/pricingui.ts @@ -67,7 +67,7 @@ export function pricingHtml(signedIn = false) {

Smart starts with Fast and reviews uncertain answers. You pay the extra charge only for answers that are successfully reviewed. No escalation means no extra charge.

For example, 1 million input tokens with 50 Smart escalations cost $0.142.

-

Output tokens are free. Input usage includes the text, labels and instructions processed by the base classifier. Retries, fallback routing and Smart model tokens add no separate charges. Usage stops when your balance runs out; no automatic top-ups.

+

Output tokens are free. Input usage includes the text, labels and instructions processed by the base classifier. Retries, fallback routing and Smart model tokens add no separate charges. Paid requests already admitted can finish and leave a negative balance. New requests require a positive available balance. No automatic top-ups.

Included on every plan

Fast and Smart classification with your own labels through REST, MCP or the CLI.

diff --git a/tests/spending.e2e.test.ts b/tests/spending.e2e.test.ts index cf4eb10..921e9dc 100644 --- a/tests/spending.e2e.test.ts +++ b/tests/spending.e2e.test.ts @@ -317,7 +317,7 @@ test("Smart bills only successful escalations at the fixed price, independent of const s = setup(); const env = { ...s.env, APP_DB: database(), APP_ACCOUNTS_ENABLED: "true", API_KEY_ENCRYPTION_KEY: "test-only-key-encryption-secret-32-characters" } as Env & AppEnv; await provisionTestAccount(new Request("http://localhost/auth/demo", { headers: { origin: "http://localhost" } }), env); - await env.APP_DB.prepare("UPDATE app_accounts SET paid_balance=balance WHERE id='local-demo'").run(); + await env.APP_DB.prepare("UPDATE app_accounts SET balance=1,paid_balance=1 WHERE id='local-demo'").run(); const key = await performAction("local-demo", { type: "enroll", client: "Codex" }, env); globalThis.fetch = (async (_url, init) => { const b = JSON.parse(String(init?.body)); @@ -332,6 +332,7 @@ test("Smart bills only successful escalations at the fixed price, independent of expect((await response!.json() as { usage: { escalated: number } }).usage.escalated).toBe(1); await s.flush(); expect(await env.APP_DB.prepare("SELECT credits::integer AS credits,actual_nano::text AS nano,metering_mode FROM app_usage WHERE id=?").bind(response!.headers.get("x-request-id")).first()).toEqual({ credits: 238, nano: "2378000", metering_mode: "tokens" }); + expect(await env.APP_DB.prepare("SELECT balance::integer AS balance FROM app_accounts WHERE id='local-demo'").first()).toEqual({ balance: -237 }); }); test("failed Smart reviews charge only base input and a failed request returns its entire hold", async () => { @@ -413,3 +414,65 @@ test("a Smart request that exhausts its permit records the final 402, not a succ expect(points.filter(p => p.blobs[3] === "200")).toHaveLength(0); expect(points.some(p => p.blobs[3] === "402" && p.blobs.includes("request_spending_limit"))).toBe(true); }); + +test("paid requests can overdraw once, settle delivered results, and resume only after funding", async () => { + const s = setup(); + const env = { ...s.env, APP_DB: database(), APP_ACCOUNTS_ENABLED: "true", API_KEY_ENCRYPTION_KEY: "test-only-key-encryption-secret-32-characters" } as Env & AppEnv; + await provisionTestAccount(new Request("http://localhost/auth/demo", { headers: { origin: "http://localhost" } }), env); + await env.APP_DB.prepare("UPDATE app_accounts SET balance=1,paid_balance=1 WHERE id='local-demo'").run(); + const keys = await Promise.all(["Codex", "Claude"].map(client => performAction("local-demo", { type: "enroll", client }, env))); + let release!: () => void; + const barrier = new Promise(resolve => { release = resolve; }); + const calls = providers(() => barrier); + const running = keys.map(key => accountClassification(request(undefined, undefined, undefined, { authorization: `Bearer ${key.secret}` }), env, "API", s.ctx)); + await new Promise(resolve => setTimeout(resolve, 100)); + release(); + const responses = await Promise.all(running); + expect(calls).toHaveLength(1); + expect(responses.map(r => r?.status).sort()).toEqual([200, 402]); + await s.flush(); + // 100 input tokens cost less than one credit; this consumes the final credit. + expect(await env.APP_DB.prepare("SELECT balance::integer AS balance FROM app_accounts WHERE id='local-demo'").first()).toEqual({ balance: 0 }); + await env.APP_DB.prepare("UPDATE app_accounts SET balance=1,paid_balance=1,fractional_spend_nano=0 WHERE id='local-demo'").run(); + const original = providers(); const primary = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + const response = await primary(url, init); + const body = await response.json() as { usage: { input_tokens: number } }; + body.usage.input_tokens = 60000; + return Response.json(body); + }) as typeof fetch; + const headers = { authorization: `Bearer ${keys[0].secret}` }; + const delivered = await accountClassification(request(undefined, undefined, undefined, headers), env, "API", s.ctx); + expect(delivered?.status).toBe(200); + await s.flush(); + expect(await env.APP_DB.prepare("SELECT balance::integer AS balance,paid_balance::integer AS paid FROM app_accounts WHERE id='local-demo'").first()).toEqual({ balance: -251, paid: -251 }); + expect(await env.APP_DB.prepare("SELECT status,credits::integer AS credits FROM app_usage WHERE id=?").bind(delivered!.headers.get("x-request-id")).first()).toEqual({ status: "completed", credits: 252 }); + expect((await accountClassification(request(undefined, undefined, undefined, headers), env, "API", s.ctx))?.status).toBe(402); + expect(original).toHaveLength(1); + await env.APP_DB.prepare("UPDATE app_accounts SET balance=balance+1000,paid_balance=paid_balance+1000 WHERE id='local-demo'").run(); + expect((await accountClassification(request(undefined, undefined, undefined, headers), env, "API", s.ctx))?.status).toBe(200); + await s.flush(); +}); + +test("paid Smart preserves and bills completed base results when no more reviews fit", async () => { + const s = setup({ PAID_REQUEST_USD: "0.006" }); + const env = { ...s.env, APP_DB: database(), APP_ACCOUNTS_ENABLED: "true", API_KEY_ENCRYPTION_KEY: "test-only-key-encryption-secret-32-characters" } as Env & AppEnv; + await provisionTestAccount(new Request("http://localhost/auth/demo", { headers: { origin: "http://localhost" } }), env); + await env.APP_DB.prepare("UPDATE app_accounts SET balance=1,paid_balance=1 WHERE id='local-demo'").run(); + const key = await performAction("local-demo", { type: "enroll", client: "Codex" }, env); + const calls = providers(); const primary = globalThis.fetch; + globalThis.fetch = (async (url, init) => { + const response = await primary(url, init); + const body = await response.json() as { usage: { input_tokens: number }; answers: Record }; + body.usage.input_tokens = 60000; + for (const answer of Object.values(body.answers)) answer.confidence = 0.51; + return Response.json(body); + }) as typeof fetch; + const response = await accountClassification(request(undefined, undefined, { input: "Invoice", labels: ["billing", "support"], tier: "smart" }, { authorization: `Bearer ${key.secret}` }), env, "API", s.ctx); + expect(response?.status).toBe(200); + expect(await response!.json()).toMatchObject({ results: [{ label: "billing", confidence: 0.51 }], usage: { escalated: 0, escalation_failed: 1 } }); + expect(response!.headers.get("x-usage-cost-usd")).toBe("0.002520000"); + await s.flush(); + expect(calls).toHaveLength(1); + expect(await env.APP_DB.prepare("SELECT balance::integer AS balance FROM app_accounts WHERE id='local-demo'").first()).toEqual({ balance: -251 }); +}); From 54f19cd21aa9bfba954ebddcef5c3f9590e062e2 Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Tue, 22 Sep 2026 10:50:37 -0700 Subject: [PATCH 2/2] Describe the hold without implying prepaid coverage is required --- src/http/spending-classification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/spending-classification.ts b/src/http/spending-classification.ts index ea41d2d..91004ad 100644 --- a/src/http/spending-classification.ts +++ b/src/http/spending-classification.ts @@ -55,7 +55,7 @@ export async function spendingClassification(request: Request, env: AppEnv & Par if (!reason) throw new SpendingError(401, "invalid_api_key", "This workspace API key is not valid."); if (!["pending", "connected"].includes(reason.status)) throw new SpendingError(403, "inactive_api_key", "This workspace API key is paused or revoked."); if (reason.request_id) throw new SpendingError(409, "duplicate_request", "This workspace already admitted the idempotency key.", { requestId: reason.request_id }); - throw new SpendingError(402, "insufficient_balance", reason.billing_hold ? "Workspace billing is awaiting review; funds remain held." : "The workspace cannot fund a new request. Add funds to clear any debt or cover the free-credit reservation, or wait for in-flight reservations to settle.", { requiredUsd: quote / 100000 }); + throw new SpendingError(402, "insufficient_balance", reason.billing_hold ? "Workspace billing is awaiting review; funds remain held." : "The workspace cannot fund a new request. Add funds to clear any debt or cover the free-credit reservation, or wait for in-flight reservations to settle.", { reservationUsd: quote / 100000 }); } const meter = newMeter(); const permit = result.funded ? new Permit(limits.paidRequest, Date.now() + 90000) : undefined;