@1930dev/opencode-usage 0.1.5 → 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/README.md +19 -4
- package/dist/cli.js +185 -167
- package/dist/tui.js +192 -155
- package/package.json +3 -2
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)
|
|
@@ -123,17 +124,26 @@ Normalized percentage of budget consumed per provider, from these sources (in pr
|
|
|
123
124
|
- `openrouter`: credits used / total credits
|
|
124
125
|
- `zai`: coding plan quota
|
|
125
126
|
|
|
126
|
-
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.
|
|
127
131
|
- `cerebras`: 1M tokens/day (free tier)
|
|
128
|
-
- `cloudflare-workers-ai`: 100k neurons/day (free tier)
|
|
129
132
|
- `digitalocean`: 5M tokens/day (paid)
|
|
130
133
|
- `google`: 1500 requests/day (free tier)
|
|
131
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)
|
|
132
141
|
- `nvidia`: 1000 credits/month (free tier)
|
|
133
142
|
- `orcarouter`: undocumented
|
|
134
143
|
- `snowflake-cortex`: 100 credits/month (paid)
|
|
135
144
|
|
|
136
|
-
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:
|
|
137
147
|
```json
|
|
138
148
|
{
|
|
139
149
|
"digitalocean": 5,
|
|
@@ -186,9 +196,14 @@ bun install # install workspace deps
|
|
|
186
196
|
bun run build # write dist/tui.js and dist/cli.js
|
|
187
197
|
bun run preview # render the /usage dialog headless, at several widths
|
|
188
198
|
bun test # run tests
|
|
199
|
+
bun run coverage # run tests with the coverage gate
|
|
189
200
|
bunx tsc --noEmit # typecheck
|
|
190
201
|
```
|
|
191
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
|
+
|
|
192
207
|
The workspace is a Bun monorepo. The packages are listed by dependency order,
|
|
193
208
|
since `cli` and `plugin` both build on `core`:
|
|
194
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",
|