pi-provider-litellm 1.3.0 → 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 } 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 { getAgentDir, readStoredCredential } 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,175 +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 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;
28
+ function getModelsDevDiscoveryOptions() {
35
29
  return {
36
- ...state.creds,
37
- baseUrl: typeof credentialBaseUrl === "string" ? normalizeBaseUrl(credentialBaseUrl) : state.creds.baseUrl,
38
- apiKey,
39
- apiKeyFingerprint: fingerprintSource ? fingerprint(fingerprintSource) : undefined,
30
+ modelsDev: process.env[ENV_MODELS_DEV] !== "0",
31
+ modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
40
32
  };
41
33
  }
42
- function getAuthPath() {
43
- return join(getAgentDir(), "auth.json");
44
- }
45
- function sanitizeCacheSegment(value) {
46
- return (value
47
- .toLowerCase()
48
- .replace(/[^a-z0-9._-]+/g, "-")
49
- .replace(/^-+|-+$/g, "") || "provider");
50
- }
51
- function getCachePath(providerName = PROVIDER_NAME) {
52
- if (providerName === PROVIDER_NAME)
53
- return join(getAgentDir(), CACHE_FILENAME);
54
- return join(getAgentDir(), `litellm-models-${sanitizeCacheSegment(providerName)}.json`);
55
- }
56
- // Same tolerance as pi core's models.json loader (stripJsonComments in dist/utils/json.js):
57
- // strip `//` line comments and trailing commas, leaving string literals untouched.
58
- function stripJsonComments(input) {
59
- return input
60
- .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
61
- .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? m);
62
- }
63
34
  function isPlainObject(value) {
64
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
65
36
  }
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"];
69
- // Mirrors pi core's ModelOverrideSchema: core rejects the whole models.json on invalid values,
70
- // so anything dropped here would also have been flagged for built-in providers. Headers matter
71
- // most: pi resolves each header value as a config string, so non-strings break requests.
72
- const MODEL_OVERRIDE_VALIDATORS = {
73
- name: (value) => typeof value === "string",
74
- reasoning: (value) => typeof value === "boolean",
75
- thinkingLevelMap: (value) => isPlainObject(value) && THINKING_LEVELS.every((level) => value[level] == null || typeof value[level] === "string"),
76
- input: (value) => Array.isArray(value) && value.every((entry) => entry === "text" || entry === "image"),
77
- contextWindow: (value) => typeof value === "number",
78
- maxTokens: (value) => typeof value === "number",
79
- headers: (value) => isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "string"),
80
- compat: isPlainObject,
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"),
88
- };
89
- function sanitizeModelOverride(modelId, raw) {
90
- if (!isPlainObject(raw)) {
91
- process.stderr.write(`LiteLLM: ignoring model override for ${modelId} in models.json (not an object).\n`);
92
- return undefined;
93
- }
94
- const override = {};
95
- const dropped = [];
96
- for (const [key, value] of Object.entries(raw)) {
97
- const isValid = MODEL_OVERRIDE_VALIDATORS[key];
98
- if (isValid?.(value))
99
- override[key] = value;
100
- else
101
- dropped.push(key);
102
- }
103
- if (dropped.length > 0) {
104
- process.stderr.write(`LiteLLM: ignoring invalid model override field(s) for ${modelId} in models.json: ${dropped.join(", ")}.\n`);
105
- }
106
- return override;
107
- }
108
- // Overrides are read per registered provider name, matching pi core's own
109
- // models.json layout (`providers.<name>.modelOverrides`) so aliases get the
110
- // same override support as the default provider, not a special case of it.
111
- async function readModelOverrides(providerName) {
112
- let raw;
113
- try {
114
- raw = await readFile(join(getAgentDir(), "models.json"), "utf8");
115
- }
116
- catch {
117
- return new Map();
118
- }
119
- try {
120
- const config = JSON.parse(stripJsonComments(raw));
121
- const overrides = new Map();
122
- for (const [id, rawOverride] of Object.entries(config.providers?.[providerName]?.modelOverrides ?? {})) {
123
- const override = sanitizeModelOverride(id, rawOverride);
124
- if (override)
125
- overrides.set(id, override);
126
- }
127
- return overrides;
128
- }
129
- catch (error) {
130
- const message = error instanceof Error ? error.message : String(error);
131
- process.stderr.write(`LiteLLM: ignoring model overrides (failed to parse models.json: ${message}).\n`);
132
- return new Map();
133
- }
134
- }
135
- // Merge semantics must match pi core's applyModelOverride/mergeCompat (dist/core/model-registry.js)
136
- // so the same models.json entry behaves identically for litellm and built-in providers.
137
- function mergeCompat(base, override) {
138
- if (!override)
139
- return base;
140
- const merged = { ...base, ...override };
141
- for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"]) {
142
- const baseValue = base?.[key];
143
- const overrideValue = override[key];
144
- if (baseValue || overrideValue) {
145
- merged[key] = { ...baseValue, ...overrideValue };
146
- }
147
- }
148
- return merged;
149
- }
150
- function applyModelOverride(model, override) {
151
- const next = { ...model };
152
- if (override.name !== undefined)
153
- next.name = override.name;
154
- if (override.reasoning !== undefined)
155
- next.reasoning = override.reasoning;
156
- if (override.thinkingLevelMap !== undefined) {
157
- next.thinkingLevelMap = { ...model.thinkingLevelMap, ...override.thinkingLevelMap };
158
- }
159
- if (override.input !== undefined)
160
- next.input = override.input;
161
- if (override.contextWindow !== undefined)
162
- next.contextWindow = override.contextWindow;
163
- if (override.maxTokens !== undefined)
164
- next.maxTokens = override.maxTokens;
165
- if (override.headers !== undefined)
166
- next.headers = override.headers;
167
- if (override.compat !== undefined)
168
- next.compat = mergeCompat(model.compat, override.compat);
169
- if (override.cost !== undefined)
170
- next.cost = { ...model.cost, ...override.cost };
171
- return next;
172
- }
173
- function applyModelOverrides(models, overrides) {
174
- if (overrides.size === 0)
175
- return models;
176
- return models.map((model) => {
177
- const override = overrides.get(model.id);
178
- return override ? applyModelOverride(model, override) : model;
179
- });
180
- }
181
- // Re-reads models.json on every call so overrides edited mid-session take effect on the next
182
- // refresh or login, matching pi core's live reload for built-in providers.
183
- async function applyOverrides(providerName, models) {
184
- return applyModelOverrides(models, await readModelOverrides(providerName));
185
- }
186
- function readAuthEntry(providerName = PROVIDER_NAME) {
187
- return readStoredCredential(providerName, getAuthPath());
188
- }
189
37
  async function readGlobalLiteLLMSettings() {
190
38
  try {
191
39
  const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
@@ -215,9 +63,50 @@ function normalizeCommand(raw) {
215
63
  function getApiKeyHelperCommand() {
216
64
  return normalizeCommand(process.env[ENV_API_KEY_HELPER]);
217
65
  }
218
- function executeApiKeyCommand(commandConfig) {
66
+ function parseApiKeyCommand(commandConfig) {
219
67
  const command = commandConfig.startsWith("!") ? commandConfig.slice(1) : commandConfig;
220
- 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();
221
110
  if (!output)
222
111
  throw new Error(`LiteLLM API key helper produced no output: ${command}`);
223
112
  return output;
@@ -237,39 +126,28 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
237
126
  }
238
127
  }
239
128
  async function generateVirtualKey(baseUrl, userToken, signal, headers) {
240
- const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
241
- try {
242
- const response = await fetch(`${baseUrl}/key/generate`, {
243
- method: "POST",
244
- headers: {
245
- ...headers,
246
- Authorization: `Bearer ${userToken}`,
247
- "Content-Type": "application/json",
248
- },
249
- body: JSON.stringify({}),
250
- signal: boundedSignal,
251
- });
252
- if (!response.ok) {
253
- const text = await response.text().catch(() => "");
254
- throw new Error(`Virtual key generation failed (${response.status}): ${text}`);
255
- }
256
- const data = (await response.json());
257
- if (typeof data.key !== "string" || !data.key)
258
- throw new Error("No key in response from /key/generate");
259
- const expiresMs = typeof data.expires === "string" ? Date.parse(data.expires) : Number.NaN;
260
- return { key: data.key, expiresAt: Number.isNaN(expiresMs) ? undefined : expiresMs };
261
- }
262
- finally {
263
- 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}`);
264
145
  }
265
- }
266
- function getLiteLLMApiKey(credentials) {
267
- return credentials.access;
268
- }
269
- function resolveOAuthApiKey(credentials) {
270
- return credentials.refresh.startsWith("!")
271
- ? executeApiKeyCommand(credentials.refresh)
272
- : 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 };
273
151
  }
274
152
  function resolveTemplateConfigValue(config) {
275
153
  let resolved = "";
@@ -313,6 +191,47 @@ function resolveTemplateConfigValue(config) {
313
191
  }
314
192
  return resolved;
315
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
+ }
316
235
  function resolveConfigValue(config, { executeCommands }) {
317
236
  if (config.startsWith("!"))
318
237
  return executeCommands ? executeApiKeyCommand(config) : undefined;
@@ -364,97 +283,56 @@ function resolveHeaders(definition) {
364
283
  return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
365
284
  return parseHeaderRecord(definition.headers);
366
285
  }
367
- // Headers can select a different tenant/customer at the same base URL with the
368
- // same key, so the cache must invalidate on header changes too, not just on
369
- // baseUrl/apiKey changes, or a stale tenant's models get reused silently.
370
- function computeHeadersFingerprint(headers) {
371
- if (!headers)
372
- return undefined;
373
- const sorted = Object.keys(headers)
374
- .sort()
375
- .map((key) => [key, headers[key]]);
376
- return fingerprint(JSON.stringify(sorted));
377
- }
378
- // Pi core resolves registered header values with the same $VAR/!command syntax
379
- // at request time, so already-resolved literals must be escaped or they get
380
- // resolved a second time ($UNSET would then fail every request).
381
- function escapeConfigValue(value) {
382
- const escaped = value.replace(/\$/g, "$$$$");
383
- return escaped.startsWith("!") ? `$${escaped}` : escaped;
384
- }
385
- function escapeHeaderConfig(headers) {
386
- if (!headers)
387
- return undefined;
388
- 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);
389
290
  }
390
291
  async function resolveCredentials(definition, { executeHelpers = true } = {}) {
391
- const entry = definition.useSavedAuth ? await readAuthEntry(definition.name) : undefined;
392
292
  const configuredBase = cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
393
293
  const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
394
294
  const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
395
295
  const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
396
- const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
397
- const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
398
- const authKey = entry?.type === "oauth"
399
- ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
400
- : entry?.type === "api_key" && typeof entry.key === "string"
401
- ? resolveConfigValue(entry.key, { executeCommands: executeHelpers })?.trim()
402
- : undefined;
296
+ const gcloudCacheKey = useGcloudToken ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
403
297
  const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
404
298
  // Resolved lazily so a `!command` key is not executed when a
405
299
  // higher-precedence credential (saved auth, gcloud token) already won.
406
300
  let configuredKey;
407
- if (!authKey && !gcloudKey && definition.apiKeyConfig) {
301
+ if (!gcloudKey && definition.apiKeyConfig) {
408
302
  configuredKey = resolveConfigValue(definition.apiKeyConfig, { executeCommands: executeHelpers });
409
303
  if (configuredKey === undefined && !definition.apiKeyConfig.startsWith("!")) {
410
304
  warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
411
305
  }
412
306
  }
413
- const helperKey = !authKey && !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
307
+ const helperKey = !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
414
308
  ? executeApiKeyCommand(envHelperCommand)
415
309
  : undefined;
416
- const apiKey = authKey || gcloudKey || configuredKey || helperKey || envKey;
417
- let apiKeyFingerprint;
310
+ const apiKey = gcloudKey || configuredKey || helperKey || envKey;
418
311
  let apiKeyConfig;
419
- if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
420
- apiKeyFingerprint = fingerprint(entry.refresh);
421
- }
422
- else if (authKey) {
423
- apiKeyFingerprint = fingerprint(authKey);
424
- }
425
- else if (gcloudKey) {
426
- apiKeyFingerprint = fingerprint(gcloudCacheKey ?? gcloudKey);
312
+ if (gcloudKey) {
427
313
  apiKeyConfig = getGcloudTokenCommand();
428
314
  }
429
315
  else if (!executeHelpers && gcloudCacheKey) {
430
- apiKeyFingerprint = fingerprint(gcloudCacheKey);
431
316
  apiKeyConfig = getGcloudTokenCommand();
432
317
  }
433
318
  else if (configuredKey && definition.apiKeyConfig) {
434
- apiKeyFingerprint = fingerprint(definition.apiKeyConfig.startsWith("!") ? definition.apiKeyConfig : configuredKey);
435
319
  apiKeyConfig = definition.apiKeyConfig;
436
320
  }
437
321
  else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
438
- apiKeyFingerprint = fingerprint(definition.apiKeyConfig);
439
322
  apiKeyConfig = definition.apiKeyConfig;
440
323
  }
441
324
  else if (helperKey && envHelperCommand) {
442
- apiKeyFingerprint = fingerprint(envHelperCommand);
443
325
  apiKeyConfig = envHelperCommand;
444
326
  }
445
327
  else if (!executeHelpers && envHelperCommand) {
446
- apiKeyFingerprint = fingerprint(envHelperCommand);
447
328
  apiKeyConfig = envHelperCommand;
448
329
  }
449
330
  else if (envKey) {
450
- apiKeyFingerprint = fingerprint(envKey);
451
331
  apiKeyConfig = `$${ENV_API_KEY}`;
452
332
  }
453
- const rawBase = authBase || configuredBase;
454
333
  return {
455
- baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
334
+ baseUrl: configuredBase ? normalizeBaseUrl(configuredBase) : undefined,
456
335
  apiKey: apiKey || undefined,
457
- apiKeyFingerprint,
458
336
  apiKeyConfig,
459
337
  };
460
338
  }
@@ -470,8 +348,8 @@ function getDiscoveryTimeoutMs() {
470
348
  function isOffline() {
471
349
  return process.env[ENV_OFFLINE] === "1";
472
350
  }
473
- function isListModelsMode() {
474
- return process.argv.includes("--list-models");
351
+ function isVerboseDiscovery() {
352
+ return process.env[ENV_VERBOSE_DISCOVERY] === "1";
475
353
  }
476
354
  function normalizeProviderSettings(raw) {
477
355
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -497,129 +375,81 @@ function getProviderDefinitions(settings) {
497
375
  headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
498
376
  useDefaultEnv: isDefault,
499
377
  useGcloudTokenAuth: isDefault,
500
- useSavedAuth: true,
501
378
  enableOAuth: isDefault,
502
379
  });
503
380
  const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
504
- const usedCacheSegments = new Map();
505
381
  for (const [name, raw] of Object.entries(providerSettings ?? {})) {
506
382
  if (name === PROVIDER_NAME)
507
383
  continue;
508
384
  const normalized = normalizeProviderSettings(raw);
509
385
  if (!normalized)
510
386
  continue;
511
- // Distinct alias names can sanitize to the same cache file; registering
512
- // both would let their model caches silently clobber each other.
513
- const segment = sanitizeCacheSegment(name);
514
- const existing = usedCacheSegments.get(segment);
515
- if (existing) {
516
- process.stderr.write(`LiteLLM: provider alias "${name}" would share a cache file with "${existing}"; skipping it. Rename the alias.\n`);
517
- continue;
518
- }
519
- usedCacheSegments.set(segment, name);
520
387
  definitions.push(makeDefinition(name, normalized, false));
521
388
  }
522
389
  return definitions;
523
390
  }
524
- async function discoverWithFallback(baseUrl, apiKey, options) {
525
- try {
526
- return { result: await discoverModels(baseUrl, apiKey, options) };
527
- }
528
- catch (error) {
529
- const message = error instanceof Error ? error.message : String(error);
530
- return {
531
- result: { models: [], source: "models_list" },
532
- warning: message,
533
- };
534
- }
535
- }
536
- async function loginLiteLLM(callbacks, options) {
537
- const rawBaseUrl = (await callbacks.onPrompt({
391
+ async function loginApiKey(interaction) {
392
+ const rawBaseUrl = (await interaction.prompt({
393
+ type: "text",
538
394
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
539
395
  placeholder: "https://litellm.example.com",
540
396
  })).trim();
541
397
  if (!rawBaseUrl)
542
398
  throw new Error("Base URL is required");
543
- const baseUrl = normalizeBaseUrl(rawBaseUrl);
544
- const method = (await callbacks.onPrompt({
545
- 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",
546
409
  })).trim();
547
- let apiKey;
548
- let refresh;
549
- let expires;
550
- if (method === "2") {
551
- callbacks.onAuth?.({
552
- url: `${baseUrl}/sso/key/generate`,
553
- instructions: "Authenticate via SSO, then copy your token from the LiteLLM UI.",
554
- });
555
- const rawToken = (await callbacks.onPrompt({ message: "Paste your SSO token from the LiteLLM UI:" }))
556
- .trim()
557
- .replace(/^Bearer\s+/i, "")
558
- .trim();
559
- if (!rawToken)
560
- throw new Error("SSO token is required");
561
- const wantVirtualKey = (await callbacks.onPrompt({ message: "Generate a LiteLLM virtual key from this token? (y/n):" }))
562
- .trim()
563
- .toLowerCase();
564
- if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
565
- try {
566
- callbacks.onProgress?.("Generating virtual key...");
567
- const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal, options.headers);
568
- apiKey = generated.key;
569
- refresh = "";
570
- expires =
571
- generated.expiresAt === undefined
572
- ? PERMANENT_TOKEN_EXPIRES_AT
573
- : Math.max(Date.now(), generated.expiresAt - TOKEN_REFRESH_LEAD_MS);
574
- callbacks.onProgress?.("Virtual key generated and will be used for API calls.");
575
- }
576
- catch (error) {
577
- const message = error instanceof Error ? error.message : String(error);
578
- callbacks.onProgress?.(`LiteLLM: virtual key generation failed (${message}); using SSO token directly.`);
579
- apiKey = rawToken;
580
- refresh = "";
581
- expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
582
- }
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." });
583
439
  }
584
- else {
585
- apiKey = rawToken;
586
- refresh = "";
587
- 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
+ });
588
448
  }
589
449
  }
590
- else {
591
- const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
592
- if (!apiKeyInput)
593
- throw new Error("Both base URL and API key are required");
594
- refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
595
- apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
596
- expires = tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT);
597
- }
598
- const { models, source } = await discoverModels(baseUrl, apiKey, {
599
- timeoutMs: LOGIN_TIMEOUT_MS,
600
- signal: callbacks.signal,
601
- headers: options.headers,
602
- onProgress: (message) => callbacks.onProgress?.(`LiteLLM: ${message}`),
603
- });
604
- const cache = {
605
- baseUrl,
606
- apiKeyFingerprint: fingerprint(refresh || apiKey),
607
- headersFingerprint: computeHeadersFingerprint(options.headers),
608
- fetchedAt: Date.now(),
609
- source,
610
- models,
611
- };
612
- await writeCache(options.cachePath, cache);
613
- await options.onCacheWrite?.(cache);
614
- callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
615
- return {
616
- access: apiKey,
617
- refresh,
618
- expires,
619
- baseUrl,
620
- };
450
+ return { type: "oauth", access, refresh: "", expires, baseUrl };
621
451
  }
622
- async function refreshLiteLLM(credentials) {
452
+ async function refreshLiteLLM(credentials, _signal) {
623
453
  if (!credentials.refresh.startsWith("!")) {
624
454
  if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
625
455
  throw new Error("LiteLLM credential cannot be refreshed; run /login litellm again");
@@ -629,11 +459,122 @@ async function refreshLiteLLM(credentials) {
629
459
  const access = executeApiKeyCommand(credentials.refresh);
630
460
  return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
631
461
  }
632
- function modifyLiteLLMModels(models, cred) {
633
- const baseUrl = cred.baseUrl;
634
- if (!baseUrl)
635
- return models;
636
- 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
+ };
637
578
  }
638
579
  function isReasoningItem(item) {
639
580
  return typeof item === "object" && item !== null && item.type === "reasoning";
@@ -769,289 +710,173 @@ export default async function (pi) {
769
710
  return `${ENV_TIMEOUT}=0`;
770
711
  return null;
771
712
  }
772
- async function loadProviderState(definition) {
773
- let creds = await resolveCredentials(definition, { executeHelpers: false });
774
- const headers = resolveHeaders(definition);
775
- const headersFp = computeHeadersFingerprint(headers);
776
- const cache = await readCache(getCachePath(definition.name));
777
- let fp = creds.apiKeyFingerprint;
778
- let cacheFetchedAt = cache?.fetchedAt ?? 0;
779
- const cacheValid = cache !== null &&
780
- creds.baseUrl !== undefined &&
781
- fp !== undefined &&
782
- cache.baseUrl === creds.baseUrl &&
783
- cache.apiKeyFingerprint === fp &&
784
- cache.headersFingerprint === headersFp;
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;
789
- let credentialWarning;
790
- let liveDiscoveryApiKey;
791
- if (shouldFetch) {
792
- try {
793
- creds = await resolveCredentials(definition);
794
- fp = creds.apiKeyFingerprint;
795
- }
796
- catch (error) {
797
- // A broken alias must not abort activation for the other providers;
798
- // only the default provider keeps the historical fail-fast behavior.
799
- if ((!cacheValid || !cache) && definition.useDefaultEnv)
800
- throw error;
801
- credentialWarning = error instanceof Error ? error.message : String(error);
802
- if (cacheValid && cache) {
803
- process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${credentialWarning}); using cached models.\n`);
804
- models = cache.models;
805
- }
806
- else {
807
- process.stderr.write(`LiteLLM (${definition.name}): credential resolution failed (${credentialWarning}); registering provider with no models.\n`);
808
- models = [];
809
- }
810
- }
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) };
811
725
  }
812
- if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
813
- const timeoutMs = getDiscoveryTimeoutMs();
814
- const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
815
- timeoutMs,
816
- headers,
817
- onProgress: (message) => process.stderr.write(`LiteLLM: ${message}\n`),
818
- });
819
- if (warning) {
820
- if (cacheValid && cache) {
821
- process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${warning}); using cached models.\n`);
822
- models = cache.models;
823
- }
824
- else {
825
- process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${warning}); registering provider with no models.\n`);
826
- models = [];
827
- }
828
- }
829
- else {
830
- models = result.models;
831
- liveDiscoveryApiKey = creds.apiKey;
832
- const next = {
833
- baseUrl: creds.baseUrl,
834
- apiKeyFingerprint: fp,
835
- headersFingerprint: headersFp,
836
- fetchedAt: Date.now(),
837
- source: result.source,
838
- models: result.models,
839
- };
840
- await writeCache(getCachePath(definition.name), next);
841
- cacheFetchedAt = next.fetchedAt;
842
- if (isListModelsMode()) {
843
- process.stderr.write(`LiteLLM (${definition.name}): ${result.models.length} models discovered (source: ${result.source}).\n`);
844
- }
845
- }
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.`);
846
729
  }
847
- // The cache keeps raw discovery output; overrides are applied freshly at each registration.
848
- models = await applyOverrides(definition.name, models);
849
730
  return {
850
- definition,
851
- creds,
852
- headers,
853
- models,
854
- cacheFetchedAt,
855
- refreshOnStart,
856
- liveDiscoveryApiKey,
857
- refreshInProgress: null,
731
+ baseUrl: normalizeBaseUrl(resolved.auth.baseUrl ?? requestBaseUrl(definition)),
732
+ apiKey: resolved.auth.apiKey,
733
+ headers: resolved.auth.headers,
858
734
  };
859
735
  }
860
- const providerStates = await Promise.all(definitions.map(loadProviderState));
861
- const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
862
- let updateCosts = () => undefined;
863
- const providerModelsForCosts = () => providerStates.map((state) => ({ provider: state.definition.name, models: state.models }));
864
- const updateAllCosts = () => updateCosts(providerModelsForCosts());
865
- function defaultApiKeyConfig(definition) {
866
- if (definition.useDefaultEnv) {
867
- return definition.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`;
868
- }
869
- // An alias must never inherit the default provider's env key; leaving the
870
- // key unset makes requests fail loudly instead of leaking credentials.
871
- return definition.apiKeyConfig;
872
- }
873
- function registerProvider(state, models = state.models, apiKeyConfig = state.creds.apiKeyConfig) {
874
- const definition = state.definition;
875
- pi.registerProvider(definition.name, {
876
- name: definition.displayName,
877
- baseUrl: state.creds.baseUrl ? `${state.creds.baseUrl}/v1` : "https://litellm.example.com/v1",
878
- apiKey: apiKeyConfig ?? defaultApiKeyConfig(definition),
879
- api: "openai-completions",
880
- headers: escapeHeaderConfig(state.headers),
881
- models,
882
- refreshModels: ({ allowNetwork, credential, signal }) => allowNetwork && !discoveryDisabledReason()
883
- ? runRefresh(state, undefined, credential, signal).then((result) => result.models)
884
- : Promise.resolve(state.models),
885
- 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)),
886
744
  });
887
745
  }
888
- 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"));
889
756
  return {
890
- cachePath: getCachePath(PROVIDER_NAME),
891
- headers: defaultState.headers,
892
- onCacheWrite: async (next) => {
893
- defaultState.cacheFetchedAt = next.fetchedAt;
894
- const overridden = await applyOverrides(PROVIDER_NAME, next.models);
895
- defaultState.models = overridden;
896
- defaultState.creds = {
897
- ...defaultState.creds,
898
- baseUrl: next.baseUrl,
899
- apiKeyFingerprint: next.apiKeyFingerprint,
900
- };
901
- registerProvider(defaultState, overridden);
902
- updateAllCosts();
903
- },
757
+ baseUrl: normalizeBaseUrl(baseUrl),
758
+ apiKey,
759
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
904
760
  };
905
761
  }
906
- const oauth = {
907
- name: "LiteLLM",
908
- login: (callbacks) => loginLiteLLM(callbacks, defaultLoginOptions()),
909
- refreshToken: refreshLiteLLM,
910
- getApiKey: getLiteLLMApiKey,
911
- modifyModels: modifyLiteLLMModels,
912
- };
913
- for (const state of providerStates)
914
- registerProvider(state);
915
- updateCosts = setupLiteLLMCostTracking(pi, providerModelsForCosts());
916
- async function resolveRuntimeApiKey(state = defaultState) {
917
- const fresh = await resolveCredentials(state.definition);
918
- if (!fresh.apiKey)
919
- throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
920
- 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
+ });
921
792
  }
922
- function registerSkillTools(state = defaultState) {
923
- if (!skillsEnabled || !state.creds.baseUrl)
793
+ async function registerMcpTools(auth, signal) {
794
+ if (!mcpEnabled || discoveryDisabledReason())
924
795
  return;
925
- for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
926
- 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;
927
802
  }
928
- }
929
- function seededRuntimeApiKey(state, seed) {
930
- let first = seed;
931
- return async () => {
932
- if (first) {
933
- const value = first;
934
- first = undefined;
935
- return value;
936
- }
937
- return resolveRuntimeApiKey(state);
938
- };
939
- }
940
- async function registerMcpTools(state, onProgress) {
941
- if (!mcpEnabled || !state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
803
+ if (registeredMcpIdentity === identity)
942
804
  return;
943
- try {
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`));
945
- for (const tool of tools) {
946
- 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;
947
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;
948
823
  }
949
- catch (error) {
950
- process.stderr.write(`LiteLLM (${state.definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
951
- }
952
- }
953
- registerSkillTools(defaultState);
954
- async function refreshModelsAndCosts(state, onProgress, credential, signal) {
955
- const fresh = credential ? credentialsFromRefresh(state, credential) : await resolveCredentials(state.definition);
956
- const freshFp = fresh.apiKeyFingerprint;
957
- if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
958
- 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;
959
827
  }
960
- const progress = onProgress
961
- ? (message) => onProgress(`LiteLLM: ${message}`)
962
- : (message) => process.stderr.write(`LiteLLM: ${message}\n`);
963
- const result = await discoverModels(fresh.baseUrl, fresh.apiKey, {
964
- timeoutMs: getDiscoveryTimeoutMs(),
965
- signal,
966
- headers: state.headers,
967
- onProgress: progress,
968
- });
969
- signal?.throwIfAborted();
970
- const now = Date.now();
971
- await writeCache(getCachePath(state.definition.name), {
972
- baseUrl: fresh.baseUrl,
973
- apiKeyFingerprint: freshFp,
974
- headersFingerprint: computeHeadersFingerprint(state.headers),
975
- fetchedAt: now,
976
- source: result.source,
977
- models: result.models,
978
- });
979
- const overridden = await applyOverrides(state.definition.name, result.models);
980
- state.creds = fresh;
981
- state.models = overridden;
982
- state.liveDiscoveryApiKey = fresh.apiKey;
983
- state.cacheFetchedAt = now;
984
- state.refreshOnStart = false;
985
- registerProvider(state, overridden, fresh.apiKeyConfig);
986
- updateAllCosts();
987
- if (state.definition.name === PROVIDER_NAME)
988
- await registerMcpTools(state, onProgress);
989
- return { providerName: state.definition.name, models: overridden, source: result.source };
990
828
  }
991
- function runRefresh(state, onProgress, credential, signal) {
992
- state.refreshInProgress ??= refreshModelsAndCosts(state, onProgress, credential, signal).finally(() => {
993
- state.refreshInProgress = null;
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` };
850
+ },
994
851
  });
995
- return state.refreshInProgress;
996
- }
997
- pi.registerCommand("litellm-refresh", {
998
- description: "Re-discover models from the LiteLLM proxy.",
999
- handler: async (args, ctx) => {
1000
- const disabledReason = discoveryDisabledReason();
1001
- if (disabledReason) {
1002
- ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
1003
- return;
1004
- }
1005
- const requestedProvider = args.trim();
1006
- const statesToRefresh = requestedProvider
1007
- ? providerStates.filter((state) => state.definition.name === requestedProvider)
1008
- : providerStates;
1009
- if (statesToRefresh.length === 0) {
1010
- ctx.ui.notify(`LiteLLM refresh failed: unknown provider ${requestedProvider}`, "error");
1011
- return;
852
+ Object.assign(provider, { headers: resolveHeaders(definition) });
853
+ const refreshModels = provider.refreshModels;
854
+ provider.refreshModels = async (context) => {
855
+ try {
856
+ await refreshModels(context);
1012
857
  }
1013
- const settled = await Promise.allSettled(statesToRefresh.map((state) => runRefresh(state, (message) => ctx.ui.notify(message, "info"))));
1014
- const succeeded = settled.filter((result) => result.status === "fulfilled").map((result) => result.value);
1015
- const failed = settled
1016
- .map((result, index) => ({ result, name: statesToRefresh[index].definition.name }))
1017
- .filter(({ result }) => result.status === "rejected")
1018
- .map(({ result, name }) => {
1019
- const reason = result.reason;
1020
- return { name, message: reason instanceof Error ? reason.message : String(reason) };
1021
- });
1022
- if (failed.length === 0) {
1023
- if (succeeded.length === 1) {
1024
- const result = succeeded[0];
1025
- ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
1026
- 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);
1027
866
  }
1028
- ctx.ui.notify(`LiteLLM: ${succeeded.length} providers refreshed (${succeeded
1029
- .map((result) => `${result.providerName}: ${result.models.length} models`)
1030
- .join(", ")})`, "info");
1031
- return;
1032
- }
1033
- const failures = failed.map(({ name, message }) => (settled.length === 1 ? message : `${name}: ${message}`));
1034
- if (succeeded.length === 0) {
1035
- ctx.ui.notify(`LiteLLM refresh failed: ${failures.join("; ")}`, "error");
1036
- return;
1037
867
  }
1038
- ctx.ui.notify(`LiteLLM: refreshed ${succeeded
1039
- .map((result) => `${result.providerName}: ${result.models.length} models`)
1040
- .join(", ")}; failed ${failures.join("; ")}`, "warning");
1041
- },
1042
- });
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
+ }
1043
877
  let sessionId;
1044
878
  pi.on("session_start", (_event, ctx) => {
1045
879
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
1046
- if (discoveryDisabledReason())
1047
- return;
1048
- for (const state of providerStates) {
1049
- const cacheIsStale = state.cacheFetchedAt > 0 && Date.now() - state.cacheFetchedAt > CACHE_STALE_MS;
1050
- if (!state.refreshOnStart && !cacheIsStale)
1051
- continue;
1052
- const progress = ctx.ui?.notify ? (message) => ctx.ui.notify(message, "info") : undefined;
1053
- void runRefresh(state, progress).catch(() => undefined);
1054
- }
1055
880
  });
1056
881
  pi.on("before_provider_request", (event, ctx) => {
1057
882
  if (!ctx.model?.provider || !providerNames.has(ctx.model.provider))
@@ -1060,13 +885,15 @@ export default async function (pi) {
1060
885
  return;
1061
886
  return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
1062
887
  });
1063
- pi.on("before_agent_start", async (event) => {
888
+ pi.on("before_agent_start", async (event, ctx) => {
889
+ defaultRuntimeAuth = undefined;
1064
890
  if (!skillsEnabled || discoveryDisabledReason())
1065
891
  return;
1066
- const fresh = await resolveCredentials(defaultState.definition);
1067
- if (!fresh.baseUrl || !fresh.apiKey)
892
+ const auth = await getRuntimeAuth(ctx);
893
+ if (!auth)
1068
894
  return;
1069
- const skills = await listSkills(fresh.baseUrl, fresh.apiKey, defaultState.headers);
895
+ defaultRuntimeAuth = auth;
896
+ const skills = await listSkills(defaultRuntimeAuth.baseUrl, defaultRuntimeAuth.apiKey, defaultRuntimeAuth.headers);
1070
897
  const section = createSkillsPromptSection(skills);
1071
898
  if (!section)
1072
899
  return;