Skip to content

Commit 16b9c52

Browse files
fix(catalog): price free SKUs at $0 and fill remaining gaps from models.dev
Drop hardcoded FALLBACK_COSTS so billed rates come from CLI, official docs, or models.dev instead of a plugin table. Command Code free SKUs stay $0 and do not inherit paid twins. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 727d6f4 commit 16b9c52

18 files changed

Lines changed: 440 additions & 113 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi
1616

1717
- Bundled `models.json` is the default runtime catalog (no local CLI scrape).
1818
- CLI cost extraction can fail (as on `command-code@1.38.x`) without dropping models.
19-
- Official docs fill missing costs; leftover models use a conservative fallback (`degraded` in `manifest.json`).
19+
- Official docs fill missing costs; remaining paid gaps use [models.dev](https://models.dev) as a reference. Command Code free SKUs stay `$0`.
2020
- Reasoning effort **variants** on models that declare `reasoningEfforts`.
2121
- Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout).
2222

docs/2026-08-28-ci-catalog/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Watch `command-code` on npm every 6 hours, refresh the bundled catalog, publish
1515
- Tag + GitHub Release **after** successful `npm publish` only. Never tag a version that is not on the registry.
1616
- Catalog auto-commit uses `GITHUB_TOKEN` (or `CATALOG_PUSH_TOKEN` if protection blocks it). Those commits do **not** trigger other workflows, so catalog-sync must publish in the **same job**.
1717
- Human merges to `main` run a separate release job that publishes only if `package.json` version is unpublished (code fixes).
18-
- Cost-only CLI failure still ships (`degraded` if fallback/unmatched costs remain). Model extract failure → no publish, `catalog-break` issue.
18+
- Cost-only CLI failure still ships (`degraded` only if unmatched placeholder costs remain). Model extract failure → no publish, `catalog-break` issue.
1919
- Runtime catalog stays bundled `models.json`. No GitHub fetch at OpenCode startup.
2020
- Hybrid OpenCode transport stays `@ai-sdk/openai-compatible` + Provider API; this package is the **plugin**, not the SDK `npm` field.
2121

docs/specs/2026-08-28-ci-catalog-automation.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Human- and CI-readable metadata about the bundled catalog. Shipped with the plug
6161
"cli": 40,
6262
"officialDocs": 25,
6363
"thirdParty": 0,
64+
"free": 0,
6465
"fallback": 0,
6566
"unmatched": 0
6667
},
@@ -71,18 +72,18 @@ Human- and CI-readable metadata about the bundled catalog. Shipped with the plug
7172
Write order: bump `package.json` version first, then write `manifest.json` with that `pluginVersion`, then one commit. Do not store `gitCommit` in the file (unknown until after commit; put SHA on the GitHub Release instead).
7273

7374
`status` rules:
74-
- `healthy`: model catalog ok **and** every model has costs from `cli`, `officialDocs`, or `thirdParty` (`fallback` = 0 and `unmatched` = 0)
75-
- `degraded`: model catalog ok, but at least one model used hardcoded fallback or has no cost
75+
- `healthy`: model catalog ok **and** every model has a sourced price (`cli`, `officialDocs`, `thirdParty` / models.dev, or `free`). `unmatched` = 0
76+
- `degraded`: model catalog ok, but at least one model still has the unmatched placeholder `{ input: 0.5, output: 2 }`
7677
- `broken`: model catalog extraction failed
7778

78-
`extraction.costCatalog` is the **best** source that contributed (`cli` | `docs` | `thirdParty` | `fallback` | `missing`).
79+
`extraction.costCatalog` is the **best** source that contributed (`cli` | `docs` | `thirdParty` | `free` | `fallback` | `missing`).
7980

8081
Status values:
8182

8283
| status | Meaning | CI action |
8384
|---|---|---|
84-
| `healthy` | models ok; costs from CLI, official docs, and/or trusted API | release |
85-
| `degraded` | models ok; some costs are hardcoded fallback or missing | release + note which models fell through |
85+
| `healthy` | models ok; costs from CLI, official docs, models.dev, and/or free SKUs | release |
86+
| `degraded` | models ok; some costs have no listed source | release + note which models fell through |
8687
| `broken` | model catalog extraction failed | no release, open issue |
8788

8889
### `_version.txt` (existing)
@@ -100,10 +101,11 @@ Waterfall, per model, first hit wins. Later steps only fill models still missing
100101
- Preferred parse target: [https://commandcode.ai/models](https://commandcode.ai/models) (per-model table: Input/M, Output/M, Cache read, Cache write).
101102
- Fallback page: [https://commandcode.ai/docs/resources/pricing-limits](https://commandcode.ai/docs/resources/pricing-limits) if `/models` fetch or parse fails.
102103
- Store the **current billed** per-1M USD rates shown on the page (deal-adjusted when the page shows an effective price). Do not invent deal math.
103-
3. **Trusted third-party API** — only if a provider is configured (`COST_ENRICHMENT_API_URL` secret/env). Default: **none**. Skip this step until one is added. Do not use OpenRouter (or similar) unless explicitly configured; their list prices are not Command Code’s billed rates.
104-
4. **Hardcoded fallback** (`FALLBACK_COSTS` + existing default `{ input: 0.5, output: 2 }`) — last resort so the catalog still ships.
104+
3. **Free SKUs** — catalog id/name matching `\bfree\b` or id ending `-free``{ input: 0, output: 0 }`. Runs **before** models.dev so Command Code free SKUs stay $0 even when models.dev lists a paid twin (e.g. Tencent Hy3).
105+
4. **models.dev**`GET https://models.dev/api.json` for remaining **paid** gaps. Exact id (case-insensitive), then last path segment as id, then exact display name. Reference prices, not Command Code billed rates. Do not apply to free SKUs.
106+
5. **Unmatched placeholder** `{ input: 0.5, output: 2 }` — last resort so the catalog still ships. These are the only models that mark the catalog `degraded`.
105107

106-
Match docs/API rows to catalog models with **exact** id (case-insensitive) then **exact** display name (case-insensitive). No fuzzy matching. Unmatched models go to the next step.
108+
Match docs/API rows to catalog models with **exact** id (case-insensitive) then **exact** display name (case-insensitive). models.dev also tries the last `/` segment of the catalog id as an exact id. No fuzzy matching. Unmatched models go to the next step.
107109

108110
Never fail the sync because costs are incomplete. Model catalog remains the hard requirement.
109111

@@ -126,8 +128,8 @@ Steps:
126128
3. Download tarball (reuse logic from `scripts/sync-models.ts --remote`).
127129
4. Extract models (required) then run the **cost waterfall**:
128130
- model catalog fail → status `broken`, stop (no commit, no publish).
129-
- CLI costs fail or partial → continue; fill gaps from official docs, then optional trusted API, then hardcoded fallback.
130-
- Record `costSources` on the manifest. `degraded` only if any model still used fallback/unmatched.
131+
- CLI costs fail or partial → continue; fill gaps from official docs, then free SKUs ($0), then models.dev, then unmatched placeholder.
132+
- Record `costSources` on the manifest. `degraded` only if any model is still unmatched.
131133
5. Sanity floor: `modelCount >= max(20, floor(lastSuccessfulModelCount * 0.5))`. `lastSuccessfulModelCount` is `modelCount` from the last committed manifest with `status` `healthy` or `degraded`. Fail as `broken` if below. If no prior manifest, use `20`.
132134
6. Write `models.json`, `_version.txt`, bump patch in `package.json`, write `manifest.json`.
133135
7. Run unit tests (including 1.38 costless fixture).
@@ -171,7 +173,7 @@ When a subsequent sync succeeds after manual fix:
171173
| Event | Version bump | Publish |
172174
|---|---|---|
173175
| New command-code catalog (healthy) | patch | yes |
174-
| New command-code catalog (degraded: some fallback costs) | patch | yes |
176+
| New command-code catalog (degraded: unmatched placeholder costs) | patch | yes |
175177
| Model extraction broken | none | no |
176178
| Plugin code fix (manual PR) | patch/minor per semver | yes (manual or on merge) |
177179

@@ -255,15 +257,14 @@ Commit **extracted snapshots** (model JSON + expected counts/errors), not the pr
255257
| `NPM_TOKEN` | publish plugin package (`brainervirus`) |
256258
| `GITHUB_TOKEN` | commit, release, issues |
257259
| `CATALOG_PUSH_TOKEN` | optional; only if branch protection blocks `GITHUB_TOKEN` |
258-
| `COST_ENRICHMENT_API_URL` | optional; skip third-party cost step when unset |
259260

260261
If `main` is protected against `GITHUB_TOKEN`, set `CATALOG_PUSH_TOKEN`. One push path only.
261262

262263
## Acceptance Criteria
263264

264265
- User can run OpenCode with **no** global/local `command-code` install and get current models from the installed plugin (npm package, or a `file://` checkout that has been synced).
265266
- Within 6 hours of a new `command-code` npm release, CI either commits a catalog update (and publishes a plugin patch if `NPM_TOKEN` is set) or opens/updates a `catalog-break` issue.
266-
- Cost-only CLI regressions (like 1.38) still ship a catalog. Costs come from official docs when the CLI map fails; `degraded` only if docs/API also miss models.
267+
- Cost-only CLI regressions (like 1.38) still ship a catalog. Costs come from official docs, then free SKUs ($0), then models.dev; `degraded` only if models remain unmatched.
267268
- Successful sync never requires local `bun run sync` from the user.
268269
- Failed model extraction never publishes a misleading npm release.
269270

manifest.json

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schemaVersion": 1,
3-
"generatedAt": "2026-08-28T19:09:12.385Z",
3+
"generatedAt": "2026-08-28T19:50:57.386Z",
44
"pluginVersion": "0.5.0",
55
"commandCodeVersion": "1.38.1",
66
"commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz",
@@ -13,10 +13,22 @@
1313
},
1414
"costSources": {
1515
"cli": 0,
16-
"officialDocs": 53,
16+
"officialDocs": 60,
1717
"thirdParty": 0,
18-
"fallback": 7,
19-
"unmatched": 5
18+
"free": 5,
19+
"fallback": 0,
20+
"unmatched": 0
2021
},
21-
"status": "degraded"
22+
"review": {
23+
"thirdParty": [],
24+
"free": [
25+
"inclusionai/ling-3.0-flash-free",
26+
"minimax/minimax-m2.7-free",
27+
"MiniMaxAI/MiniMax-M3-Free",
28+
"minimax/minimax-m3-free",
29+
"tencent/Hy3"
30+
],
31+
"unmatched": []
32+
},
33+
"status": "healthy"
2234
}

models.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -793,8 +793,8 @@
793793
"reasoning": true,
794794
"tool_call": true,
795795
"cost": {
796-
"input": 0.5,
797-
"output": 2
796+
"input": 0,
797+
"output": 0
798798
},
799799
"limit": {
800800
"context": 256000,
@@ -872,8 +872,8 @@
872872
"reasoning": false,
873873
"tool_call": true,
874874
"cost": {
875-
"input": 0.5,
876-
"output": 2
875+
"input": 0,
876+
"output": 0
877877
},
878878
"limit": {
879879
"context": 197000,
@@ -903,8 +903,8 @@
903903
"reasoning": true,
904904
"tool_call": true,
905905
"cost": {
906-
"input": 0.5,
907-
"output": 2
906+
"input": 0,
907+
"output": 0
908908
},
909909
"limit": {
910910
"context": 1000000,
@@ -918,8 +918,8 @@
918918
"reasoning": true,
919919
"tool_call": true,
920920
"cost": {
921-
"input": 0.5,
922-
"output": 2
921+
"input": 0,
922+
"output": 0
923923
},
924924
"limit": {
925925
"context": 1000000,
@@ -1193,8 +1193,8 @@
11931193
"reasoning": true,
11941194
"tool_call": true,
11951195
"cost": {
1196-
"input": 0.5,
1197-
"output": 2
1196+
"input": 0,
1197+
"output": 0
11981198
},
11991199
"limit": {
12001200
"context": 262144,

plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export default async function commandcodePlugin() {
117117
degraded = true
118118
degradedReason = manifest.status === "broken"
119119
? "bundled catalog marked broken"
120-
: "bundled catalog costs include fallback or unmatched models"
120+
: "bundled catalog has models with no listed price"
121121
}
122122
} else {
123123
const cached = readCatalogCache()

scripts/catalog-release-notes.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
#!/usr/bin/env bun
22
import { readFileSync } from "fs"
33
import { join } from "path"
4-
import { FALLBACK_COSTS } from "../src/catalog.js"
54
import { catalogReleaseNotesFromFiles } from "../src/catalog-release-notes.js"
65
import type { CatalogManifest } from "../src/manifest.js"
76
import type { PricedModel } from "../src/catalog-release-notes.js"
87

98
const ROOT = join(import.meta.dir, "..")
109
const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf-8")) as CatalogManifest
1110
const models = JSON.parse(readFileSync(join(ROOT, "models.json"), "utf-8")) as PricedModel[]
12-
process.stdout.write(
13-
catalogReleaseNotesFromFiles({
14-
manifest,
15-
models,
16-
fallbackCosts: FALLBACK_COSTS,
17-
}),
18-
)
11+
process.stdout.write(catalogReleaseNotesFromFiles({ manifest, models }))

scripts/sync-models.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { homedir, tmpdir } from "os"
44
import { execSync } from "child_process"
55
import {
66
NPM_PACKAGE,
7-
FALLBACK_COSTS,
87
extractCostData,
98
buildCostMap,
109
generateOpencodeModels,
@@ -13,6 +12,12 @@ import {
1312
type ModelEntry,
1413
} from "../src/catalog.js"
1514
import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js"
15+
import {
16+
applyFreeCosts,
17+
applyModelsDevCosts,
18+
fetchModelsDevJson,
19+
parseModelsDev,
20+
} from "../src/costs-models-dev.js"
1621
import {
1722
buildManifest,
1823
commandCodeTarballUrl,
@@ -171,20 +176,39 @@ async function main() {
171176
const filled = applyDocCosts(entries, parseModelsTable(docsMd), cliIds, docIds)
172177
console.log(` Applied official-docs costs to ${filled} models (${cliIds.size} kept CLI costs)`)
173178
} else {
174-
console.log(" Official docs returned no parseable cost table; keeping CLI/fallback costs")
179+
console.log(" Official docs returned no parseable cost table; keeping CLI costs")
180+
}
181+
182+
const priced = new Set([...cliIds, ...docIds])
183+
const freeIds = new Set<string>()
184+
const freeFilled = applyFreeCosts(entries, priced, freeIds)
185+
console.log(` Applied free SKU $0 to ${freeFilled} models`)
186+
187+
const thirdPartyIds = new Set<string>()
188+
const skipAfterFree = new Set([...priced, ...freeIds])
189+
try {
190+
const modelsDevJson = await fetchModelsDevJson()
191+
const filled = applyModelsDevCosts(entries, parseModelsDev(modelsDevJson), skipAfterFree, thirdPartyIds)
192+
console.log(` Applied models.dev costs to ${filled} models`)
193+
} catch (err) {
194+
const message = err instanceof Error ? err.message : String(err)
195+
console.log(` models.dev fill skipped: ${message}`)
175196
}
176197

177198
console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`)
178199
writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8")
179200
writeFileSync(VERSION_PATH, `${version}\n`, "utf-8")
180201

181202
const pluginVersion = (JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { version: string }).version
203+
const unmatchedIds = entries
204+
.filter((e) => !cliIds.has(e.id) && !docIds.has(e.id) && !freeIds.has(e.id) && !thirdPartyIds.has(e.id))
205+
.map((e) => e.id)
182206
const costSources = countCostSources({
183207
modelIds: entries.map((e) => e.id),
184208
cliIds,
185209
officialDocIds: docIds,
186-
thirdPartyIds: new Set(),
187-
fallbackIds: new Set(Object.keys(FALLBACK_COSTS)),
210+
thirdPartyIds,
211+
freeIds,
188212
})
189213
writeManifest(
190214
MANIFEST_PATH,
@@ -196,6 +220,11 @@ async function main() {
196220
reasoningModelCount: entries.filter((e) => e.reasoning).length,
197221
modelCatalogOk: true,
198222
costSources,
223+
review: {
224+
thirdParty: [...thirdPartyIds],
225+
free: [...freeIds],
226+
unmatched: unmatchedIds,
227+
},
199228
generatedAt: new Date().toISOString(),
200229
}),
201230
)

0 commit comments

Comments
 (0)