@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.
- package/LICENSE +18 -0
- package/README.md +269 -2
- package/package.json +31 -5
- package/src/cli/config.ts +79 -0
- package/src/cli/engine/ability.ts +50 -0
- package/src/cli/engine/cost.ts +62 -0
- package/src/cli/engine/index.ts +59 -0
- package/src/cli/engine/project.ts +145 -0
- package/src/cli/engine/rows.ts +64 -0
- package/src/cli/engine/score.ts +118 -0
- package/src/cli/flow/check.ts +66 -0
- package/src/cli/flow/collect.ts +76 -0
- package/src/cli/flow/columns.ts +32 -0
- package/src/cli/flow/sort.ts +68 -0
- package/src/cli/options.ts +78 -0
- package/src/cli/parse/cac.ts +114 -0
- package/src/cli/parse/fields.ts +65 -0
- package/src/cli/parse/map.ts +116 -0
- package/src/cli/parse/plugins.ts +67 -0
- package/src/cli/parse/validate.ts +60 -0
- package/src/cli/run.ts +151 -0
- package/src/constants/cli.ts +153 -0
- package/src/constants/data.ts +30 -0
- package/src/constants/scoring.ts +22 -0
- package/src/constants/shape.ts +19 -0
- package/src/constants/sources.ts +69 -0
- package/src/constants/view.ts +161 -0
- package/src/data/bench/cc.ts +26 -0
- package/src/data/bench/index.ts +126 -0
- package/src/data/bench/resolve.ts +107 -0
- package/src/data/bench/store.ts +169 -0
- package/src/data/bench/types.ts +34 -0
- package/src/data/cmduse.ts +43 -0
- package/src/data/scrape/catalog/catalog.ts +99 -0
- package/src/data/scrape/catalog/deal.ts +20 -0
- package/src/data/scrape/catalog/numeric.ts +30 -0
- package/src/data/scrape/catalog/variant.ts +11 -0
- package/src/data/scrape/index.ts +21 -0
- package/src/data/scrape/roleRows.ts +41 -0
- package/src/data/scrape/tables.ts +96 -0
- package/src/data/shape.ts +205 -0
- package/src/data/sources/aa/api.ts +54 -0
- package/src/data/sources/aa/parse.ts +66 -0
- package/src/data/sources/aa/web.ts +53 -0
- package/src/data/sources/cc/catalog.ts +71 -0
- package/src/data/sources/cc/cmduse.ts +24 -0
- package/src/data/sources/cc/plans.ts +29 -0
- package/src/data/sources/opencode.ts +48 -0
- package/src/data/usage/index.ts +118 -0
- package/src/data/usage/log.ts +89 -0
- package/src/data/usage/logs.ts +117 -0
- package/src/data/usage/opencodeDb.ts +135 -0
- package/src/data/usage/opencodeV2.ts +70 -0
- package/src/data/usage/parse.ts +85 -0
- package/src/index.ts +11 -0
- package/src/keys.ts +29 -0
- package/src/types.ts +92 -0
- package/src/view/columns/cc.ts +50 -0
- package/src/view/columns/meta.ts +41 -0
- package/src/view/columns/oc.ts +50 -0
- package/src/view/footer.ts +124 -0
- package/src/view/layout/fit.ts +58 -0
- package/src/view/layout/segments.ts +98 -0
- package/src/view/layout/table.ts +85 -0
- package/src/view/layout/usage.ts +163 -0
- package/src/view/render.ts +62 -0
- package/src/view/schema.ts +81 -0
- package/src/view/text/export.ts +72 -0
- package/src/view/text/format.ts +79 -0
- package/src/view/text/index.ts +54 -0
- package/src/view/text/styles.ts +151 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Jeffrey JYZ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,270 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mpc — model price compare
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Compare what the same model actually costs you on OpenCode Go vs CommandCode, using one fixed per-request workload.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/JeffreyJYZ/cmdcode-tools/actions/workflows/ci.yml) [](https://www.npmjs.com/package/@jeffreyjyz/mpc) [](LICENSE)
|
|
6
|
+
|
|
7
|
+
## What it is
|
|
8
|
+
|
|
9
|
+
Both providers sell the same shape of thing: a monthly subscription that grants a pool of usage credits, with a per-model allowance priced at API token rates. `mpc` normalises both onto one table so you can see, per model, how many requests a month each plan buys and what each request really costs you.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Install the published CLI globally:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun add -g @jeffreyjyz/mpc
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or run it from a checkout — Bun runs the TypeScript entry directly, or link the binary:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun install
|
|
23
|
+
bun run src/index.ts --help
|
|
24
|
+
bun link
|
|
25
|
+
mpc --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
mpc # oc-go Go vs CommandCode GOAT, default workload
|
|
32
|
+
mpc --cc-plan pro # compare against CommandCode Pro
|
|
33
|
+
mpc --cc-plan go # ...or the $1 Go plan
|
|
34
|
+
mpc --minimal # model + req/mo both sides + win + val
|
|
35
|
+
mpc --medium # allowances + the rate views per side
|
|
36
|
+
mpc --detail # every column, untrimmed (for copy/paste or agents)
|
|
37
|
+
mpc --model 'kimi|glm' --metric req # filter, sort by requests/month
|
|
38
|
+
mpc --in 2000 --cache 80000 --out 400 # override the fixed workload
|
|
39
|
+
mpc --shape measured # force reqshape even on a thin sample
|
|
40
|
+
mpc --shape off # force the fixed workload (default: auto)
|
|
41
|
+
mpc --bench aa-web # ability scores from Artificial Analysis
|
|
42
|
+
mpc --json # machine-readable output
|
|
43
|
+
mpc --check # validate live sources and report drift
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Flags are parsed with [cac](https://github.com/cacjs/cac); `--help` and `--version` come from it. Unknown flags and out-of-range values are rejected.
|
|
47
|
+
|
|
48
|
+
## Your real usage (`--usage`)
|
|
49
|
+
|
|
50
|
+
Project what you actually ran onto both plans, from the local CommandCode session logs (offline). `--usage-window` picks the range — `period` (the billing cycle, default), `all`, or `<n>d`:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
mpc --usage
|
|
54
|
+
mpc --usage --usage-window all
|
|
55
|
+
mpc --usage --usage-file ./usage.json # your own mix (see caveat below)
|
|
56
|
+
mpc --usage --usage-months 2 # scale a partial window to a month
|
|
57
|
+
mpc --usage --json # full projection
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Sources, merged when more than one is present:
|
|
61
|
+
|
|
62
|
+
| source | covers |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| **opencode's message store** (`~/.local/share/opencode/opencode.db`, `--usage-db`, `OPENCODE_DB`) | every request opencode ran, for every provider — complete and backfilled |
|
|
65
|
+
| external per-request log (`--usage-log`, `MPC_USAGE_LOG`, default `$XDG_CACHE_HOME/mpc/usage.jsonl`) | any harness that writes one JSON line per request; consulted only when the DB is absent |
|
|
66
|
+
| `cmduse model --json --since <ISO>` (windowed model reports) | CommandCode CLI sessions on this machine |
|
|
67
|
+
| session-log scan (`~/.commandcode/projects`) | fallback when cmduse lacks the window |
|
|
68
|
+
| `--usage-file` | anything else you have |
|
|
69
|
+
|
|
70
|
+
The DB is read-only via `bun:sqlite`; assistant messages carry `cost`, `tokens` and `modelID`/`providerID`, so no plugin is required for the opencode side. Set `CMDUSE_BIN` to test against a dev cmduse (`cmdusedev`).
|
|
71
|
+
|
|
72
|
+
**Scope caveat.** Only *local* sources exist — the account API exposes totals, not per-model usage, and Studio's API surface is the same endpoint. The report prints a coverage line (`local usage N of M account requests (x%)`) and warns below 90%, so a partial mix is visible rather than silently wrong.
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
MODEL your req your $ CC $/req CC $/mo OC $/req OC $/mo cheaper
|
|
76
|
+
GLM-5.2 142 $15.7237 $0.0158 $2.2462 $0.0185 $2.6206 CC
|
|
77
|
+
GLM-5.3 Flash 29 $0.7203 $0.0062091 $0.1801 $0.0041394 $0.12 OC
|
|
78
|
+
totals your mix · CC $2.4761/mo · OpenCode $2.7686/mo
|
|
79
|
+
head-to-head 2 of 2 models · CC $2.4761/mo · OpenCode $2.7686/mo · cheaper CommandCode by $0.2925 (12%)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Usage is per-model **totals**, so `your $` is the list value of the tokens, `CC $/mo` is what that subscription would cost you, and models that exceed a plan's allowance are flagged `over cap`. The `head-to-head` line answers "which plan is cheaper *for my mix*", so it is restricted to the models **both** plans price — a model only one provider sells would otherwise pad that side's total and "prove" the other cheaper on traffic it cannot serve. One-sided rows are excluded and counted. Unknown models are listed, never dropped. Accepts the cmduse shape, `{"entries": [...]}`, or a bare array of `{ model, requests, tokensIn, cacheRead, tokensOut }`.
|
|
83
|
+
|
|
84
|
+
## Config file
|
|
85
|
+
|
|
86
|
+
Every flag persists. `mpc` reads `~/.config/mpc/config.json` (or `$XDG_CONFIG_HOME/mpc/config.json`), overridden by CLI flags, and `--config <path>` / `--no-config` control it. Keys are the camelCase flag names, negations are plain booleans:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"ccPlan": "goat",
|
|
91
|
+
"columns": ["model", "oc-per1k", "cc-per1k", "val"],
|
|
92
|
+
"presets": { "cheap": ["model", "oc-per1k", "cc-per1k", "cost"] },
|
|
93
|
+
"metric": "val",
|
|
94
|
+
"benchWeight": 0.4,
|
|
95
|
+
"valWeights": [0.4, 0.1, 0.25, 0.15, 0.1],
|
|
96
|
+
"color": false,
|
|
97
|
+
"plugins": ["./work.json", "mpc-preset-openai"]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`--print-config` prints the effective settings.
|
|
102
|
+
|
|
103
|
+
### Plugins
|
|
104
|
+
|
|
105
|
+
`plugins` (or `--plugin a,b`) layer config fragments between the defaults and your config:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
defaults < plugins (listed order) < user config < CLI flags
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Relative paths resolve against the config file's directory. A plugin is a `.json` file, or a `.js`/`.ts`/package whose default export is a config object (or a sync/async function returning one, given `{ env, cwd, configDir }`). JS plugins run code — same trust as your shell.
|
|
112
|
+
|
|
113
|
+
## Options
|
|
114
|
+
|
|
115
|
+
| Flag | Default | Meaning |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `--cc-plan <id>` | `goat` | CommandCode plan: `go`, `goat`, `pro`, `max10`, `max20` |
|
|
118
|
+
| `--in <n>` | `800` | fixed input tokens per request |
|
|
119
|
+
| `--cache <n>` | `50000` | fixed cache-read tokens per request |
|
|
120
|
+
| `--out <n>` | `200` | fixed output tokens per request |
|
|
121
|
+
| `--reasoning <n>` | `0` | reasoning tokens per request, billed at the output rate on top of output |
|
|
122
|
+
| `--cache-write <n>` | `0` | cache-write tokens per request; a model that publishes no cache-write rate is priced at its input rate |
|
|
123
|
+
| `--shape <spec>` | `auto` | `auto` = reqshape's measured profile when it has ≥ `SHAPE_MIN_REQS` (500) reqs, else the fixed workload; `measured` forces reqshape; `off` keeps the fixed workload; a path reads a saved `reqshape --format json` payload |
|
|
124
|
+
| `--since <date>` | — | measured shape: only requests on or after this date |
|
|
125
|
+
| `--metric <name>` | `val` | sort by `val`, `cost`, `perreq`, `req` or `name`; `cost` and `perreq` ascend (lower better), `val`/`req` descend, `--asc` flips; rows with no `VAL` always sort last |
|
|
126
|
+
| `--model <re>` | — | filter rows by name (regex, substring fallback) |
|
|
127
|
+
| `--only <scope>` | `all` | `all` = union of both catalogs, `both` = only shared models |
|
|
128
|
+
| `--width <n>` | terminal | force table width; by default the table trims to the terminal and drops optional columns (`rates`, `req/$`, `5h`/`wk`) until it fits |
|
|
129
|
+
| `--minimal` | off | fewest columns: model + req/mo both sides + win + val |
|
|
130
|
+
| `--medium` | off | preset: allowance and the rate views per side + win + cost + val |
|
|
131
|
+
| `--no-fit` | off | keep the preset's full width instead of trimming to the terminal |
|
|
132
|
+
| `--columns <ids>` | preset | comma-separated columns to show, in order (overrides presets and trimming); `--columns help` lists ids |
|
|
133
|
+
| `--bench <src>` | `aa` with a key, else `cc` | ability scores: `cc`, `aa`, `aa-web`, `file:<path>`, `url:<url>` |
|
|
134
|
+
| `--bench-weight <n>` | `0.35` | ability share of `VAL`, 0-1 |
|
|
135
|
+
| `--tps-weight <n>` | `0.10` | output-speed share of `VAL`, 0-1 |
|
|
136
|
+
| `--bench-name <label>` | source | footer label for the ability source |
|
|
137
|
+
| `--aa-key <key>` | `AA_API_KEY` | Artificial Analysis API key |
|
|
138
|
+
| `--no-fallback` | off | skip filling ability misses from the other sources |
|
|
139
|
+
| `--refresh` | off | ignore the `aa-web` cache |
|
|
140
|
+
| `--no-ability` | off | hide `ability` and `VAL` |
|
|
141
|
+
| `--preset <name>` | — | named column set from `presets` in config |
|
|
142
|
+
| `--val-weights <a,b,c,d,e>` | see above | VAL weights (ability, tps, volume, cache, output) |
|
|
143
|
+
| `--scale <mode>` | `log` | `log` or `linear` normalisation for skewed terms |
|
|
144
|
+
| `--inherit-suffixes <s>` | built-in | speed-variant suffixes that inherit ability |
|
|
145
|
+
| `--window <five,week>` | derived | override rolling-window ratios |
|
|
146
|
+
| `--cost-thresholds <g,y>` | `30,60` | COST colour cut-offs |
|
|
147
|
+
| `--val-thresholds <y,g>` | `40,70` | VAL colour cut-offs |
|
|
148
|
+
| `--format <name>` | `table` | `table`, `json`, `csv`, `md` |
|
|
149
|
+
| `--color <mode>` | `auto` | `auto`, `always`, `never` |
|
|
150
|
+
| `--config <path>` | XDG | config file |
|
|
151
|
+
| `--no-config` | off | ignore config and plugins |
|
|
152
|
+
| `--plugin <paths>` | — | extra config plugins, comma-separated |
|
|
153
|
+
| `--print-config` | — | print effective settings and exit |
|
|
154
|
+
| `-h, --help` | — | generated help (cac) |
|
|
155
|
+
| `-v, --version` | — | print version |
|
|
156
|
+
| `--peak` | off | use peak-rate rows instead of off-peak (DeepSeek) |
|
|
157
|
+
| `--asc` | off | sort ascending |
|
|
158
|
+
| `--detail` | off | every column, untrimmed; the default is every column trimmed to the terminal width |
|
|
159
|
+
| `--json` | off | emit JSON instead of a table |
|
|
160
|
+
| `--no-color` | off | disable ANSI colour |
|
|
161
|
+
| `--check` | off | validate sources, list unmatched models, exit |
|
|
162
|
+
|
|
163
|
+
## Data sources (all fetched live)
|
|
164
|
+
|
|
165
|
+
| Source | Provides |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `cmduse plans --json` | CommandCode plan price, credits, 5h/weekly windows |
|
|
168
|
+
| `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 |
|
|
170
|
+
| `opencode.ai/zen/go/v1/models` | oc-go live model list (`--check` drift) |
|
|
171
|
+
|
|
172
|
+
## Reading the table
|
|
173
|
+
|
|
174
|
+
| Column | Meaning |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| `in/out/cache` (`--detail`) | model token rates, USD per 1M tokens |
|
|
177
|
+
| `allow` | monthly credits this plan devotes to that model |
|
|
178
|
+
| `req/5h`, `req/wk` (`--detail`) | requests the plan's rolling 5-hour / weekly window allows |
|
|
179
|
+
| `req/mo` | requests the allowance buys (`allowance / costPerRequest`) |
|
|
180
|
+
| `$/1K` | what 1,000 requests cost you on the plan |
|
|
181
|
+
| `req/$` | requests one dollar of subscription buys |
|
|
182
|
+
| `ability` | benchmark score for the model |
|
|
183
|
+
| `tps` | output tokens per second |
|
|
184
|
+
| `WIN` | side with the lower per-request cost |
|
|
185
|
+
| `COST` | 0-100 volume index: `requests/mo`, **lower is better** (no ability) |
|
|
186
|
+
| `VAL` | 0-100 ability-aware value score |
|
|
187
|
+
|
|
188
|
+
`--columns a,b,c` picks and orders columns; ids are listed under `--columns help`, and `cc-*` mirrors the `oc-*` set. Without it, plain `mpc` shows every column that fits the terminal, `--minimal` and `--medium` narrow the set, and `--detail` prints all of them untrimmed.
|
|
189
|
+
|
|
190
|
+
Colour carries one meaning per code. Each provider's whole column set is tinted in its own colour — **cyan** for OpenCode, **magenta** for CommandCode — and the bold form of that colour marks the group banner, the block's headers, the footer plan rows, and the **winning side**: the WIN cell and the cheaper side's `$/1K` / `req/$`. So an OpenCode win (cyan) never looks like a CommandCode win (magenta). `COST`, `VAL` and `ability` run green → orange → red — `ability` is ranked relative to the table, like the other two — and a free model is green. Dim is reserved for a missing value or a footnote. `--no-color`, or stdout that is not a terminal, turns it all off.
|
|
191
|
+
|
|
192
|
+
Rolling-window columns scale the monthly figure by each plan's own window ratio (OpenCode Go fixes 5h = 20%, weekly = 50%; CommandCode derives it from the plan's 5h/weekly dollar caps — 20%/50% on GOAT and Pro, 30%/60% on the Max plans).
|
|
193
|
+
|
|
194
|
+
`--json` reports the raw `costPerRequest`, `payPerRequest`, `requestsPerMonth`, `requestsPerFiveHour`, `requestsPerWeek`, `multiplier` and `index` per model-provider, plus a `tally` object.
|
|
195
|
+
|
|
196
|
+
The footer keeps a running **win tally** over head-to-head models (`oc-go N · cc M · tie T`) plus exclusive counts for models only one provider carries.
|
|
197
|
+
|
|
198
|
+
## How the numbers are computed
|
|
199
|
+
|
|
200
|
+
For a fixed workload of `IN` input, `CACHE` cache-read, `OUT` output, `RSN` reasoning and `CW` cache-write tokens:
|
|
201
|
+
|
|
202
|
+
```text
|
|
203
|
+
costPerRequest = (IN*input + CACHE*cacheRead + (OUT+RSN)*output + CW*cacheWrite) / 1e6 # USD, list rates
|
|
204
|
+
requestsPerMonth = allowance / costPerRequest
|
|
205
|
+
payPerRequest = planPrice * costPerRequest / allowance # what you really pay
|
|
206
|
+
multiplier = allowance / planPrice # $usage per $paid
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Reasoning bills at the output rate *on top of* output — opencode's own provider-priced rows reproduce exactly that way (a GLM-5.3 turn of 8,689 input / 14 output / 38 reasoning / 128 cache-read is priced at `0.01242668`, which only matches when reasoning joins the output term). A cache-write rate the model does not publish falls back to its input rate rather than to free.
|
|
210
|
+
|
|
211
|
+
### Measuring instead of assuming
|
|
212
|
+
|
|
213
|
+
By default mpc asks **reqshape** for the shape of your real traffic, read from opencode's own store, and prices **both** plans on that single per-req profile, so the comparison isolates price and allowance from traffic and the footer prints one workload line. reqshape only leads once it has `SHAPE_MIN_REQS` (500) measured requests — below that the fixed 800/50K/200 workload is steadier. The footer's `shape` line says plainly which was used, the sample size behind the choice, and the alternative flag.
|
|
214
|
+
|
|
215
|
+
`req/mo` then answers "how many of *my* requests fit this allowance" rather than "how many of a hypothetical 800/50K/200 ones do". Force the fixed workload with `--shape off`, force reqshape regardless of sample with `--shape measured`, or save a payload once with `reqshape --format json > shape.json` and reuse it with `--shape shape.json`; `--since <date>` narrows the window. A missing `reqshape` binary (`REQSHAPE_BIN` overrides it) is a warning, not a failure: mpc keeps the fixed workload and carries on.
|
|
216
|
+
|
|
217
|
+
The **index** behind `COST` is a 0-100 volume score across every model-provider entry:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
index = 100 * volume
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
where `volume` is min-max normalised `log10(requestsPerMonth)`. Cache and output prices are **not** folded in — they are their own columns — so `COST` reads as "how many requests the plan buys". Free models get `∞` requests and `index = 100`.
|
|
224
|
+
|
|
225
|
+
## Ability scores (`VAL`)
|
|
226
|
+
|
|
227
|
+
`VAL` adds a benchmark term alongside the same volume/price terms:
|
|
228
|
+
|
|
229
|
+
```text
|
|
230
|
+
COST = 100 - 100·volume # inverted: 0 is best
|
|
231
|
+
VAL = 100 * (0.35*ability + 0.10*tps + 0.25*volume + 0.15*cache + 0.15*output)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`--bench-weight` and `--tps-weight` set the ability and speed shares; the remaining weight splits volume/cache/output 50/25/25. Ability and `tps` come from the same source, so `--bench cc` reads both CommandCode's `Intelligence` and `Tok/s` columns. Speed variants (`…Fast`, `…HighSpeed`, `…UltraSpeed`, `…FlashX`) inherit their base model's ability — same weights — and, when the benchmark publishes no throughput for the variant, the base's throughput ×`SPEED_TPS_FACTOR` (a speed tier is multiples of its base). Without that a Fast model was scored at the neutral rate, ranking it below its slower base. Unscored models show `ability —` and `VAL —` and are excluded from the ability normalisation range.
|
|
235
|
+
|
|
236
|
+
| `--bench` | source | coverage |
|
|
237
|
+
| --- | --- | --- |
|
|
238
|
+
| `aa` | Artificial Analysis API (paginated) | full; needs `AA_API_KEY` |
|
|
239
|
+
| `aa-web` | Artificial Analysis models page scrape | partial (only the models AA embeds) |
|
|
240
|
+
| `cc` | CommandCode's `Intelligence` column | every matched model |
|
|
241
|
+
| `file:<path>` / `url:<url>` | your JSON, `{ "model": score }` or `[{ model, score }]` | whatever you supply |
|
|
242
|
+
|
|
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.
|
|
244
|
+
|
|
245
|
+
## Model matching
|
|
246
|
+
|
|
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 `—`.
|
|
248
|
+
|
|
249
|
+
## Notes and limits
|
|
250
|
+
|
|
251
|
+
- 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
|
+
- 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).
|
|
254
|
+
- `$/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
|
+
- Values reflect the docs at fetch time; active deals are picked up automatically.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
bun install
|
|
261
|
+
bun test # unit tests (parsers, normalisation, metrics)
|
|
262
|
+
bun run typecheck # tsc --noEmit
|
|
263
|
+
bun run check # biome format + lint (write)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Links
|
|
267
|
+
|
|
268
|
+
- [Source (GitHub)](https://github.com/JeffreyJYZ/cmdcode-tools/tree/main/oc-cmd-compare)
|
|
269
|
+
- [npm: @jeffreyjyz/mpc](https://www.npmjs.com/package/@jeffreyjyz/mpc)
|
|
270
|
+
- [MIT license](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@jeffreyjyz/mpc",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Compare model pricing across opencode Go and Command Code plans",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mpc": "src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "bun run src/index.ts",
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"fmt": "biome format --write .",
|
|
21
|
+
"check": "biome check --write .",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@biomejs/biome": "^2.5.13",
|
|
26
|
+
"@types/bun": "latest",
|
|
27
|
+
"typescript": "^7"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"cac": "^7.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { loadPlugins } from "./parse/plugins.ts";
|
|
5
|
+
import type { Bag } from "./parse/validate.ts";
|
|
6
|
+
|
|
7
|
+
/** Default config location, XDG-aware. */
|
|
8
|
+
export function configPath(): string {
|
|
9
|
+
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
10
|
+
return join(base, "mpc", "config.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readConfig(path: string): Bag {
|
|
14
|
+
let text: string;
|
|
15
|
+
try {
|
|
16
|
+
text = readFileSync(path, "utf8");
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(text) as unknown;
|
|
22
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
23
|
+
throw new Error("config must be a JSON object");
|
|
24
|
+
}
|
|
25
|
+
return parsed as Bag;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`invalid config ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pluginList(bag: Bag): string[] {
|
|
34
|
+
const value = bag.plugin ?? bag.plugins;
|
|
35
|
+
if (value === undefined) return [];
|
|
36
|
+
const items = Array.isArray(value) ? value : String(value).split(",");
|
|
37
|
+
return items.map((item) => String(item).trim()).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Precedence: defaults < plugins (listed order) < user config < CLI flags.
|
|
42
|
+
* `--no-config` skips both the file and its plugins.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveBag(argv: string[]): Promise<Bag> {
|
|
45
|
+
const { parseFlags } = await import("./parse/cac.ts");
|
|
46
|
+
const cli = parseFlags(argv);
|
|
47
|
+
if (cli.config === false) return cli;
|
|
48
|
+
|
|
49
|
+
const path = typeof cli.config === "string" ? cli.config : configPath();
|
|
50
|
+
const file = readConfig(path);
|
|
51
|
+
const relativeTo = dirname(path);
|
|
52
|
+
const fromCli = pluginList(cli);
|
|
53
|
+
|
|
54
|
+
// Plugins (from the config file and from `--plugin` alike) are the lowest
|
|
55
|
+
// layer, so the user config overrides them. Loading `--plugin` last made a
|
|
56
|
+
// CLI plugin beat the config file, contradicting the documented order.
|
|
57
|
+
const merged: Bag = {};
|
|
58
|
+
Object.assign(merged, await loadPlugins(pluginList(file), relativeTo));
|
|
59
|
+
if (fromCli.length > 0) {
|
|
60
|
+
Object.assign(merged, await loadPlugins(fromCli, process.cwd()));
|
|
61
|
+
}
|
|
62
|
+
for (const [key, value] of Object.entries(file)) {
|
|
63
|
+
if (value !== undefined) merged[key] = value;
|
|
64
|
+
}
|
|
65
|
+
for (const [key, value] of Object.entries(cli)) {
|
|
66
|
+
if (value !== undefined && key !== "config" && key !== "plugin") {
|
|
67
|
+
merged[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
merged.config = path;
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Effective settings, for --print-config. */
|
|
75
|
+
export function describeConfig(bag: Bag): string {
|
|
76
|
+
const { printConfig, ...rest } = bag;
|
|
77
|
+
void printConfig;
|
|
78
|
+
return `${JSON.stringify(rest, null, 2)}\n`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { SPEED_SUFFIXES, SPEED_TPS_FACTOR } from "~/constants/scoring.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The base key a speed variant shares weights with, or undefined. FlashX is the
|
|
5
|
+
* faster tier of Flash, not of the base model.
|
|
6
|
+
*/
|
|
7
|
+
function baseKey(key: string, suffixes: string[]): string | undefined {
|
|
8
|
+
if (key.endsWith("flashx")) return `${key.slice(0, -"flashx".length)}flash`;
|
|
9
|
+
for (const suffix of suffixes) {
|
|
10
|
+
if (key.endsWith(suffix)) return key.slice(0, -suffix.length);
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Benchmark lookup. A speed variant has the base model's weights, so it
|
|
17
|
+
* inherits the base ability when the benchmark has no row of its own.
|
|
18
|
+
* `suffixes` is configurable; empty means no inheritance.
|
|
19
|
+
*/
|
|
20
|
+
export function lookupAbility(
|
|
21
|
+
scores: Map<string, number>,
|
|
22
|
+
key: string,
|
|
23
|
+
suffixes: string[] = SPEED_SUFFIXES,
|
|
24
|
+
): number | null {
|
|
25
|
+
const direct = scores.get(key);
|
|
26
|
+
if (direct !== undefined) return direct;
|
|
27
|
+
const base = baseKey(key, suffixes);
|
|
28
|
+
if (base === undefined) return null;
|
|
29
|
+
return scores.get(base) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Throughput lookup. A variant with its own figure wins; otherwise it borrows
|
|
34
|
+
* the base's, multiplied by `factor` (see `SPEED_TPS_FACTOR`), so a speed tier
|
|
35
|
+
* is not scored as if it served at the neutral rate. A base with no figure
|
|
36
|
+
* either leaves the variant null.
|
|
37
|
+
*/
|
|
38
|
+
export function lookupTps(
|
|
39
|
+
scores: Map<string, number>,
|
|
40
|
+
key: string,
|
|
41
|
+
suffixes: string[] = SPEED_SUFFIXES,
|
|
42
|
+
factor: number = SPEED_TPS_FACTOR,
|
|
43
|
+
): number | null {
|
|
44
|
+
const direct = scores.get(key);
|
|
45
|
+
if (direct !== undefined) return direct;
|
|
46
|
+
const base = baseKey(key, suffixes);
|
|
47
|
+
if (base === undefined) return null;
|
|
48
|
+
const baseTps = scores.get(base);
|
|
49
|
+
return baseTps === undefined ? null : baseTps * factor;
|
|
50
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PER_MILLION } from "~/constants/scoring.ts";
|
|
2
|
+
import type { ModelPricing, PlanInfo, Workload } from "~/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* USD of list-rate spend for a single request under the fixed workload.
|
|
6
|
+
*
|
|
7
|
+
* Reasoning bills at the output rate *on top of* output: the store's own
|
|
8
|
+
* provider-priced rows reproduce exactly that way (a GLM-5.3 turn costing
|
|
9
|
+
* 0.01242668 only matches when the 38 reasoning tokens are added to the 14
|
|
10
|
+
* output ones at the 4.4 $/M output rate). A model that publishes no
|
|
11
|
+
* cache-write rate is priced at its input rate rather than assumed free.
|
|
12
|
+
*/
|
|
13
|
+
export function costPerRequest(
|
|
14
|
+
pricing: ModelPricing,
|
|
15
|
+
workload: Workload,
|
|
16
|
+
): number {
|
|
17
|
+
const output = workload.output + workload.reasoning;
|
|
18
|
+
return (
|
|
19
|
+
(workload.input * pricing.input +
|
|
20
|
+
workload.cacheRead * pricing.cacheRead +
|
|
21
|
+
output * pricing.output +
|
|
22
|
+
workload.cacheWrite * (pricing.cacheWrite ?? pricing.input)) /
|
|
23
|
+
PER_MILLION
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fraction of the monthly allowance each rolling window allows, per provider.
|
|
29
|
+
* OpenCode Go fixes this at 20%/50%; CommandCode derives it from the plan's own
|
|
30
|
+
* 5-hour / weekly dollar caps.
|
|
31
|
+
*/
|
|
32
|
+
export function windowRatios(
|
|
33
|
+
plan: PlanInfo,
|
|
34
|
+
override?: [number, number],
|
|
35
|
+
): { five: number; week: number } {
|
|
36
|
+
if (override) return { five: override[0], week: override[1] };
|
|
37
|
+
if (plan.provider === "oc-go") return { five: 0.2, week: 0.5 };
|
|
38
|
+
if (plan.fiveHour !== null && plan.weekly !== null && plan.credits > 0) {
|
|
39
|
+
return {
|
|
40
|
+
five: plan.fiveHour / plan.credits,
|
|
41
|
+
week: plan.weekly / plan.credits,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { five: 1, week: 1 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function minmax(values: number[]): number[] {
|
|
48
|
+
if (values.length === 0) return [];
|
|
49
|
+
const min = Math.min(...values);
|
|
50
|
+
const max = Math.max(...values);
|
|
51
|
+
if (max === min) return values.map(() => 0.5);
|
|
52
|
+
return values.map((v) => (v - min) / (max - min));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Min-max over log10 values. Throughput and token prices span orders of
|
|
57
|
+
* magnitude, so a single outlier would otherwise squash everyone else toward
|
|
58
|
+
* one end of the scale. Non-positive values clamp to a floor.
|
|
59
|
+
*/
|
|
60
|
+
export function logMinmax(values: number[]): number[] {
|
|
61
|
+
return minmax(values.map((v) => Math.log10(Math.max(v, 1e-6))));
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { DEFAULT_SCORE } from "~/constants/scoring.ts";
|
|
2
|
+
import type {
|
|
3
|
+
CatalogEntry,
|
|
4
|
+
EntryMetrics,
|
|
5
|
+
PlanInfo,
|
|
6
|
+
ProviderId,
|
|
7
|
+
Workload,
|
|
8
|
+
} from "~/types.ts";
|
|
9
|
+
import { lookupAbility, lookupTps } from "./ability.ts";
|
|
10
|
+
import { costPerRequest, windowRatios } from "./cost.ts";
|
|
11
|
+
import { assignIndex, assignValueIndex, type ScoreConfig } from "./score.ts";
|
|
12
|
+
|
|
13
|
+
export function buildMetrics(
|
|
14
|
+
entries: CatalogEntry[],
|
|
15
|
+
plans: Map<ProviderId, PlanInfo>,
|
|
16
|
+
workloads: Record<ProviderId, Workload>,
|
|
17
|
+
ability: Map<string, number> = new Map(),
|
|
18
|
+
tps: Map<string, number> = new Map(),
|
|
19
|
+
config: ScoreConfig = DEFAULT_SCORE,
|
|
20
|
+
): EntryMetrics[] {
|
|
21
|
+
const metrics: EntryMetrics[] = entries.map((entry) => {
|
|
22
|
+
const plan = plans.get(entry.provider);
|
|
23
|
+
if (!plan)
|
|
24
|
+
throw new Error(`no plan loaded for provider ${entry.provider}`);
|
|
25
|
+
const workload = workloads[entry.provider];
|
|
26
|
+
const cost = costPerRequest(entry.pricing, workload);
|
|
27
|
+
const free = cost === 0;
|
|
28
|
+
const requestsPerMonth = free
|
|
29
|
+
? Number.POSITIVE_INFINITY
|
|
30
|
+
: entry.allowance / cost;
|
|
31
|
+
const ratios = windowRatios(plan, config.window);
|
|
32
|
+
return {
|
|
33
|
+
provider: entry.provider,
|
|
34
|
+
plan: entry.plan,
|
|
35
|
+
pricing: entry.pricing,
|
|
36
|
+
allowance: entry.allowance,
|
|
37
|
+
costPerRequest: cost,
|
|
38
|
+
requestsPerMonth,
|
|
39
|
+
requestsPerFiveHour: ratios.five * requestsPerMonth,
|
|
40
|
+
requestsPerWeek: ratios.week * requestsPerMonth,
|
|
41
|
+
payPerRequest: free ? 0 : (plan.price * cost) / entry.allowance,
|
|
42
|
+
multiplier: plan.price > 0 ? entry.allowance / plan.price : 0,
|
|
43
|
+
ability: lookupAbility(ability, entry.key, config.inheritSuffixes),
|
|
44
|
+
tps: lookupTps(tps, entry.key, config.inheritSuffixes),
|
|
45
|
+
deal: entry.deal,
|
|
46
|
+
index: 0,
|
|
47
|
+
valueIndex: null,
|
|
48
|
+
free,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
assignIndex(metrics);
|
|
52
|
+
assignValueIndex(metrics, config);
|
|
53
|
+
return metrics;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { lookupAbility, lookupTps } from "./ability.ts";
|
|
57
|
+
export { costPerRequest, windowRatios } from "./cost.ts";
|
|
58
|
+
export { buildRows } from "./rows.ts";
|
|
59
|
+
export { assignIndex, assignValueIndex } from "./score.ts";
|