@1930dev/opencode-usage 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -4
- package/dist/cli.js +185 -167
- package/dist/tui.js +192 -155
- package/package.json +4 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agu Rodríguez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
Usage tracking, budget percentages and model ranking for [opencode](https://opencode.ai):
|
|
4
4
|
a CLI, and a `/usage` slash command for the TUI.
|
|
5
5
|
|
|
6
|
+
Every connected provider in one table, with each one's remaining budget as a
|
|
7
|
+
comparable percentage — whether the provider bills in tokens, requests, credits or
|
|
8
|
+
neurons.
|
|
9
|
+
|
|
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. -->
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
[](https://www.npmjs.com/package/@1930dev/opencode-usage)
|
|
15
|
+
[](./LICENSE)
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
opencode talks to many providers at once, and each one meters differently: a weekly
|
|
20
|
+
window here, premium requests there, credits, neurons, tokens per day. Nothing tells
|
|
21
|
+
you which one you are about to exhaust.
|
|
22
|
+
|
|
23
|
+
This reads opencode's own SQLite and auth store — no configuration, no account — and
|
|
24
|
+
normalizes every quota into one percentage, so the answer to "which provider still has
|
|
25
|
+
room" is one glance.
|
|
26
|
+
|
|
6
27
|
## Install
|
|
7
28
|
|
|
8
29
|
```bash
|
|
@@ -45,7 +66,22 @@ opencode-usage top --limit 10 --json
|
|
|
45
66
|
## Plugin for opencode
|
|
46
67
|
|
|
47
68
|
The package also ships a `/usage` slash command for the opencode TUI. It shows the
|
|
48
|
-
same table as the CLI, with the % budget column drawn as a progress bar
|
|
69
|
+
same table as the CLI, with the % budget column drawn as a progress bar:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Usage — today esc
|
|
73
|
+
|
|
74
|
+
PROVIDER MSGS TOK IN TOK OUT COST BUDGET
|
|
75
|
+
opencode-go 7 0 0 $0.00 ████████████ 100% weekly
|
|
76
|
+
nvidia 98 10.9M 19.9K $0.00
|
|
77
|
+
cloudflare-workers-ai 0 0 0 $0.00
|
|
78
|
+
github-copilot 1 4.2K 830 $12.35 █████░░░░░░░ 42% 1M tok/day
|
|
79
|
+
|
|
80
|
+
TOTAL 106 $12.35
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The dialog sizes itself to the terminal: a wide frame spells the budget labels out,
|
|
84
|
+
a narrow one shortens them to the window (`wk`, `mo`, `d`).
|
|
49
85
|
|
|
50
86
|
```bash
|
|
51
87
|
opencode plugin @1930dev/opencode-usage
|
|
@@ -88,17 +124,26 @@ Normalized percentage of budget consumed per provider, from these sources (in pr
|
|
|
88
124
|
- `openrouter`: credits used / total credits
|
|
89
125
|
- `zai`: coding plan quota
|
|
90
126
|
|
|
91
|
-
2. **Documented limits** — published quotas, used when the provider reports none
|
|
127
|
+
2. **Documented limits** — published quotas, used when the provider reports none.
|
|
128
|
+
Each one is read against the period it resets on: a daily limit against today,
|
|
129
|
+
a monthly one against the calendar month. The window you asked for (`--since`)
|
|
130
|
+
sizes the table and never the budget.
|
|
92
131
|
- `cerebras`: 1M tokens/day (free tier)
|
|
93
|
-
- `cloudflare-workers-ai`: 100k neurons/day (free tier)
|
|
94
132
|
- `digitalocean`: 5M tokens/day (paid)
|
|
95
133
|
- `google`: 1500 requests/day (free tier)
|
|
96
134
|
- `groq`: 200k tokens/day (free tier)
|
|
135
|
+
|
|
136
|
+
These are documented but not measurable, and show `—`. opencode's database
|
|
137
|
+
records tokens and requests; a credit and a neuron are the provider's own
|
|
138
|
+
unit, derived from the model and the request by a rule it does not record.
|
|
139
|
+
Give them a line in `budgets.json` to get a percentage in USD instead.
|
|
140
|
+
- `cloudflare-workers-ai`: 100k neurons/day (free tier)
|
|
97
141
|
- `nvidia`: 1000 credits/month (free tier)
|
|
98
142
|
- `orcarouter`: undocumented
|
|
99
143
|
- `snowflake-cortex`: 100 credits/month (paid)
|
|
100
144
|
|
|
101
|
-
3. **budgets.json** — your monthly USD per provider
|
|
145
|
+
3. **budgets.json** — your monthly USD per provider, read against what the
|
|
146
|
+
provider cost so far this calendar month:
|
|
102
147
|
```json
|
|
103
148
|
{
|
|
104
149
|
"digitalocean": 5,
|
|
@@ -151,9 +196,14 @@ bun install # install workspace deps
|
|
|
151
196
|
bun run build # write dist/tui.js and dist/cli.js
|
|
152
197
|
bun run preview # render the /usage dialog headless, at several widths
|
|
153
198
|
bun test # run tests
|
|
199
|
+
bun run coverage # run tests with the coverage gate
|
|
154
200
|
bunx tsc --noEmit # typecheck
|
|
155
201
|
```
|
|
156
202
|
|
|
203
|
+
Every line of every source file is covered, and `bunfig.toml` fails the run if
|
|
204
|
+
that stops being true. `packages/cli/src/cli.ts` holds no logic for that reason:
|
|
205
|
+
the commands live in `main.ts`, which a test drives directly.
|
|
206
|
+
|
|
157
207
|
The workspace is a Bun monorepo. The packages are listed by dependency order,
|
|
158
208
|
since `cli` and `plugin` both build on `core`:
|
|
159
209
|
|
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;
|
|
@@ -600,135 +589,130 @@ async function buildTop(limit) {
|
|
|
600
589
|
}
|
|
601
590
|
// packages/core/src/budget.ts
|
|
602
591
|
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
|
-
}
|
|
592
|
+
|
|
651
593
|
// packages/core/src/limits.ts
|
|
652
594
|
var PROVIDER_LIMITS = [
|
|
653
595
|
{
|
|
654
|
-
provider: "
|
|
655
|
-
metric: "tokens/day",
|
|
656
|
-
limit: 200000,
|
|
596
|
+
provider: "cerebras",
|
|
657
597
|
unit: "tokens",
|
|
598
|
+
period: "day",
|
|
599
|
+
limit: 1e6,
|
|
658
600
|
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."
|
|
601
|
+
source: "https://cerebras.ai/",
|
|
602
|
+
note: "Free tier estimate. Paid tiers much higher."
|
|
670
603
|
},
|
|
671
604
|
{
|
|
672
605
|
provider: "cloudflare-workers-ai",
|
|
673
|
-
metric: "neurons/day",
|
|
674
|
-
limit: 1e5,
|
|
675
606
|
unit: "neurons",
|
|
607
|
+
period: "day",
|
|
608
|
+
limit: 1e5,
|
|
676
609
|
tier: "free",
|
|
677
610
|
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."
|
|
611
|
+
note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
|
|
688
612
|
},
|
|
689
613
|
{
|
|
690
614
|
provider: "digitalocean",
|
|
691
|
-
metric: "tokens/day",
|
|
692
|
-
limit: 5000000,
|
|
693
615
|
unit: "tokens",
|
|
616
|
+
period: "day",
|
|
617
|
+
limit: 5000000,
|
|
694
618
|
tier: "paid",
|
|
695
619
|
source: "https://docs.digitalocean.com/products/genai/",
|
|
696
620
|
note: "GenAI platform paid plans. Exact limits per model."
|
|
697
621
|
},
|
|
698
622
|
{
|
|
699
|
-
provider: "
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
tier: "
|
|
704
|
-
source: "https://
|
|
705
|
-
note: "
|
|
623
|
+
provider: "google",
|
|
624
|
+
unit: "requests",
|
|
625
|
+
period: "day",
|
|
626
|
+
limit: 1500,
|
|
627
|
+
tier: "free",
|
|
628
|
+
source: "https://ai.google.dev/gemini-api/docs/rate-limits",
|
|
629
|
+
note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
|
|
706
630
|
},
|
|
707
631
|
{
|
|
708
|
-
provider: "
|
|
709
|
-
metric: "tokens/day",
|
|
710
|
-
limit: 1e6,
|
|
632
|
+
provider: "groq",
|
|
711
633
|
unit: "tokens",
|
|
634
|
+
period: "day",
|
|
635
|
+
limit: 200000,
|
|
712
636
|
tier: "free",
|
|
713
|
-
source: "https://
|
|
714
|
-
note: "
|
|
637
|
+
source: "https://console.groq.com/docs/rate-limits",
|
|
638
|
+
note: "Also 30k tokens/minute. Paid tiers higher."
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
provider: "nvidia",
|
|
642
|
+
unit: "credits",
|
|
643
|
+
period: "month",
|
|
644
|
+
limit: 1000,
|
|
645
|
+
tier: "free",
|
|
646
|
+
source: "https://build.nvidia.com/",
|
|
647
|
+
note: "NIM free tier ~1000 credits/month. A credit varies by model."
|
|
715
648
|
},
|
|
716
649
|
{
|
|
717
650
|
provider: "orcarouter",
|
|
718
|
-
metric: "tokens/day",
|
|
719
|
-
limit: 0,
|
|
720
651
|
unit: "tokens",
|
|
652
|
+
period: "day",
|
|
653
|
+
limit: 0,
|
|
721
654
|
tier: "unknown",
|
|
722
655
|
source: "unknown",
|
|
723
656
|
note: "Proxy service. No public limits documented."
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
provider: "snowflake-cortex",
|
|
660
|
+
unit: "credits",
|
|
661
|
+
period: "month",
|
|
662
|
+
limit: 100,
|
|
663
|
+
tier: "paid",
|
|
664
|
+
source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
|
|
665
|
+
note: "Credits follow warehouse time, not tokens. Not measurable from usage."
|
|
724
666
|
}
|
|
725
667
|
];
|
|
668
|
+
var MEASURED_UNITS = new Set(["tokens", "requests"]);
|
|
669
|
+
function isMeasurable(limit) {
|
|
670
|
+
return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
|
|
671
|
+
}
|
|
672
|
+
function limitLabel(limit) {
|
|
673
|
+
return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
|
|
674
|
+
}
|
|
726
675
|
function limitsAsMap() {
|
|
727
676
|
const m = new Map;
|
|
728
677
|
for (const l of PROVIDER_LIMITS)
|
|
729
678
|
m.set(l.provider, l);
|
|
730
679
|
return m;
|
|
731
680
|
}
|
|
681
|
+
|
|
682
|
+
// packages/core/src/budget.ts
|
|
683
|
+
async function readBudgets() {
|
|
684
|
+
try {
|
|
685
|
+
return JSON.parse(await readFile4(budgetsPath(), "utf8"));
|
|
686
|
+
} catch {
|
|
687
|
+
return {};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
function pctFromLimit(limit, usage) {
|
|
691
|
+
if (!isMeasurable(limit))
|
|
692
|
+
return;
|
|
693
|
+
const period = usage[limit.period];
|
|
694
|
+
const used = limit.unit === "requests" ? period.requests : period.tokens;
|
|
695
|
+
return used / limit.limit * 100;
|
|
696
|
+
}
|
|
697
|
+
function resolvePct(provider, sources) {
|
|
698
|
+
const live = sources.live.get(provider);
|
|
699
|
+
if (live?.budget && live.ok) {
|
|
700
|
+
return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
|
|
701
|
+
}
|
|
702
|
+
const budget = sources.budgets[provider];
|
|
703
|
+
if (budget !== undefined) {
|
|
704
|
+
if (budget <= 0)
|
|
705
|
+
return { pct: 0, source: "budgets" };
|
|
706
|
+
return { pct: sources.monthCost / budget * 100, source: "budgets" };
|
|
707
|
+
}
|
|
708
|
+
const limit = sources.limits.get(provider);
|
|
709
|
+
if (limit) {
|
|
710
|
+
const pct = pctFromLimit(limit, sources.usage);
|
|
711
|
+
if (pct !== undefined)
|
|
712
|
+
return { pct, source: "limits", label: limitLabel(limit) };
|
|
713
|
+
}
|
|
714
|
+
return { pct: 0, source: "none" };
|
|
715
|
+
}
|
|
732
716
|
// packages/core/src/snapshot.ts
|
|
733
717
|
async function withConnectedProviders(rows) {
|
|
734
718
|
let connected;
|
|
@@ -755,36 +739,48 @@ async function withConnectedProviders(rows) {
|
|
|
755
739
|
}));
|
|
756
740
|
return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
|
|
757
741
|
}
|
|
742
|
+
function periodUsage(usage, provider) {
|
|
743
|
+
const local = usage.get(provider);
|
|
744
|
+
return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
|
|
745
|
+
}
|
|
746
|
+
async function resolvePctByProvider(rows, day, month) {
|
|
747
|
+
const budgets = await readBudgets();
|
|
748
|
+
const live = new Map;
|
|
749
|
+
for (const status of await providerStatuses({ noNet: false })) {
|
|
750
|
+
if (status.quota)
|
|
751
|
+
live.set(status.provider, status.quota);
|
|
752
|
+
}
|
|
753
|
+
const limits = limitsAsMap();
|
|
754
|
+
const pct = {};
|
|
755
|
+
for (const row of rows) {
|
|
756
|
+
const resolved = resolvePct(row.provider, {
|
|
757
|
+
live,
|
|
758
|
+
budgets,
|
|
759
|
+
monthCost: month.get(row.provider)?.cost ?? 0,
|
|
760
|
+
usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
|
|
761
|
+
limits
|
|
762
|
+
});
|
|
763
|
+
if (resolved.pct > 0 || resolved.source !== "none")
|
|
764
|
+
pct[row.provider] = resolved;
|
|
765
|
+
}
|
|
766
|
+
return pct;
|
|
767
|
+
}
|
|
758
768
|
async function getUsageSnapshot(sinceMs, groupBy, includePct) {
|
|
759
769
|
const db = openDb();
|
|
760
770
|
try {
|
|
761
|
-
const
|
|
771
|
+
const grouped = usageSince(db, sinceMs, groupBy);
|
|
772
|
+
const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
|
|
762
773
|
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 };
|
|
774
|
+
if (!includePct)
|
|
775
|
+
return { rows, totals };
|
|
776
|
+
const day = localUsageByProvider(db, startOfDayMs());
|
|
777
|
+
const month = localUsageByProvider(db, startOfMonthMs());
|
|
778
|
+
return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
|
|
783
779
|
} finally {
|
|
784
780
|
db.close();
|
|
785
781
|
}
|
|
786
782
|
}
|
|
787
|
-
// packages/cli/src/
|
|
783
|
+
// packages/cli/src/main.ts
|
|
788
784
|
var USAGE = `opencode-usage \u2014 usage tracking and model ranking for opencode
|
|
789
785
|
|
|
790
786
|
USAGE
|
|
@@ -799,12 +795,14 @@ OPTIONS
|
|
|
799
795
|
--since lookback window: Nh, Nd or Nw (default 7d)
|
|
800
796
|
--pct add % BUDGET column (requires --by provider)
|
|
801
797
|
`;
|
|
798
|
+
|
|
799
|
+
class CliError extends Error {
|
|
800
|
+
}
|
|
802
801
|
function fail(msg) {
|
|
803
|
-
|
|
804
|
-
`);
|
|
805
|
-
process.exit(1);
|
|
806
|
-
throw new Error("unreachable");
|
|
802
|
+
throw new CliError(msg);
|
|
807
803
|
}
|
|
804
|
+
var GROUP_BYS = ["provider", "model", "day", "project", "agent"];
|
|
805
|
+
var VALUED_FLAGS = ["--since", "--by", "--limit"];
|
|
808
806
|
function parseArgs(argv) {
|
|
809
807
|
const cmd = argv[0];
|
|
810
808
|
if (!cmd || cmd.startsWith("-"))
|
|
@@ -820,69 +818,82 @@ ${USAGE}`);
|
|
|
820
818
|
const name = `--${key}`;
|
|
821
819
|
if (val !== undefined)
|
|
822
820
|
flags.set(name, val);
|
|
823
|
-
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") &&
|
|
821
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") && VALUED_FLAGS.includes(name)) {
|
|
824
822
|
flags.set(name, argv[++i]);
|
|
825
823
|
} else
|
|
826
824
|
flags.set(name, true);
|
|
827
825
|
}
|
|
828
826
|
return { cmd, flags };
|
|
829
827
|
}
|
|
828
|
+
function stringFlag(flags, name) {
|
|
829
|
+
const v = flags.get(name);
|
|
830
|
+
return typeof v === "string" ? v : undefined;
|
|
831
|
+
}
|
|
832
|
+
function sinceMsOf(flags) {
|
|
833
|
+
if (flags.has("--today"))
|
|
834
|
+
return startOfDayMs();
|
|
835
|
+
if (!flags.has("--since"))
|
|
836
|
+
return Date.now() - parseDuration("7d");
|
|
837
|
+
const v = stringFlag(flags, "--since");
|
|
838
|
+
if (v === undefined)
|
|
839
|
+
fail("--since needs a value like 7d");
|
|
840
|
+
return Date.now() - parseDuration(v);
|
|
841
|
+
}
|
|
830
842
|
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))
|
|
843
|
+
const sinceMs = sinceMsOf(flags);
|
|
844
|
+
const groupBy = stringFlag(flags, "--by") ?? "provider";
|
|
845
|
+
if (!GROUP_BYS.includes(groupBy))
|
|
839
846
|
fail(`invalid --by: ${groupBy}`);
|
|
840
847
|
const withPct = flags.has("--pct");
|
|
841
848
|
if (withPct && groupBy !== "provider")
|
|
842
849
|
fail("--pct only supported with --by provider");
|
|
843
850
|
const snapshot = await getUsageSnapshot(sinceMs, groupBy, withPct);
|
|
844
851
|
if (json) {
|
|
845
|
-
|
|
852
|
+
console.log(JSON.stringify({
|
|
846
853
|
since: new Date(sinceMs).toISOString(),
|
|
847
854
|
groupBy,
|
|
848
855
|
rows: snapshot.rows,
|
|
849
856
|
totals: snapshot.totals,
|
|
850
857
|
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));
|
|
858
|
+
}, null, 2));
|
|
859
|
+
return;
|
|
856
860
|
}
|
|
861
|
+
const label = flags.has("--today") ? "today" : stringFlag(flags, "--since") ?? "7d";
|
|
862
|
+
const pct = snapshot.pct ? new Map(Object.entries(snapshot.pct)) : undefined;
|
|
863
|
+
console.log(usageTable(snapshot.rows, snapshot.totals, label, groupBy, pct));
|
|
857
864
|
}
|
|
858
865
|
async function cmdProviders(flags, json) {
|
|
859
866
|
const statuses = await providerStatuses({ noNet: flags.has("--no-net") });
|
|
860
867
|
const db = openDb();
|
|
861
868
|
try {
|
|
862
869
|
const local = localUsageByProvider(db, Date.now() - parseDuration("7d"));
|
|
863
|
-
if (json)
|
|
870
|
+
if (json)
|
|
864
871
|
console.log(JSON.stringify({ statuses, local: Object.fromEntries(local) }, null, 2));
|
|
865
|
-
|
|
872
|
+
else
|
|
866
873
|
console.log(providersTable(statuses, local));
|
|
867
|
-
}
|
|
868
874
|
} finally {
|
|
869
875
|
db.close();
|
|
870
876
|
}
|
|
871
877
|
}
|
|
878
|
+
var TOP_HEADERS = ["MODEL", "NAME", "IQ", "CODING", "$/M", "IQ/$"];
|
|
879
|
+
var TOP_WIDTHS = [28, 26, 5, 7, 8, 6];
|
|
880
|
+
function topLine(cells) {
|
|
881
|
+
return cells.map((c, i) => c.length >= TOP_WIDTHS[i] ? c.slice(0, TOP_WIDTHS[i]) : c.padEnd(TOP_WIDTHS[i])).join(" ");
|
|
882
|
+
}
|
|
872
883
|
async function cmdTop(flags, json) {
|
|
873
|
-
const
|
|
884
|
+
const raw = stringFlag(flags, "--limit");
|
|
885
|
+
const limit = raw !== undefined ? Number(raw) : 20;
|
|
886
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
887
|
+
fail(`invalid --limit: ${raw}`);
|
|
874
888
|
const rows = await buildTop(limit);
|
|
875
889
|
if (json) {
|
|
876
890
|
console.log(JSON.stringify({ attribution: RANKING_ATTRIBUTION, models: rows }, null, 2));
|
|
877
891
|
return;
|
|
878
892
|
}
|
|
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(" "));
|
|
893
|
+
console.log(topLine(TOP_HEADERS));
|
|
894
|
+
console.log(TOP_WIDTHS.map((w) => "-".repeat(w)).join(" "));
|
|
884
895
|
for (const r of rows) {
|
|
885
|
-
console.log(
|
|
896
|
+
console.log(topLine([
|
|
886
897
|
r.model,
|
|
887
898
|
r.name,
|
|
888
899
|
r.intelligence !== undefined ? r.intelligence.toFixed(0) : "\u2014",
|
|
@@ -894,21 +905,28 @@ async function cmdTop(flags, json) {
|
|
|
894
905
|
console.log(`
|
|
895
906
|
${RANKING_ATTRIBUTION}`);
|
|
896
907
|
}
|
|
897
|
-
|
|
898
|
-
const { cmd, flags } = parseArgs(
|
|
908
|
+
async function run(argv) {
|
|
909
|
+
const { cmd, flags } = parseArgs(argv);
|
|
899
910
|
const json = flags.has("--json");
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
fail(`unknown command: ${cmd}
|
|
911
|
+
if (cmd === "usage")
|
|
912
|
+
await cmdUsage(flags, json);
|
|
913
|
+
else if (cmd === "providers")
|
|
914
|
+
await cmdProviders(flags, json);
|
|
915
|
+
else if (cmd === "top")
|
|
916
|
+
await cmdTop(flags, json);
|
|
917
|
+
else
|
|
918
|
+
fail(`unknown command: ${cmd}
|
|
909
919
|
|
|
910
920
|
${USAGE}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// packages/cli/src/cli.ts
|
|
924
|
+
if (import.meta.main) {
|
|
925
|
+
try {
|
|
926
|
+
await run(process.argv.slice(2));
|
|
911
927
|
} catch (err) {
|
|
912
|
-
|
|
928
|
+
console.error(`opencode-usage: ${err.message}
|
|
929
|
+
`);
|
|
930
|
+
process.exit(1);
|
|
913
931
|
}
|
|
914
932
|
}
|
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;
|
|
@@ -343,135 +354,130 @@ async function providerStatuses(opts) {
|
|
|
343
354
|
var TTL_MS2 = 24 * 60 * 60 * 1000;
|
|
344
355
|
// packages/core/src/budget.ts
|
|
345
356
|
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
|
-
}
|
|
357
|
+
|
|
394
358
|
// packages/core/src/limits.ts
|
|
395
359
|
var PROVIDER_LIMITS = [
|
|
396
360
|
{
|
|
397
|
-
provider: "
|
|
398
|
-
metric: "tokens/day",
|
|
399
|
-
limit: 200000,
|
|
361
|
+
provider: "cerebras",
|
|
400
362
|
unit: "tokens",
|
|
363
|
+
period: "day",
|
|
364
|
+
limit: 1e6,
|
|
401
365
|
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."
|
|
366
|
+
source: "https://cerebras.ai/",
|
|
367
|
+
note: "Free tier estimate. Paid tiers much higher."
|
|
413
368
|
},
|
|
414
369
|
{
|
|
415
370
|
provider: "cloudflare-workers-ai",
|
|
416
|
-
metric: "neurons/day",
|
|
417
|
-
limit: 1e5,
|
|
418
371
|
unit: "neurons",
|
|
372
|
+
period: "day",
|
|
373
|
+
limit: 1e5,
|
|
419
374
|
tier: "free",
|
|
420
375
|
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."
|
|
376
|
+
note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
|
|
431
377
|
},
|
|
432
378
|
{
|
|
433
379
|
provider: "digitalocean",
|
|
434
|
-
metric: "tokens/day",
|
|
435
|
-
limit: 5000000,
|
|
436
380
|
unit: "tokens",
|
|
381
|
+
period: "day",
|
|
382
|
+
limit: 5000000,
|
|
437
383
|
tier: "paid",
|
|
438
384
|
source: "https://docs.digitalocean.com/products/genai/",
|
|
439
385
|
note: "GenAI platform paid plans. Exact limits per model."
|
|
440
386
|
},
|
|
441
387
|
{
|
|
442
|
-
provider: "
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
tier: "
|
|
447
|
-
source: "https://
|
|
448
|
-
note: "
|
|
388
|
+
provider: "google",
|
|
389
|
+
unit: "requests",
|
|
390
|
+
period: "day",
|
|
391
|
+
limit: 1500,
|
|
392
|
+
tier: "free",
|
|
393
|
+
source: "https://ai.google.dev/gemini-api/docs/rate-limits",
|
|
394
|
+
note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
|
|
449
395
|
},
|
|
450
396
|
{
|
|
451
|
-
provider: "
|
|
452
|
-
metric: "tokens/day",
|
|
453
|
-
limit: 1e6,
|
|
397
|
+
provider: "groq",
|
|
454
398
|
unit: "tokens",
|
|
399
|
+
period: "day",
|
|
400
|
+
limit: 200000,
|
|
455
401
|
tier: "free",
|
|
456
|
-
source: "https://
|
|
457
|
-
note: "
|
|
402
|
+
source: "https://console.groq.com/docs/rate-limits",
|
|
403
|
+
note: "Also 30k tokens/minute. Paid tiers higher."
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
provider: "nvidia",
|
|
407
|
+
unit: "credits",
|
|
408
|
+
period: "month",
|
|
409
|
+
limit: 1000,
|
|
410
|
+
tier: "free",
|
|
411
|
+
source: "https://build.nvidia.com/",
|
|
412
|
+
note: "NIM free tier ~1000 credits/month. A credit varies by model."
|
|
458
413
|
},
|
|
459
414
|
{
|
|
460
415
|
provider: "orcarouter",
|
|
461
|
-
metric: "tokens/day",
|
|
462
|
-
limit: 0,
|
|
463
416
|
unit: "tokens",
|
|
417
|
+
period: "day",
|
|
418
|
+
limit: 0,
|
|
464
419
|
tier: "unknown",
|
|
465
420
|
source: "unknown",
|
|
466
421
|
note: "Proxy service. No public limits documented."
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
provider: "snowflake-cortex",
|
|
425
|
+
unit: "credits",
|
|
426
|
+
period: "month",
|
|
427
|
+
limit: 100,
|
|
428
|
+
tier: "paid",
|
|
429
|
+
source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
|
|
430
|
+
note: "Credits follow warehouse time, not tokens. Not measurable from usage."
|
|
467
431
|
}
|
|
468
432
|
];
|
|
433
|
+
var MEASURED_UNITS = new Set(["tokens", "requests"]);
|
|
434
|
+
function isMeasurable(limit) {
|
|
435
|
+
return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
|
|
436
|
+
}
|
|
437
|
+
function limitLabel(limit) {
|
|
438
|
+
return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
|
|
439
|
+
}
|
|
469
440
|
function limitsAsMap() {
|
|
470
441
|
const m = new Map;
|
|
471
442
|
for (const l of PROVIDER_LIMITS)
|
|
472
443
|
m.set(l.provider, l);
|
|
473
444
|
return m;
|
|
474
445
|
}
|
|
446
|
+
|
|
447
|
+
// packages/core/src/budget.ts
|
|
448
|
+
async function readBudgets() {
|
|
449
|
+
try {
|
|
450
|
+
return JSON.parse(await readFile3(budgetsPath(), "utf8"));
|
|
451
|
+
} catch {
|
|
452
|
+
return {};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function pctFromLimit(limit, usage) {
|
|
456
|
+
if (!isMeasurable(limit))
|
|
457
|
+
return;
|
|
458
|
+
const period = usage[limit.period];
|
|
459
|
+
const used = limit.unit === "requests" ? period.requests : period.tokens;
|
|
460
|
+
return used / limit.limit * 100;
|
|
461
|
+
}
|
|
462
|
+
function resolvePct(provider, sources) {
|
|
463
|
+
const live = sources.live.get(provider);
|
|
464
|
+
if (live?.budget && live.ok) {
|
|
465
|
+
return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
|
|
466
|
+
}
|
|
467
|
+
const budget = sources.budgets[provider];
|
|
468
|
+
if (budget !== undefined) {
|
|
469
|
+
if (budget <= 0)
|
|
470
|
+
return { pct: 0, source: "budgets" };
|
|
471
|
+
return { pct: sources.monthCost / budget * 100, source: "budgets" };
|
|
472
|
+
}
|
|
473
|
+
const limit = sources.limits.get(provider);
|
|
474
|
+
if (limit) {
|
|
475
|
+
const pct = pctFromLimit(limit, sources.usage);
|
|
476
|
+
if (pct !== undefined)
|
|
477
|
+
return { pct, source: "limits", label: limitLabel(limit) };
|
|
478
|
+
}
|
|
479
|
+
return { pct: 0, source: "none" };
|
|
480
|
+
}
|
|
475
481
|
// packages/core/src/snapshot.ts
|
|
476
482
|
async function withConnectedProviders(rows) {
|
|
477
483
|
let connected;
|
|
@@ -498,42 +504,57 @@ async function withConnectedProviders(rows) {
|
|
|
498
504
|
}));
|
|
499
505
|
return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
|
|
500
506
|
}
|
|
507
|
+
function periodUsage(usage, provider) {
|
|
508
|
+
const local = usage.get(provider);
|
|
509
|
+
return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
|
|
510
|
+
}
|
|
511
|
+
async function resolvePctByProvider(rows, day, month) {
|
|
512
|
+
const budgets = await readBudgets();
|
|
513
|
+
const live = new Map;
|
|
514
|
+
for (const status of await providerStatuses({ noNet: false })) {
|
|
515
|
+
if (status.quota)
|
|
516
|
+
live.set(status.provider, status.quota);
|
|
517
|
+
}
|
|
518
|
+
const limits = limitsAsMap();
|
|
519
|
+
const pct = {};
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
const resolved = resolvePct(row.provider, {
|
|
522
|
+
live,
|
|
523
|
+
budgets,
|
|
524
|
+
monthCost: month.get(row.provider)?.cost ?? 0,
|
|
525
|
+
usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
|
|
526
|
+
limits
|
|
527
|
+
});
|
|
528
|
+
if (resolved.pct > 0 || resolved.source !== "none")
|
|
529
|
+
pct[row.provider] = resolved;
|
|
530
|
+
}
|
|
531
|
+
return pct;
|
|
532
|
+
}
|
|
501
533
|
async function getUsageSnapshot(sinceMs, groupBy, includePct) {
|
|
502
534
|
const db = openDb();
|
|
503
535
|
try {
|
|
504
|
-
const
|
|
536
|
+
const grouped = usageSince(db, sinceMs, groupBy);
|
|
537
|
+
const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
|
|
505
538
|
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 };
|
|
539
|
+
if (!includePct)
|
|
540
|
+
return { rows, totals };
|
|
541
|
+
const day = localUsageByProvider(db, startOfDayMs());
|
|
542
|
+
const month = localUsageByProvider(db, startOfMonthMs());
|
|
543
|
+
return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
|
|
526
544
|
} finally {
|
|
527
545
|
db.close();
|
|
528
546
|
}
|
|
529
547
|
}
|
|
530
|
-
// packages/plugin/
|
|
531
|
-
import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
|
|
532
|
-
var COMMAND = "opencode-usage.show";
|
|
548
|
+
// packages/plugin/layout.ts
|
|
533
549
|
var SIZE_WIDTH = { medium: 60, large: 88, xlarge: 116 };
|
|
534
550
|
var PADDING = 1;
|
|
551
|
+
var WIDE_BAR = 12;
|
|
552
|
+
var COMPACT_BAR = 6;
|
|
553
|
+
var BUDGET_FIXED = WIDE_BAR + 6;
|
|
554
|
+
var WIDE_MIN = 80;
|
|
555
|
+
var BUDGET_MIN = 14;
|
|
535
556
|
function columnsFor(inner) {
|
|
536
|
-
const wide = inner >=
|
|
557
|
+
const wide = inner >= WIDE_MIN;
|
|
537
558
|
const base = wide ? [
|
|
538
559
|
{ title: "PROVIDER", width: 21, align: "left" },
|
|
539
560
|
{ title: "MSGS", width: 5, align: "right" },
|
|
@@ -548,7 +569,7 @@ function columnsFor(inner) {
|
|
|
548
569
|
{ title: "COST", width: 8, align: "right" }
|
|
549
570
|
];
|
|
550
571
|
const used = base.reduce((a, c) => a + c.width, 0) + base.length;
|
|
551
|
-
const budget = Math.max(
|
|
572
|
+
const budget = Math.max(BUDGET_MIN, inner - used);
|
|
552
573
|
return { cols: [...base, { title: "BUDGET", width: budget, align: "left" }], bar: wide ? WIDE_BAR : COMPACT_BAR };
|
|
553
574
|
}
|
|
554
575
|
function fmtTokens(n) {
|
|
@@ -569,9 +590,6 @@ function progressBar(pct, width) {
|
|
|
569
590
|
const filled = Math.round(Math.min(Math.max(pct, 0), 100) / 100 * width);
|
|
570
591
|
return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
|
|
571
592
|
}
|
|
572
|
-
var WIDE_BAR = 12;
|
|
573
|
-
var COMPACT_BAR = 6;
|
|
574
|
-
var BUDGET_FIXED = WIDE_BAR + 6;
|
|
575
593
|
function compactLabel(label) {
|
|
576
594
|
return label.replace(/\b\d[\d,]*\b/g, (n) => {
|
|
577
595
|
const v = Number(n.replace(/,/g, ""));
|
|
@@ -601,6 +619,13 @@ function windowSuffix(label) {
|
|
|
601
619
|
return "h";
|
|
602
620
|
return "";
|
|
603
621
|
}
|
|
622
|
+
function budgetTail(pct, label, colWidth, bar) {
|
|
623
|
+
const head = ` ${pct.toFixed(0).padStart(3)}% `;
|
|
624
|
+
const room = colWidth - bar - head.length;
|
|
625
|
+
const compact = compactLabel(label);
|
|
626
|
+
const text = compact.length <= room ? compact : windowSuffix(label);
|
|
627
|
+
return (head + text).slice(0, colWidth - bar);
|
|
628
|
+
}
|
|
604
629
|
function toHex(color, fallback) {
|
|
605
630
|
if (typeof color === "string")
|
|
606
631
|
return color;
|
|
@@ -611,22 +636,12 @@ function toHex(color, fallback) {
|
|
|
611
636
|
const hex = (v) => Math.round(Math.min(Math.max(v * scale, 0), 255)).toString(16).padStart(2, "0");
|
|
612
637
|
return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
|
|
613
638
|
}
|
|
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
|
-
}
|
|
639
|
+
var DANGER_PCT = 90;
|
|
640
|
+
var WARN_PCT = 70;
|
|
626
641
|
function barColor(pct, p) {
|
|
627
|
-
if (pct >=
|
|
642
|
+
if (pct >= DANGER_PCT)
|
|
628
643
|
return p.danger;
|
|
629
|
-
if (pct >=
|
|
644
|
+
if (pct >= WARN_PCT)
|
|
630
645
|
return p.warn;
|
|
631
646
|
return p.ok;
|
|
632
647
|
}
|
|
@@ -645,6 +660,23 @@ function widthNeeded(longestLabel) {
|
|
|
645
660
|
const base = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 1;
|
|
646
661
|
return base + BUDGET_FIXED + longestLabel;
|
|
647
662
|
}
|
|
663
|
+
|
|
664
|
+
// packages/plugin/tui.tsx
|
|
665
|
+
import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
|
|
666
|
+
var COMMAND = "opencode-usage.show";
|
|
667
|
+
var MAX_ROWS = 20;
|
|
668
|
+
function palette(api) {
|
|
669
|
+
const t = api.theme?.current;
|
|
670
|
+
return {
|
|
671
|
+
accent: toHex(t?.primary, "#a277ff"),
|
|
672
|
+
text: toHex(t?.text, "#e5e5e5"),
|
|
673
|
+
muted: toHex(t?.textMuted, "#8a8a8a"),
|
|
674
|
+
subtle: toHex(t?.borderSubtle, "#4a4a4a"),
|
|
675
|
+
ok: toHex(t?.success, "#4ade80"),
|
|
676
|
+
warn: toHex(t?.warning, "#facc15"),
|
|
677
|
+
danger: toHex(t?.error, "#f87171")
|
|
678
|
+
};
|
|
679
|
+
}
|
|
648
680
|
function terminalWidth(api) {
|
|
649
681
|
return api.renderer?.width ?? SIZE_WIDTH.medium;
|
|
650
682
|
}
|
|
@@ -652,6 +684,10 @@ function innerWidth(api, needed) {
|
|
|
652
684
|
const terminal = terminalWidth(api);
|
|
653
685
|
return Math.min(SIZE_WIDTH[chooseSize(terminal, needed)], terminal - 2) - PADDING * 2;
|
|
654
686
|
}
|
|
687
|
+
function neededWidth(snapshot) {
|
|
688
|
+
const labels = Object.values(snapshot.pct ?? {}).map((b) => compactLabel(b.label ?? b.source).length);
|
|
689
|
+
return widthNeeded(Math.max(0, ...labels));
|
|
690
|
+
}
|
|
655
691
|
function Frame(props) {
|
|
656
692
|
props.api.ui.dialog.setSize(chooseSize(terminalWidth(props.api), props.needed));
|
|
657
693
|
return /* @__PURE__ */ jsxs("box", {
|
|
@@ -680,13 +716,6 @@ function Frame(props) {
|
|
|
680
716
|
});
|
|
681
717
|
}
|
|
682
718
|
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
719
|
return /* @__PURE__ */ jsxs("box", {
|
|
691
720
|
flexDirection: "row",
|
|
692
721
|
children: [
|
|
@@ -698,22 +727,25 @@ function Budget(props) {
|
|
|
698
727
|
/* @__PURE__ */ jsx("text", {
|
|
699
728
|
fg: props.palette.muted,
|
|
700
729
|
wrapMode: "none",
|
|
701
|
-
children:
|
|
730
|
+
children: budgetTail(props.pct, props.label, props.col.width, props.bar)
|
|
702
731
|
})
|
|
703
732
|
]
|
|
704
733
|
});
|
|
705
734
|
}
|
|
706
|
-
function
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
const layout = () => columnsFor(innerWidth(props.api, needed));
|
|
710
|
-
const lead = (cols, r) => row(cols, [
|
|
735
|
+
function leadCells(cols, r) {
|
|
736
|
+
const upToBudget = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2;
|
|
737
|
+
return row(cols, [
|
|
711
738
|
r.provider,
|
|
712
739
|
String(r.messages),
|
|
713
740
|
fmtTokens(r.tokensInput),
|
|
714
741
|
fmtTokens(r.tokensOutput),
|
|
715
742
|
`$${r.cost.toFixed(2)}`
|
|
716
|
-
]).trimEnd().padEnd(
|
|
743
|
+
]).trimEnd().padEnd(upToBudget) + " ";
|
|
744
|
+
}
|
|
745
|
+
function Table(props) {
|
|
746
|
+
const p = palette(props.api);
|
|
747
|
+
const needed = neededWidth(props.snapshot);
|
|
748
|
+
const layout = () => columnsFor(innerWidth(props.api, needed));
|
|
717
749
|
return /* @__PURE__ */ jsxs(Frame, {
|
|
718
750
|
api: props.api,
|
|
719
751
|
palette: p,
|
|
@@ -724,7 +756,7 @@ function Table(props) {
|
|
|
724
756
|
wrapMode: "none",
|
|
725
757
|
children: row(layout().cols, layout().cols.map((c) => c.title))
|
|
726
758
|
}),
|
|
727
|
-
props.snapshot.rows.slice(0,
|
|
759
|
+
props.snapshot.rows.slice(0, MAX_ROWS).map((r) => {
|
|
728
760
|
const budget = props.snapshot.pct?.[r.provider];
|
|
729
761
|
return /* @__PURE__ */ jsxs("box", {
|
|
730
762
|
flexDirection: "row",
|
|
@@ -732,7 +764,7 @@ function Table(props) {
|
|
|
732
764
|
/* @__PURE__ */ jsx("text", {
|
|
733
765
|
fg: r.messages > 0 ? p.text : p.muted,
|
|
734
766
|
wrapMode: "none",
|
|
735
|
-
children:
|
|
767
|
+
children: leadCells(layout().cols, r)
|
|
736
768
|
}),
|
|
737
769
|
budget ? /* @__PURE__ */ jsx(Budget, {
|
|
738
770
|
pct: budget.pct,
|
|
@@ -829,5 +861,10 @@ export {
|
|
|
829
861
|
Message,
|
|
830
862
|
Table,
|
|
831
863
|
tui_default as default,
|
|
864
|
+
innerWidth,
|
|
865
|
+
neededWidth,
|
|
866
|
+
palette,
|
|
867
|
+
show,
|
|
868
|
+
terminalWidth,
|
|
832
869
|
tui
|
|
833
870
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1930dev/opencode-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Usage tracking, budget percentages and model ranking for opencode \u2014 CLI plus a /usage TUI plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
},
|
|
46
47
|
"files": [
|
|
47
48
|
"dist",
|
|
49
|
+
"LICENSE",
|
|
48
50
|
"README.md"
|
|
49
51
|
],
|
|
50
52
|
"publishConfig": {
|