@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 +1 -1
- package/plugin.ts +12 -1
- package/src/convert.ts +363 -22
- package/src/model-sync.ts +101 -18
- package/src/stream.ts +22 -1
package/package.json
CHANGED
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
|
|
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(
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
419
|
+
return { type: "error-text", value: capSerialized(output.value, "[unserializable tool output omitted]") }
|
|
160
420
|
case "content":
|
|
161
|
-
return {
|
|
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:
|
|
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.
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
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
|
|
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)
|
|
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
|
|
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 (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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:
|
|
111
|
+
usage: mapped,
|
|
91
112
|
}
|
|
92
113
|
}
|
|
93
114
|
|