@darthph0enix/commandcode-go-opencode-provider 0.5.1 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darthph0enix/commandcode-go-opencode-provider",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "author": "Brent Weatherall",
5
5
  "repository": {
6
6
  "type": "git",
package/plugin.ts CHANGED
@@ -21,7 +21,18 @@ export default async function commandcodePlugin() {
21
21
  if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"]
22
22
 
23
23
  if (!cc.models) {
24
- const { models } = await loadSyncedModels()
24
+ const options = (cc.options as Record<string, unknown> | undefined) ?? {}
25
+ const baseURL = typeof options.baseURL === "string" ? options.baseURL.replace(/\/+$/, "") : undefined
26
+ const catalogUrl =
27
+ typeof options.catalogUrl === "string"
28
+ ? options.catalogUrl
29
+ : baseURL
30
+ ? `${baseURL}/catalog.json`
31
+ : undefined
32
+ const { models, source } = await loadSyncedModels({ catalogUrl })
33
+ if (process.env.COMMANDCODE_DEBUG) {
34
+ console.warn(`[commandcode] injected ${models.length} models from ${source}`)
35
+ }
25
36
  const modelsObj: Record<string, unknown> = {}
26
37
  for (const entry of models) {
27
38
  const key = toConfigKey(entry.id)
package/src/convert.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
2
+ import { readFileSync, writeFileSync } from "fs"
3
+ import { dirname, join } from "path"
4
+ import { fileURLToPath } from "url"
2
5
  import type {
3
6
  LanguageModelV3FunctionTool,
4
7
  LanguageModelV3Message,
@@ -109,7 +112,10 @@ function toDataUriOrUrl(data: string | Uint8Array | URL, defaultMime: string = "
109
112
  return String(data)
110
113
  }
111
114
 
112
- function convertUserContent(content: unknown): string | Array<{ type: string; [key: string]: unknown }> {
115
+ function convertUserContent(
116
+ content: unknown,
117
+ forwarder?: ImageForwarder,
118
+ ): string | Array<{ type: string; [key: string]: unknown }> {
113
119
  if (typeof content === "string") return content
114
120
  if (!Array.isArray(content)) return ""
115
121
 
@@ -122,20 +128,20 @@ function convertUserContent(content: unknown): string | Array<{ type: string; [k
122
128
 
123
129
  if (item.type === "text" && typeof item.text === "string") {
124
130
  parts.push({ type: "text", text: item.text })
125
- } else if (item.type === "image" && item.image) {
126
- hasMultimodal = true
127
- const mimeType = typeof item.mimeType === "string" ? item.mimeType : "image/png"
128
- const imgUrl = toDataUriOrUrl(item.image as string | Uint8Array | URL, mimeType)
129
- parts.push({ type: "image", image: imgUrl, mimeType })
130
- } else if (item.type === "file" && item.data) {
131
- const mimeType = typeof item.mimeType === "string" ? item.mimeType : "application/octet-stream"
132
- if (mimeType.startsWith("image/")) {
131
+ continue
132
+ }
133
+ if (item.type === "file" || item.type === "image" || item.type === "media") {
134
+ // `{ type: "file", data: "<raw base64>", mediaType: "image/png" }` is how
135
+ // opencode hands tool-result attachments to providers. Emitting those as
136
+ // text is what pushed ~1.5M tokens of base64 into a single request.
137
+ if (convertBinaryPart(item, parts, forwarder ? forwarder.enabled : true)) {
133
138
  hasMultimodal = true
134
- const imgUrl = toDataUriOrUrl(item.data as string | Uint8Array | URL, mimeType)
135
- parts.push({ type: "image", image: imgUrl, mimeType })
136
- } else if (typeof item.data === "string" && !item.data.startsWith("data:")) {
137
- parts.push({ type: "text", text: item.data })
138
139
  }
140
+ continue
141
+ }
142
+ // Unknown part carrying an obvious binary payload: never serialize it as text
143
+ if (isBinaryLikePart(item)) {
144
+ parts.push({ type: "text", text: describeBinaryPart(item) })
139
145
  }
140
146
  }
141
147
 
@@ -145,29 +151,286 @@ function convertUserContent(content: unknown): string | Array<{ type: string; [k
145
151
  return parts
146
152
  }
147
153
 
148
- function convertToolResultOutput(output: LanguageModelV3ToolResultOutput): CCToolResultContent["output"] {
154
+ /** Extract the mime type from a `data:<mime>;base64,<payload>` URI. */
155
+ function dataUriMime(dataUri: string): string | null {
156
+ if (!dataUri.startsWith("data:")) return null
157
+ const semi = dataUri.indexOf(";")
158
+ const comma = dataUri.indexOf(",")
159
+ if (semi === -1 || comma === -1 || semi > comma) return null
160
+ const mime = dataUri.slice(5, semi)
161
+ return mime || null
162
+ }
163
+
164
+ /**
165
+ * Media type of a binary part. opencode's serialiser emits `mediaType`; older
166
+ * callers use `mimeType`; and when neither is declared, the payload's `data:`
167
+ * header still tells us what it is.
168
+ */
169
+ function detectMediaType(item: Record<string, unknown>): string {
170
+ const declared = item.mediaType ?? item.mimeType ?? item.media_type
171
+ if (typeof declared === "string" && declared.trim()) return declared.trim()
172
+ const payload = item.data ?? item.url ?? item.image
173
+ if (typeof payload === "string") {
174
+ const mime = dataUriMime(payload)
175
+ if (mime) return mime
176
+ }
177
+ return "application/octet-stream"
178
+ }
179
+
180
+ function describeOmittedImage(item: Record<string, unknown>, mimeType: string, reason: string): string {
181
+ const bytes = byteLengthOf(item.data) || byteLengthOf(item.image) || byteLengthOf(item.url) || 0
182
+ const size = bytes > 0 ? `, ${Math.max(1, Math.round(bytes / 1024))} KB` : ""
183
+ return `[image omitted: ${mimeType}${size} — ${reason}]`
184
+ }
185
+
186
+ /**
187
+ * Convert one binary content part (user attachment or tool-result media) into a
188
+ * wire content part, appending to `parts`. Returns true when an actual image
189
+ * part was emitted (i.e. the message now carries multimodal content).
190
+ *
191
+ * Images are emitted as `{ type: "image", image: "data:<mime>;base64,<b64>",
192
+ * mimeType }` — byte-for-byte the shape the Command Code CLI itself sends.
193
+ * Anything that cannot be transported becomes a short note; payloads are never
194
+ * inlined into text.
195
+ */
196
+ function convertBinaryPart(
197
+ item: Record<string, unknown>,
198
+ parts: Array<{ type: string; [key: string]: unknown }>,
199
+ visionEnabled: boolean,
200
+ ): boolean {
201
+ const mimeType = detectMediaType(item)
202
+ const payload = item.data ?? item.url ?? item.image
203
+
204
+ if (mimeType.startsWith("image/")) {
205
+ if (!visionEnabled) {
206
+ parts.push({ type: "text", text: describeOmittedImage(item, mimeType, "this model does not accept image input") })
207
+ return false
208
+ }
209
+ if (payload === undefined || payload === null) {
210
+ parts.push({ type: "text", text: describeOmittedImage(item, mimeType, "no payload present") })
211
+ return false
212
+ }
213
+ const imgUrl = toDataUriOrUrl(payload as string | Uint8Array | URL, mimeType)
214
+ if (typeof imgUrl === "string" && imgUrl.startsWith("data:")) {
215
+ parts.push({ type: "image", image: imgUrl, mimeType })
216
+ return true
217
+ }
218
+ parts.push({
219
+ type: "text",
220
+ text: describeOmittedImage(item, mimeType, "the API accepts inline data URIs only, not remote URLs"),
221
+ })
222
+ return false
223
+ }
224
+
225
+ if (mimeType.startsWith("text/") && typeof payload === "string" && !payload.startsWith("data:")) {
226
+ if (payload.length > MAX_INLINE_TOOL_TEXT_CHARS) {
227
+ parts.push({
228
+ type: "text",
229
+ text: `[attachment omitted: ${mimeType}, ${Math.round(payload.length / 1024)} KB exceeds the inline limit]`,
230
+ })
231
+ } else {
232
+ parts.push({ type: "text", text: payload })
233
+ }
234
+ return false
235
+ }
236
+
237
+ parts.push({ type: "text", text: describeBinaryPart(item) })
238
+ return false
239
+ }
240
+
241
+ /**
242
+ * Hard ceiling for any single inlined tool-result payload. Anything larger is
243
+ * replaced by a size note instead of being inlined. Binary payloads (base64
244
+ * images, PDFs, data URIs) are never inlined at all — see `describeBinaryPart`.
245
+ */
246
+ const MAX_INLINE_TOOL_TEXT_CHARS = 200_000
247
+
248
+ /**
249
+ * Image forwarding budget per request. Tool images cannot ride inside a
250
+ * tool-result (Command Code accepts images only in user content), so they are
251
+ * re-emitted as a synthetic user message right after the tool message. These
252
+ * caps keep a pathological read (huge screenshot, repeated reads) bounded.
253
+ */
254
+ const MAX_FORWARD_IMAGE_BYTES = 8 * 1024 * 1024
255
+ const MAX_FORWARD_IMAGES_TOTAL = 12
256
+ const MAX_FORWARD_IMAGE_BYTES_TOTAL = 16 * 1024 * 1024
257
+
258
+ interface ForwardedImage {
259
+ mediaType: string
260
+ dataUri: string
261
+ }
262
+
263
+ interface ImageForwarder {
264
+ /** Whether the target model accepts image input at all. */
265
+ enabled: boolean
266
+ images: ForwardedImage[]
267
+ seen: Set<string>
268
+ bytes: number
269
+ }
270
+
271
+ function createImageForwarder(enabled: boolean): ImageForwarder {
272
+ return { enabled, images: [], seen: new Set<string>(), bytes: 0 }
273
+ }
274
+
275
+ function imageFingerprint(dataUri: string): string {
276
+ return `${dataUri.length}:${dataUri.slice(0, 64)}:${dataUri.slice(-64)}`
277
+ }
278
+
279
+ function kilobytes(dataUri: string): number {
280
+ return Math.max(1, Math.round(dataUri.length / 1024))
281
+ }
282
+
283
+ /**
284
+ * Decide what happens to one binary tool-result part.
285
+ *
286
+ * Image parts are handed to the forwarder (which re-emits them as a user
287
+ * message for vision-capable models) and replaced in the tool result by a short
288
+ * note. Everything else — and every image when the model cannot see, when the
289
+ * payload is oversized, or when the budget is spent — becomes a size note so the
290
+ * payload never reaches the prompt as text.
291
+ */
292
+ function handleBinaryPart(item: Record<string, unknown>, forwarder?: ImageForwarder): string {
293
+ const mediaType = detectMediaType(item)
294
+ const raw = item.data ?? item.image ?? item.url
295
+ if (!forwarder?.enabled || !mediaType.startsWith("image/")) {
296
+ return describeBinaryPart(item)
297
+ }
298
+ const dataUri = toDataUriOrUrl(raw as string | Uint8Array | URL, mediaType)
299
+ if (typeof dataUri !== "string" || !dataUri.startsWith("data:")) {
300
+ return describeBinaryPart(item)
301
+ }
302
+ if (dataUri.length > MAX_FORWARD_IMAGE_BYTES) {
303
+ return `[image omitted: ${mediaType}, ${kilobytes(dataUri)} KB exceeds the ${Math.round(MAX_FORWARD_IMAGE_BYTES / 1024 / 1024)} MB per-image forward limit]`
304
+ }
305
+ const fingerprint = imageFingerprint(dataUri)
306
+ if (forwarder.seen.has(fingerprint)) {
307
+ return `[identical ${mediaType} image already attached earlier in this conversation — not re-sent]`
308
+ }
309
+ if (
310
+ forwarder.images.length >= MAX_FORWARD_IMAGES_TOTAL ||
311
+ forwarder.bytes + dataUri.length > MAX_FORWARD_IMAGE_BYTES_TOTAL
312
+ ) {
313
+ return `[image omitted: per-request image forward budget reached — ${forwarder.images.length} image(s), ${Math.round(forwarder.bytes / 1024 / 1024)} MB already forwarded]`
314
+ }
315
+ forwarder.seen.add(fingerprint)
316
+ forwarder.bytes += dataUri.length
317
+ forwarder.images.push({ mediaType, dataUri })
318
+ return `[${mediaType} image, ${kilobytes(dataUri)} KB — attached as a user message below]`
319
+ }
320
+
321
+ function byteLengthOf(data: unknown): number {
322
+ if (typeof data === "string") return data.length
323
+ if (data instanceof Uint8Array) return data.byteLength
324
+ if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return data.length
325
+ return 0
326
+ }
327
+
328
+ /**
329
+ * Describe a non-text tool-result part without serializing its payload.
330
+ *
331
+ * Tool results can carry binary attachments (opencode's `read` on an image
332
+ * returns `{ type: "file", mediaType, data: "data:image/png;base64,..." }`).
333
+ * Inlining those as text pushes megabytes of base64 into the prompt and blows
334
+ * the model's context window, so they are summarised instead.
335
+ */
336
+ function describeBinaryPart(item: Record<string, unknown>): string {
337
+ const mediaType =
338
+ typeof item.mediaType === "string"
339
+ ? item.mediaType
340
+ : typeof item.mimeType === "string"
341
+ ? item.mimeType
342
+ : "application/octet-stream"
343
+ const bytes =
344
+ byteLengthOf(item.data) || byteLengthOf(item.image) || byteLengthOf(item.url) || 0
345
+ const size = bytes > 0 ? `, ${Math.max(1, Math.round(bytes / 1024))} KB` : ""
346
+ return `[attachment omitted: ${mediaType}${size} — binary payloads are not inlined into text. Use a vision-capable model and send the image as a user attachment to inspect it.]`
347
+ }
348
+
349
+ function isBinaryLikePart(v: unknown): v is Record<string, unknown> {
350
+ if (!v || typeof v !== "object") return false
351
+ const item = v as Record<string, unknown>
352
+ if (item.type === "file" || item.type === "image" || item.type === "media") return true
353
+ if (typeof item.data === "string" && item.data.startsWith("data:")) return true
354
+ if (typeof item.url === "string" && item.url.startsWith("data:")) return true
355
+ return false
356
+ }
357
+
358
+ function inlineTextOrNote(v: unknown, forwarder?: ImageForwarder): string {
359
+ if (typeof v === "string") return v
360
+ if (!v || typeof v !== "object") return String(v ?? "")
361
+ const item = v as Record<string, unknown>
362
+ if (typeof item.text === "string") return item.text
363
+ if (isBinaryLikePart(item)) return handleBinaryPart(item, forwarder)
364
+ let serialized: string
365
+ try {
366
+ serialized = scrubEmbeddedPayloads(JSON.stringify(item) ?? "")
367
+ } catch {
368
+ return "[unserializable tool output omitted]"
369
+ }
370
+ if (serialized.length > MAX_INLINE_TOOL_TEXT_CHARS) {
371
+ return `[tool output omitted: ${serialized.length} chars exceed the ${MAX_INLINE_TOOL_TEXT_CHARS} char inline limit]`
372
+ }
373
+ return serialized
374
+ }
375
+
376
+ /**
377
+ * Safety net for serialised tool output: a `data:` URI buried inside an object
378
+ * must never turn into thousands of tokens of base64 text. Structural handling
379
+ * happens earlier (convertBinaryPart / handleBinaryPart); this catches the
380
+ * leftovers (e.g. a payload nested inside an otherwise-ordinary JSON result).
381
+ */
382
+ const EMBEDDED_DATA_URI_RE = /data:[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*;base64,[A-Za-z0-9+/=\s]{512,}/gi
383
+
384
+ function scrubEmbeddedPayloads(text: string): string {
385
+ if (!text.includes(";base64,")) return text
386
+ return text.replace(
387
+ EMBEDDED_DATA_URI_RE,
388
+ (match) => `[embedded base64 payload omitted: ${Math.max(1, Math.round(match.length / 1024))} KB]`,
389
+ )
390
+ }
391
+
392
+ function capSerialized(value: unknown, fallback: string): string {
393
+ let serialized: string
394
+ try {
395
+ serialized = scrubEmbeddedPayloads(JSON.stringify(value) ?? "")
396
+ } catch {
397
+ return fallback
398
+ }
399
+ if (serialized.length > MAX_INLINE_TOOL_TEXT_CHARS) {
400
+ return `[tool output omitted: ${serialized.length} chars exceed the ${MAX_INLINE_TOOL_TEXT_CHARS} char inline limit]`
401
+ }
402
+ return serialized
403
+ }
404
+
405
+ function convertToolResultOutput(
406
+ output: LanguageModelV3ToolResultOutput,
407
+ forwarder?: ImageForwarder,
408
+ ): CCToolResultContent["output"] {
149
409
  switch (output.type) {
150
410
  case "text":
151
411
  return { type: "text", value: output.value }
152
412
  case "error-text":
153
413
  return { type: "error-text", value: output.value }
154
414
  case "json":
155
- return { type: "text", value: JSON.stringify(output.value) }
415
+ return { type: "text", value: capSerialized(output.value, "[unserializable tool output omitted]") }
156
416
  case "execution-denied":
157
417
  return { type: "error-text", value: output.reason ?? "Execution denied" }
158
418
  case "error-json":
159
- return { type: "error-text", value: JSON.stringify(output.value) }
419
+ return { type: "error-text", value: capSerialized(output.value, "[unserializable tool output omitted]") }
160
420
  case "content":
161
- return { type: "text", value: output.value.map((v: Record<string, unknown>) => ("text" in v ? v.text : JSON.stringify(v))).join("\n") }
421
+ return {
422
+ type: "text",
423
+ value: output.value.map((v: unknown) => inlineTextOrNote(v, forwarder)).join("\n"),
424
+ }
162
425
  default:
163
- return { type: "text", value: JSON.stringify(output) }
426
+ return { type: "text", value: capSerialized(output, "[unserializable tool output omitted]") }
164
427
  }
165
428
  }
166
429
 
167
- function convertMessage(msg: LanguageModelV3Message): CCMessage | null {
430
+ function convertMessage(msg: LanguageModelV3Message, forwarder?: ImageForwarder): CCMessage | null {
168
431
  switch (msg.role) {
169
432
  case "user": {
170
- const content = convertUserContent(msg.content)
433
+ const content = convertUserContent(msg.content, forwarder)
171
434
  return { role: "user", content: content as any }
172
435
  }
173
436
  case "assistant": {
@@ -196,7 +459,7 @@ function convertMessage(msg: LanguageModelV3Message): CCMessage | null {
196
459
  type: "tool-result",
197
460
  toolCallId: part.toolCallId,
198
461
  toolName: part.toolName,
199
- output: convertToolResultOutput(part.output),
462
+ output: convertToolResultOutput(part.output, forwarder),
200
463
  })
201
464
  }
202
465
  }
@@ -253,24 +516,102 @@ function extractReasoningEffort(options: LanguageModelV3CallOptions): string | u
253
516
  return undefined
254
517
  }
255
518
 
519
+ /**
520
+ * Lazily-loaded map of model id -> "accepts image input", built from the same
521
+ * catalog this provider registers with opencode (models.cache.json wins, then
522
+ * models.json). Unknown models are treated as image-capable: forwarding an image
523
+ * can at worst be rejected by the upstream, never silently explode the prompt.
524
+ */
525
+ let imageInputSupport: Map<string, boolean> | null = null
526
+
527
+ function loadImageInputSupport(): Map<string, boolean> {
528
+ if (imageInputSupport) return imageInputSupport
529
+ const support = new Map<string, boolean>()
530
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..")
531
+ for (const file of ["models.cache.json", "models.json"]) {
532
+ try {
533
+ const parsed = JSON.parse(readFileSync(join(packageRoot, file), "utf-8")) as unknown
534
+ const list = Array.isArray(parsed) ? parsed : Object.values((parsed ?? {}) as Record<string, unknown>)
535
+ for (const raw of list) {
536
+ if (!raw || typeof raw !== "object") continue
537
+ const entry = raw as Record<string, unknown>
538
+ const id = typeof entry.id === "string" ? entry.id : undefined
539
+ if (!id) continue
540
+ const modalities = (entry.modalities ?? {}) as Record<string, unknown>
541
+ const inputs = Array.isArray(modalities.input) ? (modalities.input as unknown[]) : undefined
542
+ const acceptsImages = inputs ? inputs.includes("image") : entry.attachment === true
543
+ const key = id.toLowerCase()
544
+ if (!support.has(key)) support.set(key, acceptsImages)
545
+ const short = key.split("/").pop()
546
+ if (short && !support.has(short)) support.set(short, acceptsImages)
547
+ }
548
+ } catch {
549
+ // catalog missing/unreadable: fall through to the permissive default
550
+ }
551
+ }
552
+ imageInputSupport = support
553
+ return support
554
+ }
555
+
556
+ function modelAcceptsImageInput(modelId: string): boolean {
557
+ const support = loadImageInputSupport()
558
+ const key = modelId.toLowerCase()
559
+ const hit = support.get(key) ?? support.get(key.split("/").pop() ?? key)
560
+ return hit ?? true
561
+ }
562
+
256
563
  export function buildRequest(
257
564
  modelId: string,
258
565
  options: LanguageModelV3CallOptions,
259
566
  ): CCRequestEnvelope {
567
+ const rawDumpPath = process.env.COMMANDCODE_DEBUG_RAW
568
+ if (rawDumpPath) {
569
+ try {
570
+ writeFileSync(rawDumpPath, JSON.stringify(options.prompt, null, 1), "utf-8")
571
+ } catch {
572
+ // diagnostics only
573
+ }
574
+ }
260
575
  let systemPrompt = ""
261
576
  const messages: CCMessage[] = []
577
+ const forwarder = createImageForwarder(modelAcceptsImageInput(modelId))
262
578
 
263
579
  for (const msg of options.prompt) {
264
580
  if (msg.role === "system") {
265
581
  systemPrompt += (systemPrompt ? "\n\n" : "") + msg.content
266
582
  continue
267
583
  }
268
- const converted = convertMessage(msg)
584
+ const converted = convertMessage(msg, forwarder)
269
585
  if (converted) messages.push(converted)
586
+ // Tool images cannot ride inside a tool-result, so they follow it as a
587
+ // user message (Command Code accepts images only in user content).
588
+ if (msg.role === "tool" && forwarder.images.length > 0) {
589
+ const pending = forwarder.images.splice(0, forwarder.images.length)
590
+ messages.push({
591
+ role: "user",
592
+ content: [
593
+ { type: "text", text: "Images returned by the tool call(s) above:" },
594
+ ...pending.map((image) => ({ type: "image", image: image.dataUri, mimeType: image.mediaType })),
595
+ ],
596
+ })
597
+ }
270
598
  }
271
599
 
272
600
  const effort = extractReasoningEffort(options)
273
601
 
602
+ // Opt-in wire inspection: COMMANDCODE_DEBUG_BODY=/path/to/body.json dumps the
603
+ // exact request this provider is about to send (used to prove whether image
604
+ // parts are forwarded as images or inlined as text).
605
+ const debugBodyPath = process.env.COMMANDCODE_DEBUG_BODY
606
+ if (debugBodyPath) {
607
+ const dump = JSON.stringify(messages, null, 1)
608
+ try {
609
+ writeFileSync(debugBodyPath, dump, "utf-8")
610
+ } catch {
611
+ // diagnostics only — never fail the request over it
612
+ }
613
+ }
614
+
274
615
  const params: CCRequestEnvelope["params"] = {
275
616
  model: modelId,
276
617
  messages,
package/src/model-sync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs"
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "fs"
2
2
  import { dirname, join } from "path"
3
3
  import { fileURLToPath } from "url"
4
4
  import { resolveApiKey } from "./auth.js"
@@ -55,6 +55,11 @@ function writeAtomic(file: string, data: string) {
55
55
  // Locate the model catalog bundled with the installed Command Code CLI.
56
56
  // The catalog is the vendor-maintained list for the same API endpoint this
57
57
  // provider talks to, so it is the most accurate metadata source available.
58
+ function bundledMdIn(root: string): string | null {
59
+ const candidate = join(root, "command-code", "dist", "bundled", "command-code-knowledge", "reference", "models.md")
60
+ return existsSync(candidate) ? candidate : null
61
+ }
62
+
58
63
  function findModelsMd(): string | null {
59
64
  const explicit = process.env.COMMANDCODE_MODELS_MD
60
65
  if (explicit && existsSync(explicit)) return explicit
@@ -80,6 +85,28 @@ function findModelsMd(): string | null {
80
85
  }
81
86
  }
82
87
  }
88
+
89
+ // Common global npm roots — PATH-independent so background services
90
+ // (OpenChamber, launchd/systemd) resolve the catalog even when the npm
91
+ // global bin dir is not exported into their environment.
92
+ const roots: string[] = []
93
+ const home = process.env.HOME
94
+ if (home) {
95
+ for (const base of [join(home, ".local", "share", "nvm"), join(home, ".nvm", "versions", "node"), join(home, ".npm-global", "lib")]) {
96
+ try {
97
+ for (const version of readdirSync(base)) {
98
+ roots.push(join(base, version, "lib", "node_modules"))
99
+ }
100
+ } catch {
101
+ // directory absent — skip
102
+ }
103
+ }
104
+ }
105
+ roots.push("/usr/local/lib/node_modules", "/usr/lib/node_modules", "/opt/homebrew/lib/node_modules")
106
+ for (const root of roots) {
107
+ const found = bundledMdIn(root)
108
+ if (found) return found
109
+ }
83
110
  return null
84
111
  }
85
112
 
@@ -247,6 +274,34 @@ export function parseModelsMd(text: string, hints: Map<string, ModelEntry>): Mod
247
274
  return entries
248
275
  }
249
276
 
277
+ // Server-managed master catalog. The server (keypool) serves the same
278
+ // enriched model list this provider builds locally, so clients fetch it at
279
+ // startup and always see current models/capabilities — no plugin update and
280
+ // no `op pull` needed when the catalog changes.
281
+ export async function fetchRemoteCatalog(url: string, timeoutMs = 1500): Promise<ModelEntry[] | null> {
282
+ const controller = new AbortController()
283
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
284
+ try {
285
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: "application/json" } })
286
+ if (!response.ok) return null
287
+ const payload = (await response.json()) as unknown
288
+ const list = Array.isArray(payload)
289
+ ? payload
290
+ : payload && typeof payload === "object" && Array.isArray((payload as { models?: unknown }).models)
291
+ ? (payload as { models: unknown[] }).models
292
+ : null
293
+ if (!list) return null
294
+ const entries = list.filter(
295
+ (entry): entry is ModelEntry => typeof entry === "object" && entry !== null && typeof (entry as { id?: unknown }).id === "string",
296
+ )
297
+ return entries.length >= 5 ? entries : null
298
+ } catch {
299
+ return null
300
+ } finally {
301
+ clearTimeout(timer)
302
+ }
303
+ }
304
+
250
305
  async function fetchApiModels(): Promise<ApiModel[] | null> {
251
306
  const apiKey = resolveApiKey({})
252
307
  const controller = new AbortController()
@@ -311,44 +366,68 @@ function mergeApiModels(entries: ModelEntry[], api: ApiModel[], hints?: Map<stri
311
366
  }
312
367
 
313
368
  // Build the model list fresh on every opencode instance start:
314
- // 1. CLI-bundled catalog (vendor-maintained, refreshed by `cmd update`)
315
- // 2. Command Code's public model endpoint (picks up brand-new models)
316
- // 3. last synced cache, then the bundled snapshot as an offline fallback.
317
- export async function loadSyncedModels(): Promise<SyncResult> {
369
+ // 1. REMOTE master catalog (server-managed; always current — primary source
370
+ // so client devices never depend on a local CLI install for metadata)
371
+ // 2. local CLI-bundled catalog + Command Code's public model endpoint
372
+ // (used to append models newer than the server catalog, and as the
373
+ // offline fallback when the server is unreachable)
374
+ // 3. last synced cache, then the bundled snapshot as a final fallback.
375
+ export async function loadSyncedModels(options?: { catalogUrl?: string }): Promise<SyncResult> {
318
376
  const bundled = loadCachedModels(BUNDLED_MODELS)
319
377
  const cached = loadCachedModels(CACHE_MODELS)
320
378
  const hints = new Map<string, ModelEntry>()
321
379
  for (const entry of [...(cached ?? []), ...(bundled ?? [])]) hints.set(entry.id.toLowerCase(), entry)
322
380
 
323
- let docModels: ModelEntry[] | null = null
381
+ let localModels: ModelEntry[] | null = null
382
+ let localSource = ""
324
383
  try {
325
384
  const modelsMd = findModelsMd()
326
385
  if (modelsMd) {
327
386
  const parsed = parseModelsMd(readFileSync(modelsMd, "utf-8"), hints)
328
- if (parsed.length >= 5) docModels = parsed
387
+ if (parsed.length >= 5) {
388
+ const apiModels = await fetchApiModels()
389
+ localModels = mergeApiModels(parsed, apiModels ?? [], hints)
390
+ localSource = apiModels?.length ? "cli-catalog+api" : "cli-catalog"
391
+ }
329
392
  }
330
393
  } catch {
331
- // intentionally silent: fall through to the API or cache
394
+ // intentionally silent: fall through to the API or remote catalog
395
+ }
396
+ if (!localModels?.length) {
397
+ const apiModels = await fetchApiModels()
398
+ if (apiModels?.length) {
399
+ localModels = mergeApiModels([], apiModels, hints)
400
+ localSource = "api"
401
+ }
332
402
  }
333
-
334
- const apiModels = await fetchApiModels()
335
403
 
336
404
  let models: ModelEntry[] | null = null
337
405
  let source = ""
338
- if (docModels?.length) {
339
- models = mergeApiModels(docModels, apiModels ?? [], hints)
340
- source = apiModels?.length ? "cli-catalog+api" : "cli-catalog"
341
- } else if (apiModels?.length) {
342
- models = mergeApiModels([], apiModels, hints)
343
- source = "api"
406
+ if (options?.catalogUrl) {
407
+ const remote = await fetchRemoteCatalog(options.catalogUrl)
408
+ if (remote) {
409
+ models = remote
410
+ source = "remote"
411
+ if (localModels?.length) {
412
+ const seen = new Set(remote.map((entry) => entry.id.toLowerCase()))
413
+ const extra = localModels.filter((entry) => !seen.has(entry.id.toLowerCase()))
414
+ if (extra.length) {
415
+ models = [...remote, ...extra]
416
+ source = "remote+local"
417
+ }
418
+ }
419
+ }
420
+ }
421
+ if (!models?.length && localModels?.length) {
422
+ models = localModels
423
+ source = localSource
344
424
  }
345
-
346
425
  if (!models?.length) {
347
426
  models = cached ?? bundled ?? []
348
427
  source = cached ? "cache" : "bundled"
349
428
  }
350
429
 
351
- if (!models.length) throw new Error("Command Code model catalog unavailable (no CLI, API, cache, or bundled list)")
430
+ if (!models.length) throw new Error("Command Code model catalog unavailable (no remote, CLI, API, cache, or bundled list)")
352
431
 
353
432
  if (source !== "cache" && source !== "bundled") {
354
433
  try {
@@ -358,5 +437,9 @@ export async function loadSyncedModels(): Promise<SyncResult> {
358
437
  }
359
438
  }
360
439
 
440
+ if (process.env.COMMANDCODE_DEBUG) {
441
+ console.warn(`[commandcode] catalog source=${source} models=${models.length}`)
442
+ }
443
+
361
444
  return { models, source }
362
445
  }
package/src/stream.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { LanguageModelV3StreamPart, LanguageModelV3Usage, LanguageModelV3FinishReason } from "@ai-sdk/provider"
2
+ import { appendFileSync } from "fs"
2
3
 
3
4
  type RawEvent = Record<string, unknown> & { type: string }
4
5
 
@@ -84,10 +85,30 @@ function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null {
84
85
  case "finish-step": {
85
86
  const usage = event.usage ?? event.totalUsage ?? {}
86
87
  const rawReason = (event.finishReason ?? event.rawFinishReason ?? "stop") as string
88
+ const mapped = mapUsage(typeof usage === "object" && usage !== null ? (usage as Record<string, unknown>) : {})
89
+ // Opt-in usage accounting: COMMANDCODE_DEBUG_USAGE=/path/to/usage.jsonl
90
+ // appends one line per completed step (used to prove image token costs).
91
+ const usageLogPath = process.env.COMMANDCODE_DEBUG_USAGE
92
+ if (usageLogPath) {
93
+ try {
94
+ appendFileSync(
95
+ usageLogPath,
96
+ JSON.stringify({
97
+ ts: new Date().toISOString(),
98
+ inputTokens: mapped.inputTokens,
99
+ outputTokens: mapped.outputTokens,
100
+ finishReason: rawReason,
101
+ }) + "\n",
102
+ "utf-8",
103
+ )
104
+ } catch {
105
+ // diagnostics only
106
+ }
107
+ }
87
108
  return {
88
109
  type: "finish",
89
110
  finishReason: { unified: mapFinishReason(rawReason), raw: rawReason },
90
- usage: mapUsage(typeof usage === "object" && usage !== null ? (usage as Record<string, unknown>) : {}),
111
+ usage: mapped,
91
112
  }
92
113
  }
93
114