pi-custom-provider-model 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pi Custom Provider contributors
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,175 @@
1
+ # Pi Provider Manager
2
+
3
+ A small, local browser panel for configuring custom providers in [Pi](https://pi.dev). English and Indonesian UI. Built against the official **Pi 0.85.1** public extension and SDK APIs.
4
+
5
+ **v0.1.0 — local prototype.** npm publication and package scope are not configured yet. `private: true` is intentional during development.
6
+
7
+ ## What works
8
+
9
+ - Add, edit and remove custom providers using OpenAI Chat Completions, OpenAI Responses or Anthropic Messages.
10
+ - Enter a base URL and API key; preview the actual endpoint paths.
11
+ - Test the model-list endpoint; fetch all advertised pages with deduplication and explicit partial-result reporting.
12
+ - Select discovered models or add model IDs manually.
13
+ - Edit context window, output limit, input modalities and reasoning metadata.
14
+ - Fill context/output defaults from endpoint metadata or exact, unambiguous Pi catalog matches, with source labels and manual overrides.
15
+ - Run streaming chat, tool calling, reasoning and image-input checks through **Pi's SDK**, with results inline beneath each model.
16
+ - Save provider/model definitions to `models.json`, API keys through Pi's native `auth.json` handling, and startup defaults to `settings.json`.
17
+ - Preserve existing unknown settings/model overrides and JSON comments when editing; detect stale writes and retain the last 10 `models.json` backups.
18
+ - Show credential source without returning saved keys or headers to the browser.
19
+
20
+ ## Try the prototype
21
+
22
+ Requires Node **22.19+** and Pi **0.85.1+**. Compatibility is currently tested with 0.85.1 on Windows/Edge; other versions/platforms need verification before release.
23
+
24
+ From this project directory:
25
+
26
+ ```powershell
27
+ npm install --ignore-scripts
28
+ pi -e ./src/index.ts
29
+ ```
30
+
31
+ Inside Pi:
32
+
33
+ ```text
34
+ /custom-provider
35
+ ```
36
+
37
+ A browser opens on a random loopback port. If it does not, copy the URL printed by Pi. The command can open before any model is authenticated.
38
+
39
+ ```text
40
+ /custom-provider --no-browser
41
+ /custom-provider stop
42
+ ```
43
+
44
+ For ongoing local use, run this in your terminal:
45
+
46
+ ```powershell
47
+ pi install "C:\SERVER\pi-custom-provider"
48
+ ```
49
+
50
+ Then `/reload` in Pi and use `/custom-provider`. Local installation references this folder in place. The panel stops when Pi exits, reloads, or switches sessions.
51
+
52
+ ### Standalone development
53
+
54
+ ```powershell
55
+ npm run dev
56
+ ```
57
+
58
+ Open the printed URL. By default this uses **`.sandbox/agent`**, an isolated configuration directory. The backend prints which directory it is managing.
59
+
60
+ To manage your actual Pi configuration instead:
61
+
62
+ ```powershell
63
+ npm run dev -- --real-config
64
+ ```
65
+
66
+ Or select a directory explicitly:
67
+
68
+ ```powershell
69
+ npm run dev -- --agent-dir "C:\path\to\agent"
70
+ ```
71
+
72
+ The extension uses Pi's `getAgentDir()`, including `PI_CODING_AGENT_DIR`. It does not hardcode a Windows profile path.
73
+
74
+ ## First provider
75
+
76
+ 1. Click **Add provider** and choose a unique ID such as `my-gateway`.
77
+ 2. Select the API protocol and enter the **Pi-native** base URL.
78
+ 3. Enter an API key. The field is literal; leaving it empty on an existing provider preserves its credentials.
79
+ 4. Click **Test connection**, then **Fetch models**.
80
+ 5. Select the models you want to save, or use **Manual model** if listing is unavailable.
81
+ 6. Use **Test chat** for a quick streaming check, or **Check capabilities** for chat, tools, reasoning and image input. Progress and results appear directly under that model; **Stop tests** cancels the remaining work.
82
+ 7. Click **Save provider**, then select it in Pi's `/model` picker. **Set default** applies to future Pi launches.
83
+
84
+ ### Base URL examples
85
+
86
+ | Protocol | Base URL | Model list | Chat |
87
+ | --- | --- | --- | --- |
88
+ | OpenAI Chat | `https://gateway.example/v1` | `/v1/models` | `/v1/chat/completions` |
89
+ | OpenAI Responses | `https://gateway.example/v1` | `/v1/models` | `/v1/responses` |
90
+ | Anthropic Messages | `https://api.anthropic.com` | `/v1/models` | `/v1/messages` |
91
+ | Anthropic gateway | `https://gateway.example/anthropic` | `/anthropic/v1/models` | `/anthropic/v1/messages` |
92
+
93
+ Pi's Anthropic SDK appends `/v1/messages` itself. For a gateway advertising `/anthropic/v1/messages`, enter a base URL ending in `/anthropic`. Path prefixes are never silently stripped. SDK-added query parameters can appear in actual test results.
94
+
95
+ **Test connection is not a chat test.** A provider can expose `/models` without authorizing inference, or support chat without supporting `/models`. API protocol selection does not restrict the model's brand: a gateway may serve Claude through OpenAI-compatible transport.
96
+
97
+ Unknown model metadata remains absent from the saved JSON. The UI labels Pi defaults (128000 context, 16384 output, text-only, reasoning off). A model ID alone does not verify capabilities. Fetch adds new IDs and fills missing context/output values without deleting models or overwriting saved/manual settings. Unchecking a saved model and saving explicitly removes it.
98
+
99
+ ### Automatic context and output defaults
100
+
101
+ The panel fills known limits into the draft instead of using the same generic numbers for every model:
102
+
103
+ 1. **Endpoint metadata** takes priority. Anthropic uses `max_input_tokens` and `max_tokens`. OpenAI-compatible lists may supply `context_length`, `context_window` / `contextWindow`, `max_output_tokens`, `max_completion_tokens` / `maxTokens`, or OpenRouter-style `top_provider` limits. Explicit `per_request_limits` also constrain the values. When several applicable bounds are supplied, the smallest positive integer is used.
104
+ 2. **Pi catalog reference** fills missing fields using the installed Pi SDK's bundled catalog through its public exports. The ID must match exactly, and all matching catalog entries must agree on that field. There are no fuzzy aliases, guessed model-family limits, or background network catalog requests. This is the catalog's configured limit, which may differ from a model's theoretical maximum or your gateway's limit.
105
+ 3. **Maximum unknown** remains on Pi's fallback (128000 context / 16384 output). Conflicting catalog entries are labelled explicitly. Standard OpenAI `/models` returns IDs/ownership, not token limits; generic `max_tokens` alone is ambiguous and is not interpreted as an output maximum.
106
+
107
+ Use **Fetch models** to obtain endpoint metadata and fill existing empty values as well as new models. Existing saved values and manual edits win. Endpoint metadata can replace an unsaved catalog default. Opening an existing provider also fills empty fields from the local catalog into its draft. **Fill missing limits** fills known empty fields across the current provider; **Use detected limits** in a model's editor explicitly replaces that model's context/output values with the available metadata. Manual model entry looks up the exact ID in the local catalog without consuming API tokens.
108
+
109
+ Both context and maximum output are visible in each model row. Source labels distinguish endpoint, catalog, saved/custom and manual values. Metadata hints are browser/session information only: **Save provider** persists just the numeric native `contextWindow` / `maxTokens` fields. Reading metadata does not save configuration or verify the maximum through inference. Changing the connection clears endpoint hints and unsaved endpoint defaults; saved/manual values are kept. Model-specific endpoint/API overrides do not inherit metadata from a different provider endpoint.
110
+
111
+ The maximum output is a ceiling, not a requested answer length. A request may have a lower effective ceiling due to remaining context, provider routing, account limits, or Pi's request settings. Capability probes retain their small test budgets regardless of the configured maximum.
112
+
113
+ ### Per-model capability checks
114
+
115
+ | Check | Evidence required for “Verified” |
116
+ | --- | --- |
117
+ | Chat / streaming | Pi parses a streaming text response. |
118
+ | Tool calling | Pi receives one schema-valid `provider_probe` call, supplies a generated receipt as its tool result, and the model repeats that receipt in a second turn. |
119
+ | Reasoning | With reasoning requested, Pi receives a thinking block (including opaque/redacted thinking) or positive reasoning-token usage. A normal text answer alone is insufficient. |
120
+ | Image input | The model correctly identifies the four quadrant colors in a fresh, locally generated PNG. Accepting an image parameter alone is insufficient. |
121
+
122
+ Checks send only short synthetic prompts, including a system prompt serialized by Pi, the harmless test-tool schema/result and a generated image. They do not use project files, session history or executable tools. A full run makes up to **5 requests**, sequentially. Each request asks for at most **256 output tokens**, except reasoning which uses a **2,048-token ceiling** and a 1,024-token thinking budget where supported. Existing Pi compatibility settings still apply; gateways may ignore token caps. Chat times out after 30 seconds; each other check after 60 seconds, including both tool turns. Retries are disabled.
123
+
124
+ **Unconfirmed** means the expected evidence was absent; **Test failed** means a request/transport error. Neither proves that the model lacks the capability. Gateways may hide reasoning, need different compatibility parameters, or require larger budgets. Checks validate this endpoint/configuration on a small sample, not every possible workload. Context-window limits, structured output, audio and combined reasoning-plus-tool behavior are not tested.
125
+
126
+ The panel shows HTTP status, duration, request count, token usage and endpoint for each check, without exposing raw answers, thinking, arguments or upstream errors. Results remain in browser memory during editing/saving and are cleared when the connection or model settings change, when switching/reloading providers, or when reloading the page.
127
+
128
+ **Apply verified capabilities** enables only successfully verified vision/reasoning in the draft. Click **Save provider** to persist those settings. Tests alone never alter model metadata, and failed checks never disable existing settings. Pi has no model-level tool-support switch; the tool result remains diagnostic information rather than an invented `models.json` field.
129
+
130
+ ### Gateway rejects the `developer` role (HTTP 400/422)
131
+
132
+ Pi may serialize the system prompt as a `developer` message for reasoning-capable OpenAI models. Some gateways accept only `system`, `user`, `assistant` and `tool`. Chat working in another client does not guarantee identical Pi request compatibility.
133
+
134
+ 1. In **Connection**, set **Default system prompt role — this provider** to **system — compatible gateways**.
135
+ 2. Run **Test chat** / **Check capabilities**, then **Save provider**.
136
+ 3. If upgrading the extension, run `/reload` in Pi and reopen `/custom-provider` to load the new controls.
137
+
138
+ This saves `"compat": { "supportsDeveloperRole": false }` on the provider, covering existing and newly added models that inherit its settings. Model rows show whether a role comes from the provider default or an individual override. You do not need to edit every model.
139
+
140
+ For an individual exception, click **Edit** on the model and set **System prompt role (OpenAI)**. Model overrides take priority over the provider default. **Follow Pi / provider settings** removes only that model's role override and restores inheritance. **Pi automatic detection** removes the provider's role override. These controls keep reasoning and other compatibility settings intact and apply to OpenAI Chat Completions and Responses; Anthropic has its own system-prompt format.
141
+
142
+ Saving through the extension refreshes the registry and reselects the current model with its updated configuration when Pi is idle. If an agent run is active, synchronization waits until it settles. `/reload` also refreshes the active model, so a previously cached model object does not retain the old role setting. In standalone mode, reopen `/model` in Pi and select the saved provider/model after saving.
143
+
144
+ All probes include a synthetic system prompt so they exercise Pi's role serialization. Recognized developer-role rejections show a specific repair hint without displaying the raw upstream error. HTTP 5xx (including 502 Bad Gateway) remains a separate gateway/upstream failure, not evidence of this particular role mismatch. Role rejections and 5xx stop the remaining capability checks; retry explicitly after adjusting the configuration or waiting.
145
+
146
+ ## Configuration and credentials
147
+
148
+ The panel binds to `127.0.0.1`. It uses a per-launch token, same-origin requests and no external frontend assets. Entered keys are sent to the local backend and the selected upstream; existing saved keys are never returned to the browser. Test errors do not echo raw upstream bodies.
149
+
150
+ Provider-scoped API keys use Pi's public `ModelRuntime.login()` / `logout()` and native credential store. Native API-key precedence and existing environment-variable references are respected. OAuth is managed by Pi's `/login` and `/logout`. Providers defined only by other extensions and built-in provider overrides are outside the v0.1 editor.
151
+
152
+ Replacing a credential with provider-scoped `env` settings is currently refused to avoid losing that advanced state through Pi's login replacement behavior. Use Pi's native auth configuration for those entries. Deleting a provider retains its key; remove the key first if desired. Removing an `auth.json` key does not remove a fallback key from `models.json` or the environment.
153
+
154
+ Backups live in `<agentDir>/provider-manager-backups/`. To restore, stop the panel and copy the desired backup over `models.json`, then reopen `/model`. Backups can contain pre-existing secrets from models.json and are created with owner-only permissions where supported. Only `models.json` is backed up; authentication and defaults use Pi's own writers. A partial save reports which operation needs attention.
155
+
156
+ ## Development and verification
157
+
158
+ ```powershell
159
+ npm run verify
160
+ npm run pack:check
161
+ ```
162
+
163
+ - TypeScript type checking.
164
+ - Mock HTTP tests for three protocols, pagination, credentials, preservation of config and stale-write handling.
165
+ - Test through Pi's official extension loader, including asset resolution and shutdown.
166
+ - Playwright browser workflow (installed Microsoft Edge on Windows; run `npx playwright install chromium` first on other platforms).
167
+ - Screenshots are produced under `test-results/` and excluded from the npm package.
168
+
169
+ Tests use isolated temporary directories and mock endpoints. Real gateway compatibility and RAM footprint have not been benchmarked yet.
170
+
171
+ See [`docs/design.md`](docs/design.md) for the version-pinned official documentation and publication plan. Future distribution will be `pi install npm:@your-scope/package-name`, with a `pi` manifest and the `pi-package` keyword for the Pi catalog.
172
+
173
+ ## License
174
+
175
+ MIT. Independent community project; not an official Pi product.
package/docs/design.md ADDED
@@ -0,0 +1,54 @@
1
+ # v0.1 design and reference map
2
+
3
+ Status: local prototype. Pi compatibility baseline: 0.85.1. Package name/scope and public repository have not been chosen. `private: true` prevents accidental publication while retaining local/git installation and tarball inspection.
4
+
5
+ ## Official Pi sources (version pinned)
6
+
7
+ - [Custom models](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/models.md): providers, baseUrl, api, models, defaults, compat, model overrides, key references.
8
+ - [Authentication](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/providers.md): auth.json credential format, key resolution and precedence.
9
+ - [Extensions](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/extensions.md): registerCommand, modelRegistry.refresh, getAgentDir, deferred resources and session_shutdown.
10
+ - [SDK](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/sdk.md): ModelRuntime and configurable credentials.
11
+ - [Pi AI SDK](https://github.com/earendil-works/pi/blob/v0.85.1/packages/ai/README.md): tools, `validateToolCall`, tool results, image input and `completeSimple` reasoning options. Public types define thinking blocks and `usage.reasoning`.
12
+ - [Settings](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/settings.md): defaultProvider/defaultModel.
13
+ - [Packages](https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/packages.md): pi manifest, pi-package keyword, npm/git installs and peer dependencies.
14
+
15
+ API discovery complements Pi's documentation with [OpenAI Models](https://developers.openai.com/api/reference/resources/models/methods/list), [Anthropic Models](https://platform.claude.com/docs/en/api/models/list), and [OpenRouter model metadata](https://openrouter.ai/docs/api/api-reference/models/get-models). Local catalog lookup uses public `getBuiltinProviders()` / `getBuiltinModels()` exports from `@earendil-works/pi-ai/providers/all`.
16
+
17
+ ## One vertical workflow
18
+
19
+ `/custom-provider` opens a local browser panel. Enter a custom provider ID, base URL, API protocol and key. Preview request endpoints, test connectivity, fetch all advertised model pages, select models, save, then select via Pi `/model`. Per-model SDK probes check streaming text, a harmless two-turn tool call, reasoning evidence and a generated image. Progress and results are displayed inline below each model.
20
+
21
+ - APIs: openai-completions, openai-responses, anthropic-messages.
22
+ - Base URL is Pi-native: OpenAI normally ends in `/v1`; Anthropic normally has no trailing `/v1` because its SDK appends `/v1/messages`. A gateway prefix such as `/anthropic` is preserved. Do not rewrite arbitrary URL paths. Show the resulting URLs before requesting.
23
+ - Discovery is additive: refresh never automatically deletes a locally configured model. Show all IDs (including non-chat models) and allow manual IDs when listing is unavailable. Anthropic pagination follows last_id/has_more. Bounded pagination/timeouts explicitly report incomplete lists.
24
+ - Absent metadata stays absent in models.json. Label Pi fallback values (128000 context, 16384 output, text-only, reasoning false). Never infer capabilities from a model name.
25
+ - Context/output defaults: endpoint-declared positive integer limits first, then exact-ID catalog matches with consensus per field. Never normalize aliases or take the largest of conflicting catalog values. Label catalog values as references rather than verified gateway maxima. Explicit per-request caps constrain endpoint values. Metadata-only reads make no inference calls.
26
+ - Fill missing limits in the draft during provider load, discovery and manual-ID entry. Preserve saved/manual values; endpoint metadata may supersede an unsaved catalog default. Both numeric fields and their sources appear per model; hints/sources are not serialized into native configuration. Unknown fields remain absent. Explicit “Use detected limits” replaces a model's limits; “Fill missing limits” does not replace existing values.
27
+ - Capability probes use temporary model copies to enable reasoning/image transport without saving inferred settings. Only evidence earns “Verified”; absent evidence is inconclusive, and request errors are failures rather than unsupported declarations. Optional application updates only successful vision/reasoning flags in the draft. Pi has no tool-support model flag.
28
+ - A full probe run is sequential (up to five requests); each result is returned separately so the UI can show progress and cancel. Use 256 output tokens per request, 2048 for reasoning, 30s for chat and 60s for other probes. Never return raw model text/thinking/tool arguments. Tool calls are schema-validated and receive an in-memory receipt, never arbitrary execution.
29
+ - Every probe includes a synthetic system prompt to exercise Pi's system/developer serialization. Recognize explicit developer-role rejection in 400/422 SDK errors using fixed diagnostics; distinguish HTTP 5xx gateway errors. Do not silently retry or infer role rejection from a generic 422/502.
30
+ - The provider and model editors expose only `compat.supportsDeveloperRole` from compatibility metadata. False forces `system` for OpenAI APIs without disabling reasoning. Provider defaults cover existing and future models; model overrides take priority and are labelled in model rows. Undefined edits preserve saved overrides; an explicit null edit removes this one field and restores provider/Pi inheritance. Merge other compat fields and preserve comments; never return arbitrary compat/header data to the browser.
31
+ - Registry refresh alone does not replace Pi's active model object. After panel saves and on `session_start` with reason `reload`, refresh without network and use public `pi.setModel()` for changed active-model metadata. Defer selection while busy until `agent_settled`; avoid reselecting unchanged metadata.
32
+ - API keys go through the public ModelRuntime login/logout interface using its native authPath-backed credential store. This keeps key interpolation and locking in Pi itself. No imports of private AuthStorage internals. API-key replacement follows Pi's /login semantics; unrelated providers and OAuth entries are preserved.
33
+ - All configuration updates merge into the latest document under lock, check revisions, keep unknown fields/comments, and back up models.json. Conflicting external edits return a conflict instead of replacing the latest version. Native OAuth entries remain managed by Pi /login.
34
+ - Existing built-in provider overrides are displayed read-only in v0.1. Manage custom provider definitions here.
35
+
36
+ ## Components
37
+
38
+ - `src/index.ts`: Pi command and lifecycle integration.
39
+ - `src/server.ts`: on-demand localhost HTTP service and browser-safe endpoints.
40
+ - `src/service.ts`: provider workflow and native runtime adapter.
41
+ - `src/storage.ts`: comment-preserving configuration and credential storage.
42
+ - `src/discovery.ts`: protocol URL previews and remote catalog discovery.
43
+ - `src/limits.ts`: endpoint token-limit parsing, duplicate reconciliation, exact-ID local catalog references.
44
+ - `src/probes.ts`: Pi-native capability probes, synthetic PNG generation and evidence-based results.
45
+ - `web/`: dependency-free browser UI; English and Indonesian.
46
+ - `test/`: isolated mock-server integration and browser workflow checks.
47
+
48
+ ## Local transport
49
+
50
+ Bind to 127.0.0.1 on a random port. A session token in the URL fragment is exchanged for request headers, never sent upstream. Validate Host/Origin and do not enable cross-origin access. Do not return credential values or provider/model headers in browser state. Test/discovery requests use only the endpoint explicitly selected by the user; redirects do not forward keys to another location.
51
+
52
+ ## Publication checklist
53
+
54
+ Choose an available npm scope/name and repository; add repository metadata and changelog; verify Windows and CI on Linux/macOS; run tests and inspect npm pack; remove private only for an intentional public release. Include the prebuilt web assets (this prototype needs no frontend build). Gallery indexing uses npm's pi-package keyword; listing timing is outside this project's control.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-custom-provider-model",
3
+ "version": "0.1.1",
4
+ "description": "A local browser-based custom provider and API-key manager for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "providers",
11
+ "models"
12
+ ],
13
+ "files": [
14
+ "src/",
15
+ "web/",
16
+ "README.md",
17
+ "LICENSE",
18
+ "docs/"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./src/index.ts"
23
+ ]
24
+ },
25
+ "engines": {
26
+ "node": ">=22.19.0"
27
+ },
28
+ "scripts": {
29
+ "dev": "tsx src/standalone.ts",
30
+ "check": "tsc --noEmit",
31
+ "test": "tsx --test test/*.test.ts",
32
+ "test:ui": "tsx --test test/browser.e2e.ts",
33
+ "verify": "npm run check && npm test && npm run test:ui",
34
+ "pack:check": "npm pack --dry-run"
35
+ },
36
+ "dependencies": {
37
+ "jsonc-parser": "^3.3.1",
38
+ "proper-lockfile": "^4.1.2"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-ai": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-coding-agent": "0.85.1",
46
+ "@earendil-works/pi-ai": "0.85.1",
47
+ "@types/node": "^22.19.0",
48
+ "@types/proper-lockfile": "^4.1.4",
49
+ "playwright": "^1.58.0",
50
+ "tsx": "^4.21.0",
51
+ "typescript": "^5.9.3"
52
+ }
53
+ }
@@ -0,0 +1,124 @@
1
+ import { AppError, object, type Protocol } from "./types.ts";
2
+ import { endpointLimits, LIMIT_FIELDS, mergeEndpointLimits, type ListedModel } from "./limits.ts";
3
+
4
+ export function endpointUrls(baseUrl: string, api: Protocol) {
5
+ const root = baseUrl.replace(/\/+$/, "");
6
+ // Matches the SDK URL concatenation. Explicit prefixes are preserved.
7
+ return api === "anthropic-messages"
8
+ ? { models: `${root}/v1/models`, chat: `${root}/v1/messages` }
9
+ : { models: `${root}/models`, chat: `${root}/${api === "openai-responses" ? "responses" : "chat/completions"}` };
10
+ }
11
+
12
+ export function requestHeaders(api: Protocol, apiKey?: string, extra?: Record<string, string | null>): Headers {
13
+ const headers = new Headers({ Accept: "application/json" });
14
+ if (api === "anthropic-messages") {
15
+ headers.set("anthropic-version", "2023-06-01");
16
+ if (apiKey) headers.set("x-api-key", apiKey);
17
+ } else if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
18
+ for (const [key, value] of Object.entries(extra ?? {})) {
19
+ if (value === null) headers.delete(key); else headers.set(key, value);
20
+ }
21
+ return headers;
22
+ }
23
+
24
+ export function statusMessage(status: number): string {
25
+ if (status === 401 || status === 403) return "Endpoint refused access. Check the API key and account permissions.";
26
+ if (status === 404 || status === 405) return "Model-list endpoint is unavailable. Verify the URL or add models manually; chat may still work.";
27
+ if (status === 429) return "Provider rate or concurrency limit reached. Try again after active requests finish.";
28
+ if (status >= 500) return "Provider returned a server error. Try again later.";
29
+ if (status >= 300 && status < 400) return "Endpoint redirected. Enter the final base URL explicitly.";
30
+ return `Endpoint returned HTTP ${status}.`;
31
+ }
32
+
33
+ async function readLimited(response: Response, maxBytes = 8_000_000): Promise<unknown> {
34
+ if (!response.body) throw new AppError("Endpoint returned an empty response.", 502);
35
+ const reader = response.body.getReader();
36
+ let bytes = 0;
37
+ const chunks: Uint8Array[] = [];
38
+ try {
39
+ while (true) {
40
+ const { done, value } = await reader.read();
41
+ if (done) break;
42
+ bytes += value.byteLength;
43
+ if (bytes > maxBytes) throw new AppError("Model-list response is too large.", 502);
44
+ chunks.push(value);
45
+ }
46
+ } finally { await reader.cancel().catch(() => {}); }
47
+ try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); }
48
+ catch { throw new AppError("Expected a JSON model list; the endpoint returned another format.", 502); }
49
+ }
50
+
51
+ export interface DiscoveryOptions {
52
+ api: Protocol;
53
+ baseUrl: string;
54
+ headers: Headers;
55
+ allPages: boolean;
56
+ signal?: AbortSignal;
57
+ timeoutMs?: number;
58
+ }
59
+
60
+ export async function discover(options: DiscoveryOptions) {
61
+ const started = Date.now();
62
+ const signal = AbortSignal.any([AbortSignal.timeout(options.timeoutMs ?? 30000), ...(options.signal ? [options.signal] : [])]);
63
+ const target = endpointUrls(options.baseUrl, options.api).models;
64
+ const models = new Map<string, ListedModel>();
65
+ const cursors = new Set<string>();
66
+ let cursor: string | undefined;
67
+ let pages = 0;
68
+ let complete = false;
69
+ let warning: string | undefined;
70
+ try {
71
+ while (pages < 100 && models.size < 10000) {
72
+ const url = new URL(target);
73
+ if (options.api === "anthropic-messages") {
74
+ url.searchParams.set("limit", "100");
75
+ if (cursor) url.searchParams.set("after_id", cursor);
76
+ } else if (cursor) url.searchParams.set("after", cursor);
77
+ const response = await fetch(url, { headers: options.headers, signal, redirect: "manual" });
78
+ if (!response.ok) {
79
+ await response.body?.cancel();
80
+ throw new AppError(statusMessage(response.status), response.status);
81
+ }
82
+ const data = await readLimited(response);
83
+ if (!object(data) || !Array.isArray(data.data)) throw new AppError("Response must contain a data array of models.", 502);
84
+ pages++;
85
+ let truncated = false;
86
+ for (const entry of data.data) {
87
+ if (!object(entry) || typeof entry.id !== "string" || !entry.id.trim() || entry.id.length > 300 || /[\x00-\x1f]/.test(entry.id)) continue;
88
+ const model: ListedModel = { ...models.get(entry.id.trim()), id: entry.id.trim() };
89
+ if (typeof entry.display_name === "string" && entry.display_name) model.name = entry.display_name;
90
+ model.limitHints = mergeEndpointLimits(model.limitHints, endpointLimits(entry, options.api));
91
+ for (const field of LIMIT_FIELDS) if (model.limitHints[field]) model[field] = model.limitHints[field]!.value;
92
+ // Only map documented capability fields; IDs alone imply no capabilities.
93
+ if (options.api === "anthropic-messages") {
94
+ if (typeof entry.capabilities?.thinking?.supported === "boolean") model.reasoning = entry.capabilities.thinking.supported;
95
+ if (typeof entry.capabilities?.image_input?.supported === "boolean") model.input = entry.capabilities.image_input.supported ? ["text", "image"] : ["text"];
96
+ }
97
+ if (!models.has(model.id) && models.size >= 10000) { truncated = true; break; }
98
+ models.set(model.id, model);
99
+ }
100
+ if (truncated) { warning = "Discovery limit reached. Results are incomplete."; break; }
101
+ if (data.has_more !== true) {
102
+ // Do not silently claim completeness for alternate pagination formats.
103
+ if (data.links?.next || (Number.isSafeInteger(data.total_count) && data.total_count > models.size)) {
104
+ warning = "Endpoint reports additional results using unsupported pagination. Results are incomplete.";
105
+ } else complete = true;
106
+ break;
107
+ }
108
+ if (!options.allPages) { warning = "Connection verified. Fetch models to retrieve remaining pages."; break; }
109
+ const next = data.last_id;
110
+ if (typeof next !== "string" || !next || cursors.has(next)) {
111
+ warning = "Endpoint indicates more models but has no usable pagination cursor. Results are incomplete.";
112
+ break;
113
+ }
114
+ cursors.add(next);
115
+ cursor = next;
116
+ }
117
+ if (!complete && !warning) warning = "Discovery limit reached. Results are incomplete.";
118
+ } catch (error) {
119
+ const message = error instanceof AppError ? error.message : signal.aborted ? "Request cancelled or timed out." : "Cannot reach the endpoint. Check URL, DNS, TLS and network access.";
120
+ if (!pages) throw error instanceof AppError ? error : new AppError(message, 502);
121
+ warning = `${message} Results are incomplete.`;
122
+ }
123
+ return { ok: true, url: target, status: 200, durationMs: Date.now() - started, pages, complete, warning, models: [...models.values()] };
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { spawn } from "node:child_process";
2
+ import { getAgentDir, VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { startManager } from "./server.ts";
4
+
5
+ function openBrowser(url: string) {
6
+ const [program, args] = process.platform === "win32"
7
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
8
+ : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
9
+ const child = spawn(program, args, { detached: true, stdio: "ignore", windowsHide: true });
10
+ child.on("error", () => {});
11
+ child.unref();
12
+ }
13
+
14
+ export default function (pi: ExtensionAPI) {
15
+ let manager: Awaited<ReturnType<typeof startManager>> | undefined;
16
+ let starting: Promise<Awaited<ReturnType<typeof startManager>>> | undefined;
17
+ let pendingModelSync = false;
18
+ const syncActiveModel = async (ctx: ExtensionContext) => {
19
+ if (!pendingModelSync || !ctx.isIdle()) return;
20
+ const current = ctx.model;
21
+ const fresh = current && ctx.modelRegistry.find(current.provider, current.id);
22
+ if (fresh && JSON.stringify(fresh) !== JSON.stringify(current) && !await pi.setModel(fresh)) {
23
+ throw new Error("Reselect the saved model using Pi /model.");
24
+ }
25
+ pendingModelSync = false;
26
+ };
27
+ const refreshModels = async (ctx: ExtensionContext) => {
28
+ await ctx.modelRegistry.refresh({ allowNetwork: false });
29
+ pendingModelSync = true;
30
+ await syncActiveModel(ctx);
31
+ };
32
+ pi.on("session_start", async (event, ctx) => {
33
+ if (event.reason === "reload") await refreshModels(ctx);
34
+ });
35
+ pi.on("agent_settled", async (_event, ctx) => {
36
+ if (pendingModelSync) await syncActiveModel(ctx);
37
+ });
38
+ pi.registerCommand("custom-provider", {
39
+ description: "Manage custom providers, API keys and models in a local browser",
40
+ handler: async (args, ctx) => {
41
+ if (args.trim() === "stop") {
42
+ if (starting) await starting;
43
+ await manager?.close(); manager = undefined;
44
+ ctx.ui.notify("Provider manager stopped.", "info"); return;
45
+ }
46
+ const [major, minor, patch] = VERSION.split(".").map(Number);
47
+ if (major === 0 && (minor < 85 || (minor === 85 && patch < 1))) {
48
+ ctx.ui.notify("Pi Custom Provider requires Pi 0.85.1 or later.", "error"); return;
49
+ }
50
+ if (!manager) {
51
+ starting ??= startManager(getAgentDir(), () => refreshModels(ctx));
52
+ try { manager = await starting; } finally { starting = undefined; }
53
+ }
54
+ ctx.ui.notify(`Provider manager: ${manager.url}`, "info");
55
+ if (!args.includes("--no-browser")) openBrowser(manager.url);
56
+ },
57
+ });
58
+ pi.on("session_shutdown", async () => {
59
+ pendingModelSync = false;
60
+ if (starting) await starting.catch(() => {});
61
+ await manager?.close(); manager = undefined;
62
+ });
63
+ }
package/src/limits.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
2
+ import type { JsonObject, ModelInput, Protocol } from "./types.ts";
3
+
4
+ export const LIMIT_FIELDS = ["contextWindow", "maxTokens"] as const;
5
+ export type LimitField = typeof LIMIT_FIELDS[number];
6
+ export interface LimitValue {
7
+ value: number;
8
+ source: "endpoint" | "catalog";
9
+ field?: string;
10
+ }
11
+ export interface LimitHints {
12
+ contextWindow?: LimitValue;
13
+ maxTokens?: LimitValue;
14
+ ambiguous?: LimitField[];
15
+ }
16
+ export interface ListedModel extends ModelInput { limitHints?: LimitHints }
17
+ const positiveInteger = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
18
+
19
+ function smallest(fields: [string, unknown][]): LimitValue | undefined {
20
+ const valid = fields.filter((entry): entry is [string, number] => positiveInteger(entry[1]));
21
+ valid.sort((a, b) => a[1] - b[1]);
22
+ return valid.length ? { value: valid[0][1], source: "endpoint", field: valid[0][0] } : undefined;
23
+ }
24
+
25
+ export function endpointLimits(entry: JsonObject, api: Protocol): LimitHints {
26
+ if (api === "anthropic-messages") return {
27
+ contextWindow: smallest([["max_input_tokens", entry.max_input_tokens]]),
28
+ maxTokens: smallest([["max_tokens", entry.max_tokens]]),
29
+ };
30
+ // Explicit metadata fields only. Bare max_tokens is ambiguous on generic gateways.
31
+ return {
32
+ contextWindow: smallest([
33
+ ["context_length", entry.context_length], ["context_window", entry.context_window], ["contextWindow", entry.contextWindow],
34
+ ["top_provider.context_length", entry.top_provider?.context_length],
35
+ ["per_request_limits.prompt_tokens", entry.per_request_limits?.prompt_tokens],
36
+ ]),
37
+ maxTokens: smallest([
38
+ ["max_output_tokens", entry.max_output_tokens], ["max_completion_tokens", entry.max_completion_tokens], ["maxTokens", entry.maxTokens],
39
+ ["top_provider.max_completion_tokens", entry.top_provider?.max_completion_tokens],
40
+ ["per_request_limits.completion_tokens", entry.per_request_limits?.completion_tokens],
41
+ ]),
42
+ };
43
+ }
44
+
45
+ export function mergeEndpointLimits(previous: LimitHints = {}, next: LimitHints = {}): LimitHints {
46
+ const result: LimitHints = {};
47
+ for (const field of LIMIT_FIELDS) {
48
+ const a = previous[field], b = next[field];
49
+ if (a || b) result[field] = a && b ? a.value <= b.value ? a : b : a ?? b;
50
+ }
51
+ return result;
52
+ }
53
+
54
+ // The bundled public catalog is read without network, credentials, or user overrides.
55
+ export class LimitCatalog {
56
+ private entries = new Map<string, Pick<ModelInput, "id" | LimitField>[]>();
57
+ constructor(models: readonly Pick<ModelInput, "id" | LimitField>[] = getBuiltinProviders().flatMap((provider) => getBuiltinModels(provider))) {
58
+ for (const model of models) {
59
+ const entries = this.entries.get(model.id) ?? [];
60
+ entries.push(model); this.entries.set(model.id, entries);
61
+ }
62
+ }
63
+
64
+ lookup(id: string, endpoint: LimitHints = {}): LimitHints {
65
+ const result: LimitHints = {};
66
+ const matches = this.entries.get(id) ?? [];
67
+ for (const field of LIMIT_FIELDS) {
68
+ if (endpoint[field]) { result[field] = endpoint[field]; continue; }
69
+ const values = new Set(matches.map((m) => m[field]).filter(positiveInteger));
70
+ if (values.size === 1) result[field] = { value: [...values][0], source: "catalog" };
71
+ else if (values.size > 1) (result.ambiguous ??= []).push(field);
72
+ }
73
+ return result;
74
+ }
75
+ }