pi-provider-litellm 1.2.9 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/dist/cost.d.ts +0 -6
- package/dist/cost.js +0 -0
- package/dist/discover.d.ts +3 -1
- package/dist/discover.js +46 -14
- package/dist/index.js +81 -94
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +13 -5
- package/dist/types.d.ts +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -119,6 +119,25 @@ Provider fields:
|
|
|
119
119
|
|
|
120
120
|
`/login litellm` and Google ADC token auth remain scoped to the default `litellm` provider. Aliases use their configured `apiKey` or manually stored auth entries matching the alias name.
|
|
121
121
|
|
|
122
|
+
### Optional LiteLLM features
|
|
123
|
+
|
|
124
|
+
LiteLLM Skills and MCP integration are enabled by default. Disable either feature globally in `~/.pi/agent/settings.json`:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"litellm": {
|
|
129
|
+
"skills": {
|
|
130
|
+
"enabled": false
|
|
131
|
+
},
|
|
132
|
+
"mcp": {
|
|
133
|
+
"enabled": false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Setting `skills.enabled` to `false` disables the Skills Gateway management tools, skill fetching, and system-prompt injection. Setting `mcp.enabled` to `false` disables LiteLLM MCP discovery and tool registration. Restart Pi after changing these settings so previously registered tools are removed.
|
|
140
|
+
|
|
122
141
|
## Use
|
|
123
142
|
|
|
124
143
|
```
|
|
@@ -133,10 +152,10 @@ Provider fields:
|
|
|
133
152
|
| `LITELLM_HEADERS` | unset | JSON object of extra headers sent to LiteLLM provider, discovery, MCP, and Skills Gateway requests. Provider aliases can use it with `"headers": "$LITELLM_HEADERS"`. |
|
|
134
153
|
| `LITELLM_GCLOUD_TOKEN_AUTH` | unset | If set to a non-empty value other than `0`, use Google Application Default Credentials as the LiteLLM bearer token source. This takes precedence over `LITELLM_API_KEY_HELPER` and `LITELLM_API_KEY` when no stored `/login litellm` credential exists. |
|
|
135
154
|
| `GOOGLE_APPLICATION_CREDENTIALS` | Google default ADC path | Optional path to an ADC JSON file used by `LITELLM_GCLOUD_TOKEN_AUTH`. If unset, the extension checks the default gcloud ADC locations. |
|
|
136
|
-
| `LITELLM_OFFLINE` | unset | If `1`,
|
|
137
|
-
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` |
|
|
155
|
+
| `LITELLM_OFFLINE` | unset | If `1`, disable all model and MCP discovery, including `/litellm-refresh` and post-login MCP discovery; use cached models only |
|
|
156
|
+
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Background and explicit discovery fetch timeout in ms; `0` disables automatic discovery |
|
|
138
157
|
|
|
139
|
-
`LITELLM_DISCOVERY_TIMEOUT_MS=0`
|
|
158
|
+
`LITELLM_DISCOVERY_TIMEOUT_MS=0` disables automatic and explicit refresh model discovery. It does not replace the base URL or API key settings required to send requests when you are not using `/login litellm`.
|
|
140
159
|
|
|
141
160
|
### Google ADC token auth
|
|
142
161
|
|
|
@@ -157,7 +176,7 @@ If your LiteLLM proxy exposes MCP REST endpoints, this extension discovers tools
|
|
|
157
176
|
- `GET /mcp-rest/tools/list`
|
|
158
177
|
- `POST /mcp-rest/tools/call`
|
|
159
178
|
|
|
160
|
-
Each discovered tool is registered as a native Pi tool named `mcp_<server>_<tool>`, with simple JSON Schema parameters mapped to Pi/TypeBox parameters. Complex schemas fall back to a single `args` object. MCP discovery runs after
|
|
179
|
+
Each discovered tool is registered as a native Pi tool named `mcp_<server>_<tool>`, with simple JSON Schema parameters mapped to Pi/TypeBox parameters. Complex schemas fall back to a single `args` object. MCP discovery runs after background model discovery, `/login litellm`, or `/litellm-refresh`; extension activation never waits for it. MCP tools run in Pi's parallel tool mode and retry transient failures once.
|
|
161
180
|
|
|
162
181
|
## LiteLLM Skill Hub
|
|
163
182
|
|
|
@@ -171,11 +190,11 @@ If your LiteLLM proxy exposes `/claude-code/marketplace.json`, enabled skills ar
|
|
|
171
190
|
|
|
172
191
|
The `LiteLLM Smoke` GitHub Actions workflow starts VidaiMock and a real LiteLLM proxy on the runner. LiteLLM exposes OpenAI-compatible and Anthropic routes whose upstreams are served by VidaiMock, then this extension's smoke runner discovers those models through LiteLLM and sends `/v1/chat/completions` requests through the proxy.
|
|
173
192
|
|
|
174
|
-
This keeps the LiteLLM integration path under test but does not call real LLM APIs. No provider API keys or GitHub Models permission are required. The smoke runner also asserts that discovery came from `/model/info` (`LITELLM_SMOKE_EXPECT_SOURCE`) so a silent fallback to `/v1/models` fails the run. The workflow also runs auth checks plus optional Postgres-backed auth checks when `LITELLM_LICENSE` is configured for virtual-key and admin-route behavior, then runs a non-interactive Pi CLI smoke with `--list-models` and `-p` against both the OpenAI-compatible and Anthropic-backed routes, so extension loading, model discovery, and real completion paths are covered without opening the TUI.
|
|
193
|
+
This keeps the LiteLLM integration path under test but does not call real LLM APIs. No provider API keys or GitHub Models permission are required. The smoke runner also asserts that discovery came from `/model/info` (`LITELLM_SMOKE_EXPECT_SOURCE`) so a silent fallback to `/v1/models` fails the run. The workflow also runs auth checks plus optional Postgres-backed auth checks when `LITELLM_LICENSE` is configured for virtual-key and admin-route behavior, then runs a non-interactive Pi CLI smoke with `--list-models` and `-p` against both the OpenAI-compatible and Anthropic-backed routes, so extension loading, model discovery, and real completion paths are covered without opening the TUI. It also runs an interactive Pi TUI smoke covering `/login litellm`, `/litellm-refresh`, and `/model`.
|
|
175
194
|
|
|
176
195
|
## Development
|
|
177
196
|
|
|
178
|
-
This package requires Node.js `>=22.19.0`. CI currently uses Node `
|
|
197
|
+
This package requires Node.js `>=22.19.0`. CI currently uses Node `26.5.0`.
|
|
179
198
|
|
|
180
199
|
```bash
|
|
181
200
|
npm ci
|
|
@@ -209,7 +228,7 @@ Before tagging a release, keep `package.json` and `package-lock.json` versions i
|
|
|
209
228
|
|
|
210
229
|
The default provider model list is cached at `~/.pi/agent/litellm-models.json` with a keyed fingerprint of the base URL + API key. Alias provider caches use `~/.pi/agent/litellm-models-<provider>.json`. Changing the base URL or key invalidates that provider's cache automatically.
|
|
211
230
|
|
|
212
|
-
|
|
231
|
+
When the cache is missing, invalid, or older than 24 hours, the extension starts without waiting for discovery and refreshes models in the background on session start. Existing cached models remain available while an invalid or stale cache refreshes. Failures are silent; run `/litellm-refresh` to force an immediate refresh and see its result.
|
|
213
232
|
|
|
214
233
|
## Troubleshooting
|
|
215
234
|
|
package/dist/cost.d.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
export interface ModelCostInfo {
|
|
3
|
-
inputCostPerToken: number;
|
|
4
|
-
outputCostPerToken: number;
|
|
5
|
-
cacheReadCostPerToken: number;
|
|
6
|
-
cacheWriteCostPerToken: number;
|
|
7
|
-
}
|
|
8
2
|
export interface ProviderModels {
|
|
9
3
|
provider: string;
|
|
10
4
|
models: ProviderModelConfig[];
|
package/dist/cost.js
CHANGED
|
Binary file
|
package/dist/discover.d.ts
CHANGED
|
@@ -9,4 +9,6 @@ export declare function withTimeout(timeoutMs: number, signal?: AbortSignal): {
|
|
|
9
9
|
signal: AbortSignal;
|
|
10
10
|
cancel: () => void;
|
|
11
11
|
};
|
|
12
|
-
export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions
|
|
12
|
+
export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions & {
|
|
13
|
+
onProgress?: (message: string) => void;
|
|
14
|
+
}): Promise<DiscoveryResult>;
|
package/dist/discover.js
CHANGED
|
@@ -10,10 +10,10 @@ export function normalizeBaseUrl(input) {
|
|
|
10
10
|
}
|
|
11
11
|
const RESPONSES_MODE_PATTERN = /^responses?$/i;
|
|
12
12
|
function isResponsesMode(mode) {
|
|
13
|
-
return mode
|
|
13
|
+
return mode != null && RESPONSES_MODE_PATTERN.test(mode);
|
|
14
14
|
}
|
|
15
15
|
function isChatStyleMode(mode) {
|
|
16
|
-
return mode
|
|
16
|
+
return mode == null || mode === "chat" || isResponsesMode(mode);
|
|
17
17
|
}
|
|
18
18
|
// Matches both the conventional `anthropic/...` prefix and aliases that
|
|
19
19
|
// LiteLLM deployments commonly assign to Anthropic-backed routes (e.g.
|
|
@@ -104,6 +104,7 @@ function mapModelInfoCost(info, fallback) {
|
|
|
104
104
|
cacheWrite: info.cache_creation_input_token_cost !== undefined
|
|
105
105
|
? info.cache_creation_input_token_cost * 1_000_000
|
|
106
106
|
: (fallback?.cacheWrite ?? 0),
|
|
107
|
+
...(fallback?.tiers ? { tiers: fallback.tiers } : {}),
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
110
|
function getFallbackProviderAndModel(id, ownedBy) {
|
|
@@ -221,6 +222,7 @@ function mapFromModelInfo(entry) {
|
|
|
221
222
|
id,
|
|
222
223
|
name: id,
|
|
223
224
|
reasoning: info.supports_reasoning ?? false,
|
|
225
|
+
...(catalogModel?.thinkingLevelMap ? { thinkingLevelMap: catalogModel.thinkingLevelMap } : {}),
|
|
224
226
|
input: info.supports_vision ? ["text", "image"] : ["text"],
|
|
225
227
|
cost: mapModelInfoCost(info, catalogModel?.cost),
|
|
226
228
|
contextWindow: info.max_input_tokens ?? DEFAULT_CONTEXT_WINDOW,
|
|
@@ -286,45 +288,75 @@ function mapFromModelsList(entry, modelsDev) {
|
|
|
286
288
|
};
|
|
287
289
|
}
|
|
288
290
|
async function discoverFromHealth(base, apiKey, options) {
|
|
291
|
+
options.onProgress?.("Querying /health endpoint...");
|
|
289
292
|
const healthResult = await fetchJson(`${base}/health`, apiKey, options);
|
|
290
293
|
if (!healthResult.ok)
|
|
291
294
|
return [];
|
|
292
295
|
const endpoints = (healthResult.data.healthy_endpoints ?? []).filter((entry) => entry.model || entry.model_id);
|
|
296
|
+
options.onProgress?.(`Discovered ${endpoints.length} model endpoints, fetching details...`);
|
|
297
|
+
let completed = 0;
|
|
293
298
|
const models = await Promise.all(endpoints.map(async (endpoint) => {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
let model = mapFromHealthEndpoint(endpoint);
|
|
300
|
+
if (endpoint.model_id) {
|
|
301
|
+
const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
|
|
302
|
+
const entry = infoResult.ok ? infoResult.data.data?.[0] : undefined;
|
|
303
|
+
if (entry)
|
|
304
|
+
model = mapFromHealthModelInfo(entry, endpoint.model);
|
|
305
|
+
}
|
|
306
|
+
completed++;
|
|
307
|
+
if (completed % 10 === 0 || completed === endpoints.length) {
|
|
308
|
+
options.onProgress?.(`Fetched ${completed}/${endpoints.length} models...`);
|
|
309
|
+
}
|
|
310
|
+
return model;
|
|
301
311
|
}));
|
|
302
312
|
return models.filter((model) => model !== undefined);
|
|
303
313
|
}
|
|
314
|
+
function deduplicateModels(models) {
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
return models.filter((m) => {
|
|
317
|
+
if (seen.has(m.id))
|
|
318
|
+
return false;
|
|
319
|
+
seen.add(m.id);
|
|
320
|
+
return true;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
304
323
|
export async function discoverModels(baseUrl, apiKey, options = {}) {
|
|
305
324
|
const base = normalizeBaseUrl(baseUrl);
|
|
325
|
+
options.onProgress?.("Querying /model/info endpoint...");
|
|
306
326
|
const infoResult = await fetchJson(`${base}/model/info`, apiKey, options);
|
|
307
327
|
if (infoResult.ok) {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
.
|
|
328
|
+
const entries = new Map();
|
|
329
|
+
for (const entry of infoResult.data.data ?? []) {
|
|
330
|
+
if (!entry.model_name)
|
|
331
|
+
continue;
|
|
332
|
+
const previous = entries.get(entry.model_name);
|
|
333
|
+
entries.set(entry.model_name, {
|
|
334
|
+
...previous,
|
|
335
|
+
...entry,
|
|
336
|
+
model_info: { ...previous?.model_info, ...entry.model_info },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const models = [...entries.values()].map(mapFromModelInfo).filter((m) => m !== undefined);
|
|
311
340
|
return { source: "model_info", models };
|
|
312
341
|
}
|
|
313
342
|
if (![401, 403, 404].includes(infoResult.status)) {
|
|
314
343
|
throw new Error(`/model/info returned ${infoResult.status}`);
|
|
315
344
|
}
|
|
345
|
+
options.onProgress?.("/model/info unavailable, trying /v1/models...");
|
|
316
346
|
const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
|
|
317
347
|
if (!listResult.ok) {
|
|
318
348
|
if ([401, 403, 404].includes(listResult.status)) {
|
|
349
|
+
options.onProgress?.("/v1/models unavailable, falling back to /health endpoint...");
|
|
319
350
|
const models = await discoverFromHealth(base, apiKey, options);
|
|
320
351
|
if (models.length > 0)
|
|
321
|
-
return { source: "health", models };
|
|
352
|
+
return { source: "health", models: deduplicateModels(models) };
|
|
322
353
|
}
|
|
323
354
|
throw new Error(`/v1/models returned ${listResult.status}`);
|
|
324
355
|
}
|
|
356
|
+
options.onProgress?.("Fetching models.dev catalog for metadata enrichment...");
|
|
325
357
|
const modelsDev = await getModelsDevCatalog(options);
|
|
326
358
|
const models = (listResult.data.data ?? [])
|
|
327
359
|
.map((entry) => mapFromModelsList(entry, modelsDev))
|
|
328
360
|
.filter((m) => m !== undefined);
|
|
329
|
-
return { source: "models_list", models };
|
|
361
|
+
return { source: "models_list", models: deduplicateModels(models) };
|
|
330
362
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { execSync
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { fingerprint, readCache, writeCache } from "./cache.js";
|
|
6
6
|
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
7
7
|
import { discoverModels, isGpt55Model, normalizeBaseUrl, shouldSuppressReasoningContent, withTimeout, } from "./discover.js";
|
|
@@ -24,6 +24,21 @@ const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
|
|
|
24
24
|
const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
|
|
25
25
|
const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
|
|
26
26
|
const EXPIRE_TOKEN_IMMEDIATELY = 0;
|
|
27
|
+
function credentialsFromRefresh(state, credential) {
|
|
28
|
+
const apiKey = credential.type === "oauth" ? credential.access : credential.key;
|
|
29
|
+
const credentialBaseUrl = credential.type === "oauth" ? credential.baseUrl : undefined;
|
|
30
|
+
const fingerprintSource = credential.type === "api_key" && state.creds.apiKeyConfig?.startsWith("!")
|
|
31
|
+
? state.creds.apiKeyConfig
|
|
32
|
+
: credential.type === "oauth" && credential.refresh.startsWith("!")
|
|
33
|
+
? credential.refresh
|
|
34
|
+
: apiKey;
|
|
35
|
+
return {
|
|
36
|
+
...state.creds,
|
|
37
|
+
baseUrl: typeof credentialBaseUrl === "string" ? normalizeBaseUrl(credentialBaseUrl) : state.creds.baseUrl,
|
|
38
|
+
apiKey,
|
|
39
|
+
apiKeyFingerprint: fingerprintSource ? fingerprint(fingerprintSource) : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
27
42
|
function getAuthPath() {
|
|
28
43
|
return join(getAgentDir(), "auth.json");
|
|
29
44
|
}
|
|
@@ -48,7 +63,9 @@ function stripJsonComments(input) {
|
|
|
48
63
|
function isPlainObject(value) {
|
|
49
64
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
65
|
}
|
|
51
|
-
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
66
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
67
|
+
const COST_RATE_FIELDS = ["input", "output", "cacheRead", "cacheWrite"];
|
|
68
|
+
const COST_TIER_FIELDS = [...COST_RATE_FIELDS, "inputTokensAbove"];
|
|
52
69
|
// Mirrors pi core's ModelOverrideSchema: core rejects the whole models.json on invalid values,
|
|
53
70
|
// so anything dropped here would also have been flagged for built-in providers. Headers matter
|
|
54
71
|
// most: pi resolves each header value as a config string, so non-strings break requests.
|
|
@@ -61,7 +78,13 @@ const MODEL_OVERRIDE_VALIDATORS = {
|
|
|
61
78
|
maxTokens: (value) => typeof value === "number",
|
|
62
79
|
headers: (value) => isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "string"),
|
|
63
80
|
compat: isPlainObject,
|
|
64
|
-
cost: (value) => isPlainObject(value) &&
|
|
81
|
+
cost: (value) => isPlainObject(value) &&
|
|
82
|
+
Object.entries(value).every(([key, entry]) => key === "tiers"
|
|
83
|
+
? Array.isArray(entry) &&
|
|
84
|
+
entry.every((tier) => isPlainObject(tier) &&
|
|
85
|
+
COST_TIER_FIELDS.every((field) => typeof tier[field] === "number") &&
|
|
86
|
+
Object.keys(tier).every((field) => COST_TIER_FIELDS.includes(field)))
|
|
87
|
+
: COST_RATE_FIELDS.includes(key) && typeof entry === "number"),
|
|
65
88
|
};
|
|
66
89
|
function sanitizeModelOverride(modelId, raw) {
|
|
67
90
|
if (!isPlainObject(raw)) {
|
|
@@ -160,15 +183,8 @@ function applyModelOverrides(models, overrides) {
|
|
|
160
183
|
async function applyOverrides(providerName, models) {
|
|
161
184
|
return applyModelOverrides(models, await readModelOverrides(providerName));
|
|
162
185
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const raw = await readFile(getAuthPath(), "utf8");
|
|
166
|
-
const parsed = JSON.parse(raw);
|
|
167
|
-
return parsed?.[providerName];
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
186
|
+
function readAuthEntry(providerName = PROVIDER_NAME) {
|
|
187
|
+
return readStoredCredential(providerName, getAuthPath());
|
|
172
188
|
}
|
|
173
189
|
async function readGlobalLiteLLMSettings() {
|
|
174
190
|
try {
|
|
@@ -220,19 +236,6 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
|
|
|
220
236
|
return opaqueFallback;
|
|
221
237
|
}
|
|
222
238
|
}
|
|
223
|
-
function openInBrowser(url) {
|
|
224
|
-
// Never invoke a shell here: cmd.exe re-parses metacharacters before `start`
|
|
225
|
-
// runs, which would make URL contents injectable. Launch is best-effort; the
|
|
226
|
-
// URL is also shown to the user, so launcher failures must not crash the process.
|
|
227
|
-
const [cmd, args] = process.platform === "darwin"
|
|
228
|
-
? ["open", [url]]
|
|
229
|
-
: process.platform === "win32"
|
|
230
|
-
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
231
|
-
: ["xdg-open", [url]];
|
|
232
|
-
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
233
|
-
.on("error", () => undefined)
|
|
234
|
-
.unref();
|
|
235
|
-
}
|
|
236
239
|
async function generateVirtualKey(baseUrl, userToken, signal, headers) {
|
|
237
240
|
const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
|
|
238
241
|
try {
|
|
@@ -394,8 +397,8 @@ async function resolveCredentials(definition, { executeHelpers = true } = {}) {
|
|
|
394
397
|
const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
|
|
395
398
|
const authKey = entry?.type === "oauth"
|
|
396
399
|
? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
|
|
397
|
-
: entry?.type === "api_key"
|
|
398
|
-
? (
|
|
400
|
+
: entry?.type === "api_key" && typeof entry.key === "string"
|
|
401
|
+
? resolveConfigValue(entry.key, { executeCommands: executeHelpers })?.trim()
|
|
399
402
|
: undefined;
|
|
400
403
|
const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
|
|
401
404
|
// Resolved lazily so a `!command` key is not executed when a
|
|
@@ -478,8 +481,11 @@ function normalizeProviderSettings(raw) {
|
|
|
478
481
|
return undefined;
|
|
479
482
|
return record;
|
|
480
483
|
}
|
|
481
|
-
|
|
482
|
-
const
|
|
484
|
+
function isFeatureEnabled(settings, feature) {
|
|
485
|
+
const raw = settings?.[feature];
|
|
486
|
+
return !isPlainObject(raw) || raw.enabled !== false;
|
|
487
|
+
}
|
|
488
|
+
function getProviderDefinitions(settings) {
|
|
483
489
|
const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
|
|
484
490
|
const providerSettings = rawProviders;
|
|
485
491
|
const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
|
|
@@ -593,6 +599,7 @@ async function loginLiteLLM(callbacks, options) {
|
|
|
593
599
|
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
594
600
|
signal: callbacks.signal,
|
|
595
601
|
headers: options.headers,
|
|
602
|
+
onProgress: (message) => callbacks.onProgress?.(`LiteLLM: ${message}`),
|
|
596
603
|
});
|
|
597
604
|
const cache = {
|
|
598
605
|
baseUrl,
|
|
@@ -750,7 +757,10 @@ function normalizeThinkTags(message, litellmProviderNames) {
|
|
|
750
757
|
return { ...message, content };
|
|
751
758
|
}
|
|
752
759
|
export default async function (pi) {
|
|
753
|
-
const
|
|
760
|
+
const settings = await readGlobalLiteLLMSettings();
|
|
761
|
+
const definitions = getProviderDefinitions(settings);
|
|
762
|
+
const skillsEnabled = isFeatureEnabled(settings, "skills");
|
|
763
|
+
const mcpEnabled = isFeatureEnabled(settings, "mcp");
|
|
754
764
|
const providerNames = new Set(definitions.map((definition) => definition.name));
|
|
755
765
|
function discoveryDisabledReason() {
|
|
756
766
|
if (isOffline())
|
|
@@ -772,12 +782,10 @@ export default async function (pi) {
|
|
|
772
782
|
cache.baseUrl === creds.baseUrl &&
|
|
773
783
|
cache.apiKeyFingerprint === fp &&
|
|
774
784
|
cache.headersFingerprint === headersFp;
|
|
775
|
-
let models =
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
getDiscoveryTimeoutMs() > 0 &&
|
|
780
|
-
(!cacheValid || isListModelsMode());
|
|
785
|
+
let models = cache?.models ?? [];
|
|
786
|
+
const canDiscover = creds.baseUrl !== undefined && fp !== undefined && !isOffline() && getDiscoveryTimeoutMs() > 0;
|
|
787
|
+
const shouldFetch = canDiscover && isListModelsMode();
|
|
788
|
+
const refreshOnStart = canDiscover && !cacheValid && !shouldFetch;
|
|
781
789
|
let credentialWarning;
|
|
782
790
|
let liveDiscoveryApiKey;
|
|
783
791
|
if (shouldFetch) {
|
|
@@ -806,6 +814,7 @@ export default async function (pi) {
|
|
|
806
814
|
const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
|
|
807
815
|
timeoutMs,
|
|
808
816
|
headers,
|
|
817
|
+
onProgress: (message) => process.stderr.write(`LiteLLM: ${message}\n`),
|
|
809
818
|
});
|
|
810
819
|
if (warning) {
|
|
811
820
|
if (cacheValid && cache) {
|
|
@@ -837,7 +846,16 @@ export default async function (pi) {
|
|
|
837
846
|
}
|
|
838
847
|
// The cache keeps raw discovery output; overrides are applied freshly at each registration.
|
|
839
848
|
models = await applyOverrides(definition.name, models);
|
|
840
|
-
return {
|
|
849
|
+
return {
|
|
850
|
+
definition,
|
|
851
|
+
creds,
|
|
852
|
+
headers,
|
|
853
|
+
models,
|
|
854
|
+
cacheFetchedAt,
|
|
855
|
+
refreshOnStart,
|
|
856
|
+
liveDiscoveryApiKey,
|
|
857
|
+
refreshInProgress: null,
|
|
858
|
+
};
|
|
841
859
|
}
|
|
842
860
|
const providerStates = await Promise.all(definitions.map(loadProviderState));
|
|
843
861
|
const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
|
|
@@ -861,6 +879,9 @@ export default async function (pi) {
|
|
|
861
879
|
api: "openai-completions",
|
|
862
880
|
headers: escapeHeaderConfig(state.headers),
|
|
863
881
|
models,
|
|
882
|
+
refreshModels: ({ allowNetwork, credential, signal }) => allowNetwork && !discoveryDisabledReason()
|
|
883
|
+
? runRefresh(state, undefined, credential, signal).then((result) => result.models)
|
|
884
|
+
: Promise.resolve(state.models),
|
|
864
885
|
oauth: definition.enableOAuth ? oauth : undefined,
|
|
865
886
|
});
|
|
866
887
|
}
|
|
@@ -899,7 +920,7 @@ export default async function (pi) {
|
|
|
899
920
|
return fresh.apiKey;
|
|
900
921
|
}
|
|
901
922
|
function registerSkillTools(state = defaultState) {
|
|
902
|
-
if (!state.creds.baseUrl)
|
|
923
|
+
if (!skillsEnabled || !state.creds.baseUrl)
|
|
903
924
|
return;
|
|
904
925
|
for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
|
|
905
926
|
pi.registerTool(tool);
|
|
@@ -916,11 +937,11 @@ export default async function (pi) {
|
|
|
916
937
|
return resolveRuntimeApiKey(state);
|
|
917
938
|
};
|
|
918
939
|
}
|
|
919
|
-
async function registerMcpTools(state) {
|
|
920
|
-
if (!state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
|
|
940
|
+
async function registerMcpTools(state, onProgress) {
|
|
941
|
+
if (!mcpEnabled || !state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
|
|
921
942
|
return;
|
|
922
943
|
try {
|
|
923
|
-
const tools = await createMcpToolDefinitions(state.creds.baseUrl, seededRuntimeApiKey(state, state.liveDiscoveryApiKey), state.headers);
|
|
944
|
+
const tools = await createMcpToolDefinitions(state.creds.baseUrl, seededRuntimeApiKey(state, state.liveDiscoveryApiKey), state.headers, (message) => onProgress ? onProgress(`LiteLLM MCP: ${message}`) : process.stderr.write(`LiteLLM MCP: ${message}\n`));
|
|
924
945
|
for (const tool of tools) {
|
|
925
946
|
pi.registerTool(tool);
|
|
926
947
|
}
|
|
@@ -930,17 +951,22 @@ export default async function (pi) {
|
|
|
930
951
|
}
|
|
931
952
|
}
|
|
932
953
|
registerSkillTools(defaultState);
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const fresh = await resolveCredentials(state.definition);
|
|
954
|
+
async function refreshModelsAndCosts(state, onProgress, credential, signal) {
|
|
955
|
+
const fresh = credential ? credentialsFromRefresh(state, credential) : await resolveCredentials(state.definition);
|
|
936
956
|
const freshFp = fresh.apiKeyFingerprint;
|
|
937
957
|
if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
|
|
938
958
|
throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
|
|
939
959
|
}
|
|
960
|
+
const progress = onProgress
|
|
961
|
+
? (message) => onProgress(`LiteLLM: ${message}`)
|
|
962
|
+
: (message) => process.stderr.write(`LiteLLM: ${message}\n`);
|
|
940
963
|
const result = await discoverModels(fresh.baseUrl, fresh.apiKey, {
|
|
941
964
|
timeoutMs: getDiscoveryTimeoutMs(),
|
|
965
|
+
signal,
|
|
942
966
|
headers: state.headers,
|
|
967
|
+
onProgress: progress,
|
|
943
968
|
});
|
|
969
|
+
signal?.throwIfAborted();
|
|
944
970
|
const now = Date.now();
|
|
945
971
|
await writeCache(getCachePath(state.definition.name), {
|
|
946
972
|
baseUrl: fresh.baseUrl,
|
|
@@ -955,60 +981,19 @@ export default async function (pi) {
|
|
|
955
981
|
state.models = overridden;
|
|
956
982
|
state.liveDiscoveryApiKey = fresh.apiKey;
|
|
957
983
|
state.cacheFetchedAt = now;
|
|
984
|
+
state.refreshOnStart = false;
|
|
958
985
|
registerProvider(state, overridden, fresh.apiKeyConfig);
|
|
959
986
|
updateAllCosts();
|
|
960
987
|
if (state.definition.name === PROVIDER_NAME)
|
|
961
|
-
await registerMcpTools(state);
|
|
988
|
+
await registerMcpTools(state, onProgress);
|
|
962
989
|
return { providerName: state.definition.name, models: overridden, source: result.source };
|
|
963
990
|
}
|
|
964
|
-
function runRefresh(state) {
|
|
965
|
-
state.refreshInProgress ??= refreshModelsAndCosts(state).finally(() => {
|
|
991
|
+
function runRefresh(state, onProgress, credential, signal) {
|
|
992
|
+
state.refreshInProgress ??= refreshModelsAndCosts(state, onProgress, credential, signal).finally(() => {
|
|
966
993
|
state.refreshInProgress = null;
|
|
967
994
|
});
|
|
968
995
|
return state.refreshInProgress;
|
|
969
996
|
}
|
|
970
|
-
async function runLogin(ctx) {
|
|
971
|
-
const credential = await loginLiteLLM({
|
|
972
|
-
onAuth: ({ url, instructions }) => {
|
|
973
|
-
openInBrowser(url);
|
|
974
|
-
ctx.ui.notify(instructions ? `${url} — ${instructions}` : url, "info");
|
|
975
|
-
},
|
|
976
|
-
onDeviceCode: () => undefined,
|
|
977
|
-
onPrompt: async ({ message, placeholder }) => {
|
|
978
|
-
const value = await ctx.ui.input(message, placeholder);
|
|
979
|
-
if (value === undefined)
|
|
980
|
-
throw new Error("Login cancelled");
|
|
981
|
-
return value;
|
|
982
|
-
},
|
|
983
|
-
onProgress: (message) => ctx.ui.notify(message, "info"),
|
|
984
|
-
onSelect: async () => undefined,
|
|
985
|
-
signal: ctx.signal,
|
|
986
|
-
}, defaultLoginOptions());
|
|
987
|
-
ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
|
|
988
|
-
ctx.modelRegistry.refresh();
|
|
989
|
-
const credentialBaseUrl = credential.baseUrl;
|
|
990
|
-
const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
|
|
991
|
-
defaultState.creds = { ...defaultState.creds, baseUrl: credentialBaseUrl };
|
|
992
|
-
defaultState.liveDiscoveryApiKey = credentialAccess;
|
|
993
|
-
registerSkillTools(defaultState);
|
|
994
|
-
await registerMcpTools(defaultState);
|
|
995
|
-
ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
|
|
996
|
-
}
|
|
997
|
-
pi.on("input", async (event, ctx) => {
|
|
998
|
-
if (event.text.trim() !== `/login ${PROVIDER_NAME}`)
|
|
999
|
-
return { action: "continue" };
|
|
1000
|
-
if (!ctx.hasUI)
|
|
1001
|
-
return { action: "continue" };
|
|
1002
|
-
try {
|
|
1003
|
-
await runLogin(ctx);
|
|
1004
|
-
}
|
|
1005
|
-
catch (error) {
|
|
1006
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1007
|
-
if (message !== "Login cancelled")
|
|
1008
|
-
ctx.ui.notify(`LiteLLM login failed: ${message}`, "error");
|
|
1009
|
-
}
|
|
1010
|
-
return { action: "handled" };
|
|
1011
|
-
});
|
|
1012
997
|
pi.registerCommand("litellm-refresh", {
|
|
1013
998
|
description: "Re-discover models from the LiteLLM proxy.",
|
|
1014
999
|
handler: async (args, ctx) => {
|
|
@@ -1025,7 +1010,7 @@ export default async function (pi) {
|
|
|
1025
1010
|
ctx.ui.notify(`LiteLLM refresh failed: unknown provider ${requestedProvider}`, "error");
|
|
1026
1011
|
return;
|
|
1027
1012
|
}
|
|
1028
|
-
const settled = await Promise.allSettled(statesToRefresh.map(runRefresh));
|
|
1013
|
+
const settled = await Promise.allSettled(statesToRefresh.map((state) => runRefresh(state, (message) => ctx.ui.notify(message, "info"))));
|
|
1029
1014
|
const succeeded = settled.filter((result) => result.status === "fulfilled").map((result) => result.value);
|
|
1030
1015
|
const failed = settled
|
|
1031
1016
|
.map((result, index) => ({ result, name: statesToRefresh[index].definition.name }))
|
|
@@ -1061,9 +1046,11 @@ export default async function (pi) {
|
|
|
1061
1046
|
if (discoveryDisabledReason())
|
|
1062
1047
|
return;
|
|
1063
1048
|
for (const state of providerStates) {
|
|
1064
|
-
|
|
1049
|
+
const cacheIsStale = state.cacheFetchedAt > 0 && Date.now() - state.cacheFetchedAt > CACHE_STALE_MS;
|
|
1050
|
+
if (!state.refreshOnStart && !cacheIsStale)
|
|
1065
1051
|
continue;
|
|
1066
|
-
|
|
1052
|
+
const progress = ctx.ui?.notify ? (message) => ctx.ui.notify(message, "info") : undefined;
|
|
1053
|
+
void runRefresh(state, progress).catch(() => undefined);
|
|
1067
1054
|
}
|
|
1068
1055
|
});
|
|
1069
1056
|
pi.on("before_provider_request", (event, ctx) => {
|
|
@@ -1074,7 +1061,7 @@ export default async function (pi) {
|
|
|
1074
1061
|
return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
|
|
1075
1062
|
});
|
|
1076
1063
|
pi.on("before_agent_start", async (event) => {
|
|
1077
|
-
if (discoveryDisabledReason())
|
|
1064
|
+
if (!skillsEnabled || discoveryDisabledReason())
|
|
1078
1065
|
return;
|
|
1079
1066
|
const fresh = await resolveCredentials(defaultState.definition);
|
|
1080
1067
|
if (!fresh.baseUrl || !fresh.apiKey)
|
package/dist/mcp-tools.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { LiteLLMMcpTool } from "./types.js";
|
|
3
|
-
export declare function discoverMcpTools(baseUrl: string, apiKey: string, headers?: Record<string, string
|
|
3
|
+
export declare function discoverMcpTools(baseUrl: string, apiKey: string, headers?: Record<string, string>, onProgress?: (message: string) => void): Promise<LiteLLMMcpTool[]>;
|
|
4
4
|
export declare function executeMcpTool(baseUrl: string, apiKey: string, serverId: string, toolName: string, args: Record<string, unknown>, headers?: Record<string, string>): Promise<string>;
|
|
5
|
-
export declare function createMcpToolDefinitions(baseUrl: string, getApiKey: () => Promise<string>, headers?: Record<string, string
|
|
5
|
+
export declare function createMcpToolDefinitions(baseUrl: string, getApiKey: () => Promise<string>, headers?: Record<string, string>, onProgress?: (message: string) => void): Promise<ToolDefinition[]>;
|
package/dist/mcp-tools.js
CHANGED
|
@@ -57,9 +57,11 @@ function normalizeMcpTool(value) {
|
|
|
57
57
|
input_schema: inputSchema,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
-
export async function discoverMcpTools(baseUrl, apiKey, headers) {
|
|
60
|
+
export async function discoverMcpTools(baseUrl, apiKey, headers, onProgress) {
|
|
61
|
+
onProgress?.("Discovering MCP tools from server...");
|
|
61
62
|
const { signal, cancel } = withTimeout(LIST_TIMEOUT_MS);
|
|
62
63
|
try {
|
|
64
|
+
onProgress?.("Querying MCP tools/list endpoint...");
|
|
63
65
|
const response = await fetch(`${normalizeBaseUrl(baseUrl)}/mcp-rest/tools/list`, {
|
|
64
66
|
headers: {
|
|
65
67
|
...headers,
|
|
@@ -68,14 +70,20 @@ export async function discoverMcpTools(baseUrl, apiKey, headers) {
|
|
|
68
70
|
},
|
|
69
71
|
signal,
|
|
70
72
|
});
|
|
71
|
-
if (!response.ok)
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
onProgress?.(`MCP tools discovery failed with status ${response.status}`);
|
|
72
75
|
return [];
|
|
76
|
+
}
|
|
73
77
|
const body = (await response.json());
|
|
74
78
|
const bodyRecord = asRecord(body);
|
|
75
79
|
const rawTools = Array.isArray(body) ? body : Array.isArray(bodyRecord?.tools) ? bodyRecord.tools : [];
|
|
76
|
-
|
|
80
|
+
onProgress?.(`Found ${rawTools.length} raw MCP tools, normalizing...`);
|
|
81
|
+
const tools = rawTools.map(normalizeMcpTool).filter((tool) => tool !== undefined);
|
|
82
|
+
onProgress?.(`Successfully discovered ${tools.length} MCP tools`);
|
|
83
|
+
return tools;
|
|
77
84
|
}
|
|
78
85
|
catch {
|
|
86
|
+
onProgress?.("MCP tools discovery encountered an error");
|
|
79
87
|
return [];
|
|
80
88
|
}
|
|
81
89
|
finally {
|
|
@@ -156,9 +164,9 @@ function buildParameters(inputSchema) {
|
|
|
156
164
|
}
|
|
157
165
|
return Type.Unsafe(inputSchema);
|
|
158
166
|
}
|
|
159
|
-
export async function createMcpToolDefinitions(baseUrl, getApiKey, headers) {
|
|
167
|
+
export async function createMcpToolDefinitions(baseUrl, getApiKey, headers, onProgress) {
|
|
160
168
|
const discoveryApiKey = await getApiKey();
|
|
161
|
-
const tools = await discoverMcpTools(baseUrl, discoveryApiKey, headers);
|
|
169
|
+
const tools = await discoverMcpTools(baseUrl, discoveryApiKey, headers, onProgress);
|
|
162
170
|
return tools.map((mcpTool) => {
|
|
163
171
|
const safeServer = sanitizeName(mcpTool.server_name);
|
|
164
172
|
const safeTool = sanitizeName(mcpTool.name);
|
package/dist/types.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface DiscoveryOptions {
|
|
|
20
20
|
export interface ModelInfoEntry {
|
|
21
21
|
model_name?: string;
|
|
22
22
|
model_info?: {
|
|
23
|
-
mode?: string;
|
|
23
|
+
mode?: string | null;
|
|
24
24
|
input_cost_per_token?: number;
|
|
25
25
|
output_cost_per_token?: number;
|
|
26
26
|
cache_read_input_token_cost?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "LiteLLM proxy provider extension for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -64,9 +64,10 @@
|
|
|
64
64
|
"protobufjs": "8.2.1"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
+
"@biomejs/biome": "^2.4.15",
|
|
67
68
|
"@earendil-works/pi-ai": "^0.80.2",
|
|
68
69
|
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
69
|
-
"@
|
|
70
|
+
"@kitlangton/terminal-control": "0.3.1",
|
|
70
71
|
"@types/node": "^26.0.0",
|
|
71
72
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
72
73
|
"tsx": "^4.21.0",
|