pi-free 2.0.12 → 2.0.13
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 +628 -608
- package/README.md +3 -22
- package/lib/built-in-toggle.ts +31 -4
- package/package.json +1 -1
- package/providers/dynamic-built-in/index.ts +26 -3
- package/providers/opencode-session.ts +371 -33
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ When you install pi-free, it:
|
|
|
20
20
|
|
|
21
21
|
3. **Filters to show only free models by default** for providers that expose pricing — You see only the models that cost $0 to use. Paid models are hidden until you explicitly toggle them on.
|
|
22
22
|
|
|
23
|
-
4. **Provides per-provider toggle commands** — Run `/toggle-{provider}` (e.g., `/toggle-kilo
|
|
23
|
+
4. **Provides per-provider toggle commands** — Run `/toggle-{provider}` (e.g., `/toggle-kilo`) to switch between free-only mode and showing all models including paid ones. Changes apply immediately and your preference is saved for the next Pi restart.
|
|
24
24
|
|
|
25
25
|
5. **Handles authentication for you** — OAuth flows (Kilo, Cline) open your browser automatically; API keys are read from `~/.pi/free.json` or environment variables
|
|
26
26
|
|
|
@@ -46,7 +46,6 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
46
46
|
|
|
47
47
|
**✅ Free Models (no payment required):**
|
|
48
48
|
|
|
49
|
-
- `opencode/` — OpenCode models (no setup required; toggle with `/toggle-opencode`)
|
|
50
49
|
- `kilo/` — Kilo models (free models available immediately, more after `/login kilo`)
|
|
51
50
|
- `openrouter/` — OpenRouter models (free account required)
|
|
52
51
|
- `cline/` — Cline models (run `/login cline` to use)
|
|
@@ -75,7 +74,6 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
75
74
|
- `cerebras/` — Cerebras models (when `CEREBRAS_API_KEY` set)
|
|
76
75
|
- `xai/` — xAI models (when `XAI_API_KEY` set)
|
|
77
76
|
- `huggingface/` — Hugging Face models (when `HF_TOKEN` set)
|
|
78
|
-
- `opencode/` — OpenCode models (fetched from opencode.ai/zen/v1, when `OPENCODE_API_KEY` set)
|
|
79
77
|
- `openrouter/` — OpenRouter models (fetched from openrouter.ai, when `OPENROUTER_API_KEY` set)
|
|
80
78
|
- `fastrouter/` — FastRouter models (always discovered, 170+ models, no auth for listing)
|
|
81
79
|
|
|
@@ -86,7 +84,6 @@ Free models are shown by default — look for the provider prefixes:
|
|
|
86
84
|
Want to see paid models too? Run the toggle command for your provider:
|
|
87
85
|
|
|
88
86
|
```
|
|
89
|
-
/toggle-opencode # Toggle OpenCode (✅ offers free models)
|
|
90
87
|
/toggle-kilo # Toggle Kilo (✅ offers free models)
|
|
91
88
|
/toggle-openrouter # Toggle OpenRouter (✅ offers free models)
|
|
92
89
|
/toggle-cline # Toggle Cline (✅ offers free models)
|
|
@@ -114,10 +111,6 @@ Want to see paid models too? Run the toggle command for your provider:
|
|
|
114
111
|
- **🔧 Dynamic providers** show all fetched models by default — the toggle filters the list when you have an API key configured
|
|
115
112
|
- **Freemium providers** show all models by default; you manage your usage limits via their dashboards
|
|
116
113
|
|
|
117
|
-
You'll see a notification like: `opencode: showing free models` or `opencode: showing all models`
|
|
118
|
-
|
|
119
|
-
**Note:** Built-in provider toggles such as OpenCode and OpenRouter update in the current session — no restart needed.
|
|
120
|
-
|
|
121
114
|
### 4. Add API keys for more providers (optional)
|
|
122
115
|
|
|
123
116
|
Some providers require a free account or API key.
|
|
@@ -204,7 +197,7 @@ Providers have different pricing models. pi-free handles them all:
|
|
|
204
197
|
|
|
205
198
|
**Provider types:**
|
|
206
199
|
|
|
207
|
-
- ✅ **Free providers** (
|
|
200
|
+
- ✅ **Free providers** (Kilo, Cline) — Toggle between free-only vs paid models
|
|
208
201
|
- 🔄 **Freemium** (NVIDIA, Ollama) — Free tier with limits, toggle shows all
|
|
209
202
|
- 🔧 **Dynamic API** (Mistral, Groq, Cerebras, xAI) — Fetched when API key configured, toggle filters the list
|
|
210
203
|
|
|
@@ -219,17 +212,6 @@ Authentication is handled automatically:
|
|
|
219
212
|
|
|
220
213
|
## Using Free Models (No Setup Required)
|
|
221
214
|
|
|
222
|
-
### OpenCode
|
|
223
|
-
|
|
224
|
-
Works immediately with zero setup:
|
|
225
|
-
|
|
226
|
-
1. Press `Ctrl+L`
|
|
227
|
-
2. Search for `opencode/`
|
|
228
|
-
3. Pick any model (e.g., `opencode/big-pickle`)
|
|
229
|
-
4. Start chatting
|
|
230
|
-
|
|
231
|
-
No account, no API key, no OAuth. Run `/toggle-opencode` to switch between free and paid OpenCode models.
|
|
232
|
-
|
|
233
215
|
### Kilo (free models, more after login)
|
|
234
216
|
|
|
235
217
|
Kilo shows free models immediately. To unlock all models, authenticate with Kilo's free OAuth:
|
|
@@ -450,7 +432,6 @@ Each provider has toggle commands to switch between free and all models:
|
|
|
450
432
|
|
|
451
433
|
| Command | Action |
|
|
452
434
|
| ----------------------- | -------------------------------------------------------- |
|
|
453
|
-
| `/toggle-opencode` | Toggle between free/all OpenCode models |
|
|
454
435
|
| `/toggle-kilo` | Toggle between free/all Kilo models |
|
|
455
436
|
| `/toggle-openrouter` | Toggle between free/all OpenRouter models |
|
|
456
437
|
| `/toggle-cline` | Toggle between free/all Cline models |
|
|
@@ -477,7 +458,7 @@ Each provider has toggle commands to switch between free and all models:
|
|
|
477
458
|
- **For 🔄 freemium providers**: Shows all models by default; toggle switches between filtered and full list
|
|
478
459
|
- **For 🔧 dynamic API providers**: Filters the model list when you have an API key configured
|
|
479
460
|
- **Persists your preference** to `~/.pi/free.json` for next startup
|
|
480
|
-
|
|
461
|
+
|
|
481
462
|
|
|
482
463
|
### Probe Commands (Health Check)
|
|
483
464
|
|
package/lib/built-in-toggle.ts
CHANGED
|
@@ -24,9 +24,18 @@ import {
|
|
|
24
24
|
registerWithGlobalToggle,
|
|
25
25
|
} from "./registry.ts";
|
|
26
26
|
import { createToggleState } from "./toggle-state.ts";
|
|
27
|
+
import {
|
|
28
|
+
OPENCODE_DYNAMIC_API,
|
|
29
|
+
createOpenCodeSessionTracker,
|
|
30
|
+
createOpenCodeStreamSimple,
|
|
31
|
+
isOpenCodeProvider,
|
|
32
|
+
} from "../providers/opencode-session.ts";
|
|
27
33
|
|
|
28
34
|
const _logger = createLogger("built-in-toggle");
|
|
29
35
|
|
|
36
|
+
// OpenCode requires per-request ids; see createOpenCodeStreamSimple().
|
|
37
|
+
const _opencodeSession = createOpenCodeSessionTracker();
|
|
38
|
+
|
|
30
39
|
// =============================================================================
|
|
31
40
|
// Configuration
|
|
32
41
|
// =============================================================================
|
|
@@ -38,6 +47,7 @@ interface BuiltInToggleConfig {
|
|
|
38
47
|
|
|
39
48
|
const BUILT_IN_TOGGLE_PROVIDERS: BuiltInToggleConfig[] = [
|
|
40
49
|
{ id: "opencode", getShowPaid: getOpencodeShowPaid },
|
|
50
|
+
{ id: "opencode-go", getShowPaid: getOpencodeShowPaid },
|
|
41
51
|
{ id: "openrouter", getShowPaid: getOpenrouterShowPaid },
|
|
42
52
|
];
|
|
43
53
|
|
|
@@ -113,7 +123,9 @@ function tryCaptureProvider(
|
|
|
113
123
|
);
|
|
114
124
|
if (providerModels.length === 0) return undefined;
|
|
115
125
|
|
|
116
|
-
const allModels = providerModels.map(
|
|
126
|
+
const allModels = providerModels.map((m: Model<Api>) =>
|
|
127
|
+
modelToProviderConfig(m, config.id),
|
|
128
|
+
);
|
|
117
129
|
const freeModels = allModels.filter((m: ProviderModelConfig) =>
|
|
118
130
|
isFreeModel({ ...m, provider: config.id }, allModels),
|
|
119
131
|
);
|
|
@@ -126,7 +138,10 @@ function tryCaptureProvider(
|
|
|
126
138
|
pi.registerProvider(config.id, {
|
|
127
139
|
baseUrl,
|
|
128
140
|
apiKey: apiKeyEnv,
|
|
129
|
-
api,
|
|
141
|
+
api: isOpenCodeProvider(config.id) ? OPENCODE_DYNAMIC_API : api,
|
|
142
|
+
...(isOpenCodeProvider(config.id)
|
|
143
|
+
? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
|
|
144
|
+
: {}),
|
|
130
145
|
models,
|
|
131
146
|
});
|
|
132
147
|
};
|
|
@@ -196,8 +211,11 @@ function registerToggleCommand(
|
|
|
196
211
|
// Helpers
|
|
197
212
|
// =============================================================================
|
|
198
213
|
|
|
199
|
-
function modelToProviderConfig(
|
|
200
|
-
|
|
214
|
+
function modelToProviderConfig(
|
|
215
|
+
m: Model<Api>,
|
|
216
|
+
providerId?: string,
|
|
217
|
+
): ProviderModelConfig {
|
|
218
|
+
const base: ProviderModelConfig = {
|
|
201
219
|
id: m.id,
|
|
202
220
|
name: m.name,
|
|
203
221
|
api: m.api,
|
|
@@ -209,6 +227,14 @@ function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
|
|
|
209
227
|
headers: m.headers,
|
|
210
228
|
compat: (m as any).compat,
|
|
211
229
|
};
|
|
230
|
+
|
|
231
|
+
// Use a custom OpenCode API wrapper so per-request headers are regenerated
|
|
232
|
+
// for every LLM call instead of being frozen at registration time.
|
|
233
|
+
if (providerId && isOpenCodeProvider(providerId)) {
|
|
234
|
+
base.api = OPENCODE_DYNAMIC_API;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return base;
|
|
212
238
|
}
|
|
213
239
|
|
|
214
240
|
// =============================================================================
|
|
@@ -253,6 +279,7 @@ function setupStatusBar(
|
|
|
253
279
|
function getApiKeyEnvForProvider(providerId: string): string {
|
|
254
280
|
const envMap: Record<string, string> = {
|
|
255
281
|
opencode: "OPENCODE_API_KEY",
|
|
282
|
+
"opencode-go": "OPENCODE_API_KEY",
|
|
256
283
|
openrouter: "OPENROUTER_API_KEY",
|
|
257
284
|
};
|
|
258
285
|
return envMap[providerId] || `${providerId.toUpperCase()}_API_KEY`;
|
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* OpenAI is intentionally skipped per user request.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
25
26
|
import type {
|
|
26
27
|
ExtensionAPI,
|
|
27
28
|
ProviderModelConfig,
|
|
@@ -46,9 +47,18 @@ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
|
|
|
46
47
|
import { fetchOpenRouterCompatibleModels } from "../model-fetcher.ts";
|
|
47
48
|
import { createToggleState } from "../../lib/toggle-state.ts";
|
|
48
49
|
import { enhanceWithCI } from "../../provider-helper.ts";
|
|
50
|
+
import {
|
|
51
|
+
OPENCODE_DYNAMIC_API,
|
|
52
|
+
createOpenCodeSessionTracker,
|
|
53
|
+
createOpenCodeStreamSimple,
|
|
54
|
+
isOpenCodeProvider,
|
|
55
|
+
} from "../opencode-session.ts";
|
|
49
56
|
|
|
50
57
|
const _logger = createLogger("dynamic-built-in");
|
|
51
58
|
|
|
59
|
+
// OpenCode headers must be regenerated for every LLM request.
|
|
60
|
+
const _opencodeSession = createOpenCodeSessionTracker();
|
|
61
|
+
|
|
52
62
|
// =============================================================================
|
|
53
63
|
// Generic Model Fetcher
|
|
54
64
|
// =============================================================================
|
|
@@ -170,7 +180,7 @@ interface DynamicProviderDef {
|
|
|
170
180
|
providerId: string;
|
|
171
181
|
getApiKey: () => string | undefined;
|
|
172
182
|
baseUrl: string;
|
|
173
|
-
api:
|
|
183
|
+
api: Api;
|
|
174
184
|
defaultShowPaid: boolean | (() => boolean);
|
|
175
185
|
/** Optional per-provider compat overrides (e.g., DeepSeek proxy). */
|
|
176
186
|
compat?: ProviderModelConfig["compat"];
|
|
@@ -217,10 +227,18 @@ const DYNAMIC_PROVIDERS: DynamicProviderDef[] = [
|
|
|
217
227
|
providerId: "opencode",
|
|
218
228
|
getApiKey: getOpencodeApiKey,
|
|
219
229
|
baseUrl: "https://opencode.ai/zen/v1",
|
|
220
|
-
api:
|
|
230
|
+
api: OPENCODE_DYNAMIC_API,
|
|
221
231
|
defaultShowPaid: getOpencodeShowPaid,
|
|
222
232
|
// OpenCode API returns no pricing — _pricingKnown=false, name-based detection
|
|
223
233
|
},
|
|
234
|
+
{
|
|
235
|
+
providerId: "opencode-go",
|
|
236
|
+
getApiKey: getOpencodeApiKey,
|
|
237
|
+
baseUrl: "https://opencode.ai/zen/go/v1",
|
|
238
|
+
api: OPENCODE_DYNAMIC_API,
|
|
239
|
+
defaultShowPaid: getOpencodeShowPaid,
|
|
240
|
+
// OpenCode Go uses the same OPENCODE_API_KEY and per-request headers
|
|
241
|
+
},
|
|
224
242
|
{
|
|
225
243
|
providerId: "openrouter",
|
|
226
244
|
getApiKey: getOpenrouterApiKey,
|
|
@@ -261,9 +279,11 @@ async function discoverAndRegister(
|
|
|
261
279
|
});
|
|
262
280
|
}
|
|
263
281
|
|
|
264
|
-
// Apply DeepSeek proxy compat to matching models
|
|
282
|
+
// Apply DeepSeek proxy compat to matching models. OpenCode headers are
|
|
283
|
+
// injected per request by createOpenCodeStreamSimple(), not stored here.
|
|
265
284
|
allModels = allModels.map((m) => ({
|
|
266
285
|
...m,
|
|
286
|
+
api: isOpenCodeProvider(config.providerId) ? OPENCODE_DYNAMIC_API : m.api,
|
|
267
287
|
compat: getProxyModelCompat(m) ?? m.compat,
|
|
268
288
|
}));
|
|
269
289
|
} catch (error) {
|
|
@@ -327,6 +347,9 @@ async function registerProvider(
|
|
|
327
347
|
baseUrl: config.baseUrl,
|
|
328
348
|
apiKey,
|
|
329
349
|
api: config.api,
|
|
350
|
+
...(isOpenCodeProvider(config.providerId)
|
|
351
|
+
? { streamSimple: createOpenCodeStreamSimple(_opencodeSession) }
|
|
352
|
+
: {}),
|
|
330
353
|
models: enhanceWithCI(models, config.providerId),
|
|
331
354
|
});
|
|
332
355
|
};
|
|
@@ -1,33 +1,371 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import type {
|
|
7
|
+
Api,
|
|
8
|
+
AssistantMessage,
|
|
9
|
+
AssistantMessageEvent,
|
|
10
|
+
AssistantMessageEventStream,
|
|
11
|
+
Context,
|
|
12
|
+
Model,
|
|
13
|
+
SimpleStreamOptions,
|
|
14
|
+
} from "@earendil-works/pi-ai";
|
|
15
|
+
import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
export const OPENCODE_DYNAMIC_API = "opencode-dynamic" as const;
|
|
18
|
+
|
|
19
|
+
export const OPENCODE_STATIC_HEADERS = {
|
|
20
|
+
"User-Agent": "opencode/1.15.5",
|
|
21
|
+
"x-opencode-client": "cli",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* OpenCode-native identifier generation.
|
|
26
|
+
*
|
|
27
|
+
* OpenCode's server uses checkHeaders to distinguish native CLI requests from
|
|
28
|
+
* third-party clients. Native identifiers use ULID-style prefixes:
|
|
29
|
+
*
|
|
30
|
+
* Session: ses_<hex><base62> (e.g. ses_a1b2c3d4e5f6g7h8i9j0k1l2m3n4)
|
|
31
|
+
* Request: msg_<hex><base62> (e.g. msg_01KA1B2C3D4E5F6G7H8I9J0K1L2M)
|
|
32
|
+
*
|
|
33
|
+
* If the server does not see the expected prefix it applies a fallback rate
|
|
34
|
+
* limit (~2 req/day) which causes models to "freeze" after a few prompts.
|
|
35
|
+
*/
|
|
36
|
+
function generateOpenCodeId(prefix: string): string {
|
|
37
|
+
// Timestamp in ms as big-endian hex (matches ULID-style sortability).
|
|
38
|
+
const ms = BigInt(Date.now());
|
|
39
|
+
const timeHex = ms.toString(16).padStart(12, "0");
|
|
40
|
+
// Random suffix (crypto) encoded as base62 for compactness.
|
|
41
|
+
const randomLen = 14;
|
|
42
|
+
const base62Chars =
|
|
43
|
+
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
44
|
+
const bytes = randomBytes(randomLen);
|
|
45
|
+
let suffix = "";
|
|
46
|
+
for (let i = 0; i < randomLen; i++) {
|
|
47
|
+
suffix += base62Chars[bytes[i] % 62];
|
|
48
|
+
}
|
|
49
|
+
return `${prefix}${timeHex}${suffix}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Shared OpenCode session/request tracking.
|
|
54
|
+
*
|
|
55
|
+
* OpenCode endpoints require native-format identifiers (ses_ / msg_ prefix)
|
|
56
|
+
* to receive the full daily rate limit. Without matching prefixes the server
|
|
57
|
+
* falls back to a ~2 req/day limit, causing free models to freeze after a
|
|
58
|
+
* couple of prompts.
|
|
59
|
+
*/
|
|
60
|
+
export function createOpenCodeSessionTracker() {
|
|
61
|
+
let sessionId = "";
|
|
62
|
+
|
|
63
|
+
function getSessionId(): string {
|
|
64
|
+
if (!sessionId) {
|
|
65
|
+
sessionId = generateOpenCodeId("ses_");
|
|
66
|
+
}
|
|
67
|
+
return sessionId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nextRequestId(): string {
|
|
71
|
+
return generateOpenCodeId("msg_");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
getSessionId,
|
|
76
|
+
nextRequestId,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type OpenCodeSessionTracker = ReturnType<
|
|
81
|
+
typeof createOpenCodeSessionTracker
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
export function createOpenCodeHeaders(
|
|
85
|
+
tracker: OpenCodeSessionTracker,
|
|
86
|
+
existingHeaders?: Record<string, string>,
|
|
87
|
+
): Record<string, string> {
|
|
88
|
+
return {
|
|
89
|
+
...existingHeaders,
|
|
90
|
+
...OPENCODE_STATIC_HEADERS,
|
|
91
|
+
"x-opencode-session": tracker.getSessionId(),
|
|
92
|
+
"x-opencode-request": tracker.nextRequestId(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isOpenCodeProvider(providerId: string): boolean {
|
|
97
|
+
return providerId === "opencode" || providerId === "opencode-go";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stripTrailingSlashes(value: string): string {
|
|
101
|
+
let end = value.length;
|
|
102
|
+
while (end > 0 && value.codePointAt(end - 1) === 47) {
|
|
103
|
+
end--;
|
|
104
|
+
}
|
|
105
|
+
return value.slice(0, end);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isAnthropicOpenCodeEndpoint(model: Model<Api>): boolean {
|
|
109
|
+
return !stripTrailingSlashes(model.baseUrl).endsWith("/v1");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type StreamSimpleFn<TApi extends Api> = (
|
|
113
|
+
model: Model<TApi>,
|
|
114
|
+
context: Context,
|
|
115
|
+
options?: SimpleStreamOptions,
|
|
116
|
+
) => AssistantMessageEventStream;
|
|
117
|
+
|
|
118
|
+
type AnthropicStreamModule = {
|
|
119
|
+
streamSimpleAnthropic: StreamSimpleFn<"anthropic-messages">;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type OpenAICompletionsStreamModule = {
|
|
123
|
+
streamSimpleOpenAICompletions: StreamSimpleFn<"openai-completions">;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const piAiSubpathCache = new Map<string, Promise<unknown>>();
|
|
127
|
+
|
|
128
|
+
async function importPiAiSubpath<T>(subpath: string): Promise<T> {
|
|
129
|
+
const specifier = `@earendil-works/pi-ai/${subpath}`;
|
|
130
|
+
const cached = piAiSubpathCache.get(specifier) as Promise<T> | undefined;
|
|
131
|
+
if (cached) return cached;
|
|
132
|
+
|
|
133
|
+
const promise = importPiAiSubpathUncached<T>(specifier);
|
|
134
|
+
piAiSubpathCache.set(specifier, promise);
|
|
135
|
+
return promise;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function importPiAiSubpathUncached<T>(specifier: string): Promise<T> {
|
|
139
|
+
try {
|
|
140
|
+
return (await import(specifier)) as T;
|
|
141
|
+
} catch (directError) {
|
|
142
|
+
const resolved = resolvePiAiSubpathFromPackage(specifier);
|
|
143
|
+
if (!resolved) throw directError;
|
|
144
|
+
try {
|
|
145
|
+
return (await import(pathToFileURL(resolved).href)) as T;
|
|
146
|
+
} catch {
|
|
147
|
+
throw directError;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const PI_AI_DEPENDENCY_CANARY = "openai";
|
|
153
|
+
|
|
154
|
+
function findPiAiPackageDir(requireBase: string): string | undefined {
|
|
155
|
+
try {
|
|
156
|
+
const require = createRequire(requireBase);
|
|
157
|
+
const resolved = require.resolve(PI_AI_DEPENDENCY_CANARY);
|
|
158
|
+
let dir = dirname(resolved);
|
|
159
|
+
while (dir !== dirname(dir)) {
|
|
160
|
+
if (basename(dir) === "node_modules") {
|
|
161
|
+
const piAiDir = join(dir, "@earendil-works", "pi-ai");
|
|
162
|
+
const pkgJsonPath = join(piAiDir, "package.json");
|
|
163
|
+
if (existsSync(pkgJsonPath) && lstatSync(pkgJsonPath).isFile()) {
|
|
164
|
+
return piAiDir;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
dir = dirname(dir);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Resolution failed — try the next base.
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolvePiAiSubpathFromPackage(specifier: string): string | undefined {
|
|
176
|
+
const subpath = specifier.replace("@earendil-works/pi-ai/", "");
|
|
177
|
+
const candidates = [process.argv[1], import.meta.url].filter(
|
|
178
|
+
(value): value is string => Boolean(value),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
const pkgDir = findPiAiPackageDir(candidate);
|
|
183
|
+
if (!pkgDir) continue;
|
|
184
|
+
try {
|
|
185
|
+
const pkg = JSON.parse(
|
|
186
|
+
readFileSync(join(pkgDir, "package.json"), "utf-8"),
|
|
187
|
+
);
|
|
188
|
+
const exportEntry = pkg.exports?.[`./${subpath}`];
|
|
189
|
+
const targetPath = exportEntry?.import ?? exportEntry?.default;
|
|
190
|
+
if (typeof targetPath === "string") {
|
|
191
|
+
return join(pkgDir, targetPath);
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Try the next resolution base.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class DeferredAssistantMessageEventStream {
|
|
202
|
+
private queue: AssistantMessageEvent[] = [];
|
|
203
|
+
private waiting: Array<
|
|
204
|
+
(result: IteratorResult<AssistantMessageEvent>) => void
|
|
205
|
+
> = [];
|
|
206
|
+
private done = false;
|
|
207
|
+
private resolveResult!: (message: AssistantMessage) => void;
|
|
208
|
+
private readonly finalResultPromise: Promise<AssistantMessage>;
|
|
209
|
+
|
|
210
|
+
constructor() {
|
|
211
|
+
this.finalResultPromise = new Promise((resolve) => {
|
|
212
|
+
this.resolveResult = resolve;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
push(event: AssistantMessageEvent): void {
|
|
217
|
+
if (this.done) return;
|
|
218
|
+
|
|
219
|
+
if (event.type === "done" || event.type === "error") {
|
|
220
|
+
this.done = true;
|
|
221
|
+
this.resolveResult(event.type === "done" ? event.message : event.error);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const waiter = this.waiting.shift();
|
|
225
|
+
if (waiter) {
|
|
226
|
+
waiter({ value: event, done: false });
|
|
227
|
+
} else {
|
|
228
|
+
this.queue.push(event);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
end(result?: AssistantMessage): void {
|
|
233
|
+
if (this.done) return;
|
|
234
|
+
this.done = true;
|
|
235
|
+
if (result) this.resolveResult(result);
|
|
236
|
+
while (this.waiting.length > 0) {
|
|
237
|
+
this.waiting.shift()?.({ value: undefined, done: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async *[Symbol.asyncIterator](): AsyncIterator<AssistantMessageEvent> {
|
|
242
|
+
while (true) {
|
|
243
|
+
if (this.queue.length > 0) {
|
|
244
|
+
yield this.queue.shift()!;
|
|
245
|
+
} else if (this.done) {
|
|
246
|
+
return;
|
|
247
|
+
} else {
|
|
248
|
+
const result = await new Promise<IteratorResult<AssistantMessageEvent>>(
|
|
249
|
+
(resolve) => this.waiting.push(resolve),
|
|
250
|
+
);
|
|
251
|
+
if (result.done) return;
|
|
252
|
+
yield result.value;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
result(): Promise<AssistantMessage> {
|
|
258
|
+
return this.finalResultPromise;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function createErrorMessage(
|
|
263
|
+
model: Model<Api>,
|
|
264
|
+
error: unknown,
|
|
265
|
+
): AssistantMessage {
|
|
266
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
267
|
+
return {
|
|
268
|
+
role: "assistant",
|
|
269
|
+
content: [],
|
|
270
|
+
api: model.api,
|
|
271
|
+
provider: model.provider,
|
|
272
|
+
model: model.id,
|
|
273
|
+
usage: {
|
|
274
|
+
input: 0,
|
|
275
|
+
output: 0,
|
|
276
|
+
cacheRead: 0,
|
|
277
|
+
cacheWrite: 0,
|
|
278
|
+
totalTokens: 0,
|
|
279
|
+
cost: {
|
|
280
|
+
input: 0,
|
|
281
|
+
output: 0,
|
|
282
|
+
cacheRead: 0,
|
|
283
|
+
cacheWrite: 0,
|
|
284
|
+
total: 0,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
stopReason: "error",
|
|
288
|
+
errorMessage: message,
|
|
289
|
+
timestamp: Date.now(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function pipeStream(
|
|
294
|
+
stream: DeferredAssistantMessageEventStream,
|
|
295
|
+
upstream: AssistantMessageEventStream,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
let finalMessage: AssistantMessage | undefined;
|
|
298
|
+
try {
|
|
299
|
+
for await (const event of upstream) {
|
|
300
|
+
stream.push(event);
|
|
301
|
+
if (event.type === "done") finalMessage = event.message;
|
|
302
|
+
if (event.type === "error") finalMessage = event.error;
|
|
303
|
+
}
|
|
304
|
+
stream.end(finalMessage ?? (await upstream.result()));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (finalMessage) {
|
|
307
|
+
stream.end(finalMessage);
|
|
308
|
+
} else {
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Pi's static model headers are evaluated at registration time. OpenCode treats
|
|
316
|
+
* x-opencode-request like a per-request id, so reusing one value across turns can
|
|
317
|
+
* leave later requests attached to an old/in-flight generation. Registering a
|
|
318
|
+
* provider-specific stream keeps the normal Pi parsers but refreshes headers for
|
|
319
|
+
* every LLM call.
|
|
320
|
+
*/
|
|
321
|
+
export function createOpenCodeStreamSimple(
|
|
322
|
+
tracker: OpenCodeSessionTracker,
|
|
323
|
+
): NonNullable<ProviderConfig["streamSimple"]> {
|
|
324
|
+
return (model, context, options) => {
|
|
325
|
+
const headers = createOpenCodeHeaders(tracker, options?.headers);
|
|
326
|
+
const stream = new DeferredAssistantMessageEventStream();
|
|
327
|
+
|
|
328
|
+
void (async () => {
|
|
329
|
+
try {
|
|
330
|
+
if (isAnthropicOpenCodeEndpoint(model)) {
|
|
331
|
+
const { streamSimpleAnthropic } =
|
|
332
|
+
await importPiAiSubpath<AnthropicStreamModule>("anthropic");
|
|
333
|
+
await pipeStream(
|
|
334
|
+
stream,
|
|
335
|
+
streamSimpleAnthropic(
|
|
336
|
+
{
|
|
337
|
+
...model,
|
|
338
|
+
api: "anthropic-messages",
|
|
339
|
+
} as Model<"anthropic-messages">,
|
|
340
|
+
context,
|
|
341
|
+
{ ...options, headers },
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const { streamSimpleOpenAICompletions } =
|
|
348
|
+
await importPiAiSubpath<OpenAICompletionsStreamModule>(
|
|
349
|
+
"openai-completions",
|
|
350
|
+
);
|
|
351
|
+
await pipeStream(
|
|
352
|
+
stream,
|
|
353
|
+
streamSimpleOpenAICompletions(
|
|
354
|
+
{
|
|
355
|
+
...model,
|
|
356
|
+
api: "openai-completions",
|
|
357
|
+
} as Model<"openai-completions">,
|
|
358
|
+
context,
|
|
359
|
+
{ ...options, headers },
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const errorMessage = createErrorMessage(model, error);
|
|
364
|
+
stream.push({ type: "start", partial: errorMessage });
|
|
365
|
+
stream.push({ type: "error", reason: "error", error: errorMessage });
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
|
|
369
|
+
return stream as unknown as AssistantMessageEventStream;
|
|
370
|
+
};
|
|
371
|
+
}
|