From befe26c87bdce1957fdb1d904ee102200c82f32c Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 12:20:12 -0300 Subject: [PATCH 1/7] feat: complete model metadata and pricing parity --- manifest.json | 2 +- models.json | 818 +++++++++++++++++++++++----- scripts/sync-models.ts | 30 +- src/catalog.ts | 66 ++- src/costs-models-dev.ts | 175 +++++- src/schemas.ts | 32 +- src/v2models.ts | 48 +- tests/fixtures/vendor-models.json | 8 + tests/unit/catalog.test.ts | 132 ++++- tests/unit/costs-models-dev.test.ts | 157 ++++++ tests/unit/schemas.test.ts | 52 +- tests/unit/v2models.test.ts | 83 +++ 12 files changed, 1432 insertions(+), 171 deletions(-) create mode 100644 tests/fixtures/vendor-models.json diff --git a/manifest.json b/manifest.json index f8c687c..1300640 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-26T06:01:17.803Z", + "generatedAt": "2026-09-28T13:57:06.166Z", "pluginVersion": "0.9.1", "commandCodeVersion": "1.66.0", "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.66.0.tgz", diff --git a/models.json b/models.json index 9be6a01..fdd7beb 100644 --- a/models.json +++ b/models.json @@ -32,7 +32,9 @@ "output": [ "text" ] - } + }, + "family": "claude-fable", + "release_date": "2026-06-09" }, { "id": "claude-fable-5-1", @@ -67,7 +69,9 @@ "output": [ "text" ] - } + }, + "family": "claude-fable", + "release_date": "2026-09-01" }, { "id": "claude-haiku-4-5-20251001", @@ -95,7 +99,9 @@ "output": [ "text" ] - } + }, + "family": "claude-haiku", + "release_date": "2025-10-16" }, { "id": "claude-opus-4-7", @@ -114,7 +120,25 @@ "input": 5, "output": 25, "cache_read": 0.5, - "cache_write": 6.25 + "cache_write": 6.25, + "tiers": [ + { + "input": 10, + "output": 37.5, + "cache_read": 1, + "cache_write": 12.5, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 37.5, + "cache_read": 1, + "cache_write": 12.5 + } }, "limit": { "context": 1000000, @@ -130,7 +154,9 @@ "output": [ "text" ] - } + }, + "family": "claude-opus", + "release_date": "2026-04-17" }, { "id": "claude-opus-4-8", @@ -149,7 +175,25 @@ "input": 5, "output": 25, "cache_read": 0.5, - "cache_write": 6.25 + "cache_write": 6.25, + "tiers": [ + { + "input": 10, + "output": 37.5, + "cache_read": 1, + "cache_write": 12.5, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 37.5, + "cache_read": 1, + "cache_write": 12.5 + } }, "limit": { "context": 1000000, @@ -165,7 +209,9 @@ "output": [ "text" ] - } + }, + "family": "claude-opus", + "release_date": "2026-05-28" }, { "id": "claude-opus-5", @@ -200,7 +246,9 @@ "output": [ "text" ] - } + }, + "family": "claude-opus", + "release_date": "2026-07-24" }, { "id": "claude-opus-5-5", @@ -235,7 +283,9 @@ "output": [ "text" ] - } + }, + "family": "claude-opus", + "release_date": "2026-09-22" }, { "id": "claude-sonnet-4-6", @@ -254,7 +304,25 @@ "input": 3, "output": 15, "cache_read": 0.3, - "cache_write": 3.75 + "cache_write": 3.75, + "tiers": [ + { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } }, "limit": { "context": 1000000, @@ -270,7 +338,9 @@ "output": [ "text" ] - } + }, + "family": "claude-sonnet", + "release_date": "2026-02-18" }, { "id": "claude-sonnet-5", @@ -305,7 +375,9 @@ "output": [ "text" ] - } + }, + "family": "claude-sonnet", + "release_date": "2026-06-30" }, { "id": "gpt-5.3-codex", @@ -326,7 +398,8 @@ }, "limit": { "context": 400000, - "output": 128000 + "output": 128000, + "input": 272000 }, "attachment": true, "modalities": { @@ -337,7 +410,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-codex", + "release_date": "2026-02-05" }, { "id": "gpt-5.4", @@ -354,11 +429,26 @@ "cost": { "input": 2.5, "output": 15, - "cache_read": 0.25 + "cache_read": 0.25, + "tiers": [ + { + "input": 5, + "output": 22.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 5, + "output": 22.5 + } }, "limit": { "context": 400000, - "output": 128000 + "output": 128000, + "input": 922000 }, "attachment": true, "modalities": { @@ -370,7 +460,9 @@ "output": [ "text" ] - } + }, + "family": "gpt", + "release_date": "2026-03-05" }, { "id": "gpt-5.4-mini", @@ -390,7 +482,8 @@ }, "limit": { "context": 400000, - "output": 128000 + "output": 128000, + "input": 272000 }, "attachment": true, "modalities": { @@ -401,7 +494,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-mini", + "release_date": "2026-03-19" }, { "id": "gpt-5.5", @@ -418,11 +513,28 @@ "cost": { "input": 5, "output": 30, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } }, "limit": { "context": 400000, - "output": 128000 + "output": 128000, + "input": 922000 }, "attachment": true, "modalities": { @@ -434,7 +546,9 @@ "output": [ "text" ] - } + }, + "family": "gpt", + "release_date": "2026-04-23" }, { "id": "gpt-6-astra", @@ -453,11 +567,30 @@ "input": 10, "output": 50, "cache_read": 1, - "cache_write": 12.5 + "cache_write": 12.5, + "tiers": [ + { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 20, + "output": 75, + "cache_read": 2, + "cache_write": 25 + } }, "limit": { "context": 1050000, - "output": 65536 + "output": 65536, + "input": 922000 }, "attachment": true, "modalities": { @@ -469,7 +602,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-astra", + "release_date": "2026-09-04" }, { "id": "gpt-6-luna", @@ -488,11 +623,30 @@ "input": 0.1, "output": 0.5, "cache_read": 0.01, - "cache_write": 0.125 + "cache_write": 0.125, + "tiers": [ + { + "input": 0.2, + "output": 0.75, + "cache_read": 0.02, + "cache_write": 0.25, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 0.2, + "output": 0.75, + "cache_read": 0.02, + "cache_write": 0.25 + } }, "limit": { "context": 1050000, - "output": 65536 + "output": 65536, + "input": 922000 }, "attachment": true, "modalities": { @@ -504,7 +658,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-luna", + "release_date": "2026-09-22" }, { "id": "gpt-6-sol", @@ -523,11 +679,30 @@ "input": 2, "output": 10, "cache_read": 0.2, - "cache_write": 2.5 + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 15, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 15, + "cache_read": 0.4, + "cache_write": 5 + } }, "limit": { "context": 1050000, - "output": 65536 + "output": 65536, + "input": 922000 }, "attachment": true, "modalities": { @@ -539,7 +714,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-sol", + "release_date": "2026-09-22" }, { "id": "deepseek/deepseek-v4-flash", @@ -568,7 +745,9 @@ "output": [ "text" ] - } + }, + "family": "deepseek-flash", + "release_date": "2026-04-24" }, { "id": "deepseek/deepseek-v4-flash-fast", @@ -628,7 +807,9 @@ "output": [ "text" ] - } + }, + "family": "deepseek-flash", + "release_date": "2026-08-21" }, { "id": "deepseek/deepseek-v4-pro", @@ -657,7 +838,9 @@ "output": [ "text" ] - } + }, + "family": "deepseek-thinking", + "release_date": "2026-04-24" }, { "id": "deepseek/deepseek-v4.1-flash", @@ -688,7 +871,9 @@ "output": [ "text" ] - } + }, + "family": "deepseek-flash", + "release_date": "2026-09-10" }, { "id": "sakana/fugu-ultra", @@ -703,7 +888,23 @@ "cost": { "input": 5, "output": 30, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } }, "limit": { "context": 1000000, @@ -718,7 +919,9 @@ "output": [ "text" ] - } + }, + "family": "fugu", + "release_date": "2026-06-15" }, { "id": "google/gemini-3.1-flash-lite", @@ -752,7 +955,9 @@ "output": [ "text" ] - } + }, + "family": "gemini-flash-lite", + "release_date": "2026-05-07" }, { "id": "google/gemini-3.5-flash", @@ -786,7 +991,9 @@ "output": [ "text" ] - } + }, + "family": "gemini-flash", + "release_date": "2026-05-19" }, { "id": "google/gemini-3.5-flash-lite", @@ -820,7 +1027,9 @@ "output": [ "text" ] - } + }, + "family": "gemini-flash-lite", + "release_date": "2026-07-21" }, { "id": "google/gemini-3.6-flash", @@ -854,7 +1063,9 @@ "output": [ "text" ] - } + }, + "family": "gemini-flash", + "release_date": "2026-07-21" }, { "id": "google/gemini-3.7-flash", @@ -889,7 +1100,9 @@ "output": [ "text" ] - } + }, + "family": "gemini-flash", + "release_date": "2026-08-13" }, { "id": "google/gemini-3.8-flash", @@ -923,7 +1136,9 @@ "output": [ "text" ] - } + }, + "family": "gemini-flash", + "release_date": "2026-09-02" }, { "id": "zai-org/GLM-5", @@ -947,7 +1162,9 @@ "output": [ "text" ] - } + }, + "family": "glm", + "release_date": "2026-02-12" }, { "id": "zai-org/GLM-5.1", @@ -972,7 +1189,9 @@ "output": [ "text" ] - } + }, + "family": "glm", + "release_date": "2026-04-07" }, { "id": "zai-org/GLM-5.2", @@ -1001,7 +1220,9 @@ "output": [ "text" ] - } + }, + "family": "glm", + "release_date": "2026-06-13" }, { "id": "zai-org/GLM-5.2-Fast", @@ -1027,7 +1248,9 @@ "output": [ "text" ] - } + }, + "family": "glm", + "release_date": "2026-06-13" }, { "id": "zai-org/GLM-5.3", @@ -1057,7 +1280,9 @@ "output": [ "text" ] - } + }, + "family": "glm", + "release_date": "2026-08-14" }, { "id": "z-ai/glm-5.3-flash", @@ -1089,7 +1314,9 @@ "output": [ "text" ] - } + }, + "family": "glm-flash", + "release_date": "2026-08-26" }, { "id": "z-ai/glm-5.3-flashx", @@ -1121,7 +1348,9 @@ "output": [ "text" ] - } + }, + "family": "glm", + "release_date": "2026-09-18" }, { "id": "gpt-5.6-luna", @@ -1140,11 +1369,30 @@ "input": 0.2, "output": 1.2, "cache_read": 0.02, - "cache_write": 0.25 + "cache_write": 0.25, + "tiers": [ + { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 0.4, + "output": 1.8, + "cache_read": 0.04, + "cache_write": 0.5 + } }, "limit": { "context": 1050000, - "output": 65536 + "output": 65536, + "input": 922000 }, "attachment": true, "modalities": { @@ -1156,7 +1404,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-luna", + "release_date": "2026-07-09" }, { "id": "gpt-5.6-sol", @@ -1175,11 +1425,28 @@ "input": 5, "output": 30, "cache_read": 0.5, - "cache_write": 6.25 + "cache_write": 6.25, + "tiers": [ + { + "input": 10, + "output": 45, + "cache_read": 1, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } }, "limit": { "context": 1050000, - "output": 65536 + "output": 65536, + "input": 922000 }, "attachment": true, "modalities": { @@ -1191,7 +1458,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-sol", + "release_date": "2026-07-09" }, { "id": "gpt-5.6-terra", @@ -1210,11 +1479,30 @@ "input": 2, "output": 12, "cache_read": 0.2, - "cache_write": 2.5 + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 18, + "cache_read": 0.4, + "cache_write": 5 + } }, "limit": { "context": 1050000, - "output": 65536 + "output": 65536, + "input": 922000 }, "attachment": true, "modalities": { @@ -1226,7 +1514,9 @@ "output": [ "text" ] - } + }, + "family": "gpt-terra", + "release_date": "2026-07-09" }, { "id": "xai/grok-4.5", @@ -1242,7 +1532,23 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 0.6 + } }, "limit": { "context": 500000, @@ -1257,7 +1563,9 @@ "output": [ "text" ] - } + }, + "family": "grok", + "release_date": "2026-07-08" }, { "id": "xai/grok-4.6", @@ -1274,7 +1582,23 @@ "cost": { "input": 2, "output": 6, - "cache_read": 0.5 + "cache_read": 0.5, + "tiers": [ + { + "input": 4, + "output": 12, + "cache_read": 1, + "tier": { + "type": "context", + "size": 200000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 12, + "cache_read": 1 + } }, "limit": { "context": 500000, @@ -1289,7 +1613,9 @@ "output": [ "text" ] - } + }, + "family": "grok", + "release_date": "2026-08-12" }, { "id": "xai/grok-4.7", @@ -1306,7 +1632,23 @@ "cost": { "input": 1.2, "output": 3.6, - "cache_read": 0.3 + "cache_read": 0.3, + "tiers": [ + { + "input": 2.4, + "output": 7.2, + "cache_read": 0.6, + "tier": { + "type": "context", + "size": 200001 + } + } + ], + "context_over_200k": { + "input": 2.4, + "output": 7.2, + "cache_read": 0.6 + } }, "limit": { "context": 500000, @@ -1322,7 +1664,9 @@ "output": [ "text" ] - } + }, + "family": "grok", + "release_date": "2026-09-21" }, { "id": "thinkingmachines/inkling", @@ -1348,7 +1692,9 @@ "output": [ "text" ] - } + }, + "family": "ling", + "release_date": "2026-07-15" }, { "id": "thinkingmachines/inkling-small", @@ -1374,7 +1720,10 @@ "output": [ "text" ] - } + }, + "family": "ling", + "release_date": "2026-07-30", + "status": "beta" }, { "id": "moonshotai/Kimi-K2.5", @@ -1399,7 +1748,9 @@ "output": [ "text" ] - } + }, + "family": "kimi-k2", + "release_date": "2026-01-30" }, { "id": "moonshotai/Kimi-K2.6", @@ -1426,7 +1777,9 @@ "output": [ "text" ] - } + }, + "family": "kimi-k2", + "release_date": "2026-04-21" }, { "id": "moonshotai/Kimi-K2.7-Code", @@ -1453,7 +1806,9 @@ "output": [ "text" ] - } + }, + "family": "kimi-k2", + "release_date": "2026-06-12" }, { "id": "moonshotai/Kimi-K2.7-Code-Highspeed", @@ -1479,7 +1834,9 @@ "output": [ "text" ] - } + }, + "family": "kimi-k2", + "release_date": "2026-06-12" }, { "id": "moonshotai/Kimi-K3", @@ -1511,7 +1868,9 @@ "output": [ "text" ] - } + }, + "family": "kimi-k3", + "release_date": "2026-07-16" }, { "id": "poolside/laguna-s-2.1-free", @@ -1536,7 +1895,9 @@ "output": [ "text" ] - } + }, + "family": "laguna", + "release_date": "2026-07-21" }, { "id": "inclusionai/ling-3.0-flash-sante:free", @@ -1561,7 +1922,9 @@ "output": [ "text" ] - } + }, + "family": "ling", + "release_date": "2026-09-04" }, { "id": "meituan/LongCat-2.0", @@ -1586,7 +1949,9 @@ "output": [ "text" ] - } + }, + "family": "longcat", + "release_date": "2026-07-20" }, { "id": "xiaomi/mimo-v2.5", @@ -1597,7 +1962,23 @@ "cost": { "input": 0.14, "output": 0.28, - "cache_read": 0.0028 + "cache_read": 0.0028, + "tiers": [ + { + "input": 0.8, + "output": 4, + "cache_read": 0.16, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 0.8, + "output": 4, + "cache_read": 0.16 + } }, "limit": { "context": 1000000, @@ -1614,7 +1995,9 @@ "output": [ "text" ] - } + }, + "family": "mimo", + "release_date": "2026-04-22" }, { "id": "xiaomi/mimo-v2.5-pro", @@ -1625,7 +2008,23 @@ "cost": { "input": 0.435, "output": 0.87, - "cache_read": 0.0036 + "cache_read": 0.0036, + "tiers": [ + { + "input": 2, + "output": 6, + "cache_read": 0.4, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 2, + "output": 6, + "cache_read": 0.4 + } }, "limit": { "context": 1000000, @@ -1639,7 +2038,9 @@ "output": [ "text" ] - } + }, + "family": "mimo", + "release_date": "2026-04-22" }, { "id": "xiaomi/mimo-v2.6-flash", @@ -1667,7 +2068,9 @@ "output": [ "text" ] - } + }, + "family": "mimo", + "release_date": "2026-09-22" }, { "id": "xiaomi/mimo-v2.6-pro", @@ -1695,7 +2098,9 @@ "output": [ "text" ] - } + }, + "family": "mimo", + "release_date": "2026-09-22" }, { "id": "xiaomi/mimo-v2.6-pro-ultraspeed", @@ -1723,7 +2128,9 @@ "output": [ "text" ] - } + }, + "family": "mimo", + "release_date": "2026-09-21" }, { "id": "MiniMaxAI/MiniMax-M2.5", @@ -1747,7 +2154,10 @@ "output": [ "text" ] - } + }, + "family": "minimax", + "release_date": "2026-02-12", + "status": "deprecated" }, { "id": "MiniMaxAI/MiniMax-M2.7", @@ -1761,7 +2171,7 @@ "cache_read": 0.06 }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 }, "attachment": false, @@ -1772,7 +2182,9 @@ "output": [ "text" ] - } + }, + "family": "minimax", + "release_date": "2026-03-18" }, { "id": "MiniMaxAI/MiniMax-M3", @@ -1788,7 +2200,23 @@ "cost": { "input": 0.3, "output": 1.2, - "cache_read": 0.06 + "cache_read": 0.06, + "tiers": [ + { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "tier": { + "type": "context", + "size": 512000 + } + } + ], + "context_over_200k": { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12 + } }, "limit": { "context": 1000000, @@ -1804,7 +2232,9 @@ "output": [ "text" ] - } + }, + "family": "minimax", + "release_date": "2026-06-01" }, { "id": "meta/muse-spark-1.1", @@ -1832,14 +2262,15 @@ "input": [ "text", "image", - "video", "pdf", - "audio" + "video" ], "output": [ "text" ] - } + }, + "family": "muse", + "release_date": "2026-04-08" }, { "id": "meta/muse-spark-1.2", @@ -1868,13 +2299,14 @@ "text", "image", "video", - "pdf", - "audio" + "pdf" ], "output": [ "text" ] - } + }, + "family": "muse", + "release_date": "2026-08-05" }, { "id": "meta/muse-spark-1.2-contributor", @@ -1903,13 +2335,14 @@ "text", "image", "video", - "pdf", - "audio" + "pdf" ], "output": [ "text" ] - } + }, + "family": "muse", + "release_date": "2026-08-21" }, { "id": "meta/muse-spark-1.3", @@ -1939,13 +2372,14 @@ "text", "image", "video", - "pdf", - "audio" + "pdf" ], "output": [ "text" ] - } + }, + "family": "muse", + "release_date": "2026-09-02" }, { "id": "meta/muse-spark-1.3-contributor", @@ -1974,13 +2408,14 @@ "text", "image", "video", - "pdf", - "audio" + "pdf" ], "output": [ "text" ] - } + }, + "family": "muse", + "release_date": "2026-09-02" }, { "id": "nvidia/nemotron-3-ultra-550b-a55b", @@ -2005,7 +2440,9 @@ "output": [ "text" ] - } + }, + "family": "nemotron", + "release_date": "2026-06-04" }, { "id": "stealth/pixel-canary", @@ -2036,7 +2473,8 @@ "output": [ "text" ] - } + }, + "release_date": "2026-09-25" }, { "id": "Qwen/Qwen3.6-Max-Preview", @@ -2048,10 +2486,22 @@ "input": 1.3, "output": 7.8, "cache_read": 0.26, - "cache_write": 1.63 + "cache_write": 1.63, + "tiers": [ + { + "input": 2, + "output": 12, + "cache_read": 0.2, + "cache_write": 2.5, + "tier": { + "type": "context", + "size": 128000 + } + } + ] }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 }, "attachment": false, @@ -2062,7 +2512,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-04-20" }, { "id": "Qwen/Qwen3.6-Plus", @@ -2073,10 +2525,28 @@ "cost": { "input": 0.5, "output": 3, - "cache_read": 0.1 + "cache_read": 0.1, + "tiers": [ + { + "input": 2, + "output": 6, + "cache_read": 0.2, + "cache_write": 2.5, + "tier": { + "type": "context", + "size": 256000 + } + } + ], + "context_over_200k": { + "input": 2, + "output": 6, + "cache_read": 0.2, + "cache_write": 2.5 + } }, "limit": { - "context": 1000000, + "context": 200000, "output": 131072 }, "attachment": true, @@ -2089,7 +2559,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-04-02" }, { "id": "Qwen/Qwen3.7-Flash", @@ -2101,11 +2573,34 @@ "input": 0.03, "output": 0.13, "cache_read": 0.006, - "cache_write": 0.038 + "cache_write": 0.038, + "tiers": [ + { + "input": 0.1, + "output": 0.4, + "cache_read": 0.02, + "cache_write": 0.125, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 0.2, + "output": 0.8, + "cache_read": 0.04, + "cache_write": 0.25, + "tier": { + "type": "context", + "size": 256000 + } + } + ] }, "limit": { "context": 1000000, - "output": 65536 + "output": 65536, + "input": 991808 }, "attachment": true, "modalities": { @@ -2117,7 +2612,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-07-15" }, { "id": "Qwen/Qwen3.7-Max", @@ -2129,7 +2626,27 @@ "input": 2.5, "output": 7.5, "cache_read": 0.5, - "cache_write": 3.13 + "cache_write": 3.13, + "tiers": [ + { + "input": 5, + "output": 15, + "cache_read": 1, + "tier": { + "type": "context", + "size": 32000 + } + }, + { + "input": 6.25, + "output": 18.5, + "cache_read": 1.25, + "tier": { + "type": "context", + "size": 128000 + } + } + ] }, "limit": { "context": 1000000, @@ -2143,7 +2660,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-05-21" }, { "id": "Qwen/Qwen3.7-Plus", @@ -2155,7 +2674,23 @@ "input": 0.4, "output": 1.6, "cache_read": 0.08, - "cache_write": 0.5 + "cache_write": 0.5, + "tiers": [ + { + "input": 1.2, + "output": 4.8, + "cache_read": 0.24, + "tier": { + "type": "context", + "size": 262144 + } + } + ], + "context_over_200k": { + "input": 1.2, + "output": 4.8, + "cache_read": 0.24 + } }, "limit": { "context": 1000000, @@ -2171,7 +2706,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-06-02" }, { "id": "Qwen/Qwen3.8-27B", @@ -2204,7 +2741,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-08-14" }, { "id": "Qwen/Qwen3.8-Flash", @@ -2236,7 +2775,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-08-26" }, { "id": "Qwen/Qwen3.8-Max", @@ -2269,7 +2810,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-08-03" }, { "id": "Qwen/Qwen3.8-Max-0902", @@ -2301,7 +2844,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-09-02" }, { "id": "Qwen/Qwen3.8-Omni-Flash", @@ -2334,7 +2879,9 @@ "output": [ "text" ] - } + }, + "family": "qwen", + "release_date": "2026-09-17" }, { "id": "stealth/space-bunny-alpha", @@ -2366,7 +2913,9 @@ "output": [ "text" ] - } + }, + "family": "alpha", + "release_date": "2026-09-23" }, { "id": "stepfun/Step-3.5-Flash", @@ -2391,7 +2940,8 @@ "output": [ "text" ] - } + }, + "release_date": "2026-01-29" }, { "id": "stepfun/Step-3.7-Flash", @@ -2418,7 +2968,8 @@ "output": [ "text" ] - } + }, + "release_date": "2026-05-29" }, { "id": "stepfun/Step-5-Preview", @@ -2438,7 +2989,8 @@ }, "limit": { "context": 1000000, - "output": 65536 + "output": 65536, + "input": 1000000 }, "attachment": true, "modalities": { @@ -2450,7 +3002,8 @@ "output": [ "text" ] - } + }, + "release_date": "2026-09-16" }, { "id": "tencent/hy3-paid", @@ -2465,7 +3018,8 @@ }, "limit": { "context": 262144, - "output": 65536 + "output": 65536, + "input": 262144 }, "attachment": false, "modalities": { @@ -2475,7 +3029,9 @@ "output": [ "text" ] - } + }, + "family": "Hy", + "release_date": "2026-07-06" }, { "id": "tencent/hy4-preview", @@ -2505,6 +3061,8 @@ "output": [ "text" ] - } + }, + "family": "Hy", + "release_date": "2026-08-28" } ] diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index 9c18d20..5e01fb7 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -11,13 +11,15 @@ import { generateOpencodeModels, loadCatalogFromBundle, loadCatalogFromLocalCommandCode, - parseAvailabilityIds, + parseAvailabilityModels, type ModelEntry, } from "@/src/catalog.js"; import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "@/src/costs-docs.js"; import { applyFreeCosts, applyModelsDevCosts, + applyModelsDevCostTiers, + applyModelsDevMetadata, applyModelsDevModalities, fetchModelsDevJson, parseModelsDev, @@ -48,7 +50,7 @@ function readPriorManifest(): CatalogManifest | null { } } -async function fetchAvailabilityIds(): Promise { +async function fetchAvailabilityModels(): Promise> { const resp = await fetch(MODELS_API_URL); if (!resp.ok) throw new Error(`models endpoint returned ${resp.status}`); let payload: unknown; @@ -57,7 +59,7 @@ async function fetchAvailabilityIds(): Promise { } catch { throw new Error("models endpoint returned invalid JSON"); } - return parseAvailabilityIds(payload); + return parseAvailabilityModels(payload); } /** Build all model, version, and manifest contents before the first write. */ @@ -239,11 +241,22 @@ async function main() { const shouldUpdateGlobal = args.includes("--update-global"); const forceRemote = args.includes("--remote"); + console.log("Fetching callable model availability..."); + const vendorModels = await fetchAvailabilityModels(); + const availableIds = vendorModels.map((model) => model.id); + const vendorContextLengths = new Map(); + for (const model of vendorModels) { + if (model.context_length !== undefined) { + vendorContextLengths.set(model.id.toLowerCase(), model.context_length); + } + } + console.log(` Callable models: ${availableIds.length}`); + let version: string; let sourceLabel: string; let bundleSource: string | null = null; - const local = !forceRemote ? loadCatalogFromLocalCommandCode() : null; + const local = !forceRemote ? loadCatalogFromLocalCommandCode({ vendorContextLengths }) : null; let candidates: ModelEntry[]; if (local) { candidates = local.models; @@ -260,14 +273,11 @@ async function main() { sourceLabel = `npm tarball v${version}`; console.log(`Read CLI bundle v${version} (${(bundle.source.length / 1024).toFixed(0)} KB)`); console.log("Extracting model catalog..."); - candidates = loadCatalogFromBundle(bundle.source); + candidates = loadCatalogFromBundle(bundle.source, vendorContextLengths); console.log(` Found ${candidates.length} models`); } const priorManifest = readPriorManifest(); - console.log("Fetching callable model availability..."); - const availableIds = await fetchAvailabilityIds(); - console.log(` Callable models: ${availableIds.length}`); // Enrichment mutates entries in place, so filter the extracted candidates // first and run the floor gate before any cost work or artifact write. const prefiltered = filterCatalogByAvailability(candidates, availableIds); @@ -313,6 +323,10 @@ async function main() { } const modalityFilled = applyModelsDevModalities(entries, modelsDevRows); console.log(` Applied models.dev modalities to ${modalityFilled} models`); + const metadataFilled = applyModelsDevMetadata(entries, modelsDevRows); + console.log(` Applied models.dev metadata to ${metadataFilled} models`); + const tierFilled = applyModelsDevCostTiers(entries, modelsDevRows); + console.log(` Applied models.dev cost tiers to ${tierFilled} models`); // Entries are already filtered above; buildSyncArtifacts re-validates the // floor and returns every generated payload before the first write. diff --git a/src/catalog.ts b/src/catalog.ts index 8e1182a..121a2aa 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -456,6 +456,7 @@ export function buildCostMap(costs: Record): Map, + vendorContextLength?: number, ): ModelEntry | null { const provider = entry.provider || "unknown"; const tier = TIER_MAP[provider] ?? "open-source"; @@ -475,8 +476,12 @@ export function buildModelEntry( } const fallback = FALLBACK_LIMITS[entry.id]; + const context = entry.contextWindow ?? fallback?.context ?? 200000; const limit = { - context: entry.contextWindow ?? fallback?.context ?? 200000, + context: + entry.contextWindow === undefined && vendorContextLength !== undefined + ? Math.min(context, vendorContextLength) + : context, output: entry.maxOutputTokens ?? fallback?.output ?? 65536, }; @@ -535,7 +540,10 @@ export function sortModelEntries(entries: ModelEntry[]): ModelEntry[] { }); } -export function loadCatalogFromBundle(source: string): ModelEntry[] { +export function loadCatalogFromBundle( + source: string, + vendorContextLengths?: ReadonlyMap, +): ModelEntry[] { const models = extractModelCatalog(source); let costMap = new Map(); try { @@ -547,13 +555,21 @@ export function loadCatalogFromBundle(source: string): ModelEntry[] { const entries: ModelEntry[] = []; for (const model of Object.values(models)) { if (!model || typeof model !== "object" || typeof model.id !== "string") continue; - const entry = buildModelEntry(model, costMap); + const entry = buildModelEntry( + model, + costMap, + vendorContextLengths?.get(model.id.toLowerCase()), + ); if (entry) entries.push(entry); } for (const extra of HARDCODED_EXTRAS) { if (!entries.some((e) => e.id === extra.id)) { - const entry = buildModelEntry(extra, costMap); + const entry = buildModelEntry( + extra, + costMap, + vendorContextLengths?.get(extra.id.toLowerCase()), + ); if (entry) entries.push(entry); } } @@ -718,6 +734,7 @@ export function resolveCommandCodePackage( export function loadCatalogFromLocalCommandCodeResult( options?: { packagePath?: string; + vendorContextLengths?: ReadonlyMap; }, deps: EnvDeps = liveEnv, ): LoadResult { @@ -734,7 +751,7 @@ export function loadCatalogFromLocalCommandCodeResult( } let models: ModelEntry[]; try { - models = loadCatalogFromBundle(source); + models = loadCatalogFromBundle(source, options?.vendorContextLengths); } catch (error) { return errResult(`local command-code catalog failed to evaluate: ${causeMessage(error)}`); } @@ -753,6 +770,7 @@ export function loadCatalogFromLocalCommandCodeResult( /** Compat wrapper: value-or-null for callers that only need fail-open. */ export function loadCatalogFromLocalCommandCode(options?: { packagePath?: string; + vendorContextLengths?: ReadonlyMap; }): LocalCatalogResult | null { const result = loadCatalogFromLocalCommandCodeResult(options); return result.ok ? result.value : null; @@ -765,14 +783,33 @@ export function toConfigKey(id: string): string { return short.toLowerCase(); } +export function usesAnthropicMessagesApi(id: string): boolean { + return /(?:^|[/:])claude-/i.test(id); +} + export function generateOpencodeModels(entries: ModelEntry[]): Record { const models: Record = {}; for (const entry of entries) { // Map key = short UI id; `id` on the model = Command Code wire id (same split as V2 modelID). const key = toConfigKey(entry.id); - const costObj: Record = { input: entry.cost.input, output: entry.cost.output }; + const costObj: Record = { input: entry.cost.input, output: entry.cost.output }; if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read; if (entry.cost.cache_write !== undefined) costObj.cache_write = entry.cost.cache_write; + const contextOver200k = + entry.cost.context_over_200k ?? + entry.cost.tiers?.find((tier) => tier.tier.type === "context" && tier.tier.size === 200000); + if (contextOver200k) { + costObj.context_over_200k = { + input: contextOver200k.input, + output: contextOver200k.output, + ...(contextOver200k.cache_read !== undefined + ? { cache_read: contextOver200k.cache_read } + : {}), + ...(contextOver200k.cache_write !== undefined + ? { cache_write: contextOver200k.cache_write } + : {}), + }; + } const model: Record = { id: entry.id, @@ -783,8 +820,16 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record 0) { model.reasoningEfforts = entry.reasoningEfforts; const variants: Record> = {}; @@ -801,6 +846,8 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record { retained: T[]; unavailable: string[]; @@ -808,12 +855,17 @@ export interface FilteredCatalog { /** Validate the OpenAI-style `{ object: "list", data: [{ id }] }` availability payload. */ export function parseAvailabilityIds(payload: unknown): string[] { + return parseAvailabilityModels(payload).map((model) => model.id); +} + +/** Parse the vendor model inventory, retaining its context limit when present. */ +export function parseAvailabilityModels(payload: unknown): ProviderModelMetadata[] { const parsed = AvailabilityPayloadSchema.safeParse(payload); if (!parsed.success) { const detail = parsed.error.issues.map((issue) => issue.message).join("; "); throw new Error(`invalid availability response: ${detail}`); } - return parsed.data.data.map((item) => item.id); + return parsed.data.data; } /** Retain only candidates whose exact ID is callable; collect sorted excluded IDs. */ diff --git a/src/costs-models-dev.ts b/src/costs-models-dev.ts index e6f2ecf..4e81363 100644 --- a/src/costs-models-dev.ts +++ b/src/costs-models-dev.ts @@ -4,20 +4,49 @@ export const MODELS_DEV_URL = "https://models.dev/api.json"; export const FREE_COST = { input: 0, output: 0 } as const; export const TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] } as const; +export type ModelsDevPrice = { + input: number; + output: number; + cache_read?: number; + cache_write?: number; +}; + +export type ModelsDevContextTier = ModelsDevPrice & { + tier: { type: "context"; size: number }; +}; + +export type ModelsDevCost = ModelsDevPrice & { + context_over_200k?: ModelsDevPrice; + tiers?: ModelsDevContextTier[]; +}; + export type ModelsDevRow = { id: string; name: string; - cost: { input: number; output: number; cache_read?: number; cache_write?: number }; + cost?: ModelsDevCost; attachment?: boolean; modalities?: { input: string[]; output: string[] }; + family?: string; + release_date?: string; + status?: "beta" | "deprecated"; + limit?: { input?: number }; }; +type ModelsDevPriceInput = Partial; + type ModelsDevModel = { id?: string; name?: string; - cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number }; + cost?: ModelsDevPriceInput & { + context_over_200k?: ModelsDevPriceInput; + tiers?: Array; + }; attachment?: boolean; modalities?: { input?: string[]; output?: string[] }; + family?: string; + release_date?: string; + status?: string; + limit?: { input?: number }; }; type ModelsDevProvider = { models?: Record }; @@ -35,19 +64,32 @@ export function isFreeSku(model: { id: string; name: string }): boolean { export function parseModelsDev(json: string): ModelsDevRow[] { const data = JSON.parse(json) as Record; const rows: ModelsDevRow[] = []; - const seen = new Set(); for (const provider of Object.keys(data).sort()) { const models = data[provider]?.models ?? {}; for (const model of Object.values(models)) { - if (!model?.id || model.cost?.input === undefined || model.cost?.output === undefined) - continue; - const key = model.id.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - 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; - const row: ModelsDevRow = { id: model.id, name: model.name ?? model.id, cost }; + if (!model?.id) continue; + const row: ModelsDevRow = { id: model.id, name: model.name ?? model.id }; + const baseCost = parsePrice(model.cost); + if (baseCost) { + const cost: ModelsDevCost = baseCost; + const over = parsePrice(model.cost?.context_over_200k); + if (over) cost.context_over_200k = over; + const tiers = model.cost?.tiers?.flatMap((item) => { + if ( + item.tier?.type !== "context" || + !Number.isInteger(item.tier.size) || + !item.tier.size + ) { + return []; + } + const price = parsePrice(item); + return price + ? [{ ...price, tier: { type: "context" as const, size: item.tier.size! } }] + : []; + }); + if (tiers?.length) cost.tiers = tiers; + row.cost = 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"); @@ -57,16 +99,62 @@ export function parseModelsDev(json: string): ModelsDevRow[] { output: output?.length ? output : [...TEXT_ONLY_MODALITIES.output], }; } + if (typeof model.family === "string" && model.family.trim()) row.family = model.family; + if ( + typeof model.release_date === "string" && + /^\d{4}-\d{2}-\d{2}$/.test(model.release_date) + ) { + const date = new Date(`${model.release_date}T00:00:00.000Z`); + if (!Number.isNaN(date.valueOf()) && date.toISOString().startsWith(model.release_date)) { + row.release_date = model.release_date; + } + } + if (model.status === "beta" || model.status === "deprecated") row.status = model.status; + if ( + typeof model.limit?.input === "number" && + Number.isInteger(model.limit.input) && + model.limit.input > 0 + ) { + row.limit = { input: model.limit.input }; + } rows.push(row); } } return rows; } +function parsePrice(value: ModelsDevPriceInput | undefined): ModelsDevPrice | undefined { + if ( + !value || + typeof value.input !== "number" || + !Number.isFinite(value.input) || + typeof value.output !== "number" || + !Number.isFinite(value.output) + ) { + return undefined; + } + const price: ModelsDevPrice = { input: value.input, output: value.output }; + if (typeof value.cache_read === "number" && Number.isFinite(value.cache_read)) { + price.cache_read = value.cache_read; + } + if (typeof value.cache_write === "number" && Number.isFinite(value.cache_write)) { + price.cache_write = value.cache_write; + } + return price; +} + function indexRows(rows: ModelsDevRow[]) { const byId = new Map(); const bySegment = new Map(); const byName = new Map(); + const tiersById = new Map(); + const tiersBySegment = new Map(); + const tiersByName = new Map(); + const add = (index: Map, key: string, row: ModelsDevRow) => { + const matches = index.get(key); + if (matches) matches.push(row); + else index.set(key, [row]); + }; for (const row of rows) { const idKey = row.id.toLowerCase(); if (!byId.has(idKey)) byId.set(idKey, row); @@ -74,8 +162,13 @@ function indexRows(rows: ModelsDevRow[]) { if (!bySegment.has(segment)) bySegment.set(segment, row); const nameKey = row.name.toLowerCase(); if (!byName.has(nameKey)) byName.set(nameKey, row); + if (row.cost?.tiers?.length || row.cost?.context_over_200k) { + add(tiersById, idKey, row); + add(tiersBySegment, segment, row); + add(tiersByName, nameKey, row); + } } - return { byId, bySegment, byName }; + return { byId, bySegment, byName, tiersById, tiersBySegment, tiersByName }; } function findRow(model: ModelEntry, index: ReturnType): ModelsDevRow | undefined { @@ -138,6 +231,60 @@ export function applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRo return filled; } +export function applyModelsDevMetadata(models: ModelEntry[], rows: ModelsDevRow[]): number { + const index = indexRows(rows); + let filled = 0; + for (const model of models) { + const row = findRow(model, index); + if (!row) continue; + let changed = false; + if (row.family !== undefined) { + model.family = row.family; + changed = true; + } + if (row.release_date !== undefined) { + model.release_date = row.release_date; + changed = true; + } + if (row.status !== undefined) { + model.status = row.status; + changed = true; + } + if (row.limit?.input !== undefined) { + model.limit.input = row.limit.input; + changed = true; + } + if (changed) filled++; + } + return filled; +} + +export function applyModelsDevCostTiers(models: ModelEntry[], rows: ModelsDevRow[]): number { + const index = indexRows(rows); + let filled = 0; + for (const model of models) { + const sameBasePrice = (row: ModelsDevRow) => + row.cost?.input === model.cost.input && row.cost.output === model.cost.output; + const costRow = + index.tiersById.get(model.id.toLowerCase())?.find(sameBasePrice) ?? + index.tiersBySegment.get(lastSegment(model.id).toLowerCase())?.find(sameBasePrice) ?? + index.tiersByName.get(model.name.toLowerCase())?.find(sameBasePrice); + const cost = costRow?.cost; + if (!cost) continue; + let changed = false; + if (cost.tiers?.length) { + model.cost.tiers = cost.tiers.map((tier) => ({ ...tier, tier: { ...tier.tier } })); + changed = true; + } + if (cost.context_over_200k) { + model.cost.context_over_200k = { ...cost.context_over_200k }; + changed = true; + } + if (changed) filled++; + } + return filled; +} + export function applyModelsDevCosts( models: ModelEntry[], rows: ModelsDevRow[], @@ -149,7 +296,7 @@ export function applyModelsDevCosts( for (const model of models) { if (skipIds.has(model.id) || isFreeSku(model)) continue; const row = findRow(model, index); - if (!row) continue; + if (!row?.cost) continue; model.cost = { input: row.cost.input, output: row.cost.output }; if (row.cost.cache_read !== undefined) model.cost.cache_read = row.cost.cache_read; if (row.cost.cache_write !== undefined) model.cost.cache_write = row.cost.cache_write; diff --git a/src/schemas.ts b/src/schemas.ts index 93f00b2..1703fae 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -13,11 +13,34 @@ export const ModelEntrySchema = z.strictObject({ output: z.number(), cache_read: z.number().optional(), cache_write: z.number().optional(), + context_over_200k: z + .strictObject({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + tiers: z + .array( + z.strictObject({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + tier: z.strictObject({ type: z.literal("context"), size: z.number().int().positive() }), + }), + ) + .optional(), }), limit: z.strictObject({ context: z.number().int(), + input: z.number().int().optional(), output: z.number().int(), }), + family: z.string().min(1).optional(), + release_date: z.iso.date().optional(), + status: z.enum(["active", "beta", "deprecated"]).optional(), attachment: z.boolean().optional(), modalities: z .strictObject({ @@ -75,7 +98,14 @@ export const ManifestSchema = z.strictObject({ export const AvailabilityPayloadSchema = z .object({ object: z.literal("list"), - data: z.array(z.object({ id: z.string().min(1) })).min(1), + data: z + .array( + z.object({ + id: z.string().min(1), + context_length: z.number().int().positive().optional().catch(undefined), + }), + ) + .min(1), }) .superRefine((payload, ctx) => { const seen = new Set(); diff --git a/src/v2models.ts b/src/v2models.ts index f5ca65c..7473d45 100644 --- a/src/v2models.ts +++ b/src/v2models.ts @@ -1,16 +1,23 @@ -import { toConfigKey, type ModelEntry } from "./catalog.js"; +import { toConfigKey, usesAnthropicMessagesApi, type ModelEntry } from "./catalog.js"; /** Minimal V2 model shape (structural subset of Model.Info). */ export interface V2Model { id: string; modelID: string; providerID: string; + package?: string; name: string; + family?: string; capabilities: { tools: boolean; input: string[]; output: string[] }; - cost: Array<{ input: number; output: number; cache: { read: number; write: number } }>; - limit: { context: number; output: number }; + cost: Array<{ + input: number; + output: number; + cache: { read: number; write: number }; + tier?: { type: "context"; size: number }; + }>; + limit: { context: number; input?: number; output: number }; variants: Array<{ id: string; settings: Record }>; - status: "active"; + status: "active" | "beta" | "deprecated"; enabled: boolean; time: { released: number }; } @@ -32,11 +39,22 @@ export function toV2Model(entry: ModelEntry): V2Model { // UI / OpenCode selection key stays short (commandcode/deepseek-v4.1-flash). // modelID is the Command Code wire id — bare names 400 as unsupported_model. const id = toConfigKey(entry.id); - return { + const costTiers = [...(entry.cost.tiers ?? [])]; + if ( + entry.cost.context_over_200k && + !costTiers.some((tier) => tier.tier.type === "context" && tier.tier.size === 200000) + ) { + costTiers.push({ + ...entry.cost.context_over_200k, + tier: { type: "context", size: 200000 }, + }); + } + const model: V2Model = { id, modelID: entry.id, providerID: "commandcode", name: entry.name, + ...(entry.family !== undefined ? { family: entry.family } : {}), capabilities: { tools: entry.tool_call, input: v2Input(entry), @@ -51,16 +69,30 @@ export function toV2Model(entry: ModelEntry): V2Model { write: entry.cost.cache_write ?? 0, }, }, + ...costTiers.map((tier) => ({ + input: tier.input, + output: tier.output, + cache: { read: tier.cache_read ?? 0, write: tier.cache_write ?? 0 }, + tier: { ...tier.tier }, + })), ], - limit: { context: entry.limit.context, output: entry.limit.output }, + limit: { + context: entry.limit.context, + ...(entry.limit.input !== undefined ? { input: entry.limit.input } : {}), + output: entry.limit.output, + }, variants: (entry.reasoningEfforts ?? []).map((effort) => ({ id: effort, settings: { reasoningEffort: effort }, })), - status: "active", + status: entry.status ?? "active", enabled: true, - time: { released: 0 }, + time: { + released: entry.release_date ? Date.parse(`${entry.release_date}T00:00:00.000Z`) : 0, + }, }; + if (usesAnthropicMessagesApi(entry.id)) model.package = "aisdk:@ai-sdk/anthropic"; + return model; } export function generateV2Models(entries: ModelEntry[]): V2Model[] { diff --git a/tests/fixtures/vendor-models.json b/tests/fixtures/vendor-models.json new file mode 100644 index 0000000..0f1f7f1 --- /dev/null +++ b/tests/fixtures/vendor-models.json @@ -0,0 +1,8 @@ +{ + "object": "list", + "data": [ + { "id": "Qwen/Qwen3.6-Max-Preview", "context_length": 200000 }, + { "id": "Qwen/Qwen3.6-Plus", "context_length": 200000 }, + { "id": "MiniMaxAI/MiniMax-M2.7", "context_length": 200000 } + ] +} diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 03175f0..7ec5ade 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { @@ -9,6 +9,7 @@ import { generateOpencodeModels, loadCatalogFromBundle, loadCatalogFromLocalCommandCodeResult, + parseAvailabilityModels, parseAvailabilityIds, resolveCommandCodePackage, type CostEntry, @@ -138,6 +139,50 @@ describe("buildModelEntry", () => { expect(text!.limit).toEqual({ context: 1048576, output: 64000 }); }); + test("clamps fallback context limits to vendor values but keeps explicit CLI limits", () => { + const vendor = parseAvailabilityModels( + JSON.parse(readFileSync(join(import.meta.dir, "../fixtures/vendor-models.json"), "utf-8")), + ); + const lengths = new Map( + vendor.flatMap((model) => + model.context_length !== undefined + ? [[model.id.toLowerCase(), model.context_length] as const] + : [], + ), + ); + const ids = ["Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus", "MiniMaxAI/MiniMax-M2.7"]; + const entries = ids.map((id) => + buildModelEntry( + { + id, + provider: "vercel-ai-gateway", + spec: "chatComplete", + label: id, + name: id, + description: id, + }, + new Map(), + lengths.get(id.toLowerCase()), + ), + ); + expect(entries.map((entry) => entry?.limit.context)).toEqual(ids.map(() => 200000)); + + const explicit = buildModelEntry( + { + id: "Qwen/Qwen3.6-Plus", + provider: "vercel-ai-gateway", + spec: "chatComplete", + label: "Qwen", + name: "Qwen 3.6 Plus", + description: "d", + contextWindow: 1000000, + }, + new Map(), + 200000, + ); + expect(explicit?.limit.context).toBe(1000000); + }); + test("does not invent a billed rate for models missing from the CLI cost map", () => { const sn: SnEntry = { id: "google/gemini-3.5-flash", @@ -218,6 +263,36 @@ describe("generateOpencodeModels", () => { const entry = models["deepseek-v4.1-flash"] as Record; expect(entry.id).toBe("deepseek/deepseek-v4.1-flash"); expect(entry.name).toBe("DeepSeek V4.1 Flash"); + expect(entry.provider).toBeUndefined(); + }); + + test("routes Claude entries through the Anthropic Messages SDK per model", () => { + const ids = [ + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-fable-5", + "claude-fable-5-1", + "claude-opus-5-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-haiku-4-5-20251001", + ]; + const models = generateOpencodeModels( + ids.map((id) => ({ + id, + name: id, + tier: "premium" as const, + reasoning: false, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 200000, output: 16000 }, + })), + ); + for (const id of ids) { + const entry = models[id] as Record; + expect(entry.provider).toEqual({ npm: "@ai-sdk/anthropic" }); + } }); test("emits attachment and modalities, defaulting to text-only", () => { @@ -250,6 +325,61 @@ describe("generateOpencodeModels", () => { expect(hy4.attachment).toBe(false); expect(hy4.modalities).toEqual({ input: ["text"], output: ["text"] }); }); + + test("emits family, release date, status, and input limit metadata", () => { + const models = generateOpencodeModels([ + { + id: "Qwen/Qwen3.6-Plus", + name: "Qwen 3.6 Plus", + tier: "open-source", + reasoning: true, + tool_call: true, + cost: { input: 0.5, output: 3 }, + limit: { context: 200000, input: 200000, output: 131072 }, + family: "qwen", + release_date: "2026-04-02", + status: "beta", + }, + ]); + expect(models["qwen3.6-plus"]).toMatchObject({ + family: "qwen", + release_date: "2026-04-02", + status: "beta", + limit: { context: 200000, input: 200000, output: 131072 }, + }); + }); + + test("maps only an exact 200K tier to the V1 legacy cost field", () => { + const models = generateOpencodeModels([ + { + id: "provider/model", + name: "Model", + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { + input: 1, + output: 2, + tiers: [ + { + input: 10, + output: 20, + tier: { type: "context", size: 200000 }, + }, + { + input: 30, + output: 40, + tier: { type: "context", size: 272000 }, + }, + ], + }, + limit: { context: 512000, output: 16000 }, + }, + ]); + const entry = models.model as { cost: Record }; + expect(entry.cost.context_over_200k).toEqual({ input: 10, output: 20 }); + expect(entry.cost.tiers).toBeUndefined(); + }); }); describe("loadCatalogFromBundle", () => { diff --git a/tests/unit/costs-models-dev.test.ts b/tests/unit/costs-models-dev.test.ts index ec0108c..3536f08 100644 --- a/tests/unit/costs-models-dev.test.ts +++ b/tests/unit/costs-models-dev.test.ts @@ -4,6 +4,8 @@ import { join } from "path"; import { applyFreeCosts, applyModelsDevCosts, + applyModelsDevCostTiers, + applyModelsDevMetadata, applyModelsDevModalities, isFreeSku, parseModelsDev, @@ -119,6 +121,161 @@ describe("applyModelsDevModalities", () => { }); }); +describe("applyModelsDevMetadata", () => { + test("applies present fields and preserves known values missing from a partial response", () => { + const rows = parseModelsDev( + JSON.stringify({ + provider: { + models: { + "qwen/qwen3.6-plus": { + id: "qwen/qwen3.6-plus", + name: "Qwen 3.6 Plus", + family: "qwen", + release_date: "2026-04-02", + status: "beta", + limit: { input: 200000 }, + }, + partial: { id: "partial", name: "Partial", family: "new-family" }, + invalid: { + id: "invalid", + name: "Invalid", + family: " ", + release_date: "not-a-date", + status: "retired", + limit: { input: 1.5 }, + }, + }, + }, + }), + ); + expect(rows.find((row) => row.id === "invalid")).toEqual({ id: "invalid", name: "Invalid" }); + + const models = [ + model({ + id: "Qwen/Qwen3.6-Plus", + name: "Qwen 3.6 Plus", + family: "old-family", + release_date: "2025-01-01", + status: "deprecated", + limit: { context: 1000000, input: 999999, output: 131072 }, + }), + model({ + id: "partial", + name: "Partial", + family: "qwen", + release_date: "2025-01-01", + status: "deprecated", + limit: { context: 100000, input: 20000, output: 16000 }, + }), + model({ id: "missing", name: "Missing", family: "keep" }), + ]; + expect(applyModelsDevMetadata(models, rows)).toBe(2); + expect(models[0]).toMatchObject({ + family: "qwen", + release_date: "2026-04-02", + status: "beta", + limit: { input: 200000 }, + }); + expect(models[1]).toMatchObject({ + family: "new-family", + release_date: "2025-01-01", + status: "deprecated", + limit: { input: 20000 }, + }); + expect(models[2].family).toBe("keep"); + }); +}); + +describe("applyModelsDevCostTiers", () => { + test("uses a tier alias only when its base price matches the catalog model", () => { + const rows = parseModelsDev( + JSON.stringify({ + a_provider: { + models: { + wrong: { + id: "openai/example", + name: "Example", + cost: { + input: 0.9, + output: 9, + tiers: [{ input: 1.8, output: 18, tier: { type: "context", size: 512000 } }], + }, + }, + }, + }, + b_provider: { + models: { + matching: { + id: "openai/example", + name: "Example", + cost: { + input: 0.5, + output: 2, + tiers: [{ input: 1, output: 4, tier: { type: "context", size: 272000 } }], + }, + }, + }, + }, + }), + ); + const entry = model({ id: "openai/example", name: "Example" }); + expect(rows).toHaveLength(2); + expect(applyModelsDevCostTiers([entry], rows)).toBe(1); + expect(entry.cost.tiers).toEqual([ + { input: 1, output: 4, tier: { type: "context", size: 272000 } }, + ]); + + const mismatched = model({ id: "openai/example", name: "Example" }); + expect(applyModelsDevCostTiers([mismatched], rows.slice(0, 1))).toBe(0); + expect(mismatched.cost.tiers).toBeUndefined(); + }); + + test("preserves context thresholds and the legacy 200K rate", () => { + const rows = parseModelsDev( + JSON.stringify({ + provider: { + models: { + model: { + id: "qwen/qwen3.7-flash", + name: "Qwen3.7 Flash", + cost: { + input: 0.1, + output: 0.4, + tiers: [ + { + input: 0.2, + output: 0.8, + tier: { type: "context", size: 200000 }, + }, + { + input: 0.3, + output: 1.2, + tier: { type: "context", size: 272000 }, + }, + ], + context_over_200k: { input: 0.2, output: 0.8 }, + }, + }, + }, + }, + }), + ); + const entry = model({ + id: "qwen/qwen3.7-flash", + name: "Qwen3.7 Flash", + cost: { input: 0.1, output: 0.4 }, + }); + expect(applyModelsDevCostTiers([entry], rows)).toBe(1); + expect(entry.cost.tiers).toEqual([ + { input: 0.2, output: 0.8, tier: { type: "context", size: 200000 } }, + { input: 0.3, output: 1.2, tier: { type: "context", size: 272000 } }, + ]); + expect(entry.cost.context_over_200k).toEqual({ input: 0.2, output: 0.8 }); + expect(applyModelsDevCostTiers([entry], [])).toBe(0); + expect(entry.cost.tiers).toHaveLength(2); + }); +}); + describe("applyFreeCosts", () => { test("sets free SKUs to zero and leaves paid models alone", () => { const models = [ diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts index 5c027f0..94679af 100644 --- a/tests/unit/schemas.test.ts +++ b/tests/unit/schemas.test.ts @@ -2,7 +2,7 @@ import { expect, test, describe } from "bun:test"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; -import { parseAvailabilityIds } from "@/src/catalog.ts"; +import { parseAvailabilityIds, parseAvailabilityModels } from "@/src/catalog.ts"; import { AvailabilityPayloadSchema, ManifestSchema, @@ -28,6 +28,10 @@ const fullEntry = { cost: { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }, attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, + family: "claude-sonnet", + release_date: "2026-02-17", + status: "beta", + limit: { context: 200000, input: 195000, output: 16000 }, }; describe("ModelEntrySchema", () => { @@ -77,6 +81,41 @@ describe("ModelEntrySchema", () => { ).toBe(false); }); + test("validates optional catalog metadata strictly", () => { + expect(ModelEntrySchema.safeParse({ ...minimalEntry, family: "claude" }).success).toBe(true); + expect(ModelEntrySchema.safeParse({ ...minimalEntry, status: "hidden" }).success).toBe(false); + expect( + ModelEntrySchema.safeParse({ ...minimalEntry, release_date: "2026-02-30" }).success, + ).toBe(false); + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + limit: { ...minimalEntry.limit, input: 10.5 }, + }).success, + ).toBe(false); + }); + + test("accepts context pricing tiers and rejects malformed tier shapes", () => { + const tier = { + input: 10, + output: 37.5, + cache_read: 1, + tier: { type: "context", size: 200000 }, + }; + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + cost: { ...minimalEntry.cost, tiers: [tier] }, + }).success, + ).toBe(true); + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + cost: { ...minimalEntry.cost, tiers: [{ ...tier, tier: { type: "token", size: 200000 } }] }, + }).success, + ).toBe(false); + }); + test("validates every bundled models.json entry with zero drops", () => { const bundled = JSON.parse(readFileSync(join(repoRoot, "models.json"), "utf-8")) as unknown[]; expect(bundled.length).toBeGreaterThan(20); @@ -140,6 +179,17 @@ describe("AvailabilityPayloadSchema / parseAvailabilityIds", () => { expect(parseAvailabilityIds(ok([{ id: "a" }, { id: "b/c" }]))).toEqual(["a", "b/c"]); }); + test("retains valid vendor context lengths and ignores malformed optional values", () => { + expect( + parseAvailabilityModels( + ok([ + { id: "Qwen/Qwen3.6-Plus", context_length: 200000 }, + { id: "other", context_length: "unknown" }, + ]), + ), + ).toEqual([{ id: "Qwen/Qwen3.6-Plus", context_length: 200000 }, { id: "other" }]); + }); + test("tolerates extra provider fields on items and top level (lenient)", () => { expect( parseAvailabilityIds({ diff --git a/tests/unit/v2models.test.ts b/tests/unit/v2models.test.ts index e5de66f..9e8af56 100644 --- a/tests/unit/v2models.test.ts +++ b/tests/unit/v2models.test.ts @@ -37,6 +37,89 @@ test("toV2Model keeps short UI id and full Command Code wire modelID", () => { expect(m.id).toBe("deepseek-v4.1-flash"); expect(m.modelID).toBe("deepseek/deepseek-v4.1-flash"); expect(m.name).toBe("DeepSeek V4.1 Flash"); + expect(m.package).toBeUndefined(); +}); + +test("Claude catalog models use the Anthropic Messages package", () => { + const ids = [ + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-fable-5", + "claude-fable-5-1", + "claude-opus-5-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-haiku-4-5-20251001", + ]; + const models = generateV2Models(ids.map((id) => ({ ...base, id }))); + expect(models.map((model) => model.package)).toEqual(ids.map(() => "aisdk:@ai-sdk/anthropic")); +}); + +test("V2 maps models.dev release and catalog metadata", () => { + const model = toV2Model({ + ...base, + family: "claude-sonnet", + release_date: "2026-02-17", + status: "beta", + limit: { context: 200000, input: 195000, output: 16000 }, + }); + expect(model.family).toBe("claude-sonnet"); + expect(model.time.released).toBe(Date.UTC(2026, 1, 17)); + expect(model.status).toBe("beta"); + expect(model.limit).toEqual({ context: 200000, input: 195000, output: 16000 }); +}); + +test("V2 emits context cost tiers and avoids duplicating the 200K legacy tier", () => { + const model = toV2Model({ + ...base, + cost: { + input: 5, + output: 25, + cache_read: 0.5, + context_over_200k: { input: 10, output: 37.5, cache_read: 1 }, + tiers: [ + { + input: 10, + output: 37.5, + cache_read: 1, + tier: { type: "context", size: 200000 }, + }, + { + input: 12, + output: 45, + cache_read: 1.2, + tier: { type: "context", size: 272000 }, + }, + ], + }, + }); + expect(model.cost).toEqual([ + { input: 5, output: 25, cache: { read: 0.5, write: 0 } }, + { + input: 10, + output: 37.5, + cache: { read: 1, write: 0 }, + tier: { type: "context", size: 200000 }, + }, + { + input: 12, + output: 45, + cache: { read: 1.2, write: 0 }, + tier: { type: "context", size: 272000 }, + }, + ]); + + const legacy = toV2Model({ + ...base, + cost: { input: 3, output: 15, context_over_200k: { input: 6, output: 30 } }, + }); + expect(legacy.cost[1]).toEqual({ + input: 6, + output: 30, + cache: { read: 0, write: 0 }, + tier: { type: "context", size: 200000 }, + }); }); test("toV2Model defaults to text-only when no modality metadata", () => { From ca8714096baa352a9d77b71e555ae7c8365477b7 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 12:20:28 -0300 Subject: [PATCH 2/7] feat: add OpenCode V2 auth integration --- plugin.ts | 22 +++++++++++++++---- tests/unit/plugin-v2.test.ts | 41 +++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/plugin.ts b/plugin.ts index ed87cb8..f8adb8e 100644 --- a/plugin.ts +++ b/plugin.ts @@ -289,7 +289,7 @@ export async function server() { }; } -/** V2 setup: provider inventory + models through transforms. Auth stays V1-only for now. */ +/** V2 setup: provider inventory, models, and API-key integration through transforms. */ async function setup(ctx: any): Promise { const pluginCfg = loadPluginConfig(); const debug = pluginCfg.debugStartupLogs === true; @@ -304,11 +304,12 @@ async function setup(ctx: any): Promise { info: { id: "commandcode", name: "Command Code", - activation: "enabled", + activation: "auto", + integrationID: "commandcode", + env: ["COMMANDCODE_API_KEY"], package: `aisdk:${PROVIDER_SDK_NPM}`, settings: { baseURL: PROVIDER_API_BASE_URL, - apiKey: "{env:COMMANDCODE_API_KEY}", }, }, models: v2models, @@ -317,14 +318,27 @@ async function setup(ctx: any): Promise { } editor.update("commandcode", (provider: any) => { provider.name = provider.name ?? "Command Code"; + provider.activation = provider.activation ?? "auto"; + provider.integrationID = provider.integrationID ?? "commandcode"; + provider.env = provider.env ?? ["COMMANDCODE_API_KEY"]; provider.settings = provider.settings ?? {}; if (typeof provider.settings === "object") { if (!provider.settings.baseURL) provider.settings.baseURL = PROVIDER_API_BASE_URL; - if (!provider.settings.apiKey) provider.settings.apiKey = "{env:COMMANDCODE_API_KEY}"; } }); editor.models.set("commandcode", v2models); }); + + await ctx.integration.transform((editor: any) => { + editor.method.update({ + integrationID: "commandcode", + method: { type: "key", label: "API Key" }, + }); + editor.method.update({ + integrationID: "commandcode", + method: { type: "env", names: ["COMMANDCODE_API_KEY"] }, + }); + }); } const definition = { id: "commandcode", setup }; diff --git a/tests/unit/plugin-v2.test.ts b/tests/unit/plugin-v2.test.ts index 93e32a1..c949577 100644 --- a/tests/unit/plugin-v2.test.ts +++ b/tests/unit/plugin-v2.test.ts @@ -31,6 +31,7 @@ test("default export carries V2 id/setup plus V1 server", async () => { test("V2 setup adds provider with models when missing", async () => { const calls: Array<{ name: string }> = []; + const methods: unknown[] = []; let added: any = null; const ctx = { provider: { @@ -53,11 +54,21 @@ test("V2 setup adds provider with models when missing", async () => { fn(editor); }, }, + integration: { + transform: async (fn: (editor: any) => void) => { + calls.push({ name: "integration.transform" }); + fn({ method: { update: (input: unknown) => methods.push(input) } }); + }, + }, }; await dual.setup(ctx); - expect(calls.length).toBe(1); + expect(calls.map((call) => call.name)).toEqual(["provider.transform", "integration.transform"]); expect(added.info.id).toBe("commandcode"); + expect(added.info.activation).toBe("auto"); expect(added.info.package).toBe("aisdk:@ai-sdk/openai-compatible"); + expect(added.info.integrationID).toBe("commandcode"); + expect(added.info.env).toEqual(["COMMANDCODE_API_KEY"]); + expect(added.info.settings.apiKey).toBeUndefined(); expect(added.info.settings.baseURL).toBe("https://api.commandcode.ai/provider/v1"); expect(Array.isArray(added.models)).toBe(true); expect(added.models.length).toBeGreaterThan(20); @@ -69,6 +80,13 @@ test("V2 setup adds provider with models when missing", async () => { expect(deepseek).toBeDefined(); expect(deepseek.modelID).toBe("deepseek/deepseek-v4.1-flash"); expect(deepseek.id).toBe("deepseek-v4.1-flash"); + expect(methods).toEqual([ + { integrationID: "commandcode", method: { type: "key", label: "API Key" } }, + { + integrationID: "commandcode", + method: { type: "env", names: ["COMMANDCODE_API_KEY"] }, + }, + ]); }); test("V2 setup preserves existing package and sets models", async () => { @@ -83,7 +101,12 @@ test("V2 setup preserves existing package and sets models", async () => { throw new Error("add should not run when provider exists"); }, update: (_id: string, fn2: (p: any) => void) => { - updated = { id: _id }; + updated = { + id: _id, + activation: "disabled", + package: "custom/provider", + settings: {}, + }; fn2(updated); }, models: { @@ -95,13 +118,21 @@ test("V2 setup preserves existing package and sets models", async () => { fn(editor); }, }, + integration: { + transform: async (fn: (editor: any) => void) => { + fn({ method: { update: () => {} } }); + }, + }, }; await dual.setup(ctx); expect(updated.id).toBe("commandcode"); + expect(updated.activation).toBe("disabled"); expect(updated.settings.baseURL).toBe("https://api.commandcode.ai/provider/v1"); - expect(updated.settings.apiKey).toBe("{env:COMMANDCODE_API_KEY}"); - // Must not overwrite an existing custom package. - expect(updated.package).toBeUndefined(); + expect(updated.integrationID).toBe("commandcode"); + expect(updated.env).toEqual(["COMMANDCODE_API_KEY"]); + expect(updated.settings.apiKey).toBeUndefined(); + // Must not overwrite existing user choices. + expect(updated.package).toBe("custom/provider"); expect(setModels.id).toBe("commandcode"); expect(setModels.models.length).toBeGreaterThan(20); }); From 852e66e31d5e70e5269cb5da113f05cc8f620599 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 12:20:39 -0300 Subject: [PATCH 3/7] docs: document OpenCode V2 parity --- AGENTS.md | 1 - README.md | 28 +++- .../specs/2026-08-19-stable-model-identity.md | 9 +- docs/specs/2026-09-28-v2-parity.md | 121 ++++++++++++++++++ 4 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 docs/specs/2026-09-28-v2-parity.md diff --git a/AGENTS.md b/AGENTS.md index e113e85..8d63531 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,5 @@ Any branch that changes shipped behavior — `plugin.ts`, `index.ts`, `src/**`, - `src/schemas.ts` is the single source of truth for `ModelEntry` / `CatalogManifest` shapes. `src/catalog.ts` and `src/manifest.ts` derive them via `z.infer` — never redeclare the shape. Verify: `grep -rn "interface ModelEntry\|type CatalogManifest = {" src` must be empty. - zod lives only at validation boundaries (bundled `models.json` / `manifest.json` reads in `plugin.ts`, provider availability payloads, user config files). Hot paths (`src/convert.ts`, `src/stream.ts`, `src/model.ts`, the `generate*` model loops) stay zod-free. Verify: `grep -rn 'from "zod"' src/convert.ts src/stream.ts src/model.ts` must be empty. -- `dist/plugin.js` budget: ~708KB with zod bundled (was ~45KB type-only). A second runtime dependency requires either `--external` in `scripts/build-plugin.ts` or updating this budget line. Verify: `bun run build && du -h dist/plugin.js`. - V1 (`generateOpencodeModels` in `src/catalog.ts`) and V2 (`toV2Model` in `src/v2models.ts`) cost/limit/modality mappings must stay in parity — change one, update the other plus `tests/unit/v2models.test.ts`. UI/map keys use `toConfigKey` in `src/catalog.ts` (do not duplicate it); the Command Code wire id is always catalog `entry.id` (V1 model `id`, V2 `modelID`) — bare short names 400 as unsupported_model. - Entry points: `plugin.ts` owns all config-hook logic; `index.ts` re-exports + SDK factory; `src/entry.ts` is bundle glue for `scripts/build-plugin.ts` only. Do not add a fourth entry or duplicate the catalog-load path. diff --git a/README.md b/README.md index 3392217..6dbe94e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ [Command Code](https://commandcode.ai) API provider for [opencode](https://opencode.ai). Use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Step, and other models through a single API key. +This plugin is for Command Code accounts with Provider API access. GOAT is the live-tested baseline; other API-enabled plans can use models their account is entitled to. The $1 Go plan has no Provider API access. The catalog follows the provider-wide model list, so a model appearing in OpenCode does not imply that every account can call it. See [GOAT plan details](https://commandcode.ai/docs/plans/goat) and [Provider API docs](https://commandcode.ai/docs/provider). + This package keeps a **bundled** model catalog current via CI. You do **not** need a local `command-code` CLI. Catalog patches publish automatically after a green PR merges to `main`. Previously published as `@brainervirus/commandcode-go-opencode-provider`. Use this name instead. @@ -17,11 +19,13 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi ### What this package adds - Bundled `models.json` is the default runtime catalog (no local CLI scrape). -- Dual OpenCode entry: V2 `setup` injects the provider plus models via transforms; V1 `server` fills `provider.commandcode` defaults plus models and registers API-key auth (auth stays V1-only). +- V1 and V2 use their native plugin and provider surfaces. V1 `server()` fills `provider.commandcode` and registers API-key auth; V2 `setup()` registers provider/model transforms and a key/env integration for `/connect` and `opencode auth login`. - Validation at the boundaries via `src/schemas.ts` (zod): bundled `models.json` / `manifest.json` reads, provider availability payloads, and the plugin config file. - CLI cost extraction can fail without dropping models; official docs fill missing costs, remaining paid gaps use [models.dev](https://models.dev) as a reference price at sync time. Command Code free SKUs stay `$0`. - 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`. +- Release date, family, input limits, model status, vendor context limits, and matched context-price tiers flow through the bundled catalog. V2 gets native release, family, input, status, and tier fields; V1 keeps its supported fields and base prices, with `context_over_200k` where representable. +- Claude models use the Anthropic Messages package override in both host versions. This route is covered by mapping tests, but GOAT live validation returned a plan-entitlement rejection. If your API-enabled account can access a Claude model and the route fails, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). ## How it works @@ -29,32 +33,42 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi Each CI sync extracts the model catalog from the latest `command-code` npm bundle, merges costs, and commits versioned artifacts; at startup the plugin loads those artifacts and registers them with OpenCode — never the other way around. - **Extract + filter** — model entries (ids, names, reasoning, `inputModalities`, limits) are evaluated out of the minified CLI bundle (`src/catalog.ts`), then intersected with the callable IDs reported by the provider API. -- **Costs merge** — per model, first hit wins: CLI bundle costs → official Command Code docs → free SKUs (`$0`) → [models.dev](https://models.dev) reference prices → unmatched placeholder. Anything still unmatched marks the catalog `degraded`. This runs at sync time only; runtime never fetches prices. +- **Metadata + costs merge** — vendor context length tightens only a fallback context limit; models.dev contributes release date, family, input limit, status, modalities, and cost tiers when present. Tier rows are accepted only when their base prices match this Command Code catalog. Base costs use CLI bundle → official Command Code docs → free SKUs (`$0`) → [models.dev](https://models.dev) reference prices → unmatched placeholder. Anything still unmatched marks the catalog `degraded`. This runs at sync time only; runtime never fetches metadata or prices. - **Artifacts** — `models.json` (the catalog), `_version.txt` (upstream version), `manifest.json` (counts, per-source cost stats, `healthy`/`degraded`/`broken` status). -- **V1 injection + V2 transform** — V1 `server()` fills `provider.commandcode` defaults and the models map; V2 `setup()` adds/updates the provider inventory (baseURL, API key binding) and sets models through `ctx.provider.transform`. +- **Version-specific registration** — V1 uses the `plugin` config key, `server()` provider map, and V1 auth callback. V2 uses `plugins`, provider/model transforms, and a `commandcode` integration with key and `COMMANDCODE_API_KEY` environment methods. Both retain the Command Code wire model ID. V2 exposes tiered context pricing; V1 emits its supported `context_over_200k` field and keeps flat pricing for other tiers. - **Degraded/cache fallbacks** — a `degraded`/`broken` manifest sets the degraded flag with a reason; an unreadable bundled `models.json` falls back to the last-good cache; auth/connect still registers even with an empty catalog. ## Quick Start ### 1. Install the plugin +OpenCode V2: + +```json +{ + "plugins": ["@brainervirus/opencode-commandcode@latest"] +} +``` + +OpenCode V1: + ```json { "plugin": ["@brainervirus/opencode-commandcode@latest"] } ``` -Pin a version instead of `@latest` if you do not want automatic catalog patches. The config key stays `plugin` in OpenCode V2 — there is no `plugins` key. +Pin a version instead of `@latest` if you do not want automatic catalog patches. `file://` checkouts are **not** updated by npm; `git pull` after CI commits, or switch to the npm plugin line. ### 2. No provider block needed -On OpenCode V2 the plugin registers the `commandcode` provider itself (Provider API base URL plus `COMMANDCODE_API_KEY` binding) and its models. On V1 the `server` hook fills the same `provider.commandcode` defaults — `npm: "@ai-sdk/openai-compatible"` plus the Provider API `baseURL`; the plugin package itself is never the SDK `npm` field. Only add a manual `provider.commandcode` entry if you need non-default transport options. +On OpenCode V2 the plugin registers the `commandcode` provider, its models, and its API base URL through the V2 provider API. On V1 the `server` hook fills `provider.commandcode` defaults — `npm: "@ai-sdk/openai-compatible"` plus the Provider API `baseURL`; the plugin package itself is never the SDK `npm` field. Only add a manual provider entry if you need non-default transport options. ### 3. Connect -Set `COMMANDCODE_API_KEY`, or on OpenCode V1 run `/connect`, search for **Command Code**, and enter your API key. V2 registers the provider with the env binding; the `/connect` API-key method is V1-only. +Set `COMMANDCODE_API_KEY`, or connect interactively. OpenCode V1 provides **Command Code** through `/connect`; OpenCode V2 registers key and environment methods for `/connect` and `opencode auth login commandcode`. V2 uses OpenCode's automatic provider activation and preserves an explicit activation setting. Connect before running a model; an explicit run while disconnected still returns an authorization error from the API. ### 4. Select a model @@ -97,7 +111,7 @@ bun run generate-readme # reports catalog counts only; README is hand-edited bun run catalog:ci # entry used by the catalog-sync workflow ``` -Entry points: `plugin.ts` owns all config-hook logic (dual default `{ id, setup }` plus `server`); `index.ts` re-exports the plugin plus the `createCommandCode` SDK factory; `src/entry.ts` is bundle glue for `scripts/build-plugin.ts` only — it produces `dist/plugin.js`. +Entry points: `plugin.ts` owns both config surfaces (V2 `id`/`setup` plus V1 `server`); `index.ts` re-exports the plugin plus the `createCommandCode` SDK factory; `src/entry.ts` is bundle glue for `scripts/build-plugin.ts` only — it produces `dist/plugin.js`. CI (`.github/workflows/catalog-sync.yml`) opens a `fix(catalog)` PR every 6 hours when Command Code ships a new catalog; if extraction fails it opens a `catalog-break` issue instead. The PR auto-merges after **check (test)**, **check (typecheck)**, **check (lint)**, **check (format)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (build + verified npm publish + GitHub Release + tag). Do not push to `main`. diff --git a/docs/specs/2026-08-19-stable-model-identity.md b/docs/specs/2026-08-19-stable-model-identity.md index d93e55f..340e91d 100644 --- a/docs/specs/2026-08-19-stable-model-identity.md +++ b/docs/specs/2026-08-19-stable-model-identity.md @@ -1,6 +1,6 @@ # Command Code OpenCode Provider — Runtime Identity and Resilience -Status: shipped (updated 2026-09-26) +Status: shipped baseline (updated 2026-09-26; current V2 additions are in the [2026-09-28 parity spec](./2026-09-28-v2-parity.md)) ## Goal @@ -35,7 +35,7 @@ Every load with models rewrites the cache; every load writes `startup.json`. Wri | cost data missing after the CI waterfall | continue; unmatched costs keep the placeholder and the manifest is `degraded` | | provider availability call fails in sync | no artifact writes and a `catalog-break` issue; the previous catalog stays | | opt-in local extract fails | ignore override; use bundled | -| auth/connect | registers on V1 regardless of catalog state | +| auth/connect | V1 auth and V2 integration register regardless of catalog state | Degraded reporting is internal: `startup.json` carries `degraded` and `degradedReason`, and V1/V2 registration is unchanged. There is no separate degraded UI. @@ -57,7 +57,7 @@ Degraded reporting is internal: `startup.json` carries `degraded` and `degradedR - Default is quiet: no `console.log`/`console.warn` in the plugin load path. - `~/.local/state/opencode/commandcode-provider/startup.json` records `catalogSource` (`bundled`/`cache`/`opt-in-local`), `commandCodeVersion`, `modelCount`, `reasoningModelCount`, `degraded`, and `degradedReason`. - `debugStartupLogs: true` mirrors the summary to stderr once. -- V1 `server()` registers provider defaults and the API-key auth method; V2 `setup()` adds/updates the provider inventory and models through transforms. Auth stays V1-only. +- At this spec's 0.9.1 baseline, V1 `server()` registered provider defaults and the API-key auth method; V2 `setup()` only added/updated provider inventory and models. V2 key/env integration support was added later; see the [parity spec](./2026-09-28-v2-parity.md). ## Config @@ -81,7 +81,7 @@ If favorites migration is ever needed, reopen it as a new spec against the curre ## Test coverage -Unit tests exercise the shipped contract (`tests/unit/plugin.test.ts`, `startup.test.ts`, `schemas.test.ts`, `catalog.test.ts`, `v2models.test.ts`, `auth.test.ts`): +Unit tests exercise the baseline contract (`tests/unit/plugin.test.ts`, `startup.test.ts`, `schemas.test.ts`, `catalog.test.ts`, `v2models.test.ts`, `auth.test.ts`); V2 integration behavior is covered by `tests/unit/plugin-v2.test.ts`: - bundled load, cache fallback, and dropped-entry degraded reasons - V1 map key vs wire id; V2 `id` vs `modelID` @@ -92,3 +92,4 @@ Unit tests exercise the shipped contract (`tests/unit/plugin.test.ts`, `startup. - [CI catalog automation](./2026-08-28-ci-catalog-automation.md) - [Catalog freshness](./2026-09-20-catalog-freshness.md) +- [OpenCode V1 and V2 parity completion](./2026-09-28-v2-parity.md) diff --git a/docs/specs/2026-09-28-v2-parity.md b/docs/specs/2026-09-28-v2-parity.md new file mode 100644 index 0000000..7e1160d --- /dev/null +++ b/docs/specs/2026-09-28-v2-parity.md @@ -0,0 +1,121 @@ +# OpenCode V1 and V2 parity completion + +Status: implemented in the current workspace; verification results are recorded +below. This spec supersedes the pre-implementation status in the 0.9.1 audit. + +## Goal and scope + +Keep the plugin working on both OpenCode generations through their native +surfaces. V1 retains its provider map, server hook, and auth callback. V2 uses +provider/model transforms and its integration API. Use V2-only model and auth +features when V1 has no equivalent; do not weaken or remove the V1 path. + +The provider catalog is global, while Command Code enforces account entitlements +at request time. GOAT is the live-tested baseline. Other plans with Provider API +access are in scope for models they entitle, but plan-specific access is not +claimed as live-verified. The $1 Go plan is unsupported because Command Code +documents that it does not include Provider API access; this is an API +availability limit, not a claim about its Terms of Service. See the official +[GOAT plan](https://commandcode.ai/docs/plans/goat), [Go plan](https://commandcode.ai/docs/plans/go), +and [Provider API](https://commandcode.ai/docs/provider) documentation. + +## Compatibility rules + +- `src/schemas.ts` remains the strict source of truth for catalog entries. + Invalid entries are dropped and reported as degraded. +- Keep zod at validation boundaries and catalog/model hot paths zod-free. +- Preserve V1 behavior. Map metadata and costs to both versions when both host + schemas can represent them; use V2-native fields when V1 cannot. +- Keep API credentials out of configuration. V2 stores only the declared env + variable name unless a user explicitly enters a key through OpenCode auth. +- Keep the catalog, plan entitlements, and live test coverage distinct: seeing + a model in the global provider catalog does not mean every account can call it. + +## A — Claude Messages routing + +The current vendor catalog identifies nine Claude models with the Anthropic +Messages endpoint. `usesAnthropicMessagesApi()` selects the Anthropic SDK +package per model in both host paths: V1 `model.provider.npm` and V2 `model.package`. +Other models retain the OpenAI-compatible package. + +The isolated GOAT probes for Claude Sonnet and Haiku returned +`MODEL_NOT_IN_PLAN`. They did not establish whether Anthropic requests succeed +for an account entitled to those models. No higher-plan credential was used. +Mapping tests cover all current Claude catalog IDs on V1 and V2. If a user with +access sees an endpoint or request-shape problem, report the model ID and +OpenCode version in an [issue](https://github.com/BrainerVirus/opencode-commandcode/issues) +or submit a PR with a reproducible test. + +## B — Catalog metadata + +The strict catalog pipeline carries: + +1. models.dev `release_date` to V1 `release_date` and V2 `time.released` in + epoch milliseconds. +2. Vendor `/models` `context_length` as a clamp only when the CLI bundle does + not specify its own context. The current catalog clamps Qwen 3.6 Max Preview, + Qwen 3.6 Plus, and MiniMax M2.7 to 200K. +3. models.dev `limit.input`, `family`, and `beta`/`deprecated` status when + present. Missing status remains active. +4. Existing modalities and costs without clearing known values when a metadata + source is partial. + +The current 82-model catalog has release dates for 81 entries, family for 77, +input limits for 13, one beta model, and one deprecated model. These are +generated-data counts, not fixed schema expectations. + +## C — V2 integration and auth + +V2 registers the `commandcode` provider with `integrationID`, +`COMMANDCODE_API_KEY`, and V2 provider/model transforms. The integration offers +key and environment methods. New V2 provider registrations default to native +`activation: "auto"`; an explicit user activation setting is preserved. V1 +`server()` auth remains intact. + +In isolated OpenCode V2.0.18 Docker checks, the integration appeared in the +integration API, key login stored a test-only dummy account, and the env method +appeared without exposing the environment secret. With no credential, +`connections` was empty. An explicit no-key model run still reached the request +path and returned an invalid-authorization error; native integration state +reports that the provider is disconnected, but OpenCode does not block an +explicit run before the API call. With the GOAT key supplied only through the +temporary container environment, the eligible DeepSeek smoke test succeeded. + +## D — Context-size cost tiers + +The sync pipeline preserves models.dev context tiers and `context_over_200k`. +Tier aliases are accepted only when their base input and output prices match +the Command Code catalog, preventing another provider's rates from entering +this catalog. The current sync has tiers for 23 models, with boundaries at +32K, 128K, 200K, 200001, 256K, 262144, 272K, and 512K tokens. + +V2 emits native `cost[]` tiers. V1 emits `context_over_200k` when an exact +200K tier is available and keeps its flat base price for other tiers that its +model schema cannot express. + +## E — Documentation and current usage + +README examples now use `plugin` for V1 and `plugins` for V2, describe both +auth surfaces and their distinct cost support, and state the plan/test limits. +The stable-model-identity spec describes the 0.9.1 baseline historically and +links here for current V2 auth and metadata behavior. + +## Verification + +- Unit tests cover strict schema validation, source merging, vendor context + clamps, tier alias price matching, V1/V2 metadata and cost mappings, Claude + package selection, and V2 integration registration. +- `bun run check` passed: lint, formatting, typecheck, and 228 unit tests + (650 expectations). +- `bun run build` passed and produced `dist/plugin.js` (0.73 MB). +- `bun run verify:release-candidate` passed: the 0.9.1 package dry-run + contained 50 files and did not publish. +- Official OpenCode Docker images `ghcr.io/anomalyco/opencode:1.18.30` and + `ghcr.io/anomalyco/opencode:2.0.18` each returned `OK` for the GOAT-eligible + DeepSeek V4.1 Flash smoke prompt using isolated temporary homes/configs. +- Claude plan-denial and disconnected-state results are described above; no + higher-plan model request was made. + +All live checks used temporary Docker homes and config files. The user's active +OpenCode setup, global configuration, auth files, and `@latest` resolution were +not changed. From 79e5da725e3d4c27c43b6491de138c260885b337 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 16:07:04 -0300 Subject: [PATCH 4/7] feat: route models through advertised endpoints --- .github/workflows/catalog-sync.yml | 2 +- README.md | 8 +-- docs/2026-08-28-ci-catalog/spec.md | 2 +- .../specs/2026-08-28-ci-catalog-automation.md | 19 +++-- docs/specs/2026-09-20-catalog-freshness.md | 3 + docs/specs/2026-09-28-v2-parity.md | 30 +++++--- scripts/sync-models.ts | 39 +++++++++-- src/catalog.ts | 27 +++++++- src/publish-policy.ts | 7 +- src/schemas.ts | 2 + src/v2models.ts | 6 +- tests/unit/catalog.test.ts | 69 +++++++++++++++++++ tests/unit/publish-policy.test.ts | 10 +-- tests/unit/schemas.test.ts | 24 +++++++ tests/unit/sync-models.test.ts | 50 ++++++++++++-- tests/unit/v2models.test.ts | 17 +++++ 16 files changed, 273 insertions(+), 42 deletions(-) diff --git a/.github/workflows/catalog-sync.yml b/.github/workflows/catalog-sync.yml index e9cdf81..7c7d84d 100644 --- a/.github/workflows/catalog-sync.yml +++ b/.github/workflows/catalog-sync.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: force: - description: Re-extract even if command-code version is unchanged + description: Force a catalog refresh, including for an unpublished plugin version type: boolean default: false diff --git a/README.md b/README.md index 6dbe94e..e55b1e0 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,15 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - 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`. - Release date, family, input limits, model status, vendor context limits, and matched context-price tiers flow through the bundled catalog. V2 gets native release, family, input, status, and tier fields; V1 keeps its supported fields and base prices, with `context_over_200k` where representable. -- Claude models use the Anthropic Messages package override in both host versions. This route is covered by mapping tests, but GOAT live validation returned a plan-entitlement rejection. If your API-enabled account can access a Claude model and the route fails, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. +- The provider's `supported_endpoints` metadata chooses each model's API route. Responses-capable models use `@ai-sdk/openai` on V1 and `aisdk:@ai-sdk/openai` on V2; Claude falls back to Anthropic Messages when endpoint metadata is absent. The V1 and V2 Responses mappings passed official Docker checks against a local mock stream; an entitled Claude live account is not available for verification. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). ## How it works -Each CI sync extracts the model catalog from the latest `command-code` npm bundle, merges costs, and commits versioned artifacts; at startup the plugin loads those artifacts and registers them with OpenCode — never the other way around. +For published plugin versions, the six-hour CI schedule extracts the latest `command-code` npm bundle and refreshes callable models plus endpoint metadata, even when the CLI version is unchanged. It opens a catalog PR only when generated artifacts change; at startup the plugin loads those artifacts and registers them with OpenCode — never the other way around. - **Extract + filter** — model entries (ids, names, reasoning, `inputModalities`, limits) are evaluated out of the minified CLI bundle (`src/catalog.ts`), then intersected with the callable IDs reported by the provider API. -- **Metadata + costs merge** — vendor context length tightens only a fallback context limit; models.dev contributes release date, family, input limit, status, modalities, and cost tiers when present. Tier rows are accepted only when their base prices match this Command Code catalog. Base costs use CLI bundle → official Command Code docs → free SKUs (`$0`) → [models.dev](https://models.dev) reference prices → unmatched placeholder. Anything still unmatched marks the catalog `degraded`. This runs at sync time only; runtime never fetches metadata or prices. +- **Metadata + costs merge** — vendor context length tightens only a fallback context limit, while `supported_endpoints` selects Chat Completions, Responses, or Messages per model; missing endpoint metadata preserves the last known route data. models.dev contributes release date, family, input limit, status, modalities, and cost tiers when present. Tier rows are accepted only when their base prices match this Command Code catalog. Base costs use CLI bundle → official Command Code docs → free SKUs (`$0`) → [models.dev](https://models.dev) reference prices → unmatched placeholder. Anything still unmatched marks the catalog `degraded`. This runs at sync time only; runtime never fetches metadata or prices. - **Artifacts** — `models.json` (the catalog), `_version.txt` (upstream version), `manifest.json` (counts, per-source cost stats, `healthy`/`degraded`/`broken` status). - **Version-specific registration** — V1 uses the `plugin` config key, `server()` provider map, and V1 auth callback. V2 uses `plugins`, provider/model transforms, and a `commandcode` integration with key and `COMMANDCODE_API_KEY` environment methods. Both retain the Command Code wire model ID. V2 exposes tiered context pricing; V1 emits its supported `context_over_200k` field and keeps flat pricing for other tiers. - **Degraded/cache fallbacks** — a `degraded`/`broken` manifest sets the degraded flag with a reason; an unreadable bundled `models.json` falls back to the last-good cache; auth/connect still registers even with an empty catalog. @@ -113,7 +113,7 @@ bun run catalog:ci # entry used by the catalog-sync workflow Entry points: `plugin.ts` owns both config surfaces (V2 `id`/`setup` plus V1 `server`); `index.ts` re-exports the plugin plus the `createCommandCode` SDK factory; `src/entry.ts` is bundle glue for `scripts/build-plugin.ts` only — it produces `dist/plugin.js`. -CI (`.github/workflows/catalog-sync.yml`) opens a `fix(catalog)` PR every 6 hours when Command Code ships a new catalog; if extraction fails it opens a `catalog-break` issue instead. The PR auto-merges after **check (test)**, **check (typecheck)**, **check (lint)**, **check (format)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (build + verified npm publish + GitHub Release + tag). Do not push to `main`. +CI (`.github/workflows/catalog-sync.yml`) checks every 6 hours for CLI catalog changes and provider availability/endpoint metadata changes; it opens a `fix(catalog)` PR only when generated files change. If extraction fails or the model-count safety floor is breached, it opens a `catalog-break` issue and leaves the last-good files intact. The PR auto-merges after **check (test)**, **check (typecheck)**, **check (lint)**, **check (format)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (build + verified npm publish + GitHub Release + tag). Do not push to `main`. The GitHub Actions secret name is `NPMJS`. It is mapped to both `NPM_TOKEN` and `NODE_AUTH_TOKEN`. Use an npm **Automation** token (bypasses 2FA). A login token from `~/.npmrc` fails CI with `EOTP`. Catalog PRs get a real CI run when `RELEASE_SYNC_TOKEN` is a PAT; `GITHUB_TOKEN` can open the PR but GitHub will not start workflows from that event. diff --git a/docs/2026-08-28-ci-catalog/spec.md b/docs/2026-08-28-ci-catalog/spec.md index a2ebfdc..22e878e 100644 --- a/docs/2026-08-28-ci-catalog/spec.md +++ b/docs/2026-08-28-ci-catalog/spec.md @@ -17,7 +17,7 @@ Watch `command-code` on npm every 6 hours, refresh the bundled catalog, publish - Human merges to `main` run semantic-release; releases are path-gated (`scripts/analyze-release-scope.ts`), so CI/docs/tests-only merges do not publish. - Cost-only CLI failure still ships (`degraded` only if unmatched placeholder costs remain). Model extract failure → no publish, `catalog-break` issue. - Runtime catalog stays bundled `models.json`. No GitHub fetch at OpenCode startup. -- Hybrid OpenCode transport stays `@ai-sdk/openai-compatible` + Provider API; this package is the **plugin**, not the SDK `npm` field. +- Chat Completions keep `@ai-sdk/openai-compatible`; provider `supported_endpoints` selects per-model Responses (`@ai-sdk/openai`) or Anthropic Messages where advertised. This package remains the **plugin**, not the SDK `npm` field. ## First publish diff --git a/docs/specs/2026-08-28-ci-catalog-automation.md b/docs/specs/2026-08-28-ci-catalog-automation.md index a9638e1..af74ea6 100644 --- a/docs/specs/2026-08-28-ci-catalog-automation.md +++ b/docs/specs/2026-08-28-ci-catalog-automation.md @@ -1,6 +1,10 @@ # CI Catalog Automation — Zero Local Command Code -Status: shipped (updated 2026-09-26) +Status: shipped (updated 2026-09-28) + +The scheduled job also refreshes provider availability and endpoint metadata +when the CLI bundle version is unchanged. The implementation phases and plan +decomposition below are retained as historical rollout notes. ## Goal @@ -116,13 +120,13 @@ Never fail the sync because costs are incomplete. Model catalog remains the hard Triggers: - cron: every 6 hours (`0 */6 * * *`) -- `workflow_dispatch` with optional `force=true` (re-extract even if version matches) +- `workflow_dispatch` with optional `force=true` (force extraction, including for an unpublished plugin version) Out of scope: `repository_dispatch` watchers. Steps: 1. Read npm `command-code@latest` version. -2. **Idempotency:** if `commandCodeVersion` is unchanged and `force` is false, skip extraction. Unpublished-plugin-version retries are owned by the release job (`release.yml`), not catalog-sync. +2. If `commandCodeVersion` is unchanged, a published plugin still refreshes the API inventory and metadata on every scheduled run. This catches availability and `supported_endpoints` changes that can ship independently of the CLI bundle. If the current plugin version is unpublished and the CLI bundle is unchanged, wait for the release job (`release.yml`) instead of opening a catalog PR. `force=true` re-extracts in either case. 3. Download tarball (reuse logic from `scripts/sync-models.ts --remote`). 4. Extract models (required), filter to the provider-API callable ids, then run the **cost waterfall**: - model catalog fail, availability request fail, or filtered count below floor → status `broken`, no artifact writes, no PR. @@ -255,12 +259,13 @@ One PR path only. ## Acceptance Criteria - 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). -- Within 6 hours of a new `command-code` npm release, CI either opens a `fix(catalog)` PR (a patch publishes after it auto-merges) or opens/updates a `catalog-break` issue. +- Within 6 hours of a new `command-code` npm release or provider API availability/endpoint change, CI either opens a `fix(catalog)` PR (a patch publishes after it auto-merges) or opens/updates a `catalog-break` issue. This also runs when the CLI version is unchanged. +- A stable provider API response plus unchanged generated files produces no PR; a partial endpoint metadata response preserves the last known endpoint list. - 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. - Successful sync never requires local `bun run sync` from the user. - Failed model extraction never publishes a misleading npm release. -## Implementation Phases +## Historical Implementation Phases Same sequence as the identity spec. Phase 1 (A) must refresh `models.json` before anyone relies on “bundled is current”. @@ -291,7 +296,7 @@ Same sequence as the identity spec. Phase 1 (A) must refresh `models.json` befor 3. Enable publish + GitHub Release in the workflow. 4. Document `"plugin": ["@brainervirus/opencode-commandcode@latest"]` vs pin. -## Plan decomposition +## Historical Plan Decomposition When writing implementation plans, split so each plan ships something usable: @@ -306,7 +311,7 @@ Do not block Plan 1 on npm publishing. | Risk | Mitigation | |---|---| -| Bot PR noise | skip extraction when tarball version unchanged | +| Bot PR noise | compare generated artifacts; unchanged files produce no PR | | npm publish failure after merge | release job retries unpublished plugin versions; tag/Release only after publish succeeds | | Extraction anchors break silently | relative model-count floor + catalog-break issue | | User on `file://` | CI cannot update that checkout; README says git pull or switch to npm | diff --git a/docs/specs/2026-09-20-catalog-freshness.md b/docs/specs/2026-09-20-catalog-freshness.md index 3fb0f22..b766dc2 100644 --- a/docs/specs/2026-09-20-catalog-freshness.md +++ b/docs/specs/2026-09-20-catalog-freshness.md @@ -63,6 +63,8 @@ Network failure, invalid JSON, invalid shape, duplicate IDs, an empty list, or a The previous committed artifacts remain the last-good catalog; this design adds no runtime availability call and no second cache (the runtime last-good cache from the identity spec is unchanged). +The six-hour catalog job refreshes the provider availability list and its `supported_endpoints` metadata even when the CLI bundle version is unchanged. Endpoint metadata is replaced when the provider supplies it; a partial response preserves the last known endpoint list for that model. The job still opens a PR only when generated artifacts change and retains the existing model-count floor and `catalog-break` path. + Cost and modality enrichment runs only for the filtered catalog. `manifest.modelCount`, `reasoningModelCount`, and cost-source counts describe published models, not excluded candidates. `manifest.review.unavailable` is additive to schema version 1: @@ -130,6 +132,7 @@ This preserves deterministic startup and offline use. Freshness is delivered by - A fixture with three CLI candidates and two API IDs writes exactly the two exact-ID matches. - API-absent candidates appear only in sorted `manifest.review.unavailable` entries with reason `not-listed-by-provider-api`. +- An unchanged CLI bundle still refreshes provider availability and `supported_endpoints`; partial endpoint metadata preserves the last known list, and unchanged generated artifacts create no PR. - Static or dynamic Command Code hiding requires no special parser handling when the hidden ID is absent from the API. - An unavailable, malformed, empty, duplicate-ID, or below-floor response leaves all generated artifacts byte-for-byte unchanged and exits non-zero. - Cost, reasoning, and manifest counts are computed from the filtered catalog. diff --git a/docs/specs/2026-09-28-v2-parity.md b/docs/specs/2026-09-28-v2-parity.md index 7e1160d..f85a27b 100644 --- a/docs/specs/2026-09-28-v2-parity.md +++ b/docs/specs/2026-09-28-v2-parity.md @@ -31,12 +31,18 @@ and [Provider API](https://commandcode.ai/docs/provider) documentation. - Keep the catalog, plan entitlements, and live test coverage distinct: seeing a model in the global provider catalog does not mean every account can call it. -## A — Claude Messages routing - -The current vendor catalog identifies nine Claude models with the Anthropic -Messages endpoint. `usesAnthropicMessagesApi()` selects the Anthropic SDK -package per model in both host paths: V1 `model.provider.npm` and V2 `model.package`. -Other models retain the OpenAI-compatible package. +## A — Endpoint-aware model routing + +The provider's live `/models` metadata is the route authority when +`supported_endpoints` is present. Models advertising Responses use the +Responses route per model; otherwise models advertising Messages use the +Anthropic route; models that advertise only Chat Completions use the existing +compatible route. V1 uses `@ai-sdk/openai` for Responses and the Anthropic SDK +for Messages. V2 uses `aisdk:@ai-sdk/openai` for Responses and +`aisdk:@ai-sdk/anthropic` for Messages. OpenCode 2.0.18 accepts the AI SDK +package route; its newer native `@opencode/ai` package is not present in that +tested image. If an older catalog lacks route metadata, Claude retains the +Messages fallback and other models retain Chat Completions. The isolated GOAT probes for Claude Sonnet and Haiku returned `MODEL_NOT_IN_PLAN`. They did not establish whether Anthropic requests succeed @@ -46,6 +52,10 @@ access sees an endpoint or request-shape problem, report the model ID and OpenCode version in an [issue](https://github.com/BrainerVirus/opencode-commandcode/issues) or submit a PR with a reproducible test. +The route list is refreshed by the six-hour catalog job, including when the +Command Code CLI version has not changed. A missing field preserves the prior +route metadata; an explicit endpoint list replaces it. + ## B — Catalog metadata The strict catalog pipeline carries: @@ -105,14 +115,18 @@ links here for current V2 auth and metadata behavior. - Unit tests cover strict schema validation, source merging, vendor context clamps, tier alias price matching, V1/V2 metadata and cost mappings, Claude package selection, and V2 integration registration. -- `bun run check` passed: lint, formatting, typecheck, and 228 unit tests - (650 expectations). +- `bun run check` passed: lint, formatting, typecheck, and 233 unit tests + (666 expectations). - `bun run build` passed and produced `dist/plugin.js` (0.73 MB). - `bun run verify:release-candidate` passed: the 0.9.1 package dry-run contained 50 files and did not publish. - Official OpenCode Docker images `ghcr.io/anomalyco/opencode:1.18.30` and `ghcr.io/anomalyco/opencode:2.0.18` each returned `OK` for the GOAT-eligible DeepSeek V4.1 Flash smoke prompt using isolated temporary homes/configs. +- Responses routing passed both official Docker images against a temporary + local Responses stream: V1 sent `POST /provider/v1/responses` via + `@ai-sdk/openai`; V2 sent the same route via `aisdk:@ai-sdk/openai`. + No live Command Code inference was made for this check. - Claude plan-denial and disconnected-state results are described above; no higher-plan model request was made. diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index 5e01fb7..951ceaa 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -13,7 +13,9 @@ import { loadCatalogFromLocalCommandCode, parseAvailabilityModels, type ModelEntry, + type ProviderModelMetadata, } from "@/src/catalog.js"; +import { ModelEntrySchema } from "@/src/schemas.js"; import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "@/src/costs-docs.js"; import { applyFreeCosts, @@ -50,6 +52,21 @@ function readPriorManifest(): CatalogManifest | null { } } +function readPriorModels(): ModelEntry[] { + if (!existsSync(MODELS_JSON)) return []; + try { + const entries: unknown = JSON.parse(readFileSync(MODELS_JSON, "utf-8")); + return Array.isArray(entries) + ? entries.flatMap((entry) => { + const parsed = ModelEntrySchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }) + : []; + } catch { + return []; + } +} + async function fetchAvailabilityModels(): Promise> { const resp = await fetch(MODELS_API_URL); if (!resp.ok) throw new Error(`models endpoint returned ${resp.status}`); @@ -68,7 +85,8 @@ export function buildSyncArtifacts(input: { version: string; sourceLabel: string; pluginVersion: string; - availableIds: string[]; + availabilityModels: ProviderModelMetadata[]; + priorModels: ModelEntry[]; priorManifest: CatalogManifest | null; cliIds: Set; docIds: Set; @@ -78,12 +96,23 @@ export function buildSyncArtifacts(input: { }): SyncArtifacts { const { retained, unavailable } = filterCatalogByAvailability( input.candidates, - input.availableIds, + input.availabilityModels.map((model) => model.id), ); const last = lastSuccessfulModelCount(input.priorManifest); if (!meetsModelCountFloor(retained.length, last)) { throw new Error(`filtered model count ${retained.length} below floor (lastSuccessful=${last})`); } + const availabilityById = new Map( + input.availabilityModels.map((model) => [model.id, model] as const), + ); + const priorById = new Map(input.priorModels.map((model) => [model.id, model] as const)); + const models = retained.map((entry) => { + const endpoints = + availabilityById.get(entry.id)?.supported_endpoints ?? + entry.supported_endpoints ?? + priorById.get(entry.id)?.supported_endpoints; + return endpoints === undefined ? entry : { ...entry, supported_endpoints: endpoints }; + }); const costSources = countCostSources({ modelIds: retained.map((e) => e.id), cliIds: input.cliIds, @@ -118,7 +147,7 @@ export function buildSyncArtifacts(input: { ), generatedAt: input.generatedAt, }); - return { models: retained, version: input.version, manifest }; + return { models, version: input.version, manifest }; } function cliCostIds(source: string): Set { @@ -278,6 +307,7 @@ async function main() { } const priorManifest = readPriorManifest(); + const priorModels = readPriorModels(); // Enrichment mutates entries in place, so filter the extracted candidates // first and run the floor gate before any cost work or artifact write. const prefiltered = filterCatalogByAvailability(candidates, availableIds); @@ -337,7 +367,8 @@ async function main() { version, sourceLabel, pluginVersion, - availableIds, + availabilityModels: vendorModels, + priorModels, priorManifest, cliIds, docIds, diff --git a/src/catalog.ts b/src/catalog.ts index 121a2aa..a8cc9c3 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -787,6 +787,22 @@ export function usesAnthropicMessagesApi(id: string): boolean { return /(?:^|[/:])claude-/i.test(id); } +export type ModelApi = "chat" | "responses" | "messages"; + +export function modelApi(entry: Pick): ModelApi { + const endpoints = entry.supported_endpoints; + const supports = (route: string) => + endpoints?.some((endpoint) => { + const path = (endpoint.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, ""); + return path === route || path.endsWith(`/${route}`); + }) ?? false; + + if (supports("responses")) return "responses"; + if (supports("messages")) return "messages"; + if (supports("chat/completions")) return "chat"; + return usesAnthropicMessagesApi(entry.id) ? "messages" : "chat"; +} + export function generateOpencodeModels(entries: ModelEntry[]): Record { const models: Record = {}; for (const entry of entries) { @@ -826,7 +842,10 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record { retained: T[]; diff --git a/src/publish-policy.ts b/src/publish-policy.ts index e81eff5..a7ce4f1 100644 --- a/src/publish-policy.ts +++ b/src/publish-policy.ts @@ -1,7 +1,6 @@ export type CatalogSyncDecision = { extract: boolean; publishRetry: boolean; - exit: boolean; }; export type PublishDecision = "publish" | "skip-no-token" | "skip-already-published"; @@ -15,10 +14,10 @@ export function decideCatalogSync(input: { }): CatalogSyncDecision { const published = input.publishedPluginVersions.includes(input.pluginVersion); if (input.force || input.latestCommandCodeVersion !== input.bundledCommandCodeVersion) { - return { extract: true, publishRetry: false, exit: false }; + return { extract: true, publishRetry: false }; } - if (!published) return { extract: false, publishRetry: true, exit: false }; - return { extract: false, publishRetry: false, exit: true }; + if (!published) return { extract: false, publishRetry: true }; + return { extract: true, publishRetry: false }; } export function decidePublish(input: { diff --git a/src/schemas.ts b/src/schemas.ts index 1703fae..f754834 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -39,6 +39,7 @@ export const ModelEntrySchema = z.strictObject({ output: z.number().int(), }), family: z.string().min(1).optional(), + supported_endpoints: z.array(z.string().min(1)).optional(), release_date: z.iso.date().optional(), status: z.enum(["active", "beta", "deprecated"]).optional(), attachment: z.boolean().optional(), @@ -103,6 +104,7 @@ export const AvailabilityPayloadSchema = z z.object({ id: z.string().min(1), context_length: z.number().int().positive().optional().catch(undefined), + supported_endpoints: z.array(z.string().min(1)).optional().catch(undefined), }), ) .min(1), diff --git a/src/v2models.ts b/src/v2models.ts index 7473d45..9e3643a 100644 --- a/src/v2models.ts +++ b/src/v2models.ts @@ -1,4 +1,4 @@ -import { toConfigKey, usesAnthropicMessagesApi, type ModelEntry } from "./catalog.js"; +import { modelApi, toConfigKey, type ModelEntry } from "./catalog.js"; /** Minimal V2 model shape (structural subset of Model.Info). */ export interface V2Model { @@ -91,7 +91,9 @@ export function toV2Model(entry: ModelEntry): V2Model { released: entry.release_date ? Date.parse(`${entry.release_date}T00:00:00.000Z`) : 0, }, }; - if (usesAnthropicMessagesApi(entry.id)) model.package = "aisdk:@ai-sdk/anthropic"; + const api = modelApi(entry); + if (api === "responses") model.package = "aisdk:@ai-sdk/openai"; + else if (api === "messages") model.package = "aisdk:@ai-sdk/anthropic"; return model; } diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 7ec5ade..f1679dc 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -9,6 +9,7 @@ import { generateOpencodeModels, loadCatalogFromBundle, loadCatalogFromLocalCommandCodeResult, + modelApi, parseAvailabilityModels, parseAvailabilityIds, resolveCommandCodePackage, @@ -295,6 +296,74 @@ describe("generateOpencodeModels", () => { } }); + test("routes models through Responses when the availability API advertises it", () => { + const models = generateOpencodeModels([ + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + tier: "open-source", + reasoning: true, + tool_call: true, + cost: { input: 0.15, output: 0.6 }, + limit: { context: 1000000, output: 65536 }, + supported_endpoints: ["/provider/v1/chat/completions", "/provider/v1/responses"], + }, + { + id: "openai/gpt-5.5", + name: "GPT 5.5", + tier: "premium", + reasoning: true, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 200000, output: 16000 }, + supported_endpoints: ["responses"], + }, + { + id: "vendor/messages-model", + name: "Messages Model", + tier: "premium", + reasoning: false, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 200000, output: 16000 }, + supported_endpoints: ["/v1/messages"], + }, + { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 100000, output: 16000 }, + supported_endpoints: ["/v1/chat/completions"], + }, + ]); + expect((models["deepseek-v4-flash"] as Record).provider).toEqual({ + npm: "@ai-sdk/openai", + }); + expect((models["gpt-5.5"] as Record).provider).toEqual({ + npm: "@ai-sdk/openai", + }); + expect((models["messages-model"] as Record).provider).toEqual({ + npm: "@ai-sdk/anthropic", + }); + expect((models["gemini-3.5-flash"] as Record).provider).toBeUndefined(); + }); + + test("prefers advertised API routes and keeps the legacy Claude fallback", () => { + expect( + modelApi({ id: "vendor/model", supported_endpoints: ["/v1/messages", "/v1/responses"] }), + ).toBe("responses"); + expect(modelApi({ id: "vendor/model", supported_endpoints: ["/v1/messages"] })).toBe( + "messages", + ); + expect( + modelApi({ id: "claude-sonnet-4-6", supported_endpoints: ["/v1/chat/completions"] }), + ).toBe("chat"); + expect(modelApi({ id: "claude-sonnet-4-6" })).toBe("messages"); + }); + test("emits attachment and modalities, defaulting to text-only", () => { const models = generateOpencodeModels([ { diff --git a/tests/unit/publish-policy.test.ts b/tests/unit/publish-policy.test.ts index 4836c5c..6fe7a85 100644 --- a/tests/unit/publish-policy.test.ts +++ b/tests/unit/publish-policy.test.ts @@ -11,7 +11,7 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: ["0.5.0"], }), - ).toEqual({ extract: true, publishRetry: false, exit: false }); + ).toEqual({ extract: true, publishRetry: false }); }); test("extracts when command-code latest differs from the bundle", () => { @@ -23,7 +23,7 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: ["0.5.0"], }), - ).toEqual({ extract: true, publishRetry: false, exit: false }); + ).toEqual({ extract: true, publishRetry: false }); }); test("skips extraction and retries publish when the plugin version is unpublished", () => { @@ -35,10 +35,10 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: [], }), - ).toEqual({ extract: false, publishRetry: true, exit: false }); + ).toEqual({ extract: false, publishRetry: true }); }); - test("exits when command-code is unchanged and the plugin version is already on npm", () => { + test("refreshes the catalog when the CLI version is unchanged and the plugin is published", () => { expect( decideCatalogSync({ force: false, @@ -47,7 +47,7 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: ["0.5.0"], }), - ).toEqual({ extract: false, publishRetry: false, exit: true }); + ).toEqual({ extract: true, publishRetry: false }); }); }); diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts index 94679af..385bfae 100644 --- a/tests/unit/schemas.test.ts +++ b/tests/unit/schemas.test.ts @@ -29,6 +29,7 @@ const fullEntry = { attachment: true, modalities: { input: ["text", "image"], output: ["text"] }, family: "claude-sonnet", + supported_endpoints: ["/provider/v1/messages"], release_date: "2026-02-17", status: "beta", limit: { context: 200000, input: 195000, output: 16000 }, @@ -93,6 +94,15 @@ describe("ModelEntrySchema", () => { limit: { ...minimalEntry.limit, input: 10.5 }, }).success, ).toBe(false); + expect( + ModelEntrySchema.safeParse({ + ...minimalEntry, + supported_endpoints: ["/v1/responses"], + }).success, + ).toBe(true); + expect(ModelEntrySchema.safeParse({ ...minimalEntry, supported_endpoints: [""] }).success).toBe( + false, + ); }); test("accepts context pricing tiers and rejects malformed tier shapes", () => { @@ -190,6 +200,20 @@ describe("AvailabilityPayloadSchema / parseAvailabilityIds", () => { ).toEqual([{ id: "Qwen/Qwen3.6-Plus", context_length: 200000 }, { id: "other" }]); }); + test("retains supported endpoint metadata and ignores malformed optional values", () => { + expect( + parseAvailabilityModels( + ok([ + { id: "deepseek/model", supported_endpoints: ["/v1/responses", "/v1/chat/completions"] }, + { id: "other", supported_endpoints: [1] }, + ]), + ), + ).toEqual([ + { id: "deepseek/model", supported_endpoints: ["/v1/responses", "/v1/chat/completions"] }, + { id: "other" }, + ]); + }); + test("tolerates extra provider fields on items and top level (lenient)", () => { expect( parseAvailabilityIds({ diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts index 515f591..19b2ea9 100644 --- a/tests/unit/sync-models.test.ts +++ b/tests/unit/sync-models.test.ts @@ -62,6 +62,12 @@ describe("buildSyncArtifacts", () => { version: "1.58.1", sourceLabel: "test", pluginVersion: "0.7.72", + availabilityModels: [] as Array<{ + id: string; + context_length?: number; + supported_endpoints?: string[]; + }>, + priorModels: [] as ReturnType[], priorManifest: null, cliIds: new Set(), docIds: new Set(), @@ -79,12 +85,12 @@ describe("buildSyncArtifacts", () => { candidate("retired"), ...Array.from({ length: 20 }, (_, i) => candidate(`keep-extra-${i}`)), ], - availableIds: [ + availabilityModels: [ "keep-a", "keep-b", "api-only", ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`), - ], + ].map((id) => ({ id })), }); expect(artifacts.models.map((m) => m.id).slice(0, 2)).toEqual(["keep-a", "keep-b"]); expect(artifacts.manifest.modelCount).toBe(22); @@ -93,6 +99,39 @@ describe("buildSyncArtifacts", () => { ]); }); + test("sync carries advertised endpoint changes and preserves known endpoints on partial responses", () => { + const candidates = [ + candidate("changed"), + candidate("partial"), + candidate("cleared"), + ...Array.from({ length: 18 }, (_, i) => candidate(`keep-${i}`)), + ]; + const artifacts = buildSyncArtifacts({ + ...base, + candidates, + availabilityModels: [ + { id: "changed", supported_endpoints: ["/v1/responses"] }, + { id: "partial" }, + { id: "cleared", supported_endpoints: [] }, + ...Array.from({ length: 18 }, (_, i) => ({ id: `keep-${i}` })), + ], + priorModels: [ + { ...candidate("changed"), supported_endpoints: ["/v1/chat/completions"] }, + { ...candidate("partial"), supported_endpoints: ["/v1/chat/completions"] }, + { ...candidate("cleared"), supported_endpoints: ["/v1/responses"] }, + ], + }); + expect(artifacts.models.find((model) => model.id === "changed")?.supported_endpoints).toEqual([ + "/v1/responses", + ]); + expect(artifacts.models.find((model) => model.id === "partial")?.supported_endpoints).toEqual([ + "/v1/chat/completions", + ]); + expect(artifacts.models.find((model) => model.id === "cleared")?.supported_endpoints).toEqual( + [], + ); + }); + test("buildSyncArtifacts never writes generated artifacts, even on success", () => { const dir = mkdtempSync(join(tmpdir(), "cc-sync-")); try { @@ -114,7 +153,7 @@ describe("buildSyncArtifacts", () => { buildSyncArtifacts({ ...base, candidates: [candidate("keep-a")], - availableIds: ["other"], + availabilityModels: [{ id: "other" }], }), ).toThrow(); expect(readFileSync(modelsPath, "utf-8")).toBe(before.models); @@ -126,7 +165,10 @@ describe("buildSyncArtifacts", () => { candidate("keep-a"), ...Array.from({ length: 20 }, (_, i) => candidate(`keep-extra-${i}`)), ], - availableIds: ["keep-a", ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`)], + availabilityModels: [ + "keep-a", + ...Array.from({ length: 20 }, (_, i) => `keep-extra-${i}`), + ].map((id) => ({ id })), }); expect(ok.models).toHaveLength(21); // Still untouched: main() performs the three writes from these payloads. diff --git a/tests/unit/v2models.test.ts b/tests/unit/v2models.test.ts index 9e8af56..0eccace 100644 --- a/tests/unit/v2models.test.ts +++ b/tests/unit/v2models.test.ts @@ -56,6 +56,23 @@ test("Claude catalog models use the Anthropic Messages package", () => { expect(models.map((model) => model.package)).toEqual(ids.map(() => "aisdk:@ai-sdk/anthropic")); }); +test("V2 maps advertised Responses and Messages packages", () => { + expect( + toV2Model({ + ...base, + id: "deepseek/deepseek-v4-flash", + supported_endpoints: ["/provider/v1/responses"], + }).package, + ).toBe("aisdk:@ai-sdk/openai"); + expect( + toV2Model({ + ...base, + id: "vendor/messages-model", + supported_endpoints: ["/v1/messages"], + }).package, + ).toBe("aisdk:@ai-sdk/anthropic"); +}); + test("V2 maps models.dev release and catalog metadata", () => { const model = toV2Model({ ...base, From c4ba464f146705162a9ed587c43a978e1c3499cf Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 16:13:04 -0300 Subject: [PATCH 5/7] fix: avoid timestamp-only catalog sync changes --- .../specs/2026-08-28-ci-catalog-automation.md | 2 +- docs/specs/2026-09-28-v2-parity.md | 4 +- scripts/sync-models.ts | 8 ++++ tests/unit/sync-models.test.ts | 45 +++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/specs/2026-08-28-ci-catalog-automation.md b/docs/specs/2026-08-28-ci-catalog-automation.md index af74ea6..8d7a5aa 100644 --- a/docs/specs/2026-08-28-ci-catalog-automation.md +++ b/docs/specs/2026-08-28-ci-catalog-automation.md @@ -260,7 +260,7 @@ One PR path only. - 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). - Within 6 hours of a new `command-code` npm release or provider API availability/endpoint change, CI either opens a `fix(catalog)` PR (a patch publishes after it auto-merges) or opens/updates a `catalog-break` issue. This also runs when the CLI version is unchanged. -- A stable provider API response plus unchanged generated files produces no PR; a partial endpoint metadata response preserves the last known endpoint list. +- A stable provider API response plus unchanged generated files produces no PR, including no timestamp-only manifest diff; a partial endpoint metadata response preserves the last known endpoint list. - 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. - Successful sync never requires local `bun run sync` from the user. - Failed model extraction never publishes a misleading npm release. diff --git a/docs/specs/2026-09-28-v2-parity.md b/docs/specs/2026-09-28-v2-parity.md index f85a27b..f227af0 100644 --- a/docs/specs/2026-09-28-v2-parity.md +++ b/docs/specs/2026-09-28-v2-parity.md @@ -115,8 +115,8 @@ links here for current V2 auth and metadata behavior. - Unit tests cover strict schema validation, source merging, vendor context clamps, tier alias price matching, V1/V2 metadata and cost mappings, Claude package selection, and V2 integration registration. -- `bun run check` passed: lint, formatting, typecheck, and 233 unit tests - (666 expectations). +- `bun run check` passed: lint, formatting, typecheck, and 235 unit tests + (668 expectations). - `bun run build` passed and produced `dist/plugin.js` (0.73 MB). - `bun run verify:release-candidate` passed: the 0.9.1 package dry-run contained 50 files and did not publish. diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index 951ceaa..7a39efa 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync } import { join } from "path"; import { homedir, tmpdir } from "os"; import { execSync } from "child_process"; +import { isDeepStrictEqual } from "node:util"; import { NPM_PACKAGE, MODELS_API_URL, @@ -147,6 +148,13 @@ export function buildSyncArtifacts(input: { ), generatedAt: input.generatedAt, }); + if ( + input.priorManifest && + isDeepStrictEqual(models, input.priorModels) && + isDeepStrictEqual({ ...manifest, generatedAt: "" }, { ...input.priorManifest, generatedAt: "" }) + ) { + manifest.generatedAt = input.priorManifest.generatedAt; + } return { models, version: input.version, manifest }; } diff --git a/tests/unit/sync-models.test.ts b/tests/unit/sync-models.test.ts index 19b2ea9..13480cc 100644 --- a/tests/unit/sync-models.test.ts +++ b/tests/unit/sync-models.test.ts @@ -132,6 +132,51 @@ describe("buildSyncArtifacts", () => { ); }); + test("preserves manifest generatedAt when catalog and metadata are unchanged", () => { + const candidates = [ + candidate("same"), + ...Array.from({ length: 20 }, (_, i) => candidate(`keep-${i}`)), + ]; + const availabilityModels = candidates.map(({ id }) => ({ id })); + const first = buildSyncArtifacts({ ...base, candidates, availabilityModels }); + const next = buildSyncArtifacts({ + ...base, + candidates, + availabilityModels, + priorModels: first.models, + priorManifest: first.manifest, + generatedAt: "2026-09-28T06:00:00.000Z", + }); + expect(next.manifest.generatedAt).toBe(first.manifest.generatedAt); + }); + + test("updates manifest generatedAt when endpoint metadata changes", () => { + const candidates = [ + candidate("changed"), + ...Array.from({ length: 20 }, (_, i) => candidate(`keep-${i}`)), + ]; + const prior = buildSyncArtifacts({ + ...base, + candidates, + availabilityModels: candidates.map(({ id }) => ({ + id, + supported_endpoints: ["/v1/chat/completions"], + })), + }); + const next = buildSyncArtifacts({ + ...base, + candidates, + availabilityModels: candidates.map(({ id }) => ({ + id, + supported_endpoints: ["/v1/responses"], + })), + priorModels: prior.models, + priorManifest: prior.manifest, + generatedAt: "2026-09-28T06:00:00.000Z", + }); + expect(next.manifest.generatedAt).toBe("2026-09-28T06:00:00.000Z"); + }); + test("buildSyncArtifacts never writes generated artifacts, even on success", () => { const dir = mkdtempSync(join(tmpdir(), "cc-sync-")); try { From 1e853711dee87129521a3501f8f4be845e30223c Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 19:45:13 -0300 Subject: [PATCH 6/7] chore: remove redundant provider config --- opencode.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/opencode.json b/opencode.json index dc49803..34f2c01 100644 --- a/opencode.json +++ b/opencode.json @@ -2,15 +2,5 @@ "$schema": "https://opencode.ai/config.json", "model": "commandcode/deepseek-v4-flash", "small_model": "commandcode/deepseek-v4-flash", - "plugins": ["@brainervirus/opencode-commandcode"], - "provider": { - "commandcode": { - "npm": "@ai-sdk/openai-compatible", - "name": "Command Code", - "env": ["COMMANDCODE_API_KEY"], - "options": { - "baseURL": "https://api.commandcode.ai/provider/v1" - } - } - } + "plugins": ["@brainervirus/opencode-commandcode"] } From fcc2cd78dd5e3b532c103ccbb107c2706a0a5cc5 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Mon, 28 Sep 2026 19:45:17 -0300 Subject: [PATCH 7/7] docs: record Claude plan-gated probe --- README.md | 2 +- docs/specs/2026-09-28-v2-parity.md | 26 ++++++++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e55b1e0..fd0704f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - 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`. - Release date, family, input limits, model status, vendor context limits, and matched context-price tiers flow through the bundled catalog. V2 gets native release, family, input, status, and tier fields; V1 keeps its supported fields and base prices, with `context_over_200k` where representable. -- The provider's `supported_endpoints` metadata chooses each model's API route. Responses-capable models use `@ai-sdk/openai` on V1 and `aisdk:@ai-sdk/openai` on V2; Claude falls back to Anthropic Messages when endpoint metadata is absent. The V1 and V2 Responses mappings passed official Docker checks against a local mock stream; an entitled Claude live account is not available for verification. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. +- The provider's `supported_endpoints` metadata chooses each model's API route. Responses-capable models use `@ai-sdk/openai` on V1 and `aisdk:@ai-sdk/openai` on V2; Claude falls back to Anthropic Messages when endpoint metadata is absent. The V1 and V2 Responses mappings passed official Docker checks against a local mock stream, and a GOAT-key DeepSeek control request succeeded in OpenCode V2. Claude Sonnet 4.6 returned `MODEL_NOT_IN_PLAN` with the message “available in Pro and above plans or extra on-demand usage”; this confirms the account restriction, not successful Anthropic routing. Entitled Claude access has not been live-verified. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). ## How it works diff --git a/docs/specs/2026-09-28-v2-parity.md b/docs/specs/2026-09-28-v2-parity.md index f227af0..2c090e9 100644 --- a/docs/specs/2026-09-28-v2-parity.md +++ b/docs/specs/2026-09-28-v2-parity.md @@ -44,13 +44,17 @@ package route; its newer native `@opencode/ai` package is not present in that tested image. If an older catalog lacks route metadata, Claude retains the Messages fallback and other models retain Chat Completions. -The isolated GOAT probes for Claude Sonnet and Haiku returned -`MODEL_NOT_IN_PLAN`. They did not establish whether Anthropic requests succeed -for an account entitled to those models. No higher-plan credential was used. -Mapping tests cover all current Claude catalog IDs on V1 and V2. If a user with -access sees an endpoint or request-shape problem, report the model ID and -OpenCode version in an [issue](https://github.com/BrainerVirus/opencode-commandcode/issues) -or submit a PR with a reproducible test. +The isolated GOAT-key probes for Claude Sonnet and Haiku returned +`MODEL_NOT_IN_PLAN`. Claude Sonnet 4.6 reported that it is available on Pro and +above plans or with extra on-demand usage. The V2 DeepSeek control request +returned `OK`, so the Claude result is an account-entitlement denial rather +than a general credential failure. It does not establish whether Anthropic +requests succeed for an account entitled to Claude. No higher-plan credential +was used. Mapping tests cover all current Claude catalog IDs on V1 and V2. If a +user with access sees an endpoint or request-shape problem, report the model ID +and OpenCode version in an +[issue](https://github.com/BrainerVirus/opencode-commandcode/issues) or submit +a PR with a reproducible test. The route list is refreshed by the six-hour catalog job, including when the Command Code CLI version has not changed. A missing field preserves the prior @@ -120,9 +124,11 @@ links here for current V2 auth and metadata behavior. - `bun run build` passed and produced `dist/plugin.js` (0.73 MB). - `bun run verify:release-candidate` passed: the 0.9.1 package dry-run contained 50 files and did not publish. -- Official OpenCode Docker images `ghcr.io/anomalyco/opencode:1.18.30` and - `ghcr.io/anomalyco/opencode:2.0.18` each returned `OK` for the GOAT-eligible - DeepSeek V4.1 Flash smoke prompt using isolated temporary homes/configs. +- With isolated local-checkout pins, official OpenCode Docker V1.18.30 listed + 82 Command Code models, and V2.0.18's `/api/model` listed the same 82 and + `/api/integration` exposed the `key` and `env` methods. Both images returned + `OK` for a GOAT-eligible DeepSeek V4.1 Flash smoke prompt; the current V2 + local pin also returned `OK` for DeepSeek V4 Flash. - Responses routing passed both official Docker images against a temporary local Responses stream: V1 sent `POST /provider/v1/responses` via `@ai-sdk/openai`; V2 sent the same route via `aisdk:@ai-sdk/openai`.