@1930dev/opencode-usage 0.3.4 → 0.3.5

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.
Files changed (3) hide show
  1. package/README.md +21 -0
  2. package/dist/cli.js +119 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,6 +58,10 @@ opencode-usage usage --pct # add the % BUDGET column (--by provider
58
58
  opencode-usage providers
59
59
  opencode-usage providers --no-net # cached only
60
60
 
61
+ # What the providers themselves answer about their budget
62
+ opencode-usage probe # one free-model call per provider
63
+ opencode-usage probe --json
64
+
61
65
  # Model ranking by intelligence per blended dollar
62
66
  opencode-usage top
63
67
  opencode-usage top --limit 10 --json
@@ -161,6 +165,23 @@ Normalized percentage of budget consumed per provider, from these sources (in pr
161
165
 
162
166
  Providers without any source show `—`.
163
167
 
168
+ ## Free-model probe (`probe`)
169
+
170
+ `probe` sends one minimal request against a free model per connected provider
171
+ and reports what the answer reveals about the budget: a rate-limit header, a
172
+ "no credits" error, or the meter itself (`usage.neurons` for Cloudflare). It
173
+ reads the key from opencode's auth store and never prints it. Providers covered:
174
+
175
+ - `cloudflare-workers-ai` — the response carries neurons per request (the free
176
+ tier's unit), so the probe answers whether the account can still run
177
+ - `nvidia` — an HTTP 200 means the free per-model allowance still works
178
+ - `orcarouter` — the `free` model answers `rate_limit_error` with a
179
+ `retry-after`, and credits raise the cap
180
+ - `zai` — an HTTP 429 with `code 1113` means the balance is empty
181
+
182
+ `opencode` exposes no chat API for keys and `snowflake-cortex` is read through
183
+ its SQL quota, so both are skipped.
184
+
164
185
  ## Ranking (`top`)
165
186
 
166
187
  Models ranked by intelligence index per blended dollar:
package/dist/cli.js CHANGED
@@ -526,6 +526,15 @@ function fmtAgo(ms, now = Date.now()) {
526
526
  return `${h}h ago`;
527
527
  return `${Math.floor(h / 24)}d ago`;
528
528
  }
529
+ function fmtRetry(seconds) {
530
+ if (seconds < 60)
531
+ return `${seconds}s`;
532
+ if (seconds < 3600)
533
+ return `${Math.round(seconds / 60)}m`;
534
+ if (seconds < 86400)
535
+ return `${Math.ceil(seconds / 3600)}h`;
536
+ return `${Math.ceil(seconds / 86400)}d`;
537
+ }
529
538
  function pad(s, n) {
530
539
  return s.length >= n ? s : s + " ".repeat(n - s.length);
531
540
  }
@@ -578,6 +587,18 @@ function providersTable(statuses, local) {
578
587
  });
579
588
  return table(headers, rows);
580
589
  }
590
+ function probeTable(results) {
591
+ const headers = ["PROVIDER", "TAUGHT", "SIGNAL", "RETRY"];
592
+ const rows = results.map((r) => {
593
+ if (r.status === 0)
594
+ return [r.provider, "error", r.message ?? "no signal", "\u2014"];
595
+ const taught = r.ok ? "yes" : "no";
596
+ const signal = r.ok ? r.usage && Object.keys(r.usage).length > 0 ? Object.entries(r.usage).map(([k, v]) => `${k}=${v}`).join(", ") : "http ok" : r.message || (r.code ? `code ${r.code}` : `HTTP ${r.status}`);
597
+ const retry = r.retryAfterSeconds !== undefined ? fmtRetry(r.retryAfterSeconds) : "\u2014";
598
+ return [r.provider, taught, signal, retry];
599
+ });
600
+ return table(headers, rows);
601
+ }
581
602
  // packages/core/src/ranking/fetch.ts
582
603
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
583
604
  import path3 from "path";
@@ -875,6 +896,89 @@ function resolvePct(provider, sources) {
875
896
  }
876
897
  return { pct: 0, source: "none" };
877
898
  }
899
+ // packages/core/src/probe.ts
900
+ function numericUsage(u) {
901
+ if (typeof u !== "object" || u === null)
902
+ return;
903
+ const out = {};
904
+ for (const [k, v] of Object.entries(u)) {
905
+ if (typeof v === "number")
906
+ out[k] = v;
907
+ }
908
+ return Object.keys(out).length > 0 ? out : undefined;
909
+ }
910
+ function redact(text, key) {
911
+ return key.length > 6 ? text.split(key).join("[redacted]") : text;
912
+ }
913
+ var chatBody = (model) => JSON.stringify({ model, messages: [{ role: "user", content: "ping" }], max_tokens: 4 });
914
+ async function probeChat(p, key) {
915
+ let res;
916
+ try {
917
+ res = await fetch(p.url, {
918
+ method: "POST",
919
+ headers: {
920
+ "Content-Type": "application/json",
921
+ Accept: "application/json",
922
+ Authorization: `Bearer ${key}`
923
+ },
924
+ body: p.body
925
+ });
926
+ } catch (err) {
927
+ return { provider: p.name, status: 0, ok: false, message: redact(err.message, key) };
928
+ }
929
+ const text = await res.text();
930
+ let parsed = null;
931
+ try {
932
+ parsed = JSON.parse(text);
933
+ } catch {
934
+ parsed = null;
935
+ }
936
+ const obj = parsed ?? {};
937
+ const err = obj.error;
938
+ const firstErr = Array.isArray(obj.errors) ? obj.errors[0] : undefined;
939
+ const message = redact(err?.message ?? err?.detail ?? firstErr?.message ?? "", key);
940
+ const rawRetry = res.headers.get("retry-after");
941
+ const headerRetry = rawRetry !== null && rawRetry !== "" ? Number(rawRetry) : Number.NaN;
942
+ const retry = Number.isFinite(headerRetry) ? headerRetry : err?.metadata?.retry_after_seconds;
943
+ const usage = p.extractUsage ? p.extractUsage(obj) : numericUsage(obj.usage);
944
+ return {
945
+ provider: p.name,
946
+ status: res.status,
947
+ ok: res.status >= 200 && res.status < 300,
948
+ ...err?.code ?? firstErr?.code ? { code: err?.code ?? firstErr?.code } : {},
949
+ ...message ? { message } : {},
950
+ ...Number.isFinite(retry) ? { retryAfterSeconds: retry } : {},
951
+ ...usage ? { usage } : {}
952
+ };
953
+ }
954
+ var PROBERS = {
955
+ zai: (key) => probeChat({ name: "zai", url: "https://api.z.ai/api/paas/v4/chat/completions", body: chatBody("glm-4.5-air") }, key),
956
+ nvidia: (key) => probeChat({ name: "nvidia", url: "https://integrate.api.nvidia.com/v1/chat/completions", body: chatBody("meta/llama-3.2-11b-vision-instruct") }, key),
957
+ orcarouter: (key) => probeChat({ name: "orcarouter", url: "https://api.orcarouter.ai/v1/chat/completions", body: chatBody("orcarouter/free") }, key),
958
+ "cloudflare-workers-ai": (key, meta) => {
959
+ const account = String(meta?.accountId ?? "");
960
+ if (!account) {
961
+ return Promise.resolve({ provider: "cloudflare-workers-ai", status: 0, ok: false, message: "metadata.accountId is required" });
962
+ }
963
+ return probeChat({
964
+ name: "cloudflare-workers-ai",
965
+ url: `https://api.cloudflare.com/client/v4/accounts/${account}/ai/run/@cf/meta/llama-3.1-8b-fast-v2`,
966
+ body: JSON.stringify({ prompt: "ping", max_tokens: 4 }),
967
+ extractUsage: (parsed) => numericUsage(typeof parsed === "object" && parsed !== null && "result" in parsed ? parsed.result.usage : undefined)
968
+ }, key);
969
+ }
970
+ };
971
+ async function runProbes() {
972
+ const auth = await readAuth();
973
+ const results = await Promise.all(Object.entries(auth).sort().map(async ([name, entry]) => {
974
+ const prober = PROBERS[name];
975
+ const key = authSecret(entry);
976
+ if (!prober || !key)
977
+ return null;
978
+ return prober(key, entry.metadata);
979
+ }));
980
+ return results.filter((r) => r !== null);
981
+ }
878
982
  // packages/core/src/snapshot.ts
879
983
  async function withConnectedProviders(rows) {
880
984
  let connected;
@@ -948,6 +1052,7 @@ var USAGE = `opencode-usage \u2014 usage tracking and model ranking for opencode
948
1052
  USAGE
949
1053
  opencode-usage usage [--since 7d] [--by provider|model|day|project|agent] [--today] [--pct] [--json]
950
1054
  opencode-usage providers [--no-net] [--json]
1055
+ opencode-usage probe [--json]
951
1056
  opencode-usage top [--limit 20] [--json]
952
1057
 
953
1058
  OPTIONS
@@ -1037,6 +1142,18 @@ async function cmdProviders(flags, json) {
1037
1142
  db.close();
1038
1143
  }
1039
1144
  }
1145
+ async function cmdProbe(flags, json) {
1146
+ const results = await runProbes();
1147
+ if (json) {
1148
+ console.log(JSON.stringify({ probes: results }, null, 2));
1149
+ return;
1150
+ }
1151
+ const auth = await readAuth();
1152
+ const withoutProbe = Object.entries(auth).filter(([, e]) => e.key || e.access).map(([p]) => p).filter((p) => !["cloudflare-workers-ai", "nvidia", "orcarouter", "zai"].includes(p)).sort();
1153
+ console.log(probeTable(results));
1154
+ if (withoutProbe.length > 0)
1155
+ console.log(`no free-model probe: ${withoutProbe.join(", ")}`);
1156
+ }
1040
1157
  var TOP_HEADERS = ["MODEL", "NAME", "IQ", "CODING", "$/M", "IQ/$"];
1041
1158
  var TOP_WIDTHS = [28, 26, 5, 7, 8, 6];
1042
1159
  function topLine(cells) {
@@ -1074,6 +1191,8 @@ async function run(argv) {
1074
1191
  await cmdUsage(flags, json);
1075
1192
  else if (cmd === "providers")
1076
1193
  await cmdProviders(flags, json);
1194
+ else if (cmd === "probe")
1195
+ await cmdProbe(flags, json);
1077
1196
  else if (cmd === "top")
1078
1197
  await cmdTop(flags, json);
1079
1198
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1930dev/opencode-usage",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Usage tracking, budget percentages and model ranking for opencode — CLI plus a /usage TUI plugin",
5
5
  "type": "module",
6
6
  "bin": {