Skip to content

Commit ae45066

Browse files
committed
feat: order the catalog and disable foreign merged models (v1.0.3)
Ordering: the picker sorts by (Free badge, release date, title). The plugin now writes a zeroed cost entry for promo models (grants the Free badge and reports their real cost as zero) and a release timestamp that encodes the family rank plus the version recency. Promo models come first, then families are grouped and each family lists its newest version first. Merge guard: models.dev publishes its own commandcode provider, which OpenCode merges with the configured one. That merge reintroduced the Anthropic-shaped claude-* models that answer 400 unsupported_model on the OpenAI-compatible transport. The transform now disables any provider model that is not in the curated set. Also: src/catalog.ts with the classification helpers (single source for isFreeModel), the original applyModelCatalog tests restored, and new suites for the ordering and the merge guard.
1 parent 834622a commit ae45066

9 files changed

Lines changed: 447 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) - SemVer.
55

66
## [Unreleased]
77

8+
## [1.0.3] - 2026-10-19
9+
10+
### Added
11+
12+
- Model ordering in the picker: promo/free models come first (with the runtime's "Free"
13+
badge), then models are grouped by family and, inside each family, the newest version
14+
comes first. Implemented by writing `cost` (zero input) and `time.released` from the
15+
model editor; the release timestamp encodes the family rank and the version recency.
16+
- `src/catalog.ts` with pure helpers (`detectFamily`, `parseVersion`, `compareVersions`,
17+
`versionScore`, `buildReleaseDate`, `isFreeModel`, `freeCostEntry`) and 20 unit tests.
18+
19+
### Fixed
20+
21+
- models.dev publishes its own `commandcode` provider, which OpenCode merges with the
22+
configured one. That merge reintroduced the Anthropic-shaped `claude-*` models that
23+
answer `400 unsupported_model` on the OpenAI-compatible transport. The catalog
24+
transform now disables every provider model that is not in the curated set.
25+
826
## [1.0.2] - 2026-10-19
927

1028
### Fixed

‎README.es.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,25 @@ agrega en runtime):
176176
}
177177
```
178178

179+
## Orden de modelos y modelos ajenos
180+
181+
El orden del selector lo maneja el plugin, no el catalogo:
182+
183+
1. **Primero los promo / free**, con el badge `Free` del runtime. El plugin escribe una
184+
entrada de `cost` con input en cero para esos modelos, lo que otorga el badge y ademas
185+
reporta su costo real como cero.
186+
2. **Agrupados por familia** (Claude, GPT, Gemini, DeepSeek, Kimi, GLM, MiniMax, Qwen, ...).
187+
3. **La version mas nueva primero dentro de cada familia** (`gpt-6.1-sol` antes que
188+
`gpt-6-sol` antes que `gpt-5.6-sol`), derivado de la version en el id del modelo.
189+
190+
Se materializa en el `time.released` de cada modelo, asi que sobrevive a los refrescos del
191+
catalogo y los modelos nuevos se ubican solos sin editar el plugin.
192+
193+
`models.dev` publica ademas su propio provider `commandcode`, que OpenCode mergea con el
194+
configurado. Ese merge trae los `claude-*` de forma Anthropic, que responden
195+
`400 unsupported_model` en el transporte compatible con OpenAI, por eso el plugin desactiva
196+
todo modelo del provider que no forme parte de su catalogo curado.
197+
179198
---
180199

181200
## Configuracion

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,25 @@ models at runtime):
170170
}
171171
```
172172

173+
## Model Ordering and Foreign Models
174+
175+
The selector order is driven by the plugin, not by the catalog order:
176+
177+
1. **Promo / free models first**, rendered with the runtime's `Free` badge. The plugin
178+
writes a `cost` entry with zeroed input for those models, which both grants the badge
179+
and reports their real cost as zero.
180+
2. **Grouped by family** (Claude, GPT, Gemini, DeepSeek, Kimi, GLM, MiniMax, Qwen, ...).
181+
3. **Newest version first inside each family** (`gpt-6.1-sol` before `gpt-6-sol` before
182+
`gpt-5.6-sol`), derived from the version in the model id.
183+
184+
This is encoded in each model's `time.released`, so it survives catalog refreshes and new
185+
releases are placed correctly without editing the plugin.
186+
187+
`models.dev` also publishes its own `commandcode` provider, which OpenCode merges with the
188+
configured one. The merge brings in Anthropic-shaped `claude-*` models that answer
189+
`400 unsupported_model` on the OpenAI-compatible transport, so the plugin disables every
190+
provider model that is not part of its curated catalog.
191+
173192
---
174193

175194
## Configuration

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-commandcode",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "Production-grade OpenCode plugin for Command Code: dynamic model discovery, stability hooks and quota telemetry",
55
"main": "index.ts",
66
"type": "module",

‎src/catalog.ts‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Catalog classification and ordering.
3+
*
4+
* The OpenCode model picker sorts the entries of a provider with this comparator
5+
* (verified in the v2.0.32 TUI):
6+
*
7+
* 1. provider grouping (opencode-go and opencode last, then provider name)
8+
* 2. entries whose `footer` is "Free" come first
9+
* 3. `time.released` descending
10+
* 4. `title` ascending
11+
*
12+
* "Free" is granted by the picker when `cost` is a non-empty array whose entries
13+
* all have `input === 0`. `time.released` is the release timestamp in epoch ms.
14+
*
15+
* Both are writable from a plugin through the model editor, so the whole order
16+
* of the Command Code catalog can be driven from here:
17+
*
18+
* - free / promo models get `cost` with zeroed input, which also renders the
19+
* "Free" badge and reports their real cost as zero
20+
* - every model gets a release timestamp that encodes (family rank, version
21+
* recency), so families stay grouped and the newest model of each family
22+
* comes first
23+
*/
24+
25+
export interface CatalogModel {
26+
id: string;
27+
name?: string;
28+
supported_endpoints?: string[];
29+
}
30+
31+
/** Ordered list of model families. The position in this list is the group order. */
32+
const FAMILY_ORDER: Array<{ match: RegExp; family: string }> = [
33+
{ match: /^claude/i, family: "claude" },
34+
{ match: /^gpt/i, family: "gpt" },
35+
{ match: /^gemini/i, family: "gemini" },
36+
{ match: /^deepseek/i, family: "deepseek" },
37+
{ match: /kimi/i, family: "kimi" },
38+
{ match: /^(glm|zai|z-ai)/i, family: "glm" },
39+
{ match: /^minimax/i, family: "minimax" },
40+
{ match: /^qwen/i, family: "qwen" },
41+
{ match: /^grok/i, family: "grok" },
42+
{ match: /mistral/i, family: "mistral" },
43+
{ match: /nemotron/i, family: "nemotron" },
44+
{ match: /mimo/i, family: "mimo" },
45+
{ match: /muse/i, family: "muse" },
46+
{ match: /inkling/i, family: "inkling" },
47+
{ match: /^step/i, family: "step" },
48+
{ match: /^(hy|hunyuan)/i, family: "hunyuan" },
49+
{ match: /ling/i, family: "ling" },
50+
{ match: /longcat/i, family: "longcat" },
51+
{ match: /laguna/i, family: "laguna" },
52+
{ match: /fugu/i, family: "fugu" },
53+
{ match: /beam/i, family: "beam" },
54+
{ match: /cat-jam/i, family: "cat-jam" },
55+
{ match: /glyph/i, family: "glyph" },
56+
{ match: /fable/i, family: "fable" },
57+
];
58+
59+
const FAMILY_RANK = new Map(FAMILY_ORDER.map((f, i) => [f.family, i]));
60+
const UNKNOWN_FAMILY_RANK = FAMILY_ORDER.length;
61+
62+
/** Strips the vendor prefix: "deepseek/deepseek-v4.1-flash" -> "deepseek-v4.1-flash". */
63+
export function modelName(id: string): string {
64+
const idx = id.lastIndexOf("/");
65+
return idx === -1 ? id : id.slice(idx + 1);
66+
}
67+
68+
/** Detects the family of a model id. Unknown families sort last, before the promo tier. */
69+
export function detectFamily(id: string): string {
70+
const name = modelName(id);
71+
for (const { match, family } of FAMILY_ORDER) {
72+
if (match.test(name)) return family;
73+
}
74+
const vendor = id.includes("/") ? id.split("/")[0]!.toLowerCase() : "";
75+
return vendor || "other";
76+
}
77+
78+
/** Numeric rank of a family inside the group order. */
79+
export function familyRank(id: string): number {
80+
const family = detectFamily(id);
81+
return FAMILY_RANK.get(family) ?? UNKNOWN_FAMILY_RANK;
82+
}
83+
84+
/**
85+
* Extracts the version tuple of a model id, e.g. "gpt-6.1-sol" -> [6, 1],
86+
* "deepseek-v4.1-flash" -> [4, 1], "cat-jam" -> [].
87+
*/
88+
export function parseVersion(id: string): number[] {
89+
const name = modelName(id);
90+
// Drop the family token so digits inside the family name are not read as a version
91+
// ("qwen3.8-max" must yield [3, 8] and not be confused with the family prefix).
92+
const withoutFamily = name.replace(
93+
new RegExp(`^(${FAMILY_ORDER.map((f) => f.family).join("|")})[-_]?`, "i"),
94+
""
95+
);
96+
const match = withoutFamily.match(/(\d+)(?:\.(\d+))?/);
97+
if (!match) return [];
98+
const major = Number(match[1]);
99+
const minor = match[2] !== undefined ? Number(match[2]) : 0;
100+
return [major, minor];
101+
}
102+
103+
/** Compares two version tuples numerically. Missing components count as zero. */
104+
export function compareVersions(a: number[], b: number[]): number {
105+
const len = Math.max(a.length, b.length, 2);
106+
for (let i = 0; i < len; i++) {
107+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
108+
if (diff !== 0) return diff;
109+
}
110+
return 0;
111+
}
112+
113+
/** Sorts a version tuple into a single comparable score. */
114+
export function versionScore(version: number[]): number {
115+
const [major = 0, minor = 0] = version;
116+
return major * 1000 + minor;
117+
}
118+
119+
/**
120+
* Encodes the group order into a release timestamp.
121+
*
122+
* The picker only knows one descending number, so the family rank has to dominate
123+
* and the version only breaks ties inside a family. The result is a date inside
124+
* 2026 that grows with the group and with the version, which keeps the value
125+
* plausible if the UI ever renders it.
126+
*/
127+
const BASE_DATE = Date.parse("2026-01-01");
128+
const GROUP_STRIDE_MS = 7 * 24 * 60 * 60 * 1000;
129+
130+
export function buildReleaseDate(id: string): number {
131+
const rank = familyRank(id);
132+
const invertedRank = UNKNOWN_FAMILY_RANK - rank;
133+
const score = versionScore(parseVersion(id));
134+
return BASE_DATE + invertedRank * GROUP_STRIDE_MS + score * 1000;
135+
}
136+
137+
/** True for models currently offered on a free or promo tier. */
138+
export function isFreeModel(model: { id?: string; name?: string }): boolean {
139+
const id = (model?.id ?? "").toLowerCase();
140+
const name = (model?.name ?? "").toLowerCase();
141+
if (/(:free|-free|_free|\.free)$/.test(id) || id.includes(":free")) return true;
142+
return /\b(free|promo|promotional|trial)\b/.test(name);
143+
}
144+
145+
/** Cost entry that makes the picker render the "Free" badge for a promo model. */
146+
export function freeCostEntry() {
147+
return {
148+
input: 0,
149+
output: 0,
150+
cache: {
151+
read: 0,
152+
write: 0,
153+
},
154+
};
155+
}

‎src/hooks/resilience.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,8 @@ export function createInactivityWatchdogStream(
304304
});
305305
}
306306

307-
/** True for models currently offered on a free/promo tier. */
308-
export function isFreeModel(model: { id?: string; name?: string }): boolean {
309-
const id = (model?.id ?? "").toLowerCase();
310-
const name = (model?.name ?? "").toLowerCase();
311-
if (/(:free|-free|_free|\.free)$/.test(id) || id.includes(":free")) return true;
312-
return /\b(free|promo|promotional|trial)\b/.test(name);
313-
}
307+
/** True for models currently offered on a free/promo tier. Single source of truth. */
308+
export { isFreeModel } from "../catalog.ts";
314309

315310
/**
316311
* The provider is registered as OpenAI-compatible, so only models that advertise

‎src/index.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./hooks/resilience.ts";
1616
import { getQuotaReport } from "./tools/quota.ts";
1717
import { describeRetryEvent } from "./telemetry.ts";
18+
import { buildReleaseDate, freeCostEntry } from "./catalog.ts";
1819

1920
export const PROVIDER_ID = "commandcode";
2021
const WATCHDOG_TIMEOUT_MS = 60_000;
@@ -49,6 +50,7 @@ function applyModelCatalog(editor: any, models: any[]): { registered: number; sk
4950
let registered = 0;
5051
let skipped = 0;
5152
const free: string[] = [];
53+
const keptIds: string[] = [];
5254

5355
for (const model of models) {
5456
if (!model?.id) continue;
@@ -68,6 +70,11 @@ function applyModelCatalog(editor: any, models: any[]): { registered: number; sk
6870
const inputs = ["text"];
6971
if (supportsVision(model)) inputs.push("image");
7072

73+
// The picker sorts by (Free badge, release date, title). The release date
74+
// encodes the family group and the version recency, so the catalog shows the
75+
// promo models first and then each family from the newest model to the oldest.
76+
const released = buildReleaseDate(model.id);
77+
7178
try {
7279
editor.update(PROVIDER_ID, model.id, (draft: any) => {
7380
draft.name = displayName;
@@ -82,14 +89,42 @@ function applyModelCatalog(editor: any, models: any[]): { registered: number; sk
8289
input: inputs,
8390
output: ["text"],
8491
};
92+
draft.time = { released };
93+
if (freeTier) {
94+
// Non-empty cost with zeroed input: renders the "Free" badge, sorts
95+
// the model first and reports its real cost as zero.
96+
draft.cost = [freeCostEntry()];
97+
}
8598
});
8699
registered++;
100+
keptIds.push(model.id);
87101
if (freeTier) free.push(displayName);
88102
} catch {
89103
skipped++;
90104
}
91105
}
92106

107+
// OpenCode merges the provider definition with the models.dev catalog of the
108+
// same provider id. models.dev publishes its own "commandcode" provider, which
109+
// includes Anthropic-shaped claude-* models that answer 400 unsupported_model
110+
// on our OpenAI-compatible transport. Disable anything that is not in the set
111+
// we just curated, so the merge cannot reintroduce unusable entries.
112+
try {
113+
const listFn = (editor as any).list;
114+
if (typeof listFn === "function") {
115+
const curated = new Set(keptIds);
116+
for (const existing of listFn.call(editor, PROVIDER_ID) ?? []) {
117+
if (!existing?.id || curated.has(existing.id)) continue;
118+
editor.update(PROVIDER_ID, existing.id, (draft: any) => {
119+
draft.enabled = false;
120+
});
121+
skipped++;
122+
}
123+
}
124+
} catch {
125+
// fail-open: a merge we cannot inspect must not break registration
126+
}
127+
93128
return { registered, skipped, free };
94129
}
95130

0 commit comments

Comments
 (0)