pi-commandcode-provider 0.4.3 → 0.5.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.
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process"
4
+ import { mkdir, mkdtemp, rm } from "node:fs/promises"
5
+ import { tmpdir } from "node:os"
6
+ import { dirname, join, resolve } from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+
9
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
10
+ const testRoot = await mkdtemp(join(tmpdir(), "pi-commandcode-isolated-"))
11
+ const agentDir = join(testRoot, "agent")
12
+ const sessionDir = join(testRoot, "sessions")
13
+
14
+ await mkdir(agentDir, { mode: 0o700 })
15
+ await mkdir(sessionDir, { mode: 0o700 })
16
+
17
+ const env = {
18
+ ...process.env,
19
+ HOME: testRoot,
20
+ USERPROFILE: testRoot,
21
+ PI_CODING_AGENT_DIR: agentDir,
22
+ PI_CODING_AGENT_SESSION_DIR: sessionDir,
23
+ PI_SKIP_VERSION_CHECK: "1",
24
+ }
25
+ delete env.COMMANDCODE_API_KEY
26
+
27
+ let activeChild
28
+ let receivedSignal
29
+
30
+ function forwardSignal(signal) {
31
+ receivedSignal = signal
32
+ activeChild?.kill(signal)
33
+ }
34
+
35
+ const forwardSigint = () => forwardSignal("SIGINT")
36
+ const forwardSigterm = () => forwardSignal("SIGTERM")
37
+ process.on("SIGINT", forwardSigint)
38
+ process.on("SIGTERM", forwardSigterm)
39
+
40
+ function runPi(args) {
41
+ return new Promise((resolveRun, rejectRun) => {
42
+ const child = spawn("pi", args, { cwd: repoRoot, env, stdio: "inherit" })
43
+ activeChild = child
44
+ child.once("error", rejectRun)
45
+ child.once("exit", (status, signal) => {
46
+ activeChild = undefined
47
+ resolveRun({ status, signal })
48
+ })
49
+ })
50
+ }
51
+
52
+ let result
53
+ try {
54
+ console.error("Installing the current checkout into an isolated pi environment...")
55
+ const install = await runPi(["install", repoRoot, "--no-approve"])
56
+ if (install.status !== 0 || install.signal) {
57
+ result = install
58
+ } else {
59
+ console.error("Starting pi. Temporary auth and sessions will be removed on exit.")
60
+ result = await runPi([
61
+ "--no-approve",
62
+ "--provider",
63
+ "commandcode",
64
+ "--model",
65
+ "gpt-5.6-luna",
66
+ ...process.argv.slice(2),
67
+ ])
68
+ }
69
+ } finally {
70
+ process.removeListener("SIGINT", forwardSigint)
71
+ process.removeListener("SIGTERM", forwardSigterm)
72
+ await rm(testRoot, { recursive: true, force: true })
73
+ console.error("Removed the isolated pi environment.")
74
+ }
75
+
76
+ const signal = receivedSignal ?? result?.signal
77
+ if (signal) process.kill(process.pid, signal)
78
+ process.exitCode = result?.status ?? 1
package/src/converters.ts CHANGED
@@ -3,6 +3,9 @@ import { homedir } from "node:os"
3
3
  import { join } from "node:path"
4
4
 
5
5
  import type { MessageLike, StopReason, ToolLike } from "./types.ts"
6
+ import { toJsonSchema } from "./json-schema.ts"
7
+
8
+ export { toJsonSchema } from "./json-schema.ts"
6
9
 
7
10
  export function isRecord(value: unknown): value is Record<string, unknown> {
8
11
  return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -12,10 +15,6 @@ export function stringValue(value: unknown): string | undefined {
12
15
  return typeof value === "string" ? value : undefined
13
16
  }
14
17
 
15
- function booleanValue(value: unknown): boolean | undefined {
16
- return typeof value === "boolean" ? value : undefined
17
- }
18
-
19
18
  export function recordArray(value: unknown): readonly Record<string, unknown>[] {
20
19
  if (!Array.isArray(value)) return []
21
20
  return value.filter(isRecord)
@@ -56,6 +55,50 @@ function apiKeyFromCredentialRecord(value: unknown): string | undefined {
56
55
  return stringValue(value.key) ?? stringValue(value.access)
57
56
  }
58
57
 
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
+ }
62
+
63
+ function imageContentError(role: string): Error {
64
+ return new Error(`Selected Command Code model does not support image content in ${role}`)
65
+ }
66
+
67
+ export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void {
68
+ for (const message of messages ?? []) {
69
+ if (imageParts(message.content).length > 0) {
70
+ const role = message.role === "toolResult" ? "tool results" : `${message.role} messages`
71
+ throw imageContentError(role)
72
+ }
73
+ }
74
+ }
75
+
76
+ function imageToCommandCode(part: Record<string, unknown>): Record<string, string> {
77
+ const data = stringValue(part.data)
78
+ const mimeType = stringValue(part.mimeType)
79
+ if (!data || !mimeType)
80
+ throw new Error("Invalid image content: expected base64 data and mimeType")
81
+
82
+ return {
83
+ type: "image",
84
+ image: `data:${mimeType};base64,${data}`,
85
+ mimeType,
86
+ }
87
+ }
88
+
89
+ function userContentToCommandCode(content: unknown, allowImages: boolean): unknown {
90
+ if (typeof content === "string") return content
91
+
92
+ return recordArray(content).flatMap((part) => {
93
+ if (part.type === "text") return [{ type: "text", text: stringValue(part.text) ?? "" }]
94
+ if (part.type === "image") {
95
+ if (!allowImages) throw imageContentError("user messages")
96
+ return [imageToCommandCode(part)]
97
+ }
98
+ return []
99
+ })
100
+ }
101
+
59
102
  export function getApiKey(
60
103
  options: {
61
104
  env?: NodeJS.ProcessEnv
@@ -106,80 +149,6 @@ export function getEnvironmentInfo(): string {
106
149
  return `${process.platform}-${process.arch}, Node.js ${process.version}`
107
150
  }
108
151
 
109
- export function toJsonSchema(schema: unknown): unknown {
110
- if (!isRecord(schema)) return {}
111
-
112
- const kind = stringValue(schema.kind) ?? stringValue(schema.type)
113
- const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined
114
- if (enumValues) {
115
- return { type: typeof enumValues[0], enum: enumValues }
116
- }
117
-
118
- switch (kind) {
119
- case "string":
120
- case "String":
121
- return { type: "string" }
122
- case "number":
123
- case "Number":
124
- return { type: "number" }
125
- case "boolean":
126
- case "Boolean":
127
- return { type: "boolean" }
128
- case "object":
129
- case "Object": {
130
- const properties: Record<string, unknown> = {}
131
- const inferredRequired: string[] = []
132
- const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined
133
- const optional = Array.isArray(schema.optional)
134
- ? schema.optional.filter((item): item is string => typeof item === "string")
135
- : []
136
-
137
- if (sourceProperties) {
138
- for (const [key, value] of Object.entries(sourceProperties)) {
139
- properties[key] = toJsonSchema(value)
140
- const valueRecord = isRecord(value) ? value : undefined
141
- if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) {
142
- inferredRequired.push(key)
143
- }
144
- }
145
- }
146
-
147
- const explicitRequired = Array.isArray(schema.required)
148
- ? schema.required.filter((item): item is string => typeof item === "string")
149
- : undefined
150
- const required = explicitRequired ?? inferredRequired
151
- const out: Record<string, unknown> = { type: "object" }
152
- if (Object.keys(properties).length > 0) out.properties = properties
153
- if (required.length > 0) out.required = required
154
- return out
155
- }
156
- case "array":
157
- case "Array":
158
- return {
159
- type: "array",
160
- items: toJsonSchema(schema.items ?? schema.element),
161
- }
162
- case "union":
163
- case "Union": {
164
- const variants = Array.isArray(schema.variants)
165
- ? schema.variants
166
- : Array.isArray(schema.anyOf)
167
- ? schema.anyOf
168
- : []
169
- for (const variant of variants) {
170
- const converted = toJsonSchema(variant)
171
- if (isRecord(converted) && Object.keys(converted).length > 0) return converted
172
- }
173
- return {}
174
- }
175
- case "optional":
176
- case "Optional":
177
- return toJsonSchema(schema.wrapped ?? schema.inner)
178
- default:
179
- return {}
180
- }
181
- }
182
-
183
152
  export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
184
153
  if (!tools) return []
185
154
  return tools.map((tool) => ({
@@ -210,7 +179,13 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
210
179
  return new Set([...callIds].filter((id) => resultIds.has(id)))
211
180
  }
212
181
 
213
- export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
182
+ export function messagesToCC(
183
+ messages?: readonly MessageLike[],
184
+ options: { allowImages?: boolean } = {},
185
+ ): unknown[] {
186
+ const allowImages = options.allowImages ?? false
187
+ if (!allowImages) assertTextOnlyMessages(messages)
188
+
214
189
  const out: unknown[] = []
215
190
  const pairedToolCallIds = completeToolCallIds(messages)
216
191
 
@@ -218,18 +193,13 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
218
193
  if (message.role === "user") {
219
194
  out.push({
220
195
  role: "user",
221
- content: typeof message.content === "string" ? message.content : message.content,
196
+ content: userContentToCommandCode(message.content, allowImages),
222
197
  })
223
198
  } else if (message.role === "assistant") {
224
199
  const parts: unknown[] = []
225
200
  for (const content of recordArray(message.content)) {
226
201
  if (content.type === "text") {
227
202
  parts.push({ type: "text", text: stringValue(content.text) ?? "" })
228
- } else if (content.type === "thinking") {
229
- parts.push({
230
- type: "reasoning",
231
- text: stringValue(content.thinking) ?? "",
232
- })
233
203
  } else if (content.type === "toolCall") {
234
204
  const toolCallId = stringValue(content.id) ?? ""
235
205
  if (!pairedToolCallIds.has(toolCallId)) continue
@@ -257,6 +227,15 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
257
227
  },
258
228
  ],
259
229
  })
230
+
231
+ const images = imageParts(message.content)
232
+ if (images.length > 0) {
233
+ if (!allowImages) throw imageContentError("tool results")
234
+ out.push({
235
+ role: "user",
236
+ content: images.map(imageToCommandCode),
237
+ })
238
+ }
260
239
  }
261
240
  }
262
241
  return out
package/src/core.ts CHANGED
@@ -7,10 +7,13 @@
7
7
 
8
8
  import { randomUUID } from "node:crypto"
9
9
 
10
+ import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts"
11
+ import { modelSupportsImageInput } from "./models.ts"
10
12
  import {
11
13
  getApiKey,
12
14
  getEnvironmentInfo,
13
15
  isRecord,
16
+ assertTextOnlyMessages,
14
17
  mapFinishReason,
15
18
  messagesToCC,
16
19
  numberValue,
@@ -36,10 +39,11 @@ import type {
36
39
  } from "./types.ts"
37
40
 
38
41
  export * from "./converters.ts"
42
+ export * from "./overflow.ts"
39
43
  export * from "./types.ts"
40
44
 
41
45
  export const DEFAULT_API_BASE = "https://api.commandcode.ai"
42
- export const COMMAND_CODE_CLI_VERSION = "0.29.0"
46
+ export const COMMAND_CODE_CLI_VERSION = "1.15.1"
43
47
 
44
48
  const DEFAULT_GENERATE_MAX_TOKENS = 64_000
45
49
  const DEFAULT_MAX_RETRIES = 0
@@ -134,6 +138,15 @@ function generateMaxTokens(model: ModelLike, options?: StreamOptions): number {
134
138
  )
135
139
  }
136
140
 
141
+ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined {
142
+ const level = options?.reasoning
143
+ if (!level || level === "off" || !model.reasoning) return undefined
144
+
145
+ const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap
146
+ const mapped = effortMap?.[level]
147
+ return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
148
+ }
149
+
137
150
  export function projectSlugFromPath(pathName: string): string {
138
151
  const slug = pathName
139
152
  .toLowerCase()
@@ -185,6 +198,31 @@ export function createStreamCommandCode(deps: CoreDependencies) {
185
198
  })
186
199
  }
187
200
 
201
+ function raceAbortWithTimeout<T>(
202
+ promise: Promise<T>,
203
+ controller: AbortController,
204
+ timeoutMs: number | undefined,
205
+ ): Promise<T> {
206
+ if (timeoutMs === undefined) return raceAbort(promise, controller.signal)
207
+
208
+ return new Promise<T>((resolve, reject) => {
209
+ const timer = setTimeout(() => {
210
+ controller.abort()
211
+ reject(timeoutError(timeoutMs))
212
+ }, timeoutMs)
213
+ raceAbort(promise, controller.signal).then(
214
+ (value) => {
215
+ clearTimeout(timer)
216
+ resolve(value)
217
+ },
218
+ (error: unknown) => {
219
+ clearTimeout(timer)
220
+ reject(error)
221
+ },
222
+ )
223
+ })
224
+ }
225
+
188
226
  return function streamCommandCode(
189
227
  model: ModelLike,
190
228
  context: ContextLike,
@@ -388,10 +426,14 @@ export function createStreamCommandCode(deps: CoreDependencies) {
388
426
  const usage = commandCodeUsage(event)
389
427
  if (usage) {
390
428
  const details = commandCodeInputTokenDetails(usage)
391
- output.usage.input = numberValue(usage.inputTokens) ?? 0
429
+ const totalInput = numberValue(usage.inputTokens) ?? 0
430
+ const input = numberValue(details?.noCacheTokens)
431
+ const cacheRead = numberValue(details?.cacheReadTokens) ?? 0
432
+ const cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
433
+ output.usage.input = input ?? Math.max(0, totalInput - cacheRead - cacheWrite)
392
434
  output.usage.output = numberValue(usage.outputTokens) ?? 0
393
- output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0
394
- output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
435
+ output.usage.cacheRead = cacheRead
436
+ output.usage.cacheWrite = cacheWrite
395
437
  output.usage.totalTokens =
396
438
  output.usage.input +
397
439
  output.usage.output +
@@ -405,9 +447,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
405
447
  }
406
448
 
407
449
  case "error": {
408
- const errorRecord = isRecord(event.error) ? event.error : undefined
409
450
  const message =
410
- stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"
451
+ commandCodeErrorMessage(event.error) ??
452
+ commandCodeErrorMessage(event.message) ??
453
+ "Stream error"
411
454
  output.stopReason = "error"
412
455
  output.errorMessage = message
413
456
  throw new Error(message)
@@ -417,9 +460,15 @@ export function createStreamCommandCode(deps: CoreDependencies) {
417
460
 
418
461
  try {
419
462
  stream.push({ type: "start", partial: output })
463
+ if (controller.signal.aborted) throw abortError("Aborted")
420
464
 
421
465
  const workingDir = cwd()
422
466
  const threadId = uuid()
467
+ const reasoningEffort = mappedReasoningEffort(model, options)
468
+ const timeoutMs = options?.timeoutMs
469
+
470
+ const allowImages = modelSupportsImageInput(model.id)
471
+ if (!allowImages) assertTextOnlyMessages(context.messages)
423
472
 
424
473
  let body: unknown = {
425
474
  config: {
@@ -438,25 +487,34 @@ export function createStreamCommandCode(deps: CoreDependencies) {
438
487
  skills: null,
439
488
  params: {
440
489
  model: model.id,
441
- messages: messagesToCC(context.messages),
490
+ messages: messagesToCC(context.messages, { allowImages }),
442
491
  tools: toolsToJson(context.tools),
443
492
  system: systemPromptToText(context.systemPrompt),
444
493
  max_tokens: generateMaxTokens(model, options),
445
494
  temperature: 0.3,
446
495
  stream: true,
496
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
447
497
  },
448
498
  threadId,
449
499
  }
450
500
 
451
- const nextBody = await raceAbort(
452
- Promise.resolve(options?.onPayload?.(body, model)),
453
- controller.signal,
454
- )
501
+ const payloadController = new AbortController()
502
+ const onPayloadAbort = () => payloadController.abort()
503
+ controller.signal.addEventListener("abort", onPayloadAbort, { once: true })
504
+ let nextBody: unknown
505
+ try {
506
+ nextBody = await raceAbortWithTimeout(
507
+ Promise.resolve(options?.onPayload?.(body, model)),
508
+ payloadController,
509
+ timeoutMs,
510
+ )
511
+ } finally {
512
+ controller.signal.removeEventListener("abort", onPayloadAbort)
513
+ }
455
514
  if (nextBody !== undefined) body = nextBody
456
515
 
457
516
  const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
458
517
  const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs)
459
- const timeoutMs = options?.timeoutMs
460
518
  const requestHeaders = {
461
519
  "Content-Type": "application/json",
462
520
  Authorization: `Bearer ${apiKey}`,
@@ -490,6 +548,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
490
548
  }
491
549
  const onOuterAbort = () => attemptController.abort()
492
550
  controller.signal.addEventListener("abort", onOuterAbort, { once: true })
551
+ const raceAttempt = <T>(promise: Promise<T>): Promise<T> =>
552
+ raceAbort(promise, attemptController.signal).catch((error: unknown) => {
553
+ if (attemptTimedOut) throw timeoutError(timeoutMs)
554
+ throw error
555
+ })
493
556
 
494
557
  try {
495
558
  try {
@@ -525,25 +588,38 @@ export function createStreamCommandCode(deps: CoreDependencies) {
525
588
  }
526
589
  }
527
590
 
528
- await raceAbort(
529
- Promise.resolve(
530
- options?.onResponse?.(
531
- {
532
- status: response.status,
533
- headers: headersToRecord(response.headers),
534
- },
535
- model,
591
+ try {
592
+ await raceAttempt(
593
+ Promise.resolve(
594
+ options?.onResponse?.(
595
+ {
596
+ status: response.status,
597
+ headers: headersToRecord(response.headers),
598
+ },
599
+ model,
600
+ ),
536
601
  ),
537
- ),
538
- controller.signal,
539
- )
602
+ )
603
+ } catch (error: unknown) {
604
+ if (attemptTimedOut && attempt < maxRetries) continue retryLoop
605
+ throw error
606
+ }
540
607
 
541
608
  if (!response.ok) {
542
- const errBody = await raceAbort(
543
- response.text().catch(() => ""),
544
- controller.signal,
609
+ const errBody = await raceAttempt(response.text().catch(() => ""))
610
+ let errorDetail: string | undefined
611
+ try {
612
+ const parsedBody: unknown = JSON.parse(errBody)
613
+ errorDetail = commandCodeErrorMessage(parsedBody)
614
+ } catch {
615
+ // Preserve useful plain-text provider errors only after secret
616
+ // redaction; upstream/proxy bodies may echo credentials.
617
+ }
618
+ const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500)
619
+ const detail = redactCommandCodeErrorText(
620
+ errorDetail ?? (safeBody || "Provider returned an error"),
545
621
  )
546
- throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
622
+ throw new Error(`Command Code API error ${response.status}: ${detail}`)
547
623
  }
548
624
 
549
625
  // --- Read response stream ---
@@ -624,9 +700,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
624
700
  output.errorMessage =
625
701
  reason === "aborted"
626
702
  ? "Request aborted"
627
- : error instanceof Error
628
- ? error.message
629
- : String(error)
703
+ : redactCommandCodeErrorText(error instanceof Error ? error.message : String(error))
630
704
  stream.push({ type: "error", reason, error: output })
631
705
  stream.end()
632
706
  } finally {
@@ -653,7 +727,9 @@ export function createStreamCommandCode(deps: CoreDependencies) {
653
727
  model: model.id,
654
728
  usage: defaultUsage(),
655
729
  stopReason: "error",
656
- errorMessage: error instanceof Error ? error.message : String(error),
730
+ errorMessage: redactCommandCodeErrorText(
731
+ error instanceof Error ? error.message : String(error),
732
+ ),
657
733
  timestamp: now(),
658
734
  }
659
735
  stream.push({ type: "error", reason: "error", error: msg })
package/src/cost.ts CHANGED
@@ -10,10 +10,22 @@
10
10
  import type { ModelLike, Usage } from "./types.ts"
11
11
 
12
12
  export function calculateCommandCodeCost(model: ModelLike, usage: Usage): void {
13
- usage.cost.input = (model.cost.input / 1_000_000) * usage.input
14
- usage.cost.output = (model.cost.output / 1_000_000) * usage.output
15
- usage.cost.cacheRead = (model.cost.cacheRead / 1_000_000) * usage.cacheRead
16
- usage.cost.cacheWrite = (model.cost.cacheWrite / 1_000_000) * usage.cacheWrite
13
+ const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite
14
+ let rates = model.cost
15
+ let matchedThreshold = -1
16
+ for (const tier of model.cost.tiers ?? []) {
17
+ if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
18
+ rates = tier
19
+ matchedThreshold = tier.inputTokensAbove
20
+ }
21
+ }
22
+
23
+ const longWrite = usage.cacheWrite1h ?? 0
24
+ const shortWrite = usage.cacheWrite - longWrite
25
+ usage.cost.input = (rates.input / 1_000_000) * usage.input
26
+ usage.cost.output = (rates.output / 1_000_000) * usage.output
27
+ usage.cost.cacheRead = (rates.cacheRead / 1_000_000) * usage.cacheRead
28
+ usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1_000_000
17
29
  usage.cost.total =
18
30
  usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite
19
31
  }