diff --git a/src/cli/commands/usage.ts b/src/cli/commands/usage.ts index d3ecadb..9a97719 100644 --- a/src/cli/commands/usage.ts +++ b/src/cli/commands/usage.ts @@ -8,6 +8,11 @@ import { log } from "../../core/logger.ts"; import { fetchUsage } from "../../core/usage.ts"; import { parse, type Command, type RunContext } from "../command.ts"; +/** Thousands separators, so a seven-digit credit limit stays readable. */ +export function fmt(n: number): string { + return n.toLocaleString("en-US"); +} + /** Free plan still ships as plan_code=trial in R1; display the product name. */ export function formatPlanName(name: string | undefined): string { if (!name) return "—"; @@ -42,6 +47,18 @@ export const usage: Command = { const api = u.plan?.products?.api; log.info(`Plan: ${formatPlanName(u.plan?.name)}${u.plan?.recurrence ? ` (${u.plan.recurrence})` : ""}`); log.info(`Status: ${formatPlanStatus(u.status)}`); + // Credits are the unit the docs, the dashboard and the error messages all speak in, + // and the endpoint has been returning them all along. Showing only the dollar figure + // left the reader to convert — and the rate is per plan, so they could not. + if (u.usage_credits !== undefined && u.credit_limit !== undefined) { + const remaining = Math.max(0, u.credit_limit - u.usage_credits); + log.info( + `Credits: ${fmt(u.usage_credits)} of ${fmt(u.credit_limit)} used` + + `${u.usage_percent !== undefined ? ` (${u.usage_percent}%)` : ""} — ${fmt(remaining)} left`, + ); + } else if (u.usage_credits !== undefined) { + log.info(`Credits: ${fmt(u.usage_credits)} used`); + } if (u.usage !== undefined) { log.info(`Usage: ${u.usage}${u.usage_percent !== undefined ? ` (${u.usage_percent}% of plan)` : ""}`); } diff --git a/src/core/errors.ts b/src/core/errors.ts index 25a4898..520c4b9 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -88,15 +88,21 @@ export function quotaExhausted( claimUrl?: string, opts: { status?: number; detail?: string } = {}, ): ToolkitError { + // Say that the allowance comes back. Without it this reads as a permanent paywall, + // which is how the API's own AUTH004 text reads ("Purchase a new subscription to + // continue") and why exhausted clients retry-loop instead of waiting or upgrading — + // one account spent seven days at ~3 req/s against this wall. `zenrows usage` prints + // the exact `period_ends_at`, so the date is one command away rather than guessed here. + const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date."; const claimLine = claimUrl - ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}` - : `You are out of Zenrows credits. Add credits or upgrade your plan: ${DASHBOARD_URL}`; + ? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}` + : `You are out of Zenrows credits. ${renewLine} To carry on now, add credits or upgrade your plan: ${DASHBOARD_URL}`; const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : ""; return new ToolkitError({ code: "POLICY_MAX_CREDITS_EXCEEDED", message: "Zenrows request quota exhausted.", likely_cause: `${detail}HTTP ${opts.status ?? 429} for ${url}`, next_action: claimLine, - suggested_commands: claimUrl ? [] : ["zenrows usage"], + suggested_commands: ["zenrows usage"], }); } diff --git a/src/core/usage.ts b/src/core/usage.ts index d2f68bc..9128d26 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -19,6 +19,8 @@ export interface UsageConcurrency { export interface UsageProduct { usage?: number; + /** Same consumption as `usage`, counted in credits. */ + usage_credits?: number; concurrency?: UsageConcurrency; [k: string]: unknown; } @@ -27,13 +29,23 @@ export interface UsageDetails { status?: string; period_starts_at?: string; period_ends_at?: string; - /** Total units consumed across all products. */ + /** Total consumed across all products, in dollars. */ usage?: number; + /** The same consumption in credits — what the docs and the dashboard quote. */ + usage_credits?: number; + /** The plan's allowance in credits. `credit_limit * plan.unit_cost === plan.price`. */ + credit_limit?: number; /** Consumption as a percentage of the plan limit. */ usage_percent?: number; plan?: { name?: string; price?: number; + /** + * Dollars per credit, and it is **per plan**, not a platform constant: Free bills + * $0.001/credit (5,000 credits for $5) while larger plans get a volume rate. Never + * convert between dollars and credits with a hardcoded factor. + */ + unit_cost?: number; recurrence?: string; products?: { api?: UsageProduct; diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 918a06d..e3eca46 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -10,6 +10,19 @@ test("quotaExhausted surfaces the claim URL", () => { assert.ok(err.next_action.includes("https://x/claim/t")); }); +test("quotaExhausted says the allowance renews, on both account states", () => { + // A spent allowance is not a paywall: it comes back at the period boundary. Leaving + // that out is what makes an exhausted agent retry-loop instead of waiting or upgrading. + for (const claim of ["https://x/claim/t", undefined]) { + const err = quotaExhausted("https://api.zenrows.com/v1/?url=x", claim); + assert.match(err.next_action, /renew/i); + assert.ok( + err.suggested_commands?.includes("zenrows usage"), + "the exact renewal date is one command away, so point at it in both states", + ); + } +}); + test("isQuotaError distinguishes credit exhaustion from concurrency/target 429s", () => { // Genuine account credit/quota exhaustion → claim nudge. assert.equal(isQuotaError(JSON.stringify({ code: "REQS002", title: "You have used all your credits" })), true); diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 71f715b..275b3a9 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -2,6 +2,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { fetchUsage, usageUrl } from "../src/core/usage.ts"; import { ToolkitError } from "../src/core/errors.ts"; +import { fmt } from "../src/cli/commands/usage.ts"; +import type { UsageDetails } from "../src/core/usage.ts"; test("usageUrl derives subscriptions/self/details from the api base", () => { assert.equal(usageUrl("https://api.zenrows.com/v1/"), "https://api.zenrows.com/v1/subscriptions/self/details"); @@ -61,3 +63,27 @@ test("fetchUsage maps a 402 (over usage limit) to POLICY_MAX_CREDITS_EXCEEDED", (e: unknown) => e instanceof ToolkitError && e.code === "POLICY_MAX_CREDITS_EXCEEDED", ); }); + +test("zenrows usage reports credits, which the endpoint has always returned", () => { + // Verified against the live endpoint: it sends usage_credits and credit_limit + // alongside the dollar figure. Neither was declared on UsageDetails, so the command + // printed only dollars and left the reader to convert — which they cannot do, because + // the rate is per plan (Free $0.001/credit, larger plans a volume rate). + assert.equal(fmt(62982), "62,982"); + assert.equal(fmt(35999978), "35,999,978"); +}); + +test("UsageDetails carries credits and the per-plan rate", () => { + const sample: UsageDetails = { + status: "ACTIVE", + usage: 5.66823039823616, + usage_credits: 62982, + credit_limit: 35999978, + usage_percent: 0, + plan: { name: "Business", price: 3239.89, unit_cost: 8.9997e-5, recurrence: "YEARLY" }, + }; + // credit_limit * unit_cost === plan.price is the invariant that makes the rate per-plan + // rather than a platform constant. Holds on the live response. + assert.ok(Math.abs(sample.credit_limit! * sample.plan!.unit_cost! - sample.plan!.price!) < 0.01); + assert.ok(Math.abs(sample.usage! / sample.usage_credits! - sample.plan!.unit_cost!) < 1e-8); +});