@hienlh/ppm 0.23.25 → 0.23.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.23.26] - 2026-11-04
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Accounts added from `claude setup-token` show their 5-hour and weekly usage.** That token cannot read the usage page, so PPM reads the figures from a one-token message to Haiku every 5 minutes, which spends a tiny amount of the account's limit.
|
|
8
|
+
|
|
3
9
|
## [0.23.25] - 2026-11-04
|
|
4
10
|
|
|
5
11
|
### Added
|
|
@@ -73,4 +73,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
|
|
|
73
73
|
- Third-party extensions (inspect via `ppm ext list`).
|
|
74
74
|
- The Claude Agent SDK internals (separate skill).
|
|
75
75
|
|
|
76
|
-
<!-- Generated for PPM v0.23.
|
|
76
|
+
<!-- Generated for PPM v0.23.26 at build time. Re-run `ppm export skill --install` to refresh. -->
|
|
@@ -500,4 +500,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
|
|
|
500
500
|
- `ws://<host>/ws/terminal` — PTY terminal multiplexer
|
|
501
501
|
- `ws://<host>/ws/extensions` — extension host channel
|
|
502
502
|
|
|
503
|
-
<!-- Generated from src/server/routes/ for PPM v0.23.
|
|
503
|
+
<!-- Generated from src/server/routes/ for PPM v0.23.26 -->
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getAllLatestSnapshots,
|
|
8
8
|
type LimitSnapshotRow,
|
|
9
9
|
} from "./db.service.ts";
|
|
10
|
-
import { accountService } from "./account.service.ts";
|
|
10
|
+
import { accountService, isMissingScopeResponse } from "./account.service.ts";
|
|
11
11
|
import { decrypt } from "../lib/account-crypto.ts";
|
|
12
12
|
import { accountSelector } from "./account-selector.service.ts";
|
|
13
13
|
import { registerAllUsageSources } from "./provider-usage/register-usage-sources.ts";
|
|
@@ -46,6 +46,12 @@ export interface AccountUsageEntry {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
const API_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
49
|
+
const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
50
|
+
/**
|
|
51
|
+
* The cheapest model there is. Reading usage from response headers is a real request billed to
|
|
52
|
+
* the account (about ten input tokens and one output token), so it goes to the smallest model.
|
|
53
|
+
*/
|
|
54
|
+
const HEADER_PROBE_MODEL = "claude-haiku-4-5";
|
|
49
55
|
const API_BETA = "oauth-2025-04-20";
|
|
50
56
|
const USER_AGENT = "claude-code/1.0";
|
|
51
57
|
const FETCH_TIMEOUT = 10_000;
|
|
@@ -106,6 +112,60 @@ function snapshotToUsage(row: LimitSnapshotRow): ClaudeUsage {
|
|
|
106
112
|
// Fetch usage for a single token
|
|
107
113
|
// ---------------------------------------------------------------------------
|
|
108
114
|
|
|
115
|
+
/** Back off a token after a 429, and say for how long. */
|
|
116
|
+
function coolDown(token: string, res: Response, what: string): Error {
|
|
117
|
+
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
|
|
118
|
+
const cooldownMs = Math.max(retryAfter * 1000, MIN_COOLDOWN_MS);
|
|
119
|
+
tokenCooldowns.set(token.substring(0, 20), Date.now() + cooldownMs);
|
|
120
|
+
return new Error(`${what} 429 — cooldown ${Math.ceil(cooldownMs / 1000)}s`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One `anthropic-ratelimit-unified-<window>-*` header pair as a bucket; utilization arrives as 0–1. */
|
|
124
|
+
function headerBucket(headers: Headers, window: string, windowHours: number): LimitBucket | undefined {
|
|
125
|
+
const util = Number(headers.get(`anthropic-ratelimit-unified-${window}-utilization`) ?? NaN);
|
|
126
|
+
if (!Number.isFinite(util)) return undefined;
|
|
127
|
+
const resetS = Number(headers.get(`anthropic-ratelimit-unified-${window}-reset`) ?? 0);
|
|
128
|
+
return parseApiBucket({
|
|
129
|
+
utilization: util * 100,
|
|
130
|
+
resets_at: resetS > 0 ? new Date(resetS * 1000).toISOString() : "",
|
|
131
|
+
}, windowHours);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Usage for a token that may not read the usage endpoint: one from `claude setup-token` carries
|
|
136
|
+
* only `user:inference`, so `/api/oauth/usage` answers 403. Every message response carries the
|
|
137
|
+
* same 5-hour and 7-day figures in its `anthropic-ratelimit-unified-*` headers (Claude Code's
|
|
138
|
+
* own status line reads them there), so a one-token message reads them back. Unlike the usage
|
|
139
|
+
* endpoint this spends a little of the account's limit, which is why it is the fallback only.
|
|
140
|
+
*/
|
|
141
|
+
async function fetchUsageFromRateLimitHeaders(token: string): Promise<ClaudeUsage> {
|
|
142
|
+
const res = await fetch(MESSAGES_URL, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: {
|
|
145
|
+
"content-type": "application/json",
|
|
146
|
+
Authorization: `Bearer ${token}`,
|
|
147
|
+
"anthropic-version": "2023-06-01",
|
|
148
|
+
"anthropic-beta": API_BETA,
|
|
149
|
+
"User-Agent": USER_AGENT,
|
|
150
|
+
},
|
|
151
|
+
body: JSON.stringify({ model: HEADER_PROBE_MODEL, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
|
|
152
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
153
|
+
});
|
|
154
|
+
await res.text().catch(() => "");
|
|
155
|
+
// A refused message still reports the windows it was refused for — that is the reading an
|
|
156
|
+
// exhausted account most needs — so the headers are read whatever the status.
|
|
157
|
+
const session = headerBucket(res.headers, "5h", 5);
|
|
158
|
+
const weekly = headerBucket(res.headers, "7d", 168);
|
|
159
|
+
if (!session && !weekly) {
|
|
160
|
+
if (res.status === 429) throw coolDown(token, res, "Usage via message");
|
|
161
|
+
throw new Error(`Usage via message returned ${res.status} with no rate-limit headers`);
|
|
162
|
+
}
|
|
163
|
+
const data: ClaudeUsage = { lastFetchedAt: new Date().toISOString() };
|
|
164
|
+
if (session) data.session = session;
|
|
165
|
+
if (weekly) data.weekly = weekly;
|
|
166
|
+
return data;
|
|
167
|
+
}
|
|
168
|
+
|
|
109
169
|
async function fetchUsageForToken(token: string): Promise<ClaudeUsage> {
|
|
110
170
|
const res = await fetch(API_URL, {
|
|
111
171
|
headers: {
|
|
@@ -116,12 +176,9 @@ async function fetchUsageForToken(token: string): Promise<ClaudeUsage> {
|
|
|
116
176
|
},
|
|
117
177
|
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
118
178
|
});
|
|
119
|
-
if (res.status === 429)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const cooldownKey = token.substring(0, 20);
|
|
123
|
-
tokenCooldowns.set(cooldownKey, Date.now() + cooldownMs);
|
|
124
|
-
throw new Error(`Usage API 429 — cooldown ${Math.ceil(cooldownMs / 1000)}s`);
|
|
179
|
+
if (res.status === 429) throw coolDown(token, res, "Usage API");
|
|
180
|
+
if (res.status === 403 && isMissingScopeResponse(res.status, await res.text().catch(() => ""))) {
|
|
181
|
+
return fetchUsageFromRateLimitHeaders(token);
|
|
125
182
|
}
|
|
126
183
|
if (!res.ok) throw new Error(`Usage API returned ${res.status}`);
|
|
127
184
|
const raw = (await res.json()) as Record<string, any>;
|