|
| 1 | +# Catalog Modalities Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Spec:** `docs/2026-08-28-catalog-modalities/spec.md` |
| 6 | +**Branch:** `feature/2026-08-28-catalog-modalities` |
| 7 | + |
| 8 | +**Goal:** Bundled catalog models expose OpenCode `attachment` and `modalities` copied from models.dev, with unmatched SKUs defaulting to text-only. |
| 9 | + |
| 10 | +**Architecture:** Extend the existing models.dev parser and matching index. A new `applyModelsDevModalities` runs on every catalog model (unlike the cost skip set). `generateOpencodeModels` always emits the fields. Sync uses one models.dev fetch for both costs and modalities. |
| 11 | + |
| 12 | +**Tech Stack:** TypeScript, Bun (`bun test tests/unit/`), existing `src/costs-models-dev.ts` + `src/catalog.ts`. |
| 13 | + |
| 14 | +## Global Constraints |
| 15 | + |
| 16 | +- No vision allowlist. No session-prompt injection. No overwriting `limit` / `reasoning` / `tool_call`. |
| 17 | +- Runtime does not fetch models.dev. |
| 18 | +- Tests: `bun test tests/unit/` from `/home/cristhofer-pincetti/Documents/projects/personal/opencode-commandcode-provider`. |
| 19 | +- Plugin imports stay `.js`; tests import `.ts`. |
| 20 | +- Do not fold this package into workit. PRs against `BrainerVirus/opencode-commandcode` base `main`. |
| 21 | +- Implementation on `feature/2026-08-28-catalog-modalities` (in-place checkout, no worktrees). |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +### Task 1: Parse and apply modalities from models.dev |
| 26 | + |
| 27 | +**Files:** |
| 28 | +- Modify: `src/costs-models-dev.ts` |
| 29 | +- Modify: `tests/fixtures/models-dev/api.subset.json` |
| 30 | +- Test: `tests/unit/costs-models-dev.test.ts` |
| 31 | + |
| 32 | +**Interfaces:** |
| 33 | +- Consumes: existing `parseModelsDev`, `findRow` / `indexRows` matching |
| 34 | +- Produces: |
| 35 | + - `TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] }` |
| 36 | + - `ModelsDevRow` gains optional `attachment?: boolean` and `modalities?: { input: string[]; output: string[] }` |
| 37 | + - `applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRow[]): number` — returns how many models matched a models.dev row with explicit attachment/modalities; every model is assigned fields |
| 38 | + |
| 39 | +- [ ] **Step 1: Write the failing test** |
| 40 | + |
| 41 | +Add vision + text-only rows to `tests/fixtures/models-dev/api.subset.json`: |
| 42 | + |
| 43 | +```json |
| 44 | +{ |
| 45 | + "google": { |
| 46 | + "models": { |
| 47 | + "google/gemini-3.5-flash": { |
| 48 | + "id": "google/gemini-3.5-flash", |
| 49 | + "name": "Gemini 3.5 Flash", |
| 50 | + "attachment": true, |
| 51 | + "modalities": { |
| 52 | + "input": ["text", "image", "video", "audio", "pdf"], |
| 53 | + "output": ["text"] |
| 54 | + }, |
| 55 | + "cost": { "input": 1.5, "output": 9, "cache_read": 0.15 } |
| 56 | + } |
| 57 | + } |
| 58 | + }, |
| 59 | + "tencent": { |
| 60 | + "models": { |
| 61 | + "tencent/hy4-preview": { |
| 62 | + "id": "tencent/hy4-preview", |
| 63 | + "name": "Tencent Hy4 Preview", |
| 64 | + "attachment": false, |
| 65 | + "modalities": { "input": ["text"], "output": ["text"] }, |
| 66 | + "cost": { "input": 0.834, "output": 2.501, "cache_read": 0.042 } |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +Keep the existing `zai`, `vercel`, and `qwen` objects. Qwen stays cost-only (no modalities in the fixture) so the apply function must default it to text-only. |
| 74 | + |
| 75 | +In `tests/unit/costs-models-dev.test.ts`, add: |
| 76 | + |
| 77 | +```ts |
| 78 | +import { applyModelsDevModalities, TEXT_ONLY_MODALITIES } from "../../src/costs-models-dev.ts"; |
| 79 | + |
| 80 | +describe("applyModelsDevModalities", () => { |
| 81 | + const rows = parseModelsDev( |
| 82 | + readFileSync(join(import.meta.dir, "../fixtures/models-dev/api.subset.json"), "utf-8"), |
| 83 | + ); |
| 84 | + |
| 85 | + test("copies vision modalities and defaults unmatched plus cost-only rows to text-only", () => { |
| 86 | + const models = [ |
| 87 | + model({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }), |
| 88 | + model({ id: "tencent/hy4-preview", name: "Tencent Hy4 Preview" }), |
| 89 | + model({ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }), |
| 90 | + model({ id: "unknown/not-on-models-dev", name: "Unknown" }), |
| 91 | + model({ |
| 92 | + id: "inclusionai/ling-3.0-flash-free", |
| 93 | + name: "Ling 3.0 Flash Free", |
| 94 | + cost: { input: 0, output: 0 }, |
| 95 | + }), |
| 96 | + ]; |
| 97 | + const n = applyModelsDevModalities(models, rows); |
| 98 | + expect(n).toBe(2); |
| 99 | + expect(models[0].attachment).toBe(true); |
| 100 | + expect(models[0].modalities).toEqual({ |
| 101 | + input: ["text", "image", "video", "audio", "pdf"], |
| 102 | + output: ["text"], |
| 103 | + }); |
| 104 | + expect(models[1].attachment).toBe(false); |
| 105 | + expect(models[1].modalities).toEqual(TEXT_ONLY_MODALITIES); |
| 106 | + expect(models[2].attachment).toBe(false); |
| 107 | + expect(models[2].modalities).toEqual(TEXT_ONLY_MODALITIES); |
| 108 | + expect(models[3].attachment).toBe(false); |
| 109 | + expect(models[3].modalities).toEqual(TEXT_ONLY_MODALITIES); |
| 110 | + expect(models[4].attachment).toBe(false); |
| 111 | + expect(models[4].modalities).toEqual(TEXT_ONLY_MODALITIES); |
| 112 | + }); |
| 113 | +}); |
| 114 | +``` |
| 115 | + |
| 116 | +- [ ] **Step 2: Run test to verify it fails** |
| 117 | + |
| 118 | +Run: `bun test tests/unit/costs-models-dev.test.ts` |
| 119 | + |
| 120 | +Expected: FAIL — `applyModelsDevModalities` / `TEXT_ONLY_MODALITIES` not exported. |
| 121 | + |
| 122 | +- [ ] **Step 3: Write minimal implementation** |
| 123 | + |
| 124 | +In `src/costs-models-dev.ts`: |
| 125 | + |
| 126 | +```ts |
| 127 | +export const TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] } as const; |
| 128 | + |
| 129 | +export type ModelsDevRow = { |
| 130 | + id: string; |
| 131 | + name: string; |
| 132 | + cost: { input: number; output: number; cache_read?: number; cache_write?: number }; |
| 133 | + attachment?: boolean; |
| 134 | + modalities?: { input: string[]; output: string[] }; |
| 135 | +}; |
| 136 | +``` |
| 137 | + |
| 138 | +Extend `ModelsDevModel` with optional `attachment?: boolean` and `modalities?: { input?: string[]; output?: string[] }`. |
| 139 | + |
| 140 | +In `parseModelsDev`, after building `cost`, copy: |
| 141 | + |
| 142 | +```ts |
| 143 | +if (typeof model.attachment === "boolean") row.attachment = model.attachment; |
| 144 | +const input = model.modalities?.input?.filter((x) => typeof x === "string"); |
| 145 | +const output = model.modalities?.output?.filter((x) => typeof x === "string"); |
| 146 | +if (input?.length || output?.length) { |
| 147 | + row.modalities = { |
| 148 | + input: input?.length ? input : ["text"], |
| 149 | + output: output?.length ? output : ["text"], |
| 150 | + }; |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +Add `applyModelsDevModalities` using the existing `indexRows` / `findRow`. For each model: if the row has `modalities` or `attachment !== undefined`, copy them (`attachment` defaults to `modalities.input.includes("image")` when omitted); otherwise assign `attachment: false` and `{ ...TEXT_ONLY_MODALITIES }` (spread into a mutable `{ input: string[]; output: string[] }`). Return the count of models that used an explicit models.dev attachment/modalities (not the default). |
| 155 | + |
| 156 | +Extend `ModelEntry` in `src/catalog.ts`: |
| 157 | + |
| 158 | +```ts |
| 159 | +attachment?: boolean; |
| 160 | +modalities?: { input: string[]; output: string[] }; |
| 161 | +``` |
| 162 | + |
| 163 | +- [ ] **Step 4: Run test to verify it passes** |
| 164 | + |
| 165 | +Run: `bun test tests/unit/costs-models-dev.test.ts` |
| 166 | + |
| 167 | +Expected: PASS |
| 168 | + |
| 169 | +- [ ] **Step 5: Commit** (only if the user asked to commit) |
| 170 | + |
| 171 | +```bash |
| 172 | +git add src/costs-models-dev.ts src/catalog.ts tests/unit/costs-models-dev.test.ts tests/fixtures/models-dev/api.subset.json |
| 173 | +git commit -m "feat: copy vision and text-only modalities from models.dev" |
| 174 | +``` |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +### Task 2: Emit attachment and modalities on OpenCode models |
| 179 | + |
| 180 | +**Files:** |
| 181 | +- Modify: `src/catalog.ts` (`generateOpencodeModels`) |
| 182 | +- Test: `tests/unit/catalog.test.ts` |
| 183 | + |
| 184 | +**Interfaces:** |
| 185 | +- Consumes: `ModelEntry.attachment` / `ModelEntry.modalities` from Task 1 |
| 186 | +- Produces: each OpenCode model object includes `attachment: boolean` and `modalities: { input: string[]; output: string[] }`; missing fields emit text-only |
| 187 | + |
| 188 | +- [ ] **Step 1: Write the failing test** |
| 189 | + |
| 190 | +In `describe("generateOpencodeModels")` add: |
| 191 | + |
| 192 | +```ts |
| 193 | +test("emits attachment and modalities, defaulting to text-only", () => { |
| 194 | + const models = generateOpencodeModels([ |
| 195 | + { |
| 196 | + id: "google/gemini-3.5-flash", |
| 197 | + name: "Gemini 3.5 Flash", |
| 198 | + tier: "open-source", |
| 199 | + reasoning: false, |
| 200 | + tool_call: true, |
| 201 | + cost: { input: 1.5, output: 9 }, |
| 202 | + limit: { context: 1048576, output: 65536 }, |
| 203 | + attachment: true, |
| 204 | + modalities: { input: ["text", "image"], output: ["text"] }, |
| 205 | + }, |
| 206 | + { |
| 207 | + id: "tencent/hy4-preview", |
| 208 | + name: "Tencent Hy4 Preview", |
| 209 | + tier: "open-source", |
| 210 | + reasoning: true, |
| 211 | + tool_call: true, |
| 212 | + cost: { input: 0.834, output: 2.501 }, |
| 213 | + limit: { context: 1048576, output: 64000 }, |
| 214 | + }, |
| 215 | + ]); |
| 216 | + const gemini = models["gemini-3.5-flash"] as Record<string, unknown>; |
| 217 | + const hy4 = models["hy4-preview"] as Record<string, unknown>; |
| 218 | + expect(gemini.attachment).toBe(true); |
| 219 | + expect(gemini.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); |
| 220 | + expect(hy4.attachment).toBe(false); |
| 221 | + expect(hy4.modalities).toEqual({ input: ["text"], output: ["text"] }); |
| 222 | +}); |
| 223 | +``` |
| 224 | + |
| 225 | +- [ ] **Step 2: Run test to verify it fails** |
| 226 | + |
| 227 | +Run: `bun test tests/unit/catalog.test.ts` |
| 228 | + |
| 229 | +Expected: FAIL — `attachment` / `modalities` undefined on emitted models. |
| 230 | + |
| 231 | +- [ ] **Step 3: Write minimal implementation** |
| 232 | + |
| 233 | +In `generateOpencodeModels`, after building `model`: |
| 234 | + |
| 235 | +```ts |
| 236 | +model.attachment = entry.attachment ?? false; |
| 237 | +model.modalities = entry.modalities ?? { input: ["text"], output: ["text"] }; |
| 238 | +``` |
| 239 | + |
| 240 | +- [ ] **Step 4: Run test to verify it passes** |
| 241 | + |
| 242 | +Run: `bun test tests/unit/catalog.test.ts` |
| 243 | + |
| 244 | +Expected: PASS |
| 245 | + |
| 246 | +- [ ] **Step 5: Commit** (only if the user asked to commit) |
| 247 | + |
| 248 | +```bash |
| 249 | +git add src/catalog.ts tests/unit/catalog.test.ts |
| 250 | +git commit -m "feat: emit OpenCode attachment and modalities" |
| 251 | +``` |
| 252 | + |
| 253 | +--- |
| 254 | + |
| 255 | +### Task 3: Wire sync, refresh catalog, document |
| 256 | + |
| 257 | +**Files:** |
| 258 | +- Modify: `scripts/sync-models.ts` |
| 259 | +- Modify: `README.md` (What this package adds) |
| 260 | +- Modify: `CHANGELOG.md` via `workit_changelog_apply` |
| 261 | +- Modify: `models.json` (from `bun run sync`) |
| 262 | + |
| 263 | +**Interfaces:** |
| 264 | +- Consumes: `parseModelsDev`, `applyModelsDevCosts`, `applyModelsDevModalities` |
| 265 | +- Produces: one models.dev parse shared by costs and modalities; if fetch throws, still `applyModelsDevModalities(entries, [])` so every written model is text-only tagged |
| 266 | + |
| 267 | +- [ ] **Step 1: Write the failing test** |
| 268 | + |
| 269 | +No new unit test for the script. Assert the sync wiring by reading `scripts/sync-models.ts` in the step 4 check: `applyModelsDevModalities` is called with the same parsed rows, and the catch path still applies empty rows. |
| 270 | + |
| 271 | +- [ ] **Step 2: Run existing tests (still pass; wiring not covered)** |
| 272 | + |
| 273 | +Run: `bun test tests/unit/` |
| 274 | + |
| 275 | +Expected: PASS (Task 1–2 tests). |
| 276 | + |
| 277 | +- [ ] **Step 3: Write minimal implementation** |
| 278 | + |
| 279 | +In `scripts/sync-models.ts`, import `applyModelsDevModalities`. After the models.dev try/catch, always apply modalities: |
| 280 | + |
| 281 | +```ts |
| 282 | +let modelsDevRows: ReturnType<typeof parseModelsDev> = []; |
| 283 | +try { |
| 284 | + const modelsDevJson = await fetchModelsDevJson(); |
| 285 | + modelsDevRows = parseModelsDev(modelsDevJson); |
| 286 | + const filled = applyModelsDevCosts(entries, modelsDevRows, skipAfterFree, thirdPartyIds); |
| 287 | + console.log(` Applied models.dev costs to ${filled} models`); |
| 288 | +} catch (err) { |
| 289 | + const message = err instanceof Error ? err.message : String(err); |
| 290 | + console.log(` models.dev fill skipped: ${message}`); |
| 291 | +} |
| 292 | +const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); |
| 293 | +console.log(` Applied models.dev modalities to ${modalityFilled} models`); |
| 294 | +``` |
| 295 | + |
| 296 | +README, under “What this package adds”, add: vision vs text-only (`attachment` / `modalities`) is copied from models.dev during catalog sync; unmatched models are text-only. |
| 297 | + |
| 298 | +Changelog Unreleased / Added: catalog models include vision and text-only metadata from models.dev. |
| 299 | + |
| 300 | +Run: `bun run sync` from the repo root (needs network). Then `bun run check`. |
| 301 | + |
| 302 | +- [ ] **Step 4: Verify** |
| 303 | + |
| 304 | +- `bun run check` passes |
| 305 | +- `models.json` contains `"attachment"` and `"modalities"` on `tencent/hy4-preview` with `attachment: false` and input `["text"]` |
| 306 | +- A known vision id (e.g. `google/gemini-3.5-flash`) has `attachment: true` and `"image"` in `modalities.input` |
| 307 | + |
| 308 | +- [ ] **Step 5: Commit** (only if the user asked to commit) |
| 309 | + |
| 310 | +```bash |
| 311 | +git add scripts/sync-models.ts README.md CHANGELOG.md models.json manifest.json |
| 312 | +git commit -m "feat: sync OpenCode modalities from models.dev" |
| 313 | +``` |
0 commit comments