@jeffreyjyz/mpc 0.1.2 → 0.1.4

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 CHANGED
@@ -31,6 +31,7 @@ mpc --help
31
31
  mpc # oc-go Go vs CommandCode GOAT, default workload
32
32
  mpc --cc-plan pro # compare against CommandCode Pro
33
33
  mpc --cc-plan go # ...or the $1 Go plan
34
+ mpc --oc-plan go-plus # OpenCode Go Plus ($40) instead of Go ($10)
34
35
  mpc --minimal # model + req/mo both sides + win + val
35
36
  mpc --medium # allowances + the rate views per side
36
37
  mpc --detail # every column, untrimmed (for copy/paste or agents)
@@ -88,6 +89,7 @@ Every flag persists. `mpc` reads `~/.config/mpc/config.json` (or `$XDG_CONFIG_HO
88
89
  ```json
89
90
  {
90
91
  "ccPlan": "goat",
92
+ "ocPlan": "go",
91
93
  "columns": ["model", "oc-per1k", "cc-per1k", "val"],
92
94
  "presets": { "cheap": ["model", "oc-per1k", "cc-per1k", "cost"] },
93
95
  "metric": "val",
@@ -115,6 +117,7 @@ Relative paths resolve against the config file's directory. A plugin is a `.json
115
117
  | Flag | Default | Meaning |
116
118
  | --- | --- | --- |
117
119
  | `--cc-plan <id>` | `goat` | CommandCode plan: `go`, `goat`, `pro`, `max10`, `max20` |
120
+ | `--oc-plan <id>` | `go` | OpenCode Go plan: `go`, `go-plus` (same token rates, higher per-model limits) |
118
121
  | `--in <n>` | `800` | fixed input tokens per request |
119
122
  | `--cache <n>` | `50000` | fixed cache-read tokens per request |
120
123
  | `--out <n>` | `200` | fixed output tokens per request |
@@ -136,7 +139,6 @@ Relative paths resolve against the config file's directory. A plugin is a `.json
136
139
  | `--bench-name <label>` | source | footer label for the ability source |
137
140
  | `--aa-key <key>` | `AA_API_KEY` | Artificial Analysis API key |
138
141
  | `--no-fallback` | off | skip filling ability misses from the other sources |
139
- | `--refresh` | off | ignore the `aa-web` cache |
140
142
  | `--no-ability` | off | hide `ability` and `VAL` |
141
143
  | `--preset <name>` | — | named column set from `presets` in config |
142
144
  | `--val-weights <a,b,c,d,e>` | see above | VAL weights (ability, tps, volume, cache, output) |
@@ -166,7 +168,7 @@ Relative paths resolve against the config file's directory. A plugin is a `.json
166
168
  | --- | --- |
167
169
  | `cmduse plans --json` | CommandCode plan price, credits, 5h/weekly windows |
168
170
  | `commandcode.ai/docs/plans/{goat,pro,max}` | per-model credits + token rates |
169
- | `opencode.ai/docs/go/` | oc-go per-model monthly limit + token rates |
171
+ | `opencode.ai/v2/docs/console/go/` | oc-go per-model monthly limit (per Go plan) + shared token rates |
170
172
  | `opencode.ai/zen/go/v1/models` | oc-go live model list (`--check` drift) |
171
173
 
172
174
  ## Reading the table
@@ -240,17 +242,18 @@ VAL = 100 * (0.35*ability + 0.10*tps + 0.25*volume + 0.15*cache + 0.15*output)
240
242
  | `cc` | CommandCode's `Intelligence` column | every matched model |
241
243
  | `file:<path>` / `url:<url>` | your JSON, `{ "model": score }` or `[{ model, score }]` | whatever you supply |
242
244
 
243
- The default is `aa` when an AA key is available (`AA_API_KEY` or `--aa-key`), otherwise `cc`; an explicit `--bench` always wins. After the lead source, misses are filled from the same benchmark's keyless page scrape (`aa-web`) and then from CommandCode, the last resort — `--no-fallback` disables the fills. Because CC's `Intelligence` column and AA publish the same index, a `cc`-led run asks AA only when it still lacks throughput (CC's plan pages no longer publish `Tok/s`). The `aa-web` result is cached under `$XDG_CACHE_HOME/mpc/` (or `~/.cache/mpc/`) for 7 days; `--refresh` busts it.
245
+ The default is `aa` when an AA key is available (`AA_API_KEY` or `--aa-key`), otherwise `cc`; an explicit `--bench` always wins. After the lead source, misses are filled from the same benchmark's keyless page scrape (`aa-web`) and then from CommandCode, the last resort — `--no-fallback` disables the fills. Because CC's `Intelligence` column and AA publish the same index, a `cc`-led run asks AA only when it still lacks throughput (CC's plan pages no longer publish `Tok/s`). Everything is fetched live, every run — **`mpc` writes no cache**: `aa-web` reads the index and speed straight out of the models page's embedded React Server Components payload, so a source that is down costs only the columns it alone supplies, never a stale value.
244
246
 
245
247
  ## Model matching
246
248
 
247
- Names from the two catalogs are collapsed onto one canonical key (lowercase, vendor prefix stripped, punctuation removed, parenthetical qualifiers dropped), with a small alias table for branding differences (`Tencent Hy3` ↔ `hy3`, `…Vision (exp)` ↔ `…vision-exp`). Models present on only one side still appear; the other column shows `—`.
249
+ Names from the two catalogs are collapsed onto one canonical key (lowercase, vendor prefix stripped, punctuation removed, parenthetical qualifiers dropped), with a small alias table for branding differences (`Tencent Hy3` ↔ `hy3`, `…Vision (exp)` ↔ `…vision-exp`). A trailing `Free` marker is not a branding alias — it is stripped by a generic rule, since the docs append it to every free model. Models present on only one side still appear; the other column shows `—`.
248
250
 
249
251
  ## Notes and limits
250
252
 
251
253
  - CommandCode plans are read from their docs pages: `goat`, `pro` and the Max plans list explicit per-model credits; the **Go** ($1) plan publishes only a rate list, so every model draws on the plan's whole $10 credit pool.
252
254
  - Models CommandCode lists with rates but no explicit credits row (the "older models also available" set) use the documented standard allowance ($20 on GOAT, $30 on Pro).
253
- - OpenCode Go has no shared credit pool; each model carries its own monthly limit, so the plan's "credits" figure is the sum of those limits (an upper bound, not a pool).
255
+ - OpenCode sells two Go plans — **Go** ($10/mo) and **Go Plus** ($40/mo) — with identical token rates but different per-model monthly limits; `--oc-plan` picks which. Go has no shared credit pool, so the plan's "credits" figure is the sum of the per-model limits (an upper bound, not a pool).
256
+ - OpenCode Go's free models (`LongCat 2.5 Preview Free`, `Step 5 Preview Free`) publish an `Unlimited` monthly limit instead of a $ figure. They are included as free entries — `$0` rates, unbounded `req/mo` — and, since their allowance is meaningless at zero cost, they add nothing to the plan's summed credits. Their docs-only `… Free` suffix is stripped by that generic rule (the marker is systematic, never a per-model alias), so a variant merges onto the model's one row instead of appearing twice.
254
257
  - `$/1K` and `req/$` are plan-relative: they divide by the plan's own price, so a cheaper subscription can post a lower per-request cost while buying fewer requests. Compare `req/mo` for volume and `$/1K` for the effective rate.
255
258
  - Values reflect the docs at fetch time; active deals are picked up automatically.
256
259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffreyjyz/mpc",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Compare model pricing across opencode Go and Command Code plans",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,7 @@ function scoreConfig(options: Options): ScoreConfig {
21
21
 
22
22
  export async function collect(options: Options) {
23
23
  const [ocEntries, ccEntries, ccPlanInfo, shape] = await Promise.all([
24
- loadOcGoCatalog(options.peak),
24
+ loadOcGoCatalog(options.ocPlan, options.peak),
25
25
  loadCcCatalog(options.ccPlan),
26
26
  loadCcPlan(options.ccPlan),
27
27
  loadShapes(options.shape, options.since),
@@ -50,9 +50,8 @@ export async function collect(options: Options) {
50
50
  key: options.aaKey,
51
51
  keys,
52
52
  fallback: !options.noFallback,
53
- refresh: options.refresh,
54
53
  });
55
- const ocPlanInfo = ocGoPlan(ocEntries);
54
+ const ocPlanInfo = ocGoPlan(ocEntries, options.ocPlan);
56
55
  const rows = buildRows(
57
56
  ocEntries,
58
57
  ccEntries,
@@ -8,6 +8,7 @@ export type Format = "table" | "json" | "csv" | "md";
8
8
 
9
9
  export interface Options {
10
10
  ccPlan: string;
11
+ ocPlan: string;
11
12
  workload: Workload;
12
13
  /** `off` (the default), `measured`, or a path to a saved reqshape JSON. */
13
14
  shape: string;
@@ -36,7 +37,6 @@ export interface Options {
36
37
  benchName?: string;
37
38
  aaKey?: string;
38
39
  noFallback: boolean;
39
- refresh: boolean;
40
40
  noAbility: boolean;
41
41
  peak: boolean;
42
42
  asc: boolean;
@@ -10,6 +10,7 @@ export function parseFlags(argv: string[]): Bag {
10
10
  "--cc-plan <id>",
11
11
  "CommandCode plan: go, goat, pro, max10, max20",
12
12
  )
13
+ .option("--oc-plan <id>", "OpenCode Go plan: go, go-plus")
13
14
  .option("--in <n>", "input tokens per request")
14
15
  .option("--cache <n>", "cache-read tokens per request")
15
16
  .option("--out <n>", "output tokens per request")
@@ -66,7 +67,6 @@ export function parseFlags(argv: string[]): Bag {
66
67
  .option("--bench-name <label>", "footer label for the source")
67
68
  .option("--aa-key <key>", "Artificial Analysis key (else AA_API_KEY)")
68
69
  .option("--no-fallback", "do not fill misses from other sources")
69
- .option("--refresh", "ignore the aa-web cache")
70
70
  .option("--no-ability", "hide ability and VAL")
71
71
  .option("--peak", "peak-rate rows (OpenCode DeepSeek)")
72
72
  .option("--asc", "flip the default sort direction")
@@ -40,6 +40,7 @@ export function toOptions(bag: Bag): Options {
40
40
  const window = numbers(bag, "window");
41
41
  return {
42
42
  ccPlan: String(pick(bag, "ccPlan")),
43
+ ocPlan: String(pick(bag, "ocPlan")),
43
44
  workload: {
44
45
  input: int("in", pick(bag, "in")),
45
46
  cacheRead: int("cache", pick(bag, "cache")),
@@ -92,7 +93,6 @@ export function toOptions(bag: Bag): Options {
92
93
  bag.benchName === undefined ? undefined : String(bag.benchName),
93
94
  aaKey: bag.aaKey === undefined ? undefined : String(bag.aaKey),
94
95
  noFallback: bag.fallback === false,
95
- refresh: bag.refresh === true,
96
96
  noAbility: bag.ability === false,
97
97
  peak: bag.peak === true,
98
98
  asc: bag.asc === true,
package/src/cli/run.ts CHANGED
@@ -4,6 +4,7 @@ import { loadUsage } from "#~/data/usage/index.ts";
4
4
  import { renderUsage } from "#~/view/layout/usage.ts";
5
5
  import {
6
6
  fitColumns,
7
+ planTitle,
7
8
  renderCsv,
8
9
  renderJson,
9
10
  renderMarkdown,
@@ -145,7 +146,7 @@ export async function run(argv: string[]): Promise<number> {
145
146
  : { ids: requested, dropped: [] as string[] };
146
147
  renderText(result, meta, fitted.ids, fitted.dropped);
147
148
  console.log(
148
- `\n${result.length} models · OpenCode Go vs CommandCode ${ccPlanInfo.label} · ${ocEntries.length} OpenCode / ${ccEntries.length} CommandCode entries`,
149
+ `\n${result.length} models · ${planTitle(ocPlanInfo)} vs CommandCode ${ccPlanInfo.label} · ${ocEntries.length} OpenCode / ${ccEntries.length} CommandCode entries`,
149
150
  );
150
151
  return 0;
151
152
  }
@@ -1,5 +1,6 @@
1
1
  import type { Format, Metric } from "#~/cli/options.ts";
2
2
  import { SHAPE_AUTO } from "#~/constants/shape.ts";
3
+ import { DEFAULT_OC_PLAN } from "#~/constants/sources.ts";
3
4
  import {
4
5
  DETAIL_COLUMNS,
5
6
  MEDIUM_COLUMNS,
@@ -23,6 +24,7 @@ export const BENCH_DEFAULT = { keyed: "aa", keyless: "cc" } as const;
23
24
  /** The raw CLI bag defaults, before config/plugin layering. */
24
25
  export const ARG_DEFAULTS = {
25
26
  ccPlan: "goat",
27
+ ocPlan: DEFAULT_OC_PLAN,
26
28
  in: 800,
27
29
  cache: 50_000,
28
30
  out: 200,
@@ -49,6 +51,7 @@ export const INTERNAL = new Set(["--", "help", "h", "version", "v"]);
49
51
 
50
52
  export const KNOWN = new Set([
51
53
  "ccPlan",
54
+ "ocPlan",
52
55
  "in",
53
56
  "cache",
54
57
  "out",
@@ -66,7 +69,6 @@ export const KNOWN = new Set([
66
69
  "benchName",
67
70
  "aaKey",
68
71
  "fallback",
69
- "refresh",
70
72
  "ability",
71
73
  "peak",
72
74
  "asc",
@@ -110,6 +112,7 @@ export const KNOWN = new Set([
110
112
  */
111
113
  export const VALUED = new Set([
112
114
  "ccPlan",
115
+ "ocPlan",
113
116
  "in",
114
117
  "cache",
115
118
  "out",
@@ -23,6 +23,9 @@ export const ALIASES: Record<string, string> = {
23
23
  qwen38flashnext: "qwen38flash",
24
24
  };
25
25
 
26
+ /** The monthly-limit cell of a free model: "Unlimited", often with a note. */
27
+ export const UNLIMITED = /\bunlimited\b/i;
28
+
26
29
  /** The chip text of a promotion: "-40%", "-98%", "Free", "2x usage". */
27
30
  export const BADGE = /^-?\d+(?:\.\d+)?%$|^free$|^\d+x(?:\s+usage)?$/i;
28
31
 
@@ -51,9 +51,33 @@ export const CC_PLANS: Record<string, CcPlanDef> = {
51
51
  },
52
52
  };
53
53
 
54
- export const DOC_URL = "https://opencode.ai/docs/go/";
54
+ /** One OpenCode Go plan: a docs tab label and its monthly subscription price. */
55
+ interface OcPlanDef {
56
+ /** Docs tab label; the discriminator between the two plans' tables. */
57
+ label: string;
58
+ /** Subscription USD per month. */
59
+ price: number;
60
+ }
61
+
62
+ const GO_PRICE_PER_MONTH = 10;
63
+ const GO_PLUS_PRICE_PER_MONTH = 40;
64
+
65
+ /**
66
+ * OpenCode Go has two plans sharing one token price list but granting each
67
+ * model a different monthly limit. The docs put each plan's allowance table in
68
+ * a `<TabItem label="…">`; the tab label is what tells the otherwise identical
69
+ * tables apart, so it doubles as the plan's identity here.
70
+ */
71
+ export const OC_PLANS: Record<string, OcPlanDef> = {
72
+ go: { label: "Go", price: GO_PRICE_PER_MONTH },
73
+ "go-plus": { label: "Go Plus", price: GO_PLUS_PRICE_PER_MONTH },
74
+ };
75
+
76
+ /** The Go plan used when `--oc-plan` is not given. */
77
+ export const DEFAULT_OC_PLAN = "go";
78
+
79
+ export const DOC_URL = "https://opencode.ai/v2/docs/console/go/";
55
80
  export const OC_MODELS_URL = "https://opencode.ai/zen/go/v1/models";
56
- export const PRICE_PER_MONTH = 10;
57
81
 
58
82
  export const API_URL = "https://artificialanalysis.ai/api/v2/data/llms/models";
59
83
  export const AA_MODELS_URL = "https://artificialanalysis.ai/models";
@@ -64,6 +88,3 @@ export const PAGES = [
64
88
  "https://commandcode.ai/docs/plans/pro",
65
89
  ];
66
90
  export const TPS_HEADER = /tok\s*\/?\s*s|tokens?\s*per\s*sec/i;
67
-
68
- /** How long a cached benchmark result is considered fresh, in milliseconds. */
69
- export const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1,6 +1,6 @@
1
+ import { loadAaWeb } from "#~/data/sources/aa/web.ts";
1
2
  import { loadCc } from "./cc.ts";
2
- import { loadAaFallback, loadAaWeb, resolvePrimary } from "./resolve.ts";
3
- import { fillFromCache, readAbilityCache, writeAbilityCache } from "./store.ts";
3
+ import { loadAaFallback, resolvePrimary } from "./resolve.ts";
4
4
  import type { AbilityOptions, AbilityResult } from "./types.ts";
5
5
 
6
6
  export type {
@@ -32,65 +32,31 @@ function prune(map: Map<string, number>, keys?: Set<string>): void {
32
32
  }
33
33
 
34
34
  /**
35
- * Load benchmark scores. The chosen source leads; unless --no-fallback, any
36
- * model it misses is filled from the other sources so a model scored anywhere
37
- * shows a value. The whole result is cached: a source that is down — or a page
38
- * that silently dropped a column — falls back to the last good run rather than
39
- * blanking a column.
35
+ * Load benchmark scores, live, every run. The chosen source leads; unless
36
+ * --no-fallback, any model it misses is filled from the other sources so a
37
+ * model scored anywhere shows a value. Nothing is cached: a source that is down
38
+ * costs the columns it alone supplies, never a stale value.
40
39
  */
41
40
  export async function loadAbility(
42
41
  opts: AbilityOptions,
43
42
  ): Promise<AbilityResult> {
44
- let live: AbilityResult;
45
- try {
46
- live = await loadLiveAbility(opts);
47
- } catch (error) {
48
- // Refresh must not strand the caller: an outage still has the cache.
49
- const cached = await readAbilityCache(false);
50
- if (!cached) throw error;
51
- return {
52
- ...cached,
53
- label: "cached (benchmark sources unavailable)",
54
- note:
55
- error instanceof Error
56
- ? error.message.slice(0, 120)
57
- : undefined,
58
- };
59
- }
60
- const cached = await readAbilityCache(Boolean(opts.refresh));
61
- const filled = cached ? fillFromCache(live, cached) : 0;
62
- await writeAbilityCache(live);
63
- if (filled === 0) return live;
64
- return {
65
- ...live,
66
- note: [live.note, `${filled} values from the last cached run`]
67
- .filter(Boolean)
68
- .join(", "),
69
- };
70
- }
71
-
72
- async function loadLiveAbility(opts: AbilityOptions): Promise<AbilityResult> {
73
43
  const primary = await resolvePrimary(opts);
74
44
  // CommandCode and Artificial Analysis publish the same intelligence index, so
75
45
  // a CC primary normally needs no second round-trip. That premise only covered
76
46
  // the index though: CC's plan pages dropped their `Tok/s` column (Sep 2026),
77
- // so a CC primary with no speed values has to ask AA after all — otherwise
78
- // every model reports speed null and the consumers drop the row.
47
+ // so a CC primary with no speed values has to ask AA after all.
79
48
  const needsSpeed = primary.data.tps.size === 0;
80
49
  if (!opts.fallback || (primary.scheme === "cc" && !needsSpeed)) {
81
50
  return { ...primary.data, label: primary.label };
82
51
  }
83
52
 
84
53
  const fills: string[] = [];
85
- // The API leads, so give the same benchmark's keyless page scrape a second
86
- // look (cached, free when warm) before the cross-source fallback.
54
+ // The API leads, so give the same benchmark's keyless page scrape a second look.
87
55
  if (primary.scheme === "aa") {
88
- const web = await loadAaWeb(opts);
89
- const added = merge(
90
- primary.data.intelligence,
91
- web.intelligence,
92
- opts.keys,
93
- );
56
+ const web = await loadAaWeb();
57
+ const added =
58
+ merge(primary.data.intelligence, web.intelligence, opts.keys) +
59
+ merge(primary.data.tps, web.tps, opts.keys);
94
60
  if (added > 0) fills.push(`${added} values from AA web scrape`);
95
61
  }
96
62
  {
@@ -1,12 +1,44 @@
1
+ import { fetchText } from "#~/data/scrape/index.ts";
1
2
  import { loadAaApi } from "#~/data/sources/aa/api.ts";
3
+ import { loadAaWeb } from "#~/data/sources/aa/web.ts";
4
+ import { normalizeKey } from "#~/keys.ts";
2
5
  import { loadCc } from "./cc.ts";
3
- import { loadAaWebCached, loadFile, loadUrl } from "./store.ts";
4
- import {
5
- type AbilityOptions,
6
- type BenchData,
7
- emptyData,
8
- type Resolved,
9
- } from "./types.ts";
6
+ import type { AbilityOptions, BenchData, Resolved } from "./types.ts";
7
+
8
+ /** Parse `{ model: score }` or `[{ model|name, score|value }]` JSON. */
9
+ function parseJsonScores(text: string): Map<string, number> {
10
+ const body = JSON.parse(text) as unknown;
11
+ const scores = new Map<string, number>();
12
+ const add = (name: unknown, score: unknown): void => {
13
+ const key = typeof name === "string" ? normalizeKey(name) : "";
14
+ const value = Number(score);
15
+ if (key && Number.isFinite(value)) scores.set(key, value);
16
+ };
17
+ if (Array.isArray(body)) {
18
+ for (const row of body) {
19
+ const record = (row ?? {}) as Record<string, unknown>;
20
+ add(record.model ?? record.name, record.score ?? record.value);
21
+ }
22
+ } else if (body && typeof body === "object") {
23
+ for (const [name, score] of Object.entries(
24
+ body as Record<string, unknown>,
25
+ )) {
26
+ add(name, score);
27
+ }
28
+ }
29
+ return scores;
30
+ }
31
+
32
+ async function loadFile(path: string): Promise<Map<string, number>> {
33
+ const file = Bun.file(path);
34
+ if (!(await file.exists()))
35
+ throw new Error(`bench file not found: ${path}`);
36
+ return parseJsonScores(await file.text());
37
+ }
38
+
39
+ async function loadUrl(url: string): Promise<Map<string, number>> {
40
+ return parseJsonScores(await fetchText(url));
41
+ }
10
42
 
11
43
  export async function resolvePrimary(opts: AbilityOptions): Promise<Resolved> {
12
44
  const [scheme, arg] = opts.source.includes(":")
@@ -23,15 +55,12 @@ export async function resolvePrimary(opts: AbilityOptions): Promise<Resolved> {
23
55
  data: await loadCc(),
24
56
  label: "CommandCode Intelligence",
25
57
  };
26
- case "aa-web": {
27
- const data = emptyData();
28
- data.intelligence = await loadAaWebCached(Boolean(opts.refresh));
58
+ case "aa-web":
29
59
  return {
30
60
  scheme: "aa-web",
31
- data,
61
+ data: await loadAaWeb(),
32
62
  label: "Artificial Analysis (web, partial)",
33
63
  };
34
- }
35
64
  case "aa": {
36
65
  const key = opts.key ?? process.env.AA_API_KEY;
37
66
  if (!key) {
@@ -72,15 +101,6 @@ export async function resolvePrimary(opts: AbilityOptions): Promise<Resolved> {
72
101
  }
73
102
  }
74
103
 
75
- /**
76
- * Keyless Artificial Analysis page scrape (partial — only the models AA embeds).
77
- */
78
- export async function loadAaWeb(opts: AbilityOptions): Promise<BenchData> {
79
- const data = emptyData();
80
- data.intelligence = await loadAaWebCached(Boolean(opts.refresh));
81
- return data;
82
- }
83
-
84
104
  /**
85
105
  * Artificial Analysis as a gap filler: the full API when a key is available,
86
106
  * otherwise the keyless page scrape (partial).
@@ -96,7 +116,7 @@ export async function loadAaFallback(
96
116
  // fall through to the scrape
97
117
  }
98
118
  }
99
- return { data: await loadAaWeb(opts), how: "AA web scrape" };
119
+ return { data: await loadAaWeb(), how: "AA web scrape" };
100
120
  }
101
121
 
102
122
  export type {
@@ -20,7 +20,6 @@ export interface AbilityOptions {
20
20
  keys?: Set<string>;
21
21
  /** Fill models the primary source misses from the other sources. */
22
22
  fallback?: boolean;
23
- refresh?: boolean;
24
23
  }
25
24
 
26
25
  export interface Resolved {
@@ -1,3 +1,4 @@
1
+ import { UNLIMITED } from "#~/constants/data.ts";
1
2
  import {
2
3
  cellText,
3
4
  headerIndex,
@@ -71,13 +72,27 @@ export function extractCatalog(
71
72
  const rawName = nameCell(cells[0] ?? "");
72
73
  const deal = dealIn(cells[0] ?? "");
73
74
  if (!rawName) continue;
75
+ // A free model's limit cell reads "Unlimited", not a $ figure, so
76
+ // parseMoney returns null and the row used to be dropped entirely.
77
+ // Its allowance is meaningless (cost is 0), so record 0 — that keeps
78
+ // it out of the plan's summed credits and marks it free for --check.
79
+ const creditCell = cells[creditCol] ?? "";
80
+ const unlimited = creditHeader
81
+ ? UNLIMITED.test(cellText(creditCell))
82
+ : false;
74
83
  const allowance = creditHeader
75
- ? parseMoney(cells[creditCol] ?? "")
84
+ ? (parseMoney(creditCell) ?? (unlimited ? 0 : null))
76
85
  : (defaultAllowance ?? 0);
77
86
  if (allowance === null) continue;
78
87
  const key = normalizeKey(rawName);
79
88
  if (!key) continue;
80
89
 
90
+ const pricing = pricingOf(cells, cols);
91
+ // The free cells read "Free" (already 0) but the cache-write cell is
92
+ // "-", which parseMoney reads as "no rate". On a free model the
93
+ // truthful rate is 0, not unpriced.
94
+ if (unlimited) pricing.cacheWrite = 0;
95
+
81
96
  const score = variantScore(rawName, peak);
82
97
  const existing = best.get(key);
83
98
  if (existing && existing.score <= score) continue;
@@ -88,7 +103,7 @@ export function extractCatalog(
88
103
  plan,
89
104
  key,
90
105
  name: displayName(rawName),
91
- pricing: pricingOf(cells, cols),
106
+ pricing,
92
107
  allowance,
93
108
  ...(deal ? { deal } : {}),
94
109
  },
@@ -10,6 +10,7 @@ export {
10
10
  parseMoney,
11
11
  parseTables,
12
12
  } from "./tables.ts";
13
+ export { parseTabTables, type TabTable } from "./tabs.ts";
13
14
 
14
15
  export async function fetchText(url: string): Promise<string> {
15
16
  const res = await fetch(url, {
@@ -1,4 +1,5 @@
1
1
  import { BOUNDARY } from "#~/constants/data.ts";
2
+ import { parseTabTables } from "./tabs.ts";
2
3
 
3
4
  export type Table = string[][];
4
5
 
@@ -21,49 +22,7 @@ export function nameCell(cell: string): string {
21
22
 
22
23
  /** Parse every <table> on a page into rows of trimmed cell text. */
23
24
  export async function parseTables(html: string): Promise<Table[]> {
24
- const tables: Table[] = [];
25
- let table: Table | null = null;
26
- let row: string[] | null = null;
27
- let cell: string | null = null;
28
-
29
- const rewriter = new HTMLRewriter()
30
- .on("table", {
31
- element(el) {
32
- table = [];
33
- el.onEndTag(() => {
34
- if (table) tables.push(table);
35
- table = null;
36
- });
37
- },
38
- })
39
- .on("tr", {
40
- element(el) {
41
- const start = table;
42
- row = [];
43
- el.onEndTag(() => {
44
- if (row && start) start.push(row);
45
- row = null;
46
- });
47
- },
48
- })
49
- .on("th, td", {
50
- element(el) {
51
- const current = row;
52
- cell = "";
53
- el.onEndTag(() => {
54
- // BOUNDARY marks a text-node boundary so "$60" + "4x" stays
55
- // two tokens instead of collapsing into "$604x".
56
- if (current) current.push((cell ?? "").trim());
57
- cell = null;
58
- });
59
- },
60
- text(chunk) {
61
- if (cell !== null) cell += `${chunk.text}${BOUNDARY}`;
62
- },
63
- });
64
-
65
- await rewriter.transform(new Response(html)).text();
66
- return tables;
25
+ return (await parseTabTables(html)).map((tab) => tab.table);
67
26
  }
68
27
 
69
28
  /**
@@ -0,0 +1,97 @@
1
+ import { BOUNDARY } from "#~/constants/data.ts";
2
+ import type { Table } from "./tables.ts";
3
+
4
+ /** A table plus the label of the `<tabpanel>` it sits in ("" outside tabs). */
5
+ export interface TabTable {
6
+ label: string;
7
+ table: Table;
8
+ }
9
+
10
+ /**
11
+ * Parse every <table> on a page, tagging each with the label of the
12
+ * `role="tabpanel"` it belongs to. Starlight renders each `<TabItem label="…">`
13
+ * as a labelled tabpanel, so this is how two otherwise identical tables are
14
+ * told apart — the Go and Go Plus catalogs share headers and token rates, and
15
+ * differ only in the monthly-limit values.
16
+ *
17
+ * Labels come from the `[role="tab"]` control, which the document places before
18
+ * the panels: its end-tag fills the map, then the matching panel's table
19
+ * inherits its label. The tab names its panel either through `href="#id"`
20
+ * (Starlight's `<a role="tab">`) or `aria-controls="id"` (the v2 console page's
21
+ * `<button role="tab">`), so read whichever is present.
22
+ */
23
+ export async function parseTabTables(html: string): Promise<TabTable[]> {
24
+ const labels = new Map<string, string>();
25
+ const tables: TabTable[] = [];
26
+ let panelId: string | null = null;
27
+ let anchorText = "";
28
+ let label: string | null = null;
29
+ let table: Table | null = null;
30
+ let row: string[] | null = null;
31
+ let cell: string | null = null;
32
+
33
+ const rewriter = new HTMLRewriter()
34
+ .on('[role="tab"]', {
35
+ element(el) {
36
+ const href = el.getAttribute("href");
37
+ panelId = href
38
+ ? href.replace(/^#/, "")
39
+ : el.getAttribute("aria-controls");
40
+ anchorText = "";
41
+ el.onEndTag(() => {
42
+ if (panelId) labels.set(panelId, anchorText.trim());
43
+ panelId = null;
44
+ });
45
+ },
46
+ text(chunk) {
47
+ anchorText += chunk.text;
48
+ },
49
+ })
50
+ .on('div[role="tabpanel"]', {
51
+ element(el) {
52
+ label = labels.get(el.getAttribute("id") ?? "") ?? null;
53
+ el.onEndTag(() => {
54
+ label = null;
55
+ });
56
+ },
57
+ })
58
+ .on("table", {
59
+ element(el) {
60
+ table = [];
61
+ const start = table;
62
+ el.onEndTag(() => {
63
+ if (start)
64
+ tables.push({ label: label ?? "", table: start });
65
+ table = null;
66
+ });
67
+ },
68
+ })
69
+ .on("tr", {
70
+ element(el) {
71
+ const start = table;
72
+ row = [];
73
+ el.onEndTag(() => {
74
+ if (row && start) start.push(row);
75
+ row = null;
76
+ });
77
+ },
78
+ })
79
+ .on("th, td", {
80
+ element(el) {
81
+ const current = row;
82
+ cell = "";
83
+ el.onEndTag(() => {
84
+ // BOUNDARY marks a text-node boundary so "$60" + "4x" stays
85
+ // two tokens instead of collapsing into "$604x".
86
+ if (current) current.push((cell ?? "").trim());
87
+ cell = null;
88
+ });
89
+ },
90
+ text(chunk) {
91
+ if (cell !== null) cell += `${chunk.text}${BOUNDARY}`;
92
+ },
93
+ });
94
+
95
+ await rewriter.transform(new Response(html)).text();
96
+ return tables;
97
+ }
@@ -1,53 +1,110 @@
1
1
  import { AA_MODELS_URL } from "#~/constants/sources.ts";
2
+ import { type BenchData, emptyData } from "#~/data/bench/types.ts";
2
3
  import { fetchText } from "#~/data/scrape/index.ts";
3
4
  import { normalizeKey } from "#~/keys.ts";
4
5
 
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 });
6
+ /** The subset of an AA models-page record this parser reads. */
7
+ interface AaModel {
8
+ slug?: unknown;
9
+ name?: unknown;
10
+ shortName?: unknown;
11
+ intelligenceIndex?: unknown;
12
+ timescaleData?: { medianOutputSpeed?: unknown } | null;
13
+ }
14
+
15
+ /**
16
+ * Decode a Next.js page's streamed RSC payload. Each chunk is a JSON string
17
+ * literal inside `self.__next_f.push([1,"…"])`; joining the decoded strings
18
+ * yields the flight text the page renders from, with plain `"initialModels":`
19
+ * keys instead of the `\"`-escaped form seen in the raw HTML.
20
+ */
21
+ function decodeFlight(html: string): string {
22
+ const chunks: string[] = [];
23
+ const re = /self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g;
24
+ for (const match of html.matchAll(re)) {
25
+ try {
26
+ chunks.push(JSON.parse(match[1] ?? '""') as string);
27
+ } catch {
28
+ // A chunk we cannot read as a JSON string is skipped.
29
+ }
26
30
  }
27
- return out;
31
+ return chunks.join("");
28
32
  }
29
33
 
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);
34
+ /** The JSON array value of `"key"` in `text`, bracket-matched string-aware. */
35
+ function readArray(text: string, key: string): unknown[] | null {
36
+ const at = text.indexOf(`"${key}":`);
37
+ if (at < 0) return null;
38
+ const open = text.indexOf("[", at);
39
+ if (open < 0) return null;
40
+ let depth = 0;
41
+ let inString = false;
42
+ for (let i = open; i < text.length; i++) {
43
+ const char = text[i];
44
+ if (inString) {
45
+ if (char === "\\") i++;
46
+ else if (char === '"') inString = false;
47
+ continue;
48
+ }
49
+ if (char === '"') inString = true;
50
+ else if (char === "[") depth++;
51
+ else if (char === "]") {
52
+ depth--;
53
+ if (depth === 0) {
54
+ try {
55
+ return JSON.parse(text.slice(open, i + 1)) as unknown[];
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+ }
37
61
  }
38
- return scores;
62
+ return null;
63
+ }
64
+
65
+ /** A finite, positive number; `null` for anything else (incl. 0 = unmeasured). */
66
+ function positive(value: unknown): number | null {
67
+ return typeof value === "number" && Number.isFinite(value) && value > 0
68
+ ? value
69
+ : null;
39
70
  }
40
71
 
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.
72
+ function labelOf(model: AaModel): string {
73
+ if (typeof model.name === "string") return model.name;
74
+ return typeof model.shortName === "string" ? model.shortName : "";
75
+ }
76
+
77
+ function keyOf(value: unknown): string {
78
+ return typeof value === "string" ? normalizeKey(value) : "";
79
+ }
80
+
81
+ /**
82
+ * Pure parser over the AA models page. Keyless and live: the page embeds its
83
+ * `initialModels` array in the RSC flight payload, each record carrying the
84
+ * Intelligence Index and (mostly) a median output speed. Only that embedded
85
+ * top-N is covered, so this is a partial source.
44
86
  */
45
- export async function loadAaWeb(): Promise<Map<string, number>> {
46
- const html = await fetchText(AA_MODELS_URL);
47
- return parseAaWeb(html);
87
+ export function parseAaWeb(html: string): BenchData {
88
+ const data = emptyData();
89
+ const models = readArray(decodeFlight(html) || html, "initialModels");
90
+ if (!models) return data;
91
+ for (const raw of models) {
92
+ const model = (raw ?? {}) as AaModel;
93
+ // slug is the precise key; the display name is the fallback.
94
+ const keys = [keyOf(model.slug), keyOf(labelOf(model))].filter(Boolean);
95
+ const score = positive(model.intelligenceIndex);
96
+ const speed = positive(model.timescaleData?.medianOutputSpeed);
97
+ for (const key of keys) {
98
+ if (score !== null && !data.intelligence.has(key)) {
99
+ data.intelligence.set(key, score);
100
+ }
101
+ if (speed !== null && !data.tps.has(key)) data.tps.set(key, speed);
102
+ }
103
+ }
104
+ return data;
48
105
  }
49
106
 
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));
107
+ /** Fetch and parse the AA models page. Live, per run — nothing cached. */
108
+ export async function loadAaWeb(): Promise<BenchData> {
109
+ return parseAaWeb(await fetchText(AA_MODELS_URL));
53
110
  }
@@ -1,40 +1,58 @@
1
- import {
2
- DOC_URL,
3
- OC_MODELS_URL,
4
- PRICE_PER_MONTH,
5
- } from "#~/constants/sources.ts";
1
+ import { DOC_URL, OC_MODELS_URL, OC_PLANS } from "#~/constants/sources.ts";
6
2
  import {
7
3
  extractCatalog,
8
4
  fetchText,
9
- parseTables,
5
+ parseTabTables,
10
6
  } from "#~/data/scrape/index.ts";
11
7
  import type { CatalogEntry, PlanInfo } from "#~/types.ts";
12
8
 
13
- /** Per-model token rates + monthly usage limit for OpenCode Go. */
14
- export async function loadOcGoCatalog(peak = false): Promise<CatalogEntry[]> {
9
+ function planDef(planId: string) {
10
+ const def = OC_PLANS[planId];
11
+ if (!def) {
12
+ throw new Error(
13
+ `unknown OpenCode plan "${planId}" (have: ${Object.keys(OC_PLANS).join(", ")})`,
14
+ );
15
+ }
16
+ return def;
17
+ }
18
+
19
+ /**
20
+ * Per-model token rates + monthly usage limit for an OpenCode Go plan. Both
21
+ * plans share one price list, so the tab label is what selects the right
22
+ * allowance table: identical headers and rates, different monthly limits.
23
+ */
24
+ export async function loadOcGoCatalog(
25
+ planId: string,
26
+ peak = false,
27
+ ): Promise<CatalogEntry[]> {
28
+ const def = planDef(planId);
15
29
  const html = await fetchText(DOC_URL);
16
- const tables = await parseTables(html);
17
- const entries = extractCatalog(tables, {
18
- provider: "oc-go",
19
- plan: "Go",
20
- creditHeader: /monthly limit/i,
21
- peak,
22
- });
30
+ const tabs = await parseTabTables(html);
31
+ const entries = extractCatalog(
32
+ tabs.filter((tab) => tab.label === def.label).map((tab) => tab.table),
33
+ {
34
+ provider: "oc-go",
35
+ plan: def.label,
36
+ creditHeader: /monthly limit/i,
37
+ peak,
38
+ },
39
+ );
23
40
  if (entries.length === 0) {
24
41
  throw new Error(
25
- `no model table parsed from ${DOC_URL} — docs layout may have changed`,
42
+ `no model table parsed for the "${def.label}" plan from ${DOC_URL} — docs layout may have changed`,
26
43
  );
27
44
  }
28
45
  return entries;
29
46
  }
30
47
 
31
- /** OpenCode Go is a flat $10/mo; limits are per-model, so there is no shared pool. */
32
- export function ocGoPlan(entries: CatalogEntry[]): PlanInfo {
48
+ /** A Go plan is flat-priced; limits are per-model, so there is no shared pool. */
49
+ export function ocGoPlan(entries: CatalogEntry[], planId: string): PlanInfo {
50
+ const def = planDef(planId);
33
51
  return {
34
52
  provider: "oc-go",
35
- id: "go",
36
- label: "Go",
37
- price: PRICE_PER_MONTH,
53
+ id: planId,
54
+ label: def.label,
55
+ price: def.price,
38
56
  credits: entries.reduce((sum, e) => sum + e.allowance, 0),
39
57
  fiveHour: null,
40
58
  weekly: null,
package/src/keys.ts CHANGED
@@ -14,6 +14,12 @@ export function normalizeKey(name: string): string {
14
14
  // Drop a vendor prefix like "deepseek/", "z-ai/", "opencode-go/".
15
15
  const parts = s.split("/");
16
16
  s = parts[parts.length - 1] ?? s;
17
+ // OpenCode's docs append a "Free" marker to every free model ("Step 5
18
+ // Preview Free"); the other catalog omits it. That marker is systematic, not
19
+ // per-model branding, so strip one trailing occurrence here — never add
20
+ // per-model ALIASES entries for it. Trailing only: "Freestyle" or "Free Tier
21
+ // X" keeps its token.
22
+ s = s.replace(/[\s_-]+free\s*$/i, " ");
17
23
  s = s.replace(/[^a-z0-9]+/g, "");
18
24
  return ALIASES[s] ?? s;
19
25
  }
@@ -1,169 +0,0 @@
1
- import { CACHE_TTL_MS } from "#~/constants/sources.ts";
2
- import { fetchText } from "#~/data/scrape/index.ts";
3
- import { normalizeKey } from "#~/keys.ts";
4
-
5
- function parseJsonScores(text: string): Map<string, number> {
6
- const body = JSON.parse(text) as unknown;
7
- const scores = new Map<string, number>();
8
- const add = (name: unknown, score: unknown): void => {
9
- const key = typeof name === "string" ? normalizeKey(name) : "";
10
- const value = Number(score);
11
- if (key && Number.isFinite(value)) scores.set(key, value);
12
- };
13
- if (Array.isArray(body)) {
14
- for (const row of body) {
15
- const record = (row ?? {}) as Record<string, unknown>;
16
- add(record.model ?? record.name, record.score ?? record.value);
17
- }
18
- } else if (body && typeof body === "object") {
19
- for (const [name, score] of Object.entries(
20
- body as Record<string, unknown>,
21
- )) {
22
- add(name, score);
23
- }
24
- }
25
- return scores;
26
- }
27
-
28
- export async function loadFile(path: string): Promise<Map<string, number>> {
29
- const file = Bun.file(path);
30
- if (!(await file.exists()))
31
- throw new Error(`bench file not found: ${path}`);
32
- return parseJsonScores(await file.text());
33
- }
34
-
35
- export async function loadUrl(url: string): Promise<Map<string, number>> {
36
- return parseJsonScores(await fetchText(url));
37
- }
38
-
39
- import { mkdir } from "node:fs/promises";
40
- import { homedir } from "node:os";
41
- import { join } from "node:path";
42
- import { loadAaWeb } from "#~/data/sources/aa/web.ts";
43
-
44
- function cachePath(): string {
45
- const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
46
- return join(base, "mpc", "ability-aa-web.json");
47
- }
48
-
49
- async function readCache(
50
- refresh: boolean,
51
- ): Promise<Map<string, number> | null> {
52
- if (refresh) return null;
53
- try {
54
- const file = Bun.file(cachePath());
55
- if (!(await file.exists())) return null;
56
- const raw = (await file.json()) as {
57
- fetchedAt?: number;
58
- scores?: Record<string, number>;
59
- };
60
- if (!raw.fetchedAt || Date.now() - raw.fetchedAt > CACHE_TTL_MS)
61
- return null;
62
- return new Map(Object.entries(raw.scores ?? {}));
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- async function writeCache(scores: Map<string, number>): Promise<void> {
69
- try {
70
- const path = cachePath();
71
- await mkdir(join(path, ".."), { recursive: true });
72
- await Bun.write(
73
- path,
74
- JSON.stringify({
75
- fetchedAt: Date.now(),
76
- scores: Object.fromEntries(scores),
77
- }),
78
- );
79
- } catch {
80
- // Cache is best-effort.
81
- }
82
- }
83
-
84
- /** Keyless AA page scrape, cached on disk. */
85
- export async function loadAaWebCached(
86
- refresh: boolean,
87
- ): Promise<Map<string, number>> {
88
- const cached = await readCache(refresh);
89
- if (cached) return cached;
90
- const scores = await loadAaWeb();
91
- await writeCache(scores);
92
- return scores;
93
- }
94
-
95
- /**
96
- * The last good benchmark result, whole. The per-source caches above only cover
97
- * the AA page scrape; this one covers the result mpc actually returned, so a
98
- * source that is down (or a page that silently dropped a column, as the
99
- * CommandCode plan pages did with Tok/s) falls back to the scores we already
100
- * have instead of blanking the ability/tps columns.
101
- */
102
- export interface CachedAbility {
103
- intelligence: Map<string, number>;
104
- tps: Map<string, number>;
105
- }
106
-
107
- function abilityCachePath(): string {
108
- const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
109
- return join(base, "mpc", "ability.json");
110
- }
111
-
112
- export async function readAbilityCache(
113
- refresh = false,
114
- ): Promise<CachedAbility | null> {
115
- if (refresh) return null;
116
- try {
117
- const file = Bun.file(abilityCachePath());
118
- if (!(await file.exists())) return null;
119
- const raw = (await file.json()) as {
120
- fetchedAt?: number;
121
- intelligence?: Record<string, number>;
122
- tps?: Record<string, number>;
123
- };
124
- if (!raw.fetchedAt || Date.now() - raw.fetchedAt > CACHE_TTL_MS)
125
- return null;
126
- return {
127
- intelligence: new Map(Object.entries(raw.intelligence ?? {})),
128
- tps: new Map(Object.entries(raw.tps ?? {})),
129
- };
130
- } catch {
131
- return null;
132
- }
133
- }
134
-
135
- export async function writeAbilityCache(data: CachedAbility): Promise<void> {
136
- try {
137
- const path = abilityCachePath();
138
- await mkdir(join(path, ".."), { recursive: true });
139
- await Bun.write(
140
- path,
141
- JSON.stringify({
142
- fetchedAt: Date.now(),
143
- intelligence: Object.fromEntries(data.intelligence),
144
- tps: Object.fromEntries(data.tps),
145
- }),
146
- );
147
- } catch {
148
- // Cache is best-effort.
149
- }
150
- }
151
-
152
- /** Fill keys the live run is missing from `cached`. Returns how many were added. */
153
- export function fillFromCache(
154
- data: CachedAbility,
155
- cached: CachedAbility,
156
- ): number {
157
- let added = 0;
158
- for (const [into, from] of [
159
- [data.intelligence, cached.intelligence],
160
- [data.tps, cached.tps],
161
- ] as const) {
162
- for (const [key, value] of from) {
163
- if (into.has(key)) continue;
164
- into.set(key, value);
165
- added++;
166
- }
167
- }
168
- return added;
169
- }