@hashimkarim/usagestat-linux-x64-gnu 2.0.0-alpha.1
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 +57744 -0
- package/native-manifest.json +1414 -0
- package/package.json +254 -0
- package/plugins/abacus-ai/icon.svg +18 -0
- package/plugins/abacus-ai/plugin.js +121 -0
- package/plugins/abacus-ai/plugin.json +17 -0
- package/plugins/alibaba/icon-color.svg +1 -0
- package/plugins/alibaba/icon.svg +1 -0
- package/plugins/alibaba/plugin.js +322 -0
- package/plugins/alibaba/plugin.json +20 -0
- package/plugins/alibaba-token-plan/icon-color.svg +1 -0
- package/plugins/alibaba-token-plan/icon.svg +1 -0
- package/plugins/alibaba-token-plan/plugin.js +544 -0
- package/plugins/alibaba-token-plan/plugin.json +19 -0
- package/plugins/amp/icon-color.svg +1 -0
- package/plugins/amp/icon-conflict.svg +6 -0
- package/plugins/amp/icon.svg +1 -0
- package/plugins/amp/plugin.js +175 -0
- package/plugins/amp/plugin.json +16 -0
- package/plugins/antigravity/icon-color.svg +1 -0
- package/plugins/antigravity/icon-conflict.svg +3 -0
- package/plugins/antigravity/icon.svg +1 -0
- package/plugins/antigravity/plugin.js +739 -0
- package/plugins/antigravity/plugin.json +31 -0
- package/plugins/antigravity-cli/icon.svg +3 -0
- package/plugins/antigravity-cli/plugin.js +570 -0
- package/plugins/antigravity-cli/plugin.json +16 -0
- package/plugins/antigravity-ide/icon.svg +3 -0
- package/plugins/antigravity-ide/plugin.js +257 -0
- package/plugins/antigravity-ide/plugin.json +29 -0
- package/plugins/augment/icon.svg +14 -0
- package/plugins/augment/plugin.js +59 -0
- package/plugins/augment/plugin.json +18 -0
- package/plugins/aws-bedrock/icon-color.svg +1 -0
- package/plugins/aws-bedrock/icon.svg +1 -0
- package/plugins/aws-bedrock/plugin.js +206 -0
- package/plugins/aws-bedrock/plugin.json +18 -0
- package/plugins/azure-openai/icon-color.svg +1 -0
- package/plugins/azure-openai/icon.svg +1 -0
- package/plugins/azure-openai/plugin.js +145 -0
- package/plugins/azure-openai/plugin.json +17 -0
- package/plugins/chutes/icon.svg +7 -0
- package/plugins/chutes/plugin.js +236 -0
- package/plugins/chutes/plugin.json +19 -0
- package/plugins/claude/icon-color.svg +1 -0
- package/plugins/claude/icon.svg +1 -0
- package/plugins/claude/plugin.js +1723 -0
- package/plugins/claude/plugin.json +30 -0
- package/plugins/codebuff/icon.svg +92 -0
- package/plugins/codebuff/plugin.js +113 -0
- package/plugins/codebuff/plugin.json +17 -0
- package/plugins/codex/icon-color.svg +5 -0
- package/plugins/codex/icon.svg +1 -0
- package/plugins/codex/plugin.js +1009 -0
- package/plugins/codex/plugin.json +82 -0
- package/plugins/command-code/icon.svg +3 -0
- package/plugins/command-code/plugin.js +184 -0
- package/plugins/command-code/plugin.json +20 -0
- package/plugins/copilot/icon-color.svg +1 -0
- package/plugins/copilot/icon.svg +1 -0
- package/plugins/copilot/plugin.js +684 -0
- package/plugins/copilot/plugin.json +21 -0
- package/plugins/crof/icon.svg +3 -0
- package/plugins/crof/plugin.js +53 -0
- package/plugins/crof/plugin.json +17 -0
- package/plugins/crofai/icon.svg +16 -0
- package/plugins/crofai/plugin.js +81 -0
- package/plugins/crofai/plugin.json +18 -0
- package/plugins/cursor/icon-conflict.svg +3 -0
- package/plugins/cursor/icon.svg +1 -0
- package/plugins/cursor/plugin.js +1379 -0
- package/plugins/cursor/plugin.json +23 -0
- package/plugins/cursor-nightly/icon.png +0 -0
- package/plugins/cursor-nightly/plugin.js +1379 -0
- package/plugins/cursor-nightly/plugin.json +23 -0
- package/plugins/deepgram/icon.svg +1 -0
- package/plugins/deepgram/plugin.js +20 -0
- package/plugins/deepgram/plugin.json +17 -0
- package/plugins/deepseek/icon-color.svg +1 -0
- package/plugins/deepseek/icon.svg +1 -0
- package/plugins/deepseek/plugin.js +71 -0
- package/plugins/deepseek/plugin.json +17 -0
- package/plugins/devin/icon.svg +5 -0
- package/plugins/devin/plugin.js +341 -0
- package/plugins/devin/plugin.json +16 -0
- package/plugins/doubao/icon-color.svg +1 -0
- package/plugins/doubao/icon.svg +1 -0
- package/plugins/doubao/plugin.js +92 -0
- package/plugins/doubao/plugin.json +17 -0
- package/plugins/droid/icon.svg +1 -0
- package/plugins/droid/plugin.json +20 -0
- package/plugins/elevenlabs/icon.svg +4 -0
- package/plugins/elevenlabs/plugin.js +48 -0
- package/plugins/elevenlabs/plugin.json +18 -0
- package/plugins/factory/icon-conflict.svg +1 -0
- package/plugins/factory/icon.svg +1 -0
- package/plugins/factory/plugin.js +487 -0
- package/plugins/factory/plugin.json +15 -0
- package/plugins/fireworks-ai/icon.svg +6 -0
- package/plugins/fireworks-ai/plugin.js +370 -0
- package/plugins/fireworks-ai/plugin.json +19 -0
- package/plugins/gemini/icon-color.svg +1 -0
- package/plugins/gemini/icon.svg +1 -0
- package/plugins/gemini/plugin.js +460 -0
- package/plugins/gemini/plugin.json +16 -0
- package/plugins/grok/icon.svg +1 -0
- package/plugins/grok/plugin.js +297 -0
- package/plugins/grok/plugin.json +19 -0
- package/plugins/groqcloud/icon.svg +1 -0
- package/plugins/groqcloud/plugin.js +117 -0
- package/plugins/groqcloud/plugin.json +19 -0
- package/plugins/jetbrains-ai-assistant/icon-color.svg +14 -0
- package/plugins/jetbrains-ai-assistant/icon-conflict.svg +3 -0
- package/plugins/jetbrains-ai-assistant/icon.svg +14 -0
- package/plugins/jetbrains-ai-assistant/plugin.js +313 -0
- package/plugins/jetbrains-ai-assistant/plugin.json +16 -0
- package/plugins/kilo/icon.svg +1 -0
- package/plugins/kilo/plugin.js +111 -0
- package/plugins/kilo/plugin.json +19 -0
- package/plugins/kimi/icon-color.svg +1 -0
- package/plugins/kimi/icon-conflict.svg +3 -0
- package/plugins/kimi/icon.svg +1 -0
- package/plugins/kimi/plugin.js +358 -0
- package/plugins/kimi/plugin.json +15 -0
- package/plugins/kimi-k2/icon-color.svg +1 -0
- package/plugins/kimi-k2/icon.svg +1 -0
- package/plugins/kimi-k2/plugin.js +58 -0
- package/plugins/kimi-k2/plugin.json +18 -0
- package/plugins/kiro/icon-color.svg +5 -0
- package/plugins/kiro/icon-conflict.svg +1 -0
- package/plugins/kiro/icon.svg +4 -0
- package/plugins/kiro/plugin.js +390 -0
- package/plugins/kiro/plugin.json +16 -0
- package/plugins/litellm/icon.svg +16 -0
- package/plugins/litellm/plugin.js +199 -0
- package/plugins/litellm/plugin.json +16 -0
- package/plugins/llm-proxy/icon.svg +1 -0
- package/plugins/llm-proxy/plugin.js +40 -0
- package/plugins/llm-proxy/plugin.json +17 -0
- package/plugins/manus/icon.svg +6 -0
- package/plugins/manus/plugin.js +50 -0
- package/plugins/manus/plugin.json +17 -0
- package/plugins/mimo/icon.svg +1 -0
- package/plugins/mimo/plugin.js +177 -0
- package/plugins/mimo/plugin.json +19 -0
- package/plugins/minimax/icon-color.svg +1 -0
- package/plugins/minimax/icon-conflict.svg +4 -0
- package/plugins/minimax/icon.svg +1 -0
- package/plugins/minimax/plugin.js +453 -0
- package/plugins/minimax/plugin.json +14 -0
- package/plugins/mistral/icon-color.svg +1 -0
- package/plugins/mistral/icon.svg +1 -0
- package/plugins/mistral/plugin.js +152 -0
- package/plugins/mistral/plugin.json +18 -0
- package/plugins/moonshot/icon.svg +1 -0
- package/plugins/moonshot/plugin.js +43 -0
- package/plugins/moonshot/plugin.json +16 -0
- package/plugins/nanogpt/icon-color.svg +17 -0
- package/plugins/nanogpt/icon.svg +10 -0
- package/plugins/nanogpt/plugin.js +80 -0
- package/plugins/nanogpt/plugin.json +17 -0
- package/plugins/neuralwatt/icon.svg +3 -0
- package/plugins/neuralwatt/plugin.js +152 -0
- package/plugins/neuralwatt/plugin.json +17 -0
- package/plugins/ollama/icon.svg +1 -0
- package/plugins/ollama/plugin.js +92 -0
- package/plugins/ollama/plugin.json +20 -0
- package/plugins/openai-api/icon.svg +1 -0
- package/plugins/openai-api/plugin.js +533 -0
- package/plugins/openai-api/plugin.json +26 -0
- package/plugins/opencode/icon.svg +1 -0
- package/plugins/opencode/plugin.js +342 -0
- package/plugins/opencode/plugin.json +19 -0
- package/plugins/opencode-go/icon-conflict.svg +1 -0
- package/plugins/opencode-go/icon.svg +1 -0
- package/plugins/opencode-go/plugin.js +617 -0
- package/plugins/opencode-go/plugin.json +50 -0
- package/plugins/openrouter/icon.svg +1 -0
- package/plugins/openrouter/plugin.js +156 -0
- package/plugins/openrouter/plugin.json +8 -0
- package/plugins/perplexity/icon-color.svg +1 -0
- package/plugins/perplexity/icon-conflict.svg +1 -0
- package/plugins/perplexity/icon.svg +1 -0
- package/plugins/perplexity/plugin.js +425 -0
- package/plugins/perplexity/plugin.json +19 -0
- package/plugins/poe/icon.svg +1 -0
- package/plugins/poe/plugin.js +190 -0
- package/plugins/poe/plugin.json +19 -0
- package/plugins/stepfun/icon-color.svg +1 -0
- package/plugins/stepfun/icon.svg +1 -0
- package/plugins/stepfun/plugin.js +155 -0
- package/plugins/stepfun/plugin.json +18 -0
- package/plugins/synthetic/icon-conflict.svg +12 -0
- package/plugins/synthetic/icon.svg +14 -0
- package/plugins/synthetic/plugin.js +331 -0
- package/plugins/synthetic/plugin.json +23 -0
- package/plugins/t3chat/icon-color.svg +28 -0
- package/plugins/t3chat/icon.svg +4 -0
- package/plugins/t3chat/plugin.js +243 -0
- package/plugins/t3chat/plugin.json +20 -0
- package/plugins/venice/icon-color.svg +1 -0
- package/plugins/venice/icon.svg +1 -0
- package/plugins/venice/plugin.js +69 -0
- package/plugins/venice/plugin.json +16 -0
- package/plugins/vertex-ai/icon-color.svg +1 -0
- package/plugins/vertex-ai/icon.svg +1 -0
- package/plugins/vertex-ai/plugin.js +172 -0
- package/plugins/vertex-ai/plugin.json +18 -0
- package/plugins/warp/icon-color.svg +10 -0
- package/plugins/warp/icon.svg +6 -0
- package/plugins/warp/plugin.js +108 -0
- package/plugins/warp/plugin.json +18 -0
- package/plugins/windsurf/icon-conflict.svg +3 -0
- package/plugins/windsurf/icon.svg +1 -0
- package/plugins/windsurf/plugin.js +208 -0
- package/plugins/windsurf/plugin.json +16 -0
- package/plugins/zai/icon-conflict.svg +5 -0
- package/plugins/zai/icon.svg +1 -0
- package/plugins/zai/plugin.js +188 -0
- package/plugins/zai/plugin.json +16 -0
- package/plugins/zed/icon.svg +4 -0
- package/plugins/zed/plugin.js +270 -0
- package/plugins/zed/plugin.json +19 -0
- package/usagestat +0 -0
- package/usagestatd +0 -0
|
@@ -0,0 +1,1723 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const DEFAULT_CLAUDE_HOME = "~/.claude"
|
|
3
|
+
const CRED_FILE_NAME = ".credentials.json"
|
|
4
|
+
const KEYCHAIN_SERVICE_PREFIX = "Claude Code"
|
|
5
|
+
const PROD_BASE_API_URL = "https://api.anthropic.com"
|
|
6
|
+
const PROD_REFRESH_URL = "https://platform.claude.com/v1/oauth/token"
|
|
7
|
+
const ADMIN_COST_REPORT_URL = "https://api.anthropic.com/v1/organizations/cost_report"
|
|
8
|
+
const ADMIN_MESSAGES_USAGE_URL = "https://api.anthropic.com/v1/organizations/usage_report/messages"
|
|
9
|
+
const ANTHROPIC_VERSION = "2023-06-01"
|
|
10
|
+
const ADMIN_DAILY_BUCKETS = 31
|
|
11
|
+
const ADMIN_MAX_PAGES = 100
|
|
12
|
+
const PROD_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
13
|
+
const NON_PROD_CLIENT_ID = "22422756-60c9-4084-8eb7-27705fd5cf9a"
|
|
14
|
+
const SCOPES =
|
|
15
|
+
"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
16
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration
|
|
17
|
+
const CLAUDE_WEB_BASE_URL = "https://claude.ai/api"
|
|
18
|
+
|
|
19
|
+
// Rate-limit state persisted across probe() calls (module scope survives re-invocations).
|
|
20
|
+
const MIN_USAGE_FETCH_INTERVAL_MS = 5 * 60 * 1000 // never poll more than once per 5 min
|
|
21
|
+
const DEFAULT_RATE_LIMIT_BACKOFF_MS = 5 * 60 * 1000 // fallback when no Retry-After header
|
|
22
|
+
const LIVE_USAGE_CACHE_FILE = "live-usage-cache.json"
|
|
23
|
+
const LIVE_USAGE_CACHE_VERSION = 1
|
|
24
|
+
const LIVE_USAGE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
25
|
+
let rateLimitedUntilMs = 0 // epoch ms; 0 = not rate-limited
|
|
26
|
+
let lastUsageFetchMs = 0 // epoch ms of the most-recent OAuth API attempt
|
|
27
|
+
let cachedUsageData = null // last successful OAuth API response body (parsed JSON)
|
|
28
|
+
let lastWebUsageFetchMs = 0 // epoch ms of the most-recent Web API attempt
|
|
29
|
+
let cachedWebUsageData = null // last successful Web API response body (parsed JSON)
|
|
30
|
+
|
|
31
|
+
function utf8DecodeBytes(bytes) {
|
|
32
|
+
// Prefer native TextDecoder when available (QuickJS may not expose it).
|
|
33
|
+
if (typeof TextDecoder !== "undefined") {
|
|
34
|
+
try {
|
|
35
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes))
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Minimal UTF-8 decoder (replacement char on invalid sequences).
|
|
40
|
+
let out = ""
|
|
41
|
+
for (let i = 0; i < bytes.length; ) {
|
|
42
|
+
const b0 = bytes[i] & 0xff
|
|
43
|
+
if (b0 < 0x80) {
|
|
44
|
+
out += String.fromCharCode(b0)
|
|
45
|
+
i += 1
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2-byte
|
|
50
|
+
if (b0 >= 0xc2 && b0 <= 0xdf) {
|
|
51
|
+
if (i + 1 >= bytes.length) {
|
|
52
|
+
out += "�"
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
const b1 = bytes[i + 1] & 0xff
|
|
56
|
+
if ((b1 & 0xc0) !== 0x80) {
|
|
57
|
+
out += "�"
|
|
58
|
+
i += 1
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const cp = ((b0 & 0x1f) << 6) | (b1 & 0x3f)
|
|
62
|
+
out += String.fromCharCode(cp)
|
|
63
|
+
i += 2
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3-byte
|
|
68
|
+
if (b0 >= 0xe0 && b0 <= 0xef) {
|
|
69
|
+
if (i + 2 >= bytes.length) {
|
|
70
|
+
out += "�"
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
const b1 = bytes[i + 1] & 0xff
|
|
74
|
+
const b2 = bytes[i + 2] & 0xff
|
|
75
|
+
const validCont = (b1 & 0xc0) === 0x80 && (b2 & 0xc0) === 0x80
|
|
76
|
+
const notOverlong = !(b0 === 0xe0 && b1 < 0xa0)
|
|
77
|
+
const notSurrogate = !(b0 === 0xed && b1 >= 0xa0)
|
|
78
|
+
if (!validCont || !notOverlong || !notSurrogate) {
|
|
79
|
+
out += "�"
|
|
80
|
+
i += 1
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const cp = ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f)
|
|
84
|
+
out += String.fromCharCode(cp)
|
|
85
|
+
i += 3
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 4-byte
|
|
90
|
+
if (b0 >= 0xf0 && b0 <= 0xf4) {
|
|
91
|
+
if (i + 3 >= bytes.length) {
|
|
92
|
+
out += "�"
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
const b1 = bytes[i + 1] & 0xff
|
|
96
|
+
const b2 = bytes[i + 2] & 0xff
|
|
97
|
+
const b3 = bytes[i + 3] & 0xff
|
|
98
|
+
const validCont = (b1 & 0xc0) === 0x80 && (b2 & 0xc0) === 0x80 && (b3 & 0xc0) === 0x80
|
|
99
|
+
const notOverlong = !(b0 === 0xf0 && b1 < 0x90)
|
|
100
|
+
const notTooHigh = !(b0 === 0xf4 && b1 > 0x8f)
|
|
101
|
+
if (!validCont || !notOverlong || !notTooHigh) {
|
|
102
|
+
out += "�"
|
|
103
|
+
i += 1
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const cp =
|
|
107
|
+
((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f)
|
|
108
|
+
const n = cp - 0x10000
|
|
109
|
+
out += String.fromCharCode(0xd800 + ((n >> 10) & 0x3ff), 0xdc00 + (n & 0x3ff))
|
|
110
|
+
i += 4
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
out += "�"
|
|
115
|
+
i += 1
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function tryParseCredentialJSON(ctx, text) {
|
|
121
|
+
if (!text) return null
|
|
122
|
+
const parsed = ctx.util.tryParseJson(text)
|
|
123
|
+
if (parsed) return parsed
|
|
124
|
+
|
|
125
|
+
// Some macOS keychain items are returned by `security ... -w` as hex-encoded UTF-8 bytes.
|
|
126
|
+
// Example prefix: "7b0a" ( "{\\n" ).
|
|
127
|
+
// Support both plain hex and "0x..." forms.
|
|
128
|
+
let hex = String(text).trim()
|
|
129
|
+
if (hex.startsWith("0x") || hex.startsWith("0X")) hex = hex.slice(2)
|
|
130
|
+
if (!hex || hex.length % 2 !== 0) return null
|
|
131
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) return null
|
|
132
|
+
try {
|
|
133
|
+
const bytes = []
|
|
134
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
135
|
+
bytes.push(parseInt(hex.slice(i, i + 2), 16))
|
|
136
|
+
}
|
|
137
|
+
const decoded = utf8DecodeBytes(bytes)
|
|
138
|
+
const decodedParsed = ctx.util.tryParseJson(decoded)
|
|
139
|
+
if (decodedParsed) return decodedParsed
|
|
140
|
+
} catch {}
|
|
141
|
+
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readEnvText(ctx, name) {
|
|
146
|
+
try {
|
|
147
|
+
const value = ctx.host.env.get(name)
|
|
148
|
+
if (value === null || value === undefined) return null
|
|
149
|
+
const text = name === "CLAUDE_CONFIG_DIR" ? String(value) : String(value).trim()
|
|
150
|
+
return text || null
|
|
151
|
+
} catch {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readEnvFlag(ctx, name) {
|
|
157
|
+
const value = readEnvText(ctx, name)
|
|
158
|
+
if (!value) return false
|
|
159
|
+
const lower = value.toLowerCase()
|
|
160
|
+
return lower !== "0" && lower !== "false" && lower !== "no" && lower !== "off"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cleanText(value) {
|
|
164
|
+
if (value === null || value === undefined) return null
|
|
165
|
+
let text = String(value).trim()
|
|
166
|
+
if (!text) return null
|
|
167
|
+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
168
|
+
text = text.slice(1, -1).trim()
|
|
169
|
+
}
|
|
170
|
+
return text || null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readClaudeAdminApiKey(ctx) {
|
|
174
|
+
const configured = cleanText(ctx.provider && ctx.provider.apiKey)
|
|
175
|
+
if (configured) return configured
|
|
176
|
+
|
|
177
|
+
const envNames = ["ANTHROPIC_ADMIN_KEY", "ANTHROPIC_ADMIN_API_KEY"]
|
|
178
|
+
for (let i = 0; i < envNames.length; i++) {
|
|
179
|
+
const value = cleanText(readEnvText(ctx, envNames[i]))
|
|
180
|
+
if (value) return value
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function liveUsageCachePath(ctx) {
|
|
186
|
+
if (!ctx.app || typeof ctx.app.pluginDataDir !== "string" || !ctx.app.pluginDataDir.trim()) {
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
return ctx.app.pluginDataDir + "/" + LIVE_USAGE_CACHE_FILE
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readLiveUsageCache(ctx) {
|
|
193
|
+
const path = liveUsageCachePath(ctx)
|
|
194
|
+
if (!path || !ctx.host.fs.exists(path)) return null
|
|
195
|
+
try {
|
|
196
|
+
const parsed = ctx.util.tryParseJson(ctx.host.fs.readText(path))
|
|
197
|
+
if (!parsed || typeof parsed !== "object") return null
|
|
198
|
+
return parsed
|
|
199
|
+
} catch {
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function writeLiveUsageCache(ctx, updates) {
|
|
205
|
+
const path = liveUsageCachePath(ctx)
|
|
206
|
+
if (!path) return
|
|
207
|
+
try {
|
|
208
|
+
const cache = readLiveUsageCache(ctx) || {}
|
|
209
|
+
for (const key in updates) {
|
|
210
|
+
cache[key] = updates[key]
|
|
211
|
+
}
|
|
212
|
+
cache.version = LIVE_USAGE_CACHE_VERSION
|
|
213
|
+
cache.updatedAtMs = Date.now()
|
|
214
|
+
ctx.host.fs.writeText(path, JSON.stringify(cache, null, 2))
|
|
215
|
+
} catch (e) {
|
|
216
|
+
ctx.host.log.warn("Claude live usage cache write failed: " + String(e))
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cachedLiveUsageData(cache, nowMs) {
|
|
221
|
+
if (!cache || !cache.usageData || typeof cache.usageData !== "object") return null
|
|
222
|
+
const fetchedAtMs = Number(cache.usageFetchedAtMs)
|
|
223
|
+
if (!Number.isFinite(fetchedAtMs) || fetchedAtMs <= 0) return null
|
|
224
|
+
if (nowMs - fetchedAtMs > LIVE_USAGE_CACHE_MAX_AGE_MS) return null
|
|
225
|
+
return cache.usageData
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getClaudeHomePath(ctx) {
|
|
229
|
+
return readEnvText(ctx, "CLAUDE_CONFIG_DIR") || DEFAULT_CLAUDE_HOME
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function getClaudeHomeOverride(ctx) {
|
|
233
|
+
return readEnvText(ctx, "CLAUDE_CONFIG_DIR")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getClaudeCredentialsPath(ctx) {
|
|
237
|
+
return getClaudeHomePath(ctx) + "/" + CRED_FILE_NAME
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getOauthConfig(ctx) {
|
|
241
|
+
let baseApiUrl = PROD_BASE_API_URL
|
|
242
|
+
let refreshUrl = PROD_REFRESH_URL
|
|
243
|
+
let clientId = PROD_CLIENT_ID
|
|
244
|
+
let oauthFileSuffix = ""
|
|
245
|
+
|
|
246
|
+
const isAntUser = readEnvText(ctx, "USER_TYPE") === "ant"
|
|
247
|
+
if (isAntUser && readEnvFlag(ctx, "USE_LOCAL_OAUTH")) {
|
|
248
|
+
const localApiBase = readEnvText(ctx, "CLAUDE_LOCAL_OAUTH_API_BASE")
|
|
249
|
+
baseApiUrl = (localApiBase || "http://localhost:8000").replace(/\/+$/, "")
|
|
250
|
+
refreshUrl = baseApiUrl + "/v1/oauth/token"
|
|
251
|
+
clientId = NON_PROD_CLIENT_ID
|
|
252
|
+
oauthFileSuffix = "-local-oauth"
|
|
253
|
+
} else if (isAntUser && readEnvFlag(ctx, "USE_STAGING_OAUTH")) {
|
|
254
|
+
baseApiUrl = "https://api-staging.anthropic.com"
|
|
255
|
+
refreshUrl = "https://platform.staging.ant.dev/v1/oauth/token"
|
|
256
|
+
clientId = NON_PROD_CLIENT_ID
|
|
257
|
+
oauthFileSuffix = "-staging-oauth"
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const customOauthBase = readEnvText(ctx, "CLAUDE_CODE_CUSTOM_OAUTH_URL")
|
|
261
|
+
if (customOauthBase) {
|
|
262
|
+
const base = customOauthBase.replace(/\/+$/, "")
|
|
263
|
+
baseApiUrl = base
|
|
264
|
+
refreshUrl = base + "/v1/oauth/token"
|
|
265
|
+
oauthFileSuffix = "-custom-oauth"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const clientIdOverride = readEnvText(ctx, "CLAUDE_CODE_OAUTH_CLIENT_ID")
|
|
269
|
+
if (clientIdOverride) {
|
|
270
|
+
clientId = clientIdOverride
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
baseApiUrl: baseApiUrl,
|
|
275
|
+
usageUrl: baseApiUrl + "/api/oauth/usage",
|
|
276
|
+
refreshUrl: refreshUrl,
|
|
277
|
+
clientId: clientId,
|
|
278
|
+
oauthFileSuffix: oauthFileSuffix,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildClaudeBaseKeychainService(ctx) {
|
|
283
|
+
return KEYCHAIN_SERVICE_PREFIX + getOauthConfig(ctx).oauthFileSuffix + "-credentials"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function computeKeychainHashSuffix(ctx) {
|
|
287
|
+
// Mirrors upstream Claude Code (decompiled from the binary):
|
|
288
|
+
// const suffix = !process.env.CLAUDE_CONFIG_DIR
|
|
289
|
+
// ? ""
|
|
290
|
+
// : "-" + sha256(CLAUDE_CONFIG_DIR.normalize("NFC")).slice(0, 8)
|
|
291
|
+
// The hash is ONLY appended when CLAUDE_CONFIG_DIR is explicitly set;
|
|
292
|
+
// when unset, upstream uses the legacy unhashed service name.
|
|
293
|
+
const explicitConfigDir = readEnvText(ctx, "CLAUDE_CONFIG_DIR")
|
|
294
|
+
if (!explicitConfigDir) return null
|
|
295
|
+
const sha256Hex = ctx.host && ctx.host.crypto && ctx.host.crypto.sha256Hex
|
|
296
|
+
if (typeof sha256Hex !== "function") return null
|
|
297
|
+
// Match upstream's `.normalize("NFC")` exactly.
|
|
298
|
+
const normalized =
|
|
299
|
+
typeof explicitConfigDir.normalize === "function"
|
|
300
|
+
? explicitConfigDir.normalize("NFC")
|
|
301
|
+
: explicitConfigDir
|
|
302
|
+
const digest = sha256Hex(normalized)
|
|
303
|
+
if (typeof digest !== "string" || digest.length < 8) return null
|
|
304
|
+
return digest.slice(0, 8)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function getClaudeKeychainServiceCandidates(ctx) {
|
|
308
|
+
const base = buildClaudeBaseKeychainService(ctx)
|
|
309
|
+
if (!readEnvText(ctx, "CLAUDE_CONFIG_DIR")) return [base]
|
|
310
|
+
const hash = computeKeychainHashSuffix(ctx)
|
|
311
|
+
if (!hash) throw {code: "failed", message: "The host cannot resolve this Claude profile's credential service."}
|
|
312
|
+
return [base + "-" + hash]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readKeychainCredentialText(ctx, service) {
|
|
316
|
+
const keychain = ctx.host.keychain
|
|
317
|
+
if (!keychain) return null
|
|
318
|
+
const currentUser = typeof keychain.readGenericPasswordForCurrentUser === "function"
|
|
319
|
+
const read = currentUser ? keychain.readGenericPasswordForCurrentUser : keychain.readGenericPassword
|
|
320
|
+
if (typeof read !== "function") return null
|
|
321
|
+
try {
|
|
322
|
+
const value = read.call(keychain, service)
|
|
323
|
+
return value ? {value, source: currentUser ? "keychain-current-user" : "keychain-legacy"} : null
|
|
324
|
+
} catch (e) {
|
|
325
|
+
// Missing auth can fall back to this profile's file. Denied/locked stores
|
|
326
|
+
// must remain distinguishable, and never broaden the account lookup.
|
|
327
|
+
if (/credential-(denied|unavailable|account-mismatch|malformed):/.test(String(e))) throw e
|
|
328
|
+
return null
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function loadFileCredentials(ctx) {
|
|
333
|
+
const credFile = getClaudeCredentialsPath(ctx)
|
|
334
|
+
if (ctx.host.fs.exists(credFile)) {
|
|
335
|
+
try {
|
|
336
|
+
const text = ctx.host.fs.readText(credFile)
|
|
337
|
+
const parsed = tryParseCredentialJSON(ctx, text)
|
|
338
|
+
if (parsed) {
|
|
339
|
+
const oauth = parsed.claudeAiOauth
|
|
340
|
+
if (oauth && oauth.accessToken) {
|
|
341
|
+
ctx.host.log.info("credentials loaded from file")
|
|
342
|
+
return { oauth, source: "file", fullData: parsed }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
ctx.host.log.warn("credentials file exists but no valid oauth data")
|
|
346
|
+
} catch (e) {
|
|
347
|
+
ctx.host.log.warn("credentials file read failed: " + String(e))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function loadKeychainCredentials(ctx) {
|
|
355
|
+
// Upstream uses Keychain on macOS and profile files on Linux/Windows.
|
|
356
|
+
if (ctx.app.platform !== "macos") return null
|
|
357
|
+
// An explicit profile reads only its own hashed service.
|
|
358
|
+
for (const service of getClaudeKeychainServiceCandidates(ctx)) {
|
|
359
|
+
const keychainResult = readKeychainCredentialText(ctx, service)
|
|
360
|
+
if (keychainResult && keychainResult.value) {
|
|
361
|
+
const parsed = tryParseCredentialJSON(ctx, keychainResult.value)
|
|
362
|
+
if (parsed) {
|
|
363
|
+
const oauth = parsed.claudeAiOauth
|
|
364
|
+
if (oauth && oauth.accessToken) {
|
|
365
|
+
ctx.host.log.info("credentials loaded from keychain (service=" + service + ")")
|
|
366
|
+
return { oauth, source: keychainResult.source, serviceName: service, fullData: parsed }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
ctx.host.log.warn("keychain has data for " + service + " but no valid oauth")
|
|
370
|
+
// Continue: a stale legacy entry shouldn't shadow a valid hashed one.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function loadStoredCredentials(ctx, suppressMissingWarn) {
|
|
378
|
+
// Recent Claude Code versions keep the current session in Keychain and can
|
|
379
|
+
// leave a stale credentials file behind, so Keychain wins when valid.
|
|
380
|
+
let keychainError = null
|
|
381
|
+
let keychainCredentials = null
|
|
382
|
+
try { keychainCredentials = loadKeychainCredentials(ctx) } catch (e) { keychainError = e }
|
|
383
|
+
if (keychainCredentials) return keychainCredentials
|
|
384
|
+
|
|
385
|
+
const fileCredentials = loadFileCredentials(ctx)
|
|
386
|
+
if (fileCredentials) return fileCredentials
|
|
387
|
+
|
|
388
|
+
if (keychainError) throw keychainError
|
|
389
|
+
|
|
390
|
+
if (!suppressMissingWarn) {
|
|
391
|
+
ctx.host.log.warn("no credentials found")
|
|
392
|
+
}
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function loadCredentials(ctx) {
|
|
397
|
+
const injected = readInjectedCredential(ctx)
|
|
398
|
+
if (injected) return injected
|
|
399
|
+
|
|
400
|
+
const envAccessToken = readEnvText(ctx, "CLAUDE_CODE_OAUTH_TOKEN")
|
|
401
|
+
const stored = loadStoredCredentials(ctx, !!envAccessToken)
|
|
402
|
+
if (!envAccessToken) {
|
|
403
|
+
return stored
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const oauth = stored && stored.oauth ? Object.assign({}, stored.oauth) : {}
|
|
407
|
+
oauth.accessToken = envAccessToken
|
|
408
|
+
return {
|
|
409
|
+
oauth: oauth,
|
|
410
|
+
source: stored ? stored.source : null,
|
|
411
|
+
serviceName: stored ? stored.serviceName : null,
|
|
412
|
+
fullData: stored ? stored.fullData : null,
|
|
413
|
+
inferenceOnly: true,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function readInjectedCredential(ctx) {
|
|
418
|
+
try {
|
|
419
|
+
if (!ctx.host.credentials || typeof ctx.host.credentials.get !== "function") return null
|
|
420
|
+
const raw = ctx.host.credentials.get()
|
|
421
|
+
if (!raw) return null
|
|
422
|
+
const credential = ctx.util.tryParseJson(String(raw))
|
|
423
|
+
if (!credential) return null
|
|
424
|
+
const accessToken = String(credential.accessToken || credential.sessionKey || "").trim()
|
|
425
|
+
const refreshToken = String(credential.refreshToken || "").trim()
|
|
426
|
+
if (!accessToken && !refreshToken) return null
|
|
427
|
+
return {
|
|
428
|
+
oauth: {
|
|
429
|
+
accessToken,
|
|
430
|
+
refreshToken,
|
|
431
|
+
expiresAt: typeof credential.expiresAt === "number" ? credential.expiresAt : null,
|
|
432
|
+
scopes: ["user:profile"],
|
|
433
|
+
},
|
|
434
|
+
source: "provider-account",
|
|
435
|
+
serviceName: null,
|
|
436
|
+
fullData: { claudeAiOauth: {} },
|
|
437
|
+
}
|
|
438
|
+
} catch (e) {
|
|
439
|
+
ctx.host.log.warn("provider account credential read failed: " + String(e))
|
|
440
|
+
return null
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function hasProfileScope(creds) {
|
|
445
|
+
if (!creds || creds.inferenceOnly) {
|
|
446
|
+
return false
|
|
447
|
+
}
|
|
448
|
+
const scopes = creds.oauth && creds.oauth.scopes
|
|
449
|
+
if (Array.isArray(scopes) && scopes.length > 0) {
|
|
450
|
+
return scopes.indexOf("user:profile") !== -1
|
|
451
|
+
}
|
|
452
|
+
return true
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function saveCredentials(ctx, source, serviceName, fullData) {
|
|
456
|
+
if (source === "provider-account") {
|
|
457
|
+
try {
|
|
458
|
+
if (ctx.host.credentials && typeof ctx.host.credentials.update === "function") {
|
|
459
|
+
const oauth = fullData && fullData.claudeAiOauth ? fullData.claudeAiOauth : {}
|
|
460
|
+
ctx.host.credentials.update(JSON.stringify({
|
|
461
|
+
accessToken: oauth.accessToken || null,
|
|
462
|
+
refreshToken: oauth.refreshToken || null,
|
|
463
|
+
expiresAt: oauth.expiresAt || null,
|
|
464
|
+
}))
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {
|
|
467
|
+
ctx.host.log.error("Failed to update provider account credentials: " + String(e))
|
|
468
|
+
}
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
// MUST use minified JSON - macOS `security -w` hex-encodes values with newlines,
|
|
472
|
+
// which Claude Code can't read back, causing it to invalidate the session.
|
|
473
|
+
const text = JSON.stringify(fullData)
|
|
474
|
+
if (source === "file") {
|
|
475
|
+
try {
|
|
476
|
+
ctx.host.fs.writeText(getClaudeCredentialsPath(ctx), text)
|
|
477
|
+
} catch (e) {
|
|
478
|
+
ctx.host.log.error("Failed to write Claude credentials file: " + String(e))
|
|
479
|
+
}
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (!serviceName) {
|
|
483
|
+
ctx.host.log.error("Refusing keychain write: missing service name (source=" + source + ")")
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
if (source === "keychain-current-user") {
|
|
487
|
+
try {
|
|
488
|
+
if (typeof ctx.host.keychain.writeGenericPasswordForCurrentUser === "function") {
|
|
489
|
+
ctx.host.keychain.writeGenericPasswordForCurrentUser(serviceName, text)
|
|
490
|
+
} else {
|
|
491
|
+
ctx.host.keychain.writeGenericPassword(serviceName, text)
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {
|
|
494
|
+
ctx.host.log.error("Failed to write Claude credentials keychain: " + String(e))
|
|
495
|
+
}
|
|
496
|
+
} else if (source === "keychain-legacy" || source === "keychain") {
|
|
497
|
+
try {
|
|
498
|
+
ctx.host.keychain.writeGenericPassword(serviceName, text)
|
|
499
|
+
} catch (e) {
|
|
500
|
+
ctx.host.log.error("Failed to write Claude credentials keychain: " + String(e))
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function needsRefresh(ctx, oauth, nowMs) {
|
|
506
|
+
return ctx.util.needsRefreshByExpiry({
|
|
507
|
+
nowMs,
|
|
508
|
+
expiresAtMs: oauth.expiresAt,
|
|
509
|
+
bufferMs: REFRESH_BUFFER_MS,
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function refreshToken(ctx, creds) {
|
|
514
|
+
const { oauth, source, fullData } = creds
|
|
515
|
+
if (!oauth.refreshToken) {
|
|
516
|
+
ctx.host.log.warn("refresh skipped: no refresh token")
|
|
517
|
+
return null
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const oauthConfig = getOauthConfig(ctx)
|
|
521
|
+
ctx.host.log.info("attempting token refresh")
|
|
522
|
+
try {
|
|
523
|
+
const resp = ctx.util.request({
|
|
524
|
+
method: "POST",
|
|
525
|
+
url: oauthConfig.refreshUrl,
|
|
526
|
+
headers: { "Content-Type": "application/json" },
|
|
527
|
+
bodyText: JSON.stringify({
|
|
528
|
+
grant_type: "refresh_token",
|
|
529
|
+
refresh_token: oauth.refreshToken,
|
|
530
|
+
client_id: oauthConfig.clientId,
|
|
531
|
+
scope: SCOPES,
|
|
532
|
+
}),
|
|
533
|
+
timeoutMs: 15000,
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
if (resp.status === 400 || resp.status === 401) {
|
|
537
|
+
let errorCode = null
|
|
538
|
+
const body = ctx.util.tryParseJson(resp.bodyText)
|
|
539
|
+
if (body) errorCode = body.error || body.error_description
|
|
540
|
+
ctx.host.log.error("refresh failed: status=" + resp.status + " error=" + String(errorCode))
|
|
541
|
+
if (errorCode === "invalid_grant") {
|
|
542
|
+
throw "Session expired. Run `claude` to log in again."
|
|
543
|
+
}
|
|
544
|
+
throw "Token expired. Run `claude` to log in again."
|
|
545
|
+
}
|
|
546
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
547
|
+
ctx.host.log.warn("refresh returned unexpected status: " + resp.status)
|
|
548
|
+
return null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const body = ctx.util.tryParseJson(resp.bodyText)
|
|
552
|
+
if (!body) {
|
|
553
|
+
ctx.host.log.warn("refresh response not valid JSON")
|
|
554
|
+
return null
|
|
555
|
+
}
|
|
556
|
+
const newAccessToken = body.access_token
|
|
557
|
+
if (!newAccessToken) {
|
|
558
|
+
ctx.host.log.warn("refresh response missing access_token")
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Update oauth credentials
|
|
563
|
+
oauth.accessToken = newAccessToken
|
|
564
|
+
if (body.refresh_token) oauth.refreshToken = body.refresh_token
|
|
565
|
+
if (typeof body.expires_in === "number") {
|
|
566
|
+
oauth.expiresAt = Date.now() + body.expires_in * 1000
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Persist updated credentials back to the same source we read from.
|
|
570
|
+
fullData.claudeAiOauth = oauth
|
|
571
|
+
saveCredentials(ctx, source, creds.serviceName, fullData)
|
|
572
|
+
|
|
573
|
+
ctx.host.log.info("refresh succeeded, new token expires in " + (body.expires_in || "unknown") + "s")
|
|
574
|
+
return newAccessToken
|
|
575
|
+
} catch (e) {
|
|
576
|
+
if (typeof e === "string") throw e
|
|
577
|
+
ctx.host.log.error("refresh exception: " + String(e))
|
|
578
|
+
return null
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function fetchUsage(ctx, accessToken) {
|
|
583
|
+
const oauthConfig = getOauthConfig(ctx)
|
|
584
|
+
return ctx.util.request({
|
|
585
|
+
method: "GET",
|
|
586
|
+
url: oauthConfig.usageUrl,
|
|
587
|
+
headers: {
|
|
588
|
+
Authorization: "Bearer " + accessToken.trim(),
|
|
589
|
+
Accept: "application/json",
|
|
590
|
+
"Content-Type": "application/json",
|
|
591
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
592
|
+
"User-Agent": "claude-code/2.1.69",
|
|
593
|
+
},
|
|
594
|
+
timeoutMs: 10000,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function parseRetryAfterSeconds(headers) {
|
|
599
|
+
if (!headers) return null
|
|
600
|
+
const raw = headers["retry-after"] ?? headers["Retry-After"]
|
|
601
|
+
if (raw === undefined || raw === null) return null
|
|
602
|
+
const str = String(raw).trim()
|
|
603
|
+
if (!str) return null
|
|
604
|
+
// Retry-After can be a delay-seconds or HTTP-date (RFC 7231).
|
|
605
|
+
// 0 means "retry immediately" — return 0 as a valid value.
|
|
606
|
+
const seconds = parseInt(str, 10)
|
|
607
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds
|
|
608
|
+
const dateMs = Date.parse(str)
|
|
609
|
+
if (Number.isFinite(dateMs)) {
|
|
610
|
+
const delay = Math.ceil((dateMs - Date.now()) / 1000)
|
|
611
|
+
return delay > 0 ? delay : 0
|
|
612
|
+
}
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function fmtRateLimitMinutes(seconds) {
|
|
617
|
+
if (seconds <= 0) return "now"
|
|
618
|
+
const mins = Math.ceil(seconds / 60)
|
|
619
|
+
return mins + "m"
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function queryTokenUsage(ctx, homePath) {
|
|
623
|
+
if (!ctx.host.ccusage || typeof ctx.host.ccusage.query !== "function") {
|
|
624
|
+
return { status: "unavailable", data: null }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const since = new Date()
|
|
628
|
+
// Inclusive range: today + previous 30 days = 31 calendar days.
|
|
629
|
+
since.setDate(since.getDate() - 30)
|
|
630
|
+
const y = since.getFullYear()
|
|
631
|
+
const m = since.getMonth() + 1
|
|
632
|
+
const d = since.getDate()
|
|
633
|
+
const sinceStr = "" + y + (m < 10 ? "0" : "") + m + (d < 10 ? "0" : "") + d
|
|
634
|
+
|
|
635
|
+
const queryOpts = { since: sinceStr }
|
|
636
|
+
if (homePath) {
|
|
637
|
+
queryOpts.homePath = homePath
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const result = ctx.host.ccusage.query(queryOpts)
|
|
641
|
+
if (!result || typeof result !== "object" || typeof result.status !== "string") {
|
|
642
|
+
return { status: "runner_failed", data: null }
|
|
643
|
+
}
|
|
644
|
+
if (result.status !== "ok") {
|
|
645
|
+
return { status: result.status, data: null }
|
|
646
|
+
}
|
|
647
|
+
if (!result.data || !Array.isArray(result.data.daily)) {
|
|
648
|
+
return { status: "runner_failed", data: null }
|
|
649
|
+
}
|
|
650
|
+
return { status: "ok", data: result.data }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function adminIsoString(ms) {
|
|
654
|
+
return new Date(ms).toISOString().replace(".000Z", "Z")
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function adminRange(nowMs) {
|
|
658
|
+
const now = new Date(nowMs)
|
|
659
|
+
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
|
660
|
+
const startMs = todayStartMs - (ADMIN_DAILY_BUCKETS - 1) * 24 * 60 * 60 * 1000
|
|
661
|
+
const endMs = todayStartMs + 24 * 60 * 60 * 1000
|
|
662
|
+
return { startingAt: adminIsoString(startMs), endingAt: adminIsoString(endMs) }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function makeAdminUrl(baseUrl, params) {
|
|
666
|
+
const query = []
|
|
667
|
+
const keys = Object.keys(params)
|
|
668
|
+
for (let i = 0; i < keys.length; i++) {
|
|
669
|
+
const key = keys[i]
|
|
670
|
+
const value = params[key]
|
|
671
|
+
if (value === null || value === undefined || value === "") continue
|
|
672
|
+
query.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(value)))
|
|
673
|
+
}
|
|
674
|
+
return baseUrl + (query.length ? "?" + query.join("&") : "")
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function makeAdminError(message, status, endpoint) {
|
|
678
|
+
return {
|
|
679
|
+
message,
|
|
680
|
+
status,
|
|
681
|
+
endpoint,
|
|
682
|
+
authRejected: status === 401 || status === 403,
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function requestClaudeAdminJson(ctx, url, apiKey, endpoint) {
|
|
687
|
+
let resp
|
|
688
|
+
try {
|
|
689
|
+
resp = ctx.util.request({
|
|
690
|
+
method: "GET",
|
|
691
|
+
url,
|
|
692
|
+
headers: {
|
|
693
|
+
"x-api-key": apiKey,
|
|
694
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
695
|
+
Accept: "application/json",
|
|
696
|
+
"User-Agent": "UsageStat/1.0",
|
|
697
|
+
},
|
|
698
|
+
timeoutMs: 20000,
|
|
699
|
+
})
|
|
700
|
+
} catch (e) {
|
|
701
|
+
throw makeAdminError("Claude Admin API " + endpoint + " request failed: " + String(e), 0, endpoint)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (ctx.util.isAuthStatus(resp.status)) {
|
|
705
|
+
throw makeAdminError("Claude Admin API key invalid or missing required permissions.", resp.status, endpoint)
|
|
706
|
+
}
|
|
707
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
708
|
+
throw makeAdminError("Claude Admin API " + endpoint + " failed (HTTP " + resp.status + ").", resp.status, endpoint)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const json = ctx.util.tryParseJson(resp.bodyText)
|
|
712
|
+
if (!json) throw makeAdminError("Claude Admin API " + endpoint + " response invalid.", resp.status, endpoint)
|
|
713
|
+
return json
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function fetchClaudeAdminReport(ctx, apiKey, url, endpoint, groupBy) {
|
|
717
|
+
const range = adminRange(Date.now())
|
|
718
|
+
const buckets = []
|
|
719
|
+
let nextPage = null
|
|
720
|
+
const seenPages = {}
|
|
721
|
+
let pageCount = 0
|
|
722
|
+
|
|
723
|
+
do {
|
|
724
|
+
pageCount += 1
|
|
725
|
+
if (pageCount > ADMIN_MAX_PAGES) {
|
|
726
|
+
throw makeAdminError("Claude Admin API " + endpoint + " pagination exceeded " + ADMIN_MAX_PAGES + " pages.", 200, endpoint)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const params = {
|
|
730
|
+
starting_at: range.startingAt,
|
|
731
|
+
ending_at: range.endingAt,
|
|
732
|
+
bucket_width: "1d",
|
|
733
|
+
limit: ADMIN_DAILY_BUCKETS,
|
|
734
|
+
"group_by[]": groupBy,
|
|
735
|
+
}
|
|
736
|
+
if (nextPage) params.page = nextPage
|
|
737
|
+
|
|
738
|
+
const json = requestClaudeAdminJson(ctx, makeAdminUrl(url, params), apiKey, endpoint)
|
|
739
|
+
if (Array.isArray(json.data)) {
|
|
740
|
+
for (let i = 0; i < json.data.length; i++) buckets.push(json.data[i])
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!json.has_more) {
|
|
744
|
+
nextPage = null
|
|
745
|
+
continue
|
|
746
|
+
}
|
|
747
|
+
nextPage = cleanText(json.next_page)
|
|
748
|
+
if (!nextPage) {
|
|
749
|
+
throw makeAdminError("Claude Admin API " + endpoint + " pagination cursor missing.", 200, endpoint)
|
|
750
|
+
}
|
|
751
|
+
if (seenPages[nextPage]) {
|
|
752
|
+
throw makeAdminError("Claude Admin API " + endpoint + " pagination cursor repeated.", 200, endpoint)
|
|
753
|
+
}
|
|
754
|
+
seenPages[nextPage] = true
|
|
755
|
+
} while (nextPage)
|
|
756
|
+
|
|
757
|
+
return buckets
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function numberValue(value) {
|
|
761
|
+
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
762
|
+
if (typeof value === "string" && value.trim()) {
|
|
763
|
+
const parsed = Number(value.trim().replace(/,/g, ""))
|
|
764
|
+
if (Number.isFinite(parsed)) return parsed
|
|
765
|
+
}
|
|
766
|
+
return null
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function intValue(value) {
|
|
770
|
+
const n = numberValue(value)
|
|
771
|
+
return n === null ? 0 : Math.max(0, Math.floor(n))
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function displayName(raw, fallback) {
|
|
775
|
+
return cleanText(raw) || fallback
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function dayKeyFromAdminDate(rawDate) {
|
|
779
|
+
const ms = Date.parse(rawDate)
|
|
780
|
+
if (!Number.isFinite(ms)) return null
|
|
781
|
+
return new Date(ms).toISOString().slice(0, 10)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function adminDailyBucket(map, startingAt, endingAt) {
|
|
785
|
+
const dayKey = dayKeyFromAdminDate(startingAt)
|
|
786
|
+
if (!dayKey) return null
|
|
787
|
+
if (!map[dayKey]) {
|
|
788
|
+
map[dayKey] = {
|
|
789
|
+
date: dayKey,
|
|
790
|
+
startingAt,
|
|
791
|
+
endingAt,
|
|
792
|
+
costUSD: 0,
|
|
793
|
+
inputTokens: 0,
|
|
794
|
+
cacheCreationTokens: 0,
|
|
795
|
+
cacheReadTokens: 0,
|
|
796
|
+
outputTokens: 0,
|
|
797
|
+
totalTokens: 0,
|
|
798
|
+
costItems: {},
|
|
799
|
+
models: {},
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return map[dayKey]
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function addAdminModel(day, name, input, cacheCreation, cacheRead, output, total) {
|
|
806
|
+
if (!day.models[name]) {
|
|
807
|
+
day.models[name] = {
|
|
808
|
+
modelName: name,
|
|
809
|
+
inputTokens: 0,
|
|
810
|
+
cacheCreationTokens: 0,
|
|
811
|
+
cacheReadTokens: 0,
|
|
812
|
+
outputTokens: 0,
|
|
813
|
+
totalTokens: 0,
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const model = day.models[name]
|
|
817
|
+
model.inputTokens += input
|
|
818
|
+
model.cacheCreationTokens += cacheCreation
|
|
819
|
+
model.cacheReadTokens += cacheRead
|
|
820
|
+
model.outputTokens += output
|
|
821
|
+
model.totalTokens += total
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function cacheCreationTokenCount(cacheCreation) {
|
|
825
|
+
if (!cacheCreation || typeof cacheCreation !== "object") return 0
|
|
826
|
+
const total = intValue(cacheCreation.total_input_tokens)
|
|
827
|
+
if (total > 0) return total
|
|
828
|
+
return intValue(cacheCreation.ephemeral_1h_input_tokens) +
|
|
829
|
+
intValue(cacheCreation.ephemeral_5m_input_tokens)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function makeClaudeAdminDaily(costBuckets, messageBuckets) {
|
|
833
|
+
const map = {}
|
|
834
|
+
|
|
835
|
+
for (let i = 0; i < costBuckets.length; i++) {
|
|
836
|
+
const bucket = costBuckets[i] || {}
|
|
837
|
+
const day = adminDailyBucket(map, bucket.starting_at, bucket.ending_at)
|
|
838
|
+
if (!day) continue
|
|
839
|
+
const results = Array.isArray(bucket.results) ? bucket.results : []
|
|
840
|
+
for (let j = 0; j < results.length; j++) {
|
|
841
|
+
const result = results[j] || {}
|
|
842
|
+
const amount = numberValue(result.amount)
|
|
843
|
+
const costUSD = amount === null ? 0 : amount / 100
|
|
844
|
+
day.costUSD += costUSD
|
|
845
|
+
const name = displayName(result.description || result.cost_type, "Claude API")
|
|
846
|
+
day.costItems[name] = (day.costItems[name] || 0) + costUSD
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
for (let i = 0; i < messageBuckets.length; i++) {
|
|
851
|
+
const bucket = messageBuckets[i] || {}
|
|
852
|
+
const day = adminDailyBucket(map, bucket.starting_at, bucket.ending_at)
|
|
853
|
+
if (!day) continue
|
|
854
|
+
const results = Array.isArray(bucket.results) ? bucket.results : []
|
|
855
|
+
for (let j = 0; j < results.length; j++) {
|
|
856
|
+
const result = results[j] || {}
|
|
857
|
+
const input = intValue(result.uncached_input_tokens)
|
|
858
|
+
const cacheCreation = cacheCreationTokenCount(result.cache_creation)
|
|
859
|
+
const cacheRead = intValue(result.cache_read_input_tokens)
|
|
860
|
+
const output = intValue(result.output_tokens)
|
|
861
|
+
const total = input + cacheCreation + cacheRead + output
|
|
862
|
+
day.inputTokens += input
|
|
863
|
+
day.cacheCreationTokens += cacheCreation
|
|
864
|
+
day.cacheReadTokens += cacheRead
|
|
865
|
+
day.outputTokens += output
|
|
866
|
+
day.totalTokens += total
|
|
867
|
+
addAdminModel(day, displayName(result.model, "Claude API"), input, cacheCreation, cacheRead, output, total)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return Object.keys(map)
|
|
872
|
+
.sort()
|
|
873
|
+
.map((key) => {
|
|
874
|
+
const day = map[key]
|
|
875
|
+
const modelBreakdowns = Object.keys(day.models)
|
|
876
|
+
.map((name) => day.models[name])
|
|
877
|
+
.sort((a, b) => b.totalTokens - a.totalTokens || a.modelName.localeCompare(b.modelName))
|
|
878
|
+
const costItems = Object.keys(day.costItems)
|
|
879
|
+
.map((name) => ({ name, costUSD: day.costItems[name] }))
|
|
880
|
+
.sort((a, b) => b.costUSD - a.costUSD || a.name.localeCompare(b.name))
|
|
881
|
+
return {
|
|
882
|
+
date: day.date,
|
|
883
|
+
inputTokens: day.inputTokens,
|
|
884
|
+
cacheCreationTokens: day.cacheCreationTokens,
|
|
885
|
+
cacheReadTokens: day.cacheReadTokens,
|
|
886
|
+
outputTokens: day.outputTokens,
|
|
887
|
+
totalTokens: day.totalTokens,
|
|
888
|
+
costUSD: day.costUSD,
|
|
889
|
+
totalCost: day.costUSD,
|
|
890
|
+
modelsUsed: modelBreakdowns.map((model) => model.modelName),
|
|
891
|
+
modelBreakdowns,
|
|
892
|
+
costItems,
|
|
893
|
+
}
|
|
894
|
+
})
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function fetchClaudeAdminUsage(ctx, apiKey) {
|
|
898
|
+
const costs = fetchClaudeAdminReport(ctx, apiKey, ADMIN_COST_REPORT_URL, "cost_report", "description")
|
|
899
|
+
const messages = fetchClaudeAdminReport(ctx, apiKey, ADMIN_MESSAGES_USAGE_URL, "messages", "model")
|
|
900
|
+
return makeClaudeAdminDaily(costs, messages)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function fmtTokens(n) {
|
|
904
|
+
const abs = Math.abs(n)
|
|
905
|
+
const sign = n < 0 ? "-" : ""
|
|
906
|
+
const units = [
|
|
907
|
+
{ threshold: 1e9, divisor: 1e9, suffix: "B" },
|
|
908
|
+
{ threshold: 1e6, divisor: 1e6, suffix: "M" },
|
|
909
|
+
{ threshold: 1e3, divisor: 1e3, suffix: "K" },
|
|
910
|
+
]
|
|
911
|
+
for (let i = 0; i < units.length; i++) {
|
|
912
|
+
const unit = units[i]
|
|
913
|
+
if (abs >= unit.threshold) {
|
|
914
|
+
const scaled = abs / unit.divisor
|
|
915
|
+
const formatted = scaled >= 10
|
|
916
|
+
? Math.round(scaled).toString()
|
|
917
|
+
: scaled.toFixed(1).replace(/\.0$/, "")
|
|
918
|
+
return sign + formatted + unit.suffix
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return sign + Math.round(abs).toString()
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function dayKeyFromDate(date) {
|
|
925
|
+
const year = date.getFullYear()
|
|
926
|
+
const month = date.getMonth() + 1
|
|
927
|
+
const day = date.getDate()
|
|
928
|
+
return year + "-" + (month < 10 ? "0" : "") + month + "-" + (day < 10 ? "0" : "") + day
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function dayKeyFromUsageDate(rawDate) {
|
|
932
|
+
if (typeof rawDate !== "string") return null
|
|
933
|
+
const value = rawDate.trim()
|
|
934
|
+
if (!value) return null
|
|
935
|
+
|
|
936
|
+
const isoMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
937
|
+
if (isoMatch) {
|
|
938
|
+
return isoMatch[1] + "-" + isoMatch[2] + "-" + isoMatch[3]
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const isoDatePrefixMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:[Tt\s]|$)/)
|
|
942
|
+
if (isoDatePrefixMatch) {
|
|
943
|
+
return isoDatePrefixMatch[1] + "-" + isoDatePrefixMatch[2] + "-" + isoDatePrefixMatch[3]
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const compactMatch = value.match(/^(\d{4})(\d{2})(\d{2})$/)
|
|
947
|
+
if (compactMatch) {
|
|
948
|
+
return compactMatch[1] + "-" + compactMatch[2] + "-" + compactMatch[3]
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const ms = Date.parse(value)
|
|
952
|
+
if (!Number.isFinite(ms)) return null
|
|
953
|
+
return dayKeyFromDate(new Date(ms))
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function usageCostUsd(day) {
|
|
957
|
+
if (!day || typeof day !== "object") return null
|
|
958
|
+
|
|
959
|
+
if (day.totalCost != null) {
|
|
960
|
+
const totalCost = Number(day.totalCost)
|
|
961
|
+
if (Number.isFinite(totalCost)) return totalCost
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (day.costUSD != null) {
|
|
965
|
+
const costUSD = Number(day.costUSD)
|
|
966
|
+
if (Number.isFinite(costUSD)) return costUSD
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return null
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function costAndTokensLabel(data, opts) {
|
|
973
|
+
const includeZeroTokens = !!(opts && opts.includeZeroTokens)
|
|
974
|
+
const parts = []
|
|
975
|
+
if (data.costUSD != null) parts.push("$" + data.costUSD.toFixed(2))
|
|
976
|
+
if (data.tokens > 0 || (includeZeroTokens && data.tokens === 0)) {
|
|
977
|
+
parts.push(fmtTokens(data.tokens) + " tokens")
|
|
978
|
+
}
|
|
979
|
+
return parts.join(" · ")
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function usageDayLabel(rawDate) {
|
|
983
|
+
const key = dayKeyFromUsageDate(rawDate)
|
|
984
|
+
if (!key) return String(rawDate || "").slice(0, 10) || "Usage"
|
|
985
|
+
const month = Number(key.slice(5, 7))
|
|
986
|
+
const day = Number(key.slice(8, 10))
|
|
987
|
+
return month + "/" + day
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function collectUsageChartPoints(daily) {
|
|
991
|
+
const hasCost = daily.some((day) => {
|
|
992
|
+
const cost = usageCostUsd(day)
|
|
993
|
+
return cost !== null && cost > 0
|
|
994
|
+
})
|
|
995
|
+
const points = []
|
|
996
|
+
for (let i = 0; i < daily.length; i++) {
|
|
997
|
+
const day = daily[i]
|
|
998
|
+
const tokens = Number(day && day.totalTokens)
|
|
999
|
+
if (!Number.isFinite(tokens) || tokens < 0) continue
|
|
1000
|
+
const key = dayKeyFromUsageDate(day.date)
|
|
1001
|
+
if (!key) continue
|
|
1002
|
+
const cost = usageCostUsd(day) || 0
|
|
1003
|
+
points.push({
|
|
1004
|
+
key: key,
|
|
1005
|
+
label: usageDayLabel(day.date),
|
|
1006
|
+
value: hasCost ? cost : tokens,
|
|
1007
|
+
valueLabel: hasCost
|
|
1008
|
+
? "$" + cost.toFixed(2) + " · " + fmtTokens(tokens) + " tokens"
|
|
1009
|
+
: fmtTokens(tokens) + " tokens",
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
return points
|
|
1013
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
1014
|
+
.slice(-31)
|
|
1015
|
+
.map((point) => ({
|
|
1016
|
+
label: point.label,
|
|
1017
|
+
value: point.value,
|
|
1018
|
+
valueLabel: point.valueLabel,
|
|
1019
|
+
}))
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function pushUsageChartLine(lines, ctx, daily, note) {
|
|
1023
|
+
const points = collectUsageChartPoints(daily)
|
|
1024
|
+
if (points.length === 0) return
|
|
1025
|
+
lines.push(ctx.line.barChart({
|
|
1026
|
+
label: "Usage Trend",
|
|
1027
|
+
points: points,
|
|
1028
|
+
note: note || "Estimated from local Claude logs at API rates.",
|
|
1029
|
+
color: "#DE7356",
|
|
1030
|
+
}))
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function persistUsageDaily(ctx, daily, displayName, source) {
|
|
1034
|
+
if (!ctx.host.usageDaily || typeof ctx.host.usageDaily.ingest !== "function") return
|
|
1035
|
+
if (!daily || !daily.length) return
|
|
1036
|
+
try {
|
|
1037
|
+
const payload = { displayName: displayName, daily: daily }
|
|
1038
|
+
if (source) payload.source = source
|
|
1039
|
+
ctx.host.usageDaily.ingest(payload)
|
|
1040
|
+
} catch (e) { /* ignore */ }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function pushDayUsageLine(lines, ctx, label, dayEntry) {
|
|
1044
|
+
const tokens = Number(dayEntry && dayEntry.totalTokens) || 0
|
|
1045
|
+
const cost = usageCostUsd(dayEntry)
|
|
1046
|
+
if (tokens > 0 || cost !== null) {
|
|
1047
|
+
lines.push(ctx.line.text({
|
|
1048
|
+
label: label,
|
|
1049
|
+
value: costAndTokensLabel(
|
|
1050
|
+
{ tokens: tokens, costUSD: cost === null ? 0 : cost },
|
|
1051
|
+
{ includeZeroTokens: true }
|
|
1052
|
+
)
|
|
1053
|
+
}))
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
lines.push(ctx.line.text({
|
|
1058
|
+
label: label,
|
|
1059
|
+
value: costAndTokensLabel({ tokens: 0, costUSD: 0 }, { includeZeroTokens: true })
|
|
1060
|
+
}))
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function summarizeUsageDaily(daily) {
|
|
1064
|
+
const summary = { tokens: 0, costUSD: 0, hasCost: false }
|
|
1065
|
+
const selectedDaily = (daily || []).slice(-30)
|
|
1066
|
+
for (let i = 0; i < selectedDaily.length; i++) {
|
|
1067
|
+
const day = selectedDaily[i]
|
|
1068
|
+
const dayTokens = Number(day && day.totalTokens)
|
|
1069
|
+
if (Number.isFinite(dayTokens)) summary.tokens += dayTokens
|
|
1070
|
+
const dayCost = usageCostUsd(day)
|
|
1071
|
+
if (dayCost !== null) {
|
|
1072
|
+
summary.costUSD += dayCost
|
|
1073
|
+
summary.hasCost = true
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return summary
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function appendUsageDailyLines(lines, ctx, daily, chartNote) {
|
|
1080
|
+
if (!daily || !daily.length) return false
|
|
1081
|
+
const now = new Date()
|
|
1082
|
+
const todayKey = dayKeyFromDate(now)
|
|
1083
|
+
const yesterday = new Date(now.getTime())
|
|
1084
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
1085
|
+
const yesterdayKey = dayKeyFromDate(yesterday)
|
|
1086
|
+
|
|
1087
|
+
let todayEntry = null
|
|
1088
|
+
let yesterdayEntry = null
|
|
1089
|
+
for (let i = 0; i < daily.length; i++) {
|
|
1090
|
+
const usageDayKey = dayKeyFromUsageDate(daily[i].date)
|
|
1091
|
+
if (usageDayKey === todayKey) {
|
|
1092
|
+
todayEntry = daily[i]
|
|
1093
|
+
continue
|
|
1094
|
+
}
|
|
1095
|
+
if (usageDayKey === yesterdayKey) {
|
|
1096
|
+
yesterdayEntry = daily[i]
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
pushDayUsageLine(lines, ctx, "Today", todayEntry)
|
|
1101
|
+
pushDayUsageLine(lines, ctx, "Yesterday", yesterdayEntry)
|
|
1102
|
+
|
|
1103
|
+
const summary = summarizeUsageDaily(daily)
|
|
1104
|
+
if (summary.tokens > 0 || summary.hasCost) {
|
|
1105
|
+
lines.push(ctx.line.text({
|
|
1106
|
+
label: "Last 30 Days",
|
|
1107
|
+
value: costAndTokensLabel({
|
|
1108
|
+
tokens: summary.tokens,
|
|
1109
|
+
costUSD: summary.hasCost ? summary.costUSD : null,
|
|
1110
|
+
}, { includeZeroTokens: true })
|
|
1111
|
+
}))
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
pushUsageChartLine(lines, ctx, daily, chartNote)
|
|
1115
|
+
return summary.tokens > 0 || summary.hasCost
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ── Web mode helpers ───────────────────────────────────────────────────────
|
|
1119
|
+
|
|
1120
|
+
function extractSessionKey(value) {
|
|
1121
|
+
if (!value) return null
|
|
1122
|
+
const raw = String(value).trim()
|
|
1123
|
+
if (!raw) return null
|
|
1124
|
+
const cookieMatch = raw.match(/(?:^|;\s*)sessionKey=([^;]+)/i)
|
|
1125
|
+
if (cookieMatch && cookieMatch[1]) return cookieMatch[1].trim() || null
|
|
1126
|
+
const bare = raw.replace(/^sessionKey=/i, "").trim()
|
|
1127
|
+
return bare || null
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function providerCookieHeader(ctx) {
|
|
1131
|
+
if (ctx.provider && typeof ctx.provider.cookieHeader === "string") {
|
|
1132
|
+
return ctx.provider.cookieHeader
|
|
1133
|
+
}
|
|
1134
|
+
return null
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function getSessionKey(ctx) {
|
|
1138
|
+
const candidates = [
|
|
1139
|
+
readEnvText(ctx, "CLAUDE_AI_SESSION_KEY"),
|
|
1140
|
+
readEnvText(ctx, "CLAUDE_WEB_SESSION_KEY"),
|
|
1141
|
+
providerCookieHeader(ctx),
|
|
1142
|
+
]
|
|
1143
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1144
|
+
const key = extractSessionKey(candidates[i])
|
|
1145
|
+
if (key) return key
|
|
1146
|
+
}
|
|
1147
|
+
return null
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function buildWebHeaders(sessionKey) {
|
|
1151
|
+
return {
|
|
1152
|
+
Cookie: "sessionKey=" + sessionKey,
|
|
1153
|
+
Accept: "application/json",
|
|
1154
|
+
Origin: "https://claude.ai",
|
|
1155
|
+
Referer: "https://claude.ai/settings/usage",
|
|
1156
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
|
1157
|
+
"anthropic-client-platform": "web_claude_ai",
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function fetchWebUsage(ctx, sessionKey) {
|
|
1162
|
+
const headers = buildWebHeaders(sessionKey)
|
|
1163
|
+
|
|
1164
|
+
let orgResp
|
|
1165
|
+
try {
|
|
1166
|
+
orgResp = ctx.util.request({
|
|
1167
|
+
method: "GET",
|
|
1168
|
+
url: CLAUDE_WEB_BASE_URL + "/organizations",
|
|
1169
|
+
headers: headers,
|
|
1170
|
+
timeoutMs: 10000,
|
|
1171
|
+
})
|
|
1172
|
+
} catch (e) {
|
|
1173
|
+
ctx.host.log.error("web mode: org request failed: " + String(e))
|
|
1174
|
+
throw "Web usage request failed. Check your connection."
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (orgResp.status === 401 || orgResp.status === 403) {
|
|
1178
|
+
throw "Web session invalid or expired. Set CLAUDE_AI_SESSION_KEY to your claude.ai sessionKey cookie."
|
|
1179
|
+
}
|
|
1180
|
+
if (orgResp.status < 200 || orgResp.status >= 300) {
|
|
1181
|
+
throw "Web usage request failed (HTTP " + orgResp.status + "). Try again later."
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const orgs = ctx.util.tryParseJson(orgResp.bodyText)
|
|
1185
|
+
if (!Array.isArray(orgs) || orgs.length === 0) {
|
|
1186
|
+
throw "No Claude organizations found in web response."
|
|
1187
|
+
}
|
|
1188
|
+
let orgId = null
|
|
1189
|
+
let usageData = null
|
|
1190
|
+
let lastUsageStatus = null
|
|
1191
|
+
for (let i = 0; i < orgs.length; i++) {
|
|
1192
|
+
const candidateOrgId = orgs[i] && orgs[i].uuid
|
|
1193
|
+
if (!candidateOrgId) continue
|
|
1194
|
+
|
|
1195
|
+
let usageResp
|
|
1196
|
+
try {
|
|
1197
|
+
usageResp = ctx.util.request({
|
|
1198
|
+
method: "GET",
|
|
1199
|
+
url: CLAUDE_WEB_BASE_URL + "/organizations/" + candidateOrgId + "/usage",
|
|
1200
|
+
headers: headers,
|
|
1201
|
+
timeoutMs: 10000,
|
|
1202
|
+
})
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
ctx.host.log.error("web mode: usage request failed: " + String(e))
|
|
1205
|
+
throw "Web usage request failed. Check your connection."
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
lastUsageStatus = usageResp.status
|
|
1209
|
+
if (usageResp.status === 401 || usageResp.status === 403) {
|
|
1210
|
+
ctx.host.log.info("web mode: usage unavailable for org " + candidateOrgId + " (HTTP " + usageResp.status + ")")
|
|
1211
|
+
continue
|
|
1212
|
+
}
|
|
1213
|
+
if (usageResp.status < 200 || usageResp.status >= 300) {
|
|
1214
|
+
throw "Web usage request failed (HTTP " + usageResp.status + "). Try again later."
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const parsedUsageData = ctx.util.tryParseJson(usageResp.bodyText)
|
|
1218
|
+
if (!parsedUsageData) throw "Web usage response invalid. Try again later."
|
|
1219
|
+
orgId = candidateOrgId
|
|
1220
|
+
usageData = parsedUsageData
|
|
1221
|
+
break
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!usageData) {
|
|
1225
|
+
if (lastUsageStatus === 401) {
|
|
1226
|
+
throw "Web session invalid or expired. Set CLAUDE_AI_SESSION_KEY to your claude.ai sessionKey cookie."
|
|
1227
|
+
}
|
|
1228
|
+
if (lastUsageStatus === 403) {
|
|
1229
|
+
throw "No Claude organization with usage access found for this web session."
|
|
1230
|
+
}
|
|
1231
|
+
throw "Organization UUID missing in web response."
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
let accountInfo = null
|
|
1235
|
+
try {
|
|
1236
|
+
const accResp = ctx.util.request({
|
|
1237
|
+
method: "GET",
|
|
1238
|
+
url: CLAUDE_WEB_BASE_URL + "/account",
|
|
1239
|
+
headers: headers,
|
|
1240
|
+
timeoutMs: 5000,
|
|
1241
|
+
})
|
|
1242
|
+
if (accResp.status >= 200 && accResp.status < 300) {
|
|
1243
|
+
accountInfo = ctx.util.tryParseJson(accResp.bodyText)
|
|
1244
|
+
}
|
|
1245
|
+
} catch {
|
|
1246
|
+
// Account info is optional
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
ctx.host.log.info("web mode: usage fetched for org " + orgId)
|
|
1250
|
+
return { usageData: usageData, accountInfo: accountInfo }
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function loadWebUsageData(ctx, sessionKey, nowMs) {
|
|
1254
|
+
const liveCache = readLiveUsageCache(ctx)
|
|
1255
|
+
const cachedLastWebUsageFetchMs = Number(liveCache && liveCache.lastWebUsageFetchMs) || 0
|
|
1256
|
+
const effectiveLastWebUsageFetchMs = Math.max(lastWebUsageFetchMs, cachedLastWebUsageFetchMs)
|
|
1257
|
+
if (nowMs - effectiveLastWebUsageFetchMs < MIN_USAGE_FETCH_INTERVAL_MS) {
|
|
1258
|
+
lastWebUsageFetchMs = effectiveLastWebUsageFetchMs
|
|
1259
|
+
ctx.host.log.info(
|
|
1260
|
+
"web usage fetch skipped: last fetch was " +
|
|
1261
|
+
Math.round((nowMs - effectiveLastWebUsageFetchMs) / 1000) + "s ago"
|
|
1262
|
+
)
|
|
1263
|
+
return { data: cachedWebUsageData || cachedLiveUsageData(liveCache, nowMs), plan: null }
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
lastWebUsageFetchMs = nowMs
|
|
1267
|
+
const result = fetchWebUsage(ctx, sessionKey)
|
|
1268
|
+
cachedWebUsageData = result.usageData
|
|
1269
|
+
writeLiveUsageCache(ctx, {
|
|
1270
|
+
usageData: result.usageData,
|
|
1271
|
+
usageFetchedAtMs: nowMs,
|
|
1272
|
+
lastWebUsageFetchMs: nowMs,
|
|
1273
|
+
})
|
|
1274
|
+
let plan = null
|
|
1275
|
+
if (result.accountInfo && result.accountInfo.rate_limit_tier) {
|
|
1276
|
+
plan = webTierToPlanLabel(result.accountInfo.rate_limit_tier)
|
|
1277
|
+
}
|
|
1278
|
+
return { data: result.usageData, plan: plan }
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function webTierToPlanLabel(tier) {
|
|
1282
|
+
const t = (tier || "").toLowerCase()
|
|
1283
|
+
if (t === "free") return "Claude Free"
|
|
1284
|
+
if (t === "pro" || t === "claude_pro") return "Claude Pro"
|
|
1285
|
+
if (t === "max" || t === "claude_max_5" || t === "claude_max_20") return "Claude Max"
|
|
1286
|
+
if (t === "team") return "Claude Team"
|
|
1287
|
+
if (t === "enterprise") return "Claude Enterprise"
|
|
1288
|
+
return "Claude (" + tier + ")"
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ── Usage window rendering ─────────────────────────────────────────────────
|
|
1292
|
+
|
|
1293
|
+
function pickUsageWindow(data, aliases) {
|
|
1294
|
+
for (let i = 0; i < aliases.length; i++) {
|
|
1295
|
+
const w = data[aliases[i]]
|
|
1296
|
+
if (w && typeof w === "object") return w
|
|
1297
|
+
}
|
|
1298
|
+
return null
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function moneyMajorUnits(value) {
|
|
1302
|
+
const n = Number(value)
|
|
1303
|
+
if (!Number.isFinite(n)) return null
|
|
1304
|
+
// Claude usage APIs return Extra usage amounts in minor units (cents).
|
|
1305
|
+
return Math.round(n) / 100
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function currencySymbol(code) {
|
|
1309
|
+
const c = String(code || "").trim().toUpperCase()
|
|
1310
|
+
if (c === "EUR") return "€"
|
|
1311
|
+
if (c === "GBP") return "£"
|
|
1312
|
+
if (c === "JPY") return "¥"
|
|
1313
|
+
return "$"
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function formatMoneyAmount(value, code) {
|
|
1317
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return ""
|
|
1318
|
+
const symbol = currencySymbol(code)
|
|
1319
|
+
return symbol + value.toFixed(2).replace(/\.00$/, "")
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function nextMonthResetIso(nowIso) {
|
|
1323
|
+
const now = new Date(nowIso || Date.now())
|
|
1324
|
+
if (Number.isNaN(now.getTime())) return null
|
|
1325
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0)).toISOString()
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function formatMonthDay(iso) {
|
|
1329
|
+
if (!iso) return null
|
|
1330
|
+
const d = new Date(iso)
|
|
1331
|
+
if (Number.isNaN(d.getTime())) return null
|
|
1332
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
1333
|
+
return months[d.getUTCMonth()] + " " + String(d.getUTCDate())
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function addUsageWindowLines(ctx, data, lines) {
|
|
1337
|
+
const fiveHour = data.five_hour
|
|
1338
|
+
if (fiveHour && typeof fiveHour.utilization === "number") {
|
|
1339
|
+
lines.push(ctx.line.progress({
|
|
1340
|
+
label: "Session",
|
|
1341
|
+
used: fiveHour.utilization,
|
|
1342
|
+
limit: 100,
|
|
1343
|
+
format: { kind: "percent" },
|
|
1344
|
+
resetsAt: ctx.util.toIso(fiveHour.resets_at),
|
|
1345
|
+
periodDurationMs: 5 * 60 * 60 * 1000,
|
|
1346
|
+
}))
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const sevenDay = data.seven_day
|
|
1350
|
+
if (sevenDay && typeof sevenDay.utilization === "number") {
|
|
1351
|
+
lines.push(ctx.line.progress({
|
|
1352
|
+
label: "Weekly",
|
|
1353
|
+
used: sevenDay.utilization,
|
|
1354
|
+
limit: 100,
|
|
1355
|
+
format: { kind: "percent" },
|
|
1356
|
+
resetsAt: ctx.util.toIso(sevenDay.resets_at),
|
|
1357
|
+
periodDurationMs: 7 * 24 * 60 * 60 * 1000,
|
|
1358
|
+
}))
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const sevenDaySonnet = data.seven_day_sonnet
|
|
1362
|
+
if (sevenDaySonnet && typeof sevenDaySonnet.utilization === "number") {
|
|
1363
|
+
lines.push(ctx.line.progress({
|
|
1364
|
+
label: "Sonnet",
|
|
1365
|
+
used: sevenDaySonnet.utilization,
|
|
1366
|
+
limit: 100,
|
|
1367
|
+
format: { kind: "percent" },
|
|
1368
|
+
resetsAt: ctx.util.toIso(sevenDaySonnet.resets_at),
|
|
1369
|
+
periodDurationMs: 7 * 24 * 60 * 60 * 1000,
|
|
1370
|
+
}))
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const sevenDayOpus = data.seven_day_opus
|
|
1374
|
+
if (sevenDayOpus && typeof sevenDayOpus.utilization === "number") {
|
|
1375
|
+
lines.push(ctx.line.progress({
|
|
1376
|
+
label: "Opus",
|
|
1377
|
+
used: sevenDayOpus.utilization,
|
|
1378
|
+
limit: 100,
|
|
1379
|
+
format: { kind: "percent" },
|
|
1380
|
+
resetsAt: ctx.util.toIso(sevenDayOpus.resets_at),
|
|
1381
|
+
periodDurationMs: 7 * 24 * 60 * 60 * 1000,
|
|
1382
|
+
}))
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Routines: check canonical name first, then legacy aliases
|
|
1386
|
+
const routinesWindow = pickUsageWindow(data, [
|
|
1387
|
+
"seven_day_routines",
|
|
1388
|
+
"seven_day_cowork",
|
|
1389
|
+
"seven_day_claude_routines",
|
|
1390
|
+
])
|
|
1391
|
+
if (routinesWindow && typeof routinesWindow.utilization === "number") {
|
|
1392
|
+
lines.push(ctx.line.progress({
|
|
1393
|
+
label: "Routines",
|
|
1394
|
+
used: routinesWindow.utilization,
|
|
1395
|
+
limit: 100,
|
|
1396
|
+
format: { kind: "percent" },
|
|
1397
|
+
resetsAt: ctx.util.toIso(routinesWindow.resets_at),
|
|
1398
|
+
periodDurationMs: 7 * 24 * 60 * 60 * 1000,
|
|
1399
|
+
}))
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (data.extra_usage && data.extra_usage.is_enabled) {
|
|
1403
|
+
const used = moneyMajorUnits(data.extra_usage.used_credits)
|
|
1404
|
+
const limit = moneyMajorUnits(data.extra_usage.monthly_limit)
|
|
1405
|
+
const currency = data.extra_usage.currency || data.extra_usage.currency_code || data.extra_usage.currencyCode || "USD"
|
|
1406
|
+
const resetIso = ctx.util.toIso(
|
|
1407
|
+
data.extra_usage.resets_at ||
|
|
1408
|
+
data.extra_usage.reset_at ||
|
|
1409
|
+
data.extra_usage.resetsAt ||
|
|
1410
|
+
data.extra_usage.resetAt ||
|
|
1411
|
+
data.extra_usage.monthly_reset_at ||
|
|
1412
|
+
data.extra_usage.monthlyResetAt ||
|
|
1413
|
+
data.extra_usage.period_end ||
|
|
1414
|
+
data.extra_usage.periodEnd ||
|
|
1415
|
+
data.extra_usage.billing_cycle_end ||
|
|
1416
|
+
data.extra_usage.billingCycleEnd
|
|
1417
|
+
) || nextMonthResetIso(ctx.nowIso)
|
|
1418
|
+
const resetText = formatMonthDay(resetIso)
|
|
1419
|
+
|
|
1420
|
+
if (typeof used === "number" && typeof limit === "number" && limit > 0) {
|
|
1421
|
+
const pct = Math.round((used / limit) * 100)
|
|
1422
|
+
let value = formatMoneyAmount(used, currency) + " / " + formatMoneyAmount(limit, currency)
|
|
1423
|
+
value += " (" + pct + "% used"
|
|
1424
|
+
if (resetText) value += ", resets " + resetText
|
|
1425
|
+
value += ")"
|
|
1426
|
+
lines.push(ctx.line.text({ label: "Extra usage spent", value: value }))
|
|
1427
|
+
} else if (typeof used === "number" && used > 0) {
|
|
1428
|
+
let value = formatMoneyAmount(used, currency)
|
|
1429
|
+
if (resetText) value += " (resets " + resetText + ")"
|
|
1430
|
+
lines.push(ctx.line.text({ label: "Extra usage spent", value: value }))
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function oauthPlanLabel(ctx, creds) {
|
|
1436
|
+
if (!creds || !creds.oauth || !creds.oauth.subscriptionType) return null
|
|
1437
|
+
const basePlan = ctx.fmt.planLabel(creds.oauth.subscriptionType)
|
|
1438
|
+
if (!basePlan) return null
|
|
1439
|
+
|
|
1440
|
+
let tierSuffix = ""
|
|
1441
|
+
const rlt = String(creds.oauth.rateLimitTier || "")
|
|
1442
|
+
const tierMatch = rlt.match(/(\d+)x/)
|
|
1443
|
+
if (tierMatch) {
|
|
1444
|
+
tierSuffix = " " + tierMatch[1] + "x"
|
|
1445
|
+
}
|
|
1446
|
+
return basePlan + tierSuffix
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// ── probe() ────────────────────────────────────────────────────────────────
|
|
1450
|
+
|
|
1451
|
+
function probe(ctx) {
|
|
1452
|
+
const creds = loadCredentials(ctx)
|
|
1453
|
+
const sessionKey = getSessionKey(ctx)
|
|
1454
|
+
const adminApiKey = readClaudeAdminApiKey(ctx)
|
|
1455
|
+
const hasOAuth = !!(creds && creds.oauth && creds.oauth.accessToken && creds.oauth.accessToken.trim())
|
|
1456
|
+
const sourceMode = String(ctx.sourceMode || "auto").toLowerCase()
|
|
1457
|
+
const wantsAuto = sourceMode === "auto"
|
|
1458
|
+
const wantsWeb = sourceMode === "web"
|
|
1459
|
+
const wantsOAuth = sourceMode === "oauth"
|
|
1460
|
+
const wantsLocal = sourceMode === "local"
|
|
1461
|
+
const wantsApi = sourceMode === "api"
|
|
1462
|
+
|
|
1463
|
+
if (wantsWeb && !sessionKey) {
|
|
1464
|
+
ctx.host.log.error("web mode requested but no session key found")
|
|
1465
|
+
throw "Web session missing. Set CLAUDE_AI_SESSION_KEY to your claude.ai sessionKey cookie."
|
|
1466
|
+
}
|
|
1467
|
+
if (wantsOAuth && !hasOAuth) {
|
|
1468
|
+
ctx.host.log.error("oauth mode requested but no OAuth credentials found")
|
|
1469
|
+
throw "Not logged in. Run `claude` to authenticate."
|
|
1470
|
+
}
|
|
1471
|
+
if (wantsApi && !adminApiKey) {
|
|
1472
|
+
ctx.host.log.error("api mode requested but no Claude Admin API key found")
|
|
1473
|
+
throw "Claude API usage needs an Anthropic Admin API key. Set ANTHROPIC_ADMIN_KEY."
|
|
1474
|
+
}
|
|
1475
|
+
if (!wantsLocal && !wantsApi && !hasOAuth && !sessionKey && !adminApiKey) {
|
|
1476
|
+
ctx.host.log.error("probe failed: no credentials or session key")
|
|
1477
|
+
throw "Not logged in. Run `claude` to authenticate, set CLAUDE_AI_SESSION_KEY, or set ANTHROPIC_ADMIN_KEY."
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const nowMs = Date.now()
|
|
1481
|
+
const liveCache = readLiveUsageCache(ctx)
|
|
1482
|
+
const homePath = getClaudeHomeOverride(ctx)
|
|
1483
|
+
let data = null
|
|
1484
|
+
let lines = []
|
|
1485
|
+
let plan = null
|
|
1486
|
+
let rateLimited = false
|
|
1487
|
+
let retryAfterSeconds = null
|
|
1488
|
+
|
|
1489
|
+
if (hasOAuth) {
|
|
1490
|
+
plan = oauthPlanLabel(ctx, creds)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (wantsLocal) {
|
|
1494
|
+
ctx.host.log.info("local mode requested; skipping live usage fetch")
|
|
1495
|
+
} else if (wantsApi) {
|
|
1496
|
+
ctx.host.log.info("api mode requested; skipping live quota fetch")
|
|
1497
|
+
} else if (hasOAuth && !wantsWeb) {
|
|
1498
|
+
// ── OAuth mode ───────────────────────────────────────────────────────
|
|
1499
|
+
let accessToken = creds.oauth.accessToken
|
|
1500
|
+
const canFetchLiveUsage = hasProfileScope(creds)
|
|
1501
|
+
|
|
1502
|
+
if (canFetchLiveUsage) {
|
|
1503
|
+
const cachedData = cachedUsageData || cachedLiveUsageData(liveCache, nowMs)
|
|
1504
|
+
const cachedRateLimitedUntilMs = Number(liveCache && liveCache.rateLimitedUntilMs) || 0
|
|
1505
|
+
const effectiveRateLimitedUntilMs = Math.max(rateLimitedUntilMs, cachedRateLimitedUntilMs)
|
|
1506
|
+
|
|
1507
|
+
if (nowMs < effectiveRateLimitedUntilMs) {
|
|
1508
|
+
// Still within a rate-limit window from a previous probe call — skip the
|
|
1509
|
+
// API request entirely and surface the remaining wait time to the user.
|
|
1510
|
+
rateLimited = true
|
|
1511
|
+
rateLimitedUntilMs = effectiveRateLimitedUntilMs
|
|
1512
|
+
retryAfterSeconds = Math.ceil((effectiveRateLimitedUntilMs - nowMs) / 1000)
|
|
1513
|
+
data = cachedData
|
|
1514
|
+
ctx.host.log.info("usage fetch skipped: rate-limited for " + retryAfterSeconds + "s more")
|
|
1515
|
+
} else {
|
|
1516
|
+
// Rate-limit window has expired (or was never set). Check whether we were
|
|
1517
|
+
// previously rate-limited so we can bypass the min-interval guard: a short
|
|
1518
|
+
// Retry-After (< 5 min) must not be swallowed by the normal poll throttle.
|
|
1519
|
+
const wasRateLimited = effectiveRateLimitedUntilMs > 0
|
|
1520
|
+
rateLimitedUntilMs = 0
|
|
1521
|
+
|
|
1522
|
+
const cachedLastUsageFetchMs = Number(liveCache && liveCache.lastUsageFetchMs) || 0
|
|
1523
|
+
const effectiveLastUsageFetchMs = Math.max(lastUsageFetchMs, cachedLastUsageFetchMs)
|
|
1524
|
+
if (!wasRateLimited && nowMs - effectiveLastUsageFetchMs < MIN_USAGE_FETCH_INTERVAL_MS) {
|
|
1525
|
+
// Polled too recently in normal operation — reuse last cached response.
|
|
1526
|
+
lastUsageFetchMs = effectiveLastUsageFetchMs
|
|
1527
|
+
data = cachedData
|
|
1528
|
+
ctx.host.log.info(
|
|
1529
|
+
"usage fetch skipped: last fetch was " +
|
|
1530
|
+
Math.round((nowMs - effectiveLastUsageFetchMs) / 1000) + "s ago (min interval " +
|
|
1531
|
+
MIN_USAGE_FETCH_INTERVAL_MS / 1000 + "s)"
|
|
1532
|
+
)
|
|
1533
|
+
} else {
|
|
1534
|
+
// Proactively refresh if token is expired or about to expire
|
|
1535
|
+
if (needsRefresh(ctx, creds.oauth, nowMs)) {
|
|
1536
|
+
ctx.host.log.info("token needs refresh (expired or expiring soon)")
|
|
1537
|
+
const refreshed = refreshToken(ctx, creds)
|
|
1538
|
+
if (refreshed) {
|
|
1539
|
+
accessToken = refreshed
|
|
1540
|
+
} else {
|
|
1541
|
+
ctx.host.log.warn("proactive refresh failed, trying with existing token")
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
lastUsageFetchMs = nowMs
|
|
1546
|
+
let resp
|
|
1547
|
+
let didRefresh = false
|
|
1548
|
+
try {
|
|
1549
|
+
resp = ctx.util.retryOnceOnAuth({
|
|
1550
|
+
request: (token) => {
|
|
1551
|
+
try {
|
|
1552
|
+
return fetchUsage(ctx, token || accessToken)
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
ctx.host.log.error("usage request exception: " + String(e))
|
|
1555
|
+
if (didRefresh) {
|
|
1556
|
+
throw "Usage request failed after refresh. Try again."
|
|
1557
|
+
}
|
|
1558
|
+
throw "Usage request failed. Check your connection."
|
|
1559
|
+
}
|
|
1560
|
+
},
|
|
1561
|
+
refresh: () => {
|
|
1562
|
+
ctx.host.log.info("usage returned 401, attempting refresh")
|
|
1563
|
+
didRefresh = true
|
|
1564
|
+
return refreshToken(ctx, creds)
|
|
1565
|
+
},
|
|
1566
|
+
})
|
|
1567
|
+
} catch (e) {
|
|
1568
|
+
if (typeof e === "string") throw e
|
|
1569
|
+
ctx.host.log.error("usage request failed: " + String(e))
|
|
1570
|
+
throw "Usage request failed. Check your connection."
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (ctx.util.isAuthStatus(resp.status)) {
|
|
1574
|
+
ctx.host.log.error("usage returned auth error after all retries: status=" + resp.status)
|
|
1575
|
+
throw "Token expired. Run `claude` to log in again."
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (resp.status === 429) {
|
|
1579
|
+
rateLimited = true
|
|
1580
|
+
retryAfterSeconds = parseRetryAfterSeconds(resp.headers)
|
|
1581
|
+
const backoffMs = retryAfterSeconds !== null
|
|
1582
|
+
? retryAfterSeconds * 1000
|
|
1583
|
+
: DEFAULT_RATE_LIMIT_BACKOFF_MS
|
|
1584
|
+
rateLimitedUntilMs = nowMs + backoffMs
|
|
1585
|
+
data = cachedData
|
|
1586
|
+
writeLiveUsageCache(ctx, {
|
|
1587
|
+
rateLimitedUntilMs: rateLimitedUntilMs,
|
|
1588
|
+
lastUsageFetchMs: nowMs,
|
|
1589
|
+
usageData: data,
|
|
1590
|
+
usageFetchedAtMs: Number(liveCache && liveCache.usageFetchedAtMs) || null,
|
|
1591
|
+
lastRateLimitedAtMs: nowMs,
|
|
1592
|
+
})
|
|
1593
|
+
ctx.host.log.warn(
|
|
1594
|
+
"usage rate limited (429), backing off for " +
|
|
1595
|
+
Math.round(backoffMs / 1000) + "s"
|
|
1596
|
+
)
|
|
1597
|
+
} else if (resp.status < 200 || resp.status >= 300) {
|
|
1598
|
+
ctx.host.log.error("usage returned error: status=" + resp.status)
|
|
1599
|
+
throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later."
|
|
1600
|
+
} else {
|
|
1601
|
+
ctx.host.log.info("usage fetch succeeded")
|
|
1602
|
+
data = ctx.util.tryParseJson(resp.bodyText)
|
|
1603
|
+
if (data === null) {
|
|
1604
|
+
throw "Usage response invalid. Try again later."
|
|
1605
|
+
}
|
|
1606
|
+
cachedUsageData = data
|
|
1607
|
+
rateLimitedUntilMs = 0
|
|
1608
|
+
writeLiveUsageCache(ctx, {
|
|
1609
|
+
rateLimitedUntilMs: 0,
|
|
1610
|
+
lastUsageFetchMs: nowMs,
|
|
1611
|
+
usageData: data,
|
|
1612
|
+
usageFetchedAtMs: nowMs,
|
|
1613
|
+
lastRateLimitedAtMs: null,
|
|
1614
|
+
})
|
|
1615
|
+
}
|
|
1616
|
+
} // end fetch else-branch
|
|
1617
|
+
}
|
|
1618
|
+
} else {
|
|
1619
|
+
ctx.host.log.info("skipping live usage fetch for inference-only token")
|
|
1620
|
+
}
|
|
1621
|
+
} else if (sessionKey) {
|
|
1622
|
+
// ── Web mode ─────────────────────────────────────────────────────────
|
|
1623
|
+
const webResult = loadWebUsageData(ctx, sessionKey, nowMs)
|
|
1624
|
+
data = webResult.data
|
|
1625
|
+
if (webResult.plan) {
|
|
1626
|
+
plan = webResult.plan
|
|
1627
|
+
}
|
|
1628
|
+
} else {
|
|
1629
|
+
ctx.host.log.info("auto mode using Claude Admin API history only")
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (rateLimited && wantsAuto && sessionKey) {
|
|
1633
|
+
try {
|
|
1634
|
+
ctx.host.log.info("OAuth usage rate limited; trying Claude web fallback")
|
|
1635
|
+
const webResult = loadWebUsageData(ctx, sessionKey, nowMs)
|
|
1636
|
+
if (webResult.data) {
|
|
1637
|
+
data = webResult.data
|
|
1638
|
+
rateLimited = false
|
|
1639
|
+
retryAfterSeconds = null
|
|
1640
|
+
if (webResult.plan) {
|
|
1641
|
+
plan = webResult.plan
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
ctx.host.log.warn("Claude web fallback failed after OAuth rate limit: " + String(e))
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ── Render usage windows ─────────────────────────────────────────────────
|
|
1650
|
+
if (data) {
|
|
1651
|
+
addUsageWindowLines(ctx, data, lines)
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// ── Token and cost history ───────────────────────────────────────────────
|
|
1655
|
+
let attachedUsageDaily = false
|
|
1656
|
+
if (!wantsLocal && adminApiKey && (wantsAuto || wantsApi)) {
|
|
1657
|
+
try {
|
|
1658
|
+
const adminDaily = fetchClaudeAdminUsage(ctx, adminApiKey)
|
|
1659
|
+
const summary = summarizeUsageDaily(adminDaily)
|
|
1660
|
+
if (summary.hasCost || summary.tokens > 0) {
|
|
1661
|
+
lines.push(ctx.line.text({
|
|
1662
|
+
label: "API Spend",
|
|
1663
|
+
value: summary.hasCost ? "$" + summary.costUSD.toFixed(2) : "$0.00",
|
|
1664
|
+
}))
|
|
1665
|
+
}
|
|
1666
|
+
attachedUsageDaily = appendUsageDailyLines(
|
|
1667
|
+
lines,
|
|
1668
|
+
ctx,
|
|
1669
|
+
adminDaily,
|
|
1670
|
+
"Claude Admin API organization usage."
|
|
1671
|
+
)
|
|
1672
|
+
persistUsageDaily(ctx, adminDaily, "Claude", "admin_billing")
|
|
1673
|
+
} catch (e) {
|
|
1674
|
+
const message = e && e.message ? e.message : String(e)
|
|
1675
|
+
if (wantsApi) throw message
|
|
1676
|
+
ctx.host.log.warn("Claude Admin API usage failed: " + message)
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
if (!attachedUsageDaily && !wantsApi) {
|
|
1681
|
+
try {
|
|
1682
|
+
const usageResult = queryTokenUsage(ctx, homePath)
|
|
1683
|
+
if (usageResult.status === "ok") {
|
|
1684
|
+
const usage = usageResult.data
|
|
1685
|
+
appendUsageDailyLines(lines, ctx, usage.daily, "Estimated from local Claude logs at API rates.")
|
|
1686
|
+
persistUsageDaily(ctx, usage.daily, "Claude")
|
|
1687
|
+
}
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
ctx.host.log.warn("local Claude token usage failed: " + String(e))
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (rateLimited) {
|
|
1694
|
+
const retryText = retryAfterSeconds !== null
|
|
1695
|
+
? fmtRateLimitMinutes(retryAfterSeconds)
|
|
1696
|
+
: null
|
|
1697
|
+
const waitText = retryText
|
|
1698
|
+
? "Rate limited, retry in ~" + retryText
|
|
1699
|
+
: "Rate limited, try again later"
|
|
1700
|
+
lines.unshift(ctx.line.badge({ label: "Status", text: waitText, color: "#f59e0b" }))
|
|
1701
|
+
const noteText = retryText
|
|
1702
|
+
? "Live usage rate limited — retry in ~" + retryText
|
|
1703
|
+
: "Live usage rate limited — data may be stale"
|
|
1704
|
+
lines.push(ctx.line.text({ label: "Note", value: noteText }))
|
|
1705
|
+
} else if (lines.length === 0) {
|
|
1706
|
+
lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" }))
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
return { plan: plan, lines: lines }
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// _resetState is a testing hook — resets module-scope rate-limit state between tests.
|
|
1713
|
+
// The production host never calls this.
|
|
1714
|
+
function _resetState() {
|
|
1715
|
+
rateLimitedUntilMs = 0
|
|
1716
|
+
lastUsageFetchMs = 0
|
|
1717
|
+
cachedUsageData = null
|
|
1718
|
+
lastWebUsageFetchMs = 0
|
|
1719
|
+
cachedWebUsageData = null
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
globalThis.__openusage_plugin = { id: "claude", probe, _resetState }
|
|
1723
|
+
})()
|