@timo972/cc-router 0.13.0-rc.3 → 0.13.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Changed
12
+
13
+ - `cc-router usage` charts and its daily grid switch between tokens and
14
+ estimated API spend (`s`) and narrow to input or output (`i`); every bucket,
15
+ model series and day in the report now carries frozen-rate USD per token
16
+ category. The model view (`l`) lists input, output, cache and spend per
17
+ model. Input, output and cache figures share one colour each across the
18
+ header, the cost line and the model view.
19
+ - The usage view fills the terminal like `cc-router status`, is indented one
20
+ column, and quits on `Esc`. Chart columns scale to the available width and
21
+ the chart shares the daily grid's right edge; the axis names its unit
22
+ instead of running day numbers together. Warnings sit below the grid.
23
+ - Subscription cost and net savings are no longer displayed in the usage
24
+ dashboard or the plain-text summary; the API-equivalent value stands alone,
25
+ broken down by input, output and cache. Both remain in `--json` output and
26
+ `usage subscription` still records costs. A client talking to an older
27
+ router shows the per-category spend as unavailable rather than zero.
28
+
11
29
  ### ⚠️ Breaking
12
30
 
13
31
  - `cc-router accounts add-openai`, `login-openai`, `add-grok` and `login-grok`
@@ -35,6 +53,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
35
53
  - `POST /cc-router/accounts/:id/refresh` refreshes one account. The
36
54
  dashboard's `R` uses it when an account is selected and reloads the whole
37
55
  pool otherwise.
56
+ - Dashboard `rst` column and `Ctrl+R` now cover Claude accounts: banked
57
+ usage-limit resets (e.g. the Opus 5.5 launch reset) are shown and can be
58
+ redeemed after confirmation. Claude usage polls now identify as Claude Code,
59
+ which Anthropic requires before offering resets. The redemption endpoint is
60
+ undocumented and may change.
38
61
 
39
62
  ### Fixed
40
63
 
package/README.md CHANGED
@@ -109,12 +109,14 @@ it later. Adding more accounts is `cc-router accounts login`, re-signing one in
109
109
  Per-platform token extraction, Codex CLI, Docker and everything else lives in
110
110
  [the docs](docs/).
111
111
 
112
- ## Usage history and savings
112
+ ## Usage history and API-equivalent cost
113
113
 
114
114
  Run `cc-router usage` for persistent token history, provider/model stacked bars,
115
- and a daily activity grid. Switch between day, week, month and year; history
116
- survives service restarts. Configure your monthly subscription costs to compare
117
- them with the estimated standard API token value:
115
+ and a daily activity grid, each switchable between tokens and what the recorded
116
+ tokens would have cost at standard API rates, and narrowable to input or output.
117
+ Switch between day, week, month and year; history survives service restarts.
118
+ Monthly subscription costs can be recorded for comparison in the `--json`
119
+ report:
118
120
 
119
121
  ```bash
120
122
  cc-router usage subscription set personal --monthly-usd 100 --from 2026-09-01
@@ -46,15 +46,16 @@ export function formatUsageText(report) {
46
46
  const money = (n) => n === null ? "unavailable" : `${n < 0 ? "-" : ""}$${Math.abs(n).toFixed(2)}`;
47
47
  return [
48
48
  `Usage ${report.period}: ${report.start.slice(0, 10)} — ${report.end.slice(0, 10)} (UTC, end exclusive)`,
49
+ // Same four categories the dashboard shows, so the two surfaces read alike.
49
50
  `Tokens: ${totalTokens(t).toLocaleString("en-US")} | input ${t.input} | output ${t.output} | cache read ${t.cacheRead} | cache write ${t.cacheWrite}`,
51
+ // Subscription cost and net savings are hidden for now; the API-equivalent
52
+ // value is the figure that stands on its own. Both remain in --json.
50
53
  `API equivalent: ${money(c.pricedApiUsd)}${c.coverage.pricingComplete ? "" : " (partial; unpriced tokens excluded)"}`,
51
- `Subscription: ${money(c.subscriptionUsd)}${c.coverage.subscriptionComplete ? "" : " (partial; configure missing costs)"}`,
52
- `Net savings: ${money(c.savingsUsd)}${c.savingsPercent === null ? "" : ` (${c.savingsPercent.toFixed(1)}%)`}`,
53
54
  ...report.warnings.map(w => `Warning: ${w}`),
54
55
  ].join("\n");
55
56
  }
56
57
  export function registerUsage(program) {
57
- const usage = program.command("usage").description("Persistent token usage and API-equivalent subscription savings")
58
+ const usage = program.command("usage").description("Persistent token usage and its API-equivalent cost")
58
59
  .option("--period <period>", "day, week, month or year", "month")
59
60
  .option("--date <date>", "Date within the selected period (YYYY-MM-DD, UTC)")
60
61
  .option("--provider <provider>", "Filter Claude, OpenAI or Grok (repeatable)", (value, previous = []) => [...previous, value])
package/dist/cli/index.js CHANGED
@@ -31,7 +31,7 @@ Examples:
31
31
  $ cc-router start --reconfigure# Re-ask run preferences
32
32
  $ cc-router stop # Stop proxy (offers to remove auto-start / config)
33
33
  $ cc-router status # Live dashboard with account stats
34
- $ cc-router usage # Persistent token usage and subscription savings
34
+ $ cc-router usage # Persistent token usage and its API-equivalent cost
35
35
  $ cc-router models list # List dynamically discovered provider models
36
36
  $ cc-router logs # View proxy logs (background mode)
37
37
  $ cc-router accounts list # Show Claude, ChatGPT, and Grok accounts
@@ -0,0 +1,58 @@
1
+ import { ResetNotSubmittedError, RESET_OUTCOME_UNKNOWN } from "../../proxy/reset-errors.js";
2
+ import { CLAUDE_CODE_USER_AGENT, OAUTH_BETA_HEADER } from "./usage.js";
3
+ const CODES = ["reset", "already_used", "not_limited", "cooldown", "ineligible"];
4
+ // Claude Code maps these to "Couldn't confirm the reset went through" and keeps
5
+ // the claim for a same-id retry; so do we, whatever `result` says.
6
+ const UNCONFIRMED_REASONS = ["reset_unconfirmed", "stamp_indeterminate"];
7
+ const ORG_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
9
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
10
+ export async function consumeClaudeLimitReset(account, orgUuid, grantId, requestId, options = {}) {
11
+ // Contract: Claude Code 2.1.280 `/limit-reset` (program "cedar_ember").
12
+ // Undocumented; reverse-engineered from the CLI bundle on 2026-09-23.
13
+ if (!ORG_UUID.test(orgUuid) || !GRANT_ID.test(grantId) || !REQUEST_ID.test(requestId)) {
14
+ throw new ResetNotSubmittedError(503, "Reset request malformed; reset not submitted");
15
+ }
16
+ const request = options.fetch ?? globalThis.fetch;
17
+ // Never retry a spend with a fresh ID: a lost response may have consumed it.
18
+ try {
19
+ const response = await request(`https://api.anthropic.com/api/organizations/${orgUuid}/reset_rate_limits`, {
20
+ method: "POST",
21
+ headers: {
22
+ authorization: `Bearer ${account.tokens.accessToken}`,
23
+ "anthropic-beta": OAUTH_BETA_HEADER,
24
+ "user-agent": CLAUDE_CODE_USER_AGENT,
25
+ "content-type": "application/json",
26
+ },
27
+ body: JSON.stringify({ program: "cedar_ember", grant_id: grantId, request_id: requestId }),
28
+ signal: AbortSignal.timeout(10_000),
29
+ redirect: "error",
30
+ });
31
+ if (response.status === 401 || response.status === 403) {
32
+ throw new ResetNotSubmittedError(503, "Account credentials rejected; reset not submitted");
33
+ }
34
+ if (response.ok) {
35
+ const body = await response.json();
36
+ if (body && typeof body === "object" && "result" in body) {
37
+ const code = body.result;
38
+ const left = body.resets_left;
39
+ const reason = body.reason;
40
+ // `unavailable` is not in CODES: an unconfirmed claim is never "nothing used".
41
+ if (typeof reason === "string" && UNCONFIRMED_REASONS.includes(reason))
42
+ throw new Error(RESET_OUTCOME_UNKNOWN);
43
+ if (CODES.includes(code)) {
44
+ return {
45
+ code: code,
46
+ ...(typeof left === "number" && Number.isInteger(left) && left >= 0 ? { resetsLeft: left } : {}),
47
+ };
48
+ }
49
+ }
50
+ }
51
+ }
52
+ catch (error) {
53
+ if (error instanceof ResetNotSubmittedError)
54
+ throw error;
55
+ // Do not relay upstream bodies, credentials, or network error details.
56
+ }
57
+ throw new Error(RESET_OUTCOME_UNKNOWN);
58
+ }
@@ -1,6 +1,9 @@
1
1
  import { nextEventSequence } from "../../proxy/event-sequence.js";
2
- const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
3
- const OAUTH_BETA_HEADER = "oauth-2025-04-20";
2
+ const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1";
3
+ export const OAUTH_BETA_HEADER = "oauth-2025-04-20";
4
+ /** Reset status is only offered to the Claude Code surface; bump when the server answers `cli_version`. */
5
+ export const CLAUDE_CODE_UA_VERSION = "2.1.280";
6
+ export const CLAUDE_CODE_USER_AGENT = `claude-cli/${CLAUDE_CODE_UA_VERSION} (external, cli)`;
4
7
  const DEFAULT_USAGE_TIMEOUT_MS = 5_000;
5
8
  const USAGE_FIELDS = new Set([
6
9
  "five_hour",
@@ -136,6 +139,59 @@ function parseExtraUsage(value) {
136
139
  parsed.limitMinor = limitMinor;
137
140
  return parsed;
138
141
  }
142
+ const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
143
+ const RESET_WINDOWS = [
144
+ "five_hour", "seven_day", "seven_day_overage_included", "seven_day_opus", "seven_day_sonnet",
145
+ ];
146
+ const INELIGIBLE_REASONS = new Set([
147
+ "config_off", "tier", "seat", "mobile", "surface", "cli_version", "no_grant",
148
+ "tenure", "other_experiment", "unavailable",
149
+ ]);
150
+ function parseResetGrant(value) {
151
+ if (!isRecord(value))
152
+ return undefined;
153
+ const id = typeof value.id === "string" && GRANT_ID.test(value.id) ? value.id : undefined;
154
+ const left = value.resets_left;
155
+ if (!id || typeof left !== "number" || !Number.isInteger(left) || left < 0)
156
+ return undefined;
157
+ const reported = Array.isArray(value.clears) ? value.clears : undefined;
158
+ const clears = reported ? RESET_WINDOWS.filter(window => reported.includes(window)) : [];
159
+ // Anything not named here would be refilled without the operator seeing it.
160
+ const clearsOther = !reported || reported.some(window => !RESET_WINDOWS.includes(window));
161
+ return {
162
+ id,
163
+ resetsLeft: left,
164
+ endsAt: resetAt(value.ends_at),
165
+ clears,
166
+ clearsOther,
167
+ usableNow: value.usable_now === true,
168
+ useRequiresLimit: value.use_requires_limit !== false,
169
+ paused: value.paused === true,
170
+ };
171
+ }
172
+ /** Parse the cedar_ember block. Unknown or malformed → undefined, never "zero resets". */
173
+ export function parseLimitResets(value) {
174
+ if (!isRecord(value) || typeof value.eligible !== "boolean")
175
+ return undefined;
176
+ // An eligible account's count is only established by an actual list; an
177
+ // explicit [] is a real zero. Ineligible blocks may omit it.
178
+ if (value.eligible && !Array.isArray(value.grants))
179
+ return undefined;
180
+ const grants = (Array.isArray(value.grants) ? value.grants : [])
181
+ .map(parseResetGrant)
182
+ .filter((grant) => grant !== undefined);
183
+ const next = typeof value.next_grant_id === "string" && grants.some(grant => grant.id === value.next_grant_id)
184
+ ? value.next_grant_id
185
+ : undefined;
186
+ const reason = stringValue(value.ineligible_reason);
187
+ return {
188
+ eligible: value.eligible,
189
+ ...(reason ? { ineligibleReason: INELIGIBLE_REASONS.has(reason) ? reason : "unknown" } : {}),
190
+ grants,
191
+ ...(next ? { nextGrantId: next } : {}),
192
+ cooldownUntil: resetAt(value.cooldown_until),
193
+ };
194
+ }
139
195
  function legacyModelLimit(family, value) {
140
196
  const window = parseWindow(value);
141
197
  if (!window)
@@ -171,12 +227,15 @@ export function parseAnthropicUsage(value, fetchedAt, requestedSeq) {
171
227
  const fiveHour = parseWindow(value.five_hour);
172
228
  const sevenDay = parseWindow(value.seven_day);
173
229
  const extraUsage = parseExtraUsage(value.extra_usage);
230
+ const limitResets = parseLimitResets(value.cedar_ember);
174
231
  if (fiveHour)
175
232
  snapshot.fiveHour = fiveHour;
176
233
  if (sevenDay)
177
234
  snapshot.sevenDay = sevenDay;
178
235
  if (extraUsage)
179
236
  snapshot.extraUsage = extraUsage;
237
+ if (limitResets)
238
+ snapshot.limitResets = limitResets;
180
239
  return snapshot;
181
240
  }
182
241
  export function canUseExtraUsage(state) {
@@ -208,6 +267,7 @@ export async function fetchAnthropicUsage(account, options = {}) {
208
267
  headers: {
209
268
  Authorization: `Bearer ${account.tokens.accessToken}`,
210
269
  "anthropic-beta": OAUTH_BETA_HEADER,
270
+ "user-agent": CLAUDE_CODE_USER_AGENT,
211
271
  },
212
272
  signal: controller.signal,
213
273
  });
@@ -1,3 +1,12 @@
1
+ import { ResetNotSubmittedError, RESET_OUTCOME_UNKNOWN } from "./reset-errors.js";
2
+ /**
3
+ * Answer with a refusal that provably sent nothing upstream. The explicit
4
+ * `notSubmitted` marker is what lets a client tell it apart from an error a
5
+ * gateway in between may synthesize after the claim did go out.
6
+ */
7
+ function refuse(res, status, error, extra = {}) {
8
+ res.status(status).json({ error, notSubmitted: true, ...extra });
9
+ }
1
10
  export function createUsageResetHandler(options) {
2
11
  const inFlight = new WeakSet();
3
12
  // One retained snapshot per account allows an uncertain retry to reconcile
@@ -7,33 +16,35 @@ export function createUsageResetHandler(options) {
7
16
  const id = req.params.id;
8
17
  const requestId = req.body?.redeemRequestId;
9
18
  if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId)) {
10
- res.status(400).json({ error: "redeemRequestId must be a UUID" });
19
+ refuse(res, 400, "redeemRequestId must be a UUID");
11
20
  return;
12
21
  }
13
22
  const account = options.findAccount(id);
14
23
  if (!account) {
15
- res.status(404).json({ error: "ChatGPT account not found" });
24
+ refuse(res, 404, "Account not found");
16
25
  return;
17
26
  }
18
27
  if (inFlight.has(account)) {
19
- res.status(409).json({ error: "Reset already running for this account" });
28
+ refuse(res, 409, "Reset already running for this account");
20
29
  return;
21
30
  }
22
31
  inFlight.add(account);
23
32
  try {
24
33
  if (!await options.prepare(account)) {
25
- res.status(503).json({ error: "Account credentials unavailable; reset not submitted" });
34
+ refuse(res, 503, "Account credentials unavailable; reset not submitted");
26
35
  return;
27
36
  }
28
37
  if (options.findAccount(id) !== account) {
29
- res.status(404).json({ error: "ChatGPT account changed; reset not submitted" });
38
+ refuse(res, 404, "Account changed; reset not submitted");
30
39
  return;
31
40
  }
32
41
  const previous = snapshots.get(account);
33
- const replay = previous?.id === requestId;
34
- const snapshot = replay ? previous : { id: requestId, reconcile: options.captureReset?.(account) };
42
+ const sameSnapshot = previous?.id === requestId;
43
+ // Read before consume: consuming is what records the id as sent.
44
+ const replay = options.isReplay ? options.isReplay(account, requestId) : sameSnapshot;
45
+ const snapshot = sameSnapshot ? previous : { id: requestId, reconcile: options.captureReset?.(account) };
35
46
  snapshots.set(account, snapshot);
36
- const result = await options.consume(account, requestId);
47
+ const result = await options.consume(account, requestId, { retry: req.body?.retry === true, offer: req.body?.offer });
37
48
  if (result.code === "already_redeemed" && !replay) {
38
49
  // This UUID predates our ownership. Repeated historical replays must
39
50
  // never promote its newly captured quota snapshot into trusted evidence.
@@ -52,10 +63,17 @@ export function createUsageResetHandler(options) {
52
63
  }
53
64
  catch { /* retain confirmed redemption */ }
54
65
  }
55
- res.json({ reset: { ...result, usageRefreshed } });
66
+ res.json({ reset: { provider: options.provider, ...result, usageRefreshed, replay } });
56
67
  }
57
- catch {
58
- res.status(502).json({ error: "Reset outcome unknown; retry with the same redemption ID" });
68
+ catch (error) {
69
+ if (error instanceof ResetNotSubmittedError) {
70
+ refuse(res, error.status, error.message, {
71
+ ...(error.abandon ? { abandon: true } : {}),
72
+ ...(error.pendingRedemption ? { pendingRedemption: error.pendingRedemption } : {}),
73
+ });
74
+ return;
75
+ }
76
+ res.status(502).json({ error: RESET_OUTCOME_UNKNOWN });
59
77
  }
60
78
  finally {
61
79
  inFlight.delete(account);
@@ -0,0 +1,120 @@
1
+ import { ResetNotSubmittedError } from "./reset-errors.js";
2
+ import { consumeClaudeLimitReset } from "../providers/anthropic/usage-reset.js";
3
+ /** Whether the confirmed terms are exactly the next grant's, as publicLimitResets shows them. */
4
+ function offerMatches(offer, grant) {
5
+ if (typeof offer !== "object" || offer === null)
6
+ return false;
7
+ const { useBy, clears, clearsOther } = offer;
8
+ return useBy === (grant.endsAt > 0 ? grant.endsAt : 0)
9
+ && Array.isArray(clears) && clears.length === grant.clears.length && clears.every((w, i) => w === grant.clears[i])
10
+ && clearsOther === grant.clearsOther;
11
+ }
12
+ const MAX_PINNED_PER_ACCOUNT = 8;
13
+ /**
14
+ * Binds each redemption id to the grant and account holder it first
15
+ * targeted. A replay after an unknown outcome must hit the same grant, or a
16
+ * moved `next_grant_id` would turn "retry" into "spend a second reset".
17
+ *
18
+ * Pins are keyed by account id, not the Account object, so a re-auth that
19
+ * replaces the object keeps them, and a rename moves them. They live in
20
+ * memory only, and each account keeps its 8 most recent ids. While one of an
21
+ * account's claims is unsettled (no definite answer yet), new ids for that
22
+ * account are refused and pointed at it, so eviction only ever drops settled
23
+ * pins. A retry whose pin is gone (router restart) is refused before sending
24
+ * rather than re-derived: its original grant can no longer be named, and only
25
+ * fresh usage can tell whether it was spent. A pin is written only right
26
+ * before the claim is sent, and dropped again if that first claim provably
27
+ * never left (409/503): a refusal before submission leaves nothing pinned.
28
+ *
29
+ * The returned `resetsLeft` is account-wide (the redeemed grant's count plus
30
+ * the other non-paused grants), matching the rst column.
31
+ */
32
+ export function createClaudeResetConsumer(deps) {
33
+ const consume = deps.consume ?? consumeClaudeLimitReset;
34
+ // `settled`: a definite upstream answer came back for this id.
35
+ const pinned = new Map();
36
+ const run = async (account, requestId, attempt = {}) => {
37
+ const pin = pinned.get(account.id)?.get(requestId);
38
+ let grantId = pin?.grantId;
39
+ if (!grantId) {
40
+ if (attempt.retry) {
41
+ throw new ResetNotSubmittedError(409, "Earlier reset attempt can't be matched any more (router restarted?) — check rst before redeeming again; nothing sent", true);
42
+ }
43
+ // One unresolved claim per account: a client that lost track of it
44
+ // (renamed row, restarted dashboard) is handed its id to retry instead
45
+ // of starting a claim that could spend another grant.
46
+ const unresolved = [...(pinned.get(account.id) ?? [])].find(([, entry]) => !entry.settled)?.[0];
47
+ if (unresolved) {
48
+ throw new ResetNotSubmittedError(409, "An earlier reset attempt on this account never confirmed — press Ctrl+R again to retry it; nothing sent", false, unresolved);
49
+ }
50
+ const usage = account.rateLimits.usage;
51
+ if (usage?.fetchStatus !== "fresh")
52
+ throw new ResetNotSubmittedError(409, "Reset status is stale — reload with R; nothing sent");
53
+ const resets = usage.limitResets;
54
+ const next = resets?.eligible ? resets.grants.find(grant => grant.id === resets.nextGrantId) : undefined;
55
+ if (!next)
56
+ throw new ResetNotSubmittedError(409, "No reset available for this account");
57
+ if (next.clears.length === 0) {
58
+ throw new ResetNotSubmittedError(409, "Reset refill scope unknown — update cc-router; nothing sent");
59
+ }
60
+ if (!offerMatches(attempt.offer, next)) {
61
+ throw new ResetNotSubmittedError(409, "Reset offer changed since you confirmed — review it and confirm again; nothing sent");
62
+ }
63
+ grantId = next.id;
64
+ }
65
+ const who = await deps.identity(account);
66
+ if (!who?.org || !who.principal)
67
+ throw new ResetNotSubmittedError(503, "Account identity unknown; reset not submitted");
68
+ const { org, principal } = who;
69
+ // Grant ids are shared across accounts (and teammates): re-authenticating
70
+ // this id as anyone else — another organization, or another member of the
71
+ // same one — must not aim the old claim at their reset. Only the
72
+ // original account holder can settle it.
73
+ if (pin && (pin.org !== org || pin.principal !== principal)) {
74
+ // Abandoned for good: it must not hold this account's new redemptions.
75
+ pinned.get(account.id)?.delete(requestId);
76
+ throw new ResetNotSubmittedError(409, "Account now signs in as someone other than the earlier reset attempt — check rst before redeeming again; nothing sent", true);
77
+ }
78
+ let pins = pinned.get(account.id);
79
+ if (!pins)
80
+ pinned.set(account.id, pins = new Map());
81
+ const firstAttempt = !pins.has(requestId);
82
+ if (firstAttempt) {
83
+ pins.set(requestId, { grantId, org, principal, settled: false });
84
+ while (pins.size > MAX_PINNED_PER_ACCOUNT)
85
+ pins.delete(pins.keys().next().value);
86
+ }
87
+ // Other grants are untouched by this claim; summing them with the
88
+ // returned per-grant count matches the rst column (non-paused grants).
89
+ const othersLeft = (account.rateLimits.usage?.limitResets?.grants ?? [])
90
+ .filter(grant => grant.id !== grantId && !grant.paused)
91
+ .reduce((sum, grant) => sum + grant.resetsLeft, 0);
92
+ let result;
93
+ try {
94
+ result = await consume(account, org, grantId, requestId);
95
+ }
96
+ catch (error) {
97
+ // A first claim that provably never left must not bind this id: the
98
+ // next press may confirm a different offer. A pin from an earlier,
99
+ // possibly-sent attempt stays — that one still needs its own grant.
100
+ if (firstAttempt && error instanceof ResetNotSubmittedError)
101
+ pinned.get(account.id)?.delete(requestId);
102
+ throw error;
103
+ }
104
+ const entry = pinned.get(account.id)?.get(requestId);
105
+ if (entry)
106
+ entry.settled = true;
107
+ return result.resetsLeft === undefined ? result : { ...result, resetsLeft: othersLeft + result.resetsLeft };
108
+ };
109
+ /** Whether this id was already sent for this account id — survives re-auth, unlike per-object state. */
110
+ const isReplay = (account, requestId) => pinned.get(account.id)?.has(requestId) === true;
111
+ /** Called by the rename transaction (and its rollback): pins follow the router id. */
112
+ const renameAccount = (oldId, newId) => {
113
+ const pins = pinned.get(oldId);
114
+ if (!pins)
115
+ return;
116
+ pinned.delete(oldId);
117
+ pinned.set(newId, pins);
118
+ };
119
+ return Object.assign(run, { isReplay, renameAccount });
120
+ }
@@ -0,0 +1,15 @@
1
+ /** Disclosure-safe summary: counts, dates and flags only — grant ids stay in-process. */
2
+ export function publicLimitResets(state) {
3
+ const next = state.grants.find(grant => grant.id === state.nextGrantId);
4
+ const available = state.grants.filter(grant => !grant.paused).reduce((sum, grant) => sum + grant.resetsLeft, 0);
5
+ return {
6
+ eligible: state.eligible === true,
7
+ ...(state.ineligibleReason ? { ineligibleReason: state.ineligibleReason } : {}),
8
+ available: Math.max(0, Math.min(99, Math.floor(available))),
9
+ usableNow: next?.usableNow === true,
10
+ requiresLimit: next?.useRequiresLimit !== false,
11
+ useBy: next && next.endsAt > 0 ? next.endsAt : 0,
12
+ clears: next ? [...next.clears] : [],
13
+ clearsOther: next?.clearsOther === true,
14
+ };
15
+ }
@@ -0,0 +1,21 @@
1
+ /** The redemption was provably never sent (or rejected before spending); safe to report as "nothing used". */
2
+ export class ResetNotSubmittedError extends Error {
3
+ status;
4
+ abandon;
5
+ pendingRedemption;
6
+ /**
7
+ * @param abandon The client's pending redemption id can never be retried
8
+ * safely (its grant binding is gone); the client should drop it and look
9
+ * at fresh usage before starting a new redemption.
10
+ * @param pendingRedemption An earlier, unresolved redemption id for this
11
+ * account that the client should retry instead of starting a new one.
12
+ */
13
+ constructor(status, message, abandon = false, pendingRedemption) {
14
+ super(message);
15
+ this.status = status;
16
+ this.abandon = abandon;
17
+ this.pendingRedemption = pendingRedemption;
18
+ this.name = "ResetNotSubmittedError";
19
+ }
20
+ }
21
+ export const RESET_OUTCOME_UNKNOWN = "Reset outcome unknown; retry with the same redemption ID";
@@ -4,6 +4,8 @@ import { createUsageRouter } from "../usage/http.js";
4
4
  import { usageDirectoryForAccounts, withUsageRename } from "../usage/account-lifecycle.js";
5
5
  import { consumeCodexResetCredit } from "../providers/openai/usage-reset.js";
6
6
  import { createUsageResetHandler } from "./account-usage-reset.js";
7
+ import { createClaudeResetConsumer } from "./claude-usage-reset.js";
8
+ import { publicLimitResets } from "./public-limit-resets.js";
7
9
  import express from "express";
8
10
  import { createProxyMiddleware } from "http-proxy-middleware";
9
11
  import { ServerResponse } from "http";
@@ -182,6 +184,7 @@ function publicUsageSnapshot(usage) {
182
184
  usable: usage.fetchStatus === "fresh" && canUseExtraUsage(usage.extraUsage),
183
185
  },
184
186
  } : {}),
187
+ ...(usage.limitResets ? { limitResets: publicLimitResets(usage.limitResets) } : {}),
185
188
  fetchedAt: publicTimestamp(usage.fetchedAt),
186
189
  fetchStatus: usage.fetchStatus,
187
190
  };
@@ -669,13 +672,65 @@ export async function startServer(opts = {}) {
669
672
  // the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
670
673
  const accountsRouter = express.Router();
671
674
  accountsRouter.use(express.json({ limit: "32kb" }));
672
- accountsRouter.post("/:id/reset-usage", createUsageResetHandler({
675
+ const openAIReset = createUsageResetHandler({
676
+ provider: "openai",
673
677
  findAccount: id => openAIAccounts.find(account => account.id === id),
674
678
  prepare: account => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
675
679
  consume: consumeCodexResetCredit,
676
680
  captureReset: account => openAIPool.captureUsageReset(account),
677
681
  refresh: account => openAIUsageRefresher.refreshAfterCurrent(account),
678
- }));
682
+ });
683
+ // Org and account UUIDs are identity metadata the profile fetch already
684
+ // caches (keyed to the current token, so a re-auth reads the new holder).
685
+ const claudeIdentity = async (account) => {
686
+ const read = () => {
687
+ const source = accountInfoSources().find(row => row.provider === "anthropic_subscription" && row.id === account.id);
688
+ const info = source ? accountInfoCache.get(source) : undefined;
689
+ return info?.workspaceId && info.accountId ? { org: info.workspaceId, principal: info.accountId } : undefined;
690
+ };
691
+ const cached = read();
692
+ if (cached)
693
+ return cached;
694
+ // Nothing is submitted yet: a failed profile fetch must read as "not
695
+ // submitted" (the consumer's 503), never as an unknown outcome.
696
+ try {
697
+ await accountInfoCache.refreshOne({ id: account.id, provider: "anthropic_subscription" });
698
+ }
699
+ catch { /* re-read below */ }
700
+ return read();
701
+ };
702
+ const claudeResetConsumer = createClaudeResetConsumer({ identity: claudeIdentity });
703
+ const claudeReset = createUsageResetHandler({
704
+ provider: "anthropic",
705
+ findAccount: id => pool.findById(id) ?? undefined,
706
+ prepare: async (account) => {
707
+ if (account.authExpired)
708
+ return false;
709
+ if (needsRefresh(account)) {
710
+ // A throw here would surface as "outcome unknown"; nothing was sent.
711
+ try {
712
+ await refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts });
713
+ }
714
+ catch {
715
+ return false;
716
+ }
717
+ }
718
+ return !account.authExpired && account.tokens.expiresAt > Date.now();
719
+ },
720
+ consume: claudeResetConsumer,
721
+ // The grant pin, not the per-object snapshot, knows a replay across re-auth.
722
+ isReplay: claudeResetConsumer.isReplay,
723
+ refresh: account => usageRefresher.refreshAfterCurrent(account),
724
+ });
725
+ accountsRouter.post("/:id/reset-usage", (req, res, next) => {
726
+ const id = req.params.id;
727
+ // Anthropic first, matching the /:id/refresh runner.
728
+ if (pool.findById(id))
729
+ return claudeReset(req, res, next);
730
+ if (openAIAccounts.some(account => account.id === id))
731
+ return openAIReset(req, res, next);
732
+ res.status(404).json({ error: "Account not found", notSubmitted: true });
733
+ });
679
734
  // ─── Per-account refresh (authenticated) ──────────────────────────────────
680
735
  // The whole-pool reload above, narrowed to one row: the operator who just
681
736
  // re-authenticated a single account should not have to pay for every other
@@ -887,7 +942,12 @@ export async function startServer(opts = {}) {
887
942
  renameAccountTransaction(id, newId, takenIds, inAnthropic
888
943
  ? {
889
944
  rename: (oldId, nextId) => pool.renameAccount(oldId, nextId) !== null,
890
- renameSessions: (oldId, nextId) => { sessionRouter.renameAccount(oldId, nextId); },
945
+ // Reset pins follow the id too (and back again on rollback), so an
946
+ // unknown-outcome redemption stays retryable under the new name.
947
+ renameSessions: (oldId, nextId) => {
948
+ sessionRouter.renameAccount(oldId, nextId);
949
+ claudeResetConsumer.renameAccount(oldId, nextId);
950
+ },
891
951
  persist: () => withUsageRename(accountsFile, id, newId, () => persistAnthropicAccounts(pool.getAll())),
892
952
  }
893
953
  : {