pi-commandcode-provider 0.4.3 → 0.5.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,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,26 @@ function apiKeyFromCredentialRecord(value: unknown): string | undefined {
56
55
  return stringValue(value.key) ?? stringValue(value.access)
57
56
  }
58
57
 
58
+ function hasImageContent(value: unknown): boolean {
59
+ if (isRecord(value)) return value.type === "image"
60
+ return recordArray(value).some((part) => part.type === "image")
61
+ }
62
+
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
+ )
67
+ }
68
+
69
+ export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void {
70
+ 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)
74
+ }
75
+ }
76
+ }
77
+
59
78
  export function getApiKey(
60
79
  options: {
61
80
  env?: NodeJS.ProcessEnv
@@ -96,6 +115,8 @@ export function getApiKey(
96
115
  }
97
116
 
98
117
  export function textContent(message: { content?: unknown }): string {
118
+ if (hasImageContent(message.content)) throw imageContentError("tool results")
119
+
99
120
  return recordArray(message.content)
100
121
  .filter((part) => part.type === "text")
101
122
  .map((part) => stringValue(part.text) ?? "")
@@ -106,80 +127,6 @@ export function getEnvironmentInfo(): string {
106
127
  return `${process.platform}-${process.arch}, Node.js ${process.version}`
107
128
  }
108
129
 
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
130
  export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
184
131
  if (!tools) return []
185
132
  return tools.map((tool) => ({
@@ -211,6 +158,8 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
211
158
  }
212
159
 
213
160
  export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
161
+ assertTextOnlyMessages(messages)
162
+
214
163
  const out: unknown[] = []
215
164
  const pairedToolCallIds = completeToolCallIds(messages)
216
165
 
@@ -225,11 +174,6 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
225
174
  for (const content of recordArray(message.content)) {
226
175
  if (content.type === "text") {
227
176
  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
177
  } else if (content.type === "toolCall") {
234
178
  const toolCallId = stringValue(content.id) ?? ""
235
179
  if (!pairedToolCallIds.has(toolCallId)) continue
package/src/core.ts CHANGED
@@ -7,10 +7,12 @@
7
7
 
8
8
  import { randomUUID } from "node:crypto"
9
9
 
10
+ import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./overflow.ts"
10
11
  import {
11
12
  getApiKey,
12
13
  getEnvironmentInfo,
13
14
  isRecord,
15
+ assertTextOnlyMessages,
14
16
  mapFinishReason,
15
17
  messagesToCC,
16
18
  numberValue,
@@ -36,10 +38,17 @@ import type {
36
38
  } from "./types.ts"
37
39
 
38
40
  export * from "./converters.ts"
41
+ export * from "./overflow.ts"
39
42
  export * from "./types.ts"
40
43
 
41
44
  export const DEFAULT_API_BASE = "https://api.commandcode.ai"
42
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
43
52
 
44
53
  const DEFAULT_GENERATE_MAX_TOKENS = 64_000
45
54
  const DEFAULT_MAX_RETRIES = 0
@@ -134,6 +143,15 @@ function generateMaxTokens(model: ModelLike, options?: StreamOptions): number {
134
143
  )
135
144
  }
136
145
 
146
+ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined {
147
+ const level = options?.reasoning
148
+ if (!level || level === "off" || !model.reasoning) return undefined
149
+
150
+ const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap
151
+ const mapped = effortMap?.[level]
152
+ return typeof mapped === "string" && mapped !== "off" ? mapped : undefined
153
+ }
154
+
137
155
  export function projectSlugFromPath(pathName: string): string {
138
156
  const slug = pathName
139
157
  .toLowerCase()
@@ -185,6 +203,31 @@ export function createStreamCommandCode(deps: CoreDependencies) {
185
203
  })
186
204
  }
187
205
 
206
+ function raceAbortWithTimeout<T>(
207
+ promise: Promise<T>,
208
+ controller: AbortController,
209
+ timeoutMs: number | undefined,
210
+ ): Promise<T> {
211
+ if (timeoutMs === undefined) return raceAbort(promise, controller.signal)
212
+
213
+ return new Promise<T>((resolve, reject) => {
214
+ const timer = setTimeout(() => {
215
+ controller.abort()
216
+ reject(timeoutError(timeoutMs))
217
+ }, timeoutMs)
218
+ raceAbort(promise, controller.signal).then(
219
+ (value) => {
220
+ clearTimeout(timer)
221
+ resolve(value)
222
+ },
223
+ (error: unknown) => {
224
+ clearTimeout(timer)
225
+ reject(error)
226
+ },
227
+ )
228
+ })
229
+ }
230
+
188
231
  return function streamCommandCode(
189
232
  model: ModelLike,
190
233
  context: ContextLike,
@@ -388,10 +431,14 @@ export function createStreamCommandCode(deps: CoreDependencies) {
388
431
  const usage = commandCodeUsage(event)
389
432
  if (usage) {
390
433
  const details = commandCodeInputTokenDetails(usage)
391
- output.usage.input = numberValue(usage.inputTokens) ?? 0
434
+ const totalInput = numberValue(usage.inputTokens) ?? 0
435
+ const input = numberValue(details?.noCacheTokens)
436
+ const cacheRead = numberValue(details?.cacheReadTokens) ?? 0
437
+ const cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
438
+ output.usage.input = input ?? Math.max(0, totalInput - cacheRead - cacheWrite)
392
439
  output.usage.output = numberValue(usage.outputTokens) ?? 0
393
- output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0
394
- output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
440
+ output.usage.cacheRead = cacheRead
441
+ output.usage.cacheWrite = cacheWrite
395
442
  output.usage.totalTokens =
396
443
  output.usage.input +
397
444
  output.usage.output +
@@ -405,9 +452,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
405
452
  }
406
453
 
407
454
  case "error": {
408
- const errorRecord = isRecord(event.error) ? event.error : undefined
409
455
  const message =
410
- stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"
456
+ commandCodeErrorMessage(event.error) ??
457
+ commandCodeErrorMessage(event.message) ??
458
+ "Stream error"
411
459
  output.stopReason = "error"
412
460
  output.errorMessage = message
413
461
  throw new Error(message)
@@ -417,9 +465,14 @@ export function createStreamCommandCode(deps: CoreDependencies) {
417
465
 
418
466
  try {
419
467
  stream.push({ type: "start", partial: output })
468
+ if (controller.signal.aborted) throw abortError("Aborted")
420
469
 
421
470
  const workingDir = cwd()
422
471
  const threadId = uuid()
472
+ const reasoningEffort = mappedReasoningEffort(model, options)
473
+ const timeoutMs = options?.timeoutMs
474
+
475
+ assertTextOnlyMessages(context.messages)
423
476
 
424
477
  let body: unknown = {
425
478
  config: {
@@ -444,19 +497,28 @@ export function createStreamCommandCode(deps: CoreDependencies) {
444
497
  max_tokens: generateMaxTokens(model, options),
445
498
  temperature: 0.3,
446
499
  stream: true,
500
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
447
501
  },
448
502
  threadId,
449
503
  }
450
504
 
451
- const nextBody = await raceAbort(
452
- Promise.resolve(options?.onPayload?.(body, model)),
453
- controller.signal,
454
- )
505
+ const payloadController = new AbortController()
506
+ const onPayloadAbort = () => payloadController.abort()
507
+ controller.signal.addEventListener("abort", onPayloadAbort, { once: true })
508
+ let nextBody: unknown
509
+ try {
510
+ nextBody = await raceAbortWithTimeout(
511
+ Promise.resolve(options?.onPayload?.(body, model)),
512
+ payloadController,
513
+ timeoutMs,
514
+ )
515
+ } finally {
516
+ controller.signal.removeEventListener("abort", onPayloadAbort)
517
+ }
455
518
  if (nextBody !== undefined) body = nextBody
456
519
 
457
520
  const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
458
521
  const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs)
459
- const timeoutMs = options?.timeoutMs
460
522
  const requestHeaders = {
461
523
  "Content-Type": "application/json",
462
524
  Authorization: `Bearer ${apiKey}`,
@@ -490,6 +552,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
490
552
  }
491
553
  const onOuterAbort = () => attemptController.abort()
492
554
  controller.signal.addEventListener("abort", onOuterAbort, { once: true })
555
+ const raceAttempt = <T>(promise: Promise<T>): Promise<T> =>
556
+ raceAbort(promise, attemptController.signal).catch((error: unknown) => {
557
+ if (attemptTimedOut) throw timeoutError(timeoutMs)
558
+ throw error
559
+ })
493
560
 
494
561
  try {
495
562
  try {
@@ -525,25 +592,38 @@ export function createStreamCommandCode(deps: CoreDependencies) {
525
592
  }
526
593
  }
527
594
 
528
- await raceAbort(
529
- Promise.resolve(
530
- options?.onResponse?.(
531
- {
532
- status: response.status,
533
- headers: headersToRecord(response.headers),
534
- },
535
- model,
595
+ try {
596
+ await raceAttempt(
597
+ Promise.resolve(
598
+ options?.onResponse?.(
599
+ {
600
+ status: response.status,
601
+ headers: headersToRecord(response.headers),
602
+ },
603
+ model,
604
+ ),
536
605
  ),
537
- ),
538
- controller.signal,
539
- )
606
+ )
607
+ } catch (error: unknown) {
608
+ if (attemptTimedOut && attempt < maxRetries) continue retryLoop
609
+ throw error
610
+ }
540
611
 
541
612
  if (!response.ok) {
542
- const errBody = await raceAbort(
543
- response.text().catch(() => ""),
544
- controller.signal,
613
+ const errBody = await raceAttempt(response.text().catch(() => ""))
614
+ let errorDetail: string | undefined
615
+ try {
616
+ const parsedBody: unknown = JSON.parse(errBody)
617
+ errorDetail = commandCodeErrorMessage(parsedBody)
618
+ } catch {
619
+ // Preserve useful plain-text provider errors only after secret
620
+ // redaction; upstream/proxy bodies may echo credentials.
621
+ }
622
+ const safeBody = redactCommandCodeErrorText(errBody).slice(0, 500)
623
+ const detail = redactCommandCodeErrorText(
624
+ errorDetail ?? (safeBody || "Provider returned an error"),
545
625
  )
546
- throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
626
+ throw new Error(`Command Code API error ${response.status}: ${detail}`)
547
627
  }
548
628
 
549
629
  // --- Read response stream ---
@@ -624,9 +704,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
624
704
  output.errorMessage =
625
705
  reason === "aborted"
626
706
  ? "Request aborted"
627
- : error instanceof Error
628
- ? error.message
629
- : String(error)
707
+ : redactCommandCodeErrorText(error instanceof Error ? error.message : String(error))
630
708
  stream.push({ type: "error", reason, error: output })
631
709
  stream.end()
632
710
  } finally {
@@ -653,7 +731,9 @@ export function createStreamCommandCode(deps: CoreDependencies) {
653
731
  model: model.id,
654
732
  usage: defaultUsage(),
655
733
  stopReason: "error",
656
- errorMessage: error instanceof Error ? error.message : String(error),
734
+ errorMessage: redactCommandCodeErrorText(
735
+ error instanceof Error ? error.message : String(error),
736
+ ),
657
737
  timestamp: now(),
658
738
  }
659
739
  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
  }