pi-commandcode-provider 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.4.1 - 2026-06-16
6
+
7
+ - Use the explicit `$COMMANDCODE_API_KEY` provider registration syntax expected by newer pi versions, removing the startup deprecation warning while keeping legacy placeholder compatibility.
8
+ - Refresh development dependency lockfile entries to resolve npm audit findings for `tsx`/`esbuild` and `protobufjs`.
9
+
10
+ ## 0.4.0 - 2026-06-02
11
+
12
+ - Add retry mechanism for transient HTTP errors (429, 5xx) and stream-level errors, configurable via pi `settings.json` `retry.provider` fields (`timeoutMs`, `maxRetries`, `maxRetryDelayMs`). Supports exponential backoff with jitter and `Retry-After` header.
13
+
3
14
  ## 0.3.1 - 2026-05-29
4
15
 
5
16
  - Bump CLI version header to `0.29.0` for Command Code API parity.
package/RELEASE.md CHANGED
@@ -7,7 +7,8 @@ Recommended flow:
7
7
  - publish prereleases with the `next` dist-tag
8
8
  - smoke-test the npm package directly in pi
9
9
  - publish stable releases with the `latest` dist-tag
10
- - commit and tag the stable release
10
+ - commit the release on a branch, open a PR, and merge after CI passes
11
+ - tag the stable release on `main` after merge
11
12
  - comment on the related PR or issue after shipping
12
13
 
13
14
  ## Prerelease flow
@@ -144,21 +145,41 @@ npm pack --dry-run
144
145
  git diff --check
145
146
  ```
146
147
 
147
- Commit and tag:
148
+ Commit on a release branch and open a PR:
148
149
 
149
150
  ```sh
151
+ git checkout -b release/0.1.1
150
152
  git add .
151
153
  git commit -m "Release 0.1.1"
154
+ git push origin release/0.1.1
155
+ gh pr create --title "chore(release): publish 0.1.1" --base main
156
+ ```
157
+
158
+ `main` is branch-protected. The release must go through a PR with passing CI.
159
+
160
+ Once CI passes, approve and merge:
161
+
162
+ ```sh
163
+ gh pr review <number> --approve
164
+ gh pr merge <number> --squash --delete-branch
165
+ ```
166
+
167
+ After merge, pull `main` and tag locally:
168
+
169
+ ```sh
170
+ git checkout main
171
+ git pull origin main
152
172
  git tag -a v0.1.1 -m "Release 0.1.1"
173
+ git push origin v0.1.1
153
174
  ```
154
175
 
155
- Publish stable:
176
+ Publish stable locally:
156
177
 
157
178
  ```sh
158
179
  npm publish --tag latest --access public
159
180
  ```
160
181
 
161
- If npm asks for browser or OTP auth, run the publish command manually and complete the npm prompt.
182
+ Publishing is intentionally manual/local; there is no GitHub Actions publish workflow. If npm asks for browser or OTP auth, complete the npm prompt locally.
162
183
 
163
184
  Verify npm:
164
185
 
@@ -172,13 +193,6 @@ Expected:
172
193
  - `latest` points to the stable version
173
194
  - the stable version exists on npm
174
195
 
175
- Push commit and tag:
176
-
177
- ```sh
178
- git push origin main
179
- git push origin v0.1.1
180
- ```
181
-
182
196
  ## GitHub follow-up
183
197
 
184
198
  Comment on the related PR and issue after publishing and pushing:
package/index.ts CHANGED
@@ -83,7 +83,7 @@ export default async function (pi: ExtensionAPI) {
83
83
  pi.registerProvider("commandcode", {
84
84
  name: "Command Code",
85
85
  baseUrl: API_BASE,
86
- apiKey: "COMMANDCODE_API_KEY",
86
+ apiKey: "$COMMANDCODE_API_KEY",
87
87
  authHeader: true,
88
88
  api: "commandcode-custom",
89
89
  streamSimple: streamCommandCode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-commandcode-provider",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "pi custom provider for Command Code API (commandcode.ai)",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -28,7 +28,7 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "scripts": {
31
- "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
31
+ "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs",
32
32
  "typecheck": "tsc --noEmit",
33
33
  "format:check": "prettier --check '**/*.{ts,mjs,json,md}'",
34
34
  "format": "prettier --write '**/*.{ts,mjs,json,md}'",
@@ -38,6 +38,7 @@
38
38
  "test:oauth": "tsx tests/test-oauth.ts",
39
39
  "test:abort": "tsx tests/test-abort.ts",
40
40
  "test:stream": "tsx tests/test-stream.ts",
41
+ "test:retry": "tsx tests/test-retry.ts",
41
42
  "test:pi-local": "node tests/test-pi-local.mjs",
42
43
  "test:smoke": "node tests/test-smoke.mjs"
43
44
  },
@@ -49,7 +50,7 @@
49
50
  "devDependencies": {
50
51
  "@types/node": "25.6.0",
51
52
  "prettier": "^3.5.0",
52
- "tsx": "4.21.0",
53
+ "tsx": "4.22.4",
53
54
  "typescript": "6.0.3"
54
55
  },
55
56
  "dependencies": {
package/src/core.ts CHANGED
@@ -42,6 +42,43 @@ export const DEFAULT_API_BASE = "https://api.commandcode.ai"
42
42
  export const COMMAND_CODE_CLI_VERSION = "0.29.0"
43
43
 
44
44
  const DEFAULT_GENERATE_MAX_TOKENS = 64_000
45
+ const DEFAULT_MAX_RETRIES = 0
46
+ const DEFAULT_MAX_RETRY_DELAY_MS = 60_000
47
+ const BASE_RETRY_DELAY_MS = 500
48
+
49
+ function isRetryableStatus(status: number): boolean {
50
+ return status === 429 || (status >= 500 && status < 600)
51
+ }
52
+
53
+ function parseRetryAfterSeconds(value: string | null): number | undefined {
54
+ if (!value) return undefined
55
+ const seconds = Number(value)
56
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds
57
+ const date = Date.parse(value)
58
+ if (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000)
59
+ return undefined
60
+ }
61
+
62
+ function effectiveMaxRetryDelayMs(value: number | undefined): number {
63
+ if (value === undefined) return DEFAULT_MAX_RETRY_DELAY_MS
64
+ if (value === 0) return Number.POSITIVE_INFINITY
65
+ return value
66
+ }
67
+
68
+ function retryDelayMs(
69
+ attempt: number,
70
+ retryAfterHeader: string | null,
71
+ maxDelayMs: number,
72
+ ): number {
73
+ const retryAfterMs = parseRetryAfterSeconds(retryAfterHeader)
74
+ if (retryAfterMs !== undefined) {
75
+ if (retryAfterMs * 1000 > maxDelayMs) return -1
76
+ return retryAfterMs * 1000
77
+ }
78
+ const exponential = BASE_RETRY_DELAY_MS * 2 ** attempt
79
+ const jitter = exponential * 0.2 * Math.random()
80
+ return Math.min(exponential + jitter, maxDelayMs)
81
+ }
45
82
 
46
83
  function defaultUsage(): Usage {
47
84
  return {
@@ -76,6 +113,14 @@ function abortError(message = "The operation was aborted"): DOMException {
76
113
  return new DOMException(message, "AbortError")
77
114
  }
78
115
 
116
+ function timeoutError(timeoutMs: number | undefined): Error {
117
+ return new Error(
118
+ timeoutMs === undefined
119
+ ? "Command Code API request timed out"
120
+ : `Command Code API request timed out after ${timeoutMs}ms`,
121
+ )
122
+ }
123
+
79
124
  function successStopReason(reason: TerminalReason): StopReason {
80
125
  if (reason === "length" || reason === "toolUse") return reason
81
126
  return "stop"
@@ -104,6 +149,22 @@ export function createStreamCommandCode(deps: CoreDependencies) {
104
149
  const cwd = deps.cwd ?? (() => process.cwd())
105
150
  const now = deps.now ?? (() => Date.now())
106
151
  const uuid = deps.uuid ?? (() => randomUUID())
152
+ const delay =
153
+ deps.delay ??
154
+ ((ms: number, signal: AbortSignal) => {
155
+ if (signal.aborted) return Promise.reject(abortError())
156
+ return new Promise<void>((resolve, reject) => {
157
+ const id = setTimeout(() => {
158
+ signal.removeEventListener("abort", onAbort)
159
+ resolve()
160
+ }, ms)
161
+ const onAbort = () => {
162
+ clearTimeout(id)
163
+ reject(abortError())
164
+ }
165
+ signal.addEventListener("abort", onAbort, { once: true })
166
+ })
167
+ })
107
168
 
108
169
  function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
109
170
  if (signal.aborted) return Promise.reject(abortError())
@@ -132,10 +193,17 @@ export function createStreamCommandCode(deps: CoreDependencies) {
132
193
  const stream = deps.createStream()
133
194
 
134
195
  async function run() {
135
- // OMP may pass the env-var name "COMMANDCODE_API_KEY" as the apiKey
136
- // value instead of resolving it. Filter out this specific string.
196
+ // OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi)
197
+ // or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of
198
+ // resolving it. Filter out these specific strings.
199
+ const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY"
200
+ const OLD_API_KEY_REF = "COMMANDCODE_API_KEY"
137
201
  const hostKey =
138
- options?.apiKey && options.apiKey !== "COMMANDCODE_API_KEY" ? options.apiKey : undefined
202
+ options?.apiKey &&
203
+ options.apiKey !== LEGACY_API_KEY_REF &&
204
+ options.apiKey !== OLD_API_KEY_REF
205
+ ? options.apiKey
206
+ : undefined
139
207
 
140
208
  const apiKey =
141
209
  hostKey ??
@@ -386,81 +454,170 @@ export function createStreamCommandCode(deps: CoreDependencies) {
386
454
  )
387
455
  if (nextBody !== undefined) body = nextBody
388
456
 
389
- const response = await raceAbort(
390
- fetchImpl(`${apiBase}/alpha/generate`, {
391
- method: "POST",
392
- headers: {
393
- "Content-Type": "application/json",
394
- Authorization: `Bearer ${apiKey}`,
395
- "x-command-code-version": COMMAND_CODE_CLI_VERSION,
396
- "x-cli-environment": "production",
397
- "x-project-slug": projectSlugFromPath(workingDir),
398
- "x-taste-learning": "true",
399
- "x-co-flag": "false",
400
- ...options?.headers,
401
- },
402
- body: JSON.stringify(body),
403
- signal: controller.signal,
404
- }),
405
- controller.signal,
406
- )
407
-
408
- await raceAbort(
409
- Promise.resolve(
410
- options?.onResponse?.(
411
- {
412
- status: response.status,
413
- headers: headersToRecord(response.headers),
414
- },
415
- model,
416
- ),
417
- ),
418
- controller.signal,
419
- )
420
-
421
- if (!response.ok) {
422
- const errBody = await raceAbort(
423
- response.text().catch(() => ""),
424
- controller.signal,
425
- )
426
- throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
457
+ const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES
458
+ const maxRetryDelayMs = effectiveMaxRetryDelayMs(options?.maxRetryDelayMs)
459
+ const timeoutMs = options?.timeoutMs
460
+ const requestHeaders = {
461
+ "Content-Type": "application/json",
462
+ Authorization: `Bearer ${apiKey}`,
463
+ "x-command-code-version": COMMAND_CODE_CLI_VERSION,
464
+ "x-cli-environment": "production",
465
+ "x-project-slug": projectSlugFromPath(workingDir),
466
+ "x-taste-learning": "true",
467
+ "x-co-flag": "false",
468
+ ...options?.headers,
427
469
  }
470
+ const bodyStr = JSON.stringify(body)
471
+
472
+ let response!: Response
473
+ retryLoop: for (let attempt = 0; ; attempt++) {
474
+ const attemptController = new AbortController()
475
+ let attemptTimedOut = false
476
+ let attemptTimeoutId: ReturnType<typeof setTimeout> | undefined
477
+
478
+ const clearAttemptTimeout = () => {
479
+ if (attemptTimeoutId !== undefined) {
480
+ clearTimeout(attemptTimeoutId)
481
+ attemptTimeoutId = undefined
482
+ }
483
+ }
428
484
 
429
- reader = response.body?.getReader()
430
- if (!reader) throw new Error("No response body")
485
+ if (timeoutMs !== undefined) {
486
+ attemptTimeoutId = setTimeout(() => {
487
+ attemptTimedOut = true
488
+ attemptController.abort()
489
+ }, timeoutMs)
490
+ }
491
+ const onOuterAbort = () => attemptController.abort()
492
+ controller.signal.addEventListener("abort", onOuterAbort, { once: true })
493
+
494
+ try {
495
+ try {
496
+ response = await fetchImpl(`${apiBase}/alpha/generate`, {
497
+ method: "POST",
498
+ headers: requestHeaders,
499
+ body: bodyStr,
500
+ signal: attemptController.signal,
501
+ })
502
+ } catch (fetchError: unknown) {
503
+ if (controller.signal.aborted) throw abortError("Aborted")
504
+ if (attemptTimedOut) {
505
+ if (attempt < maxRetries) continue retryLoop
506
+ throw timeoutError(timeoutMs)
507
+ }
508
+ throw fetchError
509
+ }
431
510
 
432
- const decoder = new TextDecoder()
433
- let buffer = ""
511
+ // --- HTTP-level retry ---
512
+ if (!response.ok && isRetryableStatus(response.status)) {
513
+ const retryAfter = response.headers.get("retry-after")
514
+ const waitMs = retryDelayMs(attempt, retryAfter, maxRetryDelayMs)
515
+ if (waitMs < 0) {
516
+ const requestedSeconds = parseRetryAfterSeconds(retryAfter) ?? 0
517
+ const capLabel =
518
+ maxRetryDelayMs === Number.POSITIVE_INFINITY ? "disabled" : `${maxRetryDelayMs}ms`
519
+ throw new Error(`Retry-After delay ${requestedSeconds}s exceeds max ${capLabel}`)
520
+ }
521
+ if (attempt < maxRetries) {
522
+ await response.text().catch(() => "")
523
+ if (waitMs > 0) await delay(waitMs, controller.signal)
524
+ continue retryLoop
525
+ }
526
+ }
434
527
 
435
- readLoop: for (;;) {
436
- if (controller.signal.aborted) throw abortError("Aborted")
437
- const { done, value } = await raceAbort(reader.read(), controller.signal)
438
- if (done) {
439
- if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
440
- break
441
- }
442
- if (controller.signal.aborted) throw abortError("Aborted")
528
+ await raceAbort(
529
+ Promise.resolve(
530
+ options?.onResponse?.(
531
+ {
532
+ status: response.status,
533
+ headers: headersToRecord(response.headers),
534
+ },
535
+ model,
536
+ ),
537
+ ),
538
+ controller.signal,
539
+ )
540
+
541
+ if (!response.ok) {
542
+ const errBody = await raceAbort(
543
+ response.text().catch(() => ""),
544
+ controller.signal,
545
+ )
546
+ throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
547
+ }
443
548
 
444
- buffer += decoder.decode(value, { stream: true })
445
- const lines = buffer.split("\n")
446
- buffer = lines.pop() ?? ""
549
+ // --- Read response stream ---
550
+ reader = response.body?.getReader()
551
+ if (!reader) throw new Error("No response body")
552
+
553
+ const decoder = new TextDecoder()
554
+ let buffer = ""
555
+
556
+ try {
557
+ readLoop: for (;;) {
558
+ if (controller.signal.aborted) throw abortError("Aborted")
559
+ const { done, value } = await raceAbort(reader.read(), attemptController.signal)
560
+ if (done) {
561
+ if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
562
+ break
563
+ }
564
+ if (controller.signal.aborted) throw abortError("Aborted")
565
+
566
+ buffer += decoder.decode(value, { stream: true })
567
+ const lines = buffer.split("\n")
568
+ buffer = lines.pop() ?? ""
569
+
570
+ for (const line of lines) {
571
+ if (controller.signal.aborted) throw abortError("Aborted")
572
+ handleEvent(parseStreamEventLine(line))
573
+ if (finished) break readLoop
574
+ }
575
+ }
576
+ } catch (streamError: unknown) {
577
+ // Stream-level error (e.g. API returned 200 OK but sent an error event)
578
+ // or per-attempt timeout during stream reading.
579
+ await reader.cancel().catch(() => {})
580
+ try {
581
+ reader.releaseLock()
582
+ } catch {}
583
+ reader = undefined
584
+
585
+ if (controller.signal.aborted) throw streamError
586
+
587
+ // Never retry after visible content was emitted (including timeout mid-stream).
588
+ const canRetry = output.content.length === 0 && attempt < maxRetries
589
+ if (canRetry) {
590
+ output.content.length = 0
591
+ textBlock = undefined
592
+ currentTextIdx = -1
593
+ thinkingIdx = -1
594
+ output.stopReason = "stop"
595
+ output.errorMessage = undefined
596
+ finished = false
597
+ const waitMs = attemptTimedOut ? 0 : retryDelayMs(attempt, null, maxRetryDelayMs)
598
+ if (waitMs > 0) await delay(waitMs, controller.signal)
599
+ continue retryLoop
600
+ }
601
+ if (attemptTimedOut) throw timeoutError(timeoutMs)
602
+ throw streamError
603
+ }
604
+
605
+ // Stream completed successfully.
606
+ endTextBlock()
607
+ endThinking()
447
608
 
448
- for (const line of lines) {
449
- if (controller.signal.aborted) throw abortError("Aborted")
450
- handleEvent(parseStreamEventLine(line))
451
- if (finished) break readLoop
609
+ stream.push({
610
+ type: "done",
611
+ reason: successStopReason(output.stopReason),
612
+ message: output,
613
+ })
614
+ stream.end()
615
+ break retryLoop
616
+ } finally {
617
+ controller.signal.removeEventListener("abort", onOuterAbort)
618
+ clearAttemptTimeout()
452
619
  }
453
620
  }
454
-
455
- endTextBlock()
456
- endThinking()
457
-
458
- stream.push({
459
- type: "done",
460
- reason: successStopReason(output.stopReason),
461
- message: output,
462
- })
463
- stream.end()
464
621
  } catch (error: unknown) {
465
622
  const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
466
623
  output.stopReason = reason
package/src/types.ts CHANGED
@@ -89,6 +89,23 @@ export interface StreamOptions {
89
89
  maxTokens?: number
90
90
  onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>
91
91
  onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise<void>
92
+ /**
93
+ * HTTP request timeout in milliseconds.
94
+ * Applied per-attempt; on timeout the request is retried if retries remain.
95
+ */
96
+ timeoutMs?: number
97
+ /**
98
+ * Maximum retry attempts for transient HTTP errors (429, 5xx).
99
+ * Default: 0 (pi agent-level retry handles visible retries when unset).
100
+ */
101
+ maxRetries?: number
102
+ /**
103
+ * Maximum delay in milliseconds to wait for a retry when the server requests
104
+ * a long wait via Retry-After. If the server's requested delay exceeds this
105
+ * value, the request fails immediately. Default: 60000 (60 seconds).
106
+ * Set to 0 to disable the cap.
107
+ */
108
+ maxRetryDelayMs?: number
92
109
  }
93
110
 
94
111
  export type AssistantMessageEvent =
@@ -153,4 +170,6 @@ export interface CoreDependencies {
153
170
  now?: () => number
154
171
  uuid?: () => string
155
172
  homeDir?: () => string
173
+ /** Injectable delay for retry backoff. Defaults to setTimeout. */
174
+ delay?: (ms: number, signal: AbortSignal) => Promise<void>
156
175
  }