@1930dev/opencode-usage 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/dist/cli.js +219 -168
- package/dist/tui.js +226 -156
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@ Every connected provider in one table, with each one's remaining budget as a
|
|
|
7
7
|
comparable percentage — whether the provider bills in tokens, requests, credits or
|
|
8
8
|
neurons.
|
|
9
9
|
|
|
10
|
-
<!-- Absolute URL so the image also renders on npm, which resolves nothing relative.
|
|
10
|
+
<!-- Absolute URL so the image also renders on npm, which resolves nothing relative.
|
|
11
|
+
It also means updating the file on main updates both, with no republish. -->
|
|
11
12
|

|
|
12
13
|
|
|
13
14
|
[](https://www.npmjs.com/package/@1930dev/opencode-usage)
|
|
@@ -118,22 +119,32 @@ Restart opencode after a rebuild: the bundle is read once at start.
|
|
|
118
119
|
Normalized percentage of budget consumed per provider, from these sources (in priority):
|
|
119
120
|
|
|
120
121
|
1. **Live quota** — provider-reported usage:
|
|
122
|
+
- `amd`: daily spend ceiling (USD/day)
|
|
121
123
|
- `github-copilot`: premium requests entitlement (7000/mo)
|
|
122
124
|
- `opencode-go` (Zen): rolling 5h / weekly / monthly % (binding window)
|
|
123
125
|
- `openrouter`: credits used / total credits
|
|
124
126
|
- `zai`: coding plan quota
|
|
125
127
|
|
|
126
|
-
2. **Documented limits** — published quotas, used when the provider reports none
|
|
128
|
+
2. **Documented limits** — published quotas, used when the provider reports none.
|
|
129
|
+
Each one is read against the period it resets on: a daily limit against today,
|
|
130
|
+
a monthly one against the calendar month. The window you asked for (`--since`)
|
|
131
|
+
sizes the table and never the budget.
|
|
127
132
|
- `cerebras`: 1M tokens/day (free tier)
|
|
128
|
-
- `cloudflare-workers-ai`: 100k neurons/day (free tier)
|
|
129
133
|
- `digitalocean`: 5M tokens/day (paid)
|
|
130
134
|
- `google`: 1500 requests/day (free tier)
|
|
131
135
|
- `groq`: 200k tokens/day (free tier)
|
|
136
|
+
|
|
137
|
+
These are documented but not measurable, and show `—`. opencode's database
|
|
138
|
+
records tokens and requests; a credit and a neuron are the provider's own
|
|
139
|
+
unit, derived from the model and the request by a rule it does not record.
|
|
140
|
+
Give them a line in `budgets.json` to get a percentage in USD instead.
|
|
141
|
+
- `cloudflare-workers-ai`: 100k neurons/day (free tier)
|
|
132
142
|
- `nvidia`: 1000 credits/month (free tier)
|
|
133
143
|
- `orcarouter`: undocumented
|
|
134
144
|
- `snowflake-cortex`: 100 credits/month (paid)
|
|
135
145
|
|
|
136
|
-
3. **budgets.json** — your monthly USD per provider
|
|
146
|
+
3. **budgets.json** — your monthly USD per provider, read against what the
|
|
147
|
+
provider cost so far this calendar month:
|
|
137
148
|
```json
|
|
138
149
|
{
|
|
139
150
|
"digitalocean": 5,
|
|
@@ -186,9 +197,14 @@ bun install # install workspace deps
|
|
|
186
197
|
bun run build # write dist/tui.js and dist/cli.js
|
|
187
198
|
bun run preview # render the /usage dialog headless, at several widths
|
|
188
199
|
bun test # run tests
|
|
200
|
+
bun run coverage # run tests with the coverage gate
|
|
189
201
|
bunx tsc --noEmit # typecheck
|
|
190
202
|
```
|
|
191
203
|
|
|
204
|
+
Every line of every source file is covered, and `bunfig.toml` fails the run if
|
|
205
|
+
that stops being true. `packages/cli/src/cli.ts` holds no logic for that reason:
|
|
206
|
+
the commands live in `main.ts`, which a test drives directly.
|
|
207
|
+
|
|
192
208
|
The workspace is a Bun monorepo. The packages are listed by dependency order,
|
|
193
209
|
since `cli` and `plugin` both build on `core`:
|
|
194
210
|
|
package/dist/cli.js
CHANGED
|
@@ -61,7 +61,7 @@ var CREATED = "CAST(json_extract(data,'$.time.created') AS INTEGER)";
|
|
|
61
61
|
var GROUPS = {
|
|
62
62
|
provider: "json_extract(data,'$.providerID')",
|
|
63
63
|
model: "json_extract(data,'$.providerID') || '/' || json_extract(data,'$.modelID')",
|
|
64
|
-
day:
|
|
64
|
+
day: `strftime('%Y-%m-%d', (${CREATED})/1000, 'unixepoch')`,
|
|
65
65
|
project: "json_extract(data,'$.path.cwd')",
|
|
66
66
|
agent: "json_extract(data,'$.agent')"
|
|
67
67
|
};
|
|
@@ -134,19 +134,6 @@ function localUsageByProvider(db, sinceMs) {
|
|
|
134
134
|
}
|
|
135
135
|
return map;
|
|
136
136
|
}
|
|
137
|
-
function monthlyUsageByProvider(db, startOfMonthMs) {
|
|
138
|
-
const rows = db.query(`SELECT COALESCE(json_extract(data,'$.providerID'),'?') AS provider,
|
|
139
|
-
COALESCE(SUM(CAST(json_extract(data,'$.cost') AS REAL)),0) AS cost
|
|
140
|
-
FROM message
|
|
141
|
-
WHERE json_extract(data,'$.role')='assistant'
|
|
142
|
-
AND CAST(json_extract(data,'$.time.created') AS INTEGER) >= ?
|
|
143
|
-
GROUP BY provider`).all(startOfMonthMs);
|
|
144
|
-
const map = new Map;
|
|
145
|
-
for (const r of rows) {
|
|
146
|
-
map.set(String(r.provider), Number(r.cost));
|
|
147
|
-
}
|
|
148
|
-
return map;
|
|
149
|
-
}
|
|
150
137
|
// packages/core/src/providers.ts
|
|
151
138
|
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
152
139
|
import path2 from "path";
|
|
@@ -233,6 +220,8 @@ async function fetchOpenRouter(key) {
|
|
|
233
220
|
|
|
234
221
|
// packages/core/src/quota/copilot.ts
|
|
235
222
|
function premiumBudget(s) {
|
|
223
|
+
if (!s)
|
|
224
|
+
return;
|
|
236
225
|
if (s.unlimited && s.entitlement === 0)
|
|
237
226
|
return;
|
|
238
227
|
const remaining = s.remaining ?? s.quota_remaining;
|
|
@@ -307,6 +296,38 @@ async function fetchZai(key) {
|
|
|
307
296
|
}
|
|
308
297
|
}
|
|
309
298
|
|
|
299
|
+
// packages/core/src/quota/amd.ts
|
|
300
|
+
async function fetchAmd(key) {
|
|
301
|
+
try {
|
|
302
|
+
const data = await getJson("https://developer.amd.com.cn/radeon/api/v1/usage", {
|
|
303
|
+
Authorization: `Bearer ${key}`
|
|
304
|
+
});
|
|
305
|
+
if (data.status && data.status !== "ok") {
|
|
306
|
+
return { provider: "amd", ok: false, detail: `usage endpoint reports ${data.status}`, windows: [] };
|
|
307
|
+
}
|
|
308
|
+
const limit = data.daily_cost_limit_usd ?? 0;
|
|
309
|
+
const used = data.daily_cost_used_usd ?? 0;
|
|
310
|
+
const pct = limit > 0 ? Math.round(used / limit * 1000) / 10 : 0;
|
|
311
|
+
const windows = [
|
|
312
|
+
{
|
|
313
|
+
label: "daily",
|
|
314
|
+
percentUsed: pct,
|
|
315
|
+
resetsAt: data.daily_reset_at,
|
|
316
|
+
detail: `$${used.toFixed(4)} of $${limit.toFixed(2)}`
|
|
317
|
+
}
|
|
318
|
+
];
|
|
319
|
+
return {
|
|
320
|
+
provider: "amd",
|
|
321
|
+
ok: true,
|
|
322
|
+
windows,
|
|
323
|
+
budget: limit > 0 ? { percentUsed: pct, label: `$${limit}/d` } : undefined,
|
|
324
|
+
raw: data
|
|
325
|
+
};
|
|
326
|
+
} catch (err) {
|
|
327
|
+
return { provider: "amd", ok: false, detail: err.message, windows: [] };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
310
331
|
// packages/core/src/providers.ts
|
|
311
332
|
var TTL_MS = 15 * 60 * 1000;
|
|
312
333
|
async function readCache() {
|
|
@@ -328,7 +349,8 @@ var FETCHERS = {
|
|
|
328
349
|
"opencode-go": fetchZen,
|
|
329
350
|
openrouter: fetchOpenRouter,
|
|
330
351
|
"github-copilot": fetchCopilot,
|
|
331
|
-
zai: fetchZai
|
|
352
|
+
zai: fetchZai,
|
|
353
|
+
amd: fetchAmd
|
|
332
354
|
};
|
|
333
355
|
async function providerStatuses(opts) {
|
|
334
356
|
const auth = await readAuth();
|
|
@@ -600,135 +622,130 @@ async function buildTop(limit) {
|
|
|
600
622
|
}
|
|
601
623
|
// packages/core/src/budget.ts
|
|
602
624
|
import { readFile as readFile4 } from "fs/promises";
|
|
603
|
-
|
|
604
|
-
try {
|
|
605
|
-
return JSON.parse(await readFile4(budgetsPath(), "utf8"));
|
|
606
|
-
} catch {
|
|
607
|
-
return {};
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
function pctFromLimit(provider, limit, usageTokens, usageRequests, isMonthly) {
|
|
611
|
-
if (limit.limit <= 0)
|
|
612
|
-
return;
|
|
613
|
-
const effectiveLimit = limit.metric.endsWith("/day") && limit.metric !== "neurons/day" ? limit.limit * 30 : limit.limit;
|
|
614
|
-
const metric = limit.metric.replace("/day", "").replace("/month", "");
|
|
615
|
-
switch (metric) {
|
|
616
|
-
case "tokens":
|
|
617
|
-
if (effectiveLimit > 0)
|
|
618
|
-
return usageTokens / effectiveLimit * 100;
|
|
619
|
-
return;
|
|
620
|
-
case "requests":
|
|
621
|
-
if (effectiveLimit > 0)
|
|
622
|
-
return usageRequests / effectiveLimit * 100;
|
|
623
|
-
return;
|
|
624
|
-
case "credits":
|
|
625
|
-
case "neurons":
|
|
626
|
-
return;
|
|
627
|
-
default:
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
function resolvePct(provider, live, budgets, monthCost, usageTokens, usageRequests, limits) {
|
|
632
|
-
const liveQ = live.get(provider);
|
|
633
|
-
if (liveQ?.budget && liveQ.ok) {
|
|
634
|
-
return { pct: liveQ.budget.percentUsed, source: "live", label: liveQ.budget.label };
|
|
635
|
-
}
|
|
636
|
-
if (budgets[provider] !== undefined) {
|
|
637
|
-
const budget = budgets[provider];
|
|
638
|
-
if (budget <= 0)
|
|
639
|
-
return { pct: 0, source: "budgets" };
|
|
640
|
-
return { pct: monthCost / budget * 100, source: "budgets" };
|
|
641
|
-
}
|
|
642
|
-
const limit = limits.get(provider);
|
|
643
|
-
if (limit) {
|
|
644
|
-
const pct = pctFromLimit(provider, limit, usageTokens, usageRequests, true);
|
|
645
|
-
if (pct !== undefined) {
|
|
646
|
-
return { pct, source: "limits", label: `${limit.limit.toLocaleString()} ${limit.unit}/${limit.metric.split("/")[1]}` };
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
return { pct: 0, source: "none" };
|
|
650
|
-
}
|
|
625
|
+
|
|
651
626
|
// packages/core/src/limits.ts
|
|
652
627
|
var PROVIDER_LIMITS = [
|
|
653
628
|
{
|
|
654
|
-
provider: "
|
|
655
|
-
metric: "tokens/day",
|
|
656
|
-
limit: 200000,
|
|
629
|
+
provider: "cerebras",
|
|
657
630
|
unit: "tokens",
|
|
631
|
+
period: "day",
|
|
632
|
+
limit: 1e6,
|
|
658
633
|
tier: "free",
|
|
659
|
-
source: "https://
|
|
660
|
-
note: "
|
|
661
|
-
},
|
|
662
|
-
{
|
|
663
|
-
provider: "google",
|
|
664
|
-
metric: "requests/day",
|
|
665
|
-
limit: 1500,
|
|
666
|
-
unit: "requests",
|
|
667
|
-
tier: "free",
|
|
668
|
-
source: "https://ai.google.dev/gemini-api/docs/rate-limits",
|
|
669
|
-
note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
|
|
634
|
+
source: "https://cerebras.ai/",
|
|
635
|
+
note: "Free tier estimate. Paid tiers much higher."
|
|
670
636
|
},
|
|
671
637
|
{
|
|
672
638
|
provider: "cloudflare-workers-ai",
|
|
673
|
-
metric: "neurons/day",
|
|
674
|
-
limit: 1e5,
|
|
675
639
|
unit: "neurons",
|
|
640
|
+
period: "day",
|
|
641
|
+
limit: 1e5,
|
|
676
642
|
tier: "free",
|
|
677
643
|
source: "https://developers.cloudflare.com/workers-ai/platform/limits/",
|
|
678
|
-
note: "
|
|
679
|
-
},
|
|
680
|
-
{
|
|
681
|
-
provider: "nvidia",
|
|
682
|
-
metric: "credits/month",
|
|
683
|
-
limit: 1000,
|
|
684
|
-
unit: "credits",
|
|
685
|
-
tier: "free",
|
|
686
|
-
source: "https://build.nvidia.com/",
|
|
687
|
-
note: "NIM free tier ~1000 credits/month. Varies by model."
|
|
644
|
+
note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
|
|
688
645
|
},
|
|
689
646
|
{
|
|
690
647
|
provider: "digitalocean",
|
|
691
|
-
metric: "tokens/day",
|
|
692
|
-
limit: 5000000,
|
|
693
648
|
unit: "tokens",
|
|
649
|
+
period: "day",
|
|
650
|
+
limit: 5000000,
|
|
694
651
|
tier: "paid",
|
|
695
652
|
source: "https://docs.digitalocean.com/products/genai/",
|
|
696
653
|
note: "GenAI platform paid plans. Exact limits per model."
|
|
697
654
|
},
|
|
698
655
|
{
|
|
699
|
-
provider: "
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
tier: "
|
|
704
|
-
source: "https://
|
|
705
|
-
note: "
|
|
656
|
+
provider: "google",
|
|
657
|
+
unit: "requests",
|
|
658
|
+
period: "day",
|
|
659
|
+
limit: 1500,
|
|
660
|
+
tier: "free",
|
|
661
|
+
source: "https://ai.google.dev/gemini-api/docs/rate-limits",
|
|
662
|
+
note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
|
|
706
663
|
},
|
|
707
664
|
{
|
|
708
|
-
provider: "
|
|
709
|
-
metric: "tokens/day",
|
|
710
|
-
limit: 1e6,
|
|
665
|
+
provider: "groq",
|
|
711
666
|
unit: "tokens",
|
|
667
|
+
period: "day",
|
|
668
|
+
limit: 200000,
|
|
712
669
|
tier: "free",
|
|
713
|
-
source: "https://
|
|
714
|
-
note: "
|
|
670
|
+
source: "https://console.groq.com/docs/rate-limits",
|
|
671
|
+
note: "Also 30k tokens/minute. Paid tiers higher."
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
provider: "nvidia",
|
|
675
|
+
unit: "credits",
|
|
676
|
+
period: "month",
|
|
677
|
+
limit: 1000,
|
|
678
|
+
tier: "free",
|
|
679
|
+
source: "https://build.nvidia.com/",
|
|
680
|
+
note: "NIM free tier ~1000 credits/month. A credit varies by model."
|
|
715
681
|
},
|
|
716
682
|
{
|
|
717
683
|
provider: "orcarouter",
|
|
718
|
-
metric: "tokens/day",
|
|
719
|
-
limit: 0,
|
|
720
684
|
unit: "tokens",
|
|
685
|
+
period: "day",
|
|
686
|
+
limit: 0,
|
|
721
687
|
tier: "unknown",
|
|
722
688
|
source: "unknown",
|
|
723
689
|
note: "Proxy service. No public limits documented."
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
provider: "snowflake-cortex",
|
|
693
|
+
unit: "credits",
|
|
694
|
+
period: "month",
|
|
695
|
+
limit: 100,
|
|
696
|
+
tier: "paid",
|
|
697
|
+
source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
|
|
698
|
+
note: "Credits follow warehouse time, not tokens. Not measurable from usage."
|
|
724
699
|
}
|
|
725
700
|
];
|
|
701
|
+
var MEASURED_UNITS = new Set(["tokens", "requests"]);
|
|
702
|
+
function isMeasurable(limit) {
|
|
703
|
+
return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
|
|
704
|
+
}
|
|
705
|
+
function limitLabel(limit) {
|
|
706
|
+
return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
|
|
707
|
+
}
|
|
726
708
|
function limitsAsMap() {
|
|
727
709
|
const m = new Map;
|
|
728
710
|
for (const l of PROVIDER_LIMITS)
|
|
729
711
|
m.set(l.provider, l);
|
|
730
712
|
return m;
|
|
731
713
|
}
|
|
714
|
+
|
|
715
|
+
// packages/core/src/budget.ts
|
|
716
|
+
async function readBudgets() {
|
|
717
|
+
try {
|
|
718
|
+
return JSON.parse(await readFile4(budgetsPath(), "utf8"));
|
|
719
|
+
} catch {
|
|
720
|
+
return {};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function pctFromLimit(limit, usage) {
|
|
724
|
+
if (!isMeasurable(limit))
|
|
725
|
+
return;
|
|
726
|
+
const period = usage[limit.period];
|
|
727
|
+
const used = limit.unit === "requests" ? period.requests : period.tokens;
|
|
728
|
+
return used / limit.limit * 100;
|
|
729
|
+
}
|
|
730
|
+
function resolvePct(provider, sources) {
|
|
731
|
+
const live = sources.live.get(provider);
|
|
732
|
+
if (live?.budget && live.ok) {
|
|
733
|
+
return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
|
|
734
|
+
}
|
|
735
|
+
const budget = sources.budgets[provider];
|
|
736
|
+
if (budget !== undefined) {
|
|
737
|
+
if (budget <= 0)
|
|
738
|
+
return { pct: 0, source: "budgets" };
|
|
739
|
+
return { pct: sources.monthCost / budget * 100, source: "budgets" };
|
|
740
|
+
}
|
|
741
|
+
const limit = sources.limits.get(provider);
|
|
742
|
+
if (limit) {
|
|
743
|
+
const pct = pctFromLimit(limit, sources.usage);
|
|
744
|
+
if (pct !== undefined)
|
|
745
|
+
return { pct, source: "limits", label: limitLabel(limit) };
|
|
746
|
+
}
|
|
747
|
+
return { pct: 0, source: "none" };
|
|
748
|
+
}
|
|
732
749
|
// packages/core/src/snapshot.ts
|
|
733
750
|
async function withConnectedProviders(rows) {
|
|
734
751
|
let connected;
|
|
@@ -755,36 +772,48 @@ async function withConnectedProviders(rows) {
|
|
|
755
772
|
}));
|
|
756
773
|
return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
|
|
757
774
|
}
|
|
775
|
+
function periodUsage(usage, provider) {
|
|
776
|
+
const local = usage.get(provider);
|
|
777
|
+
return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
|
|
778
|
+
}
|
|
779
|
+
async function resolvePctByProvider(rows, day, month) {
|
|
780
|
+
const budgets = await readBudgets();
|
|
781
|
+
const live = new Map;
|
|
782
|
+
for (const status of await providerStatuses({ noNet: false })) {
|
|
783
|
+
if (status.quota)
|
|
784
|
+
live.set(status.provider, status.quota);
|
|
785
|
+
}
|
|
786
|
+
const limits = limitsAsMap();
|
|
787
|
+
const pct = {};
|
|
788
|
+
for (const row of rows) {
|
|
789
|
+
const resolved = resolvePct(row.provider, {
|
|
790
|
+
live,
|
|
791
|
+
budgets,
|
|
792
|
+
monthCost: month.get(row.provider)?.cost ?? 0,
|
|
793
|
+
usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
|
|
794
|
+
limits
|
|
795
|
+
});
|
|
796
|
+
if (resolved.pct > 0 || resolved.source !== "none")
|
|
797
|
+
pct[row.provider] = resolved;
|
|
798
|
+
}
|
|
799
|
+
return pct;
|
|
800
|
+
}
|
|
758
801
|
async function getUsageSnapshot(sinceMs, groupBy, includePct) {
|
|
759
802
|
const db = openDb();
|
|
760
803
|
try {
|
|
761
|
-
const
|
|
804
|
+
const grouped = usageSince(db, sinceMs, groupBy);
|
|
805
|
+
const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
|
|
762
806
|
const totals = usageTotals(db, sinceMs);
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
for (const s of live) {
|
|
769
|
-
if (s.quota)
|
|
770
|
-
liveMap.set(s.provider, s.quota);
|
|
771
|
-
}
|
|
772
|
-
const monthCosts = monthlyUsageByProvider(db, startOfMonthMs());
|
|
773
|
-
const limits = limitsAsMap();
|
|
774
|
-
const pctMap = new Map;
|
|
775
|
-
for (const r of rows) {
|
|
776
|
-
const p = resolvePct(r.provider, liveMap, budgets, monthCosts.get(r.provider) ?? 0, r.tokensInput + r.tokensOutput, r.messages, limits);
|
|
777
|
-
if (p.pct > 0 || p.source !== "none")
|
|
778
|
-
pctMap.set(r.provider, p);
|
|
779
|
-
}
|
|
780
|
-
pct = pctMap;
|
|
781
|
-
}
|
|
782
|
-
return { rows, totals, pct: pct ? Object.fromEntries(pct) : undefined };
|
|
807
|
+
if (!includePct)
|
|
808
|
+
return { rows, totals };
|
|
809
|
+
const day = localUsageByProvider(db, startOfDayMs());
|
|
810
|
+
const month = localUsageByProvider(db, startOfMonthMs());
|
|
811
|
+
return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
|
|
783
812
|
} finally {
|
|
784
813
|
db.close();
|
|
785
814
|
}
|
|
786
815
|
}
|
|
787
|
-
// packages/cli/src/
|
|
816
|
+
// packages/cli/src/main.ts
|
|
788
817
|
var USAGE = `opencode-usage \u2014 usage tracking and model ranking for opencode
|
|
789
818
|
|
|
790
819
|
USAGE
|
|
@@ -799,12 +828,14 @@ OPTIONS
|
|
|
799
828
|
--since lookback window: Nh, Nd or Nw (default 7d)
|
|
800
829
|
--pct add % BUDGET column (requires --by provider)
|
|
801
830
|
`;
|
|
831
|
+
|
|
832
|
+
class CliError extends Error {
|
|
833
|
+
}
|
|
802
834
|
function fail(msg) {
|
|
803
|
-
|
|
804
|
-
`);
|
|
805
|
-
process.exit(1);
|
|
806
|
-
throw new Error("unreachable");
|
|
835
|
+
throw new CliError(msg);
|
|
807
836
|
}
|
|
837
|
+
var GROUP_BYS = ["provider", "model", "day", "project", "agent"];
|
|
838
|
+
var VALUED_FLAGS = ["--since", "--by", "--limit"];
|
|
808
839
|
function parseArgs(argv) {
|
|
809
840
|
const cmd = argv[0];
|
|
810
841
|
if (!cmd || cmd.startsWith("-"))
|
|
@@ -820,69 +851,82 @@ ${USAGE}`);
|
|
|
820
851
|
const name = `--${key}`;
|
|
821
852
|
if (val !== undefined)
|
|
822
853
|
flags.set(name, val);
|
|
823
|
-
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") &&
|
|
854
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") && VALUED_FLAGS.includes(name)) {
|
|
824
855
|
flags.set(name, argv[++i]);
|
|
825
856
|
} else
|
|
826
857
|
flags.set(name, true);
|
|
827
858
|
}
|
|
828
859
|
return { cmd, flags };
|
|
829
860
|
}
|
|
861
|
+
function stringFlag(flags, name) {
|
|
862
|
+
const v = flags.get(name);
|
|
863
|
+
return typeof v === "string" ? v : undefined;
|
|
864
|
+
}
|
|
865
|
+
function sinceMsOf(flags) {
|
|
866
|
+
if (flags.has("--today"))
|
|
867
|
+
return startOfDayMs();
|
|
868
|
+
if (!flags.has("--since"))
|
|
869
|
+
return Date.now() - parseDuration("7d");
|
|
870
|
+
const v = stringFlag(flags, "--since");
|
|
871
|
+
if (v === undefined)
|
|
872
|
+
fail("--since needs a value like 7d");
|
|
873
|
+
return Date.now() - parseDuration(v);
|
|
874
|
+
}
|
|
830
875
|
async function cmdUsage(flags, json) {
|
|
831
|
-
const sinceMs =
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
fail("--since needs a value like 7d");
|
|
835
|
-
return Date.now() - parseDuration(v);
|
|
836
|
-
})() : Date.now() - parseDuration("7d");
|
|
837
|
-
const groupBy = typeof flags.get("--by") === "string" ? flags.get("--by") : "provider";
|
|
838
|
-
if (!["provider", "model", "day", "project", "agent"].includes(groupBy))
|
|
876
|
+
const sinceMs = sinceMsOf(flags);
|
|
877
|
+
const groupBy = stringFlag(flags, "--by") ?? "provider";
|
|
878
|
+
if (!GROUP_BYS.includes(groupBy))
|
|
839
879
|
fail(`invalid --by: ${groupBy}`);
|
|
840
880
|
const withPct = flags.has("--pct");
|
|
841
881
|
if (withPct && groupBy !== "provider")
|
|
842
882
|
fail("--pct only supported with --by provider");
|
|
843
883
|
const snapshot = await getUsageSnapshot(sinceMs, groupBy, withPct);
|
|
844
884
|
if (json) {
|
|
845
|
-
|
|
885
|
+
console.log(JSON.stringify({
|
|
846
886
|
since: new Date(sinceMs).toISOString(),
|
|
847
887
|
groupBy,
|
|
848
888
|
rows: snapshot.rows,
|
|
849
889
|
totals: snapshot.totals,
|
|
850
890
|
pct: snapshot.pct
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
} else {
|
|
854
|
-
const label = flags.has("--today") ? "today" : flags.get("--since") ?? "7d";
|
|
855
|
-
console.log(usageTable(snapshot.rows, snapshot.totals, label, groupBy, snapshot.pct ? new Map(Object.entries(snapshot.pct)) : undefined));
|
|
891
|
+
}, null, 2));
|
|
892
|
+
return;
|
|
856
893
|
}
|
|
894
|
+
const label = flags.has("--today") ? "today" : stringFlag(flags, "--since") ?? "7d";
|
|
895
|
+
const pct = snapshot.pct ? new Map(Object.entries(snapshot.pct)) : undefined;
|
|
896
|
+
console.log(usageTable(snapshot.rows, snapshot.totals, label, groupBy, pct));
|
|
857
897
|
}
|
|
858
898
|
async function cmdProviders(flags, json) {
|
|
859
899
|
const statuses = await providerStatuses({ noNet: flags.has("--no-net") });
|
|
860
900
|
const db = openDb();
|
|
861
901
|
try {
|
|
862
902
|
const local = localUsageByProvider(db, Date.now() - parseDuration("7d"));
|
|
863
|
-
if (json)
|
|
903
|
+
if (json)
|
|
864
904
|
console.log(JSON.stringify({ statuses, local: Object.fromEntries(local) }, null, 2));
|
|
865
|
-
|
|
905
|
+
else
|
|
866
906
|
console.log(providersTable(statuses, local));
|
|
867
|
-
}
|
|
868
907
|
} finally {
|
|
869
908
|
db.close();
|
|
870
909
|
}
|
|
871
910
|
}
|
|
911
|
+
var TOP_HEADERS = ["MODEL", "NAME", "IQ", "CODING", "$/M", "IQ/$"];
|
|
912
|
+
var TOP_WIDTHS = [28, 26, 5, 7, 8, 6];
|
|
913
|
+
function topLine(cells) {
|
|
914
|
+
return cells.map((c, i) => c.length >= TOP_WIDTHS[i] ? c.slice(0, TOP_WIDTHS[i]) : c.padEnd(TOP_WIDTHS[i])).join(" ");
|
|
915
|
+
}
|
|
872
916
|
async function cmdTop(flags, json) {
|
|
873
|
-
const
|
|
917
|
+
const raw = stringFlag(flags, "--limit");
|
|
918
|
+
const limit = raw !== undefined ? Number(raw) : 20;
|
|
919
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
920
|
+
fail(`invalid --limit: ${raw}`);
|
|
874
921
|
const rows = await buildTop(limit);
|
|
875
922
|
if (json) {
|
|
876
923
|
console.log(JSON.stringify({ attribution: RANKING_ATTRIBUTION, models: rows }, null, 2));
|
|
877
924
|
return;
|
|
878
925
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const line = (cells) => cells.map((c, i) => c.length >= widths[i] ? c.slice(0, widths[i]) : c + " ".repeat(widths[i] - c.length)).join(" ");
|
|
882
|
-
console.log(line(head));
|
|
883
|
-
console.log(widths.map((w) => "-".repeat(w)).join(" "));
|
|
926
|
+
console.log(topLine(TOP_HEADERS));
|
|
927
|
+
console.log(TOP_WIDTHS.map((w) => "-".repeat(w)).join(" "));
|
|
884
928
|
for (const r of rows) {
|
|
885
|
-
console.log(
|
|
929
|
+
console.log(topLine([
|
|
886
930
|
r.model,
|
|
887
931
|
r.name,
|
|
888
932
|
r.intelligence !== undefined ? r.intelligence.toFixed(0) : "\u2014",
|
|
@@ -894,21 +938,28 @@ async function cmdTop(flags, json) {
|
|
|
894
938
|
console.log(`
|
|
895
939
|
${RANKING_ATTRIBUTION}`);
|
|
896
940
|
}
|
|
897
|
-
|
|
898
|
-
const { cmd, flags } = parseArgs(
|
|
941
|
+
async function run(argv) {
|
|
942
|
+
const { cmd, flags } = parseArgs(argv);
|
|
899
943
|
const json = flags.has("--json");
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
fail(`unknown command: ${cmd}
|
|
944
|
+
if (cmd === "usage")
|
|
945
|
+
await cmdUsage(flags, json);
|
|
946
|
+
else if (cmd === "providers")
|
|
947
|
+
await cmdProviders(flags, json);
|
|
948
|
+
else if (cmd === "top")
|
|
949
|
+
await cmdTop(flags, json);
|
|
950
|
+
else
|
|
951
|
+
fail(`unknown command: ${cmd}
|
|
909
952
|
|
|
910
953
|
${USAGE}`);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// packages/cli/src/cli.ts
|
|
957
|
+
if (import.meta.main) {
|
|
958
|
+
try {
|
|
959
|
+
await run(process.argv.slice(2));
|
|
911
960
|
} catch (err) {
|
|
912
|
-
|
|
961
|
+
console.error(`opencode-usage: ${err.message}
|
|
962
|
+
`);
|
|
963
|
+
process.exit(1);
|
|
913
964
|
}
|
|
914
965
|
}
|
package/dist/tui.js
CHANGED
|
@@ -51,7 +51,7 @@ var CREATED = "CAST(json_extract(data,'$.time.created') AS INTEGER)";
|
|
|
51
51
|
var GROUPS = {
|
|
52
52
|
provider: "json_extract(data,'$.providerID')",
|
|
53
53
|
model: "json_extract(data,'$.providerID') || '/' || json_extract(data,'$.modelID')",
|
|
54
|
-
day:
|
|
54
|
+
day: `strftime('%Y-%m-%d', (${CREATED})/1000, 'unixepoch')`,
|
|
55
55
|
project: "json_extract(data,'$.path.cwd')",
|
|
56
56
|
agent: "json_extract(data,'$.agent')"
|
|
57
57
|
};
|
|
@@ -102,16 +102,25 @@ function usageTotals(db, sinceMs) {
|
|
|
102
102
|
tokensOutput: Number(r?.tokensOutput ?? 0)
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
-
function
|
|
105
|
+
function localUsageByProvider(db, sinceMs) {
|
|
106
106
|
const rows = db.query(`SELECT COALESCE(json_extract(data,'$.providerID'),'?') AS provider,
|
|
107
|
-
|
|
107
|
+
COUNT(*) AS messages,
|
|
108
|
+
COALESCE(SUM(${COST}),0) AS cost,
|
|
109
|
+
COALESCE(SUM(${T_IN} + ${T_OUT}),0) AS tokens,
|
|
110
|
+
MAX(${CREATED}) AS lastUsedMs
|
|
108
111
|
FROM message
|
|
109
112
|
WHERE json_extract(data,'$.role')='assistant'
|
|
110
|
-
AND
|
|
111
|
-
GROUP BY provider`).all(
|
|
113
|
+
AND ${CREATED} >= ?
|
|
114
|
+
GROUP BY provider`).all(sinceMs);
|
|
112
115
|
const map = new Map;
|
|
113
116
|
for (const r of rows) {
|
|
114
|
-
map.set(String(r.provider),
|
|
117
|
+
map.set(String(r.provider), {
|
|
118
|
+
provider: String(r.provider),
|
|
119
|
+
messages: Number(r.messages),
|
|
120
|
+
cost: Number(r.cost),
|
|
121
|
+
tokens: Number(r.tokens),
|
|
122
|
+
lastUsedMs: Number(r.lastUsedMs)
|
|
123
|
+
});
|
|
115
124
|
}
|
|
116
125
|
return map;
|
|
117
126
|
}
|
|
@@ -201,6 +210,8 @@ async function fetchOpenRouter(key) {
|
|
|
201
210
|
|
|
202
211
|
// packages/core/src/quota/copilot.ts
|
|
203
212
|
function premiumBudget(s) {
|
|
213
|
+
if (!s)
|
|
214
|
+
return;
|
|
204
215
|
if (s.unlimited && s.entitlement === 0)
|
|
205
216
|
return;
|
|
206
217
|
const remaining = s.remaining ?? s.quota_remaining;
|
|
@@ -275,6 +286,38 @@ async function fetchZai(key) {
|
|
|
275
286
|
}
|
|
276
287
|
}
|
|
277
288
|
|
|
289
|
+
// packages/core/src/quota/amd.ts
|
|
290
|
+
async function fetchAmd(key) {
|
|
291
|
+
try {
|
|
292
|
+
const data = await getJson("https://developer.amd.com.cn/radeon/api/v1/usage", {
|
|
293
|
+
Authorization: `Bearer ${key}`
|
|
294
|
+
});
|
|
295
|
+
if (data.status && data.status !== "ok") {
|
|
296
|
+
return { provider: "amd", ok: false, detail: `usage endpoint reports ${data.status}`, windows: [] };
|
|
297
|
+
}
|
|
298
|
+
const limit = data.daily_cost_limit_usd ?? 0;
|
|
299
|
+
const used = data.daily_cost_used_usd ?? 0;
|
|
300
|
+
const pct = limit > 0 ? Math.round(used / limit * 1000) / 10 : 0;
|
|
301
|
+
const windows = [
|
|
302
|
+
{
|
|
303
|
+
label: "daily",
|
|
304
|
+
percentUsed: pct,
|
|
305
|
+
resetsAt: data.daily_reset_at,
|
|
306
|
+
detail: `$${used.toFixed(4)} of $${limit.toFixed(2)}`
|
|
307
|
+
}
|
|
308
|
+
];
|
|
309
|
+
return {
|
|
310
|
+
provider: "amd",
|
|
311
|
+
ok: true,
|
|
312
|
+
windows,
|
|
313
|
+
budget: limit > 0 ? { percentUsed: pct, label: `$${limit}/d` } : undefined,
|
|
314
|
+
raw: data
|
|
315
|
+
};
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return { provider: "amd", ok: false, detail: err.message, windows: [] };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
278
321
|
// packages/core/src/providers.ts
|
|
279
322
|
var TTL_MS = 15 * 60 * 1000;
|
|
280
323
|
async function readCache() {
|
|
@@ -296,7 +339,8 @@ var FETCHERS = {
|
|
|
296
339
|
"opencode-go": fetchZen,
|
|
297
340
|
openrouter: fetchOpenRouter,
|
|
298
341
|
"github-copilot": fetchCopilot,
|
|
299
|
-
zai: fetchZai
|
|
342
|
+
zai: fetchZai,
|
|
343
|
+
amd: fetchAmd
|
|
300
344
|
};
|
|
301
345
|
async function providerStatuses(opts) {
|
|
302
346
|
const auth = await readAuth();
|
|
@@ -343,135 +387,130 @@ async function providerStatuses(opts) {
|
|
|
343
387
|
var TTL_MS2 = 24 * 60 * 60 * 1000;
|
|
344
388
|
// packages/core/src/budget.ts
|
|
345
389
|
import { readFile as readFile3 } from "fs/promises";
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
return JSON.parse(await readFile3(budgetsPath(), "utf8"));
|
|
349
|
-
} catch {
|
|
350
|
-
return {};
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
function pctFromLimit(provider, limit, usageTokens, usageRequests, isMonthly) {
|
|
354
|
-
if (limit.limit <= 0)
|
|
355
|
-
return;
|
|
356
|
-
const effectiveLimit = limit.metric.endsWith("/day") && limit.metric !== "neurons/day" ? limit.limit * 30 : limit.limit;
|
|
357
|
-
const metric = limit.metric.replace("/day", "").replace("/month", "");
|
|
358
|
-
switch (metric) {
|
|
359
|
-
case "tokens":
|
|
360
|
-
if (effectiveLimit > 0)
|
|
361
|
-
return usageTokens / effectiveLimit * 100;
|
|
362
|
-
return;
|
|
363
|
-
case "requests":
|
|
364
|
-
if (effectiveLimit > 0)
|
|
365
|
-
return usageRequests / effectiveLimit * 100;
|
|
366
|
-
return;
|
|
367
|
-
case "credits":
|
|
368
|
-
case "neurons":
|
|
369
|
-
return;
|
|
370
|
-
default:
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
function resolvePct(provider, live, budgets, monthCost, usageTokens, usageRequests, limits) {
|
|
375
|
-
const liveQ = live.get(provider);
|
|
376
|
-
if (liveQ?.budget && liveQ.ok) {
|
|
377
|
-
return { pct: liveQ.budget.percentUsed, source: "live", label: liveQ.budget.label };
|
|
378
|
-
}
|
|
379
|
-
if (budgets[provider] !== undefined) {
|
|
380
|
-
const budget = budgets[provider];
|
|
381
|
-
if (budget <= 0)
|
|
382
|
-
return { pct: 0, source: "budgets" };
|
|
383
|
-
return { pct: monthCost / budget * 100, source: "budgets" };
|
|
384
|
-
}
|
|
385
|
-
const limit = limits.get(provider);
|
|
386
|
-
if (limit) {
|
|
387
|
-
const pct = pctFromLimit(provider, limit, usageTokens, usageRequests, true);
|
|
388
|
-
if (pct !== undefined) {
|
|
389
|
-
return { pct, source: "limits", label: `${limit.limit.toLocaleString()} ${limit.unit}/${limit.metric.split("/")[1]}` };
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
return { pct: 0, source: "none" };
|
|
393
|
-
}
|
|
390
|
+
|
|
394
391
|
// packages/core/src/limits.ts
|
|
395
392
|
var PROVIDER_LIMITS = [
|
|
396
393
|
{
|
|
397
|
-
provider: "
|
|
398
|
-
metric: "tokens/day",
|
|
399
|
-
limit: 200000,
|
|
394
|
+
provider: "cerebras",
|
|
400
395
|
unit: "tokens",
|
|
396
|
+
period: "day",
|
|
397
|
+
limit: 1e6,
|
|
401
398
|
tier: "free",
|
|
402
|
-
source: "https://
|
|
403
|
-
note: "
|
|
404
|
-
},
|
|
405
|
-
{
|
|
406
|
-
provider: "google",
|
|
407
|
-
metric: "requests/day",
|
|
408
|
-
limit: 1500,
|
|
409
|
-
unit: "requests",
|
|
410
|
-
tier: "free",
|
|
411
|
-
source: "https://ai.google.dev/gemini-api/docs/rate-limits",
|
|
412
|
-
note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
|
|
399
|
+
source: "https://cerebras.ai/",
|
|
400
|
+
note: "Free tier estimate. Paid tiers much higher."
|
|
413
401
|
},
|
|
414
402
|
{
|
|
415
403
|
provider: "cloudflare-workers-ai",
|
|
416
|
-
metric: "neurons/day",
|
|
417
|
-
limit: 1e5,
|
|
418
404
|
unit: "neurons",
|
|
405
|
+
period: "day",
|
|
406
|
+
limit: 1e5,
|
|
419
407
|
tier: "free",
|
|
420
408
|
source: "https://developers.cloudflare.com/workers-ai/platform/limits/",
|
|
421
|
-
note: "
|
|
422
|
-
},
|
|
423
|
-
{
|
|
424
|
-
provider: "nvidia",
|
|
425
|
-
metric: "credits/month",
|
|
426
|
-
limit: 1000,
|
|
427
|
-
unit: "credits",
|
|
428
|
-
tier: "free",
|
|
429
|
-
source: "https://build.nvidia.com/",
|
|
430
|
-
note: "NIM free tier ~1000 credits/month. Varies by model."
|
|
409
|
+
note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
|
|
431
410
|
},
|
|
432
411
|
{
|
|
433
412
|
provider: "digitalocean",
|
|
434
|
-
metric: "tokens/day",
|
|
435
|
-
limit: 5000000,
|
|
436
413
|
unit: "tokens",
|
|
414
|
+
period: "day",
|
|
415
|
+
limit: 5000000,
|
|
437
416
|
tier: "paid",
|
|
438
417
|
source: "https://docs.digitalocean.com/products/genai/",
|
|
439
418
|
note: "GenAI platform paid plans. Exact limits per model."
|
|
440
419
|
},
|
|
441
420
|
{
|
|
442
|
-
provider: "
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
tier: "
|
|
447
|
-
source: "https://
|
|
448
|
-
note: "
|
|
421
|
+
provider: "google",
|
|
422
|
+
unit: "requests",
|
|
423
|
+
period: "day",
|
|
424
|
+
limit: 1500,
|
|
425
|
+
tier: "free",
|
|
426
|
+
source: "https://ai.google.dev/gemini-api/docs/rate-limits",
|
|
427
|
+
note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
|
|
449
428
|
},
|
|
450
429
|
{
|
|
451
|
-
provider: "
|
|
452
|
-
metric: "tokens/day",
|
|
453
|
-
limit: 1e6,
|
|
430
|
+
provider: "groq",
|
|
454
431
|
unit: "tokens",
|
|
432
|
+
period: "day",
|
|
433
|
+
limit: 200000,
|
|
455
434
|
tier: "free",
|
|
456
|
-
source: "https://
|
|
457
|
-
note: "
|
|
435
|
+
source: "https://console.groq.com/docs/rate-limits",
|
|
436
|
+
note: "Also 30k tokens/minute. Paid tiers higher."
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
provider: "nvidia",
|
|
440
|
+
unit: "credits",
|
|
441
|
+
period: "month",
|
|
442
|
+
limit: 1000,
|
|
443
|
+
tier: "free",
|
|
444
|
+
source: "https://build.nvidia.com/",
|
|
445
|
+
note: "NIM free tier ~1000 credits/month. A credit varies by model."
|
|
458
446
|
},
|
|
459
447
|
{
|
|
460
448
|
provider: "orcarouter",
|
|
461
|
-
metric: "tokens/day",
|
|
462
|
-
limit: 0,
|
|
463
449
|
unit: "tokens",
|
|
450
|
+
period: "day",
|
|
451
|
+
limit: 0,
|
|
464
452
|
tier: "unknown",
|
|
465
453
|
source: "unknown",
|
|
466
454
|
note: "Proxy service. No public limits documented."
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
provider: "snowflake-cortex",
|
|
458
|
+
unit: "credits",
|
|
459
|
+
period: "month",
|
|
460
|
+
limit: 100,
|
|
461
|
+
tier: "paid",
|
|
462
|
+
source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
|
|
463
|
+
note: "Credits follow warehouse time, not tokens. Not measurable from usage."
|
|
467
464
|
}
|
|
468
465
|
];
|
|
466
|
+
var MEASURED_UNITS = new Set(["tokens", "requests"]);
|
|
467
|
+
function isMeasurable(limit) {
|
|
468
|
+
return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
|
|
469
|
+
}
|
|
470
|
+
function limitLabel(limit) {
|
|
471
|
+
return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
|
|
472
|
+
}
|
|
469
473
|
function limitsAsMap() {
|
|
470
474
|
const m = new Map;
|
|
471
475
|
for (const l of PROVIDER_LIMITS)
|
|
472
476
|
m.set(l.provider, l);
|
|
473
477
|
return m;
|
|
474
478
|
}
|
|
479
|
+
|
|
480
|
+
// packages/core/src/budget.ts
|
|
481
|
+
async function readBudgets() {
|
|
482
|
+
try {
|
|
483
|
+
return JSON.parse(await readFile3(budgetsPath(), "utf8"));
|
|
484
|
+
} catch {
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function pctFromLimit(limit, usage) {
|
|
489
|
+
if (!isMeasurable(limit))
|
|
490
|
+
return;
|
|
491
|
+
const period = usage[limit.period];
|
|
492
|
+
const used = limit.unit === "requests" ? period.requests : period.tokens;
|
|
493
|
+
return used / limit.limit * 100;
|
|
494
|
+
}
|
|
495
|
+
function resolvePct(provider, sources) {
|
|
496
|
+
const live = sources.live.get(provider);
|
|
497
|
+
if (live?.budget && live.ok) {
|
|
498
|
+
return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
|
|
499
|
+
}
|
|
500
|
+
const budget = sources.budgets[provider];
|
|
501
|
+
if (budget !== undefined) {
|
|
502
|
+
if (budget <= 0)
|
|
503
|
+
return { pct: 0, source: "budgets" };
|
|
504
|
+
return { pct: sources.monthCost / budget * 100, source: "budgets" };
|
|
505
|
+
}
|
|
506
|
+
const limit = sources.limits.get(provider);
|
|
507
|
+
if (limit) {
|
|
508
|
+
const pct = pctFromLimit(limit, sources.usage);
|
|
509
|
+
if (pct !== undefined)
|
|
510
|
+
return { pct, source: "limits", label: limitLabel(limit) };
|
|
511
|
+
}
|
|
512
|
+
return { pct: 0, source: "none" };
|
|
513
|
+
}
|
|
475
514
|
// packages/core/src/snapshot.ts
|
|
476
515
|
async function withConnectedProviders(rows) {
|
|
477
516
|
let connected;
|
|
@@ -498,42 +537,57 @@ async function withConnectedProviders(rows) {
|
|
|
498
537
|
}));
|
|
499
538
|
return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
|
|
500
539
|
}
|
|
540
|
+
function periodUsage(usage, provider) {
|
|
541
|
+
const local = usage.get(provider);
|
|
542
|
+
return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
|
|
543
|
+
}
|
|
544
|
+
async function resolvePctByProvider(rows, day, month) {
|
|
545
|
+
const budgets = await readBudgets();
|
|
546
|
+
const live = new Map;
|
|
547
|
+
for (const status of await providerStatuses({ noNet: false })) {
|
|
548
|
+
if (status.quota)
|
|
549
|
+
live.set(status.provider, status.quota);
|
|
550
|
+
}
|
|
551
|
+
const limits = limitsAsMap();
|
|
552
|
+
const pct = {};
|
|
553
|
+
for (const row of rows) {
|
|
554
|
+
const resolved = resolvePct(row.provider, {
|
|
555
|
+
live,
|
|
556
|
+
budgets,
|
|
557
|
+
monthCost: month.get(row.provider)?.cost ?? 0,
|
|
558
|
+
usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
|
|
559
|
+
limits
|
|
560
|
+
});
|
|
561
|
+
if (resolved.pct > 0 || resolved.source !== "none")
|
|
562
|
+
pct[row.provider] = resolved;
|
|
563
|
+
}
|
|
564
|
+
return pct;
|
|
565
|
+
}
|
|
501
566
|
async function getUsageSnapshot(sinceMs, groupBy, includePct) {
|
|
502
567
|
const db = openDb();
|
|
503
568
|
try {
|
|
504
|
-
const
|
|
569
|
+
const grouped = usageSince(db, sinceMs, groupBy);
|
|
570
|
+
const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
|
|
505
571
|
const totals = usageTotals(db, sinceMs);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
for (const s of live) {
|
|
512
|
-
if (s.quota)
|
|
513
|
-
liveMap.set(s.provider, s.quota);
|
|
514
|
-
}
|
|
515
|
-
const monthCosts = monthlyUsageByProvider(db, startOfMonthMs());
|
|
516
|
-
const limits = limitsAsMap();
|
|
517
|
-
const pctMap = new Map;
|
|
518
|
-
for (const r of rows) {
|
|
519
|
-
const p = resolvePct(r.provider, liveMap, budgets, monthCosts.get(r.provider) ?? 0, r.tokensInput + r.tokensOutput, r.messages, limits);
|
|
520
|
-
if (p.pct > 0 || p.source !== "none")
|
|
521
|
-
pctMap.set(r.provider, p);
|
|
522
|
-
}
|
|
523
|
-
pct = pctMap;
|
|
524
|
-
}
|
|
525
|
-
return { rows, totals, pct: pct ? Object.fromEntries(pct) : undefined };
|
|
572
|
+
if (!includePct)
|
|
573
|
+
return { rows, totals };
|
|
574
|
+
const day = localUsageByProvider(db, startOfDayMs());
|
|
575
|
+
const month = localUsageByProvider(db, startOfMonthMs());
|
|
576
|
+
return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
|
|
526
577
|
} finally {
|
|
527
578
|
db.close();
|
|
528
579
|
}
|
|
529
580
|
}
|
|
530
|
-
// packages/plugin/
|
|
531
|
-
import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
|
|
532
|
-
var COMMAND = "opencode-usage.show";
|
|
581
|
+
// packages/plugin/layout.ts
|
|
533
582
|
var SIZE_WIDTH = { medium: 60, large: 88, xlarge: 116 };
|
|
534
583
|
var PADDING = 1;
|
|
584
|
+
var WIDE_BAR = 12;
|
|
585
|
+
var COMPACT_BAR = 6;
|
|
586
|
+
var BUDGET_FIXED = WIDE_BAR + 6;
|
|
587
|
+
var WIDE_MIN = 80;
|
|
588
|
+
var BUDGET_MIN = 14;
|
|
535
589
|
function columnsFor(inner) {
|
|
536
|
-
const wide = inner >=
|
|
590
|
+
const wide = inner >= WIDE_MIN;
|
|
537
591
|
const base = wide ? [
|
|
538
592
|
{ title: "PROVIDER", width: 21, align: "left" },
|
|
539
593
|
{ title: "MSGS", width: 5, align: "right" },
|
|
@@ -548,7 +602,7 @@ function columnsFor(inner) {
|
|
|
548
602
|
{ title: "COST", width: 8, align: "right" }
|
|
549
603
|
];
|
|
550
604
|
const used = base.reduce((a, c) => a + c.width, 0) + base.length;
|
|
551
|
-
const budget = Math.max(
|
|
605
|
+
const budget = Math.max(BUDGET_MIN, inner - used);
|
|
552
606
|
return { cols: [...base, { title: "BUDGET", width: budget, align: "left" }], bar: wide ? WIDE_BAR : COMPACT_BAR };
|
|
553
607
|
}
|
|
554
608
|
function fmtTokens(n) {
|
|
@@ -569,9 +623,6 @@ function progressBar(pct, width) {
|
|
|
569
623
|
const filled = Math.round(Math.min(Math.max(pct, 0), 100) / 100 * width);
|
|
570
624
|
return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
|
|
571
625
|
}
|
|
572
|
-
var WIDE_BAR = 12;
|
|
573
|
-
var COMPACT_BAR = 6;
|
|
574
|
-
var BUDGET_FIXED = WIDE_BAR + 6;
|
|
575
626
|
function compactLabel(label) {
|
|
576
627
|
return label.replace(/\b\d[\d,]*\b/g, (n) => {
|
|
577
628
|
const v = Number(n.replace(/,/g, ""));
|
|
@@ -601,6 +652,13 @@ function windowSuffix(label) {
|
|
|
601
652
|
return "h";
|
|
602
653
|
return "";
|
|
603
654
|
}
|
|
655
|
+
function budgetTail(pct, label, colWidth, bar) {
|
|
656
|
+
const head = ` ${pct.toFixed(0).padStart(3)}% `;
|
|
657
|
+
const room = colWidth - bar - head.length;
|
|
658
|
+
const compact = compactLabel(label);
|
|
659
|
+
const text = compact.length <= room ? compact : windowSuffix(label);
|
|
660
|
+
return (head + text).slice(0, colWidth - bar);
|
|
661
|
+
}
|
|
604
662
|
function toHex(color, fallback) {
|
|
605
663
|
if (typeof color === "string")
|
|
606
664
|
return color;
|
|
@@ -611,22 +669,12 @@ function toHex(color, fallback) {
|
|
|
611
669
|
const hex = (v) => Math.round(Math.min(Math.max(v * scale, 0), 255)).toString(16).padStart(2, "0");
|
|
612
670
|
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
|
|
613
671
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
return {
|
|
617
|
-
accent: toHex(t?.primary, "#a277ff"),
|
|
618
|
-
text: toHex(t?.text, "#e5e5e5"),
|
|
619
|
-
muted: toHex(t?.textMuted, "#8a8a8a"),
|
|
620
|
-
subtle: toHex(t?.borderSubtle, "#4a4a4a"),
|
|
621
|
-
ok: toHex(t?.success, "#4ade80"),
|
|
622
|
-
warn: toHex(t?.warning, "#facc15"),
|
|
623
|
-
danger: toHex(t?.error, "#f87171")
|
|
624
|
-
};
|
|
625
|
-
}
|
|
672
|
+
var DANGER_PCT = 90;
|
|
673
|
+
var WARN_PCT = 70;
|
|
626
674
|
function barColor(pct, p) {
|
|
627
|
-
if (pct >=
|
|
675
|
+
if (pct >= DANGER_PCT)
|
|
628
676
|
return p.danger;
|
|
629
|
-
if (pct >=
|
|
677
|
+
if (pct >= WARN_PCT)
|
|
630
678
|
return p.warn;
|
|
631
679
|
return p.ok;
|
|
632
680
|
}
|
|
@@ -645,6 +693,23 @@ function widthNeeded(longestLabel) {
|
|
|
645
693
|
const base = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 1;
|
|
646
694
|
return base + BUDGET_FIXED + longestLabel;
|
|
647
695
|
}
|
|
696
|
+
|
|
697
|
+
// packages/plugin/tui.tsx
|
|
698
|
+
import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
|
|
699
|
+
var COMMAND = "opencode-usage.show";
|
|
700
|
+
var MAX_ROWS = 20;
|
|
701
|
+
function palette(api) {
|
|
702
|
+
const t = api.theme?.current;
|
|
703
|
+
return {
|
|
704
|
+
accent: toHex(t?.primary, "#a277ff"),
|
|
705
|
+
text: toHex(t?.text, "#e5e5e5"),
|
|
706
|
+
muted: toHex(t?.textMuted, "#8a8a8a"),
|
|
707
|
+
subtle: toHex(t?.borderSubtle, "#4a4a4a"),
|
|
708
|
+
ok: toHex(t?.success, "#4ade80"),
|
|
709
|
+
warn: toHex(t?.warning, "#facc15"),
|
|
710
|
+
danger: toHex(t?.error, "#f87171")
|
|
711
|
+
};
|
|
712
|
+
}
|
|
648
713
|
function terminalWidth(api) {
|
|
649
714
|
return api.renderer?.width ?? SIZE_WIDTH.medium;
|
|
650
715
|
}
|
|
@@ -652,6 +717,10 @@ function innerWidth(api, needed) {
|
|
|
652
717
|
const terminal = terminalWidth(api);
|
|
653
718
|
return Math.min(SIZE_WIDTH[chooseSize(terminal, needed)], terminal - 2) - PADDING * 2;
|
|
654
719
|
}
|
|
720
|
+
function neededWidth(snapshot) {
|
|
721
|
+
const labels = Object.values(snapshot.pct ?? {}).map((b) => compactLabel(b.label ?? b.source).length);
|
|
722
|
+
return widthNeeded(Math.max(0, ...labels));
|
|
723
|
+
}
|
|
655
724
|
function Frame(props) {
|
|
656
725
|
props.api.ui.dialog.setSize(chooseSize(terminalWidth(props.api), props.needed));
|
|
657
726
|
return /* @__PURE__ */ jsxs("box", {
|
|
@@ -680,13 +749,6 @@ function Frame(props) {
|
|
|
680
749
|
});
|
|
681
750
|
}
|
|
682
751
|
function Budget(props) {
|
|
683
|
-
const tail = () => {
|
|
684
|
-
const head = ` ${props.pct.toFixed(0).padStart(3)}% `;
|
|
685
|
-
const room = props.col.width - props.bar - head.length;
|
|
686
|
-
const compact = compactLabel(props.label);
|
|
687
|
-
const label = compact.length <= room ? compact : windowSuffix(props.label);
|
|
688
|
-
return (head + label).slice(0, props.col.width - props.bar);
|
|
689
|
-
};
|
|
690
752
|
return /* @__PURE__ */ jsxs("box", {
|
|
691
753
|
flexDirection: "row",
|
|
692
754
|
children: [
|
|
@@ -698,22 +760,25 @@ function Budget(props) {
|
|
|
698
760
|
/* @__PURE__ */ jsx("text", {
|
|
699
761
|
fg: props.palette.muted,
|
|
700
762
|
wrapMode: "none",
|
|
701
|
-
children:
|
|
763
|
+
children: budgetTail(props.pct, props.label, props.col.width, props.bar)
|
|
702
764
|
})
|
|
703
765
|
]
|
|
704
766
|
});
|
|
705
767
|
}
|
|
706
|
-
function
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
const layout = () => columnsFor(innerWidth(props.api, needed));
|
|
710
|
-
const lead = (cols, r) => row(cols, [
|
|
768
|
+
function leadCells(cols, r) {
|
|
769
|
+
const upToBudget = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2;
|
|
770
|
+
return row(cols, [
|
|
711
771
|
r.provider,
|
|
712
772
|
String(r.messages),
|
|
713
773
|
fmtTokens(r.tokensInput),
|
|
714
774
|
fmtTokens(r.tokensOutput),
|
|
715
775
|
`$${r.cost.toFixed(2)}`
|
|
716
|
-
]).trimEnd().padEnd(
|
|
776
|
+
]).trimEnd().padEnd(upToBudget) + " ";
|
|
777
|
+
}
|
|
778
|
+
function Table(props) {
|
|
779
|
+
const p = palette(props.api);
|
|
780
|
+
const needed = neededWidth(props.snapshot);
|
|
781
|
+
const layout = () => columnsFor(innerWidth(props.api, needed));
|
|
717
782
|
return /* @__PURE__ */ jsxs(Frame, {
|
|
718
783
|
api: props.api,
|
|
719
784
|
palette: p,
|
|
@@ -724,7 +789,7 @@ function Table(props) {
|
|
|
724
789
|
wrapMode: "none",
|
|
725
790
|
children: row(layout().cols, layout().cols.map((c) => c.title))
|
|
726
791
|
}),
|
|
727
|
-
props.snapshot.rows.slice(0,
|
|
792
|
+
props.snapshot.rows.slice(0, MAX_ROWS).map((r) => {
|
|
728
793
|
const budget = props.snapshot.pct?.[r.provider];
|
|
729
794
|
return /* @__PURE__ */ jsxs("box", {
|
|
730
795
|
flexDirection: "row",
|
|
@@ -732,7 +797,7 @@ function Table(props) {
|
|
|
732
797
|
/* @__PURE__ */ jsx("text", {
|
|
733
798
|
fg: r.messages > 0 ? p.text : p.muted,
|
|
734
799
|
wrapMode: "none",
|
|
735
|
-
children:
|
|
800
|
+
children: leadCells(layout().cols, r)
|
|
736
801
|
}),
|
|
737
802
|
budget ? /* @__PURE__ */ jsx(Budget, {
|
|
738
803
|
pct: budget.pct,
|
|
@@ -829,5 +894,10 @@ export {
|
|
|
829
894
|
Message,
|
|
830
895
|
Table,
|
|
831
896
|
tui_default as default,
|
|
897
|
+
innerWidth,
|
|
898
|
+
neededWidth,
|
|
899
|
+
palette,
|
|
900
|
+
show,
|
|
901
|
+
terminalWidth,
|
|
832
902
|
tui
|
|
833
903
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1930dev/opencode-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Usage tracking, budget percentages and model ranking for opencode
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Usage tracking, budget percentages and model ranking for opencode — CLI plus a /usage TUI plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opencode-usage": "./dist/cli.js"
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
12
|
"build": "bun scripts/build.ts",
|
|
13
13
|
"preview": "bun run build && bun packages/plugin/preview.ts",
|
|
14
|
-
"prepublishOnly": "bun run build && bun test && tsc --noEmit"
|
|
14
|
+
"prepublishOnly": "bun run build && bun test --coverage && tsc --noEmit",
|
|
15
|
+
"coverage": "bun test --coverage"
|
|
15
16
|
},
|
|
16
17
|
"repository": {
|
|
17
18
|
"type": "git",
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"tokens",
|
|
28
29
|
"llm"
|
|
29
30
|
],
|
|
30
|
-
"author": "Agu
|
|
31
|
+
"author": "Agu Rodríguez <me@agu.uy>",
|
|
31
32
|
"license": "MIT",
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@opencode-ai/plugin": "^1.18.26",
|