Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions migrations/postgres/0014_paid_overdraft.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions src/http/spending-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.", { reservationUsd: quote / 100000 });
}
const meter = newMeter();
const permit = result.funded ? new Permit(limits.paidRequest, Date.now() + 90000) : undefined;
Expand All @@ -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 });
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 12 additions & 6 deletions src/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/pricingui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function pricingHtml(signedIn = false) {
</tbody></table></div>
<p>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.</p>
<p>For example, 1 million input tokens with 50 Smart escalations cost <strong>$0.142</strong>.</p>
<p class="pricing-note">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.</p>
<p class="pricing-note">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.</p>
</section>
<section class="pricing-section" aria-label="Included on every plan"><h2>Included on every plan</h2>
<p>Fast and Smart classification with your own labels through REST, MCP or the CLI.</p>
Expand Down
65 changes: 64 additions & 1 deletion tests/spending.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<void>(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<string, { confidence: number }> };
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 });
});
Loading