@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.25 at build time. Re-run `ppm export skill --install` to refresh. -->
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.25 -->
503
+ <!-- Generated from src/server/routes/ for PPM v0.23.26 -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.23.25",
3
+ "version": "0.23.26",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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
- const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
121
- const cooldownMs = Math.max(retryAfter * 1000, MIN_COOLDOWN_MS);
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>;