@pi-vault/pi-custom-providers 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lanh Hoang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @pi-vault/pi-custom-providers
2
+
3
+ A [Pi](https://pi.dev) custom provider package for adding third-party model providers to Pi.
4
+
5
+ > **Disclaimer:** This is an unofficial, community-maintained package. Individual providers included here may be backed by third-party services that are not affiliated with this package.
6
+
7
+ > **Note:** This package only provides model providers. It does **not** include provider API keys or subscriptions.
8
+
9
+ ## Providers
10
+
11
+ Current providers included in this package:
12
+
13
+ - Command Code
14
+
15
+ More providers can be added over time without changing the package structure Pi installs.
16
+
17
+ ## Models
18
+
19
+ Available models change over time. See the official Command Code model list:
20
+
21
+ - [Command Code Supported Models](https://commandcode.ai/docs/resources/pricing-limits#models)
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ pi install npm:@pi-vault/pi-custom-providers
27
+ ```
28
+
29
+ Then reload Pi:
30
+
31
+ ```txt
32
+ /reload
33
+ ```
34
+
35
+ ## Setup
36
+
37
+ Set up the provider you want to use in Pi.
38
+
39
+ For Command Code, use one of these methods:
40
+
41
+ ### 1. Pi login (recommended)
42
+
43
+ In Pi, run:
44
+
45
+ ```txt
46
+ /login
47
+ ```
48
+
49
+ Then select **Command Code** from the provider list.
50
+
51
+ ### 2. Environment variable
52
+
53
+ ```sh
54
+ export COMMAND_CODE_API_KEY="user_..."
55
+ ```
56
+
57
+ Legacy compatibility:
58
+
59
+ ```sh
60
+ export COMMANDCODE_API_KEY="user_..."
61
+ ```
62
+
63
+ ### 3. Auth file
64
+
65
+ Create `~/.commandcode/auth.json`:
66
+
67
+ ```json
68
+ {
69
+ "apiKey": "user_..."
70
+ }
71
+ ```
72
+
73
+ Or use Pi's auth file at `~/.pi/agent/auth.json`:
74
+
75
+ ```json
76
+ {
77
+ "commandcode": "user_..."
78
+ }
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ After installing and setting up a provider, select one of its models in Pi.
84
+
85
+ For Command Code, an example is:
86
+
87
+ ```txt
88
+ /model deepseek/deepseek-v4-flash
89
+ ```
90
+
91
+ Any query will then use the Command Code API.
92
+
93
+ You can list available models in Pi:
94
+
95
+ ```txt
96
+ /models
97
+ ```
98
+
99
+ ## Publish
100
+
101
+ ```sh
102
+ npm login
103
+ npm publish --access public
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@pi-vault/pi-custom-providers",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension that enables custom providers for Pi",
6
+ "author": "Lanh Hoang <lanhhoang@users.noreply.github.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/pi-vault/pi-custom-providers#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/pi-vault/pi-custom-providers.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/pi-vault/pi-custom-providers/issues"
15
+ },
16
+ "keywords": [
17
+ "pi",
18
+ "pi-package",
19
+ "pi-extension",
20
+ "pi-custom-providers",
21
+ "pi-providers"
22
+ ],
23
+ "scripts": {
24
+ "format": "biome format --write .",
25
+ "lint": "biome lint .",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest --run --exclude tests/**/*.smoke.test.ts",
28
+ "test:smoke": "vitest --run tests/command-code.smoke.test.ts",
29
+ "check": "biome lint . && tsc --noEmit && vitest --run --exclude tests/**/*.smoke.test.ts",
30
+ "pack:dry-run": "npm pack --dry-run"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./src/index.ts"
35
+ ]
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "README.md"
46
+ ],
47
+ "dependencies": {
48
+ "@earendil-works/pi-ai": "^0.75.5"
49
+ },
50
+ "devDependencies": {
51
+ "@biomejs/biome": "^2.4.15",
52
+ "@earendil-works/pi-coding-agent": "^0.75.5",
53
+ "@types/node": "^25.9.1",
54
+ "typescript": "^6.0.3",
55
+ "vitest": "^4.1.7"
56
+ }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerProviders } from "./providers/index.js";
3
+
4
+ export default async function customProvidersExtension(pi: ExtensionAPI) {
5
+ console.log("custom-providers extension loaded");
6
+
7
+ await registerProviders(pi);
8
+ }
@@ -0,0 +1,133 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import {
6
+ COMMAND_CODE_API_KEY_ENV,
7
+ LEGACY_COMMAND_CODE_API_KEY_ENV,
8
+ } from "./constants.js";
9
+
10
+ type Deps = {
11
+ getEnv?: (name: string) => string | undefined;
12
+ getHomeDir?: () => string;
13
+ readFileUtf8?: (path: string) => Promise<string>;
14
+ };
15
+
16
+ function normalizeApiKey(value: unknown): string | undefined {
17
+ if (typeof value !== "string") {
18
+ return undefined;
19
+ }
20
+ const trimmed = value.trim();
21
+ return trimmed.length > 0 ? trimmed : undefined;
22
+ }
23
+
24
+ function parseJsonObject(value: string): Record<string, unknown> | undefined {
25
+ try {
26
+ const parsed = JSON.parse(value) as unknown;
27
+ if (
28
+ typeof parsed === "object" &&
29
+ parsed !== null &&
30
+ !Array.isArray(parsed)
31
+ ) {
32
+ return parsed as Record<string, unknown>;
33
+ }
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ async function readJsonObjectFile(
41
+ path: string,
42
+ readFileUtf8: (path: string) => Promise<string>,
43
+ ): Promise<Record<string, unknown> | undefined> {
44
+ try {
45
+ const content = await readFileUtf8(path);
46
+ return parseJsonObject(content);
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ async function readLegacyCommandCodeAuthFile(
53
+ home: string,
54
+ readFileUtf8: (path: string) => Promise<string>,
55
+ ): Promise<string | undefined> {
56
+ const doc = await readJsonObjectFile(
57
+ join(home, ".commandcode", "auth.json"),
58
+ readFileUtf8,
59
+ );
60
+ return normalizeApiKey(doc?.apiKey);
61
+ }
62
+
63
+ async function readLegacyPiAgentAuthFile(
64
+ home: string,
65
+ readFileUtf8: (path: string) => Promise<string>,
66
+ ): Promise<string | undefined> {
67
+ const doc = await readJsonObjectFile(
68
+ join(home, ".pi", "agent", "auth.json"),
69
+ readFileUtf8,
70
+ );
71
+ if (!doc) {
72
+ return undefined;
73
+ }
74
+
75
+ const direct = normalizeApiKey(doc.commandcode);
76
+ if (direct) {
77
+ return direct;
78
+ }
79
+
80
+ const cc = doc.commandcode;
81
+ if (typeof cc === "object" && cc !== null && !Array.isArray(cc)) {
82
+ return normalizeApiKey((cc as Record<string, unknown>).access);
83
+ }
84
+
85
+ return undefined;
86
+ }
87
+
88
+ export async function resolveCommandCodeApiKey(
89
+ resolvedApiKey: string | undefined,
90
+ deps?: Deps,
91
+ ): Promise<string | undefined> {
92
+ const getEnv = deps?.getEnv ?? ((name: string) => process.env[name]);
93
+ const getHomeDir = deps?.getHomeDir ?? homedir;
94
+ const readFileUtf8 =
95
+ deps?.readFileUtf8 ?? ((path: string) => readFile(path, "utf8"));
96
+
97
+ const fromOptions = normalizeApiKey(resolvedApiKey);
98
+ if (fromOptions) {
99
+ return fromOptions;
100
+ }
101
+
102
+ const fromPreferredEnv = normalizeApiKey(getEnv(COMMAND_CODE_API_KEY_ENV));
103
+ if (fromPreferredEnv) {
104
+ return fromPreferredEnv;
105
+ }
106
+
107
+ const fromLegacyEnv = normalizeApiKey(
108
+ getEnv(LEGACY_COMMAND_CODE_API_KEY_ENV),
109
+ );
110
+ if (fromLegacyEnv) {
111
+ return fromLegacyEnv;
112
+ }
113
+
114
+ const home = getHomeDir();
115
+
116
+ const fromLegacyCommandCodeFile = await readLegacyCommandCodeAuthFile(
117
+ home,
118
+ readFileUtf8,
119
+ );
120
+ if (fromLegacyCommandCodeFile) {
121
+ return fromLegacyCommandCodeFile;
122
+ }
123
+
124
+ const fromLegacyPiAgentFile = await readLegacyPiAgentAuthFile(
125
+ home,
126
+ readFileUtf8,
127
+ );
128
+ if (fromLegacyPiAgentFile) {
129
+ return fromLegacyPiAgentFile;
130
+ }
131
+
132
+ return undefined;
133
+ }
@@ -0,0 +1,13 @@
1
+ export const COMMAND_CODE_PROVIDER_ID = "command-code";
2
+ export const COMMAND_CODE_PROVIDER_NAME = "Command Code";
3
+ export const COMMAND_CODE_API_KEY_ENV = "COMMAND_CODE_API_KEY";
4
+ export const LEGACY_COMMAND_CODE_API_KEY_ENV = "COMMANDCODE_API_KEY";
5
+
6
+ export const COMMAND_CODE_API_ID = "commandcode-custom";
7
+ export const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
8
+ export const COMMAND_CODE_CLI_VERSION = "0.26.25";
9
+ export const COMMAND_CODE_GENERATE_PATH = "/alpha/generate";
10
+ export const COMMAND_CODE_MAX_REQUEST_MAX_TOKENS = 200_000;
11
+ // Live catalog endpoint used to refresh model name/context window on startup.
12
+ export const COMMAND_CODE_MODELS_ENDPOINT =
13
+ "https://api.commandcode.ai/provider/v1/models";
@@ -0,0 +1,3 @@
1
+ export {
2
+ registerCommandCodeProvider,
3
+ } from "./register.js";
@@ -0,0 +1,244 @@
1
+ import { COMMAND_CODE_MODELS_ENDPOINT } from "./constants.js";
2
+ import type { LiveModelsResponse, ProviderModel } from "../shared/types.js";
3
+
4
+ const MODEL_METADATA: Record<
5
+ string,
6
+ Pick<ProviderModel, "reasoning" | "maxTokens" | "cost">
7
+ > = {
8
+ "claude-sonnet-4-6": {
9
+ reasoning: true,
10
+ maxTokens: 64000,
11
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
12
+ },
13
+ "claude-opus-4-7": {
14
+ reasoning: true,
15
+ maxTokens: 64000,
16
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
17
+ },
18
+ "claude-haiku-4-5-20251001": {
19
+ reasoning: true,
20
+ maxTokens: 64000,
21
+ cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
22
+ },
23
+ "gpt-5.5": {
24
+ reasoning: true,
25
+ maxTokens: 128000,
26
+ cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
27
+ },
28
+ "gpt-5.4": {
29
+ reasoning: true,
30
+ maxTokens: 128000,
31
+ cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
32
+ },
33
+ "gpt-5.3-codex": {
34
+ reasoning: true,
35
+ maxTokens: 65536,
36
+ cost: { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 0 },
37
+ },
38
+ "gpt-5.4-mini": {
39
+ reasoning: true,
40
+ maxTokens: 65536,
41
+ cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
42
+ },
43
+ "moonshotai/Kimi-K2.6": {
44
+ reasoning: true,
45
+ maxTokens: 32768,
46
+ cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
47
+ },
48
+ "moonshotai/Kimi-K2.5": {
49
+ reasoning: true,
50
+ maxTokens: 32768,
51
+ cost: { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
52
+ },
53
+ "zai-org/GLM-5.1": {
54
+ reasoning: true,
55
+ maxTokens: 32768,
56
+ cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
57
+ },
58
+ "zai-org/GLM-5": {
59
+ reasoning: true,
60
+ maxTokens: 32768,
61
+ cost: { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
62
+ },
63
+ "MiniMaxAI/MiniMax-M2.7": {
64
+ reasoning: true,
65
+ maxTokens: 32768,
66
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
67
+ },
68
+ "MiniMaxAI/MiniMax-M2.5": {
69
+ reasoning: true,
70
+ maxTokens: 32768,
71
+ cost: { input: 0.27, output: 0.95, cacheRead: 0.03, cacheWrite: 0 },
72
+ },
73
+ "deepseek/deepseek-v4-pro": {
74
+ reasoning: true,
75
+ maxTokens: 32768,
76
+ cost: { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
77
+ },
78
+ "deepseek/deepseek-v4-flash": {
79
+ reasoning: true,
80
+ maxTokens: 32768,
81
+ cost: { input: 0.14, output: 0.28, cacheRead: 0.01, cacheWrite: 0 },
82
+ },
83
+ "Qwen/Qwen3.6-Max-Preview": {
84
+ reasoning: true,
85
+ maxTokens: 32768,
86
+ cost: { input: 1.3, output: 7.8, cacheRead: 0.26, cacheWrite: 1.63 },
87
+ },
88
+ "Qwen/Qwen3.6-Plus": {
89
+ reasoning: true,
90
+ maxTokens: 32768,
91
+ cost: { input: 0.5, output: 3, cacheRead: 0.1, cacheWrite: 0 },
92
+ },
93
+ "Qwen/Qwen3.7-Max": {
94
+ reasoning: true,
95
+ maxTokens: 32768,
96
+ cost: { input: 1.25, output: 3.75, cacheRead: 0.25, cacheWrite: 1.56 },
97
+ },
98
+ "stepfun/Step-3.5-Flash": {
99
+ reasoning: true,
100
+ maxTokens: 32768,
101
+ cost: { input: 0.1, output: 0.3, cacheRead: 0.02, cacheWrite: 0 },
102
+ },
103
+ "google/gemini-3.5-flash": {
104
+ reasoning: true,
105
+ maxTokens: 65536,
106
+ cost: { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 },
107
+ },
108
+ "google/gemini-3.1-flash-lite": {
109
+ reasoning: false,
110
+ maxTokens: 8192,
111
+ cost: { input: 0.25, output: 1.5, cacheRead: 0.03, cacheWrite: 0 },
112
+ },
113
+ };
114
+
115
+ function makeModel(id: string, name: string, contextWindow: number): ProviderModel {
116
+ const metadata = MODEL_METADATA[id] ?? {
117
+ reasoning: false,
118
+ maxTokens: 8192,
119
+ cost: {
120
+ input: 0,
121
+ output: 0,
122
+ cacheRead: 0,
123
+ cacheWrite: 0,
124
+ },
125
+ };
126
+
127
+ return {
128
+ id,
129
+ name,
130
+ reasoning: metadata.reasoning,
131
+ input: ["text"],
132
+ cost: metadata.cost,
133
+ contextWindow,
134
+ maxTokens: metadata.maxTokens,
135
+ compat: {
136
+ supportsUsageInStreaming: true,
137
+ },
138
+ };
139
+ }
140
+
141
+ export const COMMAND_CODE_MODELS: ProviderModel[] = [
142
+ makeModel("claude-sonnet-4-6", "Claude Sonnet 4.6", 1000000),
143
+ makeModel("claude-opus-4-7", "Claude Opus 4.7", 1000000),
144
+ makeModel("claude-haiku-4-5-20251001", "Claude Haiku 4.5", 200000),
145
+ makeModel("gpt-5.5", "GPT-5.5", 200000),
146
+ makeModel("gpt-5.4", "GPT-5.4", 400000),
147
+ makeModel("gpt-5.3-codex", "GPT-5.3 Codex", 400000),
148
+ makeModel("gpt-5.4-mini", "GPT-5.4 Mini", 400000),
149
+ makeModel("moonshotai/Kimi-K2.6", "Kimi K2.6", 256000),
150
+ makeModel("moonshotai/Kimi-K2.5", "Kimi K2.5", 256000),
151
+ makeModel("zai-org/GLM-5.1", "GLM-5.1", 200000),
152
+ makeModel("zai-org/GLM-5", "GLM-5", 200000),
153
+ makeModel("MiniMaxAI/MiniMax-M2.7", "MiniMax M2.7", 200000),
154
+ makeModel("MiniMaxAI/MiniMax-M2.5", "MiniMax M2.5", 200000),
155
+ makeModel("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", 1000000),
156
+ makeModel("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", 1000000),
157
+ makeModel("Qwen/Qwen3.6-Max-Preview", "Qwen 3.6 Max Preview", 200000),
158
+ makeModel("Qwen/Qwen3.6-Plus", "Qwen 3.6 Plus", 200000),
159
+ makeModel("Qwen/Qwen3.7-Max", "Qwen 3.7 Max", 1000000),
160
+ makeModel("stepfun/Step-3.5-Flash", "Step 3.5 Flash", 1000000),
161
+ makeModel("google/gemini-3.5-flash", "Gemini 3.5 Flash", 1000000),
162
+ makeModel("google/gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite", 1000000),
163
+ ];
164
+
165
+ // Manual catalog refresh helper. Phase 6 runtime registration must not depend on this.
166
+ export async function fetchCommandCodeModels(
167
+ fetchImpl: typeof fetch = fetch,
168
+ ): Promise<LiveModelsResponse | null> {
169
+ try {
170
+ const response = await fetchImpl(COMMAND_CODE_MODELS_ENDPOINT, {
171
+ method: "GET",
172
+ headers: {
173
+ Accept: "application/json",
174
+ },
175
+ });
176
+
177
+ if (!response.ok) {
178
+ return null;
179
+ }
180
+
181
+ return (await response.json()) as LiveModelsResponse;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ export function mergeLiveCommandCodeModels(
188
+ bakedModels: ProviderModel[],
189
+ live: LiveModelsResponse,
190
+ ): ProviderModel[] {
191
+ if (!Array.isArray(live.data) || live.data.length === 0) {
192
+ return bakedModels;
193
+ }
194
+
195
+ const bakedById = new Map(bakedModels.map((model) => [model.id, model]));
196
+ const merged: ProviderModel[] = [];
197
+
198
+ for (const entry of live.data) {
199
+ if (!entry?.id) {
200
+ continue;
201
+ }
202
+
203
+ const baked = bakedById.get(entry.id);
204
+ if (baked) {
205
+ merged.push({
206
+ ...baked,
207
+ name: typeof entry.name === "string" ? entry.name : baked.name,
208
+ contextWindow:
209
+ typeof entry.context_length === "number" && Number.isFinite(entry.context_length)
210
+ ? entry.context_length
211
+ : baked.contextWindow,
212
+ });
213
+ continue;
214
+ }
215
+
216
+ merged.push(
217
+ makeModel(
218
+ entry.id,
219
+ typeof entry.name === "string" ? entry.name : entry.id,
220
+ typeof entry.context_length === "number" && Number.isFinite(entry.context_length)
221
+ ? entry.context_length
222
+ : 8192,
223
+ ),
224
+ );
225
+ }
226
+
227
+ return merged.length > 0 ? merged : bakedModels;
228
+ }
229
+
230
+ export async function resolveRuntimeCommandCodeModels(
231
+ fetchImpl: typeof fetch = fetch,
232
+ bakedModels: ProviderModel[] = COMMAND_CODE_MODELS,
233
+ ): Promise<ProviderModel[]> {
234
+ const liveModels = await fetchCommandCodeModels(fetchImpl);
235
+ return liveModels ? mergeLiveCommandCodeModels(bakedModels, liveModels) : bakedModels;
236
+ }
237
+
238
+ export async function fetchMergedCommandCodeModels(
239
+ fetchImpl: typeof fetch = fetch,
240
+ bakedModels: ProviderModel[] = COMMAND_CODE_MODELS,
241
+ ): Promise<ProviderModel[]> {
242
+ const liveModels = await fetchCommandCodeModels(fetchImpl);
243
+ return liveModels ? mergeLiveCommandCodeModels(bakedModels, liveModels) : bakedModels;
244
+ }
@@ -0,0 +1,27 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ COMMAND_CODE_API_ID,
4
+ COMMAND_CODE_API_KEY_ENV,
5
+ COMMAND_CODE_BASE_URL,
6
+ COMMAND_CODE_PROVIDER_ID,
7
+ COMMAND_CODE_PROVIDER_NAME,
8
+ } from "./constants.js";
9
+ import { COMMAND_CODE_MODELS, resolveRuntimeCommandCodeModels } from "./models.js";
10
+ import { streamCommandCode } from "./stream.js";
11
+
12
+ export async function registerCommandCodeProvider(
13
+ pi: ExtensionAPI,
14
+ deps?: { fetchImpl?: typeof fetch },
15
+ ): Promise<void> {
16
+ const models = await resolveRuntimeCommandCodeModels(deps?.fetchImpl, COMMAND_CODE_MODELS);
17
+
18
+ pi.registerProvider(COMMAND_CODE_PROVIDER_ID, {
19
+ name: COMMAND_CODE_PROVIDER_NAME,
20
+ apiKey: COMMAND_CODE_API_KEY_ENV,
21
+ api: COMMAND_CODE_API_ID,
22
+ authHeader: true,
23
+ baseUrl: COMMAND_CODE_BASE_URL,
24
+ streamSimple: streamCommandCode,
25
+ models,
26
+ });
27
+ }
@@ -0,0 +1,582 @@
1
+ import {
2
+ calculateCost,
3
+ createAssistantMessageEventStream,
4
+ type Api,
5
+ type AssistantMessage,
6
+ type Context,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ } from "@earendil-works/pi-ai";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { resolveCommandCodeApiKey } from "./auth.js";
12
+ import {
13
+ COMMAND_CODE_CLI_VERSION,
14
+ COMMAND_CODE_GENERATE_PATH,
15
+ COMMAND_CODE_MAX_REQUEST_MAX_TOKENS,
16
+ } from "./constants.js";
17
+
18
+ type StreamSimple = NonNullable<
19
+ Parameters<ExtensionAPI["registerProvider"]>[1]["streamSimple"]
20
+ >;
21
+ type StreamResult = ReturnType<StreamSimple>;
22
+
23
+ type CommandCodeMessage = {
24
+ role: "user" | "assistant" | "tool";
25
+ content: unknown;
26
+ };
27
+
28
+ type CommandCodeTool = {
29
+ type: "function";
30
+ name: string;
31
+ description: string;
32
+ input_schema: Record<string, unknown>;
33
+ };
34
+
35
+ type CommandCodeFinish = {
36
+ finishReason?: string;
37
+ totalUsage?: {
38
+ inputTokens?: number;
39
+ outputTokens?: number;
40
+ inputTokenDetails?: {
41
+ cacheReadTokens?: number;
42
+ cacheWriteTokens?: number;
43
+ };
44
+ };
45
+ };
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+
51
+ function stringValue(value: unknown): string | undefined {
52
+ return typeof value === "string" ? value : undefined;
53
+ }
54
+
55
+ function booleanValue(value: unknown): boolean | undefined {
56
+ return typeof value === "boolean" ? value : undefined;
57
+ }
58
+
59
+ function recordArray(value: unknown): Array<Record<string, unknown>> {
60
+ return Array.isArray(value) ? value.filter(isRecord) : [];
61
+ }
62
+
63
+ function recordOrEmpty(value: unknown): Record<string, unknown> {
64
+ if (isRecord(value)) {
65
+ return value;
66
+ }
67
+
68
+ if (typeof value === "string") {
69
+ try {
70
+ const parsed = JSON.parse(value) as unknown;
71
+ if (isRecord(parsed)) {
72
+ return parsed;
73
+ }
74
+ } catch {
75
+ return {};
76
+ }
77
+ }
78
+
79
+ return {};
80
+ }
81
+
82
+ function textContent(message: { content?: unknown }): string {
83
+ return recordArray(message.content)
84
+ .filter((part) => part.type === "text")
85
+ .map((part) => stringValue(part.text) ?? "")
86
+ .join("\n");
87
+ }
88
+
89
+ function getEnvironmentInfo(): string {
90
+ return `${process.platform}-${process.arch}, Node.js ${process.version}`;
91
+ }
92
+
93
+ function toJsonSchema(schema: unknown): unknown {
94
+ if (!isRecord(schema)) {
95
+ return {};
96
+ }
97
+
98
+ const kind = stringValue(schema.kind) ?? stringValue(schema.type);
99
+ const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
100
+ if (enumValues) {
101
+ return { type: typeof enumValues[0], enum: enumValues };
102
+ }
103
+
104
+ switch (kind) {
105
+ case "string":
106
+ case "String":
107
+ return { type: "string" };
108
+ case "number":
109
+ case "Number":
110
+ return { type: "number" };
111
+ case "boolean":
112
+ case "Boolean":
113
+ return { type: "boolean" };
114
+ case "object":
115
+ case "Object": {
116
+ const properties: Record<string, unknown> = {};
117
+ const inferredRequired: string[] = [];
118
+ const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined;
119
+ const optional = Array.isArray(schema.optional)
120
+ ? schema.optional.filter((item): item is string => typeof item === "string")
121
+ : [];
122
+
123
+ if (sourceProperties) {
124
+ for (const [key, value] of Object.entries(sourceProperties)) {
125
+ properties[key] = toJsonSchema(value);
126
+ const valueRecord = isRecord(value) ? value : undefined;
127
+ if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) {
128
+ inferredRequired.push(key);
129
+ }
130
+ }
131
+ }
132
+
133
+ const explicitRequired = Array.isArray(schema.required)
134
+ ? schema.required.filter((item): item is string => typeof item === "string")
135
+ : undefined;
136
+ const required = explicitRequired ?? inferredRequired;
137
+ const out: Record<string, unknown> = { type: "object" };
138
+ if (Object.keys(properties).length > 0) {
139
+ out.properties = properties;
140
+ }
141
+ if (required.length > 0) {
142
+ out.required = required;
143
+ }
144
+ return out;
145
+ }
146
+ case "array":
147
+ case "Array":
148
+ return {
149
+ type: "array",
150
+ items: toJsonSchema(schema.items ?? schema.element),
151
+ };
152
+ case "union":
153
+ case "Union": {
154
+ const variants = Array.isArray(schema.variants)
155
+ ? schema.variants
156
+ : Array.isArray(schema.anyOf)
157
+ ? schema.anyOf
158
+ : [];
159
+ for (const variant of variants) {
160
+ const converted = toJsonSchema(variant);
161
+ if (isRecord(converted) && Object.keys(converted).length > 0) {
162
+ return converted;
163
+ }
164
+ }
165
+ return {};
166
+ }
167
+ case "optional":
168
+ case "Optional":
169
+ return toJsonSchema(schema.wrapped ?? schema.inner);
170
+ default:
171
+ return {};
172
+ }
173
+ }
174
+
175
+ function parseStreamEventLine(line: string): Record<string, unknown> | undefined {
176
+ let trimmed = line.trim();
177
+ if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) {
178
+ return undefined;
179
+ }
180
+
181
+ if (trimmed.startsWith("data:")) {
182
+ trimmed = trimmed.slice(5).trim();
183
+ }
184
+
185
+ if (!trimmed || trimmed === "[DONE]") {
186
+ return undefined;
187
+ }
188
+
189
+ try {
190
+ const parsed = JSON.parse(trimmed) as unknown;
191
+ return isRecord(parsed) ? parsed : undefined;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ export function convertCommandCodeMessages(context: Context): CommandCodeMessage[] {
198
+ const completedToolCalls = new Set(
199
+ context.messages
200
+ .filter(
201
+ (message): message is Extract<Context["messages"][number], { role: "toolResult" }> =>
202
+ message.role === "toolResult",
203
+ )
204
+ .map((message) => message.toolCallId),
205
+ );
206
+
207
+ const converted: CommandCodeMessage[] = [];
208
+
209
+ for (const message of context.messages) {
210
+ if (message.role === "user") {
211
+ converted.push({
212
+ role: "user",
213
+ content: typeof message.content === "string" ? message.content : message.content,
214
+ });
215
+ continue;
216
+ }
217
+
218
+ if (message.role === "assistant") {
219
+ const content: Array<Record<string, unknown>> = [];
220
+
221
+ for (const block of message.content) {
222
+ if (block.type === "text") {
223
+ content.push({ type: "text", text: block.text });
224
+ } else if (block.type === "thinking") {
225
+ content.push({ type: "reasoning", text: block.thinking });
226
+ } else if (block.type === "toolCall" && completedToolCalls.has(block.id)) {
227
+ content.push({
228
+ type: "tool-call",
229
+ toolCallId: block.id,
230
+ toolName: block.name,
231
+ input: recordOrEmpty(block.arguments),
232
+ });
233
+ }
234
+ }
235
+
236
+ if (content.length > 0) {
237
+ converted.push({ role: "assistant", content });
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (!completedToolCalls.has(message.toolCallId)) {
243
+ continue;
244
+ }
245
+
246
+ converted.push({
247
+ role: "tool",
248
+ content: [
249
+ {
250
+ type: "tool-result",
251
+ toolCallId: message.toolCallId,
252
+ toolName: message.toolName,
253
+ output: message.isError
254
+ ? { type: "error-text", value: textContent(message) }
255
+ : { type: "text", value: textContent(message) },
256
+ },
257
+ ],
258
+ });
259
+ }
260
+
261
+ return converted;
262
+ }
263
+
264
+ export function convertCommandCodeTools(context: Context): CommandCodeTool[] {
265
+ return (context.tools ?? []).map((tool) => ({
266
+ type: "function",
267
+ name: tool.name,
268
+ description: tool.description,
269
+ input_schema: tool.parameters
270
+ ? (toJsonSchema(tool.parameters) as Record<string, unknown>)
271
+ : {},
272
+ }));
273
+ }
274
+
275
+ export function buildCommandCodeRequestBody(
276
+ model: Model<Api>,
277
+ context: Context,
278
+ options?: SimpleStreamOptions,
279
+ ) {
280
+ const maxTokens = Math.min(
281
+ options?.maxTokens ?? model.maxTokens,
282
+ COMMAND_CODE_MAX_REQUEST_MAX_TOKENS,
283
+ );
284
+ return {
285
+ config: {
286
+ workingDir: process.cwd(),
287
+ date: new Date().toISOString().slice(0, 10),
288
+ environment: getEnvironmentInfo(),
289
+ structure: [],
290
+ isGitRepo: false,
291
+ currentBranch: "",
292
+ mainBranch: "",
293
+ gitStatus: "",
294
+ recentCommits: [],
295
+ },
296
+ memory: "",
297
+ taste: "",
298
+ skills: null,
299
+ permissionMode: "standard",
300
+ params: {
301
+ model: model.id,
302
+ messages: convertCommandCodeMessages(context),
303
+ tools: convertCommandCodeTools(context),
304
+ system: context.systemPrompt ?? "",
305
+ max_tokens: maxTokens,
306
+ stream: true,
307
+ },
308
+ };
309
+ }
310
+
311
+ function mapFinishReason(reason?: string): "stop" | "length" | "toolUse" {
312
+ if (
313
+ reason === "max_tokens" ||
314
+ reason === "max_output_tokens" ||
315
+ reason === "max-tokens" ||
316
+ reason === "length"
317
+ ) {
318
+ return "length";
319
+ }
320
+ if (reason === "tool-calls") {
321
+ return "toolUse";
322
+ }
323
+ return "stop";
324
+ }
325
+
326
+ export function streamCommandCode(
327
+ model: Model<Api>,
328
+ context: Context,
329
+ options?: SimpleStreamOptions,
330
+ ) : StreamResult {
331
+ const stream = createAssistantMessageEventStream();
332
+
333
+ (async () => {
334
+ const output: AssistantMessage = {
335
+ role: "assistant",
336
+ content: [],
337
+ api: model.api,
338
+ provider: model.provider,
339
+ model: model.id,
340
+ usage: {
341
+ input: 0,
342
+ output: 0,
343
+ cacheRead: 0,
344
+ cacheWrite: 0,
345
+ totalTokens: 0,
346
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
347
+ },
348
+ stopReason: "stop",
349
+ timestamp: Date.now(),
350
+ };
351
+
352
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
353
+ let finished = false;
354
+
355
+ try {
356
+ const apiKey = await resolveCommandCodeApiKey(options?.apiKey);
357
+ if (!apiKey) {
358
+ throw new Error(
359
+ "Missing Command Code API key. Save one via /login or set COMMAND_CODE_API_KEY.",
360
+ );
361
+ }
362
+
363
+ const requestBody = buildCommandCodeRequestBody(model, context, options);
364
+ const sessionId = crypto.randomUUID();
365
+ const response = await fetch(`${model.baseUrl}${COMMAND_CODE_GENERATE_PATH}`, {
366
+ method: "POST",
367
+ signal: options?.signal,
368
+ headers: {
369
+ "content-type": "application/json",
370
+ authorization: `Bearer ${apiKey}`,
371
+ "x-command-code-version": COMMAND_CODE_CLI_VERSION,
372
+ "x-cli-environment": "production",
373
+ "x-project-slug": "pi-cc",
374
+ "x-taste-learning": "false",
375
+ "x-co-flag": "false",
376
+ "x-session-id": sessionId,
377
+ },
378
+ body: JSON.stringify(requestBody),
379
+ });
380
+
381
+ if (!response.ok) {
382
+ const body = await response.text().catch(() => "");
383
+ throw new Error(`Command Code API error (${response.status}): ${body || response.statusText}`);
384
+ }
385
+
386
+ if (!response.body) {
387
+ throw new Error("Command Code API error: empty response body");
388
+ }
389
+
390
+ stream.push({ type: "start", partial: output });
391
+
392
+ reader = response.body.getReader();
393
+ const decoder = new TextDecoder();
394
+ let buffer = "";
395
+ let textIndex: number | null = null;
396
+ let thinkingIndex: number | null = null;
397
+
398
+ const ensureText = () => {
399
+ if (textIndex !== null) {
400
+ return textIndex;
401
+ }
402
+ output.content.push({ type: "text", text: "" });
403
+ textIndex = output.content.length - 1;
404
+ stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
405
+ return textIndex;
406
+ };
407
+
408
+ const ensureThinking = () => {
409
+ if (thinkingIndex !== null) {
410
+ return thinkingIndex;
411
+ }
412
+ output.content.push({ type: "thinking", thinking: "" });
413
+ thinkingIndex = output.content.length - 1;
414
+ stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output });
415
+ return thinkingIndex;
416
+ };
417
+
418
+ const closeThinking = () => {
419
+ if (thinkingIndex === null) {
420
+ return;
421
+ }
422
+ const block = output.content[thinkingIndex];
423
+ if (block?.type === "thinking") {
424
+ stream.push({
425
+ type: "thinking_end",
426
+ contentIndex: thinkingIndex,
427
+ content: block.thinking,
428
+ partial: output,
429
+ });
430
+ }
431
+ thinkingIndex = null;
432
+ };
433
+
434
+ const applyFinish = (finish: CommandCodeFinish | undefined) => {
435
+ output.stopReason = mapFinishReason(finish?.finishReason);
436
+ output.usage.input = finish?.totalUsage?.inputTokens ?? 0;
437
+ output.usage.output = finish?.totalUsage?.outputTokens ?? 0;
438
+ output.usage.cacheRead = finish?.totalUsage?.inputTokenDetails?.cacheReadTokens ?? 0;
439
+ output.usage.cacheWrite = finish?.totalUsage?.inputTokenDetails?.cacheWriteTokens ?? 0;
440
+ output.usage.totalTokens =
441
+ output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
442
+ calculateCost(model, output.usage);
443
+ };
444
+
445
+ const processLine = (line: string) => {
446
+ const parsed = parseStreamEventLine(line);
447
+ if (!parsed) {
448
+ return;
449
+ }
450
+
451
+ const type = parsed.type;
452
+ if (type === "text-delta" && typeof parsed.text === "string") {
453
+ const index = ensureText();
454
+ const block = output.content[index];
455
+ if (block?.type === "text") {
456
+ block.text += parsed.text;
457
+ stream.push({ type: "text_delta", contentIndex: index, delta: parsed.text, partial: output });
458
+ }
459
+ return;
460
+ }
461
+
462
+ if (type === "reasoning-delta" && typeof parsed.text === "string") {
463
+ const index = ensureThinking();
464
+ const block = output.content[index];
465
+ if (block?.type === "thinking") {
466
+ block.thinking += parsed.text;
467
+ stream.push({
468
+ type: "thinking_delta",
469
+ contentIndex: index,
470
+ delta: parsed.text,
471
+ partial: output,
472
+ });
473
+ }
474
+ return;
475
+ }
476
+
477
+ if (type === "reasoning-end") {
478
+ closeThinking();
479
+ return;
480
+ }
481
+
482
+ if (type === "tool-call") {
483
+ const toolCallId =
484
+ typeof parsed.toolCallId === "string" ? parsed.toolCallId : crypto.randomUUID();
485
+ const toolName = typeof parsed.toolName === "string" ? parsed.toolName : "unknown";
486
+ const argumentsObject = recordOrEmpty(parsed.input);
487
+
488
+ output.content.push({
489
+ type: "toolCall",
490
+ id: toolCallId,
491
+ name: toolName,
492
+ arguments: argumentsObject,
493
+ });
494
+ const index = output.content.length - 1;
495
+ stream.push({ type: "toolcall_start", contentIndex: index, partial: output });
496
+ stream.push({
497
+ type: "toolcall_end",
498
+ contentIndex: index,
499
+ toolCall: output.content[index] as Extract<typeof output.content[number], { type: "toolCall" }>,
500
+ partial: output,
501
+ });
502
+ return;
503
+ }
504
+
505
+ if (type === "finish") {
506
+ applyFinish(parsed as CommandCodeFinish);
507
+ finished = true;
508
+ return;
509
+ }
510
+
511
+ if (type === "error") {
512
+ const errorRecord = isRecord(parsed.error) ? parsed.error : undefined;
513
+ const message = stringValue(errorRecord?.message) ?? stringValue(parsed.error) ?? "Command Code provider error";
514
+ throw new Error(message);
515
+ }
516
+ };
517
+
518
+ while (true) {
519
+ const { value, done } = await reader.read();
520
+ if (done) {
521
+ break;
522
+ }
523
+ buffer += decoder.decode(value, { stream: true });
524
+
525
+ let newlineIndex = buffer.indexOf("\n");
526
+ while (newlineIndex >= 0) {
527
+ const line = buffer.slice(0, newlineIndex);
528
+ buffer = buffer.slice(newlineIndex + 1);
529
+ processLine(line);
530
+ if (finished) {
531
+ break;
532
+ }
533
+ newlineIndex = buffer.indexOf("\n");
534
+ }
535
+
536
+ if (finished) {
537
+ break;
538
+ }
539
+ }
540
+
541
+ buffer += decoder.decode();
542
+ if (!finished && buffer.trim().length > 0) {
543
+ processLine(buffer);
544
+ }
545
+
546
+ closeThinking();
547
+ if (textIndex !== null) {
548
+ const block = output.content[textIndex];
549
+ if (block?.type === "text") {
550
+ stream.push({
551
+ type: "text_end",
552
+ contentIndex: textIndex,
553
+ content: block.text,
554
+ partial: output,
555
+ });
556
+ }
557
+ }
558
+
559
+ const doneReason: "stop" | "length" | "toolUse" =
560
+ output.stopReason === "length" || output.stopReason === "toolUse"
561
+ ? output.stopReason
562
+ : "stop";
563
+ stream.push({ type: "done", reason: doneReason, message: output });
564
+ stream.end();
565
+ } catch (error) {
566
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
567
+ output.errorMessage = options?.signal?.aborted
568
+ ? "Request aborted"
569
+ : error instanceof Error
570
+ ? error.message
571
+ : String(error);
572
+ stream.push({ type: "error", reason: output.stopReason, error: output });
573
+ stream.end();
574
+ } finally {
575
+ if (reader) {
576
+ await reader.cancel().catch(() => undefined);
577
+ }
578
+ }
579
+ })();
580
+
581
+ return stream as unknown as StreamResult;
582
+ }
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ProviderRegistrationFn } from "./shared/types.js";
3
+ import { registerCommandCodeProvider } from "./command-code/index.js";
4
+
5
+ const PROVIDER_REGISTRARS: ProviderRegistrationFn[] = [registerCommandCodeProvider];
6
+
7
+ export async function registerProviders(pi: ExtensionAPI): Promise<void> {
8
+ for (const registerProvider of PROVIDER_REGISTRARS) {
9
+ await registerProvider(pi);
10
+ }
11
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ProviderModel = {
4
+ id: string;
5
+ name: string;
6
+ reasoning: boolean;
7
+ input: ("text" | "image")[];
8
+ cost: {
9
+ input: number;
10
+ output: number;
11
+ cacheRead: number;
12
+ cacheWrite: number;
13
+ };
14
+ contextWindow: number;
15
+ maxTokens: number;
16
+ compat?: {
17
+ supportsUsageInStreaming?: boolean;
18
+ };
19
+ };
20
+
21
+ export type LiveModelsResponse = {
22
+ data?: Array<{
23
+ id?: string;
24
+ name?: string;
25
+ context_length?: number;
26
+ }>;
27
+ };
28
+
29
+ export type ProviderRegistrationFn = (pi: ExtensionAPI) => Promise<void>;