@anandamw/custom-provider 0.1.7
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 +21 -0
- package/README.md +82 -0
- package/extensions/index.js +595 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pi Rakit 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,82 @@
|
|
|
1
|
+
# @anandamw/custom-provider
|
|
2
|
+
|
|
3
|
+
A Pi extension that registers one configurable OpenAI-compatible model provider. It works with local servers such as Ollama, llama.cpp, and vLLM, as well as hosted OpenAI-compatible gateways.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@anandamw/custom-provider
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or select **Custom Provider** in the Pi Rakit installer.
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
The defaults target an Ollama-compatible local endpoint:
|
|
16
|
+
|
|
17
|
+
- Provider: `rakit-openai`
|
|
18
|
+
- Base URL: `http://localhost:11434/v1`
|
|
19
|
+
- Model: `llama3.2`
|
|
20
|
+
- API: `openai-completions`
|
|
21
|
+
|
|
22
|
+
Override them before starting Pi:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export PI_RAKIT_PROVIDER_BASE_URL="https://api.example.com/v1"
|
|
26
|
+
export PI_RAKIT_PROVIDER_API_KEY="your-secret-key"
|
|
27
|
+
export PI_RAKIT_PROVIDER_MODEL="your-model-id"
|
|
28
|
+
pi
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then start or reload Pi and select `rakit-openai/your-model-id` with `/model`. Configuration is read when the extension loads, so restart or reload Pi after changing environment variables.
|
|
32
|
+
|
|
33
|
+
Run `/provider` to choose an available provider and model, or select **Custom provider** to enter:
|
|
34
|
+
|
|
35
|
+
- API URL (must use `http` or `https`)
|
|
36
|
+
- API key
|
|
37
|
+
- Context window
|
|
38
|
+
- Max tokens
|
|
39
|
+
|
|
40
|
+
After the URL and API key are entered, the command queries the OpenAI-compatible `GET <baseUrl>/models` endpoint with a five-second timeout and displays the returned model IDs as a selection list. Choose **Enter model manually** when needed. If discovery fails or returns no models, the command automatically falls back to Model ID and name inputs.
|
|
41
|
+
|
|
42
|
+
The custom provider is selected immediately for the current Pi session and saved in `~/.pi/agent/settings.json`. Choose **Manage custom providers** to add, edit, or delete providers and their models; adding a model also performs automatic discovery. The API key is entered through Pi's input dialog and is not displayed by the command.
|
|
43
|
+
|
|
44
|
+
For the default local setup, ensure Ollama is running and the model is available:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ollama pull llama3.2
|
|
48
|
+
ollama serve
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Environment variables
|
|
52
|
+
|
|
53
|
+
| Variable | Default | Description |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `PI_RAKIT_PROVIDER_ID` | `rakit-openai` | Provider identifier shown by Pi |
|
|
56
|
+
| `PI_RAKIT_PROVIDER_NAME` | `Pi Rakit OpenAI Compatible` | Display name |
|
|
57
|
+
| `PI_RAKIT_PROVIDER_BASE_URL` | `http://localhost:11434/v1` | OpenAI-compatible API endpoint |
|
|
58
|
+
| `PI_RAKIT_PROVIDER_API_KEY` | unset | API key resolved by Pi at request time |
|
|
59
|
+
| `PI_RAKIT_PROVIDER_MODEL` | `llama3.2` | Model identifier |
|
|
60
|
+
| `PI_RAKIT_PROVIDER_MODEL_NAME` | model identifier | Model display name |
|
|
61
|
+
| `PI_RAKIT_PROVIDER_CONTEXT_WINDOW` | `128000` | Positive integer token limit |
|
|
62
|
+
| `PI_RAKIT_PROVIDER_MAX_TOKENS` | `8192` | Positive integer output limit |
|
|
63
|
+
| `PI_RAKIT_PROVIDER_REASONING` | `false` | `true`/`false` or `1`/`0` |
|
|
64
|
+
| `PI_RAKIT_PROVIDER_IMAGES` | `false` | Enable image input support |
|
|
65
|
+
|
|
66
|
+
Do not commit API keys. The extension passes `$PI_RAKIT_PROVIDER_API_KEY` to Pi, so Pi resolves the environment variable only when it needs the credential. The provider always uses the `openai-completions` API adapter and registers one model. Enable reasoning or images only when the endpoint and model support them.
|
|
67
|
+
|
|
68
|
+
## Troubleshooting
|
|
69
|
+
|
|
70
|
+
- **Model missing:** restart or reload Pi, run `/model`, and look for `<provider-id>/<model-id>`.
|
|
71
|
+
- **Discovery unavailable:** verify that `GET <baseUrl>/models` is supported, or use **Enter model manually**.
|
|
72
|
+
- **Connection refused:** start the local server and verify that the base URL includes its OpenAI-compatible path, commonly `/v1`.
|
|
73
|
+
- **Unauthorized:** export `PI_RAKIT_PROVIDER_API_KEY` in the shell that starts Pi.
|
|
74
|
+
- **Configuration error:** context and token limits must be positive integers; booleans accept only `true`, `false`, `1`, or `0`.
|
|
75
|
+
|
|
76
|
+
See the [complete Custom Provider guide](https://github.com/4nndmw/pi-rakit/blob/main/docs/rakit/Custom%20Provider.md).
|
|
77
|
+
|
|
78
|
+
## Local development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pi -e ./packages/custom-provider/extensions/index.js
|
|
82
|
+
```
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = Object.freeze({
|
|
6
|
+
providerId: "rakit-openai",
|
|
7
|
+
customProviderId: "rakit-custom",
|
|
8
|
+
providerName: "Pi Rakit OpenAI Compatible",
|
|
9
|
+
baseUrl: "http://localhost:11434/v1",
|
|
10
|
+
modelId: "llama3.2",
|
|
11
|
+
contextWindow: 128000,
|
|
12
|
+
maxTokens: 8192,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function getSettingsPath() {
|
|
16
|
+
return (
|
|
17
|
+
process.env.PI_RAKIT_SETTINGS_PATH ||
|
|
18
|
+
path.join(homedir(), ".pi", "agent", "settings.json")
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readSettings() {
|
|
23
|
+
const settingsPath = getSettingsPath();
|
|
24
|
+
if (!existsSync(settingsPath)) return {};
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeSettings(settings) {
|
|
33
|
+
const settingsPath = getSettingsPath();
|
|
34
|
+
mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
35
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveCustomProviderConfig(config) {
|
|
39
|
+
const settings = readSettings();
|
|
40
|
+
settings.customProvider = config;
|
|
41
|
+
settings.customProviders = [config];
|
|
42
|
+
writeSettings(settings);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadCustomProviderConfig() {
|
|
46
|
+
const settings = readSettings();
|
|
47
|
+
return settings.customProviders?.[0] || settings.customProvider || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadCustomProviderConfigs() {
|
|
51
|
+
const settings = readSettings();
|
|
52
|
+
if (Array.isArray(settings.customProviders)) return settings.customProviders;
|
|
53
|
+
return settings.customProvider ? [settings.customProvider] : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveCustomProviderConfigs(configs) {
|
|
57
|
+
const settings = readSettings();
|
|
58
|
+
settings.customProviders = configs;
|
|
59
|
+
settings.customProvider = configs[0] || null;
|
|
60
|
+
writeSettings(settings);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readCustomProviderInput(value, variableName) {
|
|
64
|
+
const resolved = value?.trim();
|
|
65
|
+
if (!resolved) throw new Error(`${variableName} cannot be empty.`);
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readCustomProviderUrl(value) {
|
|
70
|
+
const resolved = readCustomProviderInput(value, "API URL");
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(resolved);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error("API URL must be a valid URL.");
|
|
76
|
+
}
|
|
77
|
+
if (!/^https?:$/.test(url.protocol)) {
|
|
78
|
+
throw new Error("API URL must use http or https.");
|
|
79
|
+
}
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function discoverAvailableModels(
|
|
84
|
+
baseUrl,
|
|
85
|
+
apiKey,
|
|
86
|
+
{ fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {},
|
|
87
|
+
) {
|
|
88
|
+
if (typeof fetchImpl !== "function") {
|
|
89
|
+
throw new Error("Model discovery is unavailable in this runtime");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const endpoint = new URL(readCustomProviderUrl(baseUrl));
|
|
93
|
+
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, "")}/models`;
|
|
94
|
+
endpoint.search = "";
|
|
95
|
+
endpoint.hash = "";
|
|
96
|
+
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetchImpl(endpoint, {
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: "application/json",
|
|
103
|
+
Authorization: `Bearer ${readCustomProviderInput(apiKey, "API key")}`,
|
|
104
|
+
},
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`Model discovery failed with HTTP ${response.status}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const payload = await response.json();
|
|
112
|
+
const models = Array.isArray(payload?.data) ? payload.data : [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
return models.flatMap((model) => {
|
|
115
|
+
const id = typeof model?.id === "string" ? model.id.trim() : "";
|
|
116
|
+
if (!id || seen.has(id)) return [];
|
|
117
|
+
seen.add(id);
|
|
118
|
+
const discoveredName = model.name || model.display_name;
|
|
119
|
+
const name =
|
|
120
|
+
typeof discoveredName === "string" && discoveredName.trim()
|
|
121
|
+
? discoveredName.trim()
|
|
122
|
+
: id;
|
|
123
|
+
return [{ id, name }];
|
|
124
|
+
});
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timeout);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readPositiveInteger(value, fallback, variableName) {
|
|
131
|
+
if (value === undefined || value === "") return fallback;
|
|
132
|
+
|
|
133
|
+
const parsed = Number(value);
|
|
134
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
135
|
+
throw new Error(`${variableName} must be a positive integer.`);
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readBoolean(value, fallback, variableName) {
|
|
141
|
+
if (value === undefined || value === "") return fallback;
|
|
142
|
+
if (value === "true" || value === "1") return true;
|
|
143
|
+
if (value === "false" || value === "0") return false;
|
|
144
|
+
throw new Error(`${variableName} must be true, false, 1, or 0.`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readRequired(value, fallback, variableName) {
|
|
148
|
+
const resolved = value?.trim() || fallback;
|
|
149
|
+
if (!resolved) throw new Error(`${variableName} cannot be empty.`);
|
|
150
|
+
return resolved;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function buildProviderRegistration(env = process.env) {
|
|
154
|
+
const providerId = readRequired(
|
|
155
|
+
env.PI_RAKIT_PROVIDER_ID,
|
|
156
|
+
DEFAULTS.providerId,
|
|
157
|
+
"PI_RAKIT_PROVIDER_ID",
|
|
158
|
+
);
|
|
159
|
+
const modelId = readRequired(
|
|
160
|
+
env.PI_RAKIT_PROVIDER_MODEL,
|
|
161
|
+
DEFAULTS.modelId,
|
|
162
|
+
"PI_RAKIT_PROVIDER_MODEL",
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
providerId,
|
|
167
|
+
config: {
|
|
168
|
+
name: readRequired(
|
|
169
|
+
env.PI_RAKIT_PROVIDER_NAME,
|
|
170
|
+
DEFAULTS.providerName,
|
|
171
|
+
"PI_RAKIT_PROVIDER_NAME",
|
|
172
|
+
),
|
|
173
|
+
baseUrl: readRequired(
|
|
174
|
+
env.PI_RAKIT_PROVIDER_BASE_URL,
|
|
175
|
+
DEFAULTS.baseUrl,
|
|
176
|
+
"PI_RAKIT_PROVIDER_BASE_URL",
|
|
177
|
+
),
|
|
178
|
+
apiKey: "$PI_RAKIT_PROVIDER_API_KEY",
|
|
179
|
+
api: "openai-completions",
|
|
180
|
+
models: [
|
|
181
|
+
{
|
|
182
|
+
id: modelId,
|
|
183
|
+
name: env.PI_RAKIT_PROVIDER_MODEL_NAME?.trim() || modelId,
|
|
184
|
+
reasoning: readBoolean(
|
|
185
|
+
env.PI_RAKIT_PROVIDER_REASONING,
|
|
186
|
+
false,
|
|
187
|
+
"PI_RAKIT_PROVIDER_REASONING",
|
|
188
|
+
),
|
|
189
|
+
input: readBoolean(
|
|
190
|
+
env.PI_RAKIT_PROVIDER_IMAGES,
|
|
191
|
+
false,
|
|
192
|
+
"PI_RAKIT_PROVIDER_IMAGES",
|
|
193
|
+
)
|
|
194
|
+
? ["text", "image"]
|
|
195
|
+
: ["text"],
|
|
196
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
197
|
+
contextWindow: readPositiveInteger(
|
|
198
|
+
env.PI_RAKIT_PROVIDER_CONTEXT_WINDOW,
|
|
199
|
+
DEFAULTS.contextWindow,
|
|
200
|
+
"PI_RAKIT_PROVIDER_CONTEXT_WINDOW",
|
|
201
|
+
),
|
|
202
|
+
maxTokens: readPositiveInteger(
|
|
203
|
+
env.PI_RAKIT_PROVIDER_MAX_TOKENS,
|
|
204
|
+
DEFAULTS.maxTokens,
|
|
205
|
+
"PI_RAKIT_PROVIDER_MAX_TOKENS",
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function buildCustomProviderRegistration({
|
|
214
|
+
providerId = DEFAULTS.customProviderId,
|
|
215
|
+
providerName = "Rakit Custom Provider",
|
|
216
|
+
baseUrl,
|
|
217
|
+
apiKey,
|
|
218
|
+
models,
|
|
219
|
+
modelId,
|
|
220
|
+
contextWindow = DEFAULTS.contextWindow,
|
|
221
|
+
maxTokens = DEFAULTS.maxTokens,
|
|
222
|
+
}) {
|
|
223
|
+
const resolvedModels = models || [{ modelId, contextWindow, maxTokens }];
|
|
224
|
+
return {
|
|
225
|
+
providerId: readCustomProviderInput(providerId, "Provider ID"),
|
|
226
|
+
config: {
|
|
227
|
+
name: readCustomProviderInput(providerName, "Provider name"),
|
|
228
|
+
baseUrl: readCustomProviderUrl(baseUrl),
|
|
229
|
+
apiKey: readCustomProviderInput(apiKey, "API key"),
|
|
230
|
+
api: "openai-completions",
|
|
231
|
+
models: resolvedModels.map((model) => ({
|
|
232
|
+
id: readCustomProviderInput(model.id || model.modelId, "Model"),
|
|
233
|
+
name: readCustomProviderInput(
|
|
234
|
+
model.name || model.id || model.modelId,
|
|
235
|
+
"Model name",
|
|
236
|
+
),
|
|
237
|
+
reasoning: false,
|
|
238
|
+
input: ["text"],
|
|
239
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
240
|
+
contextWindow: readPositiveInteger(
|
|
241
|
+
model.contextWindow,
|
|
242
|
+
DEFAULTS.contextWindow,
|
|
243
|
+
"Context window",
|
|
244
|
+
),
|
|
245
|
+
maxTokens: readPositiveInteger(
|
|
246
|
+
model.maxTokens,
|
|
247
|
+
DEFAULTS.maxTokens,
|
|
248
|
+
"Max tokens",
|
|
249
|
+
),
|
|
250
|
+
})),
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeCustomProvider(provider) {
|
|
256
|
+
const registration = buildCustomProviderRegistration({
|
|
257
|
+
providerId: provider.providerId || DEFAULTS.customProviderId,
|
|
258
|
+
providerName: provider.name,
|
|
259
|
+
baseUrl: provider.baseUrl,
|
|
260
|
+
apiKey: provider.apiKey,
|
|
261
|
+
models: provider.models,
|
|
262
|
+
});
|
|
263
|
+
return { ...registration.config, providerId: registration.providerId };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function getAvailableProviders(ctx) {
|
|
267
|
+
const models = ctx.modelRegistry?.getAvailable?.() || [];
|
|
268
|
+
return [...new Set(models.map((model) => model.provider))].filter(Boolean);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function providerLabel(providerId) {
|
|
272
|
+
const labels = {
|
|
273
|
+
anthropic: "Claude (Anthropic)",
|
|
274
|
+
openai: "ChatGPT (OpenAI)",
|
|
275
|
+
google: "Gemini (Google)",
|
|
276
|
+
mistral: "Mistral",
|
|
277
|
+
groq: "Groq",
|
|
278
|
+
xai: "Grok (xAI)",
|
|
279
|
+
};
|
|
280
|
+
return labels[providerId] || providerId;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function findCustomProvider(configs, providerId) {
|
|
284
|
+
return configs.find((config) => config.providerId === providerId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function registerSavedProviders(pi, configs) {
|
|
288
|
+
for (const provider of configs) {
|
|
289
|
+
const normalized = normalizeCustomProvider(provider);
|
|
290
|
+
Object.assign(provider, normalized);
|
|
291
|
+
pi.registerProvider(normalized.providerId, normalized);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function selectAvailableProvider(pi, ctx, providerId) {
|
|
296
|
+
const models = ctx.modelRegistry
|
|
297
|
+
.getAvailable()
|
|
298
|
+
.filter((model) => model.provider === providerId);
|
|
299
|
+
const modelId = await ctx.ui.select(
|
|
300
|
+
`Select a model from ${providerId}`,
|
|
301
|
+
models.map((model) => model.id),
|
|
302
|
+
);
|
|
303
|
+
if (!modelId) return;
|
|
304
|
+
|
|
305
|
+
const model = ctx.modelRegistry.find(providerId, modelId);
|
|
306
|
+
if (!model) {
|
|
307
|
+
ctx.ui.notify(`Model ${providerId}/${modelId} was not found.`, "error");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const selected = await pi.setModel(model);
|
|
312
|
+
ctx.ui.notify(
|
|
313
|
+
selected
|
|
314
|
+
? `Using ${providerId}/${modelId}.`
|
|
315
|
+
: `Could not authenticate with ${providerId}/${modelId}.`,
|
|
316
|
+
selected ? "info" : "error",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function promptModel(ctx, model = {}, fixedIdentity) {
|
|
321
|
+
const id =
|
|
322
|
+
fixedIdentity?.id ||
|
|
323
|
+
(await ctx.ui.input("Model ID", model.id || "model-id"));
|
|
324
|
+
if (!id) return null;
|
|
325
|
+
const name =
|
|
326
|
+
fixedIdentity?.name ||
|
|
327
|
+
(await ctx.ui.input("Model name", model.name || id));
|
|
328
|
+
if (!name) return null;
|
|
329
|
+
const contextWindow = await ctx.ui.input(
|
|
330
|
+
"Context window",
|
|
331
|
+
String(model.contextWindow || DEFAULTS.contextWindow),
|
|
332
|
+
);
|
|
333
|
+
if (!contextWindow) return null;
|
|
334
|
+
const maxTokens = await ctx.ui.input(
|
|
335
|
+
"Max tokens",
|
|
336
|
+
String(model.maxTokens || DEFAULTS.maxTokens),
|
|
337
|
+
);
|
|
338
|
+
if (!maxTokens) return null;
|
|
339
|
+
return { id, name, contextWindow, maxTokens };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function discoverAndPromptModel(ctx, baseUrl, apiKey, current, options) {
|
|
343
|
+
const manualOption = "Enter model manually";
|
|
344
|
+
let discovered;
|
|
345
|
+
try {
|
|
346
|
+
discovered = await discoverAvailableModels(baseUrl, apiKey, options);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
ctx.ui.notify(
|
|
349
|
+
`Could not detect models: ${error.message}. Enter the model manually.`,
|
|
350
|
+
"warning",
|
|
351
|
+
);
|
|
352
|
+
return promptModel(ctx, current);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (discovered.length === 0) {
|
|
356
|
+
ctx.ui.notify(
|
|
357
|
+
"No models were returned by the API. Enter the model manually.",
|
|
358
|
+
"warning",
|
|
359
|
+
);
|
|
360
|
+
return promptModel(ctx, current);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const selectedId = await ctx.ui.select("Select a detected model", [
|
|
364
|
+
...discovered.map((model) => model.id),
|
|
365
|
+
manualOption,
|
|
366
|
+
]);
|
|
367
|
+
if (!selectedId) return null;
|
|
368
|
+
if (selectedId === manualOption) return promptModel(ctx, current);
|
|
369
|
+
|
|
370
|
+
const identity = discovered.find((model) => model.id === selectedId);
|
|
371
|
+
const existing = current?.id === selectedId ? current : {};
|
|
372
|
+
return promptModel(ctx, existing, identity);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function promptProvider(ctx, current = {}, options = {}) {
|
|
376
|
+
const providerId = await ctx.ui.input(
|
|
377
|
+
"Provider ID",
|
|
378
|
+
current.providerId || "my-provider",
|
|
379
|
+
);
|
|
380
|
+
if (!providerId) return null;
|
|
381
|
+
const providerName = await ctx.ui.input(
|
|
382
|
+
"Provider name",
|
|
383
|
+
current.name || providerLabel(providerId),
|
|
384
|
+
);
|
|
385
|
+
if (!providerName) return null;
|
|
386
|
+
const baseUrl = await ctx.ui.input(
|
|
387
|
+
"API URL",
|
|
388
|
+
current.baseUrl || "https://api.example.com/v1",
|
|
389
|
+
);
|
|
390
|
+
if (!baseUrl) return null;
|
|
391
|
+
const apiKey = await ctx.ui.input("API key", current.apiKey || "API key");
|
|
392
|
+
if (!apiKey) return null;
|
|
393
|
+
const model = await discoverAndPromptModel(
|
|
394
|
+
ctx,
|
|
395
|
+
baseUrl,
|
|
396
|
+
apiKey,
|
|
397
|
+
current.models?.[0],
|
|
398
|
+
options,
|
|
399
|
+
);
|
|
400
|
+
if (!model) return null;
|
|
401
|
+
|
|
402
|
+
const registration = buildCustomProviderRegistration({
|
|
403
|
+
providerId,
|
|
404
|
+
providerName,
|
|
405
|
+
baseUrl,
|
|
406
|
+
apiKey,
|
|
407
|
+
models: [model],
|
|
408
|
+
});
|
|
409
|
+
return { ...registration.config, providerId: registration.providerId };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function selectCustomModel(pi, ctx, provider) {
|
|
413
|
+
const providerId = provider.providerId || DEFAULTS.customProviderId;
|
|
414
|
+
const modelId = await ctx.ui.select(
|
|
415
|
+
`Select a model from ${provider.name}`,
|
|
416
|
+
provider.models.map((model) => model.id),
|
|
417
|
+
);
|
|
418
|
+
if (!modelId) return;
|
|
419
|
+
const model = ctx.modelRegistry.find(providerId, modelId);
|
|
420
|
+
if (!model) {
|
|
421
|
+
ctx.ui.notify(`Model ${providerId}/${modelId} was not found.`, "error");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const selected = await pi.setModel(model);
|
|
425
|
+
ctx.ui.notify(
|
|
426
|
+
selected
|
|
427
|
+
? `Using ${providerId}/${modelId}.`
|
|
428
|
+
: `Could not authenticate with ${providerId}/${modelId}.`,
|
|
429
|
+
selected ? "info" : "error",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function manageModels(pi, ctx, configs, provider, options) {
|
|
434
|
+
while (true) {
|
|
435
|
+
const addOption = "Add model";
|
|
436
|
+
const options = [
|
|
437
|
+
...provider.models.map((model) => model.id),
|
|
438
|
+
addOption,
|
|
439
|
+
"Back",
|
|
440
|
+
];
|
|
441
|
+
const selected = await ctx.ui.select(`Models in ${provider.name}`, options);
|
|
442
|
+
if (!selected || selected === "Back") return;
|
|
443
|
+
if (selected === addOption) {
|
|
444
|
+
const model = await discoverAndPromptModel(
|
|
445
|
+
ctx,
|
|
446
|
+
provider.baseUrl,
|
|
447
|
+
provider.apiKey,
|
|
448
|
+
undefined,
|
|
449
|
+
options,
|
|
450
|
+
);
|
|
451
|
+
if (!model) continue;
|
|
452
|
+
provider.models.push(model);
|
|
453
|
+
} else {
|
|
454
|
+
const modelIndex = provider.models.findIndex(
|
|
455
|
+
(model) => model.id === selected,
|
|
456
|
+
);
|
|
457
|
+
const action = await ctx.ui.select(`Manage model ${selected}`, [
|
|
458
|
+
"Edit model",
|
|
459
|
+
"Delete model",
|
|
460
|
+
"Back",
|
|
461
|
+
]);
|
|
462
|
+
if (action === "Edit model") {
|
|
463
|
+
const model = await promptModel(ctx, provider.models[modelIndex]);
|
|
464
|
+
if (model) provider.models[modelIndex] = model;
|
|
465
|
+
} else if (action === "Delete model") {
|
|
466
|
+
provider.models.splice(modelIndex, 1);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (provider.models.length === 0) {
|
|
470
|
+
ctx.ui.notify("A provider must have at least one model.", "error");
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const normalized = normalizeCustomProvider(provider);
|
|
474
|
+
Object.assign(provider, normalized);
|
|
475
|
+
saveCustomProviderConfigs(configs);
|
|
476
|
+
pi.registerProvider(normalized.providerId, normalized);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function manageProviders(pi, ctx, configs, options) {
|
|
481
|
+
while (true) {
|
|
482
|
+
const addOption = "Add provider";
|
|
483
|
+
const options = [
|
|
484
|
+
...configs.map((provider) => provider.name),
|
|
485
|
+
addOption,
|
|
486
|
+
"Back",
|
|
487
|
+
];
|
|
488
|
+
const selected = await ctx.ui.select("Manage custom providers", options);
|
|
489
|
+
if (!selected || selected === "Back") return;
|
|
490
|
+
if (selected === addOption) {
|
|
491
|
+
const provider = await promptProvider(ctx, {}, options);
|
|
492
|
+
if (!provider) continue;
|
|
493
|
+
configs.push(provider);
|
|
494
|
+
pi.registerProvider(
|
|
495
|
+
provider.providerId || DEFAULTS.customProviderId,
|
|
496
|
+
provider,
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
const providerIndex = configs.findIndex(
|
|
500
|
+
(provider) => provider.name === selected,
|
|
501
|
+
);
|
|
502
|
+
const provider = configs[providerIndex];
|
|
503
|
+
const action = await ctx.ui.select(`Manage ${provider.name}`, [
|
|
504
|
+
"Edit provider",
|
|
505
|
+
"Manage models",
|
|
506
|
+
"Delete provider",
|
|
507
|
+
"Back",
|
|
508
|
+
]);
|
|
509
|
+
if (action === "Edit provider") {
|
|
510
|
+
const updated = await promptProvider(ctx, provider, options);
|
|
511
|
+
if (updated) {
|
|
512
|
+
configs[providerIndex] = updated;
|
|
513
|
+
pi.registerProvider(
|
|
514
|
+
updated.providerId || DEFAULTS.customProviderId,
|
|
515
|
+
updated,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
} else if (action === "Manage models") {
|
|
519
|
+
await manageModels(pi, ctx, configs, provider, options);
|
|
520
|
+
} else if (action === "Delete provider") {
|
|
521
|
+
configs.splice(providerIndex, 1);
|
|
522
|
+
pi.unregisterProvider?.(
|
|
523
|
+
provider.providerId || DEFAULTS.customProviderId,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
saveCustomProviderConfigs(configs);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export default function customProvider(pi, options = {}) {
|
|
532
|
+
const { providerId, config } = buildProviderRegistration();
|
|
533
|
+
pi.registerProvider(providerId, config);
|
|
534
|
+
const savedConfigs = loadCustomProviderConfigs();
|
|
535
|
+
registerSavedProviders(pi, savedConfigs);
|
|
536
|
+
pi.registerCommand("provider", {
|
|
537
|
+
description: "Choose and manage providers and models",
|
|
538
|
+
handler: async (_args, ctx) => {
|
|
539
|
+
if (!ctx.hasUI) {
|
|
540
|
+
ctx.ui.notify("/provider requires an interactive UI.", "error");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const providers = getAvailableProviders(ctx);
|
|
545
|
+
const savedProviderIds = new Set(
|
|
546
|
+
savedConfigs.map(
|
|
547
|
+
(provider) => provider.providerId || DEFAULTS.customProviderId,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
const choices = [
|
|
551
|
+
...providers
|
|
552
|
+
.filter((id) => !savedProviderIds.has(id))
|
|
553
|
+
.map((id) => ({
|
|
554
|
+
label: providerLabel(id),
|
|
555
|
+
id,
|
|
556
|
+
custom: false,
|
|
557
|
+
})),
|
|
558
|
+
...savedConfigs.map((provider) => ({
|
|
559
|
+
label: `${provider.name} (custom)`,
|
|
560
|
+
id: provider.providerId,
|
|
561
|
+
custom: true,
|
|
562
|
+
})),
|
|
563
|
+
];
|
|
564
|
+
const addOption = "Add custom provider";
|
|
565
|
+
const manageOption = "Manage custom providers";
|
|
566
|
+
const selectedProvider = await ctx.ui.select("Select provider", [
|
|
567
|
+
...choices.map((choice) => choice.label),
|
|
568
|
+
addOption,
|
|
569
|
+
manageOption,
|
|
570
|
+
]);
|
|
571
|
+
if (!selectedProvider || selectedProvider === "Back") return;
|
|
572
|
+
if (selectedProvider === addOption) {
|
|
573
|
+
const provider = await promptProvider(ctx, {}, options);
|
|
574
|
+
if (!provider) return;
|
|
575
|
+
savedConfigs.push(provider);
|
|
576
|
+
pi.registerProvider(provider.providerId, provider);
|
|
577
|
+
saveCustomProviderConfigs(savedConfigs);
|
|
578
|
+
await selectCustomModel(pi, ctx, provider);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (selectedProvider === manageOption) {
|
|
582
|
+
await manageProviders(pi, ctx, savedConfigs, options);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const choice = choices.find((item) => item.label === selectedProvider);
|
|
586
|
+
if (!choice) return;
|
|
587
|
+
if (choice.custom) {
|
|
588
|
+
const provider = findCustomProvider(savedConfigs, choice.id);
|
|
589
|
+
if (provider) await selectCustomModel(pi, ctx, provider);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
await selectAvailableProvider(pi, ctx, choice.id);
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anandamw/custom-provider",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Configurable OpenAI-compatible custom provider for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"extensions",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi-package",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"extension",
|
|
18
|
+
"custom-provider",
|
|
19
|
+
"openai-compatible"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./extensions"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/4nndmw/pi-rakit.git",
|
|
33
|
+
"directory": "packages/custom-provider"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/4nndmw/pi-rakit#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/4nndmw/pi-rakit/issues"
|
|
38
|
+
}
|
|
39
|
+
}
|