@timo972/cc-router 0.13.0-rc.4 → 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
@@ -53,6 +53,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
53
53
  - `POST /cc-router/accounts/:id/refresh` refreshes one account. The
54
54
  dashboard's `R` uses it when an account is selected and reloads the whole
55
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.
56
61
 
57
62
  ### Fixed
58
63
 
@@ -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
  : {
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { randomUUID } from "node:crypto";
3
3
  import React, { useState, useEffect, useCallback, useRef } from "react";
4
4
  import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
5
- import { createAccountsApi } from "./accountsApi.js";
5
+ import { createAccountsApi, RouterRefusedResetError } from "./accountsApi.js";
6
6
  import { createModelsApi } from "./modelsApi.js";
7
7
  import { getCurrentVersion } from "../utils/self-update.js";
8
8
  import { readClaudeRouting, readCodexRouting, setClaudeRouting, setCodexRouting, } from "../utils/cli-routing.js";
@@ -241,11 +241,61 @@ export function isXaiAccount(account) {
241
241
  export function isClaudeAccount(account) {
242
242
  return !isOpenAIAccount(account) && !isXaiAccount(account);
243
243
  }
244
- /** Compact `rst` column: banked Codex usage-limit resets. Claude is an em dash. */
244
+ /** Compact `rst` column: banked usage-limit resets. Grok and unknown or
245
+ * ineligible Claude status are an em dash. */
245
246
  export function resetCreditsColumnLabel(account) {
246
- if (!isOpenAIAccount(account))
247
+ if (isOpenAIAccount(account))
248
+ return String(account.codexRateLimits?.resetCredits?.available ?? 0);
249
+ if (!isClaudeAccount(account))
247
250
  return "—";
248
- return String(account.codexRateLimits?.resetCredits?.available ?? 0);
251
+ const resets = account.rateLimits?.usage?.limitResets;
252
+ return resets?.eligible ? String(resets.available) : "—";
253
+ }
254
+ // Every window a grant can clear (LimitResetWindow): the operator must see
255
+ // the whole scope of a spend before confirming it.
256
+ const RESET_WINDOW_LABELS = {
257
+ five_hour: "5h",
258
+ seven_day: "7d",
259
+ seven_day_overage_included: "7d overage",
260
+ seven_day_opus: "7d Opus",
261
+ seven_day_sonnet: "7d Sonnet",
262
+ };
263
+ /** Why Ctrl+R cannot start a NEW Claude reset, or undefined when it can. */
264
+ export function claudeResetBlocker(account) {
265
+ const usage = account.rateLimits?.usage;
266
+ const resets = usage?.limitResets;
267
+ if (!resets)
268
+ return "Reset status unknown — reload with R";
269
+ // A failed poll keeps the last grant data but marks it stale; a new
270
+ // redemption must not be chosen from it.
271
+ if (usage.fetchStatus !== "fresh")
272
+ return "Reset status is out of date — reload with R";
273
+ if (!resets.eligible) {
274
+ return resets.ineligibleReason === "cli_version"
275
+ ? "Claude Code version too old for resets — update cc-router"
276
+ : `Resets unavailable for this account (${resets.ineligibleReason ?? "unknown"})`;
277
+ }
278
+ if (resets.available <= 0)
279
+ return "No resets available";
280
+ // Never spend a reset whose effect cannot be shown in the confirmation.
281
+ if (resets.clears.length === 0)
282
+ return "Reset refill scope unknown — update cc-router";
283
+ if (!resets.usableNow)
284
+ return resets.requiresLimit ? "Reset only usable at a limit" : "Reset not usable right now";
285
+ return undefined;
286
+ }
287
+ /** Whether two Claude offers carry the same terms the confirmation shows. */
288
+ export function sameResetOffer(a, b) {
289
+ return a.useBy === b.useBy && a.clearsOther === b.clearsOther
290
+ && a.clears.length === b.clears.length && a.clears.every((window, i) => window === b.clears[i]);
291
+ }
292
+ export function claudeResetConfirmText(id, resets) {
293
+ const windows = resets.clears.map(window => RESET_WINDOW_LABELS[window]).filter(Boolean);
294
+ if (resets.clearsOther)
295
+ windows.push("other");
296
+ const refills = windows.length > 0 ? `Refills ${windows.join(" + ")} limits` : "Refills your limits";
297
+ const useBy = resets.useBy > 0 ? ` · use by ${new Date(resets.useBy * 1000).toISOString().slice(0, 10)}` : "";
298
+ return `Redeem 1 reset for "${id}"? ${refills} · ${resets.available} left${useBy}`;
249
299
  }
250
300
  export function isLimitedAccount(account) {
251
301
  if (isXaiAccount(account))
@@ -675,6 +725,9 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
675
725
  const [focus, setFocus] = useState("logs");
676
726
  const [mode, setMode] = useState("view");
677
727
  const [resetTarget, setResetTarget] = useState(null);
728
+ // The Claude offer as it stood when the confirmation opened: what is shown
729
+ // and what is sent, however polls move the live grant meanwhile.
730
+ const [resetOffer, setResetOffer] = useState(null);
678
731
  // Compact ("zen") view: hides TOTALS + RECENT ACTIVITY so the account list
679
732
  // gets the whole vertical budget — the fix for a short terminal starving a
680
733
  // long fleet (e.g. showing 1 of 11 accounts). Toggled with [z], view-only,
@@ -791,6 +844,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
791
844
  ? Math.max(0, orderedAccounts.findIndex(a => a.id === selectedAccountId))
792
845
  : 0;
793
846
  const selectedAccount = orderedAccounts[selectedAccountIndex] ?? null;
847
+ const resetTargetAccount = resetTarget ? orderedAccounts.find(a => a.id === resetTarget) : undefined;
848
+ const resetTargetClaudeResets = resetTargetAccount && isClaudeAccount(resetTargetAccount)
849
+ ? resetTargetAccount.rateLimits?.usage?.limitResets
850
+ : undefined;
794
851
  // Identity for the selected row: health never carries it, so it comes from
795
852
  // the authenticated /cc-router/accounts poll above. The detail line and the
796
853
  // re-auth intent must agree on which email they mean, so both read this.
@@ -1035,23 +1092,48 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1035
1092
  showBanner(`Models error: ${errMsg(err)}`, "red");
1036
1093
  }
1037
1094
  }, [modelsApi, selectedModel, showBanner]);
1038
- const doResetUsage = useCallback(async (id) => {
1095
+ const doResetUsage = useCallback(async (id, shownOffer) => {
1039
1096
  if (resetSession.inFlight)
1040
1097
  return;
1041
1098
  resetSession.inFlight = true;
1042
- const requestId = resetSession.pendingIds.get(id) ?? randomUUID();
1043
- resetSession.pendingIds.set(id, requestId);
1099
+ const pending = resetSession.pendingIds.get(id) ?? { requestId: randomUUID(), maybeSubmitted: false };
1100
+ resetSession.pendingIds.set(id, pending);
1044
1101
  showBanner(`Redeeming usage reset for ${id}…`, "yellow", REFRESH_ALL_BANNER_MS);
1045
1102
  try {
1046
- const result = await api.resetUsage(id, requestId);
1103
+ // Only a retry of a possibly-sent id may reuse its grant; the router
1104
+ // refuses such a retry outright if it can no longer match it.
1105
+ // The terms shown at confirmation: the router refuses if the grant it
1106
+ // would spend no longer matches them.
1107
+ const offer = shownOffer ? { useBy: shownOffer.useBy, clears: shownOffer.clears, clearsOther: shownOffer.clearsOther } : undefined;
1108
+ const result = await api.resetUsage(id, pending.requestId, { retry: pending.maybeSubmitted, ...(offer ? { offer } : {}) });
1047
1109
  resetSession.pendingIds.delete(id);
1048
- const messages = {
1049
- reset: `Usage reset redeemed for ${id}`,
1050
- already_redeemed: `Usage reset already redeemed for ${id}`,
1051
- nothing_to_reset: `Nothing to reset for ${id}`,
1052
- no_credit: `No reset credits available for ${id}`,
1053
- };
1054
- showBanner(messages[result.code] + (result.usageRefreshed ? "" : " — usage refresh failed; reload with R"), result.usageRefreshed && (result.code === "reset" || result.code === "already_redeemed") ? "green" : "yellow");
1110
+ const text = result.provider === "anthropic"
1111
+ ? ({
1112
+ reset: `Limits reset for ${id}${result.resetsLeft !== undefined ? ` · ${result.resetsLeft} left` : ""}`,
1113
+ // A replayed id may have spent on the earlier, unconfirmed attempt:
1114
+ // only a first attempt can promise that nothing was used.
1115
+ already_used: result.replay
1116
+ ? `Reset already used for ${id} · nothing more spent`
1117
+ : `Reset already used elsewhere for ${id} · nothing spent now`,
1118
+ not_limited: result.replay
1119
+ ? `${id} is not at a limit · an earlier attempt may have used a reset — check rst`
1120
+ : `${id} is not at a limit · nothing used`,
1121
+ cooldown: result.replay
1122
+ ? `Resets are cooling down for ${id} · an earlier attempt may have used a reset — check rst`
1123
+ : `Resets are cooling down for ${id} · try later`,
1124
+ ineligible: result.replay
1125
+ ? `Reset unavailable for ${id} · an earlier attempt may have used a reset — check rst`
1126
+ : `Reset unavailable for ${id} · nothing used`,
1127
+ })[result.code]
1128
+ : ({
1129
+ reset: `Usage reset redeemed for ${id}`,
1130
+ already_redeemed: `Usage reset already redeemed for ${id}`,
1131
+ nothing_to_reset: `Nothing to reset for ${id}`,
1132
+ no_credit: `No reset credits available for ${id}`,
1133
+ })[result.code];
1134
+ const confirmed = result.code === "reset" || result.code === "already_redeemed"
1135
+ || (result.code === "already_used" && result.replay);
1136
+ showBanner(text + (result.usageRefreshed ? "" : " — usage refresh failed; reload with R"), result.usageRefreshed && confirmed ? "green" : "yellow");
1055
1137
  // Failure to poll the dashboard must not turn a confirmed spend into an
1056
1138
  // unknown outcome or encourage another redemption.
1057
1139
  try {
@@ -1059,8 +1141,26 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1059
1141
  }
1060
1142
  catch { /* the regular poll will retry */ }
1061
1143
  }
1062
- catch {
1063
- showBanner(`Reset outcome unknown for ${id}; Ctrl+R retries the same redemption (keep dashboard open)`, "red");
1144
+ catch (error) {
1145
+ // The pending id is kept even when the router refused before submitting:
1146
+ // an earlier attempt under the same id may still have an unknown outcome,
1147
+ // and a fresh id would let the next press spend a second reset.
1148
+ // Only when the router says the id can never be matched again is it
1149
+ // dropped, and then the next press has to pass the fresh-status blocker.
1150
+ if (error instanceof RouterRefusedResetError) {
1151
+ if (error.abandon)
1152
+ resetSession.pendingIds.delete(id);
1153
+ // The router still holds an unresolved claim for this account (e.g.
1154
+ // it was renamed, or this dashboard restarted): retry that one next.
1155
+ if (error.pendingRedemption) {
1156
+ resetSession.pendingIds.set(id, { requestId: error.pendingRedemption, maybeSubmitted: true });
1157
+ }
1158
+ showBanner(`${error.message} (${id})`, "yellow");
1159
+ }
1160
+ else {
1161
+ pending.maybeSubmitted = true;
1162
+ showBanner(`Reset outcome unknown for ${id}; Ctrl+R retries the same redemption (keep dashboard open)`, "red");
1163
+ }
1064
1164
  }
1065
1165
  finally {
1066
1166
  resetSession.inFlight = false;
@@ -1098,11 +1198,21 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1098
1198
  return;
1099
1199
  }
1100
1200
  if (mode === "confirmReset") {
1101
- if ((input === "y" || input === "Y") && resetTarget)
1102
- void doResetUsage(resetTarget);
1201
+ if ((input === "y" || input === "Y") && resetTarget) {
1202
+ // A retry of a possibly-sent id must go out to learn its outcome; an
1203
+ // earlier spend may well have moved the next grant.
1204
+ const retrying = resetSession.pendingIds.get(resetTarget)?.maybeSubmitted === true;
1205
+ if (resetOffer && !retrying && (!resetTargetClaudeResets || !sameResetOffer(resetOffer, resetTargetClaudeResets))) {
1206
+ showBanner("Reset offer changed while you were confirming — press Ctrl+R to review it; nothing sent", "yellow");
1207
+ }
1208
+ else {
1209
+ void doResetUsage(resetTarget, resetOffer ?? undefined);
1210
+ }
1211
+ }
1103
1212
  else
1104
1213
  showBanner("Reset cancelled", "gray");
1105
1214
  setResetTarget(null);
1215
+ setResetOffer(null);
1106
1216
  setMode("view");
1107
1217
  return;
1108
1218
  }
@@ -1144,15 +1254,26 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1144
1254
  showBanner("Reset already running", "yellow");
1145
1255
  return;
1146
1256
  }
1147
- if (selectedAccount.provider !== "openai_subscription") {
1148
- showBanner("Usage resets are only available for ChatGPT accounts", "yellow");
1257
+ // A pending id means an earlier attempt's outcome is unknown: let the
1258
+ // retry through so the router can deduplicate it under the same id.
1259
+ const pending = resetSession.pendingIds.has(selectedAccount.id);
1260
+ if (isClaudeAccount(selectedAccount)) {
1261
+ const blocker = pending ? undefined : claudeResetBlocker(selectedAccount);
1262
+ if (blocker) {
1263
+ showBanner(blocker, "yellow");
1264
+ return;
1265
+ }
1266
+ }
1267
+ else if (selectedAccount.provider !== "openai_subscription") {
1268
+ showBanner("Usage resets are not available for Grok accounts", "yellow");
1149
1269
  return;
1150
1270
  }
1151
- if (!resetSession.pendingIds.has(selectedAccount.id) && (selectedAccount.codexRateLimits?.resetCredits?.available ?? 0) <= 0) {
1271
+ else if (!pending && (selectedAccount.codexRateLimits?.resetCredits?.available ?? 0) <= 0) {
1152
1272
  showBanner("No reset credits available", "yellow");
1153
1273
  return;
1154
1274
  }
1155
1275
  setResetTarget(selectedAccount.id);
1276
+ setResetOffer(isClaudeAccount(selectedAccount) ? selectedAccount.rateLimits?.usage?.limitResets ?? null : null);
1156
1277
  setMode("confirmReset");
1157
1278
  return;
1158
1279
  }
@@ -1325,7 +1446,9 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1325
1446
  });
1326
1447
  const selectedLog = logs[selectedLogIndex] ?? null;
1327
1448
  const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
1328
- return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmReset" && resetTarget && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "yellow", bold: true, children: ["Redeem 1 reset for \"", resetTarget, "\"? [y] yes [n/Esc] cancel"] }) })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
1449
+ return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmReset" && resetTarget && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "yellow", bold: true, children: resetOffer
1450
+ ? `${claudeResetConfirmText(resetTarget, resetOffer)} [y] yes [n/Esc] cancel`
1451
+ : `Redeem 1 reset for "${resetTarget}"? [y] yes [n/Esc] cancel` }) })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
1329
1452
  ? `daemon v${data.version}`
1330
1453
  : "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
1331
1454
  // A remote router can only be restarted where it runs; printing a
@@ -17,6 +17,29 @@ const REFRESH_ALL_TIMEOUT_MS = 120_000;
17
17
  // still lands rather than the dashboard reporting a timeout it caused.
18
18
  const REFRESH_ONE_TIMEOUT_MS = 30_000;
19
19
  const MAX_PUBLIC_ROWS = 12;
20
+ const OPENAI_RESET_CODES = ["reset", "nothing_to_reset", "no_credit", "already_redeemed"];
21
+ const CLAUDE_RESET_CODES = ["reset", "already_used", "not_limited", "cooldown", "ineligible"];
22
+ // Statuses where the router answered before submitting anything upstream, so
23
+ // its error text is safe and useful to show. Every other status (notably 502,
24
+ // "outcome unknown") stays a bare `HTTP <status>`.
25
+ const NOT_SUBMITTED_STATUSES = new Set([400, 404, 409, 503]);
26
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
27
+ /** The router refused a redemption before submitting it (its error text is
28
+ * the message). Network failures, timeouts and bare `HTTP <status>` errors
29
+ * are never this type: their outcome is unknown. */
30
+ export class RouterRefusedResetError extends Error {
31
+ abandon;
32
+ pendingRedemption;
33
+ name = "RouterRefusedResetError";
34
+ /** The router can never match this redemption id again; start over from fresh usage. */
35
+ constructor(message, abandon = false,
36
+ /** An earlier unresolved redemption id the router wants retried instead. */
37
+ pendingRedemption) {
38
+ super(message);
39
+ this.abandon = abandon;
40
+ this.pendingRedemption = pendingRedemption;
41
+ }
42
+ }
20
43
  export function createAccountsApi(baseUrl, authToken) {
21
44
  const root = baseUrl.replace(/\/+$/, "");
22
45
  const base = root + "/cc-router/accounts";
@@ -79,21 +102,46 @@ export function createAccountsApi(baseUrl, authToken) {
79
102
  durationMs: publicInteger(r.durationMs),
80
103
  };
81
104
  },
82
- async resetUsage(id, redeemRequestId) {
105
+ async resetUsage(id, redeemRequestId, attempt = {}) {
83
106
  const response = await fetch(`${base}/${encodeURIComponent(id)}/reset-usage`, {
84
107
  method: "POST",
85
108
  headers: { ...authHeaders, "content-type": "application/json" },
86
- body: JSON.stringify({ redeemRequestId }),
109
+ body: JSON.stringify({ redeemRequestId, retry: attempt.retry === true, ...(attempt.offer ? { offer: attempt.offer } : {}) }),
87
110
  signal: AbortSignal.timeout(REFRESH_ALL_TIMEOUT_MS),
88
111
  });
89
- if (!response.ok)
90
- throw new Error(`HTTP ${response.status}`);
112
+ if (!response.ok) {
113
+ const fallback = `HTTP ${response.status}`;
114
+ if (!NOT_SUBMITTED_STATUSES.has(response.status))
115
+ throw new Error(fallback);
116
+ const errorBody = await response.json().catch(() => undefined);
117
+ // Only the router's explicit marker proves nothing was sent: a gateway
118
+ // in between can answer with the same status and an `error` string
119
+ // after the claim already went out.
120
+ if (!isRecord(errorBody) || errorBody.notSubmitted !== true)
121
+ throw new Error(fallback);
122
+ const text = publicText(errorBody.error, 160, fallback);
123
+ if (text === fallback)
124
+ throw new Error(fallback);
125
+ const pending = typeof errorBody.pendingRedemption === "string" && UUID.test(errorBody.pendingRedemption)
126
+ ? errorBody.pendingRedemption : undefined;
127
+ throw new RouterRefusedResetError(text, errorBody.abandon === true, pending);
128
+ }
91
129
  const body = await response.json();
92
130
  const reset = isRecord(body) ? body.reset : undefined;
93
- if (!isRecord(reset) || typeof reset.code !== "string" || !["reset", "nothing_to_reset", "no_credit", "already_redeemed"].includes(reset.code)
94
- || typeof reset.usageRefreshed !== "boolean")
131
+ if (!isRecord(reset) || typeof reset.code !== "string" || typeof reset.usageRefreshed !== "boolean")
95
132
  throw new Error("Invalid reset response");
96
- return { code: reset.code, usageRefreshed: reset.usageRefreshed };
133
+ // True only when the router re-submitted a request id it had already sent.
134
+ const replay = reset.replay === true;
135
+ if (reset.provider === "anthropic" && CLAUDE_RESET_CODES.includes(reset.code)) {
136
+ return {
137
+ provider: "anthropic", code: reset.code, usageRefreshed: reset.usageRefreshed, replay,
138
+ ...(typeof reset.resetsLeft === "number" ? { resetsLeft: publicInteger(reset.resetsLeft) } : {}),
139
+ };
140
+ }
141
+ if (reset.provider === "openai" && OPENAI_RESET_CODES.includes(reset.code)) {
142
+ return { provider: "openai", code: reset.code, usageRefreshed: reset.usageRefreshed, replay };
143
+ }
144
+ throw new Error("Invalid reset response");
97
145
  },
98
146
  patch(id, patch) { return send("PATCH", `/${encodeURIComponent(id)}`, patch); },
99
147
  setProviderEnabled(provider, enabled) { return send("PATCH", `/providers/${encodeURIComponent(provider)}`, { enabled }); },
@@ -71,12 +71,37 @@ export function chartColumns(buckets, width, height, models, legendLimit = 8) {
71
71
  const filled = max > 0 ? Math.max(total > 0 ? 1 : 0, Math.round(total / max * rows)) : 0;
72
72
  const allocations = legend.map(item => {
73
73
  const exact = total > 0 ? (group.values.get(item.key) ?? 0) / total * filled : 0;
74
- return { key: item.key, cells: Math.floor(exact), fraction: exact % 1 };
74
+ return { key: item.key, exact, cells: Math.floor(exact), fraction: exact % 1 };
75
75
  });
76
+ // Height stays tied to the total. Within it every series keeps at least one cell, so a provider
77
+ // at a few percent of the bucket no longer rounds to nothing and leaves the other one alone;
78
+ // when series outnumber the cells, the largest contributors get them.
79
+ const visible = new Set(allocations.filter(a => a.exact > 0).sort((a, b) => b.exact - a.exact).slice(0, filled));
80
+ for (const a of allocations)
81
+ if (!visible.has(a))
82
+ a.fraction = 0;
76
83
  let remaining = filled - allocations.reduce((n, a) => n + a.cells, 0);
84
+ for (const a of visible)
85
+ if (a.cells === 0) {
86
+ a.cells = 1;
87
+ a.fraction = 0;
88
+ remaining--;
89
+ }
77
90
  for (const a of [...allocations].sort((a, b) => b.fraction - a.fraction)) {
78
- if (remaining-- > 0)
91
+ if (remaining > 0 && a.fraction > 0) {
79
92
  a.cells++;
93
+ remaining--;
94
+ }
95
+ }
96
+ // Minimum cells may overdraw the column, or dropped fractions underfill it: settle on the largest series.
97
+ const largest = () => allocations.reduce((a, b) => b.cells > a.cells ? b : a);
98
+ while (remaining < 0 && largest().cells > 1) {
99
+ largest().cells--;
100
+ remaining++;
101
+ }
102
+ while (remaining > 0) {
103
+ largest().cells++;
104
+ remaining--;
80
105
  }
81
106
  const stack = allocations.flatMap(a => Array(a.cells).fill(a.key));
82
107
  return { label: group.label, total, cells: [...Array(rows - filled).fill(null), ...stack.reverse()] };
@@ -4,16 +4,26 @@ import { utcTimestamp } from "./types.js";
4
4
  * Verified against these official pages on 2026-09-16. Never prefix-match models.
5
5
  * Unknown cache-write duration stays unpriced unless the user supplies a rate. */
6
6
  const VERIFIED = "2026-09-16";
7
+ /** Opus 5.5 and the GPT-6 / GPT-5.6 families, verified against the same pages. */
8
+ const VERIFIED_2026_09_24 = "2026-09-24";
7
9
  const ANTHROPIC = "https://platform.claude.com/docs/en/about-claude/pricing";
8
10
  const OPENAI = "https://developers.openai.com/api/docs/pricing";
9
- const claude = (model, input, output, cacheRead = input / 10) => ({
11
+ const claude = (model, input, output, cacheRead = input / 10, effectiveDate = VERIFIED) => ({
10
12
  provider: "anthropic_subscription", model, input, output, cacheRead,
11
- cacheWrite5m: input * 1.25, cacheWrite1h: input * 2, source: ANTHROPIC, effectiveDate: VERIFIED,
13
+ cacheWrite5m: input * 1.25, cacheWrite1h: input * 2, source: ANTHROPIC, effectiveDate,
12
14
  });
13
- const codex = (model, input, output, source = OPENAI) => ({
14
- provider: "openai_subscription", model, input, output, cacheRead: input / 10, source, effectiveDate: VERIFIED,
15
+ const codex = (model, input, output, source = OPENAI, effectiveDate = VERIFIED) => ({
16
+ provider: "openai_subscription", model, input, output, cacheRead: input / 10, source, effectiveDate,
15
17
  });
18
+ const modelPage = (model) => `https://developers.openai.com/api/docs/models/${model}`;
19
+ /** Short-context (<=272K input) rates; longer prompts stay unpriced, see LONG_CONTEXT_272K. */
20
+ const GPT_6_AND_5_6 = [
21
+ ["gpt-6-astra", 10, 50], ["gpt-6-sol", 2, 10], ["gpt-6-luna", 0.1, 0.5],
22
+ ["gpt-5.6-sol", 4, 20], ["gpt-5.6-terra", 2, 12], ["gpt-5.6-luna", 0.2, 1.2],
23
+ ];
24
+ const LONG_CONTEXT_272K = new Set(["gpt-5.4", "gpt-5.4-2026-03-05", "gpt-5.5", "gpt-5.5-2026-04-23", ...GPT_6_AND_5_6.map(([model]) => model)]);
16
25
  const CATALOG = [
26
+ claude("claude-opus-5-5", 4, 20, 0.2, VERIFIED_2026_09_24),
17
27
  ...["claude-fable-5-1", "claude-mythos-5-1"].map(model => claude(model, 10, 50, 0.25)),
18
28
  ...["claude-fable-5", "claude-mythos-5"].map(model => claude(model, 10, 50)),
19
29
  ...["claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", "claude-opus-4-5-20251101"].map(model => claude(model, 5, 25)),
@@ -27,6 +37,8 @@ const CATALOG = [
27
37
  codex("gpt-5.3-codex", 1.75, 14),
28
38
  ...["gpt-5.5", "gpt-5.5-2026-04-23"].map(model => codex(model, 5, 30, "https://developers.openai.com/api/docs/models/gpt-5.5")),
29
39
  ...["gpt-5.4", "gpt-5.4-2026-03-05"].map(model => codex(model, 2.5, 15, "https://developers.openai.com/api/docs/models/gpt-5.4")),
40
+ ...GPT_6_AND_5_6.map(([model, input, output]) => codex(model, input, output, modelPage(model), VERIFIED_2026_09_24)),
41
+ codex("gpt-5.6-cyber", 12.5, 75, OPENAI, VERIFIED_2026_09_24),
30
42
  ];
31
43
  export function lookupUsageRates(provider, model, inputContext = undefined, overrides = []) {
32
44
  const override = overrides.find(rate => rate.provider === provider && rate.model === model);
@@ -34,7 +46,7 @@ export function lookupUsageRates(provider, model, inputContext = undefined, over
34
46
  if (!rate)
35
47
  return undefined;
36
48
  // Never use short-context rates for a prompt that might have tiered pricing.
37
- const threshold = provider === "openai_subscription" && ["gpt-5.4", "gpt-5.4-2026-03-05", "gpt-5.5", "gpt-5.5-2026-04-23"].includes(model) ? 272_000
49
+ const threshold = provider === "openai_subscription" && LONG_CONTEXT_272K.has(model) ? 272_000
38
50
  : provider === "anthropic_subscription" && ["claude-sonnet-4-5", "claude-sonnet-4-5-20250929"].includes(model) ? 200_000 : undefined;
39
51
  if (inputContext !== undefined && (!Number.isSafeInteger(inputContext) || inputContext < 0))
40
52
  return undefined;
package/docs/README.md CHANGED
@@ -20,7 +20,7 @@ Start with the [README](../README.md) for what CC-Router is and a quickstart.
20
20
  |---|---|
21
21
  | [Architecture](architecture.md) | The request path and what each component does |
22
22
  | [Session routing](session-routing.md) | How an account is picked, failover behaviour, running it for a team |
23
- | [Dashboard](dashboard.md) | The live TUI, keybindings, model management, ChatGPT usage resets |
23
+ | [Dashboard](dashboard.md) | The live TUI, keybindings, model management, usage resets |
24
24
  | [Troubleshooting](troubleshooting.md) | When something doesn't connect |
25
25
 
26
26
  ## Reference
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.13.0-rc.4",
3
+ "version": "0.13.0-rc.5",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {