pi-commandcode-provider 0.4.2 → 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.
package/src/models.ts CHANGED
@@ -1,6 +1,97 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+
1
4
  export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models"
5
+ export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
2
6
 
3
7
  const DEFAULT_MAX_OUTPUT_TOKENS = 65_536
8
+ const MODEL_CACHE_VERSION = 1
9
+
10
+ export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
11
+
12
+ type CommandCodeReasoningEffort = Exclude<PiThinkingLevel, "off">
13
+
14
+ /**
15
+ * Per-model reasoning efforts supported by Command Code's generate endpoint.
16
+ *
17
+ * The Provider API does not expose reasoning metadata. This is an exact
18
+ * snapshot of `reasoningEfforts` from the command-code@1.14.1 model catalog
19
+ * (`packages/shared/src/model-catalog.ts`, also published in the generated
20
+ * `dist/bundled/command-code-knowledge/reference/models.md`). Models omitted
21
+ * here let Command Code choose their reasoning depth, matching the CLI.
22
+ */
23
+ export const MODEL_EFFORTS: Readonly<Record<string, readonly CommandCodeReasoningEffort[]>> = {
24
+ "Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"],
25
+ "claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
26
+ "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"],
27
+ "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"],
28
+ "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
29
+ "claude-sonnet-4-6": ["low", "medium", "high", "xhigh", "max"],
30
+ "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
31
+ "deepseek/deepseek-v4-flash": ["high", "max"],
32
+ "deepseek/deepseek-v4-pro": ["high", "max"],
33
+ "gpt-5.3-codex": ["low", "medium", "high", "xhigh"],
34
+ "gpt-5.4": ["low", "medium", "high", "xhigh"],
35
+ "gpt-5.4-mini": ["low", "medium", "high"],
36
+ "gpt-5.5": ["low", "medium", "high", "xhigh"],
37
+ "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"],
38
+ "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"],
39
+ "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"],
40
+ "google/gemini-3.1-flash-lite": ["low", "medium", "high"],
41
+ "google/gemini-3.5-flash": ["low", "medium", "high"],
42
+ "google/gemini-3.5-flash-lite": ["low", "medium", "high"],
43
+ "google/gemini-3.6-flash": ["low", "medium", "high"],
44
+ "sakana/fugu-ultra": ["high", "xhigh"],
45
+ "xai/grok-4.5": ["low", "medium", "high"],
46
+ "zai-org/GLM-5.2": ["high", "max"],
47
+ }
48
+
49
+ const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [
50
+ "off",
51
+ "minimal",
52
+ "low",
53
+ "medium",
54
+ "high",
55
+ "xhigh",
56
+ "max",
57
+ ]
58
+
59
+ export function thinkingLevelMapForEfforts(
60
+ efforts: readonly string[],
61
+ ): Partial<Record<PiThinkingLevel, string | null>> {
62
+ const map: Partial<Record<PiThinkingLevel, string | null>> = {}
63
+ for (const level of PI_THINKING_LEVELS) {
64
+ if (level === "off") continue
65
+ map[level] = efforts.includes(level) ? level : null
66
+ }
67
+ return map
68
+ }
69
+
70
+ export interface ThinkingMetadata {
71
+ thinkingLevelMap: Partial<Record<PiThinkingLevel, string | null>>
72
+ thinking: {
73
+ mode: "effort"
74
+ effortMap: Partial<Record<CommandCodeReasoningEffort, string>>
75
+ efforts: readonly CommandCodeReasoningEffort[]
76
+ }
77
+ }
78
+
79
+ export function thinkingMetadataForModel(modelId: string): ThinkingMetadata | undefined {
80
+ const efforts = MODEL_EFFORTS[modelId]
81
+ if (!efforts) return undefined
82
+ return {
83
+ thinkingLevelMap: thinkingLevelMapForEfforts(efforts),
84
+ thinking: {
85
+ mode: "effort",
86
+ effortMap: Object.fromEntries(efforts.map((effort) => [effort, effort])),
87
+ efforts,
88
+ },
89
+ }
90
+ }
91
+
92
+ function isReasoningModel(modelId: string): boolean {
93
+ return MODEL_EFFORTS[modelId] !== undefined
94
+ }
4
95
 
5
96
  interface ApiModel {
6
97
  id: string
@@ -19,21 +110,43 @@ export interface CommandCodeModel {
19
110
  interface FetchCommandCodeModelsOptions {
20
111
  url?: string
21
112
  fetchImpl?: typeof fetch
113
+ signal?: AbortSignal
114
+ timeoutMs?: number
115
+ }
116
+
117
+ interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions {
118
+ cachePath: string
119
+ }
120
+
121
+ export interface LoadCommandCodeModelsResult {
122
+ models: readonly CommandCodeModel[]
123
+ source: "live" | "cache" | "empty"
124
+ warning?: string
22
125
  }
23
126
 
24
127
  function isRecord(value: unknown): value is Record<string, unknown> {
25
- return typeof value === "object" && value !== null
128
+ return typeof value === "object" && value !== null && !Array.isArray(value)
26
129
  }
27
130
 
28
131
  function stringField(record: Record<string, unknown>, key: string): string {
29
132
  const value = record[key]
30
- if (typeof value !== "string") throw new Error(`Expected ${key} to be a string`)
133
+ if (typeof value !== "string" || value.length === 0) {
134
+ throw new Error(`Expected ${key} to be a non-empty string`)
135
+ }
136
+ return value
137
+ }
138
+
139
+ function booleanField(record: Record<string, unknown>, key: string): boolean {
140
+ const value = record[key]
141
+ if (typeof value !== "boolean") throw new Error(`Expected ${key} to be a boolean`)
31
142
  return value
32
143
  }
33
144
 
34
- function numberField(record: Record<string, unknown>, key: string): number {
145
+ function positiveNumberField(record: Record<string, unknown>, key: string): number {
35
146
  const value = record[key]
36
- if (typeof value !== "number") throw new Error(`Expected ${key} to be a number`)
147
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
148
+ throw new Error(`Expected ${key} to be a positive number`)
149
+ }
37
150
  return value
38
151
  }
39
152
 
@@ -43,10 +156,112 @@ function parseApiModel(value: unknown): ApiModel {
43
156
  return {
44
157
  id: stringField(value, "id"),
45
158
  name: stringField(value, "name"),
46
- contextLength: numberField(value, "context_length"),
159
+ contextLength: positiveNumberField(value, "context_length"),
47
160
  }
48
161
  }
49
162
 
163
+ function parseCachedModel(value: unknown): CommandCodeModel {
164
+ if (!isRecord(value)) throw new Error("Expected cached model entry to be an object")
165
+
166
+ const id = stringField(value, "id")
167
+ booleanField(value, "reasoning")
168
+ return {
169
+ id,
170
+ name: stringField(value, "name"),
171
+ reasoning: isReasoningModel(id),
172
+ contextWindow: positiveNumberField(value, "contextWindow"),
173
+ maxTokens: positiveNumberField(value, "maxTokens"),
174
+ }
175
+ }
176
+
177
+ function requireModels(models: readonly CommandCodeModel[]): readonly CommandCodeModel[] {
178
+ if (models.length === 0) throw new Error("Command Code returned an empty model catalog")
179
+ return models
180
+ }
181
+
182
+ function errorMessage(error: unknown): string {
183
+ return error instanceof Error ? error.message : String(error)
184
+ }
185
+
186
+ function abortError(reason: unknown): Error {
187
+ if (reason instanceof Error) return reason
188
+ return new DOMException("The operation was aborted", "AbortError")
189
+ }
190
+
191
+ function configuredTimeoutMs(timeoutMs: number | undefined): number {
192
+ return timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0
193
+ ? timeoutMs
194
+ : DEFAULT_MODELS_TIMEOUT_MS
195
+ }
196
+
197
+ export function getModelsTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
198
+ const raw = env.COMMANDCODE_MODELS_TIMEOUT_MS
199
+ if (!raw) return DEFAULT_MODELS_TIMEOUT_MS
200
+
201
+ const parsed = Number(raw)
202
+ return configuredTimeoutMs(parsed)
203
+ }
204
+
205
+ class ModelDiscoveryTimeoutError extends Error {
206
+ constructor(timeoutMs: number) {
207
+ super(`Command Code model discovery timed out after ${timeoutMs}ms`)
208
+ this.name = "ModelDiscoveryTimeoutError"
209
+ }
210
+ }
211
+
212
+ function runWithTimeout<T>(
213
+ operation: (signal: AbortSignal) => Promise<T>,
214
+ timeoutMs: number,
215
+ externalSignal: AbortSignal | undefined,
216
+ ): Promise<T> {
217
+ const controller = new AbortController()
218
+ let timer: ReturnType<typeof setTimeout> | undefined
219
+ let settled = false
220
+ let onExternalAbort: (() => void) | undefined
221
+
222
+ return new Promise<T>((resolve, reject) => {
223
+ const cleanup = () => {
224
+ if (timer !== undefined) clearTimeout(timer)
225
+ if (onExternalAbort && externalSignal) {
226
+ externalSignal.removeEventListener("abort", onExternalAbort)
227
+ }
228
+ }
229
+
230
+ const resolveOnce = (value: T) => {
231
+ if (settled) return
232
+ settled = true
233
+ cleanup()
234
+ resolve(value)
235
+ }
236
+
237
+ const rejectOnce = (error: unknown) => {
238
+ if (settled) return
239
+ settled = true
240
+ cleanup()
241
+ reject(error)
242
+ }
243
+
244
+ const abort = (reason: unknown) => {
245
+ const error = abortError(reason)
246
+ controller.abort(error)
247
+ rejectOnce(error)
248
+ }
249
+
250
+ if (externalSignal?.aborted) {
251
+ abort(externalSignal.reason)
252
+ return
253
+ }
254
+
255
+ onExternalAbort = () => abort(externalSignal?.reason)
256
+ externalSignal?.addEventListener("abort", onExternalAbort, { once: true })
257
+ timer = setTimeout(() => abort(new ModelDiscoveryTimeoutError(timeoutMs)), timeoutMs)
258
+
259
+ Promise.resolve()
260
+ .then(() => operation(controller.signal))
261
+ .then(resolveOnce, rejectOnce)
262
+ })
263
+ }
264
+
50
265
  export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] {
51
266
  if (!isRecord(value)) throw new Error("Expected models response to be an object")
52
267
  if (value.object !== "list") throw new Error("Expected models response object to be 'list'")
@@ -57,29 +272,113 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma
57
272
  return data.map(parseApiModel).map((model) => ({
58
273
  id: model.id,
59
274
  name: `${model.name} (CC)`,
60
- reasoning: true,
275
+ reasoning: isReasoningModel(model.id),
61
276
  contextWindow: model.contextLength,
62
277
  maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS),
63
278
  }))
64
279
  }
65
280
 
281
+ export function commandCodeModelsFromCache(value: unknown): readonly CommandCodeModel[] {
282
+ if (!isRecord(value)) throw new Error("Expected model cache to be an object")
283
+ if (value.version !== MODEL_CACHE_VERSION) {
284
+ throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
285
+ }
286
+ if (!Array.isArray(value.models)) throw new Error("Expected cached models to be an array")
287
+
288
+ return requireModels(value.models.map(parseCachedModel))
289
+ }
290
+
66
291
  export async function fetchCommandCodeModels(
67
292
  options: FetchCommandCodeModelsOptions = {},
68
293
  ): Promise<readonly CommandCodeModel[]> {
69
294
  const url = options.url ?? DEFAULT_MODELS_URL
70
295
  const fetchImpl = options.fetchImpl ?? fetch
71
- const response = await fetchImpl(url, {
72
- headers: {
73
- accept: "application/json",
296
+ const body: unknown = await runWithTimeout(
297
+ async (signal) => {
298
+ const response = await fetchImpl(url, {
299
+ headers: {
300
+ accept: "application/json",
301
+ },
302
+ signal,
303
+ })
304
+
305
+ if (!response.ok) {
306
+ throw new Error(
307
+ `Failed to fetch Command Code models: ${response.status} ${response.statusText}`,
308
+ )
309
+ }
310
+
311
+ return await response.json()
74
312
  },
75
- })
313
+ configuredTimeoutMs(options.timeoutMs),
314
+ options.signal,
315
+ )
316
+ return requireModels(commandCodeModelsFromApiResponse(body))
317
+ }
318
+
319
+ async function readCommandCodeModelsCache(cachePath: string): Promise<readonly CommandCodeModel[]> {
320
+ const contents = await readFile(cachePath, "utf-8")
321
+ const parsed: unknown = JSON.parse(contents)
322
+ return commandCodeModelsFromCache(parsed)
323
+ }
76
324
 
77
- if (!response.ok) {
78
- throw new Error(
79
- `Failed to fetch Command Code models: ${response.status} ${response.statusText}`,
325
+ async function writeCommandCodeModelsCache(
326
+ cachePath: string,
327
+ models: readonly CommandCodeModel[],
328
+ ): Promise<void> {
329
+ await mkdir(dirname(cachePath), { recursive: true })
330
+ const temporaryPath = `${cachePath}.${process.pid}.tmp`
331
+
332
+ try {
333
+ await writeFile(
334
+ temporaryPath,
335
+ `${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`,
336
+ { encoding: "utf-8", mode: 0o600 },
80
337
  )
338
+ await rename(temporaryPath, cachePath)
339
+ } finally {
340
+ try {
341
+ await rm(temporaryPath, { force: true })
342
+ } catch {
343
+ // Best-effort cleanup must not hide the original cache write error.
344
+ }
81
345
  }
346
+ }
82
347
 
83
- const body: unknown = await response.json()
84
- return commandCodeModelsFromApiResponse(body)
348
+ export async function loadCommandCodeModels(
349
+ options: LoadCommandCodeModelsOptions,
350
+ ): Promise<LoadCommandCodeModelsResult> {
351
+ const cachePath = options.cachePath
352
+
353
+ try {
354
+ const models = await fetchCommandCodeModels(options)
355
+
356
+ try {
357
+ await writeCommandCodeModelsCache(cachePath, models)
358
+ return { models, source: "live" }
359
+ } catch (error) {
360
+ return {
361
+ models,
362
+ source: "live",
363
+ warning: `Loaded the live Command Code model catalog but could not update ${cachePath}: ${errorMessage(error)}`,
364
+ }
365
+ }
366
+ } catch (liveError) {
367
+ if (options.signal?.aborted) throw abortError(options.signal.reason ?? liveError)
368
+
369
+ try {
370
+ const models = await readCommandCodeModelsCache(cachePath)
371
+ return {
372
+ models,
373
+ source: "cache",
374
+ warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`,
375
+ }
376
+ } catch (cacheError) {
377
+ return {
378
+ models: [],
379
+ source: "empty",
380
+ warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). Command Code models will remain unavailable until /commandcode-refresh succeeds.`,
381
+ }
382
+ }
383
+ }
85
384
  }
@@ -0,0 +1,120 @@
1
+ const COMMAND_CODE_PROVIDER = "commandcode"
2
+ const CONTEXT_OVERFLOW_PREFIX = "context_length_exceeded:"
3
+
4
+ const COMMAND_CODE_OVERFLOW_PATTERNS = [
5
+ /\b(?:context[_\s-]*(?:length|window)|model[_\s-]*context[_\s-]*window)[_\s-]*(?:exceeded|overflow(?:ed)?|too[_\s-]*(?:large|long))\b/i,
6
+ /\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b[\s\S]{0,120}\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long)|(?:maximum|limit)\s+(?:reached|exceeded|hit))\b/i,
7
+ /\b(?:exceed(?:ed|s)?|overflow(?:ed|s)?|too\s+(?:large|long))\b[\s\S]{0,120}\b(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?|limit|maximum)\b/i,
8
+ /\b(?:prompt|input|context)\b[\s\S]{0,32}\btoo\s+(?:large|long)\b/i,
9
+ /\b(?:prompt|input)[_\s-]*too[_\s-]*(?:large|long)\b/i,
10
+ /\b(?:prompt|input)[_\s-]*tokens?[_\s-]*(?:limit|maximum|max)[_\s-]*(?:exceeded|reached)\b/i,
11
+ /\b(?:prompt|input)[_\s-]*(?:tokens?|length|size)\b[\s\S]{0,120}\b(?:limit|maximum)\b[\s\S]{0,40}\b(?:exceed(?:ed|s)?|reached|hit)\b/i,
12
+ /\b(?:maximum|limit)[_\s-]+(?:allowed[_\s-]+)?(?:context|prompt|input)[_\s-]*(?:length|window|size|tokens?)\b/i,
13
+ ]
14
+
15
+ const NON_OVERFLOW_PATTERNS = [
16
+ /\brate[_\s-]*limit\b/i,
17
+ /\btoo\s+many\s+requests\b/i,
18
+ /\b(?:capacity|quota|throttl(?:e|ed|ing)?|concurren(?:cy|t)|overloaded)\b/i,
19
+ /\b(?:service|temporarily)\s+unavailable\b/i,
20
+ /\bstatus(?:[_\s-]*code)?\s*[:=]\s*429\b/i,
21
+ ]
22
+
23
+ const CONTEXT_OVERFLOW_PREFIX_PATTERN = /context_length_exceeded/i
24
+
25
+ const HTTP_RATE_LIMIT_STATUS_PATTERNS = [
26
+ /\b(?:api\s+error|http|status(?:[_\s-]*code)?|status[_\s-]*code)\s*[:(]?\s*429\b/i,
27
+ /["']?(?:status|status[_\s-]*code)["']?\s*:\s*429\b/i,
28
+ ]
29
+
30
+ const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi
31
+ const CREDENTIAL_PATTERN =
32
+ /\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;)]+/gi
33
+ const USER_TOKEN_PATTERN = /\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi
34
+ const QUERY_SECRET_PATTERN =
35
+ /([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi
36
+ const STANDALONE_SECRET_PATTERN =
37
+ /\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g
38
+
39
+ export function redactCommandCodeErrorText(value: string): string {
40
+ return value
41
+ .replace(BEARER_PATTERN, "Bearer [redacted]")
42
+ .replace(CREDENTIAL_PATTERN, (match) => {
43
+ const separatorIndex = match.search(/[=:]/)
44
+ return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]`
45
+ })
46
+ .replace(USER_TOKEN_PATTERN, "[redacted]")
47
+ .replace(QUERY_SECRET_PATTERN, "$1[redacted]")
48
+ .replace(STANDALONE_SECRET_PATTERN, "[redacted]")
49
+ }
50
+
51
+ function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === "object" && value !== null
53
+ }
54
+
55
+ export interface CommandCodeMessageLike {
56
+ role: string
57
+ provider: string
58
+ stopReason: string
59
+ errorMessage?: string
60
+ }
61
+
62
+ export function commandCodeErrorMessage(value: unknown): string | undefined {
63
+ if (typeof value === "string") return value
64
+ if (!isRecord(value)) return undefined
65
+
66
+ const record = value
67
+ const parts: string[] = []
68
+ for (const key of [
69
+ "message",
70
+ "errorMessage",
71
+ "error",
72
+ "detail",
73
+ "details",
74
+ "code",
75
+ "type",
76
+ "reason",
77
+ ]) {
78
+ const part = commandCodeErrorMessage(record[key])
79
+ if (part && !parts.includes(part)) parts.push(part)
80
+ }
81
+
82
+ for (const key of ["status", "statusCode", "httpStatus"]) {
83
+ const status = record[key]
84
+ if (typeof status === "string" || typeof status === "number") {
85
+ const statusPart = `status: ${status}`
86
+ if (!parts.includes(statusPart)) parts.push(statusPart)
87
+ }
88
+ }
89
+
90
+ return parts.length > 0 ? redactCommandCodeErrorText(parts.join(": ")) : undefined
91
+ }
92
+
93
+ export function normalizeCommandCodeErrorMessage(
94
+ errorMessage: string | undefined,
95
+ ): string | undefined {
96
+ if (!errorMessage) return undefined
97
+ if (CONTEXT_OVERFLOW_PREFIX_PATTERN.test(errorMessage)) return undefined
98
+ if (NON_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))) return undefined
99
+ if (HTTP_RATE_LIMIT_STATUS_PATTERNS.some((pattern) => pattern.test(errorMessage)))
100
+ return undefined
101
+ if (!COMMAND_CODE_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage)))
102
+ return undefined
103
+
104
+ return `${CONTEXT_OVERFLOW_PREFIX} ${errorMessage}`
105
+ }
106
+
107
+ export function normalizeCommandCodeMessage<T extends CommandCodeMessageLike>(
108
+ message: T,
109
+ modelProvider?: string,
110
+ ): { message: T & { errorMessage: string } } | undefined {
111
+ if (message.role !== "assistant" || message.stopReason !== "error") return undefined
112
+ if (message.provider !== COMMAND_CODE_PROVIDER && modelProvider !== COMMAND_CODE_PROVIDER) {
113
+ return undefined
114
+ }
115
+
116
+ const errorMessage = normalizeCommandCodeErrorMessage(message.errorMessage)
117
+ if (!errorMessage) return undefined
118
+
119
+ return { message: { ...message, errorMessage } }
120
+ }