pi-commandcode-provider 0.5.0 → 0.6.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.
@@ -0,0 +1,141 @@
1
+ export const COMMAND_CODE_CLI_VERSION = "1.32.2"
2
+
3
+ export type CommandCodeInputType = "text" | "image"
4
+ export type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
5
+
6
+ /**
7
+ * Generated from command-code@1.32.2 by `npm run sync:commandcode-catalog`.
8
+ * Do not edit manually.
9
+ */
10
+ export const MODEL_INPUT_MODALITIES: Readonly<Record<string, readonly CommandCodeInputType[]>> = {
11
+ "claude-fable-5": ["text", "image"],
12
+ "claude-haiku-4-5-20251001": ["text", "image"],
13
+ "claude-opus-4-7": ["text", "image"],
14
+ "claude-opus-4-8": ["text", "image"],
15
+ "claude-opus-5": ["text", "image"],
16
+ "claude-sonnet-4-6": ["text", "image"],
17
+ "claude-sonnet-5": ["text", "image"],
18
+ "deepseek/deepseek-v4-flash-vision-exp": ["text", "image"],
19
+ "google/gemini-3.1-flash-lite": ["text", "image"],
20
+ "google/gemini-3.5-flash": ["text", "image"],
21
+ "google/gemini-3.5-flash-lite": ["text", "image"],
22
+ "google/gemini-3.6-flash": ["text", "image"],
23
+ "google/gemini-3.7-flash": ["text", "image"],
24
+ "gpt-5.3-codex": ["text", "image"],
25
+ "gpt-5.4": ["text", "image"],
26
+ "gpt-5.4-mini": ["text", "image"],
27
+ "gpt-5.5": ["text", "image"],
28
+ "gpt-5.6-luna": ["text", "image"],
29
+ "gpt-5.6-sol": ["text", "image"],
30
+ "gpt-5.6-terra": ["text", "image"],
31
+ "meta/muse-spark-1.1": ["text", "image"],
32
+ "meta/muse-spark-1.2": ["text", "image"],
33
+ "meta/muse-spark-1.2-contributor": ["text", "image"],
34
+ "MiniMaxAI/MiniMax-M3": ["text", "image"],
35
+ "moonshotai/Kimi-K2.5": ["text", "image"],
36
+ "moonshotai/Kimi-K2.6": ["text", "image"],
37
+ "moonshotai/Kimi-K2.7-Code": ["text", "image"],
38
+ "moonshotai/Kimi-K2.7-Code-Highspeed": ["text", "image"],
39
+ "moonshotai/Kimi-K3": ["text", "image"],
40
+ "Qwen/Qwen3.6-Plus": ["text", "image"],
41
+ "Qwen/Qwen3.7-Flash": ["text", "image"],
42
+ "Qwen/Qwen3.7-Plus": ["text", "image"],
43
+ "Qwen/Qwen3.8-27B": ["text", "image"],
44
+ "Qwen/Qwen3.8-Max": ["text", "image"],
45
+ "sakana/fugu-ultra": ["text", "image"],
46
+ "stealth/ox-alpha": ["text", "image"],
47
+ "stepfun/Step-3.7-Flash": ["text", "image"],
48
+ "thinkingmachines/inkling": ["text", "image"],
49
+ "thinkingmachines/inkling-small": ["text", "image"],
50
+ "xai/grok-4.5": ["text", "image"],
51
+ "xiaomi/mimo-v2.5": ["text", "image"],
52
+ }
53
+
54
+ export const MODEL_REASONING: Readonly<Record<string, true>> = {
55
+ "claude-fable-5": true,
56
+ "claude-opus-4-7": true,
57
+ "claude-opus-4-8": true,
58
+ "claude-opus-5": true,
59
+ "claude-sonnet-4-6": true,
60
+ "claude-sonnet-5": true,
61
+ "deepseek/deepseek-v4-flash": true,
62
+ "deepseek/deepseek-v4-flash-vision-exp": true,
63
+ "deepseek/deepseek-v4-pro": true,
64
+ "google/gemini-3.1-flash-lite": true,
65
+ "google/gemini-3.5-flash": true,
66
+ "google/gemini-3.5-flash-lite": true,
67
+ "google/gemini-3.6-flash": true,
68
+ "google/gemini-3.7-flash": true,
69
+ "gpt-5.3-codex": true,
70
+ "gpt-5.4": true,
71
+ "gpt-5.4-mini": true,
72
+ "gpt-5.5": true,
73
+ "gpt-5.6-luna": true,
74
+ "gpt-5.6-sol": true,
75
+ "gpt-5.6-terra": true,
76
+ "meta/muse-spark-1.1": true,
77
+ "meta/muse-spark-1.2": true,
78
+ "meta/muse-spark-1.2-contributor": true,
79
+ "MiniMaxAI/MiniMax-M3": true,
80
+ "moonshotai/Kimi-K2.7-Code": true,
81
+ "moonshotai/Kimi-K2.7-Code-Highspeed": true,
82
+ "moonshotai/Kimi-K3": true,
83
+ "nvidia/nemotron-3-ultra-550b-a55b": true,
84
+ "poolside/laguna-s-2.1-free": true,
85
+ "Qwen/Qwen3.6-Max-Preview": true,
86
+ "Qwen/Qwen3.6-Plus": true,
87
+ "Qwen/Qwen3.7-Flash": true,
88
+ "Qwen/Qwen3.7-Max": true,
89
+ "Qwen/Qwen3.7-Plus": true,
90
+ "Qwen/Qwen3.8-27B": true,
91
+ "Qwen/Qwen3.8-Max": true,
92
+ "sakana/fugu-ultra": true,
93
+ "stealth/ox-alpha": true,
94
+ "stepfun/Step-3.5-Flash": true,
95
+ "stepfun/Step-3.7-Flash": true,
96
+ "tencent/hy3-paid": true,
97
+ "thinkingmachines/inkling": true,
98
+ "thinkingmachines/inkling-small": true,
99
+ "xai/grok-4.5": true,
100
+ "xai/grok-4.6": true,
101
+ "zai-org/GLM-5.2": true,
102
+ "zai-org/GLM-5.3": true,
103
+ }
104
+
105
+ export const MODEL_EFFORTS: Readonly<Record<string, readonly CommandCodeReasoningEffort[]>> = {
106
+ "claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
107
+ "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
108
+ "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
109
+ "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
110
+ "claude-sonnet-4-6": ["low", "medium", "high", "xhigh", "max"],
111
+ "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
112
+ "deepseek/deepseek-v4-flash": ["high", "max"],
113
+ "deepseek/deepseek-v4-flash-vision-exp": ["high", "max"],
114
+ "deepseek/deepseek-v4-pro": ["high", "max"],
115
+ "google/gemini-3.1-flash-lite": ["low", "medium", "high"],
116
+ "google/gemini-3.5-flash": ["low", "medium", "high"],
117
+ "google/gemini-3.5-flash-lite": ["low", "medium", "high"],
118
+ "google/gemini-3.6-flash": ["low", "medium", "high"],
119
+ "google/gemini-3.7-flash": ["low", "medium", "high"],
120
+ "gpt-5.3-codex": ["low", "medium", "high", "xhigh"],
121
+ "gpt-5.4": ["low", "medium", "high", "xhigh"],
122
+ "gpt-5.4-mini": ["low", "medium", "high"],
123
+ "gpt-5.5": ["low", "medium", "high", "xhigh"],
124
+ "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"],
125
+ "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"],
126
+ "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"],
127
+ "Qwen/Qwen3.8-27B": ["low", "medium", "xhigh"],
128
+ "Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"],
129
+ "sakana/fugu-ultra": ["high", "xhigh"],
130
+ "stealth/ox-alpha": ["low", "high", "max"],
131
+ "xai/grok-4.5": ["low", "medium", "high"],
132
+ "xai/grok-4.6": ["low", "medium", "high", "xhigh"],
133
+ "zai-org/GLM-5.2": ["high", "max"],
134
+ "zai-org/GLM-5.3": ["low", "high", "max"],
135
+ }
136
+
137
+ export const MODEL_MAX_OUTPUT_TOKENS: Readonly<Record<string, number>> = {
138
+ "poolside/laguna-s-2.1-free": 32_768,
139
+ "Qwen/Qwen3.8-27B": 32_768,
140
+ "stealth/ox-alpha": 131_072,
141
+ }
package/src/converters.ts CHANGED
@@ -55,26 +55,49 @@ function apiKeyFromCredentialRecord(value: unknown): string | undefined {
55
55
  return stringValue(value.key) ?? stringValue(value.access)
56
56
  }
57
57
 
58
- function hasImageContent(value: unknown): boolean {
59
- if (isRecord(value)) return value.type === "image"
60
- return recordArray(value).some((part) => part.type === "image")
58
+ function imageParts(value: unknown): readonly Record<string, unknown>[] {
59
+ if (isRecord(value)) return value.type === "image" ? [value] : []
60
+ return recordArray(value).filter((part) => part.type === "image")
61
61
  }
62
62
 
63
63
  function imageContentError(role: string): Error {
64
- return new Error(
65
- `Command Code does not support image content in ${role}; refusing to send it to avoid lossy handling`,
66
- )
64
+ return new Error(`Selected Command Code model does not support image content in ${role}`)
67
65
  }
68
66
 
69
67
  export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void {
70
68
  for (const message of messages ?? []) {
71
- if (hasImageContent(message.content)) {
72
- const role = message.role === "toolResult" ? "tool results" : `${message.role} messages`
73
- throw imageContentError(role)
69
+ if (message.role !== "toolResult" && imageParts(message.content).length > 0) {
70
+ throw imageContentError(`${message.role} messages`)
74
71
  }
75
72
  }
76
73
  }
77
74
 
75
+ function imageToCommandCode(part: Record<string, unknown>): Record<string, string> {
76
+ const data = stringValue(part.data)
77
+ const mimeType = stringValue(part.mimeType)
78
+ if (!data || !mimeType)
79
+ throw new Error("Invalid image content: expected base64 data and mimeType")
80
+
81
+ return {
82
+ type: "image",
83
+ image: `data:${mimeType};base64,${data}`,
84
+ mimeType,
85
+ }
86
+ }
87
+
88
+ function userContentToCommandCode(content: unknown, allowImages: boolean): unknown {
89
+ if (typeof content === "string") return content
90
+
91
+ return recordArray(content).flatMap((part) => {
92
+ if (part.type === "text") return [{ type: "text", text: stringValue(part.text) ?? "" }]
93
+ if (part.type === "image") {
94
+ if (!allowImages) throw imageContentError("user messages")
95
+ return [imageToCommandCode(part)]
96
+ }
97
+ return []
98
+ })
99
+ }
100
+
78
101
  export function getApiKey(
79
102
  options: {
80
103
  env?: NodeJS.ProcessEnv
@@ -83,6 +106,7 @@ export function getApiKey(
83
106
  } = {},
84
107
  ): string | undefined {
85
108
  const env = options.env ?? process.env
109
+ if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY
86
110
  if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
87
111
 
88
112
  const home = options.homeDir?.() ?? homedir()
@@ -114,8 +138,40 @@ export function getApiKey(
114
138
  return undefined
115
139
  }
116
140
 
141
+ // Hosts such as OMP may pass a literal env-var name as the "resolved" registry
142
+ // key instead of the actual credential. Treat those as unresolved.
143
+ export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([
144
+ "$COMMAND_CODE_API_KEY",
145
+ "COMMAND_CODE_API_KEY",
146
+ "$COMMANDCODE_API_KEY",
147
+ "COMMANDCODE_API_KEY",
148
+ ])
149
+
150
+ /**
151
+ * Pick the real API key from a host registry value and/or the env/auth-file
152
+ * fallback, never returning a literal placeholder or an empty/whitespace value.
153
+ * Pure/testable.
154
+ */
155
+ export function pickCommandCodeApiKey(
156
+ registryKey: string | undefined,
157
+ hostKey: string | undefined,
158
+ ): string | undefined {
159
+ const trimmed = typeof registryKey === "string" ? registryKey.trim() : undefined
160
+ if (!trimmed) return hostKey
161
+ if (COMMAND_CODE_PLACEHOLDER_KEYS.has(trimmed)) return hostKey
162
+ return trimmed
163
+ }
164
+
117
165
  export function textContent(message: { content?: unknown }): string {
118
- if (hasImageContent(message.content)) throw imageContentError("tool results")
166
+ if (typeof message.content === "string") return message.content
167
+ if (message.content === null || message.content === undefined) return ""
168
+ if (!Array.isArray(message.content)) {
169
+ try {
170
+ return JSON.stringify(message.content) ?? String(message.content)
171
+ } catch {
172
+ return String(message.content)
173
+ }
174
+ }
119
175
 
120
176
  return recordArray(message.content)
121
177
  .filter((part) => part.type === "text")
@@ -137,7 +193,12 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
137
193
  }))
138
194
  }
139
195
 
140
- function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
196
+ interface ToolCallState {
197
+ callIds: ReadonlySet<string>
198
+ resultIds: ReadonlySet<string>
199
+ }
200
+
201
+ function toolCallState(messages?: readonly MessageLike[]): ToolCallState {
141
202
  const callIds = new Set<string>()
142
203
  const resultIds = new Set<string>()
143
204
 
@@ -149,45 +210,68 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
149
210
  if (id) callIds.add(id)
150
211
  }
151
212
  }
152
- } else if (message.role === "toolResult") {
153
- if (message.toolCallId) resultIds.add(message.toolCallId)
213
+ } else if (message.role === "toolResult" && message.toolCallId) {
214
+ resultIds.add(message.toolCallId)
154
215
  }
155
216
  }
156
217
 
157
- return new Set([...callIds].filter((id) => resultIds.has(id)))
218
+ return { callIds, resultIds }
158
219
  }
159
220
 
160
- export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
161
- assertTextOnlyMessages(messages)
221
+ export function messagesToCC(
222
+ messages?: readonly MessageLike[],
223
+ options: { allowImages?: boolean } = {},
224
+ ): unknown[] {
225
+ const allowImages = options.allowImages ?? false
226
+ if (!allowImages) assertTextOnlyMessages(messages)
162
227
 
163
228
  const out: unknown[] = []
164
- const pairedToolCallIds = completeToolCallIds(messages)
229
+ const { callIds, resultIds } = toolCallState(messages)
165
230
 
166
231
  for (const message of messages ?? []) {
167
232
  if (message.role === "user") {
168
233
  out.push({
169
234
  role: "user",
170
- content: typeof message.content === "string" ? message.content : message.content,
235
+ content: userContentToCommandCode(message.content, allowImages),
171
236
  })
172
237
  } else if (message.role === "assistant") {
173
238
  const parts: unknown[] = []
239
+ const missingResults: unknown[] = []
174
240
  for (const content of recordArray(message.content)) {
175
241
  if (content.type === "text") {
176
242
  parts.push({ type: "text", text: stringValue(content.text) ?? "" })
177
243
  } else if (content.type === "toolCall") {
178
244
  const toolCallId = stringValue(content.id) ?? ""
179
- if (!pairedToolCallIds.has(toolCallId)) continue
245
+ const toolName = stringValue(content.name) ?? ""
246
+ if (!toolCallId) continue
180
247
  parts.push({
181
248
  type: "tool-call",
182
249
  toolCallId,
183
- toolName: stringValue(content.name) ?? "",
250
+ toolName,
184
251
  input: recordOrEmpty(content.arguments),
185
252
  })
253
+ if (!resultIds.has(toolCallId)) {
254
+ missingResults.push({
255
+ type: "tool-result",
256
+ toolCallId,
257
+ toolName,
258
+ output: {
259
+ type: "error-text",
260
+ value: "No result — the tool call did not complete (interrupted or lost).",
261
+ },
262
+ })
263
+ }
186
264
  }
187
265
  }
188
266
  if (parts.length > 0) out.push({ role: "assistant", content: parts })
267
+ if (missingResults.length > 0) out.push({ role: "tool", content: missingResults })
189
268
  } else if (message.role === "toolResult") {
190
- if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue
269
+ if (!message.toolCallId || !callIds.has(message.toolCallId)) continue
270
+ const images = imageParts(message.content)
271
+ const text = textContent(message)
272
+ const outputText =
273
+ text ||
274
+ (images.length > 0 && !allowImages ? "[Image omitted: model does not support images]" : "")
191
275
  out.push({
192
276
  role: "tool",
193
277
  content: [
@@ -196,11 +280,18 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
196
280
  toolCallId: message.toolCallId,
197
281
  toolName: message.toolName,
198
282
  output: message.isError
199
- ? { type: "error-text", value: textContent(message) }
200
- : { type: "text", value: textContent(message) },
283
+ ? { type: "error-text", value: outputText }
284
+ : { type: "text", value: outputText },
201
285
  },
202
286
  ],
203
287
  })
288
+
289
+ if (images.length > 0 && allowImages) {
290
+ out.push({
291
+ role: "user",
292
+ content: images.map(imageToCommandCode),
293
+ })
294
+ }
204
295
  }
205
296
  }
206
297
  return out
package/src/core.ts CHANGED
@@ -7,7 +7,9 @@
7
7
 
8
8
  import { randomUUID } from "node:crypto"
9
9
 
10
+ import { COMMAND_CODE_CLI_VERSION } from "./commandcode-catalog.ts"
10
11
  import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts"
12
+ import { modelSupportsImageInput } from "./models.ts"
11
13
  import {
12
14
  getApiKey,
13
15
  getEnvironmentInfo,
@@ -42,13 +44,7 @@ export * from "./overflow.ts"
42
44
  export * from "./types.ts"
43
45
 
44
46
  export const DEFAULT_API_BASE = "https://api.commandcode.ai"
45
- export const COMMAND_CODE_CLI_VERSION = "0.29.0"
46
- /**
47
- * The legacy /alpha/generate request path used by this provider has no
48
- * documented image-part contract. Keep the advertised capability text-only
49
- * until Command Code documents and tests image handling for this endpoint.
50
- */
51
- export const COMMAND_CODE_INPUT_TYPES = ["text"] as const
47
+ export { COMMAND_CODE_CLI_VERSION }
52
48
 
53
49
  const DEFAULT_GENERATE_MAX_TOKENS = 64_000
54
50
  const DEFAULT_MAX_RETRIES = 0
@@ -152,6 +148,10 @@ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): strin
152
148
  return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
153
149
  }
154
150
 
151
+ function isUuid(value: string): boolean {
152
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
153
+ }
154
+
155
155
  export function projectSlugFromPath(pathName: string): string {
156
156
  const slug = pathName
157
157
  .toLowerCase()
@@ -236,17 +236,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
236
236
  const stream = deps.createStream()
237
237
 
238
238
  async function run() {
239
- // OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi)
240
- // or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of
241
- // resolving it. Filter out these specific strings.
242
- const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY"
243
- const OLD_API_KEY_REF = "COMMANDCODE_API_KEY"
239
+ // Some hosts pass a literal env-var reference instead of resolving it.
240
+ const PLACEHOLDER_API_KEYS = new Set([
241
+ "$COMMAND_CODE_API_KEY",
242
+ "COMMAND_CODE_API_KEY",
243
+ "$COMMANDCODE_API_KEY",
244
+ "COMMANDCODE_API_KEY",
245
+ ])
244
246
  const hostKey =
245
- options?.apiKey &&
246
- options.apiKey !== LEGACY_API_KEY_REF &&
247
- options.apiKey !== OLD_API_KEY_REF
248
- ? options.apiKey
249
- : undefined
247
+ options?.apiKey && !PLACEHOLDER_API_KEYS.has(options.apiKey) ? options.apiKey : undefined
250
248
 
251
249
  const apiKey =
252
250
  hostKey ??
@@ -266,7 +264,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
266
264
  usage: defaultUsage(),
267
265
  stopReason: "error",
268
266
  errorMessage:
269
- "No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
267
+ "No Command Code API key. Run /login and select Command Code, set COMMAND_CODE_API_KEY (or legacy COMMANDCODE_API_KEY), or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json",
270
268
  timestamp: now(),
271
269
  }
272
270
  stream.push({ type: "error", reason: "error", error: msg })
@@ -290,6 +288,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
290
288
  let textBlock: TextContent | undefined
291
289
  let currentTextIdx = -1
292
290
  let thinkingIdx = -1
291
+ const streamingToolCalls = new Map<
292
+ string,
293
+ { contentIndex: number; toolCall: ToolCallContent; partialArgs: string }
294
+ >()
293
295
  let finished = false
294
296
 
295
297
  const abortUpstream = () => {
@@ -402,25 +404,81 @@ export function createStreamCommandCode(deps: CoreDependencies) {
402
404
  break
403
405
  }
404
406
 
405
- case "tool-call": {
407
+ case "tool-input-start": {
406
408
  endTextBlock()
407
409
  endThinking()
410
+ const id = stringValue(event.id)
411
+ if (!id || streamingToolCalls.has(id)) break
412
+
408
413
  const toolCall: ToolCallContent = {
409
414
  type: "toolCall",
410
- id: stringValue(event.toolCallId) ?? "",
415
+ id,
411
416
  name: stringValue(event.toolName) ?? "",
412
- arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments),
417
+ arguments: {},
413
418
  }
414
419
  output.content.push(toolCall)
415
- const idx = output.content.length - 1
420
+ const contentIndex = output.content.length - 1
421
+ streamingToolCalls.set(id, { contentIndex, toolCall, partialArgs: "" })
416
422
  stream.push({
417
423
  type: "toolcall_start",
418
- contentIndex: idx,
424
+ contentIndex,
419
425
  partial: output,
420
426
  })
427
+ break
428
+ }
429
+
430
+ case "tool-input-delta": {
431
+ const id = stringValue(event.id)
432
+ const delta = stringValue(event.delta)
433
+ if (!id || delta === undefined) break
434
+ const active = streamingToolCalls.get(id)
435
+ if (!active) break
436
+
437
+ active.partialArgs += delta
438
+ active.toolCall.arguments = recordOrEmpty(active.partialArgs)
439
+ stream.push({
440
+ type: "toolcall_delta",
441
+ contentIndex: active.contentIndex,
442
+ delta,
443
+ partial: output,
444
+ })
445
+ break
446
+ }
447
+
448
+ case "tool-input-end": {
449
+ break
450
+ }
451
+
452
+ case "tool-call": {
453
+ endTextBlock()
454
+ endThinking()
455
+ const id = stringValue(event.toolCallId) ?? ""
456
+ const active = streamingToolCalls.get(id)
457
+ const toolCall: ToolCallContent = active?.toolCall ?? {
458
+ type: "toolCall",
459
+ id,
460
+ name: stringValue(event.toolName) ?? "",
461
+ arguments: {},
462
+ }
463
+ toolCall.name = stringValue(event.toolName) ?? toolCall.name
464
+ toolCall.arguments = recordOrEmpty(event.input ?? event.args ?? event.arguments)
465
+
466
+ let contentIndex: number
467
+ if (active) {
468
+ contentIndex = active.contentIndex
469
+ streamingToolCalls.delete(id)
470
+ } else {
471
+ output.content.push(toolCall)
472
+ contentIndex = output.content.length - 1
473
+ stream.push({
474
+ type: "toolcall_start",
475
+ contentIndex,
476
+ partial: output,
477
+ })
478
+ }
421
479
  stream.push({
422
480
  type: "toolcall_end",
423
- contentIndex: idx,
481
+ contentIndex,
424
482
  toolCall,
425
483
  partial: output,
426
484
  })
@@ -428,6 +486,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
428
486
  }
429
487
 
430
488
  case "finish": {
489
+ const rawFinishReason = stringValue(event.rawFinishReason)
490
+ if (
491
+ rawFinishReason &&
492
+ /^(?:network|connection|upstream)[-_\s]?error$/i.test(rawFinishReason)
493
+ ) {
494
+ throw new Error(
495
+ `Provider finished with reason "${rawFinishReason}" — upstream connection failed mid-stream`,
496
+ )
497
+ }
431
498
  const usage = commandCodeUsage(event)
432
499
  if (usage) {
433
500
  const details = commandCodeInputTokenDetails(usage)
@@ -451,6 +518,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
451
518
  break
452
519
  }
453
520
 
521
+ case "abort": {
522
+ throw abortError("Request aborted")
523
+ }
524
+
454
525
  case "error": {
455
526
  const message =
456
527
  commandCodeErrorMessage(event.error) ??
@@ -468,11 +539,16 @@ export function createStreamCommandCode(deps: CoreDependencies) {
468
539
  if (controller.signal.aborted) throw abortError("Aborted")
469
540
 
470
541
  const workingDir = cwd()
471
- const threadId = uuid()
542
+ const threadId = options?.sessionId
543
+ ? isUuid(options.sessionId)
544
+ ? options.sessionId
545
+ : undefined
546
+ : uuid()
472
547
  const reasoningEffort = mappedReasoningEffort(model, options)
473
548
  const timeoutMs = options?.timeoutMs
474
549
 
475
- assertTextOnlyMessages(context.messages)
550
+ const allowImages = modelSupportsImageInput(model.id)
551
+ if (!allowImages) assertTextOnlyMessages(context.messages)
476
552
 
477
553
  let body: unknown = {
478
554
  config: {
@@ -491,12 +567,12 @@ export function createStreamCommandCode(deps: CoreDependencies) {
491
567
  skills: null,
492
568
  params: {
493
569
  model: model.id,
494
- messages: messagesToCC(context.messages),
570
+ messages: messagesToCC(context.messages, { allowImages }),
495
571
  tools: toolsToJson(context.tools),
496
572
  system: systemPromptToText(context.systemPrompt),
497
573
  max_tokens: generateMaxTokens(model, options),
498
- temperature: 0.3,
499
574
  stream: true,
575
+ ...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
500
576
  ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
501
577
  },
502
578
  threadId,
@@ -526,7 +602,8 @@ export function createStreamCommandCode(deps: CoreDependencies) {
526
602
  "x-cli-environment": "production",
527
603
  "x-project-slug": projectSlugFromPath(workingDir),
528
604
  "x-taste-learning": "true",
529
- "x-co-flag": "false",
605
+ ...(options?.sessionId ? { "x-session-id": options.sessionId } : {}),
606
+ "User-Agent": "cli",
530
607
  ...options?.headers,
531
608
  }
532
609
  const bodyStr = JSON.stringify(body)
@@ -639,6 +716,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
639
716
  const { done, value } = await raceAbort(reader.read(), attemptController.signal)
640
717
  if (done) {
641
718
  if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
719
+ if (!finished) {
720
+ throw new Error(
721
+ "Stream ended unexpectedly before completion (no finish event) — response was truncated",
722
+ )
723
+ }
642
724
  break
643
725
  }
644
726
  if (controller.signal.aborted) throw abortError("Aborted")
@@ -662,7 +744,12 @@ export function createStreamCommandCode(deps: CoreDependencies) {
662
744
  } catch {}
663
745
  reader = undefined
664
746
 
665
- if (controller.signal.aborted) throw streamError
747
+ if (
748
+ controller.signal.aborted ||
749
+ (streamError instanceof Error && streamError.name === "AbortError")
750
+ ) {
751
+ throw streamError
752
+ }
666
753
 
667
754
  // Never retry after visible content was emitted (including timeout mid-stream).
668
755
  const canRetry = output.content.length === 0 && attempt < maxRetries
@@ -699,7 +786,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
699
786
  }
700
787
  }
701
788
  } catch (error: unknown) {
702
- const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
789
+ const reason: ErrorReason =
790
+ controller.signal.aborted || (error instanceof Error && error.name === "AbortError")
791
+ ? "aborted"
792
+ : "error"
703
793
  output.stopReason = reason
704
794
  output.errorMessage =
705
795
  reason === "aborted"