pi-commandcode-provider 0.4.1 → 0.4.3
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 +22 -1
- package/README.md +3 -1
- package/index.ts +14 -5
- package/package.json +4 -3
- package/src/cost.ts +19 -0
- package/src/models.ts +126 -6
- package/src/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.4.3 - 2026-08-02
|
|
4
|
+
|
|
5
|
+
- Allow pi to start when model discovery is unavailable. The provider now caches the last successfully fetched model catalog so previously discovered Command Code models remain selectable offline; a first offline start without a cache keeps Command Code unavailable until `/reload` succeeds.
|
|
6
|
+
|
|
7
|
+
### Contributors
|
|
8
|
+
|
|
9
|
+
- @k3-2o — reported that the model-list fetch blocked pi startup when offline.
|
|
10
|
+
|
|
11
|
+
## 0.4.2 - 2026-07-05
|
|
12
|
+
|
|
13
|
+
- Fix Oh My Pi extension validation by avoiding the missing `calculateCost` export from OMP's legacy `pi-ai` shim.
|
|
14
|
+
- Add a regression test that locks the local Command Code cost calculation to pi-ai's upstream `calculateCost` behavior.
|
|
15
|
+
|
|
16
|
+
### Contributors
|
|
17
|
+
|
|
18
|
+
- @CoderTCY — reported the Oh My Pi installation failure.
|
|
4
19
|
|
|
5
20
|
## 0.4.1 - 2026-06-16
|
|
6
21
|
|
|
7
22
|
- 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
23
|
- Refresh development dependency lockfile entries to resolve npm audit findings for `tsx`/`esbuild` and `protobufjs`.
|
|
9
24
|
|
|
25
|
+
### Contributors
|
|
26
|
+
|
|
27
|
+
- @plumj-am — fixed the pi provider `apiKey` deprecation warning.
|
|
28
|
+
- @cad0p — reported retry/deprecation-related issues that helped validate the current behavior.
|
|
29
|
+
- @bl4zee1g — reported provider availability concerns that prompted additional local/live validation.
|
|
30
|
+
|
|
10
31
|
## 0.4.0 - 2026-06-02
|
|
11
32
|
|
|
12
33
|
- 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.
|
package/README.md
CHANGED
|
@@ -134,7 +134,9 @@ On startup, the provider fetches:
|
|
|
134
134
|
https://api.commandcode.ai/provider/v1/models
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
The last successfully fetched catalog is cached at `<agent-dir>/commandcode-models.json` (`~/.pi/agent/commandcode-models.json` by default). The agent directory follows pi's `PI_CODING_AGENT_DIR` setting, so compatible hosts such as OMP keep the cache in their own agent directory. If model discovery is temporarily unavailable, the provider uses this cached catalog so previously discovered Command Code models remain selectable. On a first offline start without a cache, pi still loads, but Command Code models remain unavailable until the connection is restored and `/reload` succeeds.
|
|
138
|
+
|
|
139
|
+
For tests or local mocks, override the endpoint with `COMMANDCODE_MODELS_URL` and the cache file with `COMMANDCODE_MODELS_CACHE`.
|
|
138
140
|
|
|
139
141
|
## Pricing
|
|
140
142
|
|
package/index.ts
CHANGED
|
@@ -12,15 +12,19 @@
|
|
|
12
12
|
* Models are fetched from Command Code's Provider API at startup.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { AssistantMessageEventStream
|
|
16
|
-
import type
|
|
15
|
+
import { AssistantMessageEventStream } from "@earendil-works/pi-ai"
|
|
16
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
17
|
+
import { join } from "node:path"
|
|
17
18
|
|
|
18
19
|
import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
|
|
19
|
-
import {
|
|
20
|
+
import { calculateCommandCodeCost } from "./src/cost.ts"
|
|
21
|
+
import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts"
|
|
20
22
|
import { getApiKey, login, refreshToken } from "./src/oauth.ts"
|
|
21
23
|
|
|
22
24
|
const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
|
|
23
25
|
const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL
|
|
26
|
+
const MODELS_CACHE_PATH =
|
|
27
|
+
process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json")
|
|
24
28
|
|
|
25
29
|
type CommandCodeModelCost = {
|
|
26
30
|
input: number
|
|
@@ -69,7 +73,7 @@ const MODEL_COSTS: Record<string, CommandCodeModelCost> = {
|
|
|
69
73
|
|
|
70
74
|
const streamCommandCode = createStreamCommandCode({
|
|
71
75
|
createStream: () => new AssistantMessageEventStream(),
|
|
72
|
-
calculateCost,
|
|
76
|
+
calculateCost: calculateCommandCodeCost,
|
|
73
77
|
apiBase: API_BASE,
|
|
74
78
|
})
|
|
75
79
|
|
|
@@ -78,7 +82,12 @@ const streamCommandCode = createStreamCommandCode({
|
|
|
78
82
|
// ---------------------------------------------------------------------------
|
|
79
83
|
|
|
80
84
|
export default async function (pi: ExtensionAPI) {
|
|
81
|
-
const models = await
|
|
85
|
+
const { models, warning } = await loadCommandCodeModels({
|
|
86
|
+
url: MODELS_URL,
|
|
87
|
+
cachePath: MODELS_CACHE_PATH,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
if (warning) console.warn(`[commandcode] ${warning}`)
|
|
82
91
|
|
|
83
92
|
pi.registerProvider("commandcode", {
|
|
84
93
|
name: "Command Code",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-commandcode-provider",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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 && tsx tests/test-retry.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-cost.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}'",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"test:stream": "tsx tests/test-stream.ts",
|
|
41
41
|
"test:retry": "tsx tests/test-retry.ts",
|
|
42
42
|
"test:pi-local": "node tests/test-pi-local.mjs",
|
|
43
|
-
"test:smoke": "node tests/test-smoke.mjs"
|
|
43
|
+
"test:smoke": "node tests/test-smoke.mjs",
|
|
44
|
+
"test:cost": "tsx tests/test-cost.ts"
|
|
44
45
|
},
|
|
45
46
|
"pi": {
|
|
46
47
|
"extensions": [
|
package/src/cost.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local cost calculation for Command Code usage.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors pi-ai's `calculateCost` arithmetic exactly. The provider ships its
|
|
5
|
+
* own copy because Oh My Pi's legacy pi-ai shim does not export
|
|
6
|
+
* `calculateCost`, which broke extension installation there (issue #24).
|
|
7
|
+
* `tests/test-cost.ts` locks this implementation to the pi-ai original.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ModelLike, Usage } from "./types.ts"
|
|
11
|
+
|
|
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
|
|
17
|
+
usage.cost.total =
|
|
18
|
+
usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite
|
|
19
|
+
}
|
package/src/models.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
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"
|
|
2
5
|
|
|
3
6
|
const DEFAULT_MAX_OUTPUT_TOKENS = 65_536
|
|
7
|
+
const MODEL_CACHE_VERSION = 1
|
|
4
8
|
|
|
5
9
|
interface ApiModel {
|
|
6
10
|
id: string
|
|
@@ -21,19 +25,39 @@ interface FetchCommandCodeModelsOptions {
|
|
|
21
25
|
fetchImpl?: typeof fetch
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions {
|
|
29
|
+
cachePath: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoadCommandCodeModelsResult {
|
|
33
|
+
models: readonly CommandCodeModel[]
|
|
34
|
+
source: "live" | "cache" | "empty"
|
|
35
|
+
warning?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
-
return typeof value === "object" && value !== null
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
function stringField(record: Record<string, unknown>, key: string): string {
|
|
29
43
|
const value = record[key]
|
|
30
|
-
if (typeof value !== "string"
|
|
44
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
45
|
+
throw new Error(`Expected ${key} to be a non-empty string`)
|
|
46
|
+
}
|
|
31
47
|
return value
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
function
|
|
50
|
+
function booleanField(record: Record<string, unknown>, key: string): boolean {
|
|
35
51
|
const value = record[key]
|
|
36
|
-
if (typeof value !== "
|
|
52
|
+
if (typeof value !== "boolean") throw new Error(`Expected ${key} to be a boolean`)
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function positiveNumberField(record: Record<string, unknown>, key: string): number {
|
|
57
|
+
const value = record[key]
|
|
58
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
59
|
+
throw new Error(`Expected ${key} to be a positive number`)
|
|
60
|
+
}
|
|
37
61
|
return value
|
|
38
62
|
}
|
|
39
63
|
|
|
@@ -43,10 +67,31 @@ function parseApiModel(value: unknown): ApiModel {
|
|
|
43
67
|
return {
|
|
44
68
|
id: stringField(value, "id"),
|
|
45
69
|
name: stringField(value, "name"),
|
|
46
|
-
contextLength:
|
|
70
|
+
contextLength: positiveNumberField(value, "context_length"),
|
|
47
71
|
}
|
|
48
72
|
}
|
|
49
73
|
|
|
74
|
+
function parseCachedModel(value: unknown): CommandCodeModel {
|
|
75
|
+
if (!isRecord(value)) throw new Error("Expected cached model entry to be an object")
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id: stringField(value, "id"),
|
|
79
|
+
name: stringField(value, "name"),
|
|
80
|
+
reasoning: booleanField(value, "reasoning"),
|
|
81
|
+
contextWindow: positiveNumberField(value, "contextWindow"),
|
|
82
|
+
maxTokens: positiveNumberField(value, "maxTokens"),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function requireModels(models: readonly CommandCodeModel[]): readonly CommandCodeModel[] {
|
|
87
|
+
if (models.length === 0) throw new Error("Command Code returned an empty model catalog")
|
|
88
|
+
return models
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function errorMessage(error: unknown): string {
|
|
92
|
+
return error instanceof Error ? error.message : String(error)
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] {
|
|
51
96
|
if (!isRecord(value)) throw new Error("Expected models response to be an object")
|
|
52
97
|
if (value.object !== "list") throw new Error("Expected models response object to be 'list'")
|
|
@@ -63,6 +108,16 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma
|
|
|
63
108
|
}))
|
|
64
109
|
}
|
|
65
110
|
|
|
111
|
+
export function commandCodeModelsFromCache(value: unknown): readonly CommandCodeModel[] {
|
|
112
|
+
if (!isRecord(value)) throw new Error("Expected model cache to be an object")
|
|
113
|
+
if (value.version !== MODEL_CACHE_VERSION) {
|
|
114
|
+
throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(value.models)) throw new Error("Expected cached models to be an array")
|
|
117
|
+
|
|
118
|
+
return requireModels(value.models.map(parseCachedModel))
|
|
119
|
+
}
|
|
120
|
+
|
|
66
121
|
export async function fetchCommandCodeModels(
|
|
67
122
|
options: FetchCommandCodeModelsOptions = {},
|
|
68
123
|
): Promise<readonly CommandCodeModel[]> {
|
|
@@ -81,5 +136,70 @@ export async function fetchCommandCodeModels(
|
|
|
81
136
|
}
|
|
82
137
|
|
|
83
138
|
const body: unknown = await response.json()
|
|
84
|
-
return commandCodeModelsFromApiResponse(body)
|
|
139
|
+
return requireModels(commandCodeModelsFromApiResponse(body))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readCommandCodeModelsCache(cachePath: string): Promise<readonly CommandCodeModel[]> {
|
|
143
|
+
const contents = await readFile(cachePath, "utf-8")
|
|
144
|
+
const parsed: unknown = JSON.parse(contents)
|
|
145
|
+
return commandCodeModelsFromCache(parsed)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function writeCommandCodeModelsCache(
|
|
149
|
+
cachePath: string,
|
|
150
|
+
models: readonly CommandCodeModel[],
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
await mkdir(dirname(cachePath), { recursive: true })
|
|
153
|
+
const temporaryPath = `${cachePath}.${process.pid}.tmp`
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await writeFile(
|
|
157
|
+
temporaryPath,
|
|
158
|
+
`${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`,
|
|
159
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
160
|
+
)
|
|
161
|
+
await rename(temporaryPath, cachePath)
|
|
162
|
+
} finally {
|
|
163
|
+
try {
|
|
164
|
+
await rm(temporaryPath, { force: true })
|
|
165
|
+
} catch {
|
|
166
|
+
// Best-effort cleanup must not hide the original cache write error.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function loadCommandCodeModels(
|
|
172
|
+
options: LoadCommandCodeModelsOptions,
|
|
173
|
+
): Promise<LoadCommandCodeModelsResult> {
|
|
174
|
+
const cachePath = options.cachePath
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const models = await fetchCommandCodeModels(options)
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await writeCommandCodeModelsCache(cachePath, models)
|
|
181
|
+
return { models, source: "live" }
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return {
|
|
184
|
+
models,
|
|
185
|
+
source: "live",
|
|
186
|
+
warning: `Loaded the live Command Code model catalog but could not update ${cachePath}: ${errorMessage(error)}`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (liveError) {
|
|
190
|
+
try {
|
|
191
|
+
const models = await readCommandCodeModelsCache(cachePath)
|
|
192
|
+
return {
|
|
193
|
+
models,
|
|
194
|
+
source: "cache",
|
|
195
|
+
warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`,
|
|
196
|
+
}
|
|
197
|
+
} catch (cacheError) {
|
|
198
|
+
return {
|
|
199
|
+
models: [],
|
|
200
|
+
source: "empty",
|
|
201
|
+
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 /reload succeeds.`,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
85
205
|
}
|
package/src/types.ts
CHANGED
|
@@ -50,11 +50,19 @@ export interface AssistantMessageLike {
|
|
|
50
50
|
timestamp: number
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export interface ModelCost {
|
|
54
|
+
input: number
|
|
55
|
+
output: number
|
|
56
|
+
cacheRead: number
|
|
57
|
+
cacheWrite: number
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
export interface ModelLike {
|
|
54
61
|
id: string
|
|
55
62
|
api: unknown
|
|
56
63
|
provider: string
|
|
57
64
|
maxTokens: number
|
|
65
|
+
cost: ModelCost
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export interface MessageLike {
|