@darthph0enix/commandcode-go-opencode-provider 0.6.0 → 0.7.1

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.6.0",
3
+ "version": "0.7.1",
4
4
  "author": "Brent Weatherall",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,8 +24,15 @@
24
24
  "url": "https://github.com/Darthph0enix7/opencode-commandcode-provider/issues"
25
25
  },
26
26
  "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code",
27
- "engines": { "bun": ">=1.0.0" },
28
- "files": ["index.ts", "plugin.ts", "models.json", "src/"],
27
+ "engines": {
28
+ "bun": ">=1.0.0"
29
+ },
30
+ "files": [
31
+ "index.ts",
32
+ "plugin.ts",
33
+ "models.json",
34
+ "src/"
35
+ ],
29
36
  "homepage": "https://github.com/Darthph0enix7/opencode-commandcode-provider#readme",
30
37
  "keywords": [
31
38
  "opencode",
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/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