pi-provider-litellm 1.2.9 → 2.0.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/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
- import { execSync, spawn } from "node:child_process";
1
+ import { execFileSync } 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";
5
- import { fingerprint, readCache, writeCache } from "./cache.js";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
5
  import { setupLiteLLMCostTracking } from "./cost.js";
7
- import { discoverModels, isGpt55Model, normalizeBaseUrl, shouldSuppressReasoningContent, withTimeout, } from "./discover.js";
6
+ import { discoverModels, isGpt55Model, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
8
7
  import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
9
8
  import { getSessionIdFromFile } from "./litellm.js";
10
9
  import { createMcpToolDefinitions } from "./mcp-tools.js";
10
+ import { createLiteLLMProvider } from "./provider.js";
11
11
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
12
12
  const PROVIDER_NAME = "litellm";
13
13
  const SETTINGS_KEY = "litellm";
@@ -17,159 +17,23 @@ const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
17
17
  const ENV_HEADERS = "LITELLM_HEADERS";
18
18
  const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
19
19
  const ENV_OFFLINE = "LITELLM_OFFLINE";
20
+ const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
21
+ const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
20
22
  const DEFAULT_TIMEOUT_MS = 5000;
21
23
  const LOGIN_TIMEOUT_MS = 10_000;
22
- const CACHE_FILENAME = "litellm-models.json";
23
- const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
24
+ const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
24
25
  const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
25
26
  const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
26
27
  const EXPIRE_TOKEN_IMMEDIATELY = 0;
27
- function getAuthPath() {
28
- return join(getAgentDir(), "auth.json");
29
- }
30
- function sanitizeCacheSegment(value) {
31
- return (value
32
- .toLowerCase()
33
- .replace(/[^a-z0-9._-]+/g, "-")
34
- .replace(/^-+|-+$/g, "") || "provider");
35
- }
36
- function getCachePath(providerName = PROVIDER_NAME) {
37
- if (providerName === PROVIDER_NAME)
38
- return join(getAgentDir(), CACHE_FILENAME);
39
- return join(getAgentDir(), `litellm-models-${sanitizeCacheSegment(providerName)}.json`);
40
- }
41
- // Same tolerance as pi core's models.json loader (stripJsonComments in dist/utils/json.js):
42
- // strip `//` line comments and trailing commas, leaving string literals untouched.
43
- function stripJsonComments(input) {
44
- return input
45
- .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
46
- .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? m);
28
+ function getModelsDevDiscoveryOptions() {
29
+ return {
30
+ modelsDev: process.env[ENV_MODELS_DEV] !== "0",
31
+ modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
32
+ };
47
33
  }
48
34
  function isPlainObject(value) {
49
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
50
36
  }
51
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
52
- // Mirrors pi core's ModelOverrideSchema: core rejects the whole models.json on invalid values,
53
- // so anything dropped here would also have been flagged for built-in providers. Headers matter
54
- // most: pi resolves each header value as a config string, so non-strings break requests.
55
- const MODEL_OVERRIDE_VALIDATORS = {
56
- name: (value) => typeof value === "string",
57
- reasoning: (value) => typeof value === "boolean",
58
- thinkingLevelMap: (value) => isPlainObject(value) && THINKING_LEVELS.every((level) => value[level] == null || typeof value[level] === "string"),
59
- input: (value) => Array.isArray(value) && value.every((entry) => entry === "text" || entry === "image"),
60
- contextWindow: (value) => typeof value === "number",
61
- maxTokens: (value) => typeof value === "number",
62
- headers: (value) => isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "string"),
63
- compat: isPlainObject,
64
- cost: (value) => isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "number"),
65
- };
66
- function sanitizeModelOverride(modelId, raw) {
67
- if (!isPlainObject(raw)) {
68
- process.stderr.write(`LiteLLM: ignoring model override for ${modelId} in models.json (not an object).\n`);
69
- return undefined;
70
- }
71
- const override = {};
72
- const dropped = [];
73
- for (const [key, value] of Object.entries(raw)) {
74
- const isValid = MODEL_OVERRIDE_VALIDATORS[key];
75
- if (isValid?.(value))
76
- override[key] = value;
77
- else
78
- dropped.push(key);
79
- }
80
- if (dropped.length > 0) {
81
- process.stderr.write(`LiteLLM: ignoring invalid model override field(s) for ${modelId} in models.json: ${dropped.join(", ")}.\n`);
82
- }
83
- return override;
84
- }
85
- // Overrides are read per registered provider name, matching pi core's own
86
- // models.json layout (`providers.<name>.modelOverrides`) so aliases get the
87
- // same override support as the default provider, not a special case of it.
88
- async function readModelOverrides(providerName) {
89
- let raw;
90
- try {
91
- raw = await readFile(join(getAgentDir(), "models.json"), "utf8");
92
- }
93
- catch {
94
- return new Map();
95
- }
96
- try {
97
- const config = JSON.parse(stripJsonComments(raw));
98
- const overrides = new Map();
99
- for (const [id, rawOverride] of Object.entries(config.providers?.[providerName]?.modelOverrides ?? {})) {
100
- const override = sanitizeModelOverride(id, rawOverride);
101
- if (override)
102
- overrides.set(id, override);
103
- }
104
- return overrides;
105
- }
106
- catch (error) {
107
- const message = error instanceof Error ? error.message : String(error);
108
- process.stderr.write(`LiteLLM: ignoring model overrides (failed to parse models.json: ${message}).\n`);
109
- return new Map();
110
- }
111
- }
112
- // Merge semantics must match pi core's applyModelOverride/mergeCompat (dist/core/model-registry.js)
113
- // so the same models.json entry behaves identically for litellm and built-in providers.
114
- function mergeCompat(base, override) {
115
- if (!override)
116
- return base;
117
- const merged = { ...base, ...override };
118
- for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"]) {
119
- const baseValue = base?.[key];
120
- const overrideValue = override[key];
121
- if (baseValue || overrideValue) {
122
- merged[key] = { ...baseValue, ...overrideValue };
123
- }
124
- }
125
- return merged;
126
- }
127
- function applyModelOverride(model, override) {
128
- const next = { ...model };
129
- if (override.name !== undefined)
130
- next.name = override.name;
131
- if (override.reasoning !== undefined)
132
- next.reasoning = override.reasoning;
133
- if (override.thinkingLevelMap !== undefined) {
134
- next.thinkingLevelMap = { ...model.thinkingLevelMap, ...override.thinkingLevelMap };
135
- }
136
- if (override.input !== undefined)
137
- next.input = override.input;
138
- if (override.contextWindow !== undefined)
139
- next.contextWindow = override.contextWindow;
140
- if (override.maxTokens !== undefined)
141
- next.maxTokens = override.maxTokens;
142
- if (override.headers !== undefined)
143
- next.headers = override.headers;
144
- if (override.compat !== undefined)
145
- next.compat = mergeCompat(model.compat, override.compat);
146
- if (override.cost !== undefined)
147
- next.cost = { ...model.cost, ...override.cost };
148
- return next;
149
- }
150
- function applyModelOverrides(models, overrides) {
151
- if (overrides.size === 0)
152
- return models;
153
- return models.map((model) => {
154
- const override = overrides.get(model.id);
155
- return override ? applyModelOverride(model, override) : model;
156
- });
157
- }
158
- // Re-reads models.json on every call so overrides edited mid-session take effect on the next
159
- // refresh or login, matching pi core's live reload for built-in providers.
160
- async function applyOverrides(providerName, models) {
161
- return applyModelOverrides(models, await readModelOverrides(providerName));
162
- }
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
- }
172
- }
173
37
  async function readGlobalLiteLLMSettings() {
174
38
  try {
175
39
  const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
@@ -199,9 +63,50 @@ function normalizeCommand(raw) {
199
63
  function getApiKeyHelperCommand() {
200
64
  return normalizeCommand(process.env[ENV_API_KEY_HELPER]);
201
65
  }
202
- function executeApiKeyCommand(commandConfig) {
66
+ function parseApiKeyCommand(commandConfig) {
203
67
  const command = commandConfig.startsWith("!") ? commandConfig.slice(1) : commandConfig;
204
- const output = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
68
+ if (/[\n\r;&|<>`$(){}*?~%^!]|\[|\]/.test(command))
69
+ throw new Error("LiteLLM API key helper shell syntax is not supported");
70
+ const parts = [];
71
+ let current = "";
72
+ let quote;
73
+ for (let index = 0; index < command.length; index += 1) {
74
+ const character = command[index];
75
+ const next = command[index + 1];
76
+ if (character === "\\" && quote !== "'" && next && (next === "\\" || next === quote || /\s/.test(next))) {
77
+ current += next;
78
+ index += 1;
79
+ }
80
+ else if ((character === "'" || character === '"') && (!quote || quote === character)) {
81
+ quote = quote ? undefined : character;
82
+ }
83
+ else if (!quote && /\s/.test(character)) {
84
+ if (current) {
85
+ parts.push(current);
86
+ current = "";
87
+ }
88
+ }
89
+ else {
90
+ current += character;
91
+ }
92
+ }
93
+ if (quote)
94
+ throw new Error(`LiteLLM API key helper command has an unterminated quote: ${command}`);
95
+ if (current)
96
+ parts.push(current);
97
+ if (parts.length === 0)
98
+ throw new Error("LiteLLM API key helper command is empty");
99
+ if (/\.(?:cmd|bat)$/i.test(parts[0]))
100
+ throw new Error("LiteLLM API key helper shell scripts are not supported; use an executable");
101
+ return parts;
102
+ }
103
+ function executeApiKeyCommand(commandConfig) {
104
+ const [command, ...args] = parseApiKeyCommand(commandConfig);
105
+ const output = execFileSync(command, args, {
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ timeout: 10_000,
109
+ }).trim();
205
110
  if (!output)
206
111
  throw new Error(`LiteLLM API key helper produced no output: ${command}`);
207
112
  return output;
@@ -220,53 +125,29 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
220
125
  return opaqueFallback;
221
126
  }
222
127
  }
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
128
  async function generateVirtualKey(baseUrl, userToken, signal, headers) {
237
- const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
238
- try {
239
- const response = await fetch(`${baseUrl}/key/generate`, {
240
- method: "POST",
241
- headers: {
242
- ...headers,
243
- Authorization: `Bearer ${userToken}`,
244
- "Content-Type": "application/json",
245
- },
246
- body: JSON.stringify({}),
247
- signal: boundedSignal,
248
- });
249
- if (!response.ok) {
250
- const text = await response.text().catch(() => "");
251
- throw new Error(`Virtual key generation failed (${response.status}): ${text}`);
252
- }
253
- const data = (await response.json());
254
- if (typeof data.key !== "string" || !data.key)
255
- throw new Error("No key in response from /key/generate");
256
- const expiresMs = typeof data.expires === "string" ? Date.parse(data.expires) : Number.NaN;
257
- return { key: data.key, expiresAt: Number.isNaN(expiresMs) ? undefined : expiresMs };
258
- }
259
- finally {
260
- cancel();
129
+ const boundedSignal = signal
130
+ ? AbortSignal.any([signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)])
131
+ : AbortSignal.timeout(LOGIN_TIMEOUT_MS);
132
+ const response = await fetch(`${baseUrl}/key/generate`, {
133
+ method: "POST",
134
+ headers: {
135
+ ...headers,
136
+ Authorization: `Bearer ${userToken}`,
137
+ "Content-Type": "application/json",
138
+ },
139
+ body: JSON.stringify({}),
140
+ signal: boundedSignal,
141
+ });
142
+ if (!response.ok) {
143
+ const text = await response.text().catch(() => "");
144
+ throw new Error(`Virtual key generation failed (${response.status}): ${text}`);
261
145
  }
262
- }
263
- function getLiteLLMApiKey(credentials) {
264
- return credentials.access;
265
- }
266
- function resolveOAuthApiKey(credentials) {
267
- return credentials.refresh.startsWith("!")
268
- ? executeApiKeyCommand(credentials.refresh)
269
- : getLiteLLMApiKey(credentials);
146
+ const data = (await response.json());
147
+ if (typeof data.key !== "string" || !data.key)
148
+ throw new Error("No key in response from /key/generate");
149
+ const expiresMs = typeof data.expires === "string" ? Date.parse(data.expires) : Number.NaN;
150
+ return { key: data.key, expiresAt: Number.isNaN(expiresMs) ? undefined : expiresMs };
270
151
  }
271
152
  function resolveTemplateConfigValue(config) {
272
153
  let resolved = "";
@@ -310,6 +191,47 @@ function resolveTemplateConfigValue(config) {
310
191
  }
311
192
  return resolved;
312
193
  }
194
+ async function resolveTemplateConfigValueFromContext(config, env) {
195
+ let resolved = "";
196
+ for (let index = 0; index < config.length;) {
197
+ const dollarIndex = config.indexOf("$", index);
198
+ if (dollarIndex === -1)
199
+ return resolved + config.slice(index);
200
+ resolved += config.slice(index, dollarIndex);
201
+ const nextChar = config[dollarIndex + 1];
202
+ if (nextChar === "$" || nextChar === "!") {
203
+ resolved += nextChar;
204
+ index = dollarIndex + 2;
205
+ continue;
206
+ }
207
+ if (nextChar === "{") {
208
+ const endIndex = config.indexOf("}", dollarIndex + 2);
209
+ if (endIndex === -1) {
210
+ resolved += "$";
211
+ index = dollarIndex + 1;
212
+ continue;
213
+ }
214
+ const envValue = await env(config.slice(dollarIndex + 2, endIndex));
215
+ if (envValue === undefined)
216
+ return undefined;
217
+ resolved += envValue;
218
+ index = endIndex + 1;
219
+ continue;
220
+ }
221
+ const match = config.slice(dollarIndex + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
222
+ if (!match) {
223
+ resolved += "$";
224
+ index = dollarIndex + 1;
225
+ continue;
226
+ }
227
+ const envValue = await env(match[0]);
228
+ if (envValue === undefined)
229
+ return undefined;
230
+ resolved += envValue;
231
+ index = dollarIndex + 1 + match[0].length;
232
+ }
233
+ return resolved;
234
+ }
313
235
  function resolveConfigValue(config, { executeCommands }) {
314
236
  if (config.startsWith("!"))
315
237
  return executeCommands ? executeApiKeyCommand(config) : undefined;
@@ -361,97 +283,56 @@ function resolveHeaders(definition) {
361
283
  return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
362
284
  return parseHeaderRecord(definition.headers);
363
285
  }
364
- // Headers can select a different tenant/customer at the same base URL with the
365
- // same key, so the cache must invalidate on header changes too, not just on
366
- // baseUrl/apiKey changes, or a stale tenant's models get reused silently.
367
- function computeHeadersFingerprint(headers) {
368
- if (!headers)
369
- return undefined;
370
- const sorted = Object.keys(headers)
371
- .sort()
372
- .map((key) => [key, headers[key]]);
373
- return fingerprint(JSON.stringify(sorted));
374
- }
375
- // Pi core resolves registered header values with the same $VAR/!command syntax
376
- // at request time, so already-resolved literals must be escaped or they get
377
- // resolved a second time ($UNSET would then fail every request).
378
- function escapeConfigValue(value) {
379
- const escaped = value.replace(/\$/g, "$$$$");
380
- return escaped.startsWith("!") ? `$${escaped}` : escaped;
381
- }
382
- function escapeHeaderConfig(headers) {
383
- if (!headers)
384
- return undefined;
385
- return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, escapeConfigValue(value)]));
286
+ async function resolveHeadersFromContext(definition, env) {
287
+ if (typeof definition.headers === "string")
288
+ return parseCustomHeaders(await resolveTemplateConfigValueFromContext(definition.headers, env));
289
+ return parseHeaderRecord(definition.headers);
386
290
  }
387
291
  async function resolveCredentials(definition, { executeHelpers = true } = {}) {
388
- const entry = definition.useSavedAuth ? await readAuthEntry(definition.name) : undefined;
389
292
  const configuredBase = cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
390
293
  const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
391
294
  const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
392
295
  const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
393
- const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
394
- const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
395
- const authKey = entry?.type === "oauth"
396
- ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
397
- : entry?.type === "api_key"
398
- ? (await AuthStorage.create(getAuthPath()).getApiKey(definition.name, { includeFallback: false }))?.trim()
399
- : undefined;
296
+ const gcloudCacheKey = useGcloudToken ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
400
297
  const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
401
298
  // Resolved lazily so a `!command` key is not executed when a
402
299
  // higher-precedence credential (saved auth, gcloud token) already won.
403
300
  let configuredKey;
404
- if (!authKey && !gcloudKey && definition.apiKeyConfig) {
301
+ if (!gcloudKey && definition.apiKeyConfig) {
405
302
  configuredKey = resolveConfigValue(definition.apiKeyConfig, { executeCommands: executeHelpers });
406
303
  if (configuredKey === undefined && !definition.apiKeyConfig.startsWith("!")) {
407
304
  warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
408
305
  }
409
306
  }
410
- const helperKey = !authKey && !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
307
+ const helperKey = !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
411
308
  ? executeApiKeyCommand(envHelperCommand)
412
309
  : undefined;
413
- const apiKey = authKey || gcloudKey || configuredKey || helperKey || envKey;
414
- let apiKeyFingerprint;
310
+ const apiKey = gcloudKey || configuredKey || helperKey || envKey;
415
311
  let apiKeyConfig;
416
- if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
417
- apiKeyFingerprint = fingerprint(entry.refresh);
418
- }
419
- else if (authKey) {
420
- apiKeyFingerprint = fingerprint(authKey);
421
- }
422
- else if (gcloudKey) {
423
- apiKeyFingerprint = fingerprint(gcloudCacheKey ?? gcloudKey);
312
+ if (gcloudKey) {
424
313
  apiKeyConfig = getGcloudTokenCommand();
425
314
  }
426
315
  else if (!executeHelpers && gcloudCacheKey) {
427
- apiKeyFingerprint = fingerprint(gcloudCacheKey);
428
316
  apiKeyConfig = getGcloudTokenCommand();
429
317
  }
430
318
  else if (configuredKey && definition.apiKeyConfig) {
431
- apiKeyFingerprint = fingerprint(definition.apiKeyConfig.startsWith("!") ? definition.apiKeyConfig : configuredKey);
432
319
  apiKeyConfig = definition.apiKeyConfig;
433
320
  }
434
321
  else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
435
- apiKeyFingerprint = fingerprint(definition.apiKeyConfig);
436
322
  apiKeyConfig = definition.apiKeyConfig;
437
323
  }
438
324
  else if (helperKey && envHelperCommand) {
439
- apiKeyFingerprint = fingerprint(envHelperCommand);
440
325
  apiKeyConfig = envHelperCommand;
441
326
  }
442
327
  else if (!executeHelpers && envHelperCommand) {
443
- apiKeyFingerprint = fingerprint(envHelperCommand);
444
328
  apiKeyConfig = envHelperCommand;
445
329
  }
446
330
  else if (envKey) {
447
- apiKeyFingerprint = fingerprint(envKey);
448
331
  apiKeyConfig = `$${ENV_API_KEY}`;
449
332
  }
450
- const rawBase = authBase || configuredBase;
451
333
  return {
452
- baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
334
+ baseUrl: configuredBase ? normalizeBaseUrl(configuredBase) : undefined,
453
335
  apiKey: apiKey || undefined,
454
- apiKeyFingerprint,
455
336
  apiKeyConfig,
456
337
  };
457
338
  }
@@ -467,8 +348,8 @@ function getDiscoveryTimeoutMs() {
467
348
  function isOffline() {
468
349
  return process.env[ENV_OFFLINE] === "1";
469
350
  }
470
- function isListModelsMode() {
471
- return process.argv.includes("--list-models");
351
+ function isVerboseDiscovery() {
352
+ return process.env[ENV_VERBOSE_DISCOVERY] === "1";
472
353
  }
473
354
  function normalizeProviderSettings(raw) {
474
355
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -478,8 +359,11 @@ function normalizeProviderSettings(raw) {
478
359
  return undefined;
479
360
  return record;
480
361
  }
481
- async function getProviderDefinitions() {
482
- const settings = await readGlobalLiteLLMSettings();
362
+ function isFeatureEnabled(settings, feature) {
363
+ const raw = settings?.[feature];
364
+ return !isPlainObject(raw) || raw.enabled !== false;
365
+ }
366
+ function getProviderDefinitions(settings) {
483
367
  const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
484
368
  const providerSettings = rawProviders;
485
369
  const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
@@ -491,128 +375,81 @@ async function getProviderDefinitions() {
491
375
  headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
492
376
  useDefaultEnv: isDefault,
493
377
  useGcloudTokenAuth: isDefault,
494
- useSavedAuth: true,
495
378
  enableOAuth: isDefault,
496
379
  });
497
380
  const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
498
- const usedCacheSegments = new Map();
499
381
  for (const [name, raw] of Object.entries(providerSettings ?? {})) {
500
382
  if (name === PROVIDER_NAME)
501
383
  continue;
502
384
  const normalized = normalizeProviderSettings(raw);
503
385
  if (!normalized)
504
386
  continue;
505
- // Distinct alias names can sanitize to the same cache file; registering
506
- // both would let their model caches silently clobber each other.
507
- const segment = sanitizeCacheSegment(name);
508
- const existing = usedCacheSegments.get(segment);
509
- if (existing) {
510
- process.stderr.write(`LiteLLM: provider alias "${name}" would share a cache file with "${existing}"; skipping it. Rename the alias.\n`);
511
- continue;
512
- }
513
- usedCacheSegments.set(segment, name);
514
387
  definitions.push(makeDefinition(name, normalized, false));
515
388
  }
516
389
  return definitions;
517
390
  }
518
- async function discoverWithFallback(baseUrl, apiKey, options) {
519
- try {
520
- return { result: await discoverModels(baseUrl, apiKey, options) };
521
- }
522
- catch (error) {
523
- const message = error instanceof Error ? error.message : String(error);
524
- return {
525
- result: { models: [], source: "models_list" },
526
- warning: message,
527
- };
528
- }
529
- }
530
- async function loginLiteLLM(callbacks, options) {
531
- const rawBaseUrl = (await callbacks.onPrompt({
391
+ async function loginApiKey(interaction) {
392
+ const rawBaseUrl = (await interaction.prompt({
393
+ type: "text",
532
394
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
533
395
  placeholder: "https://litellm.example.com",
534
396
  })).trim();
535
397
  if (!rawBaseUrl)
536
398
  throw new Error("Base URL is required");
537
- const baseUrl = normalizeBaseUrl(rawBaseUrl);
538
- const method = (await callbacks.onPrompt({
539
- message: "Select login method (1 = API key / !command, 2 = SSO / Enterprise JWT):",
399
+ const key = (await interaction.prompt({ type: "secret", message: "Enter API key:" })).trim();
400
+ if (!key)
401
+ throw new Error("Both base URL and API key are required");
402
+ return { type: "api_key", key, env: { [ENV_BASE_URL]: normalizeBaseUrl(rawBaseUrl) } };
403
+ }
404
+ async function loginOAuth(interaction, headers) {
405
+ const rawBaseUrl = (await interaction.prompt({
406
+ type: "text",
407
+ message: "Enter LiteLLM proxy URL (no trailing /v1):",
408
+ placeholder: "https://litellm.example.com",
540
409
  })).trim();
541
- let apiKey;
542
- let refresh;
543
- let expires;
544
- if (method === "2") {
545
- callbacks.onAuth?.({
546
- url: `${baseUrl}/sso/key/generate`,
547
- instructions: "Authenticate via SSO, then copy your token from the LiteLLM UI.",
548
- });
549
- const rawToken = (await callbacks.onPrompt({ message: "Paste your SSO token from the LiteLLM UI:" }))
550
- .trim()
551
- .replace(/^Bearer\s+/i, "")
552
- .trim();
553
- if (!rawToken)
554
- throw new Error("SSO token is required");
555
- const wantVirtualKey = (await callbacks.onPrompt({ message: "Generate a LiteLLM virtual key from this token? (y/n):" }))
556
- .trim()
557
- .toLowerCase();
558
- if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
559
- try {
560
- callbacks.onProgress?.("Generating virtual key...");
561
- const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal, options.headers);
562
- apiKey = generated.key;
563
- refresh = "";
564
- expires =
565
- generated.expiresAt === undefined
566
- ? PERMANENT_TOKEN_EXPIRES_AT
567
- : Math.max(Date.now(), generated.expiresAt - TOKEN_REFRESH_LEAD_MS);
568
- callbacks.onProgress?.("Virtual key generated and will be used for API calls.");
569
- }
570
- catch (error) {
571
- const message = error instanceof Error ? error.message : String(error);
572
- callbacks.onProgress?.(`LiteLLM: virtual key generation failed (${message}); using SSO token directly.`);
573
- apiKey = rawToken;
574
- refresh = "";
575
- expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
576
- }
410
+ if (!rawBaseUrl)
411
+ throw new Error("Base URL is required");
412
+ const baseUrl = normalizeBaseUrl(rawBaseUrl);
413
+ interaction.notify({
414
+ type: "auth_url",
415
+ url: `${baseUrl}/sso/key/generate`,
416
+ instructions: "Authenticate via SSO, then copy your token from the LiteLLM UI.",
417
+ });
418
+ const rawToken = (await interaction.prompt({ type: "secret", message: "Paste your SSO token from the LiteLLM UI:" }))
419
+ .trim()
420
+ .replace(/^Bearer\s+/i, "")
421
+ .trim();
422
+ if (!rawToken)
423
+ throw new Error("SSO token is required");
424
+ const wantVirtualKey = (await interaction.prompt({ type: "text", message: "Generate a LiteLLM virtual key from this token? (y/n):" }))
425
+ .trim()
426
+ .toLowerCase();
427
+ let access = rawToken;
428
+ let expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
429
+ if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
430
+ try {
431
+ interaction.notify({ type: "progress", message: "Generating virtual key..." });
432
+ const generated = await generateVirtualKey(baseUrl, rawToken, interaction.signal, headers);
433
+ access = generated.key;
434
+ expires =
435
+ generated.expiresAt === undefined
436
+ ? PERMANENT_TOKEN_EXPIRES_AT
437
+ : Math.max(Date.now(), generated.expiresAt - TOKEN_REFRESH_LEAD_MS);
438
+ interaction.notify({ type: "progress", message: "Virtual key generated and will be used for API calls." });
577
439
  }
578
- else {
579
- apiKey = rawToken;
580
- refresh = "";
581
- expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
440
+ catch (error) {
441
+ if (interaction.signal?.aborted)
442
+ throw interaction.signal.reason;
443
+ const message = error instanceof Error ? error.message : String(error);
444
+ interaction.notify({
445
+ type: "progress",
446
+ message: `LiteLLM: virtual key generation failed (${message}); using SSO token directly.`,
447
+ });
582
448
  }
583
449
  }
584
- else {
585
- const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
586
- if (!apiKeyInput)
587
- throw new Error("Both base URL and API key are required");
588
- refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
589
- apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
590
- expires = tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT);
591
- }
592
- const { models, source } = await discoverModels(baseUrl, apiKey, {
593
- timeoutMs: LOGIN_TIMEOUT_MS,
594
- signal: callbacks.signal,
595
- headers: options.headers,
596
- });
597
- const cache = {
598
- baseUrl,
599
- apiKeyFingerprint: fingerprint(refresh || apiKey),
600
- headersFingerprint: computeHeadersFingerprint(options.headers),
601
- fetchedAt: Date.now(),
602
- source,
603
- models,
604
- };
605
- await writeCache(options.cachePath, cache);
606
- await options.onCacheWrite?.(cache);
607
- callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
608
- return {
609
- access: apiKey,
610
- refresh,
611
- expires,
612
- baseUrl,
613
- };
450
+ return { type: "oauth", access, refresh: "", expires, baseUrl };
614
451
  }
615
- async function refreshLiteLLM(credentials) {
452
+ async function refreshLiteLLM(credentials, _signal) {
616
453
  if (!credentials.refresh.startsWith("!")) {
617
454
  if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
618
455
  throw new Error("LiteLLM credential cannot be refreshed; run /login litellm again");
@@ -622,11 +459,122 @@ async function refreshLiteLLM(credentials) {
622
459
  const access = executeApiKeyCommand(credentials.refresh);
623
460
  return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
624
461
  }
625
- function modifyLiteLLMModels(models, cred) {
626
- const baseUrl = cred.baseUrl;
627
- if (!baseUrl)
628
- return models;
629
- return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
462
+ async function resolveApiKeyAuth(definition, ctx, credential, executeHelpers = true) {
463
+ const baseUrl = cleanConfig(credential?.env?.[ENV_BASE_URL]) ??
464
+ cleanConfig(definition.baseUrl) ??
465
+ (definition.useDefaultEnv ? cleanConfig(await ctx.env(ENV_BASE_URL)) : undefined);
466
+ const stored = credential?.key
467
+ ? resolveConfigValue(credential.key, { executeCommands: executeHelpers })?.trim()
468
+ : undefined;
469
+ let source;
470
+ let creds;
471
+ if (stored) {
472
+ source = "stored credential";
473
+ creds = {
474
+ baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined,
475
+ apiKey: stored,
476
+ };
477
+ }
478
+ else {
479
+ creds = await resolveCredentials({ ...definition, apiKeyConfig: undefined, useDefaultEnv: false }, { executeHelpers });
480
+ if (!creds.apiKey && definition.apiKeyConfig) {
481
+ const configured = definition.apiKeyConfig.startsWith("!")
482
+ ? executeHelpers
483
+ ? executeApiKeyCommand(definition.apiKeyConfig)
484
+ : undefined
485
+ : await resolveTemplateConfigValueFromContext(definition.apiKeyConfig, ctx.env);
486
+ if (configured) {
487
+ creds.apiKey = configured;
488
+ creds.apiKeyConfig = definition.apiKeyConfig;
489
+ }
490
+ else if (!definition.apiKeyConfig.startsWith("!")) {
491
+ warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
492
+ }
493
+ }
494
+ if (!creds.apiKey && definition.useDefaultEnv) {
495
+ const helper = normalizeCommand(await ctx.env(ENV_API_KEY_HELPER));
496
+ if (helper) {
497
+ creds.apiKey = executeHelpers ? executeApiKeyCommand(helper) : undefined;
498
+ creds.apiKeyConfig = helper;
499
+ source = ENV_API_KEY_HELPER;
500
+ }
501
+ else {
502
+ const envKey = cleanConfig(await ctx.env(ENV_API_KEY));
503
+ if (envKey) {
504
+ creds.apiKey = envKey;
505
+ creds.apiKeyConfig = ENV_API_KEY;
506
+ source = ENV_API_KEY;
507
+ }
508
+ }
509
+ }
510
+ if (!creds.baseUrl && baseUrl)
511
+ creds.baseUrl = normalizeBaseUrl(baseUrl);
512
+ }
513
+ if (!creds.apiKey)
514
+ return undefined;
515
+ return {
516
+ auth: {
517
+ apiKey: creds.apiKey,
518
+ baseUrl: creds.baseUrl ? `${creds.baseUrl}/v1` : undefined,
519
+ headers: await resolveHeadersFromContext(definition, ctx.env),
520
+ },
521
+ env: baseUrl ? { [ENV_BASE_URL]: normalizeBaseUrl(baseUrl) } : undefined,
522
+ source: source ?? creds.apiKeyConfig ?? ENV_API_KEY,
523
+ };
524
+ }
525
+ function createProviderAuth(definition) {
526
+ return {
527
+ apiKey: {
528
+ name: `${definition.displayName} API key`,
529
+ login: definition.name === PROVIDER_NAME ? loginApiKey : undefined,
530
+ check: async ({ ctx, credential }) => {
531
+ const baseUrl = credential?.env?.[ENV_BASE_URL] ??
532
+ definition.baseUrl ??
533
+ (definition.useDefaultEnv ? await ctx.env(ENV_BASE_URL) : undefined);
534
+ if (!cleanConfig(baseUrl))
535
+ return undefined;
536
+ if (credential?.key)
537
+ return { type: "api_key", source: "stored credential" };
538
+ const configured = await resolveCredentials({ ...definition, apiKeyConfig: undefined, useDefaultEnv: false }, { executeHelpers: false });
539
+ if (configured.apiKey || configured.apiKeyConfig)
540
+ return {
541
+ type: "api_key",
542
+ source: definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled()
543
+ ? "gcloud ADC"
544
+ : (configured.apiKeyConfig ?? ENV_API_KEY),
545
+ };
546
+ if (definition.apiKeyConfig) {
547
+ const configuredKey = definition.apiKeyConfig.startsWith("!")
548
+ ? definition.apiKeyConfig
549
+ : await resolveTemplateConfigValueFromContext(definition.apiKeyConfig, ctx.env);
550
+ if (configuredKey)
551
+ return { type: "api_key", source: definition.apiKeyConfig };
552
+ }
553
+ if (definition.useDefaultEnv && cleanConfig(await ctx.env(ENV_API_KEY_HELPER)))
554
+ return { type: "api_key", source: ENV_API_KEY_HELPER };
555
+ return definition.useDefaultEnv && cleanConfig(await ctx.env(ENV_API_KEY))
556
+ ? { type: "api_key", source: ENV_API_KEY }
557
+ : undefined;
558
+ },
559
+ resolve: ({ ctx, credential }) => resolveApiKeyAuth(definition, ctx, credential),
560
+ },
561
+ oauth: definition.enableOAuth
562
+ ? {
563
+ name: "LiteLLM SSO",
564
+ loginLabel: "Sign in with LiteLLM SSO",
565
+ login: (interaction) => loginOAuth(interaction, resolveHeaders(definition)),
566
+ refresh: async (credential, signal) => ({
567
+ ...(await refreshLiteLLM(credential, signal)),
568
+ type: "oauth",
569
+ }),
570
+ toAuth: async (credential) => ({
571
+ apiKey: credential.access,
572
+ baseUrl: credential.baseUrl ? `${normalizeBaseUrl(String(credential.baseUrl))}/v1` : undefined,
573
+ headers: resolveHeaders(definition),
574
+ }),
575
+ }
576
+ : undefined,
577
+ };
630
578
  }
631
579
  function isReasoningItem(item) {
632
580
  return typeof item === "object" && item !== null && item.type === "reasoning";
@@ -750,7 +698,10 @@ function normalizeThinkTags(message, litellmProviderNames) {
750
698
  return { ...message, content };
751
699
  }
752
700
  export default async function (pi) {
753
- const definitions = await getProviderDefinitions();
701
+ const settings = await readGlobalLiteLLMSettings();
702
+ const definitions = getProviderDefinitions(settings);
703
+ const skillsEnabled = isFeatureEnabled(settings, "skills");
704
+ const mcpEnabled = isFeatureEnabled(settings, "mcp");
754
705
  const providerNames = new Set(definitions.map((definition) => definition.name));
755
706
  function discoveryDisabledReason() {
756
707
  if (isOffline())
@@ -759,312 +710,173 @@ export default async function (pi) {
759
710
  return `${ENV_TIMEOUT}=0`;
760
711
  return null;
761
712
  }
762
- async function loadProviderState(definition) {
763
- let creds = await resolveCredentials(definition, { executeHelpers: false });
764
- const headers = resolveHeaders(definition);
765
- const headersFp = computeHeadersFingerprint(headers);
766
- const cache = await readCache(getCachePath(definition.name));
767
- let fp = creds.apiKeyFingerprint;
768
- let cacheFetchedAt = cache?.fetchedAt ?? 0;
769
- const cacheValid = cache !== null &&
770
- creds.baseUrl !== undefined &&
771
- fp !== undefined &&
772
- cache.baseUrl === creds.baseUrl &&
773
- cache.apiKeyFingerprint === fp &&
774
- 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());
781
- let credentialWarning;
782
- let liveDiscoveryApiKey;
783
- if (shouldFetch) {
784
- try {
785
- creds = await resolveCredentials(definition);
786
- fp = creds.apiKeyFingerprint;
787
- }
788
- catch (error) {
789
- // A broken alias must not abort activation for the other providers;
790
- // only the default provider keeps the historical fail-fast behavior.
791
- if ((!cacheValid || !cache) && definition.useDefaultEnv)
792
- throw error;
793
- credentialWarning = error instanceof Error ? error.message : String(error);
794
- if (cacheValid && cache) {
795
- process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${credentialWarning}); using cached models.\n`);
796
- models = cache.models;
797
- }
798
- else {
799
- process.stderr.write(`LiteLLM (${definition.name}): credential resolution failed (${credentialWarning}); registering provider with no models.\n`);
800
- models = [];
801
- }
802
- }
713
+ function requestBaseUrl(definition) {
714
+ const baseUrl = cleanConfig(definition.baseUrl) ??
715
+ (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined) ??
716
+ "https://litellm.example.com";
717
+ return `${normalizeBaseUrl(baseUrl)}/v1`;
718
+ }
719
+ async function authForCredential(definition, credential) {
720
+ if (credential.type === "oauth") {
721
+ const baseUrl = typeof credential.baseUrl === "string"
722
+ ? normalizeBaseUrl(credential.baseUrl)
723
+ : normalizeBaseUrl(requestBaseUrl(definition));
724
+ return { baseUrl, apiKey: credential.access, headers: resolveHeaders(definition) };
803
725
  }
804
- if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
805
- const timeoutMs = getDiscoveryTimeoutMs();
806
- const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
807
- timeoutMs,
808
- headers,
809
- });
810
- if (warning) {
811
- if (cacheValid && cache) {
812
- process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${warning}); using cached models.\n`);
813
- models = cache.models;
814
- }
815
- else {
816
- process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${warning}); registering provider with no models.\n`);
817
- models = [];
818
- }
819
- }
820
- else {
821
- models = result.models;
822
- liveDiscoveryApiKey = creds.apiKey;
823
- const next = {
824
- baseUrl: creds.baseUrl,
825
- apiKeyFingerprint: fp,
826
- headersFingerprint: headersFp,
827
- fetchedAt: Date.now(),
828
- source: result.source,
829
- models: result.models,
830
- };
831
- await writeCache(getCachePath(definition.name), next);
832
- cacheFetchedAt = next.fetchedAt;
833
- if (isListModelsMode()) {
834
- process.stderr.write(`LiteLLM (${definition.name}): ${result.models.length} models discovered (source: ${result.source}).\n`);
835
- }
836
- }
726
+ const resolved = await resolveApiKeyAuth(definition, { env: async (name) => process.env[name] }, credential);
727
+ if (!resolved?.auth.apiKey) {
728
+ throw new Error(`no credentials for ${definition.name}. Run /login litellm or set env vars.`);
837
729
  }
838
- // The cache keeps raw discovery output; overrides are applied freshly at each registration.
839
- models = await applyOverrides(definition.name, models);
840
- return { definition, creds, headers, models, cacheFetchedAt, liveDiscoveryApiKey, refreshInProgress: null };
841
- }
842
- const providerStates = await Promise.all(definitions.map(loadProviderState));
843
- const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
844
- let updateCosts = () => undefined;
845
- const providerModelsForCosts = () => providerStates.map((state) => ({ provider: state.definition.name, models: state.models }));
846
- const updateAllCosts = () => updateCosts(providerModelsForCosts());
847
- function defaultApiKeyConfig(definition) {
848
- if (definition.useDefaultEnv) {
849
- return definition.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`;
850
- }
851
- // An alias must never inherit the default provider's env key; leaving the
852
- // key unset makes requests fail loudly instead of leaking credentials.
853
- return definition.apiKeyConfig;
730
+ return {
731
+ baseUrl: normalizeBaseUrl(resolved.auth.baseUrl ?? requestBaseUrl(definition)),
732
+ apiKey: resolved.auth.apiKey,
733
+ headers: resolved.auth.headers,
734
+ };
854
735
  }
855
- function registerProvider(state, models = state.models, apiKeyConfig = state.creds.apiKeyConfig) {
856
- const definition = state.definition;
857
- pi.registerProvider(definition.name, {
858
- name: definition.displayName,
859
- baseUrl: state.creds.baseUrl ? `${state.creds.baseUrl}/v1` : "https://litellm.example.com/v1",
860
- apiKey: apiKeyConfig ?? defaultApiKeyConfig(definition),
861
- api: "openai-completions",
862
- headers: escapeHeaderConfig(state.headers),
863
- models,
864
- oauth: definition.enableOAuth ? oauth : undefined,
736
+ let registeredMcpIdentity;
737
+ let mcpRegistration;
738
+ let defaultRuntimeAuth;
739
+ function mcpCatalogIdentity(auth) {
740
+ return JSON.stringify({
741
+ baseUrl: normalizeBaseUrl(auth.baseUrl),
742
+ apiKey: auth.apiKey,
743
+ headers: Object.entries(auth.headers ?? {}).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
865
744
  });
866
745
  }
867
- function defaultLoginOptions() {
746
+ async function getRuntimeAuth(ctx) {
747
+ const auth = await ctx.modelRegistry.getProviderAuth(PROVIDER_NAME);
748
+ if (!auth)
749
+ return undefined;
750
+ const provider = ctx.modelRegistry.getProvider(PROVIDER_NAME);
751
+ const baseUrl = auth.auth.baseUrl ?? provider?.baseUrl;
752
+ const apiKey = auth.auth.apiKey;
753
+ if (!baseUrl || !apiKey)
754
+ return undefined;
755
+ const headers = Object.fromEntries(Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter((entry) => typeof entry[1] === "string"));
868
756
  return {
869
- cachePath: getCachePath(PROVIDER_NAME),
870
- headers: defaultState.headers,
871
- onCacheWrite: async (next) => {
872
- defaultState.cacheFetchedAt = next.fetchedAt;
873
- const overridden = await applyOverrides(PROVIDER_NAME, next.models);
874
- defaultState.models = overridden;
875
- defaultState.creds = {
876
- ...defaultState.creds,
877
- baseUrl: next.baseUrl,
878
- apiKeyFingerprint: next.apiKeyFingerprint,
879
- };
880
- registerProvider(defaultState, overridden);
881
- updateAllCosts();
882
- },
757
+ baseUrl: normalizeBaseUrl(baseUrl),
758
+ apiKey,
759
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
883
760
  };
884
761
  }
885
- const oauth = {
886
- name: "LiteLLM",
887
- login: (callbacks) => loginLiteLLM(callbacks, defaultLoginOptions()),
888
- refreshToken: refreshLiteLLM,
889
- getApiKey: getLiteLLMApiKey,
890
- modifyModels: modifyLiteLLMModels,
891
- };
892
- for (const state of providerStates)
893
- registerProvider(state);
894
- updateCosts = setupLiteLLMCostTracking(pi, providerModelsForCosts());
895
- async function resolveRuntimeApiKey(state = defaultState) {
896
- const fresh = await resolveCredentials(state.definition);
897
- if (!fresh.apiKey)
898
- throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
899
- return fresh.apiKey;
762
+ async function resolveDefaultRuntimeAuth(ctx) {
763
+ if (ctx?.modelRegistry) {
764
+ const auth = await getRuntimeAuth(ctx);
765
+ if (auth)
766
+ return auth;
767
+ throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
768
+ }
769
+ if (!defaultRuntimeAuth)
770
+ throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
771
+ return defaultRuntimeAuth;
772
+ }
773
+ function waitForMcpRegistration(registration, signal) {
774
+ if (!signal)
775
+ return registration.catch(() => undefined);
776
+ signal.throwIfAborted();
777
+ return new Promise((resolve, reject) => {
778
+ const onAbort = () => {
779
+ signal.removeEventListener("abort", onAbort);
780
+ reject(signal.reason);
781
+ };
782
+ const onComplete = () => {
783
+ signal.removeEventListener("abort", onAbort);
784
+ resolve();
785
+ };
786
+ signal.addEventListener("abort", onAbort, { once: true });
787
+ if (signal.aborted)
788
+ onAbort();
789
+ else
790
+ registration.then(onComplete, onComplete);
791
+ });
900
792
  }
901
- function registerSkillTools(state = defaultState) {
902
- if (!state.creds.baseUrl)
793
+ async function registerMcpTools(auth, signal) {
794
+ if (!mcpEnabled || discoveryDisabledReason())
903
795
  return;
904
- for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
905
- pi.registerTool(tool);
796
+ const identity = mcpCatalogIdentity(auth);
797
+ while (mcpRegistration) {
798
+ await waitForMcpRegistration(mcpRegistration, signal);
799
+ signal?.throwIfAborted();
800
+ if (registeredMcpIdentity === identity)
801
+ return;
906
802
  }
907
- }
908
- function seededRuntimeApiKey(state, seed) {
909
- let first = seed;
910
- return async () => {
911
- if (first) {
912
- const value = first;
913
- first = undefined;
914
- return value;
915
- }
916
- return resolveRuntimeApiKey(state);
917
- };
918
- }
919
- async function registerMcpTools(state) {
920
- if (!state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
803
+ if (registeredMcpIdentity === identity)
921
804
  return;
922
- try {
923
- const tools = await createMcpToolDefinitions(state.creds.baseUrl, seededRuntimeApiKey(state, state.liveDiscoveryApiKey), state.headers);
924
- for (const tool of tools) {
925
- pi.registerTool(tool);
805
+ const registration = (async () => {
806
+ try {
807
+ signal?.throwIfAborted();
808
+ const tools = await createMcpToolDefinitions((ctx) => (ctx?.modelRegistry ? resolveDefaultRuntimeAuth(ctx) : Promise.resolve(auth)), isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM MCP: ${message}\n`) : undefined, signal);
809
+ signal?.throwIfAborted();
810
+ for (const tool of tools)
811
+ pi.registerTool(tool);
812
+ registeredMcpIdentity = identity;
926
813
  }
814
+ catch (error) {
815
+ if (signal?.aborted)
816
+ throw signal.reason;
817
+ process.stderr.write(`LiteLLM (${PROVIDER_NAME}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
818
+ }
819
+ })();
820
+ mcpRegistration = registration;
821
+ try {
822
+ await registration;
927
823
  }
928
- catch (error) {
929
- process.stderr.write(`LiteLLM (${state.definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
930
- }
931
- }
932
- registerSkillTools(defaultState);
933
- await registerMcpTools(defaultState);
934
- async function refreshModelsAndCosts(state) {
935
- const fresh = await resolveCredentials(state.definition);
936
- const freshFp = fresh.apiKeyFingerprint;
937
- if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
938
- throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
824
+ finally {
825
+ if (mcpRegistration === registration)
826
+ mcpRegistration = undefined;
939
827
  }
940
- const result = await discoverModels(fresh.baseUrl, fresh.apiKey, {
941
- timeoutMs: getDiscoveryTimeoutMs(),
942
- headers: state.headers,
943
- });
944
- const now = Date.now();
945
- await writeCache(getCachePath(state.definition.name), {
946
- baseUrl: fresh.baseUrl,
947
- apiKeyFingerprint: freshFp,
948
- headersFingerprint: computeHeadersFingerprint(state.headers),
949
- fetchedAt: now,
950
- source: result.source,
951
- models: result.models,
952
- });
953
- const overridden = await applyOverrides(state.definition.name, result.models);
954
- state.creds = fresh;
955
- state.models = overridden;
956
- state.liveDiscoveryApiKey = fresh.apiKey;
957
- state.cacheFetchedAt = now;
958
- registerProvider(state, overridden, fresh.apiKeyConfig);
959
- updateAllCosts();
960
- if (state.definition.name === PROVIDER_NAME)
961
- await registerMcpTools(state);
962
- return { providerName: state.definition.name, models: overridden, source: result.source };
963
- }
964
- function runRefresh(state) {
965
- state.refreshInProgress ??= refreshModelsAndCosts(state).finally(() => {
966
- state.refreshInProgress = null;
967
- });
968
- return state.refreshInProgress;
969
828
  }
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;
829
+ for (const definition of definitions) {
830
+ const provider = createLiteLLMProvider({
831
+ id: definition.name,
832
+ name: definition.displayName,
833
+ baseUrl: requestBaseUrl(definition),
834
+ auth: createProviderAuth(definition),
835
+ discover: async (credential, signal) => {
836
+ const disabledReason = discoveryDisabledReason();
837
+ if (disabledReason)
838
+ throw new Error(`discovery disabled (${disabledReason})`);
839
+ const auth = await authForCredential(definition, credential);
840
+ const result = await discoverModels(auth.baseUrl, auth.apiKey, {
841
+ ...getModelsDevDiscoveryOptions(),
842
+ timeoutMs: getDiscoveryTimeoutMs(),
843
+ signal,
844
+ headers: auth.headers,
845
+ silent: !isVerboseDiscovery(),
846
+ onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
847
+ });
848
+ signal?.throwIfAborted();
849
+ return { ...result, baseUrl: `${normalizeBaseUrl(auth.baseUrl)}/v1` };
982
850
  },
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
- pi.registerCommand("litellm-refresh", {
1013
- description: "Re-discover models from the LiteLLM proxy.",
1014
- handler: async (args, ctx) => {
1015
- const disabledReason = discoveryDisabledReason();
1016
- if (disabledReason) {
1017
- ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
1018
- return;
1019
- }
1020
- const requestedProvider = args.trim();
1021
- const statesToRefresh = requestedProvider
1022
- ? providerStates.filter((state) => state.definition.name === requestedProvider)
1023
- : providerStates;
1024
- if (statesToRefresh.length === 0) {
1025
- ctx.ui.notify(`LiteLLM refresh failed: unknown provider ${requestedProvider}`, "error");
1026
- return;
851
+ });
852
+ Object.assign(provider, { headers: resolveHeaders(definition) });
853
+ const refreshModels = provider.refreshModels;
854
+ provider.refreshModels = async (context) => {
855
+ try {
856
+ await refreshModels(context);
1027
857
  }
1028
- const settled = await Promise.allSettled(statesToRefresh.map(runRefresh));
1029
- const succeeded = settled.filter((result) => result.status === "fulfilled").map((result) => result.value);
1030
- const failed = settled
1031
- .map((result, index) => ({ result, name: statesToRefresh[index].definition.name }))
1032
- .filter(({ result }) => result.status === "rejected")
1033
- .map(({ result, name }) => {
1034
- const reason = result.reason;
1035
- return { name, message: reason instanceof Error ? reason.message : String(reason) };
1036
- });
1037
- if (failed.length === 0) {
1038
- if (succeeded.length === 1) {
1039
- const result = succeeded[0];
1040
- ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
1041
- return;
858
+ finally {
859
+ if (definition.name === PROVIDER_NAME &&
860
+ context.allowNetwork &&
861
+ !discoveryDisabledReason() &&
862
+ context.credential) {
863
+ const auth = await authForCredential(definition, context.credential);
864
+ defaultRuntimeAuth = auth;
865
+ await registerMcpTools(auth, context.signal);
1042
866
  }
1043
- ctx.ui.notify(`LiteLLM: ${succeeded.length} providers refreshed (${succeeded
1044
- .map((result) => `${result.providerName}: ${result.models.length} models`)
1045
- .join(", ")})`, "info");
1046
- return;
1047
- }
1048
- const failures = failed.map(({ name, message }) => (settled.length === 1 ? message : `${name}: ${message}`));
1049
- if (succeeded.length === 0) {
1050
- ctx.ui.notify(`LiteLLM refresh failed: ${failures.join("; ")}`, "error");
1051
- return;
1052
867
  }
1053
- ctx.ui.notify(`LiteLLM: refreshed ${succeeded
1054
- .map((result) => `${result.providerName}: ${result.models.length} models`)
1055
- .join(", ")}; failed ${failures.join("; ")}`, "warning");
1056
- },
1057
- });
868
+ };
869
+ pi.registerProvider(provider);
870
+ }
871
+ setupLiteLLMCostTracking(pi, [...providerNames]);
872
+ if (skillsEnabled) {
873
+ for (const tool of createSkillToolDefinitions(resolveDefaultRuntimeAuth)) {
874
+ pi.registerTool(tool);
875
+ }
876
+ }
1058
877
  let sessionId;
1059
878
  pi.on("session_start", (_event, ctx) => {
1060
879
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
1061
- if (discoveryDisabledReason())
1062
- return;
1063
- for (const state of providerStates) {
1064
- if (!state.cacheFetchedAt || Date.now() - state.cacheFetchedAt <= CACHE_STALE_MS)
1065
- continue;
1066
- void runRefresh(state).catch(() => undefined);
1067
- }
1068
880
  });
1069
881
  pi.on("before_provider_request", (event, ctx) => {
1070
882
  if (!ctx.model?.provider || !providerNames.has(ctx.model.provider))
@@ -1073,13 +885,15 @@ export default async function (pi) {
1073
885
  return;
1074
886
  return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
1075
887
  });
1076
- pi.on("before_agent_start", async (event) => {
1077
- if (discoveryDisabledReason())
888
+ pi.on("before_agent_start", async (event, ctx) => {
889
+ defaultRuntimeAuth = undefined;
890
+ if (!skillsEnabled || discoveryDisabledReason())
1078
891
  return;
1079
- const fresh = await resolveCredentials(defaultState.definition);
1080
- if (!fresh.baseUrl || !fresh.apiKey)
892
+ const auth = await getRuntimeAuth(ctx);
893
+ if (!auth)
1081
894
  return;
1082
- const skills = await listSkills(fresh.baseUrl, fresh.apiKey, defaultState.headers);
895
+ defaultRuntimeAuth = auth;
896
+ const skills = await listSkills(defaultRuntimeAuth.baseUrl, defaultRuntimeAuth.apiKey, defaultRuntimeAuth.headers);
1083
897
  const section = createSkillsPromptSection(skills);
1084
898
  if (!section)
1085
899
  return;