@telsoncc/opencode-commandcode 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-09-25
11
+
10
12
  ## [1.1.0] - 2026-09-25
11
13
 
12
14
  ## [1.0.6] - 2026-09-24
package/README.md CHANGED
@@ -24,6 +24,9 @@ Forked and currently maintained by **[telsoncc](https://github.com/telsoncc)** (
24
24
  - 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.
25
25
  - Reasoning effort **variants** on models that declare `reasoningEfforts`.
26
26
  - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout).
27
+ - Live catalog refresh on the 2.0 entrypoint: retired models are pruned and
28
+ new ones are published with [models.dev](https://models.dev) estimates
29
+ (marked `(est.)`) until the next catalog sync replaces them.
27
30
  - Native **OpenCode 2.x** entrypoint (`plugin/opencode2`, also served as `server`):
28
31
  in-memory provider inventory via `ctx.provider.transform` (no config writes)
29
32
  plus a `commandcode` integration for `/connect`. See `docs/opencode-2.md`.
@@ -100,6 +103,22 @@ Run `/connect` in opencode, search for **Command Code**, and enter your API key,
100
103
  On 2.x the catalog carries no release dates, so filter the picker by provider
101
104
  **Command Code** if the global list looks empty.
102
105
 
106
+ ## Live catalog refresh (2.x)
107
+
108
+ The 2.0 entrypoint re-checks the Provider API (`/models`, no auth) on setup
109
+ and every 6h on a process-shared timer:
110
+
111
+ - ids the API retired are pruned from the inventory;
112
+ - ids missing locally are published immediately with
113
+ [models.dev](https://models.dev) cost/capability estimates, named
114
+ `"<model> (est.)"`, and replaced by exact data on the next catalog sync;
115
+ - ids with no estimable data stay reported (not published) until the sync.
116
+
117
+ Estimates are list prices, close to but not equal to Command Code billing.
118
+ `startup.json` records `catalogSource: "remote"`, `pendingNewCount`, and
119
+ `estimatedCount`. Opt out with `disableModelSync: true` in
120
+ `~/.config/opencode/opencode-commandcode.json`.
121
+
103
122
  ## Optional local CLI override
104
123
 
105
124
  Maintainers only. OpenCode will scrape a local `command-code` install when `COMMANDCODE_PACKAGE_PATH` or `commandCodePackagePath` in `~/.config/opencode/opencode-commandcode.json` is set.
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "generatedAt": "2026-09-24T21:10:50.082Z",
4
- "pluginVersion": "1.1.0",
4
+ "pluginVersion": "1.2.0",
5
5
  "commandCodeVersion": "1.65.2",
6
6
  "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.65.2.tgz",
7
7
  "modelCount": 81,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telsoncc/opencode-commandcode",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code",
5
5
  "keywords": [
6
6
  "ai",
@@ -71,10 +71,10 @@ export async function setup(ctx: V2SetupContext): Promise<() => Promise<void>> {
71
71
  // Keep the last-good inventory when the reload fails.
72
72
  }
73
73
 
74
- // Live availability refresh: prune ids the API retired and surface new
75
- // ones without waiting for an npm publish. The bundled catalog stays as
76
- // the offline fallback; models unknown locally are reported, not
77
- // published, until a catalog sync extracts their cost data.
74
+ // Live availability refresh: prune ids the API retired and publish new
75
+ // ones with models.dev cost estimates until a catalog sync replaces them.
76
+ // The bundled catalog stays as the offline fallback; ids with no
77
+ // estimable data stay reported as pending.
78
78
  const sameInventory = (next: ModelEntry[]): boolean =>
79
79
  next.length === inventory.length && next.every((m, i) => m.id === inventory[i]?.id);
80
80
 
package/src/catalog.ts CHANGED
@@ -17,6 +17,8 @@ export interface ModelEntry {
17
17
  limit: { context: number; output: number };
18
18
  attachment?: boolean;
19
19
  modalities?: { input: string[]; output: string[] };
20
+ /** Runtime estimate from models.dev — replaced on the next catalog sync. */
21
+ estimated?: boolean;
20
22
  }
21
23
 
22
24
  export interface CostEntry {
@@ -1,6 +1,14 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { pluginStateDir } from "./startup.js";
1
4
  import type { ModelEntry } from "./catalog.js";
2
5
 
3
6
  export const MODELS_DEV_URL = "https://models.dev/api.json";
7
+ export const MODELS_DEV_TIMEOUT_MS = 30_000;
8
+ export const MODELS_DEV_CACHE_TTL_MS = 24 * 3600_000;
9
+ export const MODELS_DEV_CACHE_FILE = "models-dev-cache.json";
10
+ export const ESTIMATED_CONTEXT_TOKENS = 200000;
11
+ export const ESTIMATED_OUTPUT_TOKENS = 65536;
4
12
  export const FREE_COST = { input: 0, output: 0 } as const;
5
13
  export const TEXT_ONLY_MODALITIES = { input: ["text"], output: ["text"] } as const;
6
14
 
@@ -10,6 +18,15 @@ export type ModelsDevRow = {
10
18
  cost: { input: number; output: number; cache_read?: number; cache_write?: number };
11
19
  attachment?: boolean;
12
20
  modalities?: { input: string[]; output: string[] };
21
+ reasoning?: boolean;
22
+ reasoningEfforts?: string[];
23
+ toolCall?: boolean;
24
+ limit?: { context: number; output: number };
25
+ };
26
+
27
+ type ReasoningOption = {
28
+ type?: string;
29
+ values?: unknown;
13
30
  };
14
31
 
15
32
  type ModelsDevModel = {
@@ -18,6 +35,10 @@ type ModelsDevModel = {
18
35
  cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
19
36
  attachment?: boolean;
20
37
  modalities?: { input?: string[]; output?: string[] };
38
+ reasoning?: boolean;
39
+ reasoning_options?: ReasoningOption[];
40
+ tool_call?: boolean;
41
+ limit?: { context?: number; output?: number };
21
42
  };
22
43
 
23
44
  type ModelsDevProvider = { models?: Record<string, ModelsDevModel> };
@@ -49,6 +70,12 @@ export function parseModelsDev(json: string): ModelsDevRow[] {
49
70
  if (model.cost.cache_write !== undefined) cost.cache_write = model.cost.cache_write;
50
71
  const row: ModelsDevRow = { id: model.id, name: model.name ?? model.id, cost };
51
72
  if (typeof model.attachment === "boolean") row.attachment = model.attachment;
73
+ if (typeof model.reasoning === "boolean") row.reasoning = model.reasoning;
74
+ const efforts = extractEffortValues(model.reasoning_options);
75
+ if (efforts) row.reasoningEfforts = efforts;
76
+ if (typeof model.tool_call === "boolean") row.toolCall = model.tool_call;
77
+ const limit = extractLimit(model.limit);
78
+ if (limit) row.limit = limit;
52
79
  const input = model.modalities?.input?.filter((x) => typeof x === "string");
53
80
  const output = model.modalities?.output?.filter((x) => typeof x === "string");
54
81
  if (input?.length || output?.length) {
@@ -105,6 +132,162 @@ function textOnly(): { input: string[]; output: string[] } {
105
132
  return { input: [...TEXT_ONLY_MODALITIES.input], output: [...TEXT_ONLY_MODALITIES.output] };
106
133
  }
107
134
 
135
+ function extractEffortValues(options: ReasoningOption[] | undefined): string[] | undefined {
136
+ if (!Array.isArray(options)) return undefined;
137
+ const values = options.flatMap((o) =>
138
+ o?.type === "effort" && Array.isArray(o.values) ? o.values : [],
139
+ );
140
+ const unique = [
141
+ ...new Set(values.filter((v): v is string => typeof v === "string" && v.length > 0)),
142
+ ];
143
+ return unique.length > 0 ? unique : undefined;
144
+ }
145
+
146
+ function extractLimit(
147
+ limit: { context?: number; output?: number } | undefined,
148
+ ): { context: number; output: number } | undefined {
149
+ if (!limit || typeof limit.context !== "number" || typeof limit.output !== "number") {
150
+ return undefined;
151
+ }
152
+ if (!(limit.context > 0) || !(limit.output > 0)) return undefined;
153
+ return { context: limit.context, output: limit.output };
154
+ }
155
+
156
+ function prettifyModelId(id: string): string {
157
+ return lastSegment(id)
158
+ .replace(/[-_]+/g, " ")
159
+ .replace(/\s+/g, " ")
160
+ .trim()
161
+ .replace(/\b\w/g, (c) => c.toUpperCase());
162
+ }
163
+
164
+ /** Match a models.dev row by exact id, last path segment, or display name. */
165
+ export function findModelsDevRow(
166
+ rows: ModelsDevRow[],
167
+ id: string,
168
+ name?: string,
169
+ ): ModelsDevRow | undefined {
170
+ const index = indexRows(rows);
171
+ return (
172
+ index.byId.get(id.toLowerCase()) ??
173
+ index.bySegment.get(lastSegment(id).toLowerCase()) ??
174
+ (name ? index.byName.get(name.toLowerCase()) : undefined)
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Build a provisional catalog entry for a remote id missing locally.
180
+ * Costs/capabilities come from models.dev (list prices, close to but not
181
+ * equal to Command Code billing); the entry is replaced on the next catalog
182
+ * sync. Returns null when nothing can ground an estimate.
183
+ */
184
+ export function buildEstimatedEntry(id: string, rows: ModelsDevRow[]): ModelEntry | null {
185
+ const displayName = prettifyModelId(id);
186
+ if (isFreeSku({ id, name: displayName })) {
187
+ return {
188
+ id,
189
+ name: `${displayName} (est.)`,
190
+ tier: "open-source",
191
+ reasoning: false,
192
+ tool_call: true,
193
+ cost: { ...FREE_COST },
194
+ limit: { context: ESTIMATED_CONTEXT_TOKENS, output: ESTIMATED_OUTPUT_TOKENS },
195
+ attachment: false,
196
+ modalities: textOnly(),
197
+ estimated: true,
198
+ };
199
+ }
200
+ const row = findModelsDevRow(rows, id);
201
+ if (!row) return null;
202
+ const cost: ModelEntry["cost"] = { input: row.cost.input, output: row.cost.output };
203
+ if (row.cost.cache_read !== undefined) cost.cache_read = row.cost.cache_read;
204
+ if (row.cost.cache_write !== undefined) cost.cache_write = row.cost.cache_write;
205
+ const modalities = row.modalities
206
+ ? { input: [...row.modalities.input], output: [...row.modalities.output] }
207
+ : textOnly();
208
+ return {
209
+ id,
210
+ name: `${row.name} (est.)`,
211
+ tier: "open-source",
212
+ reasoning: row.reasoning ?? (row.reasoningEfforts?.length ?? 0) > 0,
213
+ ...(row.reasoningEfforts ? { reasoningEfforts: [...row.reasoningEfforts] } : {}),
214
+ tool_call: row.toolCall ?? true,
215
+ cost,
216
+ limit: row.limit ?? {
217
+ context: ESTIMATED_CONTEXT_TOKENS,
218
+ output: ESTIMATED_OUTPUT_TOKENS,
219
+ },
220
+ attachment: row.attachment ?? modalities.input.includes("image"),
221
+ modalities,
222
+ estimated: true,
223
+ };
224
+ }
225
+
226
+ export interface ModelsDevCache {
227
+ fetchedAt: number;
228
+ json: string;
229
+ }
230
+
231
+ function readModelsDevCache(cacheDir: string): ModelsDevCache | null {
232
+ const path = join(cacheDir, MODELS_DEV_CACHE_FILE);
233
+ if (!existsSync(path)) return null;
234
+ try {
235
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<ModelsDevCache>;
236
+ if (typeof parsed.json !== "string" || typeof parsed.fetchedAt !== "number") return null;
237
+ return { fetchedAt: parsed.fetchedAt, json: parsed.json };
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ function writeModelsDevCache(cacheDir: string, json: string): void {
244
+ try {
245
+ mkdirSync(cacheDir, { recursive: true });
246
+ writeFileSync(
247
+ join(cacheDir, MODELS_DEV_CACHE_FILE),
248
+ JSON.stringify({ fetchedAt: Date.now(), json }),
249
+ "utf-8",
250
+ );
251
+ } catch {
252
+ // ignore cache write
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Load models.dev rows, cached on disk for a day. Falls back to stale cache
258
+ * when the network fails; returns [] only when nothing is available.
259
+ */
260
+ export async function loadModelsDevRows(
261
+ options: { timeoutMs?: number; maxAgeMs?: number; cacheDir?: string } = {},
262
+ ): Promise<ModelsDevRow[]> {
263
+ const cacheDir = options.cacheDir ?? pluginStateDir();
264
+ const maxAgeMs = options.maxAgeMs ?? MODELS_DEV_CACHE_TTL_MS;
265
+ const cached = readModelsDevCache(cacheDir);
266
+ if (cached && Date.now() - cached.fetchedAt < maxAgeMs) {
267
+ try {
268
+ return parseModelsDev(cached.json);
269
+ } catch {
270
+ // fall through to refetch
271
+ }
272
+ }
273
+ try {
274
+ const json = await fetchModelsDevJson(
275
+ AbortSignal.timeout(options.timeoutMs ?? MODELS_DEV_TIMEOUT_MS),
276
+ );
277
+ writeModelsDevCache(cacheDir, json);
278
+ return parseModelsDev(json);
279
+ } catch {
280
+ if (cached) {
281
+ try {
282
+ return parseModelsDev(cached.json);
283
+ } catch {
284
+ // fall through to []
285
+ }
286
+ }
287
+ return [];
288
+ }
289
+ }
290
+
108
291
  export function applyModelsDevModalities(models: ModelEntry[], rows: ModelsDevRow[]): number {
109
292
  const index = indexRows(rows);
110
293
  let filled = 0;
@@ -159,7 +342,7 @@ export function applyModelsDevCosts(
159
342
  return filled;
160
343
  }
161
344
 
162
- export async function fetchModelsDevJson(): Promise<string> {
345
+ export async function fetchModelsDevJson(signal?: AbortSignal): Promise<string> {
163
346
  const resp = await fetch(MODELS_DEV_URL, {
164
347
  headers: {
165
348
  // ponytail: models.dev returns 403 without a browser-like UA; upgrade if they add a real API token
@@ -167,6 +350,7 @@ export async function fetchModelsDevJson(): Promise<string> {
167
350
  "Mozilla/5.0 (compatible; opencode-commandcode/0.5; +https://github.com/telsoncc/opencode-commandcode)",
168
351
  Accept: "application/json",
169
352
  },
353
+ ...(signal ? { signal } : {}),
170
354
  });
171
355
  if (!resp.ok) throw new Error(`models.dev returned ${resp.status}`);
172
356
  return resp.text();
package/src/opencode2.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  loadCatalogFromLocalCommandCode,
11
11
  type ModelEntry,
12
12
  } from "./catalog.js";
13
+ import { buildEstimatedEntry, loadModelsDevRows, type ModelsDevRow } from "./costs-models-dev.js";
13
14
  import {
14
15
  readCatalogCache,
15
16
  writeCatalogCache,
@@ -350,6 +351,8 @@ export interface RemoteRefreshResult {
350
351
  unavailable: string[];
351
352
  pendingNew: string[];
352
353
  remoteCount: number;
354
+ /** Provisional entries published with models.dev estimates. */
355
+ estimated: ModelEntry[];
353
356
  }
354
357
 
355
358
  /** Live availability ids from the Provider API. No auth required. */
@@ -369,8 +372,8 @@ export async function fetchRemoteAvailability(
369
372
  /**
370
373
  * Reconcile a local catalog with live availability: keep the ids the API
371
374
  * still serves, report retired ones, and list remote ids missing locally.
372
- * Missing ids are *not* published — they carry no cost/limit data until a
373
- * catalog sync extracts them.
375
+ * Missing ids stay pending here — refreshCatalogFromRemote tries to publish
376
+ * them with models.dev estimates first.
374
377
  */
375
378
  export function applyRemoteAvailability(
376
379
  base: ModelEntry[],
@@ -379,14 +382,52 @@ export function applyRemoteAvailability(
379
382
  const { retained, unavailable } = filterCatalogByAvailability(base, remoteIds);
380
383
  const known = new Set(base.map((m) => m.id));
381
384
  const pendingNew = [...new Set(remoteIds)].filter((id) => !known.has(id)).sort();
382
- return { models: retained, unavailable, pendingNew, remoteCount: remoteIds.length };
385
+ return {
386
+ models: retained,
387
+ unavailable,
388
+ pendingNew,
389
+ remoteCount: remoteIds.length,
390
+ estimated: [],
391
+ };
383
392
  }
384
393
 
385
394
  export async function refreshCatalogFromRemote(
386
395
  base: ModelEntry[],
387
396
  timeoutMs = REMOTE_SYNC_TIMEOUT_MS,
388
397
  ): Promise<RemoteRefreshResult> {
389
- return applyRemoteAvailability(base, await fetchRemoteAvailability(timeoutMs));
398
+ const result = applyRemoteAvailability(base, await fetchRemoteAvailability(timeoutMs));
399
+ if (result.pendingNew.length === 0) return result;
400
+ let rows: ModelsDevRow[] = [];
401
+ try {
402
+ rows = await loadModelsDevRows();
403
+ } catch {
404
+ return result;
405
+ }
406
+ if (rows.length === 0) return result;
407
+ const usedKeys = new Set(base.map((m) => toConfigKey(m.id)));
408
+ const estimated: ModelEntry[] = [];
409
+ const stillPending: string[] = [];
410
+ for (const id of result.pendingNew) {
411
+ const key = toConfigKey(id);
412
+ if (usedKeys.has(key)) {
413
+ stillPending.push(id);
414
+ continue;
415
+ }
416
+ const entry = buildEstimatedEntry(id, rows);
417
+ if (!entry) {
418
+ stillPending.push(id);
419
+ continue;
420
+ }
421
+ usedKeys.add(key);
422
+ estimated.push(entry);
423
+ }
424
+ return {
425
+ models: [...result.models, ...estimated],
426
+ unavailable: result.unavailable,
427
+ pendingNew: stillPending,
428
+ remoteCount: result.remoteCount,
429
+ estimated,
430
+ };
390
431
  }
391
432
 
392
433
  export function isRemoteSyncDisabled(): boolean {
@@ -416,6 +457,7 @@ export function writeRemoteRefreshSummary(
416
457
  degradedReason:
417
458
  result.models.length === 0 ? "remote availability matched no local models" : null,
418
459
  pendingNewCount: result.pendingNew.length,
460
+ estimatedCount: result.estimated.length,
419
461
  };
420
462
  try {
421
463
  writeStartupSummary(pluginStateDir(), summary);
package/src/startup.ts CHANGED
@@ -14,6 +14,8 @@ export type StartupSummary = {
14
14
  degradedReason: string | null;
15
15
  /** Remote ids with no local cost data yet — next catalog sync picks them up. */
16
16
  pendingNewCount?: number;
17
+ /** Provisional entries published with models.dev estimates. */
18
+ estimatedCount?: number;
17
19
  };
18
20
 
19
21
  export function pluginStateDir(): string {