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 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`, skip discovery on this start; use cache only |
137
- | `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
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` only disables startup and 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`.
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 successful live model discovery, `/login litellm`, or `/litellm-refresh`; it does not force network or helper-token access when a fresh model cache is used. MCP tools run in Pi's parallel tool mode and retry transient failures once.
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 `24.16.0`.
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
- If the cache is older than 24 hours, the extension refreshes it in the background on session start (non-blocking). Failures are silent — the cached models remain in use. Run `/litellm-refresh` to force an immediate refresh.
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
@@ -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): Promise<DiscoveryResult>;
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 !== undefined && RESPONSES_MODE_PATTERN.test(mode);
13
+ return mode != null && RESPONSES_MODE_PATTERN.test(mode);
14
14
  }
15
15
  function isChatStyleMode(mode) {
16
- return mode === undefined || mode === "chat" || isResponsesMode(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
- if (!endpoint.model_id)
295
- return mapFromHealthEndpoint(endpoint);
296
- const infoResult = await fetchJson(`${base}/model/info?litellm_model_id=${encodeURIComponent(endpoint.model_id)}`, apiKey, options);
297
- if (!infoResult.ok)
298
- return mapFromHealthEndpoint(endpoint);
299
- const entry = infoResult.data.data?.[0];
300
- return entry ? mapFromHealthModelInfo(entry, endpoint.model) : mapFromHealthEndpoint(endpoint);
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 models = (infoResult.data.data ?? [])
309
- .map(mapFromModelInfo)
310
- .filter((m) => m !== undefined);
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, spawn } from "node:child_process";
1
+ import { execSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
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) && Object.values(value).every((entry) => typeof entry === "number"),
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
- async function readAuthEntry(providerName = PROVIDER_NAME) {
164
- try {
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
- ? (await AuthStorage.create(getAuthPath()).getApiKey(definition.name, { includeFallback: false }))?.trim()
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
- async function getProviderDefinitions() {
482
- const settings = await readGlobalLiteLLMSettings();
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 definitions = await getProviderDefinitions();
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 = cacheValid && cache ? cache.models : [];
776
- const shouldFetch = creds.baseUrl !== undefined &&
777
- fp !== undefined &&
778
- !isOffline() &&
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 { definition, creds, headers, models, cacheFetchedAt, liveDiscoveryApiKey, refreshInProgress: null };
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
- await registerMcpTools(defaultState);
934
- async function refreshModelsAndCosts(state) {
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
- if (!state.cacheFetchedAt || Date.now() - state.cacheFetchedAt <= CACHE_STALE_MS)
1049
+ const cacheIsStale = state.cacheFetchedAt > 0 && Date.now() - state.cacheFetchedAt > CACHE_STALE_MS;
1050
+ if (!state.refreshOnStart && !cacheIsStale)
1065
1051
  continue;
1066
- void runRefresh(state).catch(() => undefined);
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)
@@ -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>): Promise<LiteLLMMcpTool[]>;
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>): Promise<ToolDefinition[]>;
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
- return rawTools.map(normalizeMcpTool).filter((tool) => tool !== undefined);
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.2.9",
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
- "@biomejs/biome": "^2.4.15",
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",