moonpi 0.4.3 → 0.4.5
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/README.md +183 -85
- package/package.json +1 -1
- package/src/config.ts +14 -0
- package/src/context-files.ts +15 -0
- package/src/custom-providers.ts +673 -0
- package/src/index.ts +3 -1
- package/src/modes.ts +9 -3
- package/src/synthetic.ts +173 -1
- package/src/types.ts +6 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ npm install -g @mariozechner/pi-coding-agent
|
|
|
54
54
|
Then, install the `moonpi` extension set directly via `pi`:
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
pi install
|
|
57
|
+
pi install npm:moonpi
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
|
|
@@ -182,7 +182,177 @@ Example output:
|
|
|
182
182
|
|
|
183
183
|
At startup, a notification shows which files are currently selected for injection.
|
|
184
184
|
|
|
185
|
-
###
|
|
185
|
+
### `/context:clear`
|
|
186
|
+
|
|
187
|
+
Deselects all currently active context files (both `/pick`-selected and auto-discovered). After clearing, no files are injected into the prompt until you run `/pick` again.
|
|
188
|
+
|
|
189
|
+
Example output:
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
Cleared context file selection (3 file(s) deselected). Use /pick to select files.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Custom Providers
|
|
196
|
+
|
|
197
|
+
moonpi includes the support from some custom providers, and provides slash commands to manage custom providers in `~/.pi/agent/models.json`.
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
### Synthetic Provider
|
|
201
|
+
|
|
202
|
+
Moonpi registers Synthetic as the `synthetic` provider using the OpenAI-compatible endpoint.
|
|
203
|
+
|
|
204
|
+
Configure credentials with either:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
export SYNTHETIC_API_KEY=...
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
or run:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
/login
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your weekly token and rolling 5h usage quotas:
|
|
217
|
+
|
|
218
|
+

|
|
219
|
+
|
|
220
|
+
#### Web Search
|
|
221
|
+
|
|
222
|
+
When authenticated with Synthetic, moonpi makes a `web_search` tool available to the agent. This tool uses the [Synthetic search API](https://docs.synthetic.new) to perform zero-data-retention web searches and return results with title, URL, published date, and text excerpt.
|
|
223
|
+
|
|
224
|
+
The tool is **only visible to the LLM when logged in with Synthetic** — it is not registered at all when no API key is configured, so the model never sees it or knows it exists.
|
|
225
|
+
|
|
226
|
+
To disable the search tool even when logged in, set `synthetic.search.enabled` to `false` in your config:
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"synthetic": {
|
|
231
|
+
"search": {
|
|
232
|
+
"enabled": false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Managing Custom Providers
|
|
239
|
+
|
|
240
|
+
moonpi provides five slash commands to manage custom providers in `~/.pi/agent/models.json`:
|
|
241
|
+
|
|
242
|
+
#### `/custom-provider:add-provider`
|
|
243
|
+
|
|
244
|
+
Interactive wizard that adds a new custom provider. Prompts for:
|
|
245
|
+
|
|
246
|
+
1. **Provider name** — a unique identifier (e.g. `my-vllm`)
|
|
247
|
+
2. **API type** — select from all supported APIs (`openai-completions`, `anthropic-messages`, `google-generative-ai`, etc.)
|
|
248
|
+
3. **Base URL** — the API endpoint (sensible defaults per API type)
|
|
249
|
+
4. **API key** — your API key or an environment variable name
|
|
250
|
+
|
|
251
|
+
Example `models.json` result:
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{
|
|
255
|
+
"providers": {
|
|
256
|
+
"my-vllm": {
|
|
257
|
+
"baseUrl": "http://127.0.0.1:8000/v1",
|
|
258
|
+
"api": "openai-completions",
|
|
259
|
+
"apiKey": "none",
|
|
260
|
+
"models": []
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
#### `/custom-provider:add-model`
|
|
267
|
+
|
|
268
|
+
Adds a model to an existing custom provider. Prompts for:
|
|
269
|
+
|
|
270
|
+
1. **Provider** — select from existing custom providers
|
|
271
|
+
2. **Model ID** — the model identifier (e.g. `Qwen/Qwen3-27B`)
|
|
272
|
+
3. **Display name** — optional human-readable name
|
|
273
|
+
4. **Advanced options** — optional configuration for reasoning, context window, max tokens, image input, and API type override
|
|
274
|
+
|
|
275
|
+
#### `/custom-provider:scan-models`
|
|
276
|
+
|
|
277
|
+
Auto-detects models from an OpenAI-compatible provider endpoint. Works with providers using `openai-completions` or `openai-responses` API.
|
|
278
|
+
|
|
279
|
+
1. **Select provider** — choose from OpenAI-compatible custom providers
|
|
280
|
+
2. **Scan** — fetches `/v1/models` from the provider's base URL
|
|
281
|
+
3. **Select models** — checkbox UI shows all discovered models; already-added models are greyed out
|
|
282
|
+
- `Space` to toggle individual models
|
|
283
|
+
- `a`/`A` to select/deselect all
|
|
284
|
+
- `Enter` to confirm (adds all new models if none selected)
|
|
285
|
+
4. Auto-fills `contextWindow` from `max_model_len` when available
|
|
286
|
+
|
|
287
|
+
#### `/custom-provider:remove-provider`
|
|
288
|
+
|
|
289
|
+
Removes a custom provider and all its models from `models.json`. Asks for confirmation before deleting.
|
|
290
|
+
|
|
291
|
+
#### `/custom-provider:remove-model`
|
|
292
|
+
|
|
293
|
+
Removes a single model from a custom provider. Prompts for the provider, then the model to remove, with confirmation.
|
|
294
|
+
|
|
295
|
+
After any change, run `/reload` to refresh pi's model registry and make new models available in `/model`.
|
|
296
|
+
|
|
297
|
+
## Moonpi loop
|
|
298
|
+
|
|
299
|
+
moonpi includes sprint-oriented loop for larger projects.
|
|
300
|
+
|
|
301
|
+
### `/sprint:init`
|
|
302
|
+
|
|
303
|
+
Creates a new sprint for a larger project.
|
|
304
|
+
|
|
305
|
+
This command asks **one question**: the sprint objective.
|
|
306
|
+
|
|
307
|
+
It then delegates SPRINT.md and TASKS.md creation to the agent, which writes:
|
|
308
|
+
|
|
309
|
+
```txt
|
|
310
|
+
./sprints/<sprint_number>/SPRINT.md
|
|
311
|
+
./sprints/<sprint_number>/TASKS.md
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The sprint is divided into phases.
|
|
315
|
+
|
|
316
|
+
Each phase includes tasks and verification steps that define when the phase is complete.
|
|
317
|
+
|
|
318
|
+
The goal is to turn a vague big project into a concrete, phased execution plan.
|
|
319
|
+
|
|
320
|
+
### `/sprint:loop`
|
|
321
|
+
|
|
322
|
+
Runs the latest sprint phase-by-phase. Automatically picks the most recent sprint.
|
|
323
|
+
|
|
324
|
+
The loop works like this:
|
|
325
|
+
|
|
326
|
+
1. complete one phase
|
|
327
|
+
2. mark completed tasks in:
|
|
328
|
+
|
|
329
|
+
```txt
|
|
330
|
+
./sprints/<sprint_number>/TASKS.md
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
3. compact the conversation/context
|
|
334
|
+
4. proceed to the next phase
|
|
335
|
+
5. repeat until the sprint is complete
|
|
336
|
+
|
|
337
|
+
The model signals the end of a phase by calling a special `end_phase` tool.
|
|
338
|
+
|
|
339
|
+
This keeps long-running projects simple, resumable, and grounded in actual files.
|
|
340
|
+
|
|
341
|
+
### Does it work?
|
|
342
|
+
|
|
343
|
+
Watch a drastically sped-up video of `Qwen/Qwen3.6-27B` working unattended for over an hour on this sprint prompt:
|
|
344
|
+
|
|
345
|
+
```text
|
|
346
|
+
create WebOS a fully functional web-based operating system with apps, games and everything
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
https://github.com/user-attachments/assets/92670a55-a3c4-4c31-a4a2-0afc449f0137
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
And judge the result yourself [here](https://qwen36-27b-moonpi-webos.netlify.app/).
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
## Configuration
|
|
186
356
|
|
|
187
357
|
Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
|
|
188
358
|
|
|
@@ -191,6 +361,11 @@ Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
|
|
|
191
361
|
"defaultMode": "auto",
|
|
192
362
|
"preserveExternalTools": false,
|
|
193
363
|
"customEditor": true,
|
|
364
|
+
"synthetic": {
|
|
365
|
+
"search": {
|
|
366
|
+
"enabled": true
|
|
367
|
+
}
|
|
368
|
+
},
|
|
194
369
|
"contextFiles": {
|
|
195
370
|
"enabled": true,
|
|
196
371
|
"fileNames": ["README.md", "SPECS.md", "SPRINT.md"],
|
|
@@ -230,6 +405,12 @@ Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
|
|
|
230
405
|
| `preserveExternalTools` | `false` | When `true`, tools registered by other extensions are kept alongside moonpi tools when applying mode tool restrictions |
|
|
231
406
|
| `customEditor` | `true` | When `false`, moonpi skips installing its mode-colored editor, preserving editor customizations from other extensions |
|
|
232
407
|
|
|
408
|
+
#### Synthetic
|
|
409
|
+
|
|
410
|
+
| Field | Default | Description |
|
|
411
|
+
| --- | --- | --- |
|
|
412
|
+
| `synthetic.search.enabled` | `true` | When `false`, the `web_search` tool is not registered even if logged in with Synthetic |
|
|
413
|
+
|
|
233
414
|
#### Keybindings
|
|
234
415
|
|
|
235
416
|
| Field | Default | Description |
|
|
@@ -316,89 +497,6 @@ If the model tries to write to a file without reading it first, the write tool r
|
|
|
316
497
|
|
|
317
498
|
This prevents careless overwrites and forces the agent to inspect the current state of a file before modifying it.
|
|
318
499
|
|
|
319
|
-
## Moonpi loop
|
|
320
|
-
|
|
321
|
-
moonpi includes sprint-oriented loop for larger projects.
|
|
322
|
-
|
|
323
|
-
### `/sprint:init`
|
|
324
|
-
|
|
325
|
-
Creates a new sprint for a larger project.
|
|
326
|
-
|
|
327
|
-
This command asks **one question**: the sprint objective.
|
|
328
|
-
|
|
329
|
-
It then delegates SPRINT.md and TASKS.md creation to the agent, which writes:
|
|
330
|
-
|
|
331
|
-
```txt
|
|
332
|
-
./sprints/<sprint_number>/SPRINT.md
|
|
333
|
-
./sprints/<sprint_number>/TASKS.md
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
The sprint is divided into phases.
|
|
337
|
-
|
|
338
|
-
Each phase includes tasks and verification steps that define when the phase is complete.
|
|
339
|
-
|
|
340
|
-
The goal is to turn a vague big project into a concrete, phased execution plan.
|
|
341
|
-
|
|
342
|
-
### `/sprint:loop`
|
|
343
|
-
|
|
344
|
-
Runs the latest sprint phase-by-phase. Automatically picks the most recent sprint.
|
|
345
|
-
|
|
346
|
-
The loop works like this:
|
|
347
|
-
|
|
348
|
-
1. complete one phase
|
|
349
|
-
2. mark completed tasks in:
|
|
350
|
-
|
|
351
|
-
```txt
|
|
352
|
-
./sprints/<sprint_number>/TASKS.md
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
3. compact the conversation/context
|
|
356
|
-
4. proceed to the next phase
|
|
357
|
-
5. repeat until the sprint is complete
|
|
358
|
-
|
|
359
|
-
The model signals the end of a phase by calling a special `end_phase` tool.
|
|
360
|
-
|
|
361
|
-
This keeps long-running projects simple, resumable, and grounded in actual files.
|
|
362
|
-
|
|
363
|
-
### Does it work?
|
|
364
|
-
|
|
365
|
-
Watch a drastically sped-up video of `Qwen/Qwen3.6-27B` working unattended for over an hour on this sprint prompt:
|
|
366
|
-
|
|
367
|
-
```text
|
|
368
|
-
create WebOS a fully functional web-based operating system with apps, games and everything
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
https://github.com/user-attachments/assets/92670a55-a3c4-4c31-a4a2-0afc449f0137
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
And judge the result yourself [here](https://qwen36-27b-moonpi-webos.netlify.app/).
|
|
375
|
-
|
|
376
|
-
## Custom Providers
|
|
377
|
-
|
|
378
|
-
moonpi includes the support from some custom providers.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
### Synthetic Provider
|
|
382
|
-
|
|
383
|
-
Moonpi registers Synthetic as the `synthetic` provider using the OpenAI-compatible endpoint.
|
|
384
|
-
|
|
385
|
-
Configure credentials with either:
|
|
386
|
-
|
|
387
|
-
```bash
|
|
388
|
-
export SYNTHETIC_API_KEY=...
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
or run:
|
|
392
|
-
|
|
393
|
-
```text
|
|
394
|
-
/login
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your weekly token and rolling 5h usage quotas:
|
|
398
|
-
|
|
399
|
-

|
|
400
|
-
|
|
401
|
-
|
|
402
500
|
## Why moonpi?
|
|
403
501
|
|
|
404
502
|
moonpi is not trying to be a giant agent framework.
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -42,6 +42,11 @@ export const DEFAULT_CONFIG: MoonpiConfig = {
|
|
|
42
42
|
defaultMode: "auto",
|
|
43
43
|
preserveExternalTools: true,
|
|
44
44
|
customEditor: true,
|
|
45
|
+
synthetic: {
|
|
46
|
+
search: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
45
50
|
contextFiles: {
|
|
46
51
|
enabled: true,
|
|
47
52
|
fileNames: ["README.md", "SPECS.md", "SPRINT.md"],
|
|
@@ -125,6 +130,7 @@ function mergeConfig(base: MoonpiConfig, raw: Record<string, unknown> | undefine
|
|
|
125
130
|
defaultMode: base.defaultMode,
|
|
126
131
|
preserveExternalTools: base.preserveExternalTools,
|
|
127
132
|
customEditor: base.customEditor,
|
|
133
|
+
synthetic: { ...base.synthetic },
|
|
128
134
|
contextFiles: { ...base.contextFiles },
|
|
129
135
|
guards: { ...base.guards },
|
|
130
136
|
keybindings: { ...base.keybindings },
|
|
@@ -134,6 +140,14 @@ function mergeConfig(base: MoonpiConfig, raw: Record<string, unknown> | undefine
|
|
|
134
140
|
if (typeof raw.preserveExternalTools === "boolean") next.preserveExternalTools = raw.preserveExternalTools;
|
|
135
141
|
if (typeof raw.customEditor === "boolean") next.customEditor = raw.customEditor;
|
|
136
142
|
|
|
143
|
+
if (isRecord(raw.synthetic)) {
|
|
144
|
+
if (isRecord(raw.synthetic.search)) {
|
|
145
|
+
if (typeof raw.synthetic.search.enabled === "boolean") {
|
|
146
|
+
next.synthetic.search.enabled = raw.synthetic.search.enabled;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
if (isRecord(raw.contextFiles)) {
|
|
138
152
|
const context = raw.contextFiles;
|
|
139
153
|
if (typeof context.enabled === "boolean") next.contextFiles.enabled = context.enabled;
|
package/src/context-files.ts
CHANGED
|
@@ -484,6 +484,21 @@ export function installContextFiles(pi: ExtensionAPI, controller: MoonpiControll
|
|
|
484
484
|
},
|
|
485
485
|
});
|
|
486
486
|
|
|
487
|
+
pi.registerCommand("context:clear", {
|
|
488
|
+
description: "Deselect all context files (clear /pick and auto-discovered files)",
|
|
489
|
+
handler: async (_args, ctx) => {
|
|
490
|
+
if (!controller.config.contextFiles.enabled) {
|
|
491
|
+
ctx.ui.notify("moonpi context file injection is disabled in /moonpi:settings.", "warning");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const previousCount = getEffectiveSelectedContextFilePaths(ctx.cwd, controller).length;
|
|
496
|
+
controller.state.selectedContextFilePaths = [];
|
|
497
|
+
controller.persist();
|
|
498
|
+
ctx.ui.notify(`Cleared context file selection (${previousCount} file(s) deselected). Use /pick to select files.`, "info");
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
487
502
|
pi.on("session_start", async (_event, ctx) => {
|
|
488
503
|
controller.restoreFromSession(ctx);
|
|
489
504
|
const discovery = controller.state.selectedContextFilePaths === undefined ? findDefaultContextFilePaths(ctx.cwd, controller) : undefined;
|
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
6
|
+
|
|
7
|
+
// =========================================================================
|
|
8
|
+
// models.json helpers
|
|
9
|
+
// =========================================================================
|
|
10
|
+
|
|
11
|
+
/** The API types that custom providers can use. */
|
|
12
|
+
const KNOWN_APIS = [
|
|
13
|
+
"openai-completions",
|
|
14
|
+
"openai-responses",
|
|
15
|
+
"anthropic-messages",
|
|
16
|
+
"google-generative-ai",
|
|
17
|
+
"google-vertex",
|
|
18
|
+
"mistral-conversations",
|
|
19
|
+
"bedrock-converse-stream",
|
|
20
|
+
"azure-openai-responses",
|
|
21
|
+
"openai-codex-responses",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
/** APIs that are OpenAI-compatible and support the /v1/models endpoint. */
|
|
25
|
+
const OPENAI_COMPAT_APIS = new Set([
|
|
26
|
+
"openai-completions",
|
|
27
|
+
"openai-responses",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
interface ModelDefinition {
|
|
31
|
+
id: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
api?: string;
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
reasoning?: boolean;
|
|
36
|
+
input?: string[];
|
|
37
|
+
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
38
|
+
contextWindow?: number;
|
|
39
|
+
maxTokens?: number;
|
|
40
|
+
headers?: Record<string, string>;
|
|
41
|
+
compat?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ProviderConfig {
|
|
45
|
+
name?: string;
|
|
46
|
+
baseUrl?: string;
|
|
47
|
+
apiKey?: string;
|
|
48
|
+
api?: string;
|
|
49
|
+
headers?: Record<string, string>;
|
|
50
|
+
compat?: Record<string, unknown>;
|
|
51
|
+
authHeader?: boolean;
|
|
52
|
+
models?: ModelDefinition[];
|
|
53
|
+
modelOverrides?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ModelsConfig {
|
|
57
|
+
providers: Record<string, ProviderConfig>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getModelsJsonPath(): string {
|
|
61
|
+
return join(getAgentDir(), "models.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readModelsJson(): ModelsConfig {
|
|
65
|
+
const filePath = getModelsJsonPath();
|
|
66
|
+
if (!existsSync(filePath)) {
|
|
67
|
+
return { providers: {} };
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
71
|
+
// Strip // line comments (same as pi's model-registry.ts)
|
|
72
|
+
const stripped = raw
|
|
73
|
+
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
|
|
74
|
+
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
|
|
75
|
+
return JSON.parse(stripped) as ModelsConfig;
|
|
76
|
+
} catch {
|
|
77
|
+
return { providers: {} };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeModelsJson(config: ModelsConfig): void {
|
|
82
|
+
const filePath = getModelsJsonPath();
|
|
83
|
+
const dir = join(filePath, "..");
|
|
84
|
+
if (!existsSync(dir)) {
|
|
85
|
+
mkdirSync(dir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// =========================================================================
|
|
91
|
+
// Command: /custom-provider:add-provider
|
|
92
|
+
// =========================================================================
|
|
93
|
+
|
|
94
|
+
async function addProviderCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
95
|
+
const config = readModelsJson();
|
|
96
|
+
|
|
97
|
+
// 1. Provider name
|
|
98
|
+
const providerName = await ctx.ui.input("Provider name (e.g. my-vllm)", "my-provider");
|
|
99
|
+
if (!providerName?.trim()) {
|
|
100
|
+
ctx.ui.notify("Cancelled: no provider name given.", "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const name = providerName.trim();
|
|
104
|
+
|
|
105
|
+
if (config.providers[name]) {
|
|
106
|
+
const overwrite = await ctx.ui.confirm(
|
|
107
|
+
`Provider "${name}" already exists. Overwrite?`,
|
|
108
|
+
`Existing provider has baseUrl: ${config.providers[name]!.baseUrl ?? "none"}, api: ${config.providers[name]!.api ?? "none"}`,
|
|
109
|
+
);
|
|
110
|
+
if (!overwrite) {
|
|
111
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. API type
|
|
117
|
+
const selectedApi = await ctx.ui.select("Select API type", [...KNOWN_APIS]);
|
|
118
|
+
if (!selectedApi) {
|
|
119
|
+
ctx.ui.notify("Cancelled: no API type selected.", "warning");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. Base URL
|
|
124
|
+
const defaultBaseUrl = selectedApi === "anthropic-messages"
|
|
125
|
+
? "https://api.anthropic.com"
|
|
126
|
+
: selectedApi === "google-generative-ai"
|
|
127
|
+
? "https://generativelanguage.googleapis.com"
|
|
128
|
+
: "";
|
|
129
|
+
const baseUrl = await ctx.ui.input("Base URL", defaultBaseUrl || "http://localhost:8000/v1");
|
|
130
|
+
if (!baseUrl?.trim()) {
|
|
131
|
+
ctx.ui.notify("Cancelled: no base URL given.", "warning");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. API key
|
|
136
|
+
const apiKey = await ctx.ui.input("API key (or env var name like MY_API_KEY)", "none");
|
|
137
|
+
if (!apiKey?.trim()) {
|
|
138
|
+
ctx.ui.notify("Cancelled: no API key given.", "warning");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build the provider config
|
|
143
|
+
const providerConfig: ProviderConfig = {
|
|
144
|
+
baseUrl: baseUrl.trim(),
|
|
145
|
+
api: selectedApi,
|
|
146
|
+
apiKey: apiKey.trim(),
|
|
147
|
+
models: [],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
config.providers[name] = providerConfig;
|
|
151
|
+
writeModelsJson(config);
|
|
152
|
+
|
|
153
|
+
ctx.ui.notify(
|
|
154
|
+
`Provider "${name}" added to models.json.\n` +
|
|
155
|
+
` API: ${selectedApi}\n` +
|
|
156
|
+
` Base URL: ${baseUrl.trim()}\n` +
|
|
157
|
+
` API Key: ${apiKey.trim()}\n\n` +
|
|
158
|
+
`Use /custom-provider:add-model to add models, or /custom-provider:scan-models to auto-detect them.\n` +
|
|
159
|
+
`Run /reload to refresh pi's model registry.`,
|
|
160
|
+
"info",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// =========================================================================
|
|
165
|
+
// Command: /custom-provider:add-model
|
|
166
|
+
// =========================================================================
|
|
167
|
+
|
|
168
|
+
async function addModelCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
169
|
+
const config = readModelsJson();
|
|
170
|
+
const providerNames = Object.keys(config.providers);
|
|
171
|
+
|
|
172
|
+
if (providerNames.length === 0) {
|
|
173
|
+
ctx.ui.notify(
|
|
174
|
+
"No custom providers found. Use /custom-provider:add-provider first.",
|
|
175
|
+
"warning",
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 1. Select provider
|
|
181
|
+
const selectedProvider = await ctx.ui.select("Select provider to add model to", providerNames);
|
|
182
|
+
if (!selectedProvider) {
|
|
183
|
+
ctx.ui.notify("Cancelled: no provider selected.", "warning");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const provider = config.providers[selectedProvider]!;
|
|
188
|
+
|
|
189
|
+
// 2. Model ID
|
|
190
|
+
const modelId = await ctx.ui.input("Model ID (e.g. Qwen/Qwen3-27B)", "");
|
|
191
|
+
if (!modelId?.trim()) {
|
|
192
|
+
ctx.ui.notify("Cancelled: no model ID given.", "warning");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check for duplicates
|
|
197
|
+
const existingModels = provider.models ?? [];
|
|
198
|
+
if (existingModels.some((m) => m.id === modelId.trim())) {
|
|
199
|
+
ctx.ui.notify(`Model "${modelId.trim()}" already exists in provider "${selectedProvider}".`, "warning");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 3. Optional fields
|
|
204
|
+
const modelName = await ctx.ui.input("Model display name (optional, press Enter to skip)", modelId.trim());
|
|
205
|
+
|
|
206
|
+
const wantsAdvanced = await ctx.ui.confirm("Configure advanced options?", "Context window, max tokens, reasoning, input types");
|
|
207
|
+
const modelDef: ModelDefinition = {
|
|
208
|
+
id: modelId.trim(),
|
|
209
|
+
...(modelName?.trim() && modelName.trim() !== modelId.trim() ? { name: modelName.trim() } : {}),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (wantsAdvanced) {
|
|
213
|
+
// API override
|
|
214
|
+
const overrideApi = await ctx.ui.confirm("Override API type for this model?", `Provider default: ${provider.api ?? "unknown"}`);
|
|
215
|
+
if (overrideApi) {
|
|
216
|
+
const modelApi = await ctx.ui.select("Select model API type", [...KNOWN_APIS]);
|
|
217
|
+
if (modelApi) modelDef.api = modelApi;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Reasoning
|
|
221
|
+
const reasoning = await ctx.ui.confirm("Does this model support reasoning/thinking?", "");
|
|
222
|
+
modelDef.reasoning = reasoning;
|
|
223
|
+
|
|
224
|
+
// Context window
|
|
225
|
+
const ctxWindow = await ctx.ui.input("Context window (tokens)", "128000");
|
|
226
|
+
if (ctxWindow?.trim()) {
|
|
227
|
+
const val = parseInt(ctxWindow.trim(), 10);
|
|
228
|
+
if (!Number.isNaN(val) && val > 0) modelDef.contextWindow = val;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Max tokens
|
|
232
|
+
const maxTokens = await ctx.ui.input("Max output tokens", "16384");
|
|
233
|
+
if (maxTokens?.trim()) {
|
|
234
|
+
const val = parseInt(maxTokens.trim(), 10);
|
|
235
|
+
if (!Number.isNaN(val) && val > 0) modelDef.maxTokens = val;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Input types
|
|
239
|
+
const hasImage = await ctx.ui.confirm("Supports image input?", "");
|
|
240
|
+
modelDef.input = hasImage ? ["text", "image"] : ["text"];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Add model to provider
|
|
244
|
+
if (!provider.models) provider.models = [];
|
|
245
|
+
provider.models.push(modelDef);
|
|
246
|
+
writeModelsJson(config);
|
|
247
|
+
|
|
248
|
+
const summary = [
|
|
249
|
+
`Model "${modelDef.id}" added to provider "${selectedProvider}" in models.json.`,
|
|
250
|
+
];
|
|
251
|
+
if (modelDef.name) summary.push(` Display name: ${modelDef.name}`);
|
|
252
|
+
if (modelDef.reasoning !== undefined) summary.push(` Reasoning: ${modelDef.reasoning}`);
|
|
253
|
+
if (modelDef.contextWindow) summary.push(` Context window: ${modelDef.contextWindow}`);
|
|
254
|
+
if (modelDef.maxTokens) summary.push(` Max tokens: ${modelDef.maxTokens}`);
|
|
255
|
+
summary.push("");
|
|
256
|
+
summary.push("Run /reload to refresh pi's model registry.");
|
|
257
|
+
|
|
258
|
+
ctx.ui.notify(summary.join("\n"), "info");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// =========================================================================
|
|
262
|
+
// Command: /custom-provider:scan-models
|
|
263
|
+
// =========================================================================
|
|
264
|
+
|
|
265
|
+
interface RemoteModel {
|
|
266
|
+
id: string;
|
|
267
|
+
object?: string;
|
|
268
|
+
created?: number;
|
|
269
|
+
owned_by?: string;
|
|
270
|
+
max_model_len?: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface ModelsListResponse {
|
|
274
|
+
object: string;
|
|
275
|
+
data: RemoteModel[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function scanModelsCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
279
|
+
const config = readModelsJson();
|
|
280
|
+
const providerNames = Object.keys(config.providers);
|
|
281
|
+
|
|
282
|
+
if (providerNames.length === 0) {
|
|
283
|
+
ctx.ui.notify(
|
|
284
|
+
"No custom providers found. Use /custom-provider:add-provider first.",
|
|
285
|
+
"warning",
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Filter to providers that are OpenAI-compatible
|
|
291
|
+
const compatProviders = providerNames.filter((name) => {
|
|
292
|
+
const api = config.providers[name]!.api;
|
|
293
|
+
return !api || OPENAI_COMPAT_APIS.has(api);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (compatProviders.length === 0) {
|
|
297
|
+
ctx.ui.notify(
|
|
298
|
+
"No OpenAI-compatible providers found. Scan requires providers using openai-completions or openai-responses API.",
|
|
299
|
+
"warning",
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 1. Select provider
|
|
305
|
+
const selectedProvider = await ctx.ui.select(
|
|
306
|
+
"Select provider to scan for models",
|
|
307
|
+
compatProviders,
|
|
308
|
+
);
|
|
309
|
+
if (!selectedProvider) {
|
|
310
|
+
ctx.ui.notify("Cancelled: no provider selected.", "warning");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const provider = config.providers[selectedProvider]!;
|
|
315
|
+
const baseUrl = provider.baseUrl?.replace(/\/+$/, "");
|
|
316
|
+
|
|
317
|
+
if (!baseUrl) {
|
|
318
|
+
ctx.ui.notify(`Provider "${selectedProvider}" has no base URL configured.`, "error");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 2. Fetch models from /v1/models or /models
|
|
323
|
+
const modelsEndpoint = baseUrl.endsWith("/v1") || baseUrl.endsWith("/v1/")
|
|
324
|
+
? `${baseUrl}/models`
|
|
325
|
+
: `${baseUrl}/v1/models`;
|
|
326
|
+
|
|
327
|
+
ctx.ui.notify(`Scanning ${modelsEndpoint}...`, "info");
|
|
328
|
+
|
|
329
|
+
let response: ModelsListResponse;
|
|
330
|
+
try {
|
|
331
|
+
const headers: Record<string, string> = {
|
|
332
|
+
"Accept": "application/json",
|
|
333
|
+
};
|
|
334
|
+
if (provider.apiKey && provider.apiKey !== "none") {
|
|
335
|
+
headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const res = await fetch(modelsEndpoint, { headers, signal: AbortSignal.timeout(15_000) });
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
ctx.ui.notify(
|
|
341
|
+
`Failed to fetch models: HTTP ${res.status} ${res.statusText}\n` +
|
|
342
|
+
`Endpoint: ${modelsEndpoint}`,
|
|
343
|
+
"error",
|
|
344
|
+
);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const body = await res.json() as ModelsListResponse;
|
|
348
|
+
if (!body.data || !Array.isArray(body.data)) {
|
|
349
|
+
ctx.ui.notify(
|
|
350
|
+
`Unexpected response format. Expected { "data": [...] }.\n` +
|
|
351
|
+
`Got: ${JSON.stringify(body).slice(0, 500)}`,
|
|
352
|
+
"error",
|
|
353
|
+
);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
response = body;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
ctx.ui.notify(
|
|
359
|
+
`Failed to connect to ${modelsEndpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
360
|
+
"error",
|
|
361
|
+
);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (response.data.length === 0) {
|
|
366
|
+
ctx.ui.notify("No models found at the endpoint.", "info");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 3. Show discovered models and let user select which to add
|
|
371
|
+
const existingIds = new Set((provider.models ?? []).map((m) => m.id));
|
|
372
|
+
const modelOptions = response.data.map((m) => {
|
|
373
|
+
const exists = existingIds.has(m.id) ? " [already added]" : "";
|
|
374
|
+
const maxLen = m.max_model_len ? ` (ctx: ${m.max_model_len})` : "";
|
|
375
|
+
const owner = m.owned_by ? ` - ${m.owned_by}` : "";
|
|
376
|
+
return `${m.id}${maxLen}${owner}${exists}`;
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Select which models to add
|
|
380
|
+
const selectedModels = await ctx.ui.custom<string[]>((tui, theme, _kb, done) => {
|
|
381
|
+
let cursorIndex = 0;
|
|
382
|
+
const selected = new Set<number>();
|
|
383
|
+
let cachedLines: string[] | undefined;
|
|
384
|
+
// Only allow selecting models that aren't already added
|
|
385
|
+
const addableIndices = response.data
|
|
386
|
+
.map((m, i) => ({ i, alreadyAdded: existingIds.has(m.id) }))
|
|
387
|
+
.filter((x) => !x.alreadyAdded)
|
|
388
|
+
.map((x) => x.i);
|
|
389
|
+
|
|
390
|
+
function refresh() {
|
|
391
|
+
cachedLines = undefined;
|
|
392
|
+
tui.requestRender();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function handleInput(data: string) {
|
|
396
|
+
if (matchesKey(data, Key.up)) {
|
|
397
|
+
cursorIndex = Math.max(0, cursorIndex - 1);
|
|
398
|
+
refresh();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (matchesKey(data, Key.down)) {
|
|
402
|
+
cursorIndex = Math.min(modelOptions.length - 1, cursorIndex + 1);
|
|
403
|
+
refresh();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (matchesKey(data, Key.space)) {
|
|
407
|
+
if (addableIndices.includes(cursorIndex)) {
|
|
408
|
+
if (selected.has(cursorIndex)) {
|
|
409
|
+
selected.delete(cursorIndex);
|
|
410
|
+
} else {
|
|
411
|
+
selected.add(cursorIndex);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
refresh();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (matchesKey(data, Key.enter)) {
|
|
418
|
+
// If nothing selected, select all addable models
|
|
419
|
+
const toAdd = selected.size > 0
|
|
420
|
+
? [...selected]
|
|
421
|
+
: addableIndices;
|
|
422
|
+
const ids = toAdd
|
|
423
|
+
.sort((a, b) => a - b)
|
|
424
|
+
.map((i) => response.data[i]!.id);
|
|
425
|
+
done(ids);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (matchesKey(data, Key.escape)) {
|
|
429
|
+
done([]);
|
|
430
|
+
}
|
|
431
|
+
// a/A to select/deselect all
|
|
432
|
+
if (data === "a") {
|
|
433
|
+
for (const i of addableIndices) selected.add(i);
|
|
434
|
+
refresh();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (data === "A") {
|
|
438
|
+
selected.clear();
|
|
439
|
+
refresh();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function render(width: number): string[] {
|
|
445
|
+
if (cachedLines) return cachedLines;
|
|
446
|
+
const lines: string[] = [];
|
|
447
|
+
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
|
448
|
+
|
|
449
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
450
|
+
add(theme.fg("text", ` Found ${response.data.length} models at ${selectedProvider}`));
|
|
451
|
+
lines.push("");
|
|
452
|
+
|
|
453
|
+
for (let i = 0; i < modelOptions.length; i++) {
|
|
454
|
+
const isCursor = i === cursorIndex;
|
|
455
|
+
const isAddable = addableIndices.includes(i);
|
|
456
|
+
const isChecked = selected.has(i);
|
|
457
|
+
const prefix = isCursor ? theme.fg("accent", "> ") : " ";
|
|
458
|
+
const box = isAddable
|
|
459
|
+
? (isChecked ? theme.fg("success", "☑") : theme.fg("dim", "☐"))
|
|
460
|
+
: theme.fg("dim", "■");
|
|
461
|
+
const label = isAddable
|
|
462
|
+
? theme.fg("text", modelOptions[i]!)
|
|
463
|
+
: theme.fg("dim", modelOptions[i]!);
|
|
464
|
+
add(`${prefix}${box} ${label}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
lines.push("");
|
|
468
|
+
const selectedCount = selected.size;
|
|
469
|
+
if (selectedCount > 0) {
|
|
470
|
+
add(theme.fg("dim", ` ${selectedCount} selected • Space toggle • Enter confirm • a/A select/deselect all • Esc cancel`));
|
|
471
|
+
} else {
|
|
472
|
+
add(theme.fg("dim", ` Enter to add all new models • Space toggle • a/A select/deselect all • Esc cancel`));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
476
|
+
cachedLines = lines;
|
|
477
|
+
return lines;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
render,
|
|
482
|
+
invalidate: () => { cachedLines = undefined; },
|
|
483
|
+
handleInput,
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (selectedModels.length === 0) {
|
|
488
|
+
ctx.ui.notify("No models selected. Cancelled.", "info");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 4. Add selected models to provider
|
|
493
|
+
if (!provider.models) provider.models = [];
|
|
494
|
+
|
|
495
|
+
let addedCount = 0;
|
|
496
|
+
let skippedCount = 0;
|
|
497
|
+
for (const modelId of selectedModels) {
|
|
498
|
+
if (existingIds.has(modelId)) {
|
|
499
|
+
skippedCount++;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const remoteModel = response.data.find((m) => m.id === modelId);
|
|
503
|
+
const modelDef: ModelDefinition = {
|
|
504
|
+
id: modelId,
|
|
505
|
+
...(remoteModel?.max_model_len ? { contextWindow: remoteModel.max_model_len } : {}),
|
|
506
|
+
};
|
|
507
|
+
provider.models.push(modelDef);
|
|
508
|
+
existingIds.add(modelId);
|
|
509
|
+
addedCount++;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
writeModelsJson(config);
|
|
513
|
+
|
|
514
|
+
ctx.ui.notify(
|
|
515
|
+
`Scan complete for "${selectedProvider}".\n` +
|
|
516
|
+
` Added: ${addedCount} model(s)\n` +
|
|
517
|
+
(skippedCount > 0 ? ` Skipped (already existing): ${skippedCount}\n` : "") +
|
|
518
|
+
`\nRun /reload to refresh pi's model registry.`,
|
|
519
|
+
"info",
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// =========================================================================
|
|
524
|
+
// Command: /custom-provider:remove-provider
|
|
525
|
+
// =========================================================================
|
|
526
|
+
|
|
527
|
+
async function removeProviderCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
528
|
+
const config = readModelsJson();
|
|
529
|
+
const providerNames = Object.keys(config.providers);
|
|
530
|
+
|
|
531
|
+
if (providerNames.length === 0) {
|
|
532
|
+
ctx.ui.notify(
|
|
533
|
+
"No custom providers found in models.json.",
|
|
534
|
+
"warning",
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 1. Select provider to remove
|
|
540
|
+
const selectedProvider = await ctx.ui.select("Select provider to remove", providerNames);
|
|
541
|
+
if (!selectedProvider) {
|
|
542
|
+
ctx.ui.notify("Cancelled: no provider selected.", "warning");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 2. Confirm deletion
|
|
547
|
+
const provider = config.providers[selectedProvider]!;
|
|
548
|
+
const modelCount = provider.models?.length ?? 0;
|
|
549
|
+
const confirmMessage = modelCount > 0
|
|
550
|
+
? `Remove provider "${selectedProvider}" and its ${modelCount} model(s)?`
|
|
551
|
+
: `Remove provider "${selectedProvider}"?`;
|
|
552
|
+
|
|
553
|
+
const confirmed = await ctx.ui.confirm(
|
|
554
|
+
confirmMessage,
|
|
555
|
+
`This will update models.json. Run /reload after to refresh pi's model registry.`,
|
|
556
|
+
);
|
|
557
|
+
if (!confirmed) {
|
|
558
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// 3. Remove provider
|
|
563
|
+
delete config.providers[selectedProvider];
|
|
564
|
+
writeModelsJson(config);
|
|
565
|
+
|
|
566
|
+
ctx.ui.notify(
|
|
567
|
+
`Provider "${selectedProvider}" removed from models.json.\n` +
|
|
568
|
+
`Run /reload to refresh pi's model registry.`,
|
|
569
|
+
"info",
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// =========================================================================
|
|
574
|
+
// Command: /custom-provider:remove-model
|
|
575
|
+
// =========================================================================
|
|
576
|
+
|
|
577
|
+
async function removeModelCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
578
|
+
const config = readModelsJson();
|
|
579
|
+
const providerNames = Object.keys(config.providers);
|
|
580
|
+
|
|
581
|
+
if (providerNames.length === 0) {
|
|
582
|
+
ctx.ui.notify(
|
|
583
|
+
"No custom providers found in models.json.",
|
|
584
|
+
"warning",
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 1. Select provider
|
|
590
|
+
const selectedProvider = await ctx.ui.select("Select provider to remove a model from", providerNames);
|
|
591
|
+
if (!selectedProvider) {
|
|
592
|
+
ctx.ui.notify("Cancelled: no provider selected.", "warning");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const provider = config.providers[selectedProvider]!;
|
|
597
|
+
const models = provider.models ?? [];
|
|
598
|
+
|
|
599
|
+
if (models.length === 0) {
|
|
600
|
+
ctx.ui.notify(
|
|
601
|
+
`Provider "${selectedProvider}" has no models.`,
|
|
602
|
+
"warning",
|
|
603
|
+
);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 2. Select model to remove
|
|
608
|
+
const modelLabels = models.map((m) => m.name ? `${m.id} (${m.name})` : m.id);
|
|
609
|
+
const selectedModelLabel = await ctx.ui.select("Select model to remove", modelLabels);
|
|
610
|
+
if (!selectedModelLabel) {
|
|
611
|
+
ctx.ui.notify("Cancelled: no model selected.", "warning");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const selectedIndex = modelLabels.indexOf(selectedModelLabel);
|
|
616
|
+
if (selectedIndex === -1) {
|
|
617
|
+
ctx.ui.notify("Cancelled: could not resolve model.", "warning");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const modelToRemove = models[selectedIndex]!;
|
|
622
|
+
|
|
623
|
+
// 3. Confirm
|
|
624
|
+
const confirmed = await ctx.ui.confirm(
|
|
625
|
+
`Remove model "${modelToRemove.id}" from provider "${selectedProvider}"?`,
|
|
626
|
+
"This will update models.json. Run /reload after to refresh pi's model registry.",
|
|
627
|
+
);
|
|
628
|
+
if (!confirmed) {
|
|
629
|
+
ctx.ui.notify("Cancelled.", "info");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 4. Remove model
|
|
634
|
+
provider.models = models.filter((_, i) => i !== selectedIndex);
|
|
635
|
+
writeModelsJson(config);
|
|
636
|
+
|
|
637
|
+
ctx.ui.notify(
|
|
638
|
+
`Model "${modelToRemove.id}" removed from provider "${selectedProvider}" in models.json.\n` +
|
|
639
|
+
`Run /reload to refresh pi's model registry.`,
|
|
640
|
+
"info",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// =========================================================================
|
|
645
|
+
// Install all custom-provider commands
|
|
646
|
+
// =========================================================================
|
|
647
|
+
|
|
648
|
+
export function installCustomProviderCommands(pi: ExtensionAPI): void {
|
|
649
|
+
pi.registerCommand("custom-provider:add-provider", {
|
|
650
|
+
description: "Add a custom provider to models.json (interactive wizard)",
|
|
651
|
+
handler: addProviderCommand,
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
pi.registerCommand("custom-provider:add-model", {
|
|
655
|
+
description: "Add a model to an existing custom provider in models.json",
|
|
656
|
+
handler: addModelCommand,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
pi.registerCommand("custom-provider:scan-models", {
|
|
660
|
+
description: "Scan an OpenAI-compatible provider endpoint for available models and add them to models.json",
|
|
661
|
+
handler: scanModelsCommand,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
pi.registerCommand("custom-provider:remove-provider", {
|
|
665
|
+
description: "Remove a custom provider and all its models from models.json",
|
|
666
|
+
handler: removeProviderCommand,
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
pi.registerCommand("custom-provider:remove-model", {
|
|
670
|
+
description: "Remove a model from an existing custom provider in models.json",
|
|
671
|
+
handler: removeModelCommand,
|
|
672
|
+
});
|
|
673
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { formatConfig } from "./config.js";
|
|
3
3
|
import { installContextFiles } from "./context-files.js";
|
|
4
|
+
import { installCustomProviderCommands } from "./custom-providers.js";
|
|
4
5
|
import { installGuards } from "./guards.js";
|
|
5
6
|
import { MoonpiController } from "./modes.js";
|
|
6
7
|
import { formatTodoList } from "./state.js";
|
|
@@ -22,6 +23,7 @@ export default async function moonpi(pi: ExtensionAPI): Promise<void> {
|
|
|
22
23
|
installGuards(pi, controller);
|
|
23
24
|
installContextFiles(pi, controller);
|
|
24
25
|
installSprintWorkflow(pi, controller);
|
|
26
|
+
installCustomProviderCommands(pi);
|
|
25
27
|
|
|
26
28
|
pi.registerCommand("moonpi:mode", {
|
|
27
29
|
description: "Switch moonpi mode: plan, act, auto, fast",
|
|
@@ -138,7 +140,7 @@ ${todoList}`);
|
|
|
138
140
|
|
|
139
141
|
// Synthetic is optional; keep core Moonpi mode hooks installed even if provider setup fails.
|
|
140
142
|
try {
|
|
141
|
-
await installSynthetic(pi);
|
|
143
|
+
await installSynthetic(pi, controller);
|
|
142
144
|
} catch {
|
|
143
145
|
// Ignore optional provider setup failures.
|
|
144
146
|
}
|
package/src/modes.ts
CHANGED
|
@@ -40,6 +40,7 @@ function latestSnapshot(entries: SessionEntry[]): MoonpiSnapshot | undefined {
|
|
|
40
40
|
export class MoonpiController {
|
|
41
41
|
readonly state = new MoonpiState();
|
|
42
42
|
config: MoonpiConfig = loadMoonpiConfig(process.cwd());
|
|
43
|
+
syntheticAuthenticated = false;
|
|
43
44
|
private terminalInputUnsubscribe: (() => void) | undefined;
|
|
44
45
|
|
|
45
46
|
constructor(private readonly pi: ExtensionAPI) {}
|
|
@@ -144,9 +145,14 @@ export class MoonpiController {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
getToolsForCurrentMode(): string[] {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
let tools = this.config.preserveExternalTools
|
|
149
|
+
? [...new Set([...STABLE_MOONPI_TOOLS, ...this.pi.getActiveTools().filter((toolName) => !MOONPI_TOOL_NAMES.has(toolName))])]
|
|
150
|
+
: [...STABLE_MOONPI_TOOLS];
|
|
151
|
+
|
|
152
|
+
if (this.syntheticAuthenticated && this.config.synthetic.search.enabled) {
|
|
153
|
+
tools.push("web_search");
|
|
154
|
+
}
|
|
155
|
+
return tools;
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
buildModePrompt(): string {
|
package/src/synthetic.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ProviderModelConfig } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { Type, type Static } from "typebox";
|
|
4
|
+
import type { MoonpiController } from "./modes.js";
|
|
2
5
|
import { SYNTHETIC_MODELS_FALLBACK, mergeWithFallback, parseSyntheticModels, readCachedModels, writeCachedModels } from "./synthetic-models.js";
|
|
3
6
|
|
|
4
7
|
const SYNTHETIC_PROVIDER = "synthetic";
|
|
@@ -6,6 +9,7 @@ const SYNTHETIC_API_KEY_ENV = "SYNTHETIC_API_KEY";
|
|
|
6
9
|
const SYNTHETIC_OPENAI_BASE_URL = "https://api.synthetic.new/openai/v1";
|
|
7
10
|
const SYNTHETIC_MODELS_URL = "https://api.synthetic.new/openai/v1/models";
|
|
8
11
|
const SYNTHETIC_QUOTAS_URL = "https://api.synthetic.new/v2/quotas";
|
|
12
|
+
const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
|
|
9
13
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
10
14
|
|
|
11
15
|
type QuotasErrorKind = "cancelled" | "timeout" | "config" | "http" | "network";
|
|
@@ -238,6 +242,155 @@ async function getSyntheticApiKey(ctx: ExtensionCommandContext | ExtensionContex
|
|
|
238
242
|
return storedKey ?? process.env[SYNTHETIC_API_KEY_ENV] ?? "";
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
// =========================================================================
|
|
246
|
+
// Search API
|
|
247
|
+
// =========================================================================
|
|
248
|
+
|
|
249
|
+
interface SearchResult {
|
|
250
|
+
url: string;
|
|
251
|
+
title: string;
|
|
252
|
+
text: string;
|
|
253
|
+
published?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface SearchResponse {
|
|
257
|
+
results: SearchResult[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const SearchParamsSchema = Type.Object({
|
|
261
|
+
query: Type.String({ description: "Search query" }),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
type SearchParams = Static<typeof SearchParamsSchema>;
|
|
265
|
+
|
|
266
|
+
interface SearchDetails {
|
|
267
|
+
query: string;
|
|
268
|
+
resultCount: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function formatSearchResults(query: string, results: SearchResult[]): string {
|
|
272
|
+
if (results.length === 0) {
|
|
273
|
+
return `No results found for "${query}".`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lines: string[] = [`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}":\n`];
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < results.length; i++) {
|
|
279
|
+
const result = results[i]!;
|
|
280
|
+
const num = i + 1;
|
|
281
|
+
lines.push(`${num}. **${result.title}**`);
|
|
282
|
+
lines.push(` ${result.url}`);
|
|
283
|
+
if (result.published) {
|
|
284
|
+
try {
|
|
285
|
+
const date = new Date(result.published);
|
|
286
|
+
if (!Number.isNaN(date.getTime())) {
|
|
287
|
+
lines.push(` Published: ${date.toISOString().split("T")[0]}`);
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// Skip unparseable dates
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
lines.push(` ${result.text}`);
|
|
294
|
+
lines.push("");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function registerSearchTool(pi: ExtensionAPI): void {
|
|
301
|
+
pi.registerTool<typeof SearchParamsSchema, SearchDetails>({
|
|
302
|
+
name: "web_search",
|
|
303
|
+
label: "web search",
|
|
304
|
+
description:
|
|
305
|
+
"Search the web using the Synthetic search API. Returns a list of results with title, URL, and text excerpt.",
|
|
306
|
+
promptSnippet: "Search the web for information",
|
|
307
|
+
promptGuidelines: [
|
|
308
|
+
"Use web_search when you need to find information on the web that you don't already know.",
|
|
309
|
+
"Prefer web_search over guessing URLs or making assumptions about external documentation.",
|
|
310
|
+
],
|
|
311
|
+
parameters: SearchParamsSchema,
|
|
312
|
+
async execute(_toolCallId, params: SearchParams, signal, _onUpdate, ctx) {
|
|
313
|
+
const apiKey = await getSyntheticApiKey(ctx);
|
|
314
|
+
if (!apiKey) {
|
|
315
|
+
return {
|
|
316
|
+
content: [{ type: "text", text: "Error: Synthetic API key not available. Please re-authenticate with /login synthetic." }],
|
|
317
|
+
details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const signals = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
|
|
322
|
+
if (signal) signals.push(signal);
|
|
323
|
+
const combinedSignal = AbortSignal.any(signals);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const response = await fetch(SYNTHETIC_SEARCH_URL, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: {
|
|
329
|
+
Authorization: `Bearer ${apiKey}`,
|
|
330
|
+
"Content-Type": "application/json",
|
|
331
|
+
"X-Title": "moonpi",
|
|
332
|
+
},
|
|
333
|
+
body: JSON.stringify({ query: params.query }),
|
|
334
|
+
signal: combinedSignal,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
let message = response.statusText;
|
|
339
|
+
const body = await response.text();
|
|
340
|
+
if (body.length > 0) {
|
|
341
|
+
try {
|
|
342
|
+
const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
|
|
343
|
+
if (typeof parsed.error === "string") message = parsed.error;
|
|
344
|
+
else if (typeof parsed.message === "string") message = parsed.message;
|
|
345
|
+
else message = body;
|
|
346
|
+
} catch {
|
|
347
|
+
message = body;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: "text", text: `Error: Synthetic search API returned ${response.status}: ${message}` }],
|
|
352
|
+
details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const data = (await response.json()) as SearchResponse;
|
|
357
|
+
const results = data.results ?? [];
|
|
358
|
+
return {
|
|
359
|
+
content: [{ type: "text", text: formatSearchResults(params.query, results) }],
|
|
360
|
+
details: { query: params.query, resultCount: results.length } satisfies SearchDetails,
|
|
361
|
+
};
|
|
362
|
+
} catch (error: unknown) {
|
|
363
|
+
const aborted = combinedSignal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
|
364
|
+
if (aborted) {
|
|
365
|
+
if (isTimeoutReason(combinedSignal.reason)) {
|
|
366
|
+
return {
|
|
367
|
+
content: [{ type: "text", text: "Error: Synthetic search request timed out." }],
|
|
368
|
+
details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: "text", text: "Error: Synthetic search request was cancelled." }],
|
|
373
|
+
details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
378
|
+
return {
|
|
379
|
+
content: [{ type: "text", text: `Error: Synthetic search failed: ${message}` }],
|
|
380
|
+
details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
renderResult(result, _options, theme) {
|
|
385
|
+
const text = result.content
|
|
386
|
+
.filter((item) => item.type === "text")
|
|
387
|
+
.map((item) => item.text)
|
|
388
|
+
.join("\n");
|
|
389
|
+
return new Text(theme.fg("toolOutput", text), 0, 0);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
241
394
|
async function fetchSyntheticModels(apiKey: string, signal?: AbortSignal): Promise<ProviderModelConfig[] | null> {
|
|
242
395
|
if (!apiKey) return null;
|
|
243
396
|
|
|
@@ -309,7 +462,16 @@ async function refreshLiveModels(pi: ExtensionAPI, apiKey: string, signal?: Abor
|
|
|
309
462
|
return fetchedModels;
|
|
310
463
|
}
|
|
311
464
|
|
|
312
|
-
export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
|
|
465
|
+
export async function installSynthetic(pi: ExtensionAPI, controller: MoonpiController): Promise<void> {
|
|
466
|
+
let searchToolRegistered = false;
|
|
467
|
+
const searchEnabled = () => controller.config.synthetic.search.enabled;
|
|
468
|
+
|
|
469
|
+
function ensureSearchToolRegistered(): void {
|
|
470
|
+
if (searchToolRegistered || !searchEnabled()) return;
|
|
471
|
+
searchToolRegistered = true;
|
|
472
|
+
registerSearchTool(pi);
|
|
473
|
+
}
|
|
474
|
+
|
|
313
475
|
// Fast path: read cached models from disk (synchronous, no network).
|
|
314
476
|
// This ensures models are available immediately at init time, which is
|
|
315
477
|
// critical for model restoration on session resume. Without this, models
|
|
@@ -327,6 +489,8 @@ export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
|
|
|
327
489
|
// resolved later in the session_start handler.
|
|
328
490
|
const apiKey = process.env[SYNTHETIC_API_KEY_ENV] ?? "";
|
|
329
491
|
if (apiKey) {
|
|
492
|
+
controller.syntheticAuthenticated = true;
|
|
493
|
+
ensureSearchToolRegistered();
|
|
330
494
|
// Fire and forget – we already registered with cached/fallback models,
|
|
331
495
|
// so startup isn't blocked on the network request.
|
|
332
496
|
refreshLiveModels(pi, apiKey).catch(() => {
|
|
@@ -338,6 +502,14 @@ export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
|
|
|
338
502
|
// and refresh the model list from the live API.
|
|
339
503
|
pi.on("session_start", async (_event, ctx) => {
|
|
340
504
|
const apiKey = await getSyntheticApiKey(ctx);
|
|
505
|
+
const wasAuthenticated = controller.syntheticAuthenticated;
|
|
506
|
+
controller.syntheticAuthenticated = !!apiKey;
|
|
507
|
+
if (controller.syntheticAuthenticated && !searchToolRegistered) {
|
|
508
|
+
ensureSearchToolRegistered();
|
|
509
|
+
}
|
|
510
|
+
if (controller.syntheticAuthenticated !== wasAuthenticated) {
|
|
511
|
+
controller.applyMode(ctx);
|
|
512
|
+
}
|
|
341
513
|
// Fire-and-forget: don't block session startup on the network request.
|
|
342
514
|
// Cached/fallback models are already registered, so the provider is usable immediately.
|
|
343
515
|
refreshLiveModels(pi, apiKey, ctx.signal).catch(() => {});
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,12 @@ export interface MoonpiConfig {
|
|
|
35
35
|
/** Whether moonpi installs its custom mode-colored editor. Set to false to preserve
|
|
36
36
|
* editor customizations from other extensions (e.g. pi-wierd-statusline). */
|
|
37
37
|
customEditor: boolean;
|
|
38
|
+
synthetic: {
|
|
39
|
+
search: {
|
|
40
|
+
/** Whether the web_search tool is available when logged in with Synthetic. */
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
38
44
|
contextFiles: {
|
|
39
45
|
enabled: boolean;
|
|
40
46
|
fileNames: string[];
|