From 92f4dbfa10aac71b67e87f9f023a9aa49b436fa5 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Fri, 28 Aug 2026 17:02:55 -0400 Subject: [PATCH 1/3] docs: spec and plan for catalog modalities from models.dev Co-authored-by: Cursor --- docs/2026-08-28-catalog-modalities/plan.md | 313 +++++++++++++++++++++ docs/2026-08-28-catalog-modalities/spec.md | 62 ++++ 2 files changed, 375 insertions(+) create mode 100644 docs/2026-08-28-catalog-modalities/plan.md create mode 100644 docs/2026-08-28-catalog-modalities/spec.md diff --git a/docs/2026-08-28-catalog-modalities/plan.md b/docs/2026-08-28-catalog-modalities/plan.md new file mode 100644 index 0000000..a4f95e1 --- /dev/null +++ b/docs/2026-08-28-catalog-modalities/plan.md @@ -0,0 +1,313 @@ +# Catalog Modalities Implementation Plan + +> **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. + +**Spec:** `docs/2026-08-28-catalog-modalities/spec.md` +**Branch:** `feature/2026-08-28-catalog-modalities` + +**Goal:** Bundled catalog models expose OpenCode `attachment` and `modalities` copied from models.dev, with unmatched SKUs defaulting to text-only. + +**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. + +**Tech Stack:** TypeScript, Bun (`bun test tests/unit/`), existing `src/costs-models-dev.ts` + `src/catalog.ts`. + +## Global Constraints + +- No vision allowlist. No session-prompt injection. No overwriting `limit` / `reasoning` / `tool_call`. +- Runtime does not fetch models.dev. +- Tests: `bun test tests/unit/` from `/home/cristhofer-pincetti/Documents/projects/personal/opencode-commandcode-provider`. +- Plugin imports stay `.js`; tests import `.ts`. +- Do not fold this package into workit. PRs against `BrainerVirus/opencode-commandcode` base `main`. +- Implementation on `feature/2026-08-28-catalog-modalities` (in-place checkout, no worktrees). + +--- + +### Task 1: Parse and apply modalities from models.dev + +**Files:** +- Modify: `src/costs-models-dev.ts` +- Modify: `tests/fixtures/models-dev/api.subset.json` +- Test: `tests/unit/costs-models-dev.test.ts` + +**Interfaces:** +- Consumes: existing `parseModelsDev`, `findRow` / `indexRows` matching +- Produces: + - `TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] }` + - `ModelsDevRow` gains optional `attachment?: boolean` and `modalities?: { input: string[]; output: string[] }` + - `applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRow[]): number` — returns how many models matched a models.dev row with explicit attachment/modalities; every model is assigned fields + +- [ ] **Step 1: Write the failing test** + +Add vision + text-only rows to `tests/fixtures/models-dev/api.subset.json`: + +```json +{ + "google": { + "models": { + "google/gemini-3.5-flash": { + "id": "google/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "attachment": true, + "modalities": { + "input": ["text", "image", "video", "audio", "pdf"], + "output": ["text"] + }, + "cost": { "input": 1.5, "output": 9, "cache_read": 0.15 } + } + } + }, + "tencent": { + "models": { + "tencent/hy4-preview": { + "id": "tencent/hy4-preview", + "name": "Tencent Hy4 Preview", + "attachment": false, + "modalities": { "input": ["text"], "output": ["text"] }, + "cost": { "input": 0.834, "output": 2.501, "cache_read": 0.042 } + } + } + } +} +``` + +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. + +In `tests/unit/costs-models-dev.test.ts`, add: + +```ts +import { applyModelsDevModalities, TEXT_ONLY_MODALITIES } from "../../src/costs-models-dev.ts"; + +describe("applyModelsDevModalities", () => { + const rows = parseModelsDev( + readFileSync(join(import.meta.dir, "../fixtures/models-dev/api.subset.json"), "utf-8"), + ); + + test("copies vision modalities and defaults unmatched plus cost-only rows to text-only", () => { + const models = [ + model({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }), + model({ id: "tencent/hy4-preview", name: "Tencent Hy4 Preview" }), + model({ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }), + model({ id: "unknown/not-on-models-dev", name: "Unknown" }), + model({ + id: "inclusionai/ling-3.0-flash-free", + name: "Ling 3.0 Flash Free", + cost: { input: 0, output: 0 }, + }), + ]; + const n = applyModelsDevModalities(models, rows); + expect(n).toBe(2); + expect(models[0].attachment).toBe(true); + expect(models[0].modalities).toEqual({ + input: ["text", "image", "video", "audio", "pdf"], + output: ["text"], + }); + expect(models[1].attachment).toBe(false); + expect(models[1].modalities).toEqual(TEXT_ONLY_MODALITIES); + expect(models[2].attachment).toBe(false); + expect(models[2].modalities).toEqual(TEXT_ONLY_MODALITIES); + expect(models[3].attachment).toBe(false); + expect(models[3].modalities).toEqual(TEXT_ONLY_MODALITIES); + expect(models[4].attachment).toBe(false); + expect(models[4].modalities).toEqual(TEXT_ONLY_MODALITIES); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/unit/costs-models-dev.test.ts` + +Expected: FAIL — `applyModelsDevModalities` / `TEXT_ONLY_MODALITIES` not exported. + +- [ ] **Step 3: Write minimal implementation** + +In `src/costs-models-dev.ts`: + +```ts +export const TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] } as const; + +export type ModelsDevRow = { + id: string; + name: string; + cost: { input: number; output: number; cache_read?: number; cache_write?: number }; + attachment?: boolean; + modalities?: { input: string[]; output: string[] }; +}; +``` + +Extend `ModelsDevModel` with optional `attachment?: boolean` and `modalities?: { input?: string[]; output?: string[] }`. + +In `parseModelsDev`, after building `cost`, copy: + +```ts +if (typeof model.attachment === "boolean") row.attachment = model.attachment; +const input = model.modalities?.input?.filter((x) => typeof x === "string"); +const output = model.modalities?.output?.filter((x) => typeof x === "string"); +if (input?.length || output?.length) { + row.modalities = { + input: input?.length ? input : ["text"], + output: output?.length ? output : ["text"], + }; +} +``` + +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). + +Extend `ModelEntry` in `src/catalog.ts`: + +```ts +attachment?: boolean; +modalities?: { input: string[]; output: string[] }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/unit/costs-models-dev.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** (only if the user asked to commit) + +```bash +git add src/costs-models-dev.ts src/catalog.ts tests/unit/costs-models-dev.test.ts tests/fixtures/models-dev/api.subset.json +git commit -m "feat: copy vision and text-only modalities from models.dev" +``` + +--- + +### Task 2: Emit attachment and modalities on OpenCode models + +**Files:** +- Modify: `src/catalog.ts` (`generateOpencodeModels`) +- Test: `tests/unit/catalog.test.ts` + +**Interfaces:** +- Consumes: `ModelEntry.attachment` / `ModelEntry.modalities` from Task 1 +- Produces: each OpenCode model object includes `attachment: boolean` and `modalities: { input: string[]; output: string[] }`; missing fields emit text-only + +- [ ] **Step 1: Write the failing test** + +In `describe("generateOpencodeModels")` add: + +```ts +test("emits attachment and modalities, defaulting to text-only", () => { + const models = generateOpencodeModels([ + { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 1.5, output: 9 }, + limit: { context: 1048576, output: 65536 }, + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + }, + { + id: "tencent/hy4-preview", + name: "Tencent Hy4 Preview", + tier: "open-source", + reasoning: true, + tool_call: true, + cost: { input: 0.834, output: 2.501 }, + limit: { context: 1048576, output: 64000 }, + }, + ]); + const gemini = models["gemini-3.5-flash"] as Record; + const hy4 = models["hy4-preview"] as Record; + expect(gemini.attachment).toBe(true); + expect(gemini.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); + expect(hy4.attachment).toBe(false); + expect(hy4.modalities).toEqual({ input: ["text"], output: ["text"] }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test tests/unit/catalog.test.ts` + +Expected: FAIL — `attachment` / `modalities` undefined on emitted models. + +- [ ] **Step 3: Write minimal implementation** + +In `generateOpencodeModels`, after building `model`: + +```ts +model.attachment = entry.attachment ?? false; +model.modalities = entry.modalities ?? { input: ["text"], output: ["text"] }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test tests/unit/catalog.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** (only if the user asked to commit) + +```bash +git add src/catalog.ts tests/unit/catalog.test.ts +git commit -m "feat: emit OpenCode attachment and modalities" +``` + +--- + +### Task 3: Wire sync, refresh catalog, document + +**Files:** +- Modify: `scripts/sync-models.ts` +- Modify: `README.md` (What this package adds) +- Modify: `CHANGELOG.md` via `workit_changelog_apply` +- Modify: `models.json` (from `bun run sync`) + +**Interfaces:** +- Consumes: `parseModelsDev`, `applyModelsDevCosts`, `applyModelsDevModalities` +- Produces: one models.dev parse shared by costs and modalities; if fetch throws, still `applyModelsDevModalities(entries, [])` so every written model is text-only tagged + +- [ ] **Step 1: Write the failing test** + +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. + +- [ ] **Step 2: Run existing tests (still pass; wiring not covered)** + +Run: `bun test tests/unit/` + +Expected: PASS (Task 1–2 tests). + +- [ ] **Step 3: Write minimal implementation** + +In `scripts/sync-models.ts`, import `applyModelsDevModalities`. After the models.dev try/catch, always apply modalities: + +```ts +let modelsDevRows: ReturnType = []; +try { + const modelsDevJson = await fetchModelsDevJson(); + modelsDevRows = parseModelsDev(modelsDevJson); + const filled = applyModelsDevCosts(entries, modelsDevRows, skipAfterFree, thirdPartyIds); + console.log(` Applied models.dev costs to ${filled} models`); +} catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(` models.dev fill skipped: ${message}`); +} +const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); +console.log(` Applied models.dev modalities to ${modalityFilled} models`); +``` + +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. + +Changelog Unreleased / Added: catalog models include vision and text-only metadata from models.dev. + +Run: `bun run sync` from the repo root (needs network). Then `bun run check`. + +- [ ] **Step 4: Verify** + +- `bun run check` passes +- `models.json` contains `"attachment"` and `"modalities"` on `tencent/hy4-preview` with `attachment: false` and input `["text"]` +- A known vision id (e.g. `google/gemini-3.5-flash`) has `attachment: true` and `"image"` in `modalities.input` + +- [ ] **Step 5: Commit** (only if the user asked to commit) + +```bash +git add scripts/sync-models.ts README.md CHANGELOG.md models.json manifest.json +git commit -m "feat: sync OpenCode modalities from models.dev" +``` diff --git a/docs/2026-08-28-catalog-modalities/spec.md b/docs/2026-08-28-catalog-modalities/spec.md new file mode 100644 index 0000000..a3efd6f --- /dev/null +++ b/docs/2026-08-28-catalog-modalities/spec.md @@ -0,0 +1,62 @@ +# Spec: Catalog modalities from models.dev + +Status: approved (2026-08-28) +**Branch:** `feature/2026-08-28-catalog-modalities` + +Parent: [docs/2026-08-28-ci-catalog/spec.md](../2026-08-28-ci-catalog/spec.md) + +## Goals + +- G1: Every bundled catalog model carries OpenCode `attachment` and `modalities` copied from [models.dev](https://models.dev). +- G2: Unmatched models default to text-only (no vision guessing, no frozen allowlist). +- G3: Cost waterfall stays independent; modalities apply to all matching models including free SKUs. + +## Locked (do not reopen) + +- Source of truth is models.dev `attachment` + `modalities` (same fetch already used for costs). No human allowlist. No guessing vision. +- Cost waterfall stays independent. Modalities apply to **all** matching models, including free SKUs and models that already have CLI/docs prices. +- Unmatched models default to text-only: `attachment: false`, `modalities: { input: ["text"], output: ["text"] }`. +- Runtime still does not fetch models.dev. Sync writes the fields into `models.json`; `generateOpencodeModels` emits them (and the text-only default if a field is missing). +- Do not overwrite CLI `limit`, `reasoning`, or `tool_call` from models.dev. +- Do not inject capability text into the session prompt. Asking the chat model “what can you do?” is out of scope. + +## Context + +`generateOpencodeModels` currently emits `id`, `name`, `reasoning`, `tool_call`, `cost`, `limit`, and optional `reasoningEfforts`/`variants`. OpenCode uses `attachment` and `modalities.input` for vision. Hy4 Preview (`tencent/hy4-preview`) is text-only on models.dev with a 1,048,576 context window already present on `limit`; the model itself does not read that metadata. + +## Non-goals + +- Session/system-prompt injection of context window or vision flags +- Filling `limit` from models.dev +- Local OpenCode overlay `commandcode-modalities.ts` (becomes redundant after this ships; not deleted in this repo) +- Changing hybrid Provider API transport + +## Architecture + +```mermaid +flowchart TD + sync["bun run sync"] --> fetch[Fetch models.dev api.json] + fetch --> costs[applyModelsDevCosts skip priced/free] + fetch --> mods[applyModelsDevModalities all models] + mods --> unmatched[No match: text-only default] + costs --> json[models.json] + unmatched --> json + json --> emit[generateOpencodeModels] + emit --> oc["OpenCode attachment + modalities"] +``` + +Matching reuses the existing models.dev index: exact id, then last path segment, then display name (case-insensitive). Copy `modalities` arrays as-is (Gemini may include `video` / `audio` / `pdf`). `attachment` comes from models.dev when present, otherwise `modalities.input` includes `"image"`. + +## Acceptance criteria + +- CA-01: `parseModelsDev` retains `attachment` and `modalities` on rows that already have costs. +- CA-02: `applyModelsDevModalities` sets Gemini-style vision on a matching catalog id and text-only on Hy4 Preview / unmatched ids. +- CA-03: Free SKUs and CLI-priced models still receive modalities (not skipped the way costs are). +- CA-04: `generateOpencodeModels` always emits `attachment` and `modalities`; missing fields become text-only. +- CA-05: `bun run sync` applies modalities from the same models.dev JSON used for costs; if that fetch fails, every model still gets the text-only default before write. +- CA-06: README states that vision/text-only comes from models.dev. + +## Decisions + +- D-01: Conservative default is text-only, not “omit the field and let OpenCode guess”. +- D-02: Extra modality strings from models.dev are copied, not filtered to `text`/`image`. From 5f334e02c35d9bafb3454fd345354169837405be Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Fri, 28 Aug 2026 17:06:15 -0400 Subject: [PATCH 2/3] feat: copy vision and text-only metadata from models.dev Co-authored-by: Cursor --- CHANGELOG.md | 4 + README.md | 1 + manifest.json | 2 +- models.json | 799 ++++++++++++++++++++-- scripts/sync-models.ts | 12 +- src/catalog.ts | 4 + src/costs-models-dev.ts | 41 +- tests/fixtures/models-dev/api.subset.json | 16 + tests/unit/catalog.test.ts | 31 + tests/unit/costs-models-dev.test.ts | 37 + 10 files changed, 875 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d8638..35468af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - npm publishes only when plugin or catalog files change since the last tag — CI, tests, and scripts-only merges skip a release. +### Added + +- Catalog models include vision vs text-only metadata (`attachment` / `modalities`) copied from models.dev; unmatched models default to text-only. + ## [0.6.1] - 2026-08-28 ### Added diff --git a/README.md b/README.md index dbe6e4e..c230df2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - Bundled `models.json` is the default runtime catalog (no local CLI scrape). - CLI cost extraction can fail (as on `command-code@1.38.x`) without dropping models. - Official docs fill missing costs; remaining paid gaps use [models.dev](https://models.dev) as a reference. Command Code free SKUs stay `$0`. +- Vision vs text-only (`attachment` / `modalities`) is copied from models.dev during catalog sync. Unmatched models are text-only. - Reasoning effort **variants** on models that declare `reasoningEfforts`. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). diff --git a/manifest.json b/manifest.json index 42caf7d..4b471b9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-08-28T19:50:57.386Z", + "generatedAt": "2026-08-28T21:04:48.971Z", "pluginVersion": "0.6.1", "commandCodeVersion": "1.38.1", "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", diff --git a/models.json b/models.json index bc8195a..2a37a07 100644 --- a/models.json +++ b/models.json @@ -21,7 +21,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "claude-haiku-4-5-20251001", @@ -38,7 +48,18 @@ "limit": { "context": 200000, "output": 8192 - } + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "claude-opus-4-7", @@ -62,7 +83,18 @@ "limit": { "context": 1000000, "output": 32000 - } + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "claude-opus-4-8", @@ -86,7 +118,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "claude-opus-5", @@ -110,7 +152,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "claude-sonnet-4-6", @@ -134,7 +186,18 @@ "limit": { "context": 1000000, "output": 16000 - } + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "claude-sonnet-5", @@ -158,7 +221,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.3-codex", @@ -180,7 +253,17 @@ "limit": { "context": 400000, "output": 128000 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.4", @@ -202,7 +285,18 @@ "limit": { "context": 400000, "output": 128000 - } + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.4-mini", @@ -223,7 +317,17 @@ "limit": { "context": 400000, "output": 128000 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.5", @@ -245,7 +349,17 @@ "limit": { "context": 400000, "output": 128000 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "deepseek/deepseek-v4-flash", @@ -265,7 +379,16 @@ "limit": { "context": 1000000, "output": 384000 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "deepseek/deepseek-v4-flash-vision-exp", @@ -285,7 +408,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "deepseek/deepseek-v4-pro", @@ -305,7 +438,16 @@ "limit": { "context": 1000000, "output": 384000 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "sakana/fugu-ultra", @@ -325,7 +467,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "google/gemini-3.1-flash-lite", @@ -346,7 +498,20 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "google/gemini-3.5-flash", @@ -367,7 +532,20 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "google/gemini-3.5-flash-lite", @@ -388,7 +566,20 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "google/gemini-3.6-flash", @@ -409,7 +600,20 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "google/gemini-3.7-flash", @@ -431,7 +635,20 @@ "limit": { "context": 1048576, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "zai-org/GLM-5", @@ -447,7 +664,16 @@ "limit": { "context": 200000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "zai-org/GLM-5.1", @@ -463,7 +689,16 @@ "limit": { "context": 200000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "zai-org/GLM-5.2", @@ -483,7 +718,16 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "zai-org/GLM-5.2-Fast", @@ -499,7 +743,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "zai-org/GLM-5.3", @@ -520,7 +774,16 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "z-ai/glm-5.3-flash", @@ -541,7 +804,18 @@ "limit": { "context": 1048576, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.6-luna", @@ -565,7 +839,17 @@ "limit": { "context": 1050000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.6-sol", @@ -589,7 +873,17 @@ "limit": { "context": 1050000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "gpt-5.6-terra", @@ -613,7 +907,17 @@ "limit": { "context": 1050000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "xai/grok-4.5", @@ -634,7 +938,17 @@ "limit": { "context": 500000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "xai/grok-4.6", @@ -656,7 +970,17 @@ "limit": { "context": 500000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "thinkingmachines/inkling", @@ -672,7 +996,17 @@ "limit": { "context": 256000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "thinkingmachines/inkling-small", @@ -688,7 +1022,16 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "moonshotai/Kimi-K2.5", @@ -704,7 +1047,17 @@ "limit": { "context": 256000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "moonshotai/Kimi-K2.6", @@ -720,7 +1073,18 @@ "limit": { "context": 256000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "moonshotai/Kimi-K2.7-Code", @@ -736,7 +1100,18 @@ "limit": { "context": 256000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "moonshotai/Kimi-K2.7-Code-Highspeed", @@ -752,7 +1127,16 @@ "limit": { "context": 262000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "moonshotai/Kimi-K3", @@ -768,7 +1152,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "poolside/laguna-s-2.1-free", @@ -784,7 +1179,16 @@ "limit": { "context": 256000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "inclusionai/ling-3.0-flash-free", @@ -799,7 +1203,16 @@ "limit": { "context": 256000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "xiaomi/mimo-v2.5", @@ -815,7 +1228,19 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "audio", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "xiaomi/mimo-v2.5-pro", @@ -831,7 +1256,16 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "MiniMaxAI/MiniMax-M2.5", @@ -847,7 +1281,16 @@ "limit": { "context": 200000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "MiniMaxAI/MiniMax-M2.7", @@ -863,7 +1306,16 @@ "limit": { "context": 1000000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "minimax/minimax-m2.7-free", @@ -878,7 +1330,16 @@ "limit": { "context": 197000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "MiniMaxAI/MiniMax-M3", @@ -894,7 +1355,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "MiniMaxAI/MiniMax-M3-Free", @@ -909,7 +1381,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "minimax/minimax-m3-free", @@ -924,7 +1407,17 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "meta/muse-spark-1.1", @@ -940,7 +1433,20 @@ "limit": { "context": 1048576, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "meta/muse-spark-1.2", @@ -956,7 +1462,20 @@ "limit": { "context": 1048576, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "meta/muse-spark-1.2-contributor", @@ -972,7 +1491,20 @@ "limit": { "context": 1048576, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video", + "pdf", + "audio" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "nvidia/nemotron-3-ultra-550b-a55b", @@ -988,7 +1520,16 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "Qwen/Qwen3.6-Max-Preview", @@ -1005,7 +1546,16 @@ "limit": { "context": 1000000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "Qwen/Qwen3.6-Plus", @@ -1021,7 +1571,18 @@ "limit": { "context": 1000000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "Qwen/Qwen3.7-Flash", @@ -1038,7 +1599,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "Qwen/Qwen3.7-Max", @@ -1055,7 +1627,16 @@ "limit": { "context": 1000000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "Qwen/Qwen3.7-Plus", @@ -1072,7 +1653,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "Qwen/Qwen3.8-27B", @@ -1093,7 +1685,17 @@ "limit": { "context": 262144, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "Qwen/Qwen3.8-Flash", @@ -1114,7 +1716,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "Qwen/Qwen3.8-Max", @@ -1136,7 +1749,18 @@ "limit": { "context": 1000000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "stepfun/Step-3.5-Flash", @@ -1152,7 +1776,16 @@ "limit": { "context": 1000000, "output": 131072 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "stepfun/Step-3.7-Flash", @@ -1168,7 +1801,18 @@ "limit": { "context": 256000, "output": 65536 - } + }, + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "attachment": true }, { "id": "tencent/hy3-paid", @@ -1184,6 +1828,15 @@ "limit": { "context": 262144, "output": 65536 + }, + "attachment": false, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] } }, { @@ -1199,7 +1852,16 @@ "limit": { "context": 262144, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false }, { "id": "tencent/hy4-preview", @@ -1220,6 +1882,15 @@ "limit": { "context": 1048576, "output": 65536 - } + }, + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "attachment": false } ] diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index 364d290..ce97e4d 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -15,6 +15,7 @@ import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from ".. import { applyFreeCosts, applyModelsDevCosts, + applyModelsDevModalities, fetchModelsDevJson, parseModelsDev, } from "../src/costs-models-dev.js"; @@ -186,19 +187,18 @@ async function main() { const thirdPartyIds = new Set(); const skipAfterFree = new Set([...priced, ...freeIds]); + let modelsDevRows: ReturnType = []; try { const modelsDevJson = await fetchModelsDevJson(); - const filled = applyModelsDevCosts( - entries, - parseModelsDev(modelsDevJson), - skipAfterFree, - thirdPartyIds, - ); + modelsDevRows = parseModelsDev(modelsDevJson); + const filled = applyModelsDevCosts(entries, modelsDevRows, skipAfterFree, thirdPartyIds); console.log(` Applied models.dev costs to ${filled} models`); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.log(` models.dev fill skipped: ${message}`); } + const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); + console.log(` Applied models.dev modalities to ${modalityFilled} models`); console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`); writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8"); diff --git a/src/catalog.ts b/src/catalog.ts index 213c9ba..006e054 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -15,6 +15,8 @@ export interface ModelEntry { tool_call: boolean; cost: { input: number; output: number; cache_read?: number; cache_write?: number }; limit: { context: number; output: number }; + attachment?: boolean; + modalities?: { input: string[]; output: string[] }; } export interface CostEntry { @@ -740,6 +742,8 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record }; @@ -42,7 +47,17 @@ export function parseModelsDev(json: string): ModelsDevRow[] { const cost: ModelsDevRow["cost"] = { input: model.cost.input, output: model.cost.output }; if (model.cost.cache_read !== undefined) cost.cache_read = model.cost.cache_read; if (model.cost.cache_write !== undefined) cost.cache_write = model.cost.cache_write; - rows.push({ id: model.id, name: model.name ?? model.id, cost }); + const row: ModelsDevRow = { id: model.id, name: model.name ?? model.id, cost }; + if (typeof model.attachment === "boolean") row.attachment = model.attachment; + const input = model.modalities?.input?.filter((x) => typeof x === "string"); + const output = model.modalities?.output?.filter((x) => typeof x === "string"); + if (input?.length || output?.length) { + row.modalities = { + input: input?.length ? input : [...TEXT_ONLY_MODALITIES.input], + output: output?.length ? output : [...TEXT_ONLY_MODALITIES.output], + }; + } + rows.push(row); } } return rows; @@ -86,6 +101,30 @@ export function applyFreeCosts( return filled; } +function textOnly(): { input: string[]; output: string[] } { + return { input: [...TEXT_ONLY_MODALITIES.input], output: [...TEXT_ONLY_MODALITIES.output] }; +} + +export function applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRow[]): number { + const index = indexRows(rows); + let filled = 0; + for (const model of models) { + const row = findRow(model, index); + if (row && (row.modalities || row.attachment !== undefined)) { + const modalities = row.modalities + ? { input: [...row.modalities.input], output: [...row.modalities.output] } + : textOnly(); + model.modalities = modalities; + model.attachment = row.attachment ?? modalities.input.includes("image"); + filled++; + } else { + model.attachment = false; + model.modalities = textOnly(); + } + } + return filled; +} + export function applyModelsDevCosts( models: ModelEntry[], rows: ModelsDevRow[], diff --git a/tests/fixtures/models-dev/api.subset.json b/tests/fixtures/models-dev/api.subset.json index 80fa860..cbc9dbd 100644 --- a/tests/fixtures/models-dev/api.subset.json +++ b/tests/fixtures/models-dev/api.subset.json @@ -4,10 +4,26 @@ "google/gemini-3.5-flash": { "id": "google/gemini-3.5-flash", "name": "Gemini 3.5 Flash", + "attachment": true, + "modalities": { + "input": ["text", "image", "video", "audio", "pdf"], + "output": ["text"] + }, "cost": { "input": 1.5, "output": 9, "cache_read": 0.15 } } } }, + "tencent": { + "models": { + "tencent/hy4-preview": { + "id": "tencent/hy4-preview", + "name": "Tencent Hy4 Preview", + "attachment": false, + "modalities": { "input": ["text"], "output": ["text"] }, + "cost": { "input": 0.834, "output": 2.501, "cache_read": 0.042 } + } + } + }, "zai": { "models": { "zai-org/GLM-5.1": { diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 7c40eb6..964331f 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -158,6 +158,37 @@ describe("generateOpencodeModels", () => { high: { reasoningEffort: "high" }, }); }); + + test("emits attachment and modalities, defaulting to text-only", () => { + const models = generateOpencodeModels([ + { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 1.5, output: 9 }, + limit: { context: 1048576, output: 65536 }, + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + }, + { + id: "tencent/hy4-preview", + name: "Tencent Hy4 Preview", + tier: "open-source", + reasoning: true, + tool_call: true, + cost: { input: 0.834, output: 2.501 }, + limit: { context: 1048576, output: 64000 }, + }, + ]); + const gemini = models["gemini-3.5-flash"] as Record; + const hy4 = models["hy4-preview"] as Record; + expect(gemini.attachment).toBe(true); + expect(gemini.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); + expect(hy4.attachment).toBe(false); + expect(hy4.modalities).toEqual({ input: ["text"], output: ["text"] }); + }); }); describe("loadCatalogFromBundle", () => { diff --git a/tests/unit/costs-models-dev.test.ts b/tests/unit/costs-models-dev.test.ts index d0a1489..16fca2d 100644 --- a/tests/unit/costs-models-dev.test.ts +++ b/tests/unit/costs-models-dev.test.ts @@ -4,8 +4,10 @@ import { join } from "path"; import { applyFreeCosts, applyModelsDevCosts, + applyModelsDevModalities, isFreeSku, parseModelsDev, + TEXT_ONLY_MODALITIES, } from "../../src/costs-models-dev.ts"; import type { ModelEntry } from "../../src/catalog.ts"; @@ -75,6 +77,41 @@ describe("parseModelsDev + applyModelsDevCosts", () => { }); }); +describe("applyModelsDevModalities", () => { + const rows = parseModelsDev( + readFileSync(join(import.meta.dir, "../fixtures/models-dev/api.subset.json"), "utf-8"), + ); + + test("copies vision modalities and defaults unmatched plus cost-only rows to text-only", () => { + const models = [ + model({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }), + model({ id: "tencent/hy4-preview", name: "Tencent Hy4 Preview" }), + model({ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }), + model({ id: "unknown/not-on-models-dev", name: "Unknown" }), + model({ + id: "inclusionai/ling-3.0-flash-free", + name: "Ling 3.0 Flash Free", + cost: { input: 0, output: 0 }, + }), + ]; + const n = applyModelsDevModalities(models, rows); + expect(n).toBe(2); + expect(models[0].attachment).toBe(true); + expect(models[0].modalities).toEqual({ + input: ["text", "image", "video", "audio", "pdf"], + output: ["text"], + }); + expect(models[1].attachment).toBe(false); + expect(models[1].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); + expect(models[2].attachment).toBe(false); + expect(models[2].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); + expect(models[3].attachment).toBe(false); + expect(models[3].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); + expect(models[4].attachment).toBe(false); + expect(models[4].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); + }); +}); + describe("applyFreeCosts", () => { test("sets free SKUs to zero and leaves paid models alone", () => { const models = [ From 46a435d4e921040c6ac1dee4cf462798371a50cd Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Fri, 28 Aug 2026 17:15:40 -0400 Subject: [PATCH 3/3] feat: take vision flags from Command Code CLI inputModalities Co-authored-by: Cursor --- CHANGELOG.md | 2 +- README.md | 2 +- docs/2026-08-28-catalog-modalities/spec.md | 58 +++-- manifest.json | 2 +- models.json | 270 +++++++++++---------- src/catalog.ts | 19 +- src/costs-models-dev.ts | 13 + tests/unit/catalog.test.ts | 47 +++- tests/unit/costs-models-dev.test.ts | 35 +-- 9 files changed, 260 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35468af..ce2c1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Catalog models include vision vs text-only metadata (`attachment` / `modalities`) copied from models.dev; unmatched models default to text-only. +- Catalog models include vision vs text-only from the Command Code CLI `inputModalities` field on every SKU. models.dev only adds extra inputs (video/audio/pdf) on matches. ## [0.6.1] - 2026-08-28 diff --git a/README.md b/README.md index c230df2..368f560 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - Bundled `models.json` is the default runtime catalog (no local CLI scrape). - CLI cost extraction can fail (as on `command-code@1.38.x`) without dropping models. - Official docs fill missing costs; remaining paid gaps use [models.dev](https://models.dev) as a reference. Command Code free SKUs stay `$0`. -- Vision vs text-only (`attachment` / `modalities`) is copied from models.dev during catalog sync. Unmatched models are text-only. +- Vision vs text-only comes from the Command Code CLI catalog (`inputModalities` on every SKU). [models.dev](https://models.dev) only adds extra inputs (video/audio/pdf) when it matches. - Reasoning effort **variants** on models that declare `reasoningEfforts`. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). diff --git a/docs/2026-08-28-catalog-modalities/spec.md b/docs/2026-08-28-catalog-modalities/spec.md index a3efd6f..5eff6a5 100644 --- a/docs/2026-08-28-catalog-modalities/spec.md +++ b/docs/2026-08-28-catalog-modalities/spec.md @@ -1,4 +1,4 @@ -# Spec: Catalog modalities from models.dev +# Spec: Catalog modalities from Command Code CLI Status: approved (2026-08-28) **Branch:** `feature/2026-08-28-catalog-modalities` @@ -7,56 +7,54 @@ Parent: [docs/2026-08-28-ci-catalog/spec.md](../2026-08-28-ci-catalog/spec.md) ## Goals -- G1: Every bundled catalog model carries OpenCode `attachment` and `modalities` copied from [models.dev](https://models.dev). -- G2: Unmatched models default to text-only (no vision guessing, no frozen allowlist). -- G3: Cost waterfall stays independent; modalities apply to all matching models including free SKUs. +- G1: Every Command Code SKU in the bundled catalog carries OpenCode `attachment` and `modalities` from the CLI catalog `inputModalities` field (same extract as ids/reasoning). +- G2: models.dev only **enriches** extra input types (`video` / `audio` / `pdf`) on matches. It never overwrites CLI vision/text-only and never invents text-only for a SKU the CLI already classified. +- G3: Cost waterfall stays independent. ## Locked (do not reopen) -- Source of truth is models.dev `attachment` + `modalities` (same fetch already used for costs). No human allowlist. No guessing vision. -- Cost waterfall stays independent. Modalities apply to **all** matching models, including free SKUs and models that already have CLI/docs prices. -- Unmatched models default to text-only: `attachment: false`, `modalities: { input: ["text"], output: ["text"] }`. -- Runtime still does not fetch models.dev. Sync writes the fields into `models.json`; `generateOpencodeModels` emits them (and the text-only default if a field is missing). -- Do not overwrite CLI `limit`, `reasoning`, or `tool_call` from models.dev. -- Do not inject capability text into the session prompt. Asking the chat model “what can you do?” is out of scope. +- Command Code CLI catalog is the source of truth for native vision vs text-only. The public GitHub repo is a stub; the field lives on every model object in the npm `command-code` bundle as `inputModalities: ["text"]` or `["text","image"]`. +- Provider API `GET /provider/v1/models` has `context_length` only — no vision flags. Official docs Capabilities column is icons, not structured data. +- models.dev may add extra modality strings; it must not flip a CLI text-only model to vision or wipe CLI vision on unmatched ids. +- Last-resort text-only applies only when a model has **no** CLI `inputModalities` and no models.dev match (e.g. hardcoded extras). +- Runtime still does not fetch models.dev. Sync writes the fields into `models.json`. +- Do not overwrite CLI `reasoning` / `reasoningEfforts` from models.dev. +- Do not inject capability text into the session prompt. ## Context -`generateOpencodeModels` currently emits `id`, `name`, `reasoning`, `tool_call`, `cost`, `limit`, and optional `reasoningEfforts`/`variants`. OpenCode uses `attachment` and `modalities.input` for vision. Hy4 Preview (`tencent/hy4-preview`) is text-only on models.dev with a 1,048,576 context window already present on `limit`; the model itself does not read that metadata. +`extractModelCatalog` already evaluates full CLI model objects. `buildModelEntry` previously dropped `inputModalities` and `maxOutputTokens`. Hy4 Preview is `["text"]` in the CLI catalog (not a missing match). Gemini 3.5 Flash is `["text","image"]`. ## Non-goals -- Session/system-prompt injection of context window or vision flags -- Filling `limit` from models.dev -- Local OpenCode overlay `commandcode-modalities.ts` (becomes redundant after this ships; not deleted in this repo) +- Session/system-prompt injection +- Local OpenCode overlay `commandcode-modalities.ts` (redundant after this ships) - Changing hybrid Provider API transport +- Parsing the HTML Capabilities column on commandcode.ai docs ## Architecture ```mermaid flowchart TD - sync["bun run sync"] --> fetch[Fetch models.dev api.json] - fetch --> costs[applyModelsDevCosts skip priced/free] - fetch --> mods[applyModelsDevModalities all models] - mods --> unmatched[No match: text-only default] - costs --> json[models.json] - unmatched --> json + sync["bun run sync"] --> cli[Extract CLI catalog including inputModalities] + cli --> map["buildModelEntry: attachment + modalities per SKU"] + map --> fetch[Fetch models.dev] + fetch --> enrich[Union extra inputs on matches only] + enrich --> json[models.json] json --> emit[generateOpencodeModels] - emit --> oc["OpenCode attachment + modalities"] ``` -Matching reuses the existing models.dev index: exact id, then last path segment, then display name (case-insensitive). Copy `modalities` arrays as-is (Gemini may include `video` / `audio` / `pdf`). `attachment` comes from models.dev when present, otherwise `modalities.input` includes `"image"`. - ## Acceptance criteria -- CA-01: `parseModelsDev` retains `attachment` and `modalities` on rows that already have costs. -- CA-02: `applyModelsDevModalities` sets Gemini-style vision on a matching catalog id and text-only on Hy4 Preview / unmatched ids. -- CA-03: Free SKUs and CLI-priced models still receive modalities (not skipped the way costs are). -- CA-04: `generateOpencodeModels` always emits `attachment` and `modalities`; missing fields become text-only. -- CA-05: `bun run sync` applies modalities from the same models.dev JSON used for costs; if that fetch fails, every model still gets the text-only default before write. -- CA-06: README states that vision/text-only comes from models.dev. +- CA-01: `buildModelEntry` maps CLI `inputModalities` including `"image"` to `attachment: true` and maps `["text"]` to text-only, including Hy4 Preview. +- CA-02: `loadCatalogFromBundle` preserves those fields from a minified CLI-shaped catalog. +- CA-03: `applyModelsDevModalities` keeps CLI vision on ids models.dev does not list. +- CA-04: models.dev may append extra inputs (e.g. Gemini `video`/`audio`/`pdf`) without removing CLI `text`/`image`. +- CA-05: `generateOpencodeModels` always emits `attachment` and `modalities`. +- CA-06: README states vision/text-only comes from the CLI catalog. ## Decisions -- D-01: Conservative default is text-only, not “omit the field and let OpenCode guess”. +- D-01: CLI `inputModalities` wins over models.dev for native vision vs text-only. - D-02: Extra modality strings from models.dev are copied, not filtered to `text`/`image`. +- D-03: `maxOutputTokens` from the CLI sets `limit.output` when present. diff --git a/manifest.json b/manifest.json index 4b471b9..49be2eb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-08-28T21:04:48.971Z", + "generatedAt": "2026-08-28T21:14:42.165Z", "pluginVersion": "0.6.1", "commandCodeVersion": "1.38.1", "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", diff --git a/models.json b/models.json index 2a37a07..ac5a884 100644 --- a/models.json +++ b/models.json @@ -22,6 +22,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -30,8 +31,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "claude-haiku-4-5-20251001", @@ -49,6 +49,7 @@ "context": 200000, "output": 8192 }, + "attachment": true, "modalities": { "input": [ "text", @@ -58,8 +59,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "claude-opus-4-7", @@ -84,6 +84,7 @@ "context": 1000000, "output": 32000 }, + "attachment": true, "modalities": { "input": [ "text", @@ -93,8 +94,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "claude-opus-4-8", @@ -119,6 +119,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -127,8 +128,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "claude-opus-5", @@ -153,6 +153,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -161,8 +162,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "claude-sonnet-4-6", @@ -187,6 +187,7 @@ "context": 1000000, "output": 16000 }, + "attachment": true, "modalities": { "input": [ "text", @@ -196,8 +197,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "claude-sonnet-5", @@ -222,6 +222,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -230,8 +231,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.3-codex", @@ -254,6 +254,7 @@ "context": 400000, "output": 128000 }, + "attachment": true, "modalities": { "input": [ "text", @@ -262,8 +263,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.4", @@ -286,6 +286,7 @@ "context": 400000, "output": 128000 }, + "attachment": true, "modalities": { "input": [ "text", @@ -295,8 +296,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.4-mini", @@ -318,6 +318,7 @@ "context": 400000, "output": 128000 }, + "attachment": true, "modalities": { "input": [ "text", @@ -326,8 +327,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.5", @@ -350,6 +350,7 @@ "context": 400000, "output": 128000 }, + "attachment": true, "modalities": { "input": [ "text", @@ -358,8 +359,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "deepseek/deepseek-v4-flash", @@ -380,6 +380,7 @@ "context": 1000000, "output": 384000 }, + "attachment": false, "modalities": { "input": [ "text" @@ -387,8 +388,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "deepseek/deepseek-v4-flash-vision-exp", @@ -409,6 +409,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -417,8 +418,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "deepseek/deepseek-v4-pro", @@ -439,6 +439,7 @@ "context": 1000000, "output": 384000 }, + "attachment": false, "modalities": { "input": [ "text" @@ -446,8 +447,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "sakana/fugu-ultra", @@ -468,6 +468,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -476,8 +477,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "google/gemini-3.1-flash-lite", @@ -499,6 +499,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -510,8 +511,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "google/gemini-3.5-flash", @@ -533,6 +533,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -544,8 +545,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "google/gemini-3.5-flash-lite", @@ -567,6 +567,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -578,8 +579,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "google/gemini-3.6-flash", @@ -601,6 +601,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -612,8 +613,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "google/gemini-3.7-flash", @@ -636,6 +636,7 @@ "context": 1048576, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -647,8 +648,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "zai-org/GLM-5", @@ -665,6 +665,7 @@ "context": 200000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -672,8 +673,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "zai-org/GLM-5.1", @@ -690,6 +690,7 @@ "context": 200000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -697,8 +698,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "zai-org/GLM-5.2", @@ -719,6 +719,7 @@ "context": 1000000, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -726,8 +727,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "zai-org/GLM-5.2-Fast", @@ -744,6 +744,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -752,8 +753,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "zai-org/GLM-5.3", @@ -775,6 +775,7 @@ "context": 1000000, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -782,8 +783,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "z-ai/glm-5.3-flash", @@ -803,8 +803,9 @@ }, "limit": { "context": 1048576, - "output": 65536 + "output": 131072 }, + "attachment": true, "modalities": { "input": [ "text", @@ -814,8 +815,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.6-luna", @@ -840,6 +840,7 @@ "context": 1050000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -848,8 +849,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.6-sol", @@ -874,6 +874,7 @@ "context": 1050000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -882,8 +883,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "gpt-5.6-terra", @@ -908,6 +908,7 @@ "context": 1050000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -916,8 +917,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "xai/grok-4.5", @@ -939,6 +939,7 @@ "context": 500000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -947,8 +948,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "xai/grok-4.6", @@ -971,6 +971,7 @@ "context": 500000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -979,8 +980,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "thinkingmachines/inkling", @@ -997,6 +997,7 @@ "context": 256000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1005,8 +1006,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "thinkingmachines/inkling-small", @@ -1023,15 +1023,16 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] - }, - "attachment": false + } }, { "id": "moonshotai/Kimi-K2.5", @@ -1048,6 +1049,7 @@ "context": 256000, "output": 131072 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1056,8 +1058,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "moonshotai/Kimi-K2.6", @@ -1074,6 +1075,7 @@ "context": 256000, "output": 131072 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1083,8 +1085,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "moonshotai/Kimi-K2.7-Code", @@ -1101,6 +1102,7 @@ "context": 256000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1110,8 +1112,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "moonshotai/Kimi-K2.7-Code-Highspeed", @@ -1128,15 +1129,16 @@ "context": 262000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ - "text" + "text", + "image" ], "output": [ "text" ] - }, - "attachment": true + } }, { "id": "moonshotai/Kimi-K3", @@ -1153,6 +1155,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1162,8 +1165,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "poolside/laguna-s-2.1-free", @@ -1178,8 +1180,9 @@ }, "limit": { "context": 256000, - "output": 65536 + "output": 32768 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1187,8 +1190,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "inclusionai/ling-3.0-flash-free", @@ -1202,8 +1204,9 @@ }, "limit": { "context": 256000, - "output": 65536 + "output": 32768 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1211,8 +1214,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "xiaomi/mimo-v2.5", @@ -1229,6 +1231,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1239,8 +1242,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "xiaomi/mimo-v2.5-pro", @@ -1257,6 +1259,7 @@ "context": 1000000, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1264,8 +1267,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "MiniMaxAI/MiniMax-M2.5", @@ -1282,6 +1284,7 @@ "context": 200000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1289,8 +1292,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "MiniMaxAI/MiniMax-M2.7", @@ -1307,6 +1309,7 @@ "context": 1000000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1314,8 +1317,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "minimax/minimax-m2.7-free", @@ -1331,6 +1333,7 @@ "context": 197000, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1338,8 +1341,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "MiniMaxAI/MiniMax-M3", @@ -1356,6 +1358,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1365,8 +1368,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "MiniMaxAI/MiniMax-M3-Free", @@ -1382,6 +1384,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1391,8 +1394,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "minimax/minimax-m3-free", @@ -1408,6 +1410,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1416,8 +1419,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "meta/muse-spark-1.1", @@ -1434,6 +1436,7 @@ "context": 1048576, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1445,8 +1448,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "meta/muse-spark-1.2", @@ -1463,6 +1465,7 @@ "context": 1048576, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1474,8 +1477,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "meta/muse-spark-1.2-contributor", @@ -1492,6 +1494,7 @@ "context": 1048576, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1503,8 +1506,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "nvidia/nemotron-3-ultra-550b-a55b", @@ -1521,6 +1523,7 @@ "context": 1000000, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1528,8 +1531,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "Qwen/Qwen3.6-Max-Preview", @@ -1547,6 +1549,7 @@ "context": 1000000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1554,8 +1557,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "Qwen/Qwen3.6-Plus", @@ -1572,6 +1574,7 @@ "context": 1000000, "output": 131072 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1581,8 +1584,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "Qwen/Qwen3.7-Flash", @@ -1600,6 +1602,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1609,8 +1612,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "Qwen/Qwen3.7-Max", @@ -1628,6 +1630,7 @@ "context": 1000000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1635,8 +1638,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "Qwen/Qwen3.7-Plus", @@ -1654,6 +1656,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1663,8 +1666,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "Qwen/Qwen3.8-27B", @@ -1684,8 +1686,9 @@ }, "limit": { "context": 262144, - "output": 65536 + "output": 32768 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1694,8 +1697,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "Qwen/Qwen3.8-Flash", @@ -1717,6 +1719,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1726,8 +1729,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "Qwen/Qwen3.8-Max", @@ -1750,6 +1752,7 @@ "context": 1000000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1759,8 +1762,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "stepfun/Step-3.5-Flash", @@ -1777,6 +1779,7 @@ "context": 1000000, "output": 131072 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1784,8 +1787,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "stepfun/Step-3.7-Flash", @@ -1802,6 +1804,7 @@ "context": 256000, "output": 65536 }, + "attachment": true, "modalities": { "input": [ "text", @@ -1811,8 +1814,7 @@ "output": [ "text" ] - }, - "attachment": true + } }, { "id": "tencent/hy3-paid", @@ -1853,6 +1855,7 @@ "context": 262144, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1860,8 +1863,7 @@ "output": [ "text" ] - }, - "attachment": false + } }, { "id": "tencent/hy4-preview", @@ -1883,6 +1885,7 @@ "context": 1048576, "output": 65536 }, + "attachment": false, "modalities": { "input": [ "text" @@ -1890,7 +1893,6 @@ "output": [ "text" ] - }, - "attachment": false + } } ] diff --git a/src/catalog.ts b/src/catalog.ts index 006e054..c350299 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -40,6 +40,8 @@ export interface SnEntry { reasoning?: boolean; reasoningEfforts?: string[]; contextWindow?: number; + inputModalities?: string[]; + maxOutputTokens?: number; } export interface ResolvedCommandCodePackage { @@ -486,11 +488,21 @@ export function buildModelEntry( cost = { input: 0.5, output: 2 }; } - const limit = entry.contextWindow - ? { context: entry.contextWindow, output: FALLBACK_LIMITS[entry.id]?.output ?? 65536 } - : (FALLBACK_LIMITS[entry.id] ?? { context: 200000, output: 65536 }); + const fallback = FALLBACK_LIMITS[entry.id]; + const limit = { + context: entry.contextWindow ?? fallback?.context ?? 200000, + output: entry.maxOutputTokens ?? fallback?.output ?? 65536, + }; const efforts = entry.reasoningEfforts?.length ? [...entry.reasoningEfforts] : undefined; + const input = entry.inputModalities?.filter((x) => typeof x === "string") ?? []; + const fromCli = + input.length > 0 + ? { + attachment: input.includes("image"), + modalities: { input: [...input], output: ["text"] }, + } + : {}; return { id: entry.id, @@ -501,6 +513,7 @@ export function buildModelEntry( tool_call: true, cost, limit, + ...fromCli, }; } diff --git a/src/costs-models-dev.ts b/src/costs-models-dev.ts index f59c05f..e6f2ecf 100644 --- a/src/costs-models-dev.ts +++ b/src/costs-models-dev.ts @@ -110,6 +110,19 @@ export function applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRo let filled = 0; for (const model of models) { const row = findRow(model, index); + const current = model.modalities; + if (current && model.attachment !== undefined) { + const extra = row?.modalities?.input?.filter((x) => !current.input.includes(x)) ?? []; + if (extra.length > 0) { + model.modalities = { + input: [...current.input, ...extra], + output: [...current.output], + }; + if (model.modalities.input.includes("image")) model.attachment = true; + filled++; + } + continue; + } if (row && (row.modalities || row.attachment !== undefined)) { const modalities = row.modalities ? { input: [...row.modalities.input], output: [...row.modalities.output] } diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 964331f..c99ee56 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -95,6 +95,41 @@ describe("buildModelEntry", () => { expect(entry!.cost).toEqual({ input: 0.5, output: 2 }); }); + test("maps CLI inputModalities to attachment and modalities for every SKU", () => { + const vision = buildModelEntry( + { + id: "google/gemini-3.5-flash", + provider: "vercel-ai-gateway", + spec: "chatComplete", + label: "Gemini", + name: "Gemini 3.5 Flash", + description: "d", + inputModalities: ["text", "image"], + contextWindow: 1e6, + }, + new Map(), + ); + const text = buildModelEntry( + { + id: "tencent/hy4-preview", + provider: "vercel-ai-gateway", + spec: "chatComplete", + label: "Hy4", + name: "Tencent Hy4 Preview", + description: "d", + inputModalities: ["text"], + contextWindow: 1048576, + maxOutputTokens: 64000, + }, + new Map(), + ); + expect(vision!.attachment).toBe(true); + expect(vision!.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); + expect(text!.attachment).toBe(false); + expect(text!.modalities).toEqual({ input: ["text"], output: ["text"] }); + expect(text!.limit).toEqual({ context: 1048576, output: 64000 }); + }); + test("does not invent a billed rate for models missing from the CLI cost map", () => { const sn: SnEntry = { id: "google/gemini-3.5-flash", @@ -198,8 +233,8 @@ describe("loadCatalogFromBundle", () => { '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', "var Sn=(Wt=>({", - 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5},', - 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",reasoningEfforts:["low","high"],contextWindow:256000}', + 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",inputModalities:["text","image"],reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5},', + 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",inputModalities:["text"],reasoningEfforts:["low","high"],contextWindow:256000}', "}))(Wt);", 'var costs={anthropic:[{id:"anthropic:claude-sonnet-4-6",provider:"anthropic",category:"p",promptCost:3,completionCost:15,cacheWrite5mCost:3.75,cacheWrite1hCost:6,cacheHitCost:.3}],openai:[{id:"openai:gpt-5.5",provider:"openai",category:"p",promptCost:1,completionCost:2,cacheWrite5mCost:0,cacheWrite1hCost:0,cacheHitCost:0}]};', ].join(""); @@ -213,11 +248,15 @@ describe("loadCatalogFromBundle", () => { expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]); expect(sonnet!.tier).toBe("premium"); expect(sonnet!.limit.context).toBe(200000); + expect(sonnet!.attachment).toBe(true); + expect(sonnet!.modalities).toEqual({ input: ["text", "image"], output: ["text"] }); const gpt = entries.find((e) => e.id === "gpt-5.5"); expect(gpt).toBeDefined(); expect(gpt!.reasoning).toBe(true); expect(gpt!.reasoningEfforts).toEqual(["low", "high"]); + expect(gpt!.attachment).toBe(false); + expect(gpt!.modalities).toEqual({ input: ["text"], output: ["text"] }); }); test("returns models when cost extraction fails", () => { @@ -225,8 +264,8 @@ describe("loadCatalogFromBundle", () => { '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', "var Sn=(Wt=>({", - 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5},', - 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",reasoningEfforts:["low","high"],contextWindow:256000}', + 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",inputModalities:["text","image"],reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5},', + 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",inputModalities:["text"],reasoningEfforts:["low","high"],contextWindow:256000}', "}))(Wt);", ].join(""); diff --git a/tests/unit/costs-models-dev.test.ts b/tests/unit/costs-models-dev.test.ts index 16fca2d..9490905 100644 --- a/tests/unit/costs-models-dev.test.ts +++ b/tests/unit/costs-models-dev.test.ts @@ -82,33 +82,40 @@ describe("applyModelsDevModalities", () => { readFileSync(join(import.meta.dir, "../fixtures/models-dev/api.subset.json"), "utf-8"), ); - test("copies vision modalities and defaults unmatched plus cost-only rows to text-only", () => { + test("keeps CLI modalities on unmatched SKUs and only enriches extras from models.dev", () => { const models = [ - model({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }), - model({ id: "tencent/hy4-preview", name: "Tencent Hy4 Preview" }), - model({ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" }), - model({ id: "unknown/not-on-models-dev", name: "Unknown" }), model({ - id: "inclusionai/ling-3.0-flash-free", - name: "Ling 3.0 Flash Free", - cost: { input: 0, output: 0 }, + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + }), + model({ + id: "unknown/cli-vision-only", + name: "CLI Vision Only", + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + }), + model({ + id: "tencent/hy4-preview", + name: "Tencent Hy4 Preview", + attachment: false, + modalities: { input: ["text"], output: ["text"] }, }), + model({ id: "no-cli-no-dev", name: "Gap" }), ]; const n = applyModelsDevModalities(models, rows); - expect(n).toBe(2); - expect(models[0].attachment).toBe(true); + expect(n).toBe(1); expect(models[0].modalities).toEqual({ input: ["text", "image", "video", "audio", "pdf"], output: ["text"], }); - expect(models[1].attachment).toBe(false); - expect(models[1].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); + expect(models[1].attachment).toBe(true); + expect(models[1].modalities).toEqual({ input: ["text", "image"], output: ["text"] }); expect(models[2].attachment).toBe(false); expect(models[2].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); expect(models[3].attachment).toBe(false); expect(models[3].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); - expect(models[4].attachment).toBe(false); - expect(models[4].modalities).toEqual({ ...TEXT_ONLY_MODALITIES }); }); });