@timo972/cc-router 0.13.0-rc.3 → 0.13.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +6 -4
- package/dist/cli/cmd-usage.js +4 -3
- package/dist/cli/index.js +1 -1
- 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 +112 -29
- package/dist/ui/accountsApi.js +55 -7
- package/dist/ui/usage-chart.js +27 -2
- package/dist/usage/client.js +14 -1
- package/dist/usage/pricing.js +17 -5
- package/dist/usage/query.js +16 -14
- package/dist/usage/types.js +16 -0
- package/docs/README.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `cc-router usage` charts and its daily grid switch between tokens and
|
|
14
|
+
estimated API spend (`s`) and narrow to input or output (`i`); every bucket,
|
|
15
|
+
model series and day in the report now carries frozen-rate USD per token
|
|
16
|
+
category. The model view (`l`) lists input, output, cache and spend per
|
|
17
|
+
model. Input, output and cache figures share one colour each across the
|
|
18
|
+
header, the cost line and the model view.
|
|
19
|
+
- The usage view fills the terminal like `cc-router status`, is indented one
|
|
20
|
+
column, and quits on `Esc`. Chart columns scale to the available width and
|
|
21
|
+
the chart shares the daily grid's right edge; the axis names its unit
|
|
22
|
+
instead of running day numbers together. Warnings sit below the grid.
|
|
23
|
+
- Subscription cost and net savings are no longer displayed in the usage
|
|
24
|
+
dashboard or the plain-text summary; the API-equivalent value stands alone,
|
|
25
|
+
broken down by input, output and cache. Both remain in `--json` output and
|
|
26
|
+
`usage subscription` still records costs. A client talking to an older
|
|
27
|
+
router shows the per-category spend as unavailable rather than zero.
|
|
28
|
+
|
|
11
29
|
### ⚠️ Breaking
|
|
12
30
|
|
|
13
31
|
- `cc-router accounts add-openai`, `login-openai`, `add-grok` and `login-grok`
|
|
@@ -35,6 +53,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
35
53
|
- `POST /cc-router/accounts/:id/refresh` refreshes one account. The
|
|
36
54
|
dashboard's `R` uses it when an account is selected and reloads the whole
|
|
37
55
|
pool otherwise.
|
|
56
|
+
- Dashboard `rst` column and `Ctrl+R` now cover Claude accounts: banked
|
|
57
|
+
usage-limit resets (e.g. the Opus 5.5 launch reset) are shown and can be
|
|
58
|
+
redeemed after confirmation. Claude usage polls now identify as Claude Code,
|
|
59
|
+
which Anthropic requires before offering resets. The redemption endpoint is
|
|
60
|
+
undocumented and may change.
|
|
38
61
|
|
|
39
62
|
### Fixed
|
|
40
63
|
|
package/README.md
CHANGED
|
@@ -109,12 +109,14 @@ it later. Adding more accounts is `cc-router accounts login`, re-signing one in
|
|
|
109
109
|
Per-platform token extraction, Codex CLI, Docker and everything else lives in
|
|
110
110
|
[the docs](docs/).
|
|
111
111
|
|
|
112
|
-
## Usage history and
|
|
112
|
+
## Usage history and API-equivalent cost
|
|
113
113
|
|
|
114
114
|
Run `cc-router usage` for persistent token history, provider/model stacked bars,
|
|
115
|
-
and a daily activity grid
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
and a daily activity grid, each switchable between tokens and what the recorded
|
|
116
|
+
tokens would have cost at standard API rates, and narrowable to input or output.
|
|
117
|
+
Switch between day, week, month and year; history survives service restarts.
|
|
118
|
+
Monthly subscription costs can be recorded for comparison in the `--json`
|
|
119
|
+
report:
|
|
118
120
|
|
|
119
121
|
```bash
|
|
120
122
|
cc-router usage subscription set personal --monthly-usd 100 --from 2026-09-01
|
package/dist/cli/cmd-usage.js
CHANGED
|
@@ -46,15 +46,16 @@ export function formatUsageText(report) {
|
|
|
46
46
|
const money = (n) => n === null ? "unavailable" : `${n < 0 ? "-" : ""}$${Math.abs(n).toFixed(2)}`;
|
|
47
47
|
return [
|
|
48
48
|
`Usage ${report.period}: ${report.start.slice(0, 10)} — ${report.end.slice(0, 10)} (UTC, end exclusive)`,
|
|
49
|
+
// Same four categories the dashboard shows, so the two surfaces read alike.
|
|
49
50
|
`Tokens: ${totalTokens(t).toLocaleString("en-US")} | input ${t.input} | output ${t.output} | cache read ${t.cacheRead} | cache write ${t.cacheWrite}`,
|
|
51
|
+
// Subscription cost and net savings are hidden for now; the API-equivalent
|
|
52
|
+
// value is the figure that stands on its own. Both remain in --json.
|
|
50
53
|
`API equivalent: ${money(c.pricedApiUsd)}${c.coverage.pricingComplete ? "" : " (partial; unpriced tokens excluded)"}`,
|
|
51
|
-
`Subscription: ${money(c.subscriptionUsd)}${c.coverage.subscriptionComplete ? "" : " (partial; configure missing costs)"}`,
|
|
52
|
-
`Net savings: ${money(c.savingsUsd)}${c.savingsPercent === null ? "" : ` (${c.savingsPercent.toFixed(1)}%)`}`,
|
|
53
54
|
...report.warnings.map(w => `Warning: ${w}`),
|
|
54
55
|
].join("\n");
|
|
55
56
|
}
|
|
56
57
|
export function registerUsage(program) {
|
|
57
|
-
const usage = program.command("usage").description("Persistent token usage and API-equivalent
|
|
58
|
+
const usage = program.command("usage").description("Persistent token usage and its API-equivalent cost")
|
|
58
59
|
.option("--period <period>", "day, week, month or year", "month")
|
|
59
60
|
.option("--date <date>", "Date within the selected period (YYYY-MM-DD, UTC)")
|
|
60
61
|
.option("--provider <provider>", "Filter Claude, OpenAI or Grok (repeatable)", (value, previous = []) => [...previous, value])
|
package/dist/cli/index.js
CHANGED
|
@@ -31,7 +31,7 @@ Examples:
|
|
|
31
31
|
$ cc-router start --reconfigure# Re-ask run preferences
|
|
32
32
|
$ cc-router stop # Stop proxy (offers to remove auto-start / config)
|
|
33
33
|
$ cc-router status # Live dashboard with account stats
|
|
34
|
-
$ cc-router usage # Persistent token usage and
|
|
34
|
+
$ cc-router usage # Persistent token usage and its API-equivalent cost
|
|
35
35
|
$ cc-router models list # List dynamically discovered provider models
|
|
36
36
|
$ cc-router logs # View proxy logs (background mode)
|
|
37
37
|
$ cc-router accounts list # Show Claude, ChatGPT, and Grok accounts
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ResetNotSubmittedError, RESET_OUTCOME_UNKNOWN } from "../../proxy/reset-errors.js";
|
|
2
|
+
import { CLAUDE_CODE_USER_AGENT, OAUTH_BETA_HEADER } from "./usage.js";
|
|
3
|
+
const CODES = ["reset", "already_used", "not_limited", "cooldown", "ineligible"];
|
|
4
|
+
// Claude Code maps these to "Couldn't confirm the reset went through" and keeps
|
|
5
|
+
// the claim for a same-id retry; so do we, whatever `result` says.
|
|
6
|
+
const UNCONFIRMED_REASONS = ["reset_unconfirmed", "stamp_indeterminate"];
|
|
7
|
+
const ORG_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
|
|
9
|
+
const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
10
|
+
export async function consumeClaudeLimitReset(account, orgUuid, grantId, requestId, options = {}) {
|
|
11
|
+
// Contract: Claude Code 2.1.280 `/limit-reset` (program "cedar_ember").
|
|
12
|
+
// Undocumented; reverse-engineered from the CLI bundle on 2026-09-23.
|
|
13
|
+
if (!ORG_UUID.test(orgUuid) || !GRANT_ID.test(grantId) || !REQUEST_ID.test(requestId)) {
|
|
14
|
+
throw new ResetNotSubmittedError(503, "Reset request malformed; reset not submitted");
|
|
15
|
+
}
|
|
16
|
+
const request = options.fetch ?? globalThis.fetch;
|
|
17
|
+
// Never retry a spend with a fresh ID: a lost response may have consumed it.
|
|
18
|
+
try {
|
|
19
|
+
const response = await request(`https://api.anthropic.com/api/organizations/${orgUuid}/reset_rate_limits`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
authorization: `Bearer ${account.tokens.accessToken}`,
|
|
23
|
+
"anthropic-beta": OAUTH_BETA_HEADER,
|
|
24
|
+
"user-agent": CLAUDE_CODE_USER_AGENT,
|
|
25
|
+
"content-type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ program: "cedar_ember", grant_id: grantId, request_id: requestId }),
|
|
28
|
+
signal: AbortSignal.timeout(10_000),
|
|
29
|
+
redirect: "error",
|
|
30
|
+
});
|
|
31
|
+
if (response.status === 401 || response.status === 403) {
|
|
32
|
+
throw new ResetNotSubmittedError(503, "Account credentials rejected; reset not submitted");
|
|
33
|
+
}
|
|
34
|
+
if (response.ok) {
|
|
35
|
+
const body = await response.json();
|
|
36
|
+
if (body && typeof body === "object" && "result" in body) {
|
|
37
|
+
const code = body.result;
|
|
38
|
+
const left = body.resets_left;
|
|
39
|
+
const reason = body.reason;
|
|
40
|
+
// `unavailable` is not in CODES: an unconfirmed claim is never "nothing used".
|
|
41
|
+
if (typeof reason === "string" && UNCONFIRMED_REASONS.includes(reason))
|
|
42
|
+
throw new Error(RESET_OUTCOME_UNKNOWN);
|
|
43
|
+
if (CODES.includes(code)) {
|
|
44
|
+
return {
|
|
45
|
+
code: code,
|
|
46
|
+
...(typeof left === "number" && Number.isInteger(left) && left >= 0 ? { resetsLeft: left } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof ResetNotSubmittedError)
|
|
54
|
+
throw error;
|
|
55
|
+
// Do not relay upstream bodies, credentials, or network error details.
|
|
56
|
+
}
|
|
57
|
+
throw new Error(RESET_OUTCOME_UNKNOWN);
|
|
58
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { nextEventSequence } from "../../proxy/event-sequence.js";
|
|
2
|
-
const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
|
|
3
|
-
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
|
2
|
+
const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage?cedar_ember=1";
|
|
3
|
+
export const OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
|
4
|
+
/** Reset status is only offered to the Claude Code surface; bump when the server answers `cli_version`. */
|
|
5
|
+
export const CLAUDE_CODE_UA_VERSION = "2.1.280";
|
|
6
|
+
export const CLAUDE_CODE_USER_AGENT = `claude-cli/${CLAUDE_CODE_UA_VERSION} (external, cli)`;
|
|
4
7
|
const DEFAULT_USAGE_TIMEOUT_MS = 5_000;
|
|
5
8
|
const USAGE_FIELDS = new Set([
|
|
6
9
|
"five_hour",
|
|
@@ -136,6 +139,59 @@ function parseExtraUsage(value) {
|
|
|
136
139
|
parsed.limitMinor = limitMinor;
|
|
137
140
|
return parsed;
|
|
138
141
|
}
|
|
142
|
+
const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
|
|
143
|
+
const RESET_WINDOWS = [
|
|
144
|
+
"five_hour", "seven_day", "seven_day_overage_included", "seven_day_opus", "seven_day_sonnet",
|
|
145
|
+
];
|
|
146
|
+
const INELIGIBLE_REASONS = new Set([
|
|
147
|
+
"config_off", "tier", "seat", "mobile", "surface", "cli_version", "no_grant",
|
|
148
|
+
"tenure", "other_experiment", "unavailable",
|
|
149
|
+
]);
|
|
150
|
+
function parseResetGrant(value) {
|
|
151
|
+
if (!isRecord(value))
|
|
152
|
+
return undefined;
|
|
153
|
+
const id = typeof value.id === "string" && GRANT_ID.test(value.id) ? value.id : undefined;
|
|
154
|
+
const left = value.resets_left;
|
|
155
|
+
if (!id || typeof left !== "number" || !Number.isInteger(left) || left < 0)
|
|
156
|
+
return undefined;
|
|
157
|
+
const reported = Array.isArray(value.clears) ? value.clears : undefined;
|
|
158
|
+
const clears = reported ? RESET_WINDOWS.filter(window => reported.includes(window)) : [];
|
|
159
|
+
// Anything not named here would be refilled without the operator seeing it.
|
|
160
|
+
const clearsOther = !reported || reported.some(window => !RESET_WINDOWS.includes(window));
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
resetsLeft: left,
|
|
164
|
+
endsAt: resetAt(value.ends_at),
|
|
165
|
+
clears,
|
|
166
|
+
clearsOther,
|
|
167
|
+
usableNow: value.usable_now === true,
|
|
168
|
+
useRequiresLimit: value.use_requires_limit !== false,
|
|
169
|
+
paused: value.paused === true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Parse the cedar_ember block. Unknown or malformed → undefined, never "zero resets". */
|
|
173
|
+
export function parseLimitResets(value) {
|
|
174
|
+
if (!isRecord(value) || typeof value.eligible !== "boolean")
|
|
175
|
+
return undefined;
|
|
176
|
+
// An eligible account's count is only established by an actual list; an
|
|
177
|
+
// explicit [] is a real zero. Ineligible blocks may omit it.
|
|
178
|
+
if (value.eligible && !Array.isArray(value.grants))
|
|
179
|
+
return undefined;
|
|
180
|
+
const grants = (Array.isArray(value.grants) ? value.grants : [])
|
|
181
|
+
.map(parseResetGrant)
|
|
182
|
+
.filter((grant) => grant !== undefined);
|
|
183
|
+
const next = typeof value.next_grant_id === "string" && grants.some(grant => grant.id === value.next_grant_id)
|
|
184
|
+
? value.next_grant_id
|
|
185
|
+
: undefined;
|
|
186
|
+
const reason = stringValue(value.ineligible_reason);
|
|
187
|
+
return {
|
|
188
|
+
eligible: value.eligible,
|
|
189
|
+
...(reason ? { ineligibleReason: INELIGIBLE_REASONS.has(reason) ? reason : "unknown" } : {}),
|
|
190
|
+
grants,
|
|
191
|
+
...(next ? { nextGrantId: next } : {}),
|
|
192
|
+
cooldownUntil: resetAt(value.cooldown_until),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
139
195
|
function legacyModelLimit(family, value) {
|
|
140
196
|
const window = parseWindow(value);
|
|
141
197
|
if (!window)
|
|
@@ -171,12 +227,15 @@ export function parseAnthropicUsage(value, fetchedAt, requestedSeq) {
|
|
|
171
227
|
const fiveHour = parseWindow(value.five_hour);
|
|
172
228
|
const sevenDay = parseWindow(value.seven_day);
|
|
173
229
|
const extraUsage = parseExtraUsage(value.extra_usage);
|
|
230
|
+
const limitResets = parseLimitResets(value.cedar_ember);
|
|
174
231
|
if (fiveHour)
|
|
175
232
|
snapshot.fiveHour = fiveHour;
|
|
176
233
|
if (sevenDay)
|
|
177
234
|
snapshot.sevenDay = sevenDay;
|
|
178
235
|
if (extraUsage)
|
|
179
236
|
snapshot.extraUsage = extraUsage;
|
|
237
|
+
if (limitResets)
|
|
238
|
+
snapshot.limitResets = limitResets;
|
|
180
239
|
return snapshot;
|
|
181
240
|
}
|
|
182
241
|
export function canUseExtraUsage(state) {
|
|
@@ -208,6 +267,7 @@ export async function fetchAnthropicUsage(account, options = {}) {
|
|
|
208
267
|
headers: {
|
|
209
268
|
Authorization: `Bearer ${account.tokens.accessToken}`,
|
|
210
269
|
"anthropic-beta": OAUTH_BETA_HEADER,
|
|
270
|
+
"user-agent": CLAUDE_CODE_USER_AGENT,
|
|
211
271
|
},
|
|
212
272
|
signal: controller.signal,
|
|
213
273
|
});
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { ResetNotSubmittedError, RESET_OUTCOME_UNKNOWN } from "./reset-errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Answer with a refusal that provably sent nothing upstream. The explicit
|
|
4
|
+
* `notSubmitted` marker is what lets a client tell it apart from an error a
|
|
5
|
+
* gateway in between may synthesize after the claim did go out.
|
|
6
|
+
*/
|
|
7
|
+
function refuse(res, status, error, extra = {}) {
|
|
8
|
+
res.status(status).json({ error, notSubmitted: true, ...extra });
|
|
9
|
+
}
|
|
1
10
|
export function createUsageResetHandler(options) {
|
|
2
11
|
const inFlight = new WeakSet();
|
|
3
12
|
// One retained snapshot per account allows an uncertain retry to reconcile
|
|
@@ -7,33 +16,35 @@ export function createUsageResetHandler(options) {
|
|
|
7
16
|
const id = req.params.id;
|
|
8
17
|
const requestId = req.body?.redeemRequestId;
|
|
9
18
|
if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId)) {
|
|
10
|
-
res
|
|
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
|
: {
|