Skip to content

Commit ccb69f3

Browse files
authored
Merge pull request #17 from pablomogamon/act/credits-renew-message
Report credits in zenrows usage, and say the allowance renews
2 parents 5e52585 + 5bf6d16 commit ccb69f3

6 files changed

Lines changed: 88 additions & 5 deletions

File tree

‎src/cli/commands/usage.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import { log } from "../../core/logger.ts";
88
import { fetchUsage } from "../../core/usage.ts";
99
import { parse, type Command, type RunContext } from "../command.ts";
1010

11+
/** Thousands separators, so a seven-digit credit limit stays readable. */
12+
export function fmt(n: number): string {
13+
return n.toLocaleString("en-US");
14+
}
15+
1116
/** Free plan still ships as plan_code=trial in R1; display the product name. */
1217
export function formatPlanName(name: string | undefined): string {
1318
if (!name) return "—";
@@ -42,6 +47,18 @@ export const usage: Command = {
4247
const api = u.plan?.products?.api;
4348
log.info(`Plan: ${formatPlanName(u.plan?.name)}${u.plan?.recurrence ? ` (${u.plan.recurrence})` : ""}`);
4449
log.info(`Status: ${formatPlanStatus(u.status)}`);
50+
// Credits are the unit the docs, the dashboard and the error messages all speak in,
51+
// and the endpoint has been returning them all along. Showing only the dollar figure
52+
// left the reader to convert — and the rate is per plan, so they could not.
53+
if (u.usage_credits !== undefined && u.credit_limit !== undefined) {
54+
const remaining = Math.max(0, u.credit_limit - u.usage_credits);
55+
log.info(
56+
`Credits: ${fmt(u.usage_credits)} of ${fmt(u.credit_limit)} used` +
57+
`${u.usage_percent !== undefined ? ` (${u.usage_percent}%)` : ""} — ${fmt(remaining)} left`,
58+
);
59+
} else if (u.usage_credits !== undefined) {
60+
log.info(`Credits: ${fmt(u.usage_credits)} used`);
61+
}
4562
if (u.usage !== undefined) {
4663
log.info(`Usage: ${u.usage}${u.usage_percent !== undefined ? ` (${u.usage_percent}% of plan)` : ""}`);
4764
}

‎src/core/errors.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* a `next_action`, and optional `suggested_commands`. Agents (and humans) can
66
* react to the code and follow the suggested command without guessing.
77
*/
8-
import { DASHBOARD_URL } from "./open-url.ts";
8+
import { BILLING_TOPUP_URL, PLANS_URL } from "./open-url.ts";
99

1010
export type ErrorCode =
1111
| "AUTH_MISSING"
@@ -88,15 +88,21 @@ export function quotaExhausted(
8888
claimUrl?: string,
8989
opts: { status?: number; detail?: string } = {},
9090
): ToolkitError {
91+
// Say that the allowance comes back. Without it this reads as a permanent paywall,
92+
// which is how the API's own AUTH004 text reads ("Purchase a new subscription to
93+
// continue") and why exhausted clients retry-loop instead of waiting or upgrading —
94+
// one account spent seven days at ~3 req/s against this wall. `zenrows usage` prints
95+
// the exact `period_ends_at`, so the date is one command away rather than guessed here.
96+
const renewLine = "Credits renew at the end of the billing period — run `zenrows usage` for the date.";
9197
const claimLine = claimUrl
92-
? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}`
93-
: `You are out of Zenrows credits. Add credits or upgrade your plan: ${DASHBOARD_URL}`;
98+
? `You are on the Zenrows Free plan. Claim your account to keep your usage and add credits: ${claimUrl}. ${renewLine}`
99+
: `You are out of Zenrows credits. ${renewLine} To carry on now, add a credit pack (${BILLING_TOPUP_URL} opens the purchase directly) or upgrade your plan: ${PLANS_URL}`;
94100
const detail = opts.detail ? `${opts.detail.replace(/\.\s*$/, "")}. ` : "";
95101
return new ToolkitError({
96102
code: "POLICY_MAX_CREDITS_EXCEEDED",
97103
message: "Zenrows request quota exhausted.",
98104
likely_cause: `${detail}HTTP ${opts.status ?? 429} for ${url}`,
99105
next_action: claimLine,
100-
suggested_commands: claimUrl ? [] : ["zenrows usage"],
106+
suggested_commands: ["zenrows usage"],
101107
});
102108
}

‎src/core/open-url.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export const AGENT_SIGNUP_API_URL = "https://app.zenrows.com/api/agent/signup";
1010
/** Well-known path advertising the agent-auth endpoints (signup discovery). */
1111
export const WELL_KNOWN_PROTECTED_RESOURCE = "/.well-known/oauth-protected-resource";
1212
export const DASHBOARD_URL = "https://app.zenrows.com/dashboard";
13+
/** Lands on Billing with the credit-pack purchase modal already open (app PR #1972). */
14+
export const BILLING_TOPUP_URL = "https://app.zenrows.com/billing?topup=open";
15+
export const PLANS_URL = "https://app.zenrows.com/plans";
1316
export const DOCS_URL = "https://docs.zenrows.com";
1417

1518
/** Append query params to a URL (used to attach anonymous `utm_*` attribution). */

‎src/core/usage.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface UsageConcurrency {
1919

2020
export interface UsageProduct {
2121
usage?: number;
22+
/** Same consumption as `usage`, counted in credits. */
23+
usage_credits?: number;
2224
concurrency?: UsageConcurrency;
2325
[k: string]: unknown;
2426
}
@@ -27,13 +29,23 @@ export interface UsageDetails {
2729
status?: string;
2830
period_starts_at?: string;
2931
period_ends_at?: string;
30-
/** Total units consumed across all products. */
32+
/** Total consumed across all products, in dollars. */
3133
usage?: number;
34+
/** The same consumption in credits — what the docs and the dashboard quote. */
35+
usage_credits?: number;
36+
/** The plan's allowance in credits. `credit_limit * plan.unit_cost === plan.price`. */
37+
credit_limit?: number;
3238
/** Consumption as a percentage of the plan limit. */
3339
usage_percent?: number;
3440
plan?: {
3541
name?: string;
3642
price?: number;
43+
/**
44+
* Dollars per credit, and it is **per plan**, not a platform constant: Free bills
45+
* $0.001/credit (5,000 credits for $5) while larger plans get a volume rate. Never
46+
* convert between dollars and credits with a hardcoded factor.
47+
*/
48+
unit_cost?: number;
3749
recurrence?: string;
3850
products?: {
3951
api?: UsageProduct;

‎tests/errors.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ test("quotaExhausted surfaces the claim URL", () => {
1010
assert.ok(err.next_action.includes("https://x/claim/t"));
1111
});
1212

13+
test("quotaExhausted says the allowance renews, on both account states", () => {
14+
// A spent allowance is not a paywall: it comes back at the period boundary. Leaving
15+
// that out is what makes an exhausted agent retry-loop instead of waiting or upgrading.
16+
for (const claim of ["https://x/claim/t", undefined]) {
17+
const err = quotaExhausted("https://api.zenrows.com/v1/?url=x", claim);
18+
assert.match(err.next_action, /renew/i);
19+
if (!claim) {
20+
// The route out is a deep link that lands with the purchase already open,
21+
// not a generic dashboard URL the reader has to navigate from.
22+
assert.ok(err.next_action.includes("https://app.zenrows.com/billing?topup=open"));
23+
assert.ok(err.next_action.includes("https://app.zenrows.com/plans"));
24+
}
25+
assert.ok(
26+
err.suggested_commands?.includes("zenrows usage"),
27+
"the exact renewal date is one command away, so point at it in both states",
28+
);
29+
}
30+
});
31+
1332
test("isQuotaError distinguishes credit exhaustion from concurrency/target 429s", () => {
1433
// Genuine account credit/quota exhaustion → claim nudge.
1534
assert.equal(isQuotaError(JSON.stringify({ code: "REQS002", title: "You have used all your credits" })), true);

‎tests/usage.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import { fetchUsage, usageUrl } from "../src/core/usage.ts";
44
import { ToolkitError } from "../src/core/errors.ts";
5+
import { fmt } from "../src/cli/commands/usage.ts";
6+
import type { UsageDetails } from "../src/core/usage.ts";
57

68
test("usageUrl derives subscriptions/self/details from the api base", () => {
79
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",
6163
(e: unknown) => e instanceof ToolkitError && e.code === "POLICY_MAX_CREDITS_EXCEEDED",
6264
);
6365
});
66+
67+
test("zenrows usage reports credits, which the endpoint has always returned", () => {
68+
// Verified against the live endpoint: it sends usage_credits and credit_limit
69+
// alongside the dollar figure. Neither was declared on UsageDetails, so the command
70+
// printed only dollars and left the reader to convert — which they cannot do, because
71+
// the rate is per plan (Free $0.001/credit, larger plans a volume rate).
72+
assert.equal(fmt(62982), "62,982");
73+
assert.equal(fmt(35999978), "35,999,978");
74+
});
75+
76+
test("UsageDetails carries credits and the per-plan rate", () => {
77+
const sample: UsageDetails = {
78+
status: "ACTIVE",
79+
usage: 5.66823039823616,
80+
usage_credits: 62982,
81+
credit_limit: 35999978,
82+
usage_percent: 0,
83+
plan: { name: "Business", price: 3239.89, unit_cost: 8.9997e-5, recurrence: "YEARLY" },
84+
};
85+
// credit_limit * unit_cost === plan.price is the invariant that makes the rate per-plan
86+
// rather than a platform constant. Holds on the live response.
87+
assert.ok(Math.abs(sample.credit_limit! * sample.plan!.unit_cost! - sample.plan!.price!) < 0.01);
88+
assert.ok(Math.abs(sample.usage! / sample.usage_credits! - sample.plan!.unit_cost!) < 1e-8);
89+
});

0 commit comments

Comments
 (0)