@jeffreyjyz/mpc 0.0.0-stage → 0.1.1

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 (71) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +269 -2
  3. package/package.json +31 -5
  4. package/src/cli/config.ts +79 -0
  5. package/src/cli/engine/ability.ts +50 -0
  6. package/src/cli/engine/cost.ts +62 -0
  7. package/src/cli/engine/index.ts +59 -0
  8. package/src/cli/engine/project.ts +145 -0
  9. package/src/cli/engine/rows.ts +64 -0
  10. package/src/cli/engine/score.ts +118 -0
  11. package/src/cli/flow/check.ts +66 -0
  12. package/src/cli/flow/collect.ts +76 -0
  13. package/src/cli/flow/columns.ts +32 -0
  14. package/src/cli/flow/sort.ts +68 -0
  15. package/src/cli/options.ts +78 -0
  16. package/src/cli/parse/cac.ts +114 -0
  17. package/src/cli/parse/fields.ts +65 -0
  18. package/src/cli/parse/map.ts +116 -0
  19. package/src/cli/parse/plugins.ts +67 -0
  20. package/src/cli/parse/validate.ts +60 -0
  21. package/src/cli/run.ts +151 -0
  22. package/src/constants/cli.ts +153 -0
  23. package/src/constants/data.ts +30 -0
  24. package/src/constants/scoring.ts +22 -0
  25. package/src/constants/shape.ts +19 -0
  26. package/src/constants/sources.ts +69 -0
  27. package/src/constants/view.ts +161 -0
  28. package/src/data/bench/cc.ts +26 -0
  29. package/src/data/bench/index.ts +126 -0
  30. package/src/data/bench/resolve.ts +107 -0
  31. package/src/data/bench/store.ts +169 -0
  32. package/src/data/bench/types.ts +34 -0
  33. package/src/data/cmduse.ts +43 -0
  34. package/src/data/scrape/catalog/catalog.ts +99 -0
  35. package/src/data/scrape/catalog/deal.ts +20 -0
  36. package/src/data/scrape/catalog/numeric.ts +30 -0
  37. package/src/data/scrape/catalog/variant.ts +11 -0
  38. package/src/data/scrape/index.ts +21 -0
  39. package/src/data/scrape/roleRows.ts +41 -0
  40. package/src/data/scrape/tables.ts +96 -0
  41. package/src/data/shape.ts +205 -0
  42. package/src/data/sources/aa/api.ts +54 -0
  43. package/src/data/sources/aa/parse.ts +66 -0
  44. package/src/data/sources/aa/web.ts +53 -0
  45. package/src/data/sources/cc/catalog.ts +71 -0
  46. package/src/data/sources/cc/cmduse.ts +24 -0
  47. package/src/data/sources/cc/plans.ts +29 -0
  48. package/src/data/sources/opencode.ts +48 -0
  49. package/src/data/usage/index.ts +118 -0
  50. package/src/data/usage/log.ts +89 -0
  51. package/src/data/usage/logs.ts +117 -0
  52. package/src/data/usage/opencodeDb.ts +135 -0
  53. package/src/data/usage/opencodeV2.ts +70 -0
  54. package/src/data/usage/parse.ts +85 -0
  55. package/src/index.ts +11 -0
  56. package/src/keys.ts +29 -0
  57. package/src/types.ts +92 -0
  58. package/src/view/columns/cc.ts +50 -0
  59. package/src/view/columns/meta.ts +41 -0
  60. package/src/view/columns/oc.ts +50 -0
  61. package/src/view/footer.ts +124 -0
  62. package/src/view/layout/fit.ts +58 -0
  63. package/src/view/layout/segments.ts +98 -0
  64. package/src/view/layout/table.ts +85 -0
  65. package/src/view/layout/usage.ts +163 -0
  66. package/src/view/render.ts +62 -0
  67. package/src/view/schema.ts +81 -0
  68. package/src/view/text/export.ts +72 -0
  69. package/src/view/text/format.ts +79 -0
  70. package/src/view/text/index.ts +54 -0
  71. package/src/view/text/styles.ts +151 -0
@@ -0,0 +1,205 @@
1
+ import {
2
+ SHAPE_AUTO,
3
+ SHAPE_GUARD_ENV,
4
+ SHAPE_MEASURED,
5
+ SHAPE_MIN_REQS,
6
+ SHAPE_OFF,
7
+ } from "~/constants/shape.ts";
8
+ import type { Workload } from "~/types.ts";
9
+ import { runBinary } from "./cmduse.ts";
10
+
11
+ interface Profile {
12
+ input?: number;
13
+ cacheRead?: number;
14
+ output?: number;
15
+ reasoning?: number;
16
+ cacheWrite?: number;
17
+ }
18
+
19
+ interface Payload {
20
+ profile?: Profile;
21
+ /** The full reqshape shape, which carries the sample size. */
22
+ shape?: { perReq?: Profile; reqs?: number };
23
+ }
24
+
25
+ /** reqshape to shell out to; override to test a dev build. */
26
+ function reqshapeBin(): string {
27
+ return process.env.REQSHAPE_BIN || "reqshape";
28
+ }
29
+
30
+ function message(error: unknown): string {
31
+ return error instanceof Error ? error.message : String(error);
32
+ }
33
+
34
+ function round(value: number | undefined): number {
35
+ return Math.round(typeof value === "number" ? value : 0);
36
+ }
37
+
38
+ function count(value: number): string {
39
+ return value.toLocaleString("en-US");
40
+ }
41
+
42
+ /** `1,234 reqs` / `1 req` — the sample size with the right noun. */
43
+ function reqWords(value: number): string {
44
+ return `${count(value)} ${value === 1 ? "req" : "reqs"}`;
45
+ }
46
+
47
+ /**
48
+ * The one measured per-req profile, as a Workload. reqshape emits it top-level
49
+ * (`profile` — the vector its own projections were priced against) and as
50
+ * `shape.perReq`; either works, and a payload with neither yields nothing
51
+ * rather than throwing. This is one shape for *both* plans, so the comparison
52
+ * isolates price and allowance from which traffic went where.
53
+ */
54
+ export function workloadOf(json: string): Workload | undefined {
55
+ const parsed = JSON.parse(json) as Payload;
56
+ const profile = parsed.profile ?? parsed.shape?.perReq;
57
+ if (!profile) return undefined;
58
+ // Means are fractional; a token count is not, and rounding here keeps the
59
+ // footer and the priced workload showing the same numbers.
60
+ return {
61
+ input: round(profile.input),
62
+ cacheRead: round(profile.cacheRead),
63
+ output: round(profile.output),
64
+ reasoning: round(profile.reasoning),
65
+ cacheWrite: round(profile.cacheWrite),
66
+ };
67
+ }
68
+
69
+ /** Reqs behind the profile, when the payload carries the count. */
70
+ export function reqsOf(json: string): number | undefined {
71
+ const reqs = (JSON.parse(json) as Payload).shape?.reqs;
72
+ return typeof reqs === "number" && Number.isFinite(reqs) ? reqs : undefined;
73
+ }
74
+
75
+ function safeWorkload(json: string): Workload | undefined {
76
+ try {
77
+ return workloadOf(json);
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /** The shape chosen for the run, plus the footer line that explains it. */
84
+ export interface ShapeResolution {
85
+ /** The measured per-req profile to price with; absent means the fixed workload. */
86
+ workload?: Workload;
87
+ /** One footer line: the source, its sample and the alternative. */
88
+ note: string;
89
+ }
90
+
91
+ async function payloadOf(
92
+ source: string,
93
+ args: string[],
94
+ warn: boolean,
95
+ ): Promise<string | null> {
96
+ if (source !== SHAPE_MEASURED) {
97
+ try {
98
+ return await Bun.file(source).text();
99
+ } catch (error) {
100
+ if (warn) {
101
+ process.stderr.write(
102
+ `mpc: cannot read shape file "${source}": ${message(error)}; using the fixed workload\n`,
103
+ );
104
+ }
105
+ return null;
106
+ }
107
+ }
108
+ const result = await runBinary(reqshapeBin(), args, {
109
+ [SHAPE_GUARD_ENV]: "1",
110
+ });
111
+ if (!result.ok || !result.stdout.trim()) {
112
+ if (warn) {
113
+ process.stderr.write(
114
+ `mpc: reqshape unavailable: ${result.stderr.trim() || "no output"}; using the fixed workload\n`,
115
+ );
116
+ }
117
+ return null;
118
+ }
119
+ return result.stdout;
120
+ }
121
+
122
+ /**
123
+ * Resolve `--shape`. `auto` (the default) asks reqshape and uses its profile
124
+ * only once the sample reaches `SHAPE_MIN_REQS`, so a thin history falls back
125
+ * to the documented fixed workload; `measured` forces reqshape; `off` keeps the
126
+ * fixed workload; anything else is a saved reqshape JSON. Never throws — a
127
+ * missing binary or a bad payload degrades to the fixed workload. The returned
128
+ * `note` names which path was taken, so the footer can show whether reqshape
129
+ * was used and what the alternative flag is.
130
+ */
131
+ export async function loadShapes(
132
+ spec: string,
133
+ since?: string,
134
+ ): Promise<ShapeResolution> {
135
+ const trimmed = spec.trim();
136
+ const args = ["--format", "json"];
137
+ if (since) args.push("--since", since);
138
+
139
+ // A nested mpc (reqshape runs `mpc --json`) must not measure again — that is
140
+ // the cycle. Callers that pass `--shape off` never reach here; this catches
141
+ // the ones that do not (a sibling still pinned to the old default).
142
+ if (
143
+ process.env[SHAPE_GUARD_ENV] &&
144
+ (trimmed === SHAPE_AUTO || trimmed === SHAPE_MEASURED)
145
+ ) {
146
+ return {
147
+ note: "fixed workload · nested mpc (reqshape is already resolving the shape)",
148
+ };
149
+ }
150
+
151
+ if (trimmed === "" || trimmed === SHAPE_OFF) {
152
+ return {
153
+ note: "fixed workload · --shape measured to measure with reqshape",
154
+ };
155
+ }
156
+
157
+ if (trimmed === SHAPE_AUTO) {
158
+ const payload = await payloadOf(SHAPE_MEASURED, args, false);
159
+ if (!payload) {
160
+ return {
161
+ note: "fixed workload · reqshape unavailable · --shape measured to require it",
162
+ };
163
+ }
164
+ const reqs = reqsOf(payload);
165
+ const workload = safeWorkload(payload);
166
+ if (!workload || reqs === undefined || reqs < SHAPE_MIN_REQS) {
167
+ const seen = reqs === undefined ? "no req count" : reqWords(reqs);
168
+ return {
169
+ note: `fixed workload · reqshape: ${seen} (< ${count(SHAPE_MIN_REQS)}) · --shape measured to force`,
170
+ };
171
+ }
172
+ return {
173
+ workload,
174
+ note: `reqshape measured · ${reqWords(reqs)} (auto ≥ ${count(SHAPE_MIN_REQS)}) · --shape off to assume the fixed workload`,
175
+ };
176
+ }
177
+
178
+ const measured = trimmed === SHAPE_MEASURED;
179
+ const payload = await payloadOf(
180
+ measured ? SHAPE_MEASURED : trimmed,
181
+ args,
182
+ true,
183
+ );
184
+ if (!payload) {
185
+ return {
186
+ note: "fixed workload · shape source unavailable · --shape auto to fall back automatically",
187
+ };
188
+ }
189
+ const workload = safeWorkload(payload);
190
+ if (!workload) {
191
+ if (measured) {
192
+ process.stderr.write(
193
+ "mpc: reqshape reports no measured profile; using the fixed workload\n",
194
+ );
195
+ }
196
+ return { note: "fixed workload · the payload has no measured profile" };
197
+ }
198
+ if (!measured) return { workload, note: `measured from ${trimmed}` };
199
+ const reqs = reqsOf(payload);
200
+ const sample = reqs === undefined ? "" : ` · ${reqWords(reqs)}`;
201
+ return {
202
+ workload,
203
+ note: `reqshape measured (forced)${sample} · --shape off to assume the fixed workload`,
204
+ };
205
+ }
@@ -0,0 +1,54 @@
1
+ import { API_URL } from "~/constants/sources.ts";
2
+ import { parseAaApi } from "./parse.ts";
3
+
4
+ /** Full catalog via the Artificial Analysis API. Needs a key. Paginated. */
5
+ export async function loadAaApi(
6
+ key: string,
7
+ ): Promise<{ intelligence: Map<string, number>; tps: Map<string, number> }> {
8
+ const intelligence = new Map<string, number>();
9
+ const tps = new Map<string, number>();
10
+ const pageSize = 200;
11
+ const maxPages = 20;
12
+
13
+ for (let page = 1; page <= maxPages; page++) {
14
+ const res = await fetchTextWithKey(
15
+ `${API_URL}?page=${page}&page_size=${pageSize}`,
16
+ key,
17
+ );
18
+ const body = JSON.parse(res) as unknown;
19
+ mergeAaPage(parseAaApi(body), intelligence, tps);
20
+ const pagination = (body as { pagination?: { has_more?: boolean } })
21
+ .pagination;
22
+ if (!pagination?.has_more) break;
23
+ }
24
+
25
+ return { intelligence, tps };
26
+ }
27
+
28
+ async function fetchTextWithKey(url: string, key: string): Promise<string> {
29
+ const res = await fetch(url, {
30
+ headers: { "x-api-key": key, "user-agent": "mpc/0.1" },
31
+ });
32
+ if (!res.ok) {
33
+ throw new Error(
34
+ `Artificial Analysis API ${res.status}: set AA_API_KEY or use --bench cc / aa-web`,
35
+ );
36
+ }
37
+ return res.text();
38
+ }
39
+
40
+ /** Keep the best variant per key; AA lists a row per reasoning effort. */
41
+ function mergeAaPage(
42
+ page: { intelligence: Map<string, number>; tps: Map<string, number> },
43
+ intelligence: Map<string, number>,
44
+ tps: Map<string, number>,
45
+ ): void {
46
+ for (const [key, value] of page.intelligence) {
47
+ if (!intelligence.has(key) || value > (intelligence.get(key) ?? 0)) {
48
+ intelligence.set(key, value);
49
+ }
50
+ }
51
+ for (const [key, value] of page.tps) {
52
+ if (!tps.has(key) || value > (tps.get(key) ?? 0)) tps.set(key, value);
53
+ }
54
+ }
@@ -0,0 +1,66 @@
1
+ import { normalizeKey } from "~/keys.ts";
2
+
3
+ /** Pure parser over one AA API page, for tests and the loader. */
4
+ export function parseAaApi(body: unknown): {
5
+ intelligence: Map<string, number>;
6
+ tps: Map<string, number>;
7
+ } {
8
+ const root = (body ?? {}) as Record<string, unknown>;
9
+ const rows = Array.isArray(root.data)
10
+ ? root.data
11
+ : Array.isArray(body)
12
+ ? body
13
+ : Object.values(root).filter((v) => v && typeof v === "object");
14
+ const intelligence = new Map<string, number>();
15
+ const tps = new Map<string, number>();
16
+
17
+ for (const row of rows) {
18
+ if (!row || typeof row !== "object") continue;
19
+ const record = row as Record<string, unknown>;
20
+ const name = String(record.name ?? record.slug ?? record.id ?? "");
21
+ const slug = typeof record.slug === "string" ? record.slug : undefined;
22
+ const keys = [
23
+ slug ? normalizeKey(slug) : "",
24
+ normalizeKey(name),
25
+ ].filter(Boolean);
26
+ if (keys.length === 0) continue;
27
+
28
+ const iq = findNumber(record, /intelligence_index/i);
29
+ if (iq !== null && iq > 0) {
30
+ for (const k of keys) {
31
+ if (!intelligence.has(k) || iq > (intelligence.get(k) ?? 0)) {
32
+ intelligence.set(k, iq);
33
+ }
34
+ }
35
+ }
36
+ // 0 means "not benchmarked", not "zero tokens per second".
37
+ const speed = findNumber(record, /tokens_per_second|(^|_)tps($|_)/i);
38
+ if (speed !== null && speed > 0) {
39
+ for (const k of keys) {
40
+ if (!tps.has(k) || speed > (tps.get(k) ?? 0)) tps.set(k, speed);
41
+ }
42
+ }
43
+ }
44
+ return { intelligence, tps };
45
+ }
46
+
47
+ /** Depth-first search for the first finite number under a matching key. */
48
+ function findNumber(value: unknown, pattern: RegExp): number | null {
49
+ if (!value || typeof value !== "object") return null;
50
+ for (const [key, child] of Object.entries(
51
+ value as Record<string, unknown>,
52
+ )) {
53
+ if (
54
+ typeof child === "number" &&
55
+ Number.isFinite(child) &&
56
+ pattern.test(key)
57
+ ) {
58
+ return child;
59
+ }
60
+ }
61
+ for (const child of Object.values(value as Record<string, unknown>)) {
62
+ const found = findNumber(child, pattern);
63
+ if (found !== null) return found;
64
+ }
65
+ return null;
66
+ }
@@ -0,0 +1,53 @@
1
+ import { AA_MODELS_URL } from "~/constants/sources.ts";
2
+ import { fetchText } from "~/data/scrape/index.ts";
3
+ import { normalizeKey } from "~/keys.ts";
4
+
5
+ /** A model label and its Artificial Analysis Intelligence Index. */
6
+ interface Scored {
7
+ label: string;
8
+ slug?: string;
9
+ score: number;
10
+ }
11
+
12
+ /** Every `{label, intelligenceIndex, detailsUrl}` the models page embeds. */
13
+ function parseEmbedded(html: string): Scored[] {
14
+ const re =
15
+ /"label":"([^"]+)","intelligenceIndex":([0-9.]+)(?:,"detailsUrl":"([^"]*)")?/g;
16
+ const out: Scored[] = [];
17
+ const seen = new Set<string>();
18
+ for (let match = re.exec(html); match !== null; match = re.exec(html)) {
19
+ const label = match[1] ?? "";
20
+ const score = Number(match[2]);
21
+ const slug = match[3]?.split("/").pop();
22
+ const id = `${label}|${slug ?? ""}`;
23
+ if (!label || !Number.isFinite(score) || seen.has(id)) continue;
24
+ seen.add(id);
25
+ out.push({ label, slug, score });
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function toMap(scored: Scored[]): Map<string, number> {
31
+ const scores = new Map<string, number>();
32
+ for (const { label, slug, score } of scored) {
33
+ // slug is the more precise key; label is the fallback.
34
+ if (slug) scores.set(normalizeKey(slug), score);
35
+ const labelKey = normalizeKey(label);
36
+ if (labelKey && !scores.has(labelKey)) scores.set(labelKey, score);
37
+ }
38
+ return scores;
39
+ }
40
+
41
+ /** Keyless scrape of the Artificial Analysis models page. Only the models that
42
+ * page embeds (its chart top-N) are covered, so this is a fallback, not a full
43
+ * catalog.
44
+ */
45
+ export async function loadAaWeb(): Promise<Map<string, number>> {
46
+ const html = await fetchText(AA_MODELS_URL);
47
+ return parseAaWeb(html);
48
+ }
49
+
50
+ /** Pure parser over the AA models page HTML, for tests and the scraper. */
51
+ export function parseAaWeb(html: string): Map<string, number> {
52
+ return toMap(parseEmbedded(html));
53
+ }
@@ -0,0 +1,71 @@
1
+ import { CC_PLANS } from "~/constants/sources.ts";
2
+ import {
3
+ extractCatalog,
4
+ fetchText,
5
+ parseRoleRows,
6
+ parseTables,
7
+ } from "~/data/scrape/index.ts";
8
+ import type { CatalogEntry } from "~/types.ts";
9
+
10
+ /** Per-model token rates + monthly credit allowance for a CommandCode plan. */
11
+ export async function loadCcCatalog(planId: string): Promise<CatalogEntry[]> {
12
+ const def = CC_PLANS[planId];
13
+ if (!def) throw new Error(`unknown CommandCode plan "${planId}"`);
14
+ const url = `https://commandcode.ai/docs/plans/${def.slug}`;
15
+ const html = await fetchText(url);
16
+
17
+ // The page shape is chosen by the plan, not inferred from the absence of a
18
+ // credit column. Go's list moved from a `role="row"` div grid to a real
19
+ // <table> while still publishing no per-model credits, so "no credit header"
20
+ // no longer means "grid" — inferring it sent Go down `parseRoleRows`, which
21
+ // matches no row, and the load threw.
22
+ const tables = def.grid
23
+ ? await parseRoleRows(html)
24
+ : await parseTables(html);
25
+ const entries = extractCatalog(tables, {
26
+ provider: "cc",
27
+ plan: def.label,
28
+ creditHeader: def.creditHeader,
29
+ // A rate-only list (Go) bills every model against the plan's whole pool.
30
+ defaultAllowance: def.standardAllowance,
31
+ });
32
+ if (entries.length === 0) {
33
+ const shape = def.grid ? "row" : "table";
34
+ throw new Error(
35
+ `no model ${shape}s parsed from ${url} — docs layout may have changed`,
36
+ );
37
+ }
38
+ if (def.creditHeader && def.standardAllowance !== undefined) {
39
+ fillDeals(
40
+ entries,
41
+ extractCatalog(tables, {
42
+ provider: "cc",
43
+ plan: def.label,
44
+ defaultAllowance: def.standardAllowance,
45
+ }),
46
+ );
47
+ }
48
+ return entries;
49
+ }
50
+
51
+ /**
52
+ * Fold the rate-only pass into the priced one: it adds models the credits
53
+ * tables omit entirely (the free ones), and lends its promotion badges to
54
+ * entries that came from a credits table — those carry no badges, so without
55
+ * this a paid model silently loses the deal published beside its name.
56
+ */
57
+ export function fillDeals(
58
+ entries: CatalogEntry[],
59
+ rateOnly: CatalogEntry[],
60
+ ): void {
61
+ const byKey = new Map(entries.map((entry) => [entry.key, entry]));
62
+ for (const entry of rateOnly) {
63
+ const existing = byKey.get(entry.key);
64
+ if (!existing) {
65
+ byKey.set(entry.key, entry);
66
+ entries.push(entry);
67
+ continue;
68
+ }
69
+ if (!existing.deal && entry.deal) existing.deal = entry.deal;
70
+ }
71
+ }
@@ -0,0 +1,24 @@
1
+ import { runCmduse } from "~/data/cmduse.ts";
2
+
3
+ interface CmdusePlan {
4
+ name: string;
5
+ price: string;
6
+ creditsMonthly: string;
7
+ fiveHour: string;
8
+ weekly: string;
9
+ }
10
+
11
+ export function money(value: string): number | null {
12
+ const m = value.match(/\$?\s*([0-9]+(?:\.[0-9]+)?)/);
13
+ return m ? Number(m[1]) : null;
14
+ }
15
+
16
+ export async function cmdusePlans(): Promise<CmdusePlan[]> {
17
+ const result = await runCmduse(["plans", "--json"]);
18
+ if (!result.ok) {
19
+ throw new Error(
20
+ `cmduse plans --json failed: ${result.stderr.trim() || "is the command-code CLI installed?"}`,
21
+ );
22
+ }
23
+ return JSON.parse(result.stdout) as CmdusePlan[];
24
+ }
@@ -0,0 +1,29 @@
1
+ import { CC_PLANS } from "~/constants/sources.ts";
2
+ import type { PlanInfo } from "~/types.ts";
3
+ import { cmdusePlans, money } from "./cmduse.ts";
4
+
5
+ /** Plan price + windows for a CommandCode plan, from the official JSON. */
6
+ export async function loadCcPlan(planId: string): Promise<PlanInfo> {
7
+ const def = CC_PLANS[planId];
8
+ if (!def) {
9
+ throw new Error(
10
+ `unknown CommandCode plan "${planId}" (have: ${Object.keys(CC_PLANS).join(", ")})`,
11
+ );
12
+ }
13
+ const plans = await cmdusePlans();
14
+ const match = plans.find((p) => p.name === def.cmduse);
15
+ if (!match) {
16
+ throw new Error(
17
+ `cmduse plans --json has no plan named "${def.cmduse}"`,
18
+ );
19
+ }
20
+ return {
21
+ provider: "cc",
22
+ id: planId,
23
+ label: def.label,
24
+ price: money(match.price) ?? 0,
25
+ credits: money(match.creditsMonthly) ?? 0,
26
+ fiveHour: money(match.fiveHour),
27
+ weekly: money(match.weekly),
28
+ };
29
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ DOC_URL,
3
+ OC_MODELS_URL,
4
+ PRICE_PER_MONTH,
5
+ } from "~/constants/sources.ts";
6
+ import { extractCatalog, fetchText, parseTables } from "~/data/scrape/index.ts";
7
+ import type { CatalogEntry, PlanInfo } from "~/types.ts";
8
+
9
+ /** Per-model token rates + monthly usage limit for OpenCode Go. */
10
+ export async function loadOcGoCatalog(peak = false): Promise<CatalogEntry[]> {
11
+ const html = await fetchText(DOC_URL);
12
+ const tables = await parseTables(html);
13
+ const entries = extractCatalog(tables, {
14
+ provider: "oc-go",
15
+ plan: "Go",
16
+ creditHeader: /monthly limit/i,
17
+ peak,
18
+ });
19
+ if (entries.length === 0) {
20
+ throw new Error(
21
+ `no model table parsed from ${DOC_URL} — docs layout may have changed`,
22
+ );
23
+ }
24
+ return entries;
25
+ }
26
+
27
+ /** OpenCode Go is a flat $10/mo; limits are per-model, so there is no shared pool. */
28
+ export function ocGoPlan(entries: CatalogEntry[]): PlanInfo {
29
+ return {
30
+ provider: "oc-go",
31
+ id: "go",
32
+ label: "Go",
33
+ price: PRICE_PER_MONTH,
34
+ credits: entries.reduce((sum, e) => sum + e.allowance, 0),
35
+ fiveHour: null,
36
+ weekly: null,
37
+ };
38
+ }
39
+
40
+ /** Live model ids from the Go endpoint, for drift detection. */
41
+ export async function loadOcGoModelIds(): Promise<string[]> {
42
+ const res = await fetch(OC_MODELS_URL, {
43
+ headers: { "user-agent": "mpc/0.1 (+model price compare)" },
44
+ });
45
+ if (!res.ok) throw new Error(`GET ${OC_MODELS_URL} -> ${res.status}`);
46
+ const body = (await res.json()) as { data?: { id: string }[] };
47
+ return (body.data ?? []).map((m) => m.id);
48
+ }
@@ -0,0 +1,118 @@
1
+ import { runCmduse } from "~/data/cmduse.ts";
2
+ import { defaultUsageLog, mergeUsage, readUsageLog } from "./log.ts";
3
+ import { scanLogs } from "./logs.ts";
4
+ import { defaultOpencodeDb, readOpencodeDb } from "./opencodeDb.ts";
5
+ import {
6
+ type AccountSummary,
7
+ accountSummary,
8
+ parseUsage,
9
+ type UsageEntry,
10
+ } from "./parse.ts";
11
+
12
+ export type { AccountSummary, UsageEntry } from "./parse.ts";
13
+
14
+ export interface UsageReportInput {
15
+ entries: UsageEntry[];
16
+ /** Where the numbers came from, for the header. */
17
+ label: string;
18
+ /** Window description, e.g. "period (since 2026-08-27)". */
19
+ window: string;
20
+ /** Account-level period totals, for the coverage cross-check. */
21
+ account?: AccountSummary;
22
+ }
23
+
24
+ export type UsageWindow = "period" | "all" | `${number}d`;
25
+
26
+ /**
27
+ * Per-model usage from cmduse. Needs a cmduse that understands `--since`
28
+ * (0.6.x with the window feature); returns null on an older build so the
29
+ * caller can fall back to scanning the session logs itself.
30
+ */
31
+ async function cmduseModel(since?: Date): Promise<UsageEntry[] | null> {
32
+ const args = ["model", "--json"];
33
+ if (since) args.push("--since", since.toISOString());
34
+ const result = await runCmduse(args);
35
+ if (!result.ok || !result.stdout.trim()) return null;
36
+ try {
37
+ return parseUsage(result.stdout);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function sinceFor(
44
+ window: UsageWindow,
45
+ account?: AccountSummary,
46
+ ): Date | undefined {
47
+ if (window === "all") return undefined;
48
+ if (window.endsWith("d")) {
49
+ const days = Number(window.slice(0, -1));
50
+ if (Number.isFinite(days) && days > 0) {
51
+ return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
52
+ }
53
+ }
54
+ if (account?.periodEnd) {
55
+ const end = new Date(account.periodEnd);
56
+ const start = new Date(end);
57
+ start.setMonth(start.getMonth() - 1);
58
+ return start;
59
+ }
60
+ return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
61
+ }
62
+
63
+ function label(window: UsageWindow, since?: Date): string {
64
+ if (window === "all") return "all local logs";
65
+ if (!since) return window;
66
+ return `${window} (since ${since.toISOString().slice(0, 10)})`;
67
+ }
68
+
69
+ /** Load the user's real per-model usage for a window. No network. */
70
+ export async function loadUsage(
71
+ source: string | undefined,
72
+ window: UsageWindow = "period",
73
+ logPath?: string,
74
+ dbPath?: string,
75
+ sessionRoot?: string,
76
+ ): Promise<UsageReportInput> {
77
+ if (source) {
78
+ const file = Bun.file(source);
79
+ if (!(await file.exists()))
80
+ throw new Error(`usage file not found: ${source}`);
81
+ return {
82
+ entries: parseUsage(await file.text()),
83
+ label: source,
84
+ window: "file",
85
+ };
86
+ }
87
+ const account = await accountSummary();
88
+ const since = sinceFor(window, account);
89
+ const fromCmduse = await cmduseModel(since);
90
+ const sessions = fromCmduse ?? scanLogs(since, sessionRoot);
91
+
92
+ // opencode's own store is complete and backfilled for every provider it
93
+ // ran, so it supersedes the provider plugin's log (a subset of it).
94
+ const db = readOpencodeDb(dbPath ?? defaultOpencodeDb(), since);
95
+ const log = db ? null : readUsageLog(logPath ?? defaultUsageLog(), since);
96
+
97
+ const sources: string[] = [];
98
+ if (db) {
99
+ sources.push(
100
+ `opencode db (${db.providers.length} providers, ${db.records} records)`,
101
+ );
102
+ }
103
+ if (log) sources.push("provider usage log");
104
+ if (sessions.length > 0) {
105
+ sources.push(fromCmduse ? "cmduse model --json" : "local session logs");
106
+ }
107
+
108
+ const entries = mergeUsage(
109
+ mergeUsage(db?.entries ?? [], log ?? []),
110
+ sessions,
111
+ );
112
+ return {
113
+ entries,
114
+ label: sources.join(" + ") || "no usage found",
115
+ window: label(window, since),
116
+ account,
117
+ };
118
+ }