@songsid/agend 2.1.4-beta.37 → 2.1.4-beta.39
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/dist/backend/codex.js +13 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/channel/mcp-server.js +11 -3
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/cli.js +14 -14
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +6 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +6 -0
- package/dist/cross-instance-envelope.js +30 -0
- package/dist/cross-instance-envelope.js.map +1 -0
- package/dist/daemon.d.ts +73 -0
- package/dist/daemon.js +229 -54
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.js +33 -0
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +168 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +57 -13
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +2 -1
- package/dist/outbound-schemas.js +2 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/tmux-manager.d.ts +7 -0
- package/dist/tmux-manager.js +62 -14
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/view.html +124 -15
- package/dist/usage/format-rich.js +24 -23
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +7 -0
- package/dist/usage/i18n-keys.js +34 -0
- package/dist/usage/i18n-keys.js.map +1 -0
- package/dist/usage/i18n.d.ts +5 -0
- package/dist/usage/i18n.js +27 -0
- package/dist/usage/i18n.js.map +1 -0
- package/dist/usage/providers.d.ts +12 -0
- package/dist/usage/providers.js +132 -73
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.js +22 -21
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/usage/providers.js
CHANGED
|
@@ -25,6 +25,7 @@ import { homedir } from "node:os";
|
|
|
25
25
|
import { getProviderRateLimit } from "./provider-alerts.js";
|
|
26
26
|
import { readStatuslineRateLimits, } from "./statusline-usage.js";
|
|
27
27
|
import Database from "better-sqlite3";
|
|
28
|
+
const i18n = (key, ...args) => args.length ? { key, args } : { key };
|
|
28
29
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
29
30
|
const SESSION_MS = 5 * 60 * 60 * 1000;
|
|
30
31
|
function jwtExpiryMs(token) {
|
|
@@ -173,19 +174,20 @@ function readShellRcToken() {
|
|
|
173
174
|
*/
|
|
174
175
|
export function statuslineUsage(reading, plan, now = Date.now()) {
|
|
175
176
|
const metrics = [];
|
|
176
|
-
const add = (w, label, windowMs) => {
|
|
177
|
+
const add = (w, label, labelKey, windowMs) => {
|
|
177
178
|
if (!w)
|
|
178
179
|
return;
|
|
179
180
|
metrics.push({
|
|
180
181
|
label,
|
|
182
|
+
labelI18n: i18n(labelKey),
|
|
181
183
|
type: "percent",
|
|
182
184
|
used: w.usedPercent,
|
|
183
185
|
resetsAt: w.resetsAtMs !== null ? new Date(w.resetsAtMs).toISOString() : null,
|
|
184
186
|
windowMs,
|
|
185
187
|
});
|
|
186
188
|
};
|
|
187
|
-
add(reading.fiveHour, "Session", SESSION_MS);
|
|
188
|
-
add(reading.sevenDay, "Weekly", WEEK_MS);
|
|
189
|
+
add(reading.fiveHour, "Session", "usage.metric.session", SESSION_MS);
|
|
190
|
+
add(reading.sevenDay, "Weekly", "usage.metric.weekly", WEEK_MS);
|
|
189
191
|
const ageMin = Math.round((now - reading.observedAtMs) / 60_000);
|
|
190
192
|
const age = ageMin <= 0 ? "just now" : `${ageMin}m ago`;
|
|
191
193
|
return {
|
|
@@ -194,6 +196,7 @@ export function statuslineUsage(reading, plan, now = Date.now()) {
|
|
|
194
196
|
// Say what is missing as well as where it came from: someone comparing this
|
|
195
197
|
// against claude.ai should not have to guess why per-model rows vanished.
|
|
196
198
|
hint: `from ${reading.instance}'s statusline, ${age} — no API call; plan and per-model limits need a CLI login`,
|
|
199
|
+
hintI18n: i18n(ageMin <= 0 ? "usage.hint.statusline_now" : "usage.hint.statusline_minutes", reading.instance, ageMin),
|
|
197
200
|
metrics,
|
|
198
201
|
};
|
|
199
202
|
}
|
|
@@ -237,12 +240,19 @@ export async function fetchClaudeUsage() {
|
|
|
237
240
|
const hint = process.env.ANTHROPIC_API_KEY
|
|
238
241
|
? "API-key login has no subscription usage. Use `claude /login` or set CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`)."
|
|
239
242
|
: "Log in with the Claude Code CLI (`claude`).";
|
|
240
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
status: "no-credentials",
|
|
245
|
+
hint,
|
|
246
|
+
hintI18n: process.env.ANTHROPIC_API_KEY
|
|
247
|
+
? i18n("usage.hint.claude_api_key")
|
|
248
|
+
: i18n("usage.hint.login_claude"),
|
|
249
|
+
metrics: [],
|
|
250
|
+
};
|
|
241
251
|
}
|
|
242
252
|
if ("error" in auth) {
|
|
243
253
|
if (statusline)
|
|
244
254
|
return statuslineUsage(statusline, auth.plan);
|
|
245
|
-
return { status: "error", plan: auth.plan, error: auth.error, metrics: [] };
|
|
255
|
+
return { status: "error", plan: auth.plan, error: auth.error, errorI18n: i18n("usage.error.claude_token_expired"), metrics: [] };
|
|
246
256
|
}
|
|
247
257
|
const plan = auth.plan;
|
|
248
258
|
/** Every API failure degrades to the statusline rather than to an error row. */
|
|
@@ -261,33 +271,39 @@ export async function fetchClaudeUsage() {
|
|
|
261
271
|
});
|
|
262
272
|
}
|
|
263
273
|
catch {
|
|
264
|
-
return orStatusline({ status: "error", plan, error: "Could not reach api.anthropic.com.", metrics: [] });
|
|
274
|
+
return orStatusline({ status: "error", plan, error: "Could not reach api.anthropic.com.", errorI18n: i18n("usage.error.unreachable", "api.anthropic.com"), metrics: [] });
|
|
265
275
|
}
|
|
266
276
|
if (res.status === 401 || res.status === 403) {
|
|
267
|
-
return orStatusline({ status: "error", plan, error: "Token rejected. Run `claude` once to refresh the login.", metrics: [] });
|
|
277
|
+
return orStatusline({ status: "error", plan, error: "Token rejected. Run `claude` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "claude"), metrics: [] });
|
|
268
278
|
}
|
|
269
279
|
if (res.status === 429) {
|
|
270
|
-
return orStatusline({ status: "error", plan, error: "Rate limited by Anthropic — try again later.", metrics: [] });
|
|
280
|
+
return orStatusline({ status: "error", plan, error: "Rate limited by Anthropic — try again later.", errorI18n: i18n("usage.error.rate_limited", "Anthropic"), metrics: [] });
|
|
271
281
|
}
|
|
272
282
|
if (!res.ok)
|
|
273
|
-
return orStatusline({ status: "error", plan, error: `Usage request failed (HTTP ${res.status}).`, metrics: [] });
|
|
283
|
+
return orStatusline({ status: "error", plan, error: `Usage request failed (HTTP ${res.status}).`, errorI18n: i18n("usage.error.http", res.status), metrics: [] });
|
|
274
284
|
let body;
|
|
275
285
|
try {
|
|
276
286
|
body = await res.json();
|
|
277
287
|
}
|
|
278
288
|
catch {
|
|
279
|
-
return orStatusline({ status: "error", plan, error: "Invalid response from usage endpoint.", metrics: [] });
|
|
289
|
+
return orStatusline({ status: "error", plan, error: "Invalid response from usage endpoint.", errorI18n: i18n("usage.error.invalid_response"), metrics: [] });
|
|
280
290
|
}
|
|
281
291
|
const metrics = [];
|
|
282
|
-
const window = (obj, label, windowMs) => {
|
|
292
|
+
const window = (obj, label, labelKey, windowMs) => {
|
|
283
293
|
const o = obj;
|
|
284
294
|
if (typeof o?.utilization !== "number")
|
|
285
295
|
return;
|
|
286
|
-
metrics.push({ label, type: "percent", used: o.utilization, resetsAt: claudeResetIso(o.resets_at), windowMs });
|
|
296
|
+
metrics.push({ label, labelI18n: i18n(labelKey), type: "percent", used: o.utilization, resetsAt: claudeResetIso(o.resets_at), windowMs });
|
|
287
297
|
};
|
|
288
|
-
window(body.five_hour, "Session", SESSION_MS);
|
|
289
|
-
window(body.seven_day, "Weekly", WEEK_MS);
|
|
290
|
-
|
|
298
|
+
window(body.five_hour, "Session", "usage.metric.session", SESSION_MS);
|
|
299
|
+
window(body.seven_day, "Weekly", "usage.metric.weekly", WEEK_MS);
|
|
300
|
+
const sonnet = body.seven_day_sonnet;
|
|
301
|
+
if (typeof sonnet?.utilization === "number") {
|
|
302
|
+
metrics.push({
|
|
303
|
+
label: "Sonnet (weekly)", labelI18n: i18n("usage.metric.named_weekly", "Sonnet"),
|
|
304
|
+
type: "percent", used: sonnet.utilization, resetsAt: claudeResetIso(sonnet.resets_at), windowMs: WEEK_MS,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
291
307
|
// Per-model weekly windows live in the `limits` array (kind: weekly_scoped).
|
|
292
308
|
// `limits` also carries which window is currently BINDING (`is_active`) and a
|
|
293
309
|
// severity the top-level windows do not expose. Probed live on a Team account:
|
|
@@ -305,31 +321,38 @@ export async function fetchClaudeUsage() {
|
|
|
305
321
|
continue;
|
|
306
322
|
metrics.push({
|
|
307
323
|
label: `${model} (weekly)`,
|
|
324
|
+
labelI18n: i18n("usage.metric.named_weekly", model),
|
|
308
325
|
type: "percent",
|
|
309
326
|
used: e.percent,
|
|
310
327
|
resetsAt: claudeResetIso(e.resets_at),
|
|
311
328
|
windowMs: WEEK_MS,
|
|
312
|
-
...(e.is_active === true ? { note: "binding" } : {}),
|
|
313
|
-
...(typeof e.severity === "string" && e.severity !== "normal"
|
|
329
|
+
...(e.is_active === true ? { note: "binding", noteI18n: i18n("usage.note.binding") } : {}),
|
|
330
|
+
...(typeof e.severity === "string" && e.severity !== "normal"
|
|
331
|
+
? { note: `binding · ${e.severity}`, noteI18n: i18n("usage.note.binding_severity", e.severity) }
|
|
332
|
+
: {}),
|
|
314
333
|
});
|
|
315
334
|
}
|
|
316
335
|
// Mark the two plain windows too, using the same source.
|
|
317
336
|
for (const m of metrics) {
|
|
318
337
|
if (m.note)
|
|
319
338
|
continue;
|
|
320
|
-
if (m.label === "Session" && activeKinds.has("session"))
|
|
339
|
+
if (m.label === "Session" && activeKinds.has("session")) {
|
|
321
340
|
m.note = "binding";
|
|
322
|
-
|
|
341
|
+
m.noteI18n = i18n("usage.note.binding");
|
|
342
|
+
}
|
|
343
|
+
if (m.label === "Weekly" && activeKinds.has("weekly_all")) {
|
|
323
344
|
m.note = "binding";
|
|
345
|
+
m.noteI18n = i18n("usage.note.binding");
|
|
346
|
+
}
|
|
324
347
|
}
|
|
325
348
|
const extra = body.extra_usage;
|
|
326
349
|
if (extra?.is_enabled === true && typeof extra.used_credits === "number") {
|
|
327
350
|
const used = extra.used_credits / 100;
|
|
328
351
|
const limit = typeof extra.monthly_limit === "number" && extra.monthly_limit > 0 ? extra.monthly_limit / 100 : null;
|
|
329
352
|
if (limit)
|
|
330
|
-
metrics.push({ label: "Extra usage", type: "dollars", used, limit });
|
|
353
|
+
metrics.push({ label: "Extra usage", labelI18n: i18n("usage.metric.extra_usage"), type: "dollars", used, limit });
|
|
331
354
|
else if (used > 0)
|
|
332
|
-
metrics.push({ label: "Extra usage", type: "dollars", used });
|
|
355
|
+
metrics.push({ label: "Extra usage", labelI18n: i18n("usage.metric.extra_usage"), type: "dollars", used });
|
|
333
356
|
}
|
|
334
357
|
// A 200 with nothing in it is not better than the file on disk.
|
|
335
358
|
if (metrics.length === 0 && statusline)
|
|
@@ -398,8 +421,10 @@ let agyTokenCache = null;
|
|
|
398
421
|
*/
|
|
399
422
|
export function agyPoolMetrics(buckets) {
|
|
400
423
|
const POOLED = {
|
|
401
|
-
"gemini-5h": "Gemini (session)", "
|
|
402
|
-
"
|
|
424
|
+
"gemini-5h": { text: "Gemini (session)", i18n: i18n("usage.metric.named_session", "Gemini") },
|
|
425
|
+
"gemini-weekly": { text: "Gemini (weekly)", i18n: i18n("usage.metric.named_weekly", "Gemini") },
|
|
426
|
+
"3p-5h": { text: "Claude & others (session)", i18n: i18n("usage.metric.agy_claude_others_session") },
|
|
427
|
+
"3p-weekly": { text: "Claude & others (weekly)", i18n: i18n("usage.metric.agy_claude_others_weekly") },
|
|
403
428
|
};
|
|
404
429
|
const direct = [];
|
|
405
430
|
const pools = new Map();
|
|
@@ -409,7 +434,7 @@ export function agyPoolMetrics(buckets) {
|
|
|
409
434
|
const reset = b.resetTime ?? null;
|
|
410
435
|
const pooledLabel = POOLED[b.bucketId];
|
|
411
436
|
if (pooledLabel) {
|
|
412
|
-
direct.push({ label: pooledLabel, type: "percent", used: (1 - b.remainingFraction) * 100, resetsAt: reset });
|
|
437
|
+
direct.push({ label: pooledLabel.text, labelI18n: pooledLabel.i18n, type: "percent", used: (1 - b.remainingFraction) * 100, resetsAt: reset });
|
|
413
438
|
continue;
|
|
414
439
|
}
|
|
415
440
|
const pool = b.bucketId.startsWith("gemini") ? "Gemini" : "Claude & others";
|
|
@@ -430,6 +455,9 @@ export function agyPoolMetrics(buckets) {
|
|
|
430
455
|
return direct;
|
|
431
456
|
return [...pools.entries()].map(([label, p]) => ({
|
|
432
457
|
label,
|
|
458
|
+
labelI18n: label === "Claude & others"
|
|
459
|
+
? i18n("usage.metric.agy_claude_others")
|
|
460
|
+
: i18n("usage.metric.named", label),
|
|
433
461
|
type: "percent",
|
|
434
462
|
used: (1 - p.fraction) * 100,
|
|
435
463
|
resetsAt: p.resetsAt,
|
|
@@ -437,6 +465,7 @@ export function agyPoolMetrics(buckets) {
|
|
|
437
465
|
// it a flat 0% right after a weekly rollover is indistinguishable from a
|
|
438
466
|
// broken reading — which is exactly how it was reported.
|
|
439
467
|
note: `busiest of ${p.models} model${p.models === 1 ? "" : "s"}`,
|
|
468
|
+
noteI18n: i18n(p.models === 1 ? "usage.note.busiest_model" : "usage.note.busiest_models", p.models),
|
|
440
469
|
}));
|
|
441
470
|
}
|
|
442
471
|
/**
|
|
@@ -482,6 +511,9 @@ async function agyAccessToken() {
|
|
|
482
511
|
error: client
|
|
483
512
|
? "Access token expired and no refresh token is stored. Run `agy` once."
|
|
484
513
|
: "Access token expired. Run `agy` once to refresh it (or set AGY_OAUTH_CLIENT_ID/SECRET to refresh automatically).",
|
|
514
|
+
errorI18n: client
|
|
515
|
+
? i18n("usage.error.agy_no_refresh")
|
|
516
|
+
: i18n("usage.error.agy_token_expired"),
|
|
485
517
|
};
|
|
486
518
|
}
|
|
487
519
|
try {
|
|
@@ -497,15 +529,15 @@ async function agyAccessToken() {
|
|
|
497
529
|
signal: AbortSignal.timeout(15_000),
|
|
498
530
|
});
|
|
499
531
|
if (!res.ok)
|
|
500
|
-
return { error: "Google sign-in expired. Run `agy` once to log in again." };
|
|
532
|
+
return { error: "Google sign-in expired. Run `agy` once to log in again.", errorI18n: i18n("usage.error.agy_login_expired") };
|
|
501
533
|
const body = await res.json();
|
|
502
534
|
if (!body.access_token)
|
|
503
|
-
return { error: "Google token refresh returned no token." };
|
|
535
|
+
return { error: "Google token refresh returned no token.", errorI18n: i18n("usage.error.agy_refresh_empty") };
|
|
504
536
|
agyTokenCache = { token: body.access_token, until: Date.now() + Math.max(60, (body.expires_in ?? 3600) - 120) * 1000 };
|
|
505
537
|
return { token: body.access_token };
|
|
506
538
|
}
|
|
507
539
|
catch {
|
|
508
|
-
return { error: "Could not reach oauth2.googleapis.com." };
|
|
540
|
+
return { error: "Could not reach oauth2.googleapis.com.", errorI18n: i18n("usage.error.unreachable", "oauth2.googleapis.com") };
|
|
509
541
|
}
|
|
510
542
|
}
|
|
511
543
|
async function fetchAntigravityUsage() {
|
|
@@ -523,15 +555,16 @@ async function fetchAntigravityUsage() {
|
|
|
523
555
|
status: "error",
|
|
524
556
|
error: `Individual cap reached — CLI is rate limited (resets in ~${resetText}). `
|
|
525
557
|
+ "The per-model pool quotas are a separate counter and may still read 0% used.",
|
|
558
|
+
errorI18n: i18n("usage.error.agy_cap", resetText),
|
|
526
559
|
metrics: [],
|
|
527
560
|
};
|
|
528
561
|
}
|
|
529
562
|
const auth = await agyAccessToken();
|
|
530
563
|
if (auth === null) {
|
|
531
|
-
return { status: "no-credentials", hint: "Log in with the Antigravity CLI (`agy`).", metrics: [] };
|
|
564
|
+
return { status: "no-credentials", hint: "Log in with the Antigravity CLI (`agy`).", hintI18n: i18n("usage.hint.login_agy"), metrics: [] };
|
|
532
565
|
}
|
|
533
566
|
if ("error" in auth)
|
|
534
|
-
return { status: "error", error: auth.error, metrics: [] };
|
|
567
|
+
return { status: "error", error: auth.error, errorI18n: auth.errorI18n, metrics: [] };
|
|
535
568
|
for (const base of AGY_CLOUDCODE_URLS) {
|
|
536
569
|
let res;
|
|
537
570
|
try {
|
|
@@ -550,7 +583,7 @@ async function fetchAntigravityUsage() {
|
|
|
550
583
|
continue;
|
|
551
584
|
}
|
|
552
585
|
if (res.status === 401 || res.status === 403) {
|
|
553
|
-
return { status: "error", error: "Token rejected. Run `agy` once to refresh the login.", metrics: [] };
|
|
586
|
+
return { status: "error", error: "Token rejected. Run `agy` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "agy"), metrics: [] };
|
|
554
587
|
}
|
|
555
588
|
if (!res.ok)
|
|
556
589
|
continue; // try the other base
|
|
@@ -565,9 +598,9 @@ async function fetchAntigravityUsage() {
|
|
|
565
598
|
const metrics = agyPoolMetrics(buckets);
|
|
566
599
|
return metrics.length
|
|
567
600
|
? { status: "ok", plan: null, metrics }
|
|
568
|
-
: { status: "ok", plan: null, hint: "No quota information reported for this account.", metrics: [] };
|
|
601
|
+
: { status: "ok", plan: null, hint: "No quota information reported for this account.", hintI18n: i18n("usage.hint.no_quota"), metrics: [] };
|
|
569
602
|
}
|
|
570
|
-
return { status: "error", error: "Could not reach the Cloud Code quota endpoints.", metrics: [] };
|
|
603
|
+
return { status: "error", error: "Could not reach the Cloud Code quota endpoints.", errorI18n: i18n("usage.error.unreachable", "Cloud Code quota endpoints"), metrics: [] };
|
|
571
604
|
}
|
|
572
605
|
// ── Codex ────────────────────────────────────────────────────────────────────
|
|
573
606
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
@@ -606,7 +639,7 @@ function codexWindows(rateLimit, labels, nowMs) {
|
|
|
606
639
|
label = labels.other(Math.round(windowMs / 864e5));
|
|
607
640
|
else
|
|
608
641
|
label = fallback === "session" ? labels.session : labels.weekly;
|
|
609
|
-
metrics.push({ label, type: "percent", used: win.used_percent, resetsAt: codexResetIso(win, nowMs), windowMs });
|
|
642
|
+
metrics.push({ label: label.text, labelI18n: label.i18n, type: "percent", used: win.used_percent, resetsAt: codexResetIso(win, nowMs), windowMs });
|
|
610
643
|
}
|
|
611
644
|
return metrics;
|
|
612
645
|
}
|
|
@@ -626,15 +659,15 @@ export async function fetchCodexUsage() {
|
|
|
626
659
|
auth = JSON.parse(await readFile(join(home, "auth.json"), "utf8"));
|
|
627
660
|
}
|
|
628
661
|
catch {
|
|
629
|
-
return { status: "no-credentials", hint: "Log in with the Codex CLI (`codex`).", metrics: [] };
|
|
662
|
+
return { status: "no-credentials", hint: "Log in with the Codex CLI (`codex`).", hintI18n: i18n("usage.hint.login_codex"), metrics: [] };
|
|
630
663
|
}
|
|
631
664
|
const accessToken = auth?.tokens?.access_token;
|
|
632
665
|
if (!accessToken) {
|
|
633
|
-
return { status: "no-credentials", hint: "auth.json has no OAuth login (API-key-only auth cannot read usage).", metrics: [] };
|
|
666
|
+
return { status: "no-credentials", hint: "auth.json has no OAuth login (API-key-only auth cannot read usage).", hintI18n: i18n("usage.hint.codex_no_oauth"), metrics: [] };
|
|
634
667
|
}
|
|
635
668
|
const exp = jwtExpiryMs(accessToken);
|
|
636
669
|
if (exp && exp < Date.now()) {
|
|
637
|
-
return { status: "error", error: "Access token expired. Run `codex` once to refresh it.", metrics: [] };
|
|
670
|
+
return { status: "error", error: "Access token expired. Run `codex` once to refresh it.", errorI18n: i18n("usage.error.codex_token_expired"), metrics: [] };
|
|
638
671
|
}
|
|
639
672
|
const headers = {
|
|
640
673
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -648,23 +681,26 @@ export async function fetchCodexUsage() {
|
|
|
648
681
|
res = await fetch(CODEX_USAGE_URL, { headers, signal: AbortSignal.timeout(10_000) });
|
|
649
682
|
}
|
|
650
683
|
catch {
|
|
651
|
-
return { status: "error", error: "Could not reach chatgpt.com.", metrics: [] };
|
|
684
|
+
return { status: "error", error: "Could not reach chatgpt.com.", errorI18n: i18n("usage.error.unreachable", "chatgpt.com"), metrics: [] };
|
|
652
685
|
}
|
|
653
686
|
if (res.status === 401 || res.status === 403) {
|
|
654
|
-
return { status: "error", error: "Token rejected. Run `codex` once to refresh the login.", metrics: [] };
|
|
687
|
+
return { status: "error", error: "Token rejected. Run `codex` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "codex"), metrics: [] };
|
|
655
688
|
}
|
|
656
689
|
if (!res.ok)
|
|
657
|
-
return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, metrics: [] };
|
|
690
|
+
return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, errorI18n: i18n("usage.error.http", res.status), metrics: [] };
|
|
658
691
|
let body;
|
|
659
692
|
try {
|
|
660
693
|
body = await res.json();
|
|
661
694
|
}
|
|
662
695
|
catch {
|
|
663
|
-
return { status: "error", error: "Invalid response from usage endpoint.", metrics: [] };
|
|
696
|
+
return { status: "error", error: "Invalid response from usage endpoint.", errorI18n: i18n("usage.error.invalid_response"), metrics: [] };
|
|
664
697
|
}
|
|
665
698
|
const nowMs = Date.now();
|
|
666
699
|
const metrics = codexWindows(body.rate_limit, {
|
|
667
|
-
session:
|
|
700
|
+
session: { text: "Session", i18n: i18n("usage.metric.session") },
|
|
701
|
+
weekly: { text: "Weekly", i18n: i18n("usage.metric.weekly") },
|
|
702
|
+
monthly: { text: "Monthly", i18n: i18n("usage.metric.monthly") },
|
|
703
|
+
other: d => ({ text: `${d}-day window`, i18n: i18n("usage.metric.days_window", d) }),
|
|
668
704
|
}, nowMs);
|
|
669
705
|
// Model-specific limits (e.g. Spark) ride in additional_rate_limits, same window shape.
|
|
670
706
|
for (const entry of Array.isArray(body.additional_rate_limits) ? body.additional_rate_limits : []) {
|
|
@@ -673,12 +709,19 @@ export async function fetchCodexUsage() {
|
|
|
673
709
|
continue;
|
|
674
710
|
const rawName = e.limit_name || e.metered_feature || "Model limit";
|
|
675
711
|
metrics.push(...codexWindows(e.rate_limit, {
|
|
676
|
-
session:
|
|
712
|
+
session: { text: rawName, i18n: i18n("usage.metric.named", rawName) },
|
|
713
|
+
weekly: { text: `${rawName} (weekly)`, i18n: i18n("usage.metric.named_weekly", rawName) },
|
|
714
|
+
monthly: { text: `${rawName} (monthly)`, i18n: i18n("usage.metric.named_monthly", rawName) },
|
|
715
|
+
other: d => ({ text: `${rawName} (${d}d)`, i18n: i18n("usage.metric.named_days", rawName, d) }),
|
|
677
716
|
}, nowMs));
|
|
678
717
|
}
|
|
679
718
|
const resets = body.rate_limit_reset_credits;
|
|
680
719
|
if (typeof resets?.available_count === "number" && resets.available_count >= 0) {
|
|
681
|
-
metrics.push({
|
|
720
|
+
metrics.push({
|
|
721
|
+
label: "Rate limit resets", labelI18n: i18n("usage.metric.rate_limit_resets"),
|
|
722
|
+
type: "count", value: Math.floor(resets.available_count),
|
|
723
|
+
unit: "available", unitI18n: i18n("usage.unit.available"),
|
|
724
|
+
});
|
|
682
725
|
}
|
|
683
726
|
const credits = body.credits;
|
|
684
727
|
let remaining = null;
|
|
@@ -688,7 +731,10 @@ export async function fetchCodexUsage() {
|
|
|
688
731
|
remaining = 0;
|
|
689
732
|
if (remaining !== null) {
|
|
690
733
|
const count = Math.max(0, Math.floor(remaining));
|
|
691
|
-
metrics.push({
|
|
734
|
+
metrics.push({
|
|
735
|
+
label: "Credits", labelI18n: i18n("usage.metric.credits"), type: "count", value: count,
|
|
736
|
+
unit: "credits", unitI18n: i18n("usage.unit.credits"), note: `≈ $${(count * CREDIT_USD_RATE).toFixed(2)}`,
|
|
737
|
+
});
|
|
692
738
|
}
|
|
693
739
|
return { status: "ok", plan: codexPlan(body.plan_type), metrics };
|
|
694
740
|
}
|
|
@@ -718,11 +764,11 @@ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
|
|
|
718
764
|
await access(file, constants.W_OK);
|
|
719
765
|
}
|
|
720
766
|
catch {
|
|
721
|
-
return { error: "Token expired and auth.json is read-only. Run `grok` once to refresh it." };
|
|
767
|
+
return { error: "Token expired and auth.json is read-only. Run `grok` once to refresh it.", errorI18n: i18n("usage.error.grok_auth_readonly") };
|
|
722
768
|
}
|
|
723
769
|
const refreshToken = (entry.refresh_token || entry.refresh || "").trim();
|
|
724
770
|
if (!refreshToken)
|
|
725
|
-
return { error: "Token expired and no refresh token found. Run `grok login`." };
|
|
771
|
+
return { error: "Token expired and no refresh token found. Run `grok login`.", errorI18n: i18n("usage.error.grok_no_refresh") };
|
|
726
772
|
const clientId = entry.oidc_client_id?.trim()
|
|
727
773
|
|| entryKey.split("::").pop()?.trim()
|
|
728
774
|
|| GROK_DEFAULT_CLIENT_ID;
|
|
@@ -736,19 +782,19 @@ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
|
|
|
736
782
|
});
|
|
737
783
|
}
|
|
738
784
|
catch {
|
|
739
|
-
return { error: "Could not reach auth.x.ai to refresh the token." };
|
|
785
|
+
return { error: "Could not reach auth.x.ai to refresh the token.", errorI18n: i18n("usage.error.grok_refresh_unreachable") };
|
|
740
786
|
}
|
|
741
787
|
if (!res.ok)
|
|
742
|
-
return { error: "Grok session expired. Run `grok login` again." };
|
|
788
|
+
return { error: "Grok session expired. Run `grok login` again.", errorI18n: i18n("usage.error.grok_expired") };
|
|
743
789
|
let body;
|
|
744
790
|
try {
|
|
745
791
|
body = await res.json();
|
|
746
792
|
}
|
|
747
793
|
catch {
|
|
748
|
-
return { error: "Invalid refresh response from auth.x.ai." };
|
|
794
|
+
return { error: "Invalid refresh response from auth.x.ai.", errorI18n: i18n("usage.error.grok_invalid_refresh") };
|
|
749
795
|
}
|
|
750
796
|
if (!body.access_token)
|
|
751
|
-
return { error: "Grok session expired. Run `grok login` again." };
|
|
797
|
+
return { error: "Grok session expired. Run `grok login` again.", errorI18n: i18n("usage.error.grok_expired") };
|
|
752
798
|
// Update only this entry's fields, preserving everything else in the file
|
|
753
799
|
// (other accounts' entries included).
|
|
754
800
|
const updated = { ...(auth[entryKey] ?? {}) };
|
|
@@ -763,7 +809,7 @@ async function grokRefreshAndPersist(file, auth, entryKey, entry) {
|
|
|
763
809
|
await writeFile(file, JSON.stringify({ ...auth, [entryKey]: updated }, null, 2));
|
|
764
810
|
}
|
|
765
811
|
catch {
|
|
766
|
-
return { error: "Refreshed but could not write auth.json — refusing to continue without persisting. Run `grok` once instead." };
|
|
812
|
+
return { error: "Refreshed but could not write auth.json — refusing to continue without persisting. Run `grok` once instead.", errorI18n: i18n("usage.error.grok_persist") };
|
|
767
813
|
}
|
|
768
814
|
return { token: body.access_token };
|
|
769
815
|
}
|
|
@@ -783,18 +829,18 @@ async function fetchGrokUsage() {
|
|
|
783
829
|
auth = JSON.parse(await readFile(file, "utf8"));
|
|
784
830
|
}
|
|
785
831
|
catch {
|
|
786
|
-
return { status: "no-credentials", hint: "Log in with the Grok CLI (`grok login`).", metrics: [] };
|
|
832
|
+
return { status: "no-credentials", hint: "Log in with the Grok CLI (`grok login`).", hintI18n: i18n("usage.hint.login_grok"), metrics: [] };
|
|
787
833
|
}
|
|
788
834
|
const entries = Object.entries(auth).filter(([, e]) => typeof e?.key === "string" && e.key.trim());
|
|
789
835
|
if (!entries.length)
|
|
790
|
-
return { status: "no-credentials", hint: "auth.json has no usable login. Run `grok login`.", metrics: [] };
|
|
836
|
+
return { status: "no-credentials", hint: "auth.json has no usable login. Run `grok login`.", hintI18n: i18n("usage.hint.grok_no_login"), metrics: [] };
|
|
791
837
|
const [entryKey, entry] = entries[0];
|
|
792
838
|
let token = entry.key.trim();
|
|
793
839
|
const expiry = grokEntryExpiryMs(entry, token);
|
|
794
840
|
if (expiry && expiry - Date.now() <= GROK_REFRESH_BUFFER_MS) {
|
|
795
841
|
const refreshed = await grokRefreshAndPersist(file, auth, entryKey, entry);
|
|
796
842
|
if (refreshed.error)
|
|
797
|
-
return { status: "error", error: refreshed.error, metrics: [] };
|
|
843
|
+
return { status: "error", error: refreshed.error, errorI18n: refreshed.errorI18n, metrics: [] };
|
|
798
844
|
token = refreshed.token;
|
|
799
845
|
}
|
|
800
846
|
const fetchBoth = async (tok) => {
|
|
@@ -810,30 +856,30 @@ async function fetchGrokUsage() {
|
|
|
810
856
|
if (credits.status === 401 || credits.status === 403) {
|
|
811
857
|
const refreshed = await grokRefreshAndPersist(file, auth, entryKey, entry);
|
|
812
858
|
if (refreshed.error)
|
|
813
|
-
return { status: "error", error: refreshed.error, metrics: [] };
|
|
859
|
+
return { status: "error", error: refreshed.error, errorI18n: refreshed.errorI18n, metrics: [] };
|
|
814
860
|
token = refreshed.token;
|
|
815
861
|
({ credits, settings } = await fetchBoth(token));
|
|
816
862
|
}
|
|
817
863
|
}
|
|
818
864
|
catch {
|
|
819
|
-
return { status: "error", error: "Could not reach cli-chat-proxy.grok.com.", metrics: [] };
|
|
865
|
+
return { status: "error", error: "Could not reach cli-chat-proxy.grok.com.", errorI18n: i18n("usage.error.unreachable", "cli-chat-proxy.grok.com"), metrics: [] };
|
|
820
866
|
}
|
|
821
867
|
if (credits.status === 401 || credits.status === 403) {
|
|
822
|
-
return { status: "error", error: "Grok session expired. Run `grok login` again.", metrics: [] };
|
|
868
|
+
return { status: "error", error: "Grok session expired. Run `grok login` again.", errorI18n: i18n("usage.error.grok_expired"), metrics: [] };
|
|
823
869
|
}
|
|
824
870
|
if (!credits.ok)
|
|
825
|
-
return { status: "error", error: `Billing request failed (HTTP ${credits.status}).`, metrics: [] };
|
|
871
|
+
return { status: "error", error: `Billing request failed (HTTP ${credits.status}).`, errorI18n: i18n("usage.error.billing_http", credits.status), metrics: [] };
|
|
826
872
|
let body;
|
|
827
873
|
try {
|
|
828
874
|
body = await credits.json();
|
|
829
875
|
}
|
|
830
876
|
catch {
|
|
831
|
-
return { status: "error", error: "Invalid response from billing endpoint.", metrics: [] };
|
|
877
|
+
return { status: "error", error: "Invalid response from billing endpoint.", errorI18n: i18n("usage.error.invalid_billing_response"), metrics: [] };
|
|
832
878
|
}
|
|
833
879
|
const config = body?.config;
|
|
834
880
|
const period = config?.currentPeriod;
|
|
835
881
|
if (!config || !period?.type)
|
|
836
|
-
return { status: "error", error: "Grok billing response changed.", metrics: [] };
|
|
882
|
+
return { status: "error", error: "Grok billing response changed.", errorI18n: i18n("usage.error.grok_response_changed"), metrics: [] };
|
|
837
883
|
const metrics = [];
|
|
838
884
|
if (period.type === GROK_WEEKLY_PERIOD) {
|
|
839
885
|
// proto-JSON drops zero-valued fields: an absent creditUsagePercent means 0%.
|
|
@@ -842,6 +888,7 @@ async function fetchGrokUsage() {
|
|
|
842
888
|
const end = new Date(period.end ?? "").getTime();
|
|
843
889
|
metrics.push({
|
|
844
890
|
label: "Weekly limit",
|
|
891
|
+
labelI18n: i18n("usage.metric.weekly_limit"),
|
|
845
892
|
type: "percent",
|
|
846
893
|
used: Math.min(100, Math.max(0, pct)),
|
|
847
894
|
resetsAt: Number.isNaN(end) ? null : new Date(end).toISOString(),
|
|
@@ -849,7 +896,11 @@ async function fetchGrokUsage() {
|
|
|
849
896
|
});
|
|
850
897
|
}
|
|
851
898
|
const cap = typeof config.onDemandCap?.val === "number" ? config.onDemandCap.val : 0;
|
|
852
|
-
metrics.push({
|
|
899
|
+
metrics.push({
|
|
900
|
+
label: "Pay as you go", labelI18n: i18n("usage.metric.pay_as_you_go"), type: "text",
|
|
901
|
+
value: cap > 0 ? `${cap} cap` : "Disabled",
|
|
902
|
+
valueI18n: cap > 0 ? i18n("usage.value.cap", cap) : i18n("usage.value.disabled"),
|
|
903
|
+
});
|
|
853
904
|
let plan = null;
|
|
854
905
|
if (settings?.ok) {
|
|
855
906
|
try {
|
|
@@ -942,12 +993,12 @@ export async function fetchKiroUsage() {
|
|
|
942
993
|
const { token, kind, missing, notInstalled } = readKiroToken();
|
|
943
994
|
if (notInstalled) {
|
|
944
995
|
// The one case that should disappear from the panel: kiro-cli isn't here.
|
|
945
|
-
return { status: "no-credentials", hint: "Log in with the Kiro CLI (`kiro-cli`).", metrics: [] };
|
|
996
|
+
return { status: "no-credentials", hint: "Log in with the Kiro CLI (`kiro-cli`).", hintI18n: i18n("usage.hint.login_kiro"), metrics: [] };
|
|
946
997
|
}
|
|
947
998
|
if (missing || !token) {
|
|
948
999
|
// Installed but no readable login. Previously indistinguishable from
|
|
949
1000
|
// not-installed, so the whole Kiro row vanished instead of saying this.
|
|
950
|
-
return { status: "ok", plan: "Kiro", hint: "Signed out — run `kiro-cli` to log in.", metrics: [] };
|
|
1001
|
+
return { status: "ok", plan: "Kiro", hint: "Signed out — run `kiro-cli` to log in.", hintI18n: i18n("usage.hint.kiro_signed_out"), metrics: [] };
|
|
951
1002
|
}
|
|
952
1003
|
const expiresAt = token.expires_at ? new Date(token.expires_at).getTime() : null;
|
|
953
1004
|
if (expiresAt && expiresAt < Date.now()) {
|
|
@@ -955,7 +1006,7 @@ export async function fetchKiroUsage() {
|
|
|
955
1006
|
// panel showing a red failure for a routine token rollover is just noise.
|
|
956
1007
|
// Still `ok` rather than no-credentials, so the row stays on screen with its
|
|
957
1008
|
// explanation instead of the provider silently disappearing.
|
|
958
|
-
return { status: "ok", plan: "Kiro", hint: "Token refreshing — try again in a moment.", metrics: [] };
|
|
1009
|
+
return { status: "ok", plan: "Kiro", hint: "Token refreshing — try again in a moment.", hintI18n: i18n("usage.hint.token_refreshing"), metrics: [] };
|
|
959
1010
|
}
|
|
960
1011
|
if (kind === "q-developer-pro") {
|
|
961
1012
|
// Seat-licensed subscription: GetUsageLimits describes Builder-ID credit
|
|
@@ -964,6 +1015,7 @@ export async function fetchKiroUsage() {
|
|
|
964
1015
|
status: "ok",
|
|
965
1016
|
plan: "Q Developer Pro",
|
|
966
1017
|
hint: "Subscription — no credit usage to report.",
|
|
1018
|
+
hintI18n: i18n("usage.hint.subscription_no_credit"),
|
|
967
1019
|
metrics: [],
|
|
968
1020
|
};
|
|
969
1021
|
}
|
|
@@ -984,35 +1036,40 @@ export async function fetchKiroUsage() {
|
|
|
984
1036
|
});
|
|
985
1037
|
}
|
|
986
1038
|
catch {
|
|
987
|
-
return { status: "error", error: `Could not reach codewhisperer.${region}.amazonaws.com.`, metrics: [] };
|
|
1039
|
+
return { status: "error", error: `Could not reach codewhisperer.${region}.amazonaws.com.`, errorI18n: i18n("usage.error.unreachable", `codewhisperer.${region}.amazonaws.com`), metrics: [] };
|
|
988
1040
|
}
|
|
989
1041
|
if (res.status === 401 || res.status === 403) {
|
|
990
|
-
return { status: "error", error: "Token rejected. Use `kiro-cli` once to refresh the login.", metrics: [] };
|
|
1042
|
+
return { status: "error", error: "Token rejected. Use `kiro-cli` once to refresh the login.", errorI18n: i18n("usage.error.token_rejected", "kiro-cli"), metrics: [] };
|
|
991
1043
|
}
|
|
992
1044
|
if (!res.ok)
|
|
993
|
-
return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, metrics: [] };
|
|
1045
|
+
return { status: "error", error: `Usage request failed (HTTP ${res.status}).`, errorI18n: i18n("usage.error.http", res.status), metrics: [] };
|
|
994
1046
|
let body;
|
|
995
1047
|
try {
|
|
996
1048
|
body = await res.json();
|
|
997
1049
|
}
|
|
998
1050
|
catch {
|
|
999
|
-
return { status: "error", error: "Invalid response from GetUsageLimits.", metrics: [] };
|
|
1051
|
+
return { status: "error", error: "Invalid response from GetUsageLimits.", errorI18n: i18n("usage.error.invalid_kiro_response"), metrics: [] };
|
|
1000
1052
|
}
|
|
1001
1053
|
const plan = typeof body.subscriptionInfo?.subscriptionTitle === "string"
|
|
1002
1054
|
? kiroTitleCase(body.subscriptionInfo.subscriptionTitle)
|
|
1003
1055
|
: null;
|
|
1004
1056
|
const metrics = [];
|
|
1005
1057
|
for (const ub of Array.isArray(body.usageBreakdownList) ? body.usageBreakdownList : []) {
|
|
1006
|
-
const
|
|
1058
|
+
const displayName = ub.displayNamePlural || ub.displayName;
|
|
1059
|
+
const unit = (displayName || "credits").toLowerCase();
|
|
1007
1060
|
const used = ub.currentUsageWithPrecision ?? ub.currentUsage ?? null;
|
|
1008
1061
|
const limit = ub.usageLimitWithPrecision ?? ub.usageLimit ?? null;
|
|
1009
1062
|
if (used !== null && limit !== null && limit > 0) {
|
|
1010
1063
|
metrics.push({
|
|
1011
|
-
label: `${
|
|
1064
|
+
label: `${displayName || "Usage"} (monthly)`,
|
|
1065
|
+
labelI18n: displayName
|
|
1066
|
+
? i18n("usage.metric.named_monthly", displayName)
|
|
1067
|
+
: i18n("usage.metric.usage_monthly"),
|
|
1012
1068
|
type: "percent",
|
|
1013
1069
|
used: Math.min(100, (used / limit) * 100),
|
|
1014
1070
|
resetsAt: kiroEpochIso(ub.nextDateReset ?? body.nextDateReset),
|
|
1015
1071
|
note: `${+used.toFixed(1)} / ${+limit.toFixed(0)} ${unit}`,
|
|
1072
|
+
noteI18n: i18n("usage.note.used_limit_unit", +used.toFixed(1), +limit.toFixed(0), unit),
|
|
1016
1073
|
});
|
|
1017
1074
|
}
|
|
1018
1075
|
// Bonus / gift credits (redeemed promo codes), aggregated across active codes.
|
|
@@ -1024,27 +1081,29 @@ export async function fetchKiroUsage() {
|
|
|
1024
1081
|
if (bLimit > 0) {
|
|
1025
1082
|
metrics.push({
|
|
1026
1083
|
label: "Bonus credits",
|
|
1084
|
+
labelI18n: i18n("usage.metric.bonus_credits"),
|
|
1027
1085
|
type: "percent",
|
|
1028
1086
|
used: Math.min(100, (bUsed / bLimit) * 100),
|
|
1029
1087
|
resetsAt: expiries.length ? kiroEpochIso(Math.min(...expiries)) : null,
|
|
1030
1088
|
note: `${+bUsed.toFixed(1)} / ${+bLimit.toFixed(0)} ${unit} · ${bonuses.length} codes`,
|
|
1089
|
+
noteI18n: i18n("usage.note.bonus_codes", +bUsed.toFixed(1), +bLimit.toFixed(0), unit, bonuses.length),
|
|
1031
1090
|
});
|
|
1032
1091
|
}
|
|
1033
1092
|
}
|
|
1034
1093
|
const overages = ub.currentOveragesWithPrecision ?? ub.currentOverages ?? 0;
|
|
1035
1094
|
if (overages > 0 && typeof ub.overageCharges === "number") {
|
|
1036
|
-
metrics.push({ label: "Overage charges", type: "dollars", used: ub.overageCharges });
|
|
1095
|
+
metrics.push({ label: "Overage charges", labelI18n: i18n("usage.metric.overage_charges"), type: "dollars", used: ub.overageCharges });
|
|
1037
1096
|
}
|
|
1038
1097
|
}
|
|
1039
1098
|
const overageStatus = body.overageConfiguration?.overageStatus;
|
|
1040
1099
|
if (typeof overageStatus === "string") {
|
|
1041
|
-
metrics.push({ label: "Pay-per-use", type: "text", value: kiroTitleCase(overageStatus.replace(/_/g, " ")) });
|
|
1100
|
+
metrics.push({ label: "Pay-per-use", labelI18n: i18n("usage.metric.pay_per_use"), type: "text", value: kiroTitleCase(overageStatus.replace(/_/g, " ")) });
|
|
1042
1101
|
}
|
|
1043
1102
|
if (metrics.length === 0) {
|
|
1044
1103
|
// Amazon Q logins whose account type reports no credit buckets (#398's
|
|
1045
1104
|
// sibling case): an OK answer with nothing in it must say so, not render an
|
|
1046
1105
|
// empty row that reads like a failure.
|
|
1047
|
-
return { status: "ok", plan: plan ?? "Amazon Q", hint: "No quota information available for this account type.", metrics: [] };
|
|
1106
|
+
return { status: "ok", plan: plan ?? "Amazon Q", hint: "No quota information available for this account type.", hintI18n: i18n("usage.hint.no_quota_account"), metrics: [] };
|
|
1048
1107
|
}
|
|
1049
1108
|
return { status: "ok", plan, metrics };
|
|
1050
1109
|
}
|