@smithers-orchestrator/usage 0.23.0
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/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +35 -0
- package/src/UsageReport.ts +33 -0
- package/src/UsageSource.ts +9 -0
- package/src/UsageWindow.ts +28 -0
- package/src/anthropicHeaderUsage.js +51 -0
- package/src/buildUsageReport.js +43 -0
- package/src/claudeOauthUsage.js +52 -0
- package/src/codexWhamUsage.js +45 -0
- package/src/decodeJwtClaims.js +26 -0
- package/src/formatRelativeReset.js +16 -0
- package/src/formatUsageReports.js +59 -0
- package/src/getAccountUsage.js +50 -0
- package/src/getUsageForAccounts.js +75 -0
- package/src/googleUsage.js +20 -0
- package/src/humanizeDurationShort.js +20 -0
- package/src/index.d.ts +442 -0
- package/src/index.js +21 -0
- package/src/openaiHeaderUsage.js +59 -0
- package/src/parseAnthropicRateLimitHeaders.js +57 -0
- package/src/parseClaudeOauthUsage.js +47 -0
- package/src/parseCodexUsage.js +77 -0
- package/src/parseDurationSeconds.js +38 -0
- package/src/parseOpenAiRateLimitHeaders.js +60 -0
- package/src/publishedCaps.js +29 -0
- package/src/readClaudeCredentials.js +69 -0
- package/src/readCodexCredentials.js +41 -0
- package/src/usageCache.js +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 William Cory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @smithers-orchestrator/usage
|
|
2
|
+
|
|
3
|
+
Report how much rate limit or subscription quota each registered Smithers account
|
|
4
|
+
has consumed. Powers `smithers usage`.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
$ smithers usage
|
|
8
|
+
ACCOUNT PROVIDER PLAN WINDOW USED RESETS IN
|
|
9
|
+
claude-work claude-code max 5-hour session 33% 2h 41m
|
|
10
|
+
claude-work claude-code max weekly 13% 5d 3h
|
|
11
|
+
codex-main codex pro 5-hour 12% 4h 02m
|
|
12
|
+
codex-main codex pro weekly 40% 6d 1h
|
|
13
|
+
openai-ci openai-api — requests/min 820/1000 left 0m 42s
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it reads, per provider
|
|
17
|
+
|
|
18
|
+
The numbers live in three incompatible shapes, so every adapter normalizes to one
|
|
19
|
+
`UsageReport`.
|
|
20
|
+
|
|
21
|
+
| Provider | Source | Auth read from |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `claude-code` | `GET api.anthropic.com/api/oauth/usage` (5h + weekly %) | `<configDir>/.credentials.json` or macOS Keychain `Claude Code-credentials` |
|
|
24
|
+
| `codex` | `GET chatgpt.com/backend-api/wham/usage` (5h + weekly %) | `<configDir>/auth.json` |
|
|
25
|
+
| `anthropic-api` | live `anthropic-ratelimit-*` headers off `count_tokens` | account `apiKey` |
|
|
26
|
+
| `openai-api` | live `x-ratelimit-*` headers off a `max_tokens:1` POST | account `apiKey` |
|
|
27
|
+
| `gemini` / `antigravity` / `gemini-api` | none yet (Google exposes no live quota) | — |
|
|
28
|
+
| `kimi`, others | none | — |
|
|
29
|
+
|
|
30
|
+
The subscription endpoints (`claude-code`, `codex`) are undocumented: they are the
|
|
31
|
+
same endpoints the official CLIs call, reachable by reading the CLI's own OAuth
|
|
32
|
+
token off disk. They are best-effort — any failure degrades to a `source: "none"`
|
|
33
|
+
report with a readable reason, never an exception.
|
|
34
|
+
|
|
35
|
+
## Design
|
|
36
|
+
|
|
37
|
+
- `getAccountUsage(account)` is the dispatcher. It switches on `account.provider`,
|
|
38
|
+
mirroring `accountToProviderEnv` in `@smithers-orchestrator/accounts`.
|
|
39
|
+
- `getUsageForAccounts(accounts, opts)` fans out in parallel through an on-disk
|
|
40
|
+
cache (`usage-cache.json`). Cached reports come back with `stale: true`. The
|
|
41
|
+
cache enforces a hard 180s floor for `claude-code` because its usage endpoint
|
|
42
|
+
429s aggressively below that.
|
|
43
|
+
- Credentials are read on the host that owns them. Only the normalized
|
|
44
|
+
`UsageReport` ever leaves the process — no token is returned or logged.
|
|
45
|
+
|
|
46
|
+
## Safety
|
|
47
|
+
|
|
48
|
+
- The Claude usage endpoint requires `User-Agent: claude-code/<ver>` and
|
|
49
|
+
`anthropic-beta: oauth-2025-04-20`. Override the UA with
|
|
50
|
+
`SMITHERS_CLAUDE_CODE_UA` if the installed version matters.
|
|
51
|
+
- `--fresh` bypasses the soft cache but never the hard per-provider floor.
|
|
52
|
+
|
|
53
|
+
See `.smithers/specs/usage-and-limits.md` for the full design and the phased plan
|
|
54
|
+
(gateway RPC, studio dashboard, Google local-estimate accounting).
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smithers-orchestrator/usage",
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"description": "Report how much rate limit or subscription quota each registered Smithers account has consumed, across Claude, Codex, OpenAI, Anthropic, and Google providers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
10
|
+
"import": "./src/index.js",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./*": {
|
|
14
|
+
"types": "./src/index.d.ts",
|
|
15
|
+
"import": "./src/*.js",
|
|
16
|
+
"default": "./src/*.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@smithers-orchestrator/errors": "0.23.0",
|
|
24
|
+
"@smithers-orchestrator/accounts": "0.23.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"typescript": "~5.9.3"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test tests",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
|
+
"build": "tsup --dts-only"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AccountProvider } from "@smithers-orchestrator/accounts";
|
|
2
|
+
import type { UsageSource } from "./UsageSource";
|
|
3
|
+
import type { UsageWindow } from "./UsageWindow";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalized usage for a single registered account. Every adapter — subscription
|
|
7
|
+
* utilization, API-key headers, local estimate — produces this same shape so the
|
|
8
|
+
* CLI, gateway, and UI render one model.
|
|
9
|
+
*/
|
|
10
|
+
export type UsageReport = {
|
|
11
|
+
/** The account's label in `~/.smithers/accounts.json`. */
|
|
12
|
+
accountLabel: string;
|
|
13
|
+
/** The account's provider. */
|
|
14
|
+
provider: AccountProvider;
|
|
15
|
+
/** How this account authenticates. */
|
|
16
|
+
authMode: "subscription" | "api-key";
|
|
17
|
+
/** Where the numbers came from. */
|
|
18
|
+
source: UsageSource;
|
|
19
|
+
/** Quota windows, possibly empty when `source` is `none`. */
|
|
20
|
+
windows: UsageWindow[];
|
|
21
|
+
/** Plan/tier label if the provider reports one, e.g. "max", "pro". */
|
|
22
|
+
planType?: string;
|
|
23
|
+
/** Pay-as-you-go credit balance, if the provider reports one (Codex). */
|
|
24
|
+
credits?: { hasCredits: boolean; unlimited: boolean; balance?: string };
|
|
25
|
+
/** ISO-8601 timestamp of when this report was produced. */
|
|
26
|
+
fetchedAt: string;
|
|
27
|
+
/** True when served from cache past its soft TTL. */
|
|
28
|
+
stale: boolean;
|
|
29
|
+
/** True when the windows are locally estimated, not provider-authoritative. */
|
|
30
|
+
estimate: boolean;
|
|
31
|
+
/** Human-readable reason when `source` is `none` or a probe failed. */
|
|
32
|
+
error?: string;
|
|
33
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a usage report's numbers came from.
|
|
3
|
+
*
|
|
4
|
+
* - `oauth` — an authenticated subscription usage endpoint (Claude, Codex).
|
|
5
|
+
* - `headers` — live rate-limit response headers from an API-key request.
|
|
6
|
+
* - `local` — estimated locally from token logs (Google providers).
|
|
7
|
+
* - `none` — the provider exposes no usage surface, or the probe failed.
|
|
8
|
+
*/
|
|
9
|
+
export type UsageSource = "oauth" | "headers" | "local" | "none";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One quota window for an account: a 5-hour session, a weekly cap, a per-minute
|
|
3
|
+
* request bucket, and so on.
|
|
4
|
+
*
|
|
5
|
+
* The `unit` decides which fields are meaningful:
|
|
6
|
+
* - `percent` — subscription utilization; read `usedPercent` (0–100).
|
|
7
|
+
* - `count` — API-key buckets; read `limit`, `remaining`, `used`.
|
|
8
|
+
* - `estimated` — locally estimated; read `usedPercent`/`used`/`limit`, treat as
|
|
9
|
+
* a lower bound, never as authoritative.
|
|
10
|
+
*/
|
|
11
|
+
export type UsageWindow = {
|
|
12
|
+
/** Stable id, e.g. "5h" | "weekly" | "requests-per-min" | "tokens-per-min". */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Human label, e.g. "5-hour session". */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Which fields below are meaningful. */
|
|
17
|
+
unit: "percent" | "count" | "estimated";
|
|
18
|
+
/** 0–100. Set for `percent` and `estimated`. */
|
|
19
|
+
usedPercent?: number;
|
|
20
|
+
/** Absolute amount consumed. Set for `count` and `estimated`. */
|
|
21
|
+
used?: number;
|
|
22
|
+
/** Absolute cap. Set for `count` and `estimated`. */
|
|
23
|
+
limit?: number;
|
|
24
|
+
/** `limit - used`. Set for `count`. */
|
|
25
|
+
remaining?: number;
|
|
26
|
+
/** ISO-8601 timestamp when this window rolls over. */
|
|
27
|
+
resetsAt?: string;
|
|
28
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseAnthropicRateLimitHeaders } from "./parseAnthropicRateLimitHeaders.js";
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
|
|
4
|
+
|
|
5
|
+
const COUNT_TOKENS_URL = "https://api.anthropic.com/v1/messages/count_tokens";
|
|
6
|
+
|
|
7
|
+
/** The count_tokens probe needs a valid model id but generates no output tokens. */
|
|
8
|
+
const PROBE_MODEL = process.env.SMITHERS_ANTHROPIC_PROBE_MODEL ?? "claude-sonnet-4-20250514";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads live Anthropic rate-limit headers for an API-key account. Uses the
|
|
12
|
+
* `count_tokens` endpoint, which returns the rate-limit header family without
|
|
13
|
+
* producing output tokens.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ apiKey?: string }} account
|
|
16
|
+
* @returns {Promise<UsageProbe>}
|
|
17
|
+
*/
|
|
18
|
+
export async function anthropicHeaderUsage(account) {
|
|
19
|
+
const apiKey = account.apiKey;
|
|
20
|
+
if (!apiKey) {
|
|
21
|
+
return { source: "none", error: "Account has no API key set" };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(COUNT_TOKENS_URL, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"x-api-key": apiKey,
|
|
28
|
+
"anthropic-version": "2023-06-01",
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({ model: PROBE_MODEL, messages: [{ role: "user", content: "hi" }] }),
|
|
32
|
+
signal: AbortSignal.timeout(6_000),
|
|
33
|
+
});
|
|
34
|
+
if (res.status === 401) {
|
|
35
|
+
return { source: "none", error: "ANTHROPIC_API_KEY rejected (401)" };
|
|
36
|
+
}
|
|
37
|
+
const get = (name) => res.headers.get(name);
|
|
38
|
+
const windows = parseAnthropicRateLimitHeaders(get);
|
|
39
|
+
if (res.status === 429) {
|
|
40
|
+
const retryAfter = res.headers.get("retry-after");
|
|
41
|
+
return {
|
|
42
|
+
source: "headers",
|
|
43
|
+
windows,
|
|
44
|
+
error: `Rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { source: "headers", windows };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { source: "none", error: `Anthropic header probe failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { API_KEY_PROVIDERS } from "@smithers-orchestrator/accounts";
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
|
|
4
|
+
/** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
|
|
5
|
+
/** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The partial result an adapter returns. The dispatcher wraps it with the
|
|
9
|
+
* account identity and timestamp to form a complete {@link UsageReport}.
|
|
10
|
+
*
|
|
11
|
+
* @typedef {object} UsageProbe
|
|
12
|
+
* @property {import("./UsageSource.ts").UsageSource} source
|
|
13
|
+
* @property {UsageWindow[]} [windows]
|
|
14
|
+
* @property {string} [planType]
|
|
15
|
+
* @property {{ hasCredits: boolean; unlimited: boolean; balance?: string }} [credits]
|
|
16
|
+
* @property {boolean} [estimate]
|
|
17
|
+
* @property {string} [error]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Assembles a full usage report from an account and an adapter probe. Keeps the
|
|
22
|
+
* adapters free of repeated identity/timestamp boilerplate.
|
|
23
|
+
*
|
|
24
|
+
* @param {Account} account
|
|
25
|
+
* @param {UsageProbe} probe
|
|
26
|
+
* @param {{ nowIso?: string }} [options]
|
|
27
|
+
* @returns {UsageReport}
|
|
28
|
+
*/
|
|
29
|
+
export function buildUsageReport(account, probe, options = {}) {
|
|
30
|
+
return {
|
|
31
|
+
accountLabel: account.label,
|
|
32
|
+
provider: account.provider,
|
|
33
|
+
authMode: API_KEY_PROVIDERS.has(account.provider) ? "api-key" : "subscription",
|
|
34
|
+
source: probe.source,
|
|
35
|
+
windows: probe.windows ?? [],
|
|
36
|
+
planType: probe.planType,
|
|
37
|
+
credits: probe.credits,
|
|
38
|
+
fetchedAt: options.nowIso ?? new Date().toISOString(),
|
|
39
|
+
stale: false,
|
|
40
|
+
estimate: probe.estimate ?? false,
|
|
41
|
+
error: probe.error,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { parseClaudeOauthUsage } from "./parseClaudeOauthUsage.js";
|
|
2
|
+
import { readClaudeCredentials } from "./readClaudeCredentials.js";
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
|
|
5
|
+
|
|
6
|
+
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The User-Agent must start with `claude-code/`; without it the endpoint drops
|
|
10
|
+
* the caller into an aggressively rate-limited bucket. Overridable for when the
|
|
11
|
+
* real installed version matters.
|
|
12
|
+
*/
|
|
13
|
+
const USER_AGENT = process.env.SMITHERS_CLAUDE_CODE_UA ?? "claude-code/2.0.0";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Probes the Claude Code subscription usage endpoint for an account's 5-hour and
|
|
17
|
+
* weekly utilization. Undocumented and best-effort: any failure degrades to a
|
|
18
|
+
* `none` report with a readable reason.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ configDir?: string }} account
|
|
21
|
+
* @returns {Promise<UsageProbe>}
|
|
22
|
+
*/
|
|
23
|
+
export async function claudeOauthUsage(account) {
|
|
24
|
+
const creds = readClaudeCredentials(account);
|
|
25
|
+
if (!creds) {
|
|
26
|
+
return { source: "none", error: "No Claude OAuth credentials in configDir or Keychain" };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(USAGE_URL, {
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
32
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
33
|
+
"User-Agent": USER_AGENT,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
signal: AbortSignal.timeout(6_000),
|
|
37
|
+
});
|
|
38
|
+
if (res.status === 401) {
|
|
39
|
+
return { source: "none", error: "Claude OAuth token rejected (401); run `claude` to refresh" };
|
|
40
|
+
}
|
|
41
|
+
if (res.status === 429) {
|
|
42
|
+
return { source: "none", error: "Claude usage endpoint rate limited (429); try again shortly" };
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
return { source: "none", error: `Claude usage endpoint returned ${res.status}` };
|
|
46
|
+
}
|
|
47
|
+
const payload = await res.json();
|
|
48
|
+
return { source: "oauth", windows: parseClaudeOauthUsage(payload) };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { source: "none", error: `Claude usage probe failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { parseCodexUsage } from "./parseCodexUsage.js";
|
|
2
|
+
import { readCodexCredentials } from "./readCodexCredentials.js";
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
|
|
5
|
+
|
|
6
|
+
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Probes the Codex ChatGPT-subscription usage endpoint for an account's 5-hour
|
|
10
|
+
* and weekly windows. This is the same data the Codex `/status` view shows and
|
|
11
|
+
* does not spend a turn. Undocumented and best-effort.
|
|
12
|
+
*
|
|
13
|
+
* @param {{ configDir?: string }} account
|
|
14
|
+
* @returns {Promise<UsageProbe>}
|
|
15
|
+
*/
|
|
16
|
+
export async function codexWhamUsage(account) {
|
|
17
|
+
const creds = readCodexCredentials(account);
|
|
18
|
+
if (!creds) {
|
|
19
|
+
return { source: "none", error: "No Codex ChatGPT credentials in configDir/auth.json" };
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
/** @type {Record<string, string>} */
|
|
23
|
+
const headers = {
|
|
24
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
25
|
+
"User-Agent": "codex-cli",
|
|
26
|
+
Accept: "application/json",
|
|
27
|
+
};
|
|
28
|
+
if (creds.accountId) headers["ChatGPT-Account-Id"] = creds.accountId;
|
|
29
|
+
const res = await fetch(USAGE_URL, {
|
|
30
|
+
headers,
|
|
31
|
+
signal: AbortSignal.timeout(6_000),
|
|
32
|
+
});
|
|
33
|
+
if (res.status === 401) {
|
|
34
|
+
return { source: "none", error: "Codex token rejected (401); run `codex` to refresh" };
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
return { source: "none", error: `Codex usage endpoint returned ${res.status}` };
|
|
38
|
+
}
|
|
39
|
+
const payload = await res.json();
|
|
40
|
+
const { windows, planType, credits } = parseCodexUsage(payload);
|
|
41
|
+
return { source: "oauth", windows, planType, credits };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { source: "none", error: `Codex usage probe failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes the claims (the middle segment) of a JWT without verifying its
|
|
3
|
+
* signature. Used to read the `chatgpt_account_id` claim out of the Codex
|
|
4
|
+
* `id_token` when `auth.json` does not carry `tokens.account_id` directly.
|
|
5
|
+
*
|
|
6
|
+
* Verification is intentionally skipped: the token already authenticated the
|
|
7
|
+
* user with the provider, and we only read a non-secret routing claim from it.
|
|
8
|
+
*
|
|
9
|
+
* Returns an empty object for anything that is not a decodable JWT.
|
|
10
|
+
*
|
|
11
|
+
* @param {string | null | undefined} token
|
|
12
|
+
* @returns {Record<string, unknown>}
|
|
13
|
+
*/
|
|
14
|
+
export function decodeJwtClaims(token) {
|
|
15
|
+
if (typeof token !== "string") return {};
|
|
16
|
+
const parts = token.split(".");
|
|
17
|
+
if (parts.length < 2) return {};
|
|
18
|
+
try {
|
|
19
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
20
|
+
const json = Buffer.from(payload, "base64").toString("utf8");
|
|
21
|
+
const claims = JSON.parse(json);
|
|
22
|
+
return claims && typeof claims === "object" ? claims : {};
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { humanizeDurationShort } from "./humanizeDurationShort.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats an ISO reset timestamp as a relative "resets in" string. Returns an
|
|
5
|
+
* empty string when there is no timestamp, and `"now"` when it is in the past.
|
|
6
|
+
*
|
|
7
|
+
* @param {string | undefined} resetsAt
|
|
8
|
+
* @param {number} [nowMs]
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function formatRelativeReset(resetsAt, nowMs = Date.now()) {
|
|
12
|
+
if (!resetsAt) return "";
|
|
13
|
+
const ms = Date.parse(resetsAt);
|
|
14
|
+
if (Number.isNaN(ms)) return "";
|
|
15
|
+
return humanizeDurationShort((ms - nowMs) / 1000);
|
|
16
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { formatRelativeReset } from "./formatRelativeReset.js";
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
|
|
4
|
+
/** @typedef {import("./UsageWindow.ts").UsageWindow} UsageWindow */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders the "used" cell for one window.
|
|
8
|
+
*
|
|
9
|
+
* @param {UsageWindow} w
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
function usedCell(w) {
|
|
13
|
+
if (w.unit === "percent") {
|
|
14
|
+
return w.usedPercent === undefined ? "" : `${Math.round(w.usedPercent)}%`;
|
|
15
|
+
}
|
|
16
|
+
if (w.unit === "estimated") {
|
|
17
|
+
if (w.used !== undefined && w.limit !== undefined) return `~${w.used}/${w.limit}`;
|
|
18
|
+
if (w.usedPercent !== undefined) return `~${Math.round(w.usedPercent)}%`;
|
|
19
|
+
return "~?";
|
|
20
|
+
}
|
|
21
|
+
// count
|
|
22
|
+
if (w.remaining !== undefined && w.limit !== undefined) return `${w.remaining}/${w.limit} left`;
|
|
23
|
+
if (w.remaining !== undefined) return `${w.remaining} left`;
|
|
24
|
+
if (w.used !== undefined && w.limit !== undefined) return `${w.used}/${w.limit}`;
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Renders an array of usage reports as an aligned text table. Pure: pass a fixed
|
|
30
|
+
* `nowMs` in tests to get deterministic "resets in" values.
|
|
31
|
+
*
|
|
32
|
+
* @param {UsageReport[]} reports
|
|
33
|
+
* @param {number} [nowMs]
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function formatUsageReports(reports, nowMs = Date.now()) {
|
|
37
|
+
if (reports.length === 0) {
|
|
38
|
+
return "No accounts registered. Add one with `smithers agents add`.";
|
|
39
|
+
}
|
|
40
|
+
const header = ["ACCOUNT", "PROVIDER", "PLAN", "WINDOW", "USED", "RESETS IN"];
|
|
41
|
+
/** @type {string[][]} */
|
|
42
|
+
const rows = [header];
|
|
43
|
+
for (const r of reports) {
|
|
44
|
+
const plan = r.planType ?? (r.authMode === "api-key" ? "—" : "");
|
|
45
|
+
if (r.windows.length === 0) {
|
|
46
|
+
const note = r.error ?? (r.source === "none" ? "not supported" : "");
|
|
47
|
+
rows.push([r.accountLabel, r.provider, plan, "—", note, ""]);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const w of r.windows) {
|
|
51
|
+
const used = usedCell(w) + (w.unit === "estimated" ? " (est)" : "");
|
|
52
|
+
rows.push([r.accountLabel, r.provider, plan, w.label, used, formatRelativeReset(w.resetsAt, nowMs)]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const widths = header.map((_, col) => Math.max(...rows.map((row) => row[col].length)));
|
|
56
|
+
return rows
|
|
57
|
+
.map((row) => row.map((cell, col) => cell.padEnd(widths[col])).join(" ").trimEnd())
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { anthropicHeaderUsage } from "./anthropicHeaderUsage.js";
|
|
2
|
+
import { buildUsageReport } from "./buildUsageReport.js";
|
|
3
|
+
import { claudeOauthUsage } from "./claudeOauthUsage.js";
|
|
4
|
+
import { codexWhamUsage } from "./codexWhamUsage.js";
|
|
5
|
+
import { googleUsage } from "./googleUsage.js";
|
|
6
|
+
import { openaiHeaderUsage } from "./openaiHeaderUsage.js";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
|
|
9
|
+
/** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Routes an account to its usage adapter and returns a normalized report. This
|
|
13
|
+
* switch mirrors `accountToProviderEnv` in the accounts package so the two stay
|
|
14
|
+
* structurally aligned. Adapters never throw; they degrade to a `none` report.
|
|
15
|
+
*
|
|
16
|
+
* Credentials are read on the host that owns them and only the normalized report
|
|
17
|
+
* leaves this function — no token is ever returned or logged.
|
|
18
|
+
*
|
|
19
|
+
* @param {Account} account
|
|
20
|
+
* @returns {Promise<UsageReport>}
|
|
21
|
+
*/
|
|
22
|
+
export async function getAccountUsage(account) {
|
|
23
|
+
const probe = await probeFor(account);
|
|
24
|
+
return buildUsageReport(account, probe);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Account} account
|
|
29
|
+
* @returns {Promise<import("./buildUsageReport.js").UsageProbe>}
|
|
30
|
+
*/
|
|
31
|
+
async function probeFor(account) {
|
|
32
|
+
switch (account.provider) {
|
|
33
|
+
case "claude-code":
|
|
34
|
+
return claudeOauthUsage(account);
|
|
35
|
+
case "codex":
|
|
36
|
+
return codexWhamUsage(account);
|
|
37
|
+
case "anthropic-api":
|
|
38
|
+
return anthropicHeaderUsage(account);
|
|
39
|
+
case "openai-api":
|
|
40
|
+
return openaiHeaderUsage(account);
|
|
41
|
+
case "gemini":
|
|
42
|
+
case "antigravity":
|
|
43
|
+
case "gemini-api":
|
|
44
|
+
return googleUsage(account);
|
|
45
|
+
case "kimi":
|
|
46
|
+
return { source: "none", error: "Kimi exposes no usage endpoint yet" };
|
|
47
|
+
default:
|
|
48
|
+
return { source: "none", error: `Usage not supported for provider "${account.provider}"` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getAccountUsage } from "./getAccountUsage.js";
|
|
2
|
+
import { readUsageCache, writeUsageCache } from "./usageCache.js";
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("@smithers-orchestrator/accounts").Account} Account */
|
|
5
|
+
/** @typedef {import("./UsageReport.ts").UsageReport} UsageReport */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Soft refresh interval: within this age a cached report is reused on a normal
|
|
9
|
+
* run. `--fresh` bypasses it.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} provider
|
|
12
|
+
* @returns {number}
|
|
13
|
+
*/
|
|
14
|
+
function refreshIntervalMs(provider) {
|
|
15
|
+
switch (provider) {
|
|
16
|
+
case "claude-code": return 180_000;
|
|
17
|
+
case "codex": return 60_000;
|
|
18
|
+
default: return 30_000;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hard floor: never re-probe faster than this, even with `--fresh`. The Claude
|
|
24
|
+
* usage endpoint 429s aggressively below 180s, so we protect it unconditionally.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} provider
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
function hardFloorMs(provider) {
|
|
30
|
+
return provider === "claude-code" ? 180_000 : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gathers usage for many accounts in parallel, served through the on-disk cache.
|
|
35
|
+
* Cached reports come back with `stale: true`. The cache is read once and written
|
|
36
|
+
* once, so parallel probes never race on the file.
|
|
37
|
+
*
|
|
38
|
+
* @param {Account[]} accounts
|
|
39
|
+
* @param {{ fresh?: boolean; env?: NodeJS.ProcessEnv; nowMs?: number }} [options]
|
|
40
|
+
* @returns {Promise<UsageReport[]>}
|
|
41
|
+
*/
|
|
42
|
+
export async function getUsageForAccounts(accounts, options = {}) {
|
|
43
|
+
const { fresh = false, env = process.env, nowMs = Date.now() } = options;
|
|
44
|
+
const cache = readUsageCache(env);
|
|
45
|
+
const decisions = accounts.map((account) => {
|
|
46
|
+
const entry = cache.entries[account.label];
|
|
47
|
+
const fetchedAt = entry?.report?.fetchedAt;
|
|
48
|
+
const parsed = typeof fetchedAt === "string" ? nowMs - Date.parse(fetchedAt) : Number.NaN;
|
|
49
|
+
const age = Number.isFinite(parsed) ? parsed : Infinity;
|
|
50
|
+
const useCache = Number.isFinite(parsed) && (
|
|
51
|
+
age < hardFloorMs(account.provider) ||
|
|
52
|
+
(!fresh && age < refreshIntervalMs(account.provider))
|
|
53
|
+
);
|
|
54
|
+
return { account, entry, useCache };
|
|
55
|
+
});
|
|
56
|
+
const reports = await Promise.all(decisions.map(async (d) => {
|
|
57
|
+
if (d.useCache && d.entry) return { ...d.entry.report, stale: true };
|
|
58
|
+
return getAccountUsage(d.account);
|
|
59
|
+
}));
|
|
60
|
+
let changed = false;
|
|
61
|
+
reports.forEach((report, i) => {
|
|
62
|
+
if (!decisions[i].useCache) {
|
|
63
|
+
cache.entries[report.accountLabel] = { report };
|
|
64
|
+
changed = true;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
if (changed) {
|
|
68
|
+
try {
|
|
69
|
+
writeUsageCache(cache, env);
|
|
70
|
+
} catch {
|
|
71
|
+
// a cache write failure must not break the command
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return reports;
|
|
75
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** @typedef {import("./buildUsageReport.js").UsageProbe} UsageProbe */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Google (Gemini, Antigravity, Gemini API) exposes no live "remaining quota"
|
|
5
|
+
* surface to a personal-login or API-key client: there are no rate-limit
|
|
6
|
+
* response headers, only a 429 `RESOURCE_EXHAUSTED` after the wall is hit. The
|
|
7
|
+
* documented path forward is local token-log accounting against published caps
|
|
8
|
+
* (see `publishedCaps.js`), which depends on run-history integration and lands
|
|
9
|
+
* in a later phase. Until then this reports `none` honestly rather than inventing
|
|
10
|
+
* a number.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ provider: string }} account
|
|
13
|
+
* @returns {Promise<UsageProbe>}
|
|
14
|
+
*/
|
|
15
|
+
export async function googleUsage(account) {
|
|
16
|
+
return {
|
|
17
|
+
source: "none",
|
|
18
|
+
error: "Google exposes no live usage endpoint; local estimate not yet wired",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats a number of seconds as a short human duration, e.g. `"2h 41m"`,
|
|
3
|
+
* `"5d 3h"`, `"42s"`. Used for "resets in" columns. Negative input renders as
|
|
4
|
+
* `"now"`.
|
|
5
|
+
*
|
|
6
|
+
* @param {number} seconds
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function humanizeDurationShort(seconds) {
|
|
10
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return "now";
|
|
11
|
+
const total = Math.round(seconds);
|
|
12
|
+
const days = Math.floor(total / 86400);
|
|
13
|
+
const hours = Math.floor((total % 86400) / 3600);
|
|
14
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
15
|
+
const secs = total % 60;
|
|
16
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
17
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
18
|
+
if (minutes > 0) return `${minutes}m ${secs}s`;
|
|
19
|
+
return `${secs}s`;
|
|
20
|
+
}
|