@timo972/cc-router 0.13.0-rc.4 → 0.13.0-rc.6
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 +5 -0
- package/dist/providers/anthropic/usage-reset.js +58 -0
- package/dist/providers/anthropic/usage.js +62 -2
- package/dist/proxy/account-usage-reset.js +29 -11
- package/dist/proxy/claude-usage-reset.js +120 -0
- package/dist/proxy/public-limit-resets.js +15 -0
- package/dist/proxy/reset-errors.js +21 -0
- package/dist/proxy/server.js +63 -3
- package/dist/ui/Dashboard.js +146 -23
- package/dist/ui/UsageDashboard.js +14 -6
- package/dist/ui/accountsApi.js +55 -7
- package/dist/ui/usage-chart.js +27 -2
- package/dist/usage/pricing.js +17 -5
- package/docs/README.md +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
24
|
+
refuse(res, 404, "Account not found");
|
|
16
25
|
return;
|
|
17
26
|
}
|
|
18
27
|
if (inFlight.has(account)) {
|
|
19
|
-
res
|
|
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
|
|
34
|
+
refuse(res, 503, "Account credentials unavailable; reset not submitted");
|
|
26
35
|
return;
|
|
27
36
|
}
|
|
28
37
|
if (options.findAccount(id) !== account) {
|
|
29
|
-
res
|
|
38
|
+
refuse(res, 404, "Account changed; reset not submitted");
|
|
30
39
|
return;
|
|
31
40
|
}
|
|
32
41
|
const previous = snapshots.get(account);
|
|
33
|
-
const
|
|
34
|
-
|
|
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
|
-
|
|
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";
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
: {
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -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
|
|
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 (
|
|
247
|
+
if (isOpenAIAccount(account))
|
|
248
|
+
return String(account.codexRateLimits?.resetCredits?.available ?? 0);
|
|
249
|
+
if (!isClaudeAccount(account))
|
|
247
250
|
return "—";
|
|
248
|
-
|
|
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
|
|
1043
|
-
resetSession.pendingIds.set(id,
|
|
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
|
-
|
|
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
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
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 (!
|
|
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:
|
|
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
|
|
@@ -225,21 +225,29 @@ export function UsageDashboard({ load, initialQuery = { period: "month" }, onExi
|
|
|
225
225
|
if (showBoth || !gridFocus) {
|
|
226
226
|
gap("gap-chart");
|
|
227
227
|
// Few buckets (a week has seven) get wide columns so the bars fill the row and every label has room.
|
|
228
|
-
const
|
|
229
|
-
const columnWidth = Math.max(2, Math.min(MAX_COLUMN_WIDTH, Math.floor(plotWidth / Math.max(1, report.buckets.length))));
|
|
230
|
-
const chart = chartColumns(report.buckets.map(b => ({
|
|
228
|
+
const buckets = report.buckets.map(b => ({
|
|
231
229
|
label: query.period === "day" ? b.start.slice(11, 13) : query.period === "year" ? b.start.slice(5, 7) : b.start.slice(8, 10),
|
|
232
230
|
series: Object.values(b.series).map(s => ({ ...s, tokens: measure(s.tokens, s.usd) })),
|
|
233
|
-
}))
|
|
231
|
+
}));
|
|
232
|
+
// The y-axis gutter fits its widest label: a spend peak like $945.61 is seven characters, and a
|
|
233
|
+
// fixed six-character gutter pushed that row one column off the axis line.
|
|
234
|
+
const layout = (gutter) => {
|
|
235
|
+
const plotWidth = Math.max(2, Math.min(width, gridWidth) - gutter - 2);
|
|
236
|
+
const columnWidth = Math.max(2, Math.min(MAX_COLUMN_WIDTH, Math.floor(plotWidth / Math.max(1, report.buckets.length))));
|
|
237
|
+
const chart = chartColumns(buckets, Math.max(1, Math.floor(plotWidth / columnWidth)), chartHeight, models, models ? Math.max(2, Math.floor(width / 24)) : 3);
|
|
238
|
+
return { columnWidth, chart, gutter: Math.max(gutter, formatMeasure(chart.max).length) };
|
|
239
|
+
};
|
|
240
|
+
const first = layout(6);
|
|
241
|
+
const { columnWidth, chart, gutter } = first.gutter > 6 ? layout(first.gutter) : first;
|
|
234
242
|
const colors = new Map(chart.legend.map((s, i) => [s.key, legendColor(s, i, models)]));
|
|
235
243
|
const glyphs = new Map(chart.legend.map((s, i) => [s.key, legendGlyph(s, i, models)]));
|
|
236
244
|
// Wide columns keep one blank cell between bars; two-cell columns stay flush as before.
|
|
237
245
|
const fill = columnWidth >= 3 ? columnWidth - 1 : columnWidth;
|
|
238
246
|
for (let row = 0; row < chartHeight; row++)
|
|
239
|
-
lines.push(line(_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [(row === 0 ? formatMeasure(chart.max) : row === chartHeight - 1 ? "0" : "").padStart(
|
|
247
|
+
lines.push(line(_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [(row === 0 ? formatMeasure(chart.max) : row === chartHeight - 1 ? "0" : "").padStart(gutter), " \u2502"] }), chart.columns.map((col, i) => _jsx(Text, { color: col.cells[row] ? colors.get(col.cells[row]) : undefined, children: (col.cells[row] ? glyphs.get(col.cells[row]).repeat(fill) : " ".repeat(fill)).padEnd(columnWidth) }, i))] }), `bar${row}`));
|
|
240
248
|
const labelStride = columnWidth >= 3 ? 1 : Math.max(1, Math.ceil(chart.columns.length / 8));
|
|
241
249
|
const axisUnit = query.period === "day" ? "hour" : query.period === "year" ? "month" : "day";
|
|
242
|
-
lines.push(line(_jsxs(Text, { dimColor: true, children: ["
|
|
250
|
+
lines.push(line(_jsxs(Text, { dimColor: true, children: [`${" ".repeat(gutter + 1)}└`, chart.columns.map((col, i) => i % labelStride === 0 ? col.label.padStart(2).slice(-2).padEnd(columnWidth) : " ".repeat(columnWidth)).join("").trimEnd(), " ", axisUnit] }), "axis"));
|
|
243
251
|
const labelWidth = Math.max(5, Math.floor(width / Math.max(1, chart.legend.length)) - 4);
|
|
244
252
|
lines.push(line(_jsx(_Fragment, { children: chart.legend.map(s => _jsxs(Text, { color: colors.get(s.key), children: [glyphs.get(s.key), " ", s.label.length > labelWidth ? `${s.label.slice(0, Math.ceil((labelWidth - 1) / 2))}…${s.label.slice(-Math.floor((labelWidth - 1) / 2))}` : s.label, " "] }, s.key)) }), "legend"));
|
|
245
253
|
}
|
package/dist/ui/accountsApi.js
CHANGED
|
@@ -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
|
-
|
|
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" ||
|
|
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
|
-
|
|
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 }); },
|
package/dist/ui/usage-chart.js
CHANGED
|
@@ -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
|
|
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()] };
|
package/dist/usage/pricing.js
CHANGED
|
@@ -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
|
|
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
|
|
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" &&
|
|
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,
|
|
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