pi-provider-litellm 1.2.7 → 1.2.9

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,18 +1,20 @@
1
- import { execSync } from "node:child_process";
1
+ import { execSync, spawn } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { fingerprint, readCache, writeCache } from "./cache.js";
6
6
  import { setupLiteLLMCostTracking } from "./cost.js";
7
- import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
7
+ import { discoverModels, isGpt55Model, normalizeBaseUrl, shouldSuppressReasoningContent, withTimeout, } from "./discover.js";
8
8
  import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
9
9
  import { getSessionIdFromFile } from "./litellm.js";
10
10
  import { createMcpToolDefinitions } from "./mcp-tools.js";
11
11
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
12
12
  const PROVIDER_NAME = "litellm";
13
+ const SETTINGS_KEY = "litellm";
13
14
  const ENV_BASE_URL = "LITELLM_BASE_URL";
14
15
  const ENV_API_KEY = "LITELLM_API_KEY";
15
16
  const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
17
+ const ENV_HEADERS = "LITELLM_HEADERS";
16
18
  const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
17
19
  const ENV_OFFLINE = "LITELLM_OFFLINE";
18
20
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -25,14 +27,157 @@ const EXPIRE_TOKEN_IMMEDIATELY = 0;
25
27
  function getAuthPath() {
26
28
  return join(getAgentDir(), "auth.json");
27
29
  }
28
- function getCachePath() {
29
- return join(getAgentDir(), CACHE_FILENAME);
30
+ function sanitizeCacheSegment(value) {
31
+ return (value
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9._-]+/g, "-")
34
+ .replace(/^-+|-+$/g, "") || "provider");
30
35
  }
31
- async function readAuthEntry() {
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);
47
+ }
48
+ function isPlainObject(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
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) {
32
164
  try {
33
165
  const raw = await readFile(getAuthPath(), "utf8");
34
166
  const parsed = JSON.parse(raw);
35
- return parsed?.[PROVIDER_NAME];
167
+ return parsed?.[providerName];
168
+ }
169
+ catch {
170
+ return undefined;
171
+ }
172
+ }
173
+ async function readGlobalLiteLLMSettings() {
174
+ try {
175
+ const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
176
+ const parsed = JSON.parse(raw);
177
+ const settings = parsed[SETTINGS_KEY];
178
+ return settings && typeof settings === "object" && !Array.isArray(settings)
179
+ ? settings
180
+ : undefined;
36
181
  }
37
182
  catch {
38
183
  return undefined;
@@ -42,6 +187,9 @@ function cleanConfig(raw) {
42
187
  const trimmed = raw?.trim();
43
188
  return trimmed && trimmed !== "undefined" ? trimmed : undefined;
44
189
  }
190
+ function stringSetting(value) {
191
+ return typeof value === "string" ? cleanConfig(value) : undefined;
192
+ }
45
193
  function normalizeCommand(raw) {
46
194
  const trimmed = cleanConfig(raw);
47
195
  if (!trimmed)
@@ -72,6 +220,46 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
72
220
  return opaqueFallback;
73
221
  }
74
222
  }
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
+ 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();
261
+ }
262
+ }
75
263
  function getLiteLLMApiKey(credentials) {
76
264
  return credentials.access;
77
265
  }
@@ -80,22 +268,149 @@ function resolveOAuthApiKey(credentials) {
80
268
  ? executeApiKeyCommand(credentials.refresh)
81
269
  : getLiteLLMApiKey(credentials);
82
270
  }
83
- async function resolveCredentials({ executeHelpers = true } = {}) {
84
- const entry = await readAuthEntry();
85
- const envBase = cleanConfig(process.env[ENV_BASE_URL]);
86
- const envKey = cleanConfig(process.env[ENV_API_KEY]);
87
- const envHelperCommand = getApiKeyHelperCommand();
88
- const useGcloudToken = isGcloudTokenAuthEnabled();
271
+ function resolveTemplateConfigValue(config) {
272
+ let resolved = "";
273
+ for (let index = 0; index < config.length;) {
274
+ const dollarIndex = config.indexOf("$", index);
275
+ if (dollarIndex === -1)
276
+ return resolved + config.slice(index);
277
+ resolved += config.slice(index, dollarIndex);
278
+ const nextChar = config[dollarIndex + 1];
279
+ if (nextChar === "$" || nextChar === "!") {
280
+ resolved += nextChar;
281
+ index = dollarIndex + 2;
282
+ continue;
283
+ }
284
+ if (nextChar === "{") {
285
+ const endIndex = config.indexOf("}", dollarIndex + 2);
286
+ if (endIndex === -1) {
287
+ resolved += "$";
288
+ index = dollarIndex + 1;
289
+ continue;
290
+ }
291
+ const name = config.slice(dollarIndex + 2, endIndex);
292
+ const envValue = process.env[name];
293
+ if (envValue === undefined)
294
+ return undefined;
295
+ resolved += envValue;
296
+ index = endIndex + 1;
297
+ continue;
298
+ }
299
+ const match = config.slice(dollarIndex + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
300
+ if (!match) {
301
+ resolved += "$";
302
+ index = dollarIndex + 1;
303
+ continue;
304
+ }
305
+ const envValue = process.env[match[0]];
306
+ if (envValue === undefined)
307
+ return undefined;
308
+ resolved += envValue;
309
+ index = dollarIndex + 1 + match[0].length;
310
+ }
311
+ return resolved;
312
+ }
313
+ function resolveConfigValue(config, { executeCommands }) {
314
+ if (config.startsWith("!"))
315
+ return executeCommands ? executeApiKeyCommand(config) : undefined;
316
+ return resolveTemplateConfigValue(config);
317
+ }
318
+ const warnedUnresolvedApiKeys = new Set();
319
+ function warnUnresolvedApiKeyConfig(providerName, config) {
320
+ const key = `${providerName} ${config}`;
321
+ if (warnedUnresolvedApiKeys.has(key))
322
+ return;
323
+ warnedUnresolvedApiKeys.add(key);
324
+ process.stderr.write(`LiteLLM (${providerName}): configured apiKey did not resolve (unset environment variable?); use $$ for a literal $.\n`);
325
+ }
326
+ function parseHeaderRecord(value) {
327
+ if (!value || typeof value !== "object" || Array.isArray(value))
328
+ return undefined;
329
+ const headers = {};
330
+ for (const [key, raw] of Object.entries(value)) {
331
+ if (!key.trim())
332
+ continue;
333
+ let resolved;
334
+ if (typeof raw === "string")
335
+ resolved = resolveTemplateConfigValue(raw);
336
+ else if (typeof raw === "number" || typeof raw === "boolean")
337
+ resolved = String(raw);
338
+ else {
339
+ process.stderr.write(`LiteLLM: ignoring non-primitive header value for "${key}".\n`);
340
+ continue;
341
+ }
342
+ if (resolved)
343
+ headers[key] = resolved;
344
+ }
345
+ return Object.keys(headers).length > 0 ? headers : undefined;
346
+ }
347
+ function parseCustomHeaders(raw) {
348
+ const trimmed = cleanConfig(raw);
349
+ if (!trimmed)
350
+ return undefined;
351
+ try {
352
+ return parseHeaderRecord(JSON.parse(trimmed));
353
+ }
354
+ catch (error) {
355
+ process.stderr.write(`LiteLLM: failed to parse custom headers (${error instanceof Error ? error.message : String(error)}).\n`);
356
+ return undefined;
357
+ }
358
+ }
359
+ function resolveHeaders(definition) {
360
+ if (typeof definition.headers === "string")
361
+ return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
362
+ return parseHeaderRecord(definition.headers);
363
+ }
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)]));
386
+ }
387
+ async function resolveCredentials(definition, { executeHelpers = true } = {}) {
388
+ const entry = definition.useSavedAuth ? await readAuthEntry(definition.name) : undefined;
389
+ const configuredBase = cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
390
+ const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
391
+ const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
392
+ const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
89
393
  const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
90
394
  const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
91
395
  const authKey = entry?.type === "oauth"
92
396
  ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
93
397
  : entry?.type === "api_key"
94
- ? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
398
+ ? (await AuthStorage.create(getAuthPath()).getApiKey(definition.name, { includeFallback: false }))?.trim()
95
399
  : undefined;
96
400
  const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
97
- const helperKey = !authKey && !gcloudKey && executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined;
98
- const apiKey = authKey || gcloudKey || helperKey || envKey;
401
+ // Resolved lazily so a `!command` key is not executed when a
402
+ // higher-precedence credential (saved auth, gcloud token) already won.
403
+ let configuredKey;
404
+ if (!authKey && !gcloudKey && definition.apiKeyConfig) {
405
+ configuredKey = resolveConfigValue(definition.apiKeyConfig, { executeCommands: executeHelpers });
406
+ if (configuredKey === undefined && !definition.apiKeyConfig.startsWith("!")) {
407
+ warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
408
+ }
409
+ }
410
+ const helperKey = !authKey && !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
411
+ ? executeApiKeyCommand(envHelperCommand)
412
+ : undefined;
413
+ const apiKey = authKey || gcloudKey || configuredKey || helperKey || envKey;
99
414
  let apiKeyFingerprint;
100
415
  let apiKeyConfig;
101
416
  if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
@@ -112,6 +427,14 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
112
427
  apiKeyFingerprint = fingerprint(gcloudCacheKey);
113
428
  apiKeyConfig = getGcloudTokenCommand();
114
429
  }
430
+ else if (configuredKey && definition.apiKeyConfig) {
431
+ apiKeyFingerprint = fingerprint(definition.apiKeyConfig.startsWith("!") ? definition.apiKeyConfig : configuredKey);
432
+ apiKeyConfig = definition.apiKeyConfig;
433
+ }
434
+ else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
435
+ apiKeyFingerprint = fingerprint(definition.apiKeyConfig);
436
+ apiKeyConfig = definition.apiKeyConfig;
437
+ }
115
438
  else if (helperKey && envHelperCommand) {
116
439
  apiKeyFingerprint = fingerprint(envHelperCommand);
117
440
  apiKeyConfig = envHelperCommand;
@@ -124,7 +447,7 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
124
447
  apiKeyFingerprint = fingerprint(envKey);
125
448
  apiKeyConfig = `$${ENV_API_KEY}`;
126
449
  }
127
- const rawBase = authBase || envBase;
450
+ const rawBase = authBase || configuredBase;
128
451
  return {
129
452
  baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
130
453
  apiKey: apiKey || undefined,
@@ -147,6 +470,51 @@ function isOffline() {
147
470
  function isListModelsMode() {
148
471
  return process.argv.includes("--list-models");
149
472
  }
473
+ function normalizeProviderSettings(raw) {
474
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
475
+ return undefined;
476
+ const record = raw;
477
+ if (record.enabled === false)
478
+ return undefined;
479
+ return record;
480
+ }
481
+ async function getProviderDefinitions() {
482
+ const settings = await readGlobalLiteLLMSettings();
483
+ const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
484
+ const providerSettings = rawProviders;
485
+ const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
486
+ const makeDefinition = (name, raw, isDefault) => ({
487
+ name,
488
+ displayName: stringSetting(raw?.displayName) ?? (isDefault ? "LiteLLM" : name),
489
+ baseUrl: stringSetting(raw?.baseUrl),
490
+ apiKeyConfig: stringSetting(raw?.apiKey),
491
+ headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
492
+ useDefaultEnv: isDefault,
493
+ useGcloudTokenAuth: isDefault,
494
+ useSavedAuth: true,
495
+ enableOAuth: isDefault,
496
+ });
497
+ const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
498
+ const usedCacheSegments = new Map();
499
+ for (const [name, raw] of Object.entries(providerSettings ?? {})) {
500
+ if (name === PROVIDER_NAME)
501
+ continue;
502
+ const normalized = normalizeProviderSettings(raw);
503
+ if (!normalized)
504
+ 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
+ definitions.push(makeDefinition(name, normalized, false));
515
+ }
516
+ return definitions;
517
+ }
150
518
  async function discoverWithFallback(baseUrl, apiKey, options) {
151
519
  try {
152
520
  return { result: await discoverModels(baseUrl, apiKey, options) };
@@ -159,41 +527,98 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
159
527
  };
160
528
  }
161
529
  }
162
- async function loginLiteLLM(callbacks, onCacheWrite) {
530
+ async function loginLiteLLM(callbacks, options) {
163
531
  const rawBaseUrl = (await callbacks.onPrompt({
164
532
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
165
533
  placeholder: "https://litellm.example.com",
166
534
  })).trim();
167
- const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
168
- if (!rawBaseUrl || !apiKeyInput)
169
- throw new Error("Both base URL and API key are required");
535
+ if (!rawBaseUrl)
536
+ throw new Error("Base URL is required");
170
537
  const baseUrl = normalizeBaseUrl(rawBaseUrl);
171
- const refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
172
- const apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
538
+ const method = (await callbacks.onPrompt({
539
+ message: "Select login method (1 = API key / !command, 2 = SSO / Enterprise JWT):",
540
+ })).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
+ }
577
+ }
578
+ else {
579
+ apiKey = rawToken;
580
+ refresh = "";
581
+ expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
582
+ }
583
+ }
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
+ }
173
592
  const { models, source } = await discoverModels(baseUrl, apiKey, {
174
593
  timeoutMs: LOGIN_TIMEOUT_MS,
175
594
  signal: callbacks.signal,
595
+ headers: options.headers,
176
596
  });
177
597
  const cache = {
178
598
  baseUrl,
179
599
  apiKeyFingerprint: fingerprint(refresh || apiKey),
600
+ headersFingerprint: computeHeadersFingerprint(options.headers),
180
601
  fetchedAt: Date.now(),
181
602
  source,
182
603
  models,
183
604
  };
184
- await writeCache(getCachePath(), cache);
185
- onCacheWrite?.(cache);
605
+ await writeCache(options.cachePath, cache);
606
+ await options.onCacheWrite?.(cache);
186
607
  callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
187
608
  return {
188
609
  access: apiKey,
189
610
  refresh,
190
- expires: tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT),
611
+ expires,
191
612
  baseUrl,
192
613
  };
193
614
  }
194
615
  async function refreshLiteLLM(credentials) {
195
- if (!credentials.refresh.startsWith("!"))
616
+ if (!credentials.refresh.startsWith("!")) {
617
+ if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
618
+ throw new Error("LiteLLM credential cannot be refreshed; run /login litellm again");
619
+ }
196
620
  return credentials;
621
+ }
197
622
  const access = executeApiKeyCommand(credentials.refresh);
198
623
  return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
199
624
  }
@@ -203,7 +628,18 @@ function modifyLiteLLMModels(models, cred) {
203
628
  return models;
204
629
  return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
205
630
  }
206
- function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
631
+ function isReasoningItem(item) {
632
+ return typeof item === "object" && item !== null && item.type === "reasoning";
633
+ }
634
+ // Reasoning fields LiteLLM forwards to chat-completions providers. The Moonshot
635
+ // path defaults them off; the gpt-5.5 tool path strips them entirely.
636
+ const REASONING_SUPPRESSION_DEFAULTS = {
637
+ include_reasoning: false,
638
+ reasoning_content: false,
639
+ merge_reasoning_content_in_choices: true,
640
+ thinking: { type: "disabled" },
641
+ };
642
+ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
207
643
  let next;
208
644
  const update = (key, value) => {
209
645
  if (payload[key] !== undefined)
@@ -211,11 +647,40 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
211
647
  next ??= { ...payload };
212
648
  next[key] = value;
213
649
  };
214
- if (modelId && shouldSuppressReasoningContent(modelId)) {
215
- update("include_reasoning", false);
216
- update("reasoning_content", false);
217
- update("merge_reasoning_content_in_choices", true);
218
- update("thinking", { type: "disabled" });
650
+ if (api !== "openai-responses" && modelId && shouldSuppressReasoningContent(modelId)) {
651
+ for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS))
652
+ update(key, value);
653
+ }
654
+ // LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
655
+ // Drop reasoning until the gateway honors /v1/responses for this route.
656
+ if (api !== "openai-responses" &&
657
+ modelId &&
658
+ isGpt55Model(modelId) &&
659
+ Array.isArray(payload.tools) &&
660
+ payload.tools.length > 0) {
661
+ const reasoningKeys = ["reasoning", "reasoning_effort", ...Object.keys(REASONING_SUPPRESSION_DEFAULTS)];
662
+ for (const key of reasoningKeys) {
663
+ if (payload[key] === undefined)
664
+ continue;
665
+ next ??= { ...payload };
666
+ delete next[key];
667
+ }
668
+ const include = (next ?? payload).include;
669
+ if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
670
+ next ??= { ...payload };
671
+ const filteredInclude = include.filter((value) => value !== "reasoning.encrypted_content");
672
+ if (filteredInclude.length === 0)
673
+ delete next.include;
674
+ else
675
+ next.include = filteredInclude;
676
+ }
677
+ // Prior turns may have replayed reasoning items (with encrypted_content)
678
+ // into the input; they are rejected once reasoning is stripped.
679
+ const input = (next ?? payload).input;
680
+ if (Array.isArray(input) && input.some(isReasoningItem)) {
681
+ next ??= { ...payload };
682
+ next.input = input.filter((item) => !isReasoningItem(item));
683
+ }
219
684
  }
220
685
  if (sessionId) {
221
686
  next ??= { ...payload };
@@ -223,8 +688,8 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
223
688
  }
224
689
  return next;
225
690
  }
226
- function normalizeThinkTags(message) {
227
- if (message.provider !== PROVIDER_NAME || !shouldSuppressReasoningContent(message.model))
691
+ function normalizeThinkTags(message, litellmProviderNames) {
692
+ if (!litellmProviderNames.has(message.provider) || !shouldSuppressReasoningContent(message.model))
228
693
  return;
229
694
  let changed = false;
230
695
  const content = [];
@@ -285,120 +750,162 @@ function normalizeThinkTags(message) {
285
750
  return { ...message, content };
286
751
  }
287
752
  export default async function (pi) {
288
- let creds = await resolveCredentials({ executeHelpers: false });
289
- const cache = await readCache(getCachePath());
290
- let fp = creds.apiKeyFingerprint;
291
- let cacheFetchedAt = cache?.fetchedAt ?? 0;
292
- const cacheValid = cache !== null &&
293
- creds.baseUrl !== undefined &&
294
- fp !== undefined &&
295
- cache.baseUrl === creds.baseUrl &&
296
- cache.apiKeyFingerprint === fp;
297
- let models = cacheValid && cache ? cache.models : [];
298
- const shouldFetch = creds.baseUrl !== undefined &&
299
- fp !== undefined &&
300
- !isOffline() &&
301
- getDiscoveryTimeoutMs() > 0 &&
302
- (!cacheValid || isListModelsMode());
303
- let credentialWarning;
304
- let liveDiscoveryApiKey;
305
- if (shouldFetch) {
306
- try {
307
- creds = await resolveCredentials();
308
- fp = creds.apiKeyFingerprint;
309
- }
310
- catch (error) {
311
- if (!cacheValid || !cache)
312
- throw error;
313
- credentialWarning = error instanceof Error ? error.message : String(error);
314
- process.stderr.write(`LiteLLM: discovery failed (${credentialWarning}); using cached models.\n`);
315
- models = cache.models;
316
- }
753
+ const definitions = await getProviderDefinitions();
754
+ const providerNames = new Set(definitions.map((definition) => definition.name));
755
+ function discoveryDisabledReason() {
756
+ if (isOffline())
757
+ return `${ENV_OFFLINE}=1`;
758
+ if (getDiscoveryTimeoutMs() === 0)
759
+ return `${ENV_TIMEOUT}=0`;
760
+ return null;
317
761
  }
318
- if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
319
- const timeoutMs = getDiscoveryTimeoutMs();
320
- const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
321
- timeoutMs,
322
- });
323
- if (warning) {
324
- if (cacheValid && cache) {
325
- process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
326
- models = cache.models;
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;
327
787
  }
328
- else {
329
- process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
330
- models = [];
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
+ }
331
802
  }
332
803
  }
333
- else {
334
- models = result.models;
335
- liveDiscoveryApiKey = creds.apiKey;
336
- const next = {
337
- baseUrl: creds.baseUrl,
338
- apiKeyFingerprint: fp,
339
- fetchedAt: Date.now(),
340
- source: result.source,
341
- models: result.models,
342
- };
343
- await writeCache(getCachePath(), next);
344
- cacheFetchedAt = next.fetchedAt;
345
- if (isListModelsMode()) {
346
- process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
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
+ }
347
836
  }
348
837
  }
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 };
349
841
  }
842
+ const providerStates = await Promise.all(definitions.map(loadProviderState));
843
+ const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
350
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;
854
+ }
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,
865
+ });
866
+ }
867
+ function defaultLoginOptions() {
868
+ 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
+ },
883
+ };
884
+ }
351
885
  const oauth = {
352
886
  name: "LiteLLM",
353
- login: (callbacks) => loginLiteLLM(callbacks, (next) => {
354
- cacheFetchedAt = next.fetchedAt;
355
- registerProvider(next.baseUrl, next.models);
356
- updateCosts(next.models);
357
- }),
887
+ login: (callbacks) => loginLiteLLM(callbacks, defaultLoginOptions()),
358
888
  refreshToken: refreshLiteLLM,
359
889
  getApiKey: getLiteLLMApiKey,
360
890
  modifyModels: modifyLiteLLMModels,
361
891
  };
362
- function registerProvider(baseUrl, models, apiKeyConfig = creds.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`) {
363
- pi.registerProvider(PROVIDER_NAME, {
364
- baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
365
- // When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
366
- // Pi's per-request auth path (ModelRegistry.getApiKeyAndHeaders) resolves provider keys via
367
- // resolveConfigValueOrThrow -> resolveConfigValueUncached, i.e. the command is re-executed on
368
- // every request (it does NOT use the process-lifetime command cache, which only applies to
369
- // resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
370
- // remain registered for `/login litellm` users. See the regression test
371
- // "re-runs the helper command on every request" in tests/index.test.ts.
372
- apiKey: apiKeyConfig,
373
- api: "openai-completions",
374
- models,
375
- oauth,
376
- });
377
- }
378
- registerProvider(creds.baseUrl, models);
379
- updateCosts = setupLiteLLMCostTracking(pi, models);
380
- let refreshInProgress = null;
381
- function discoveryDisabledReason() {
382
- if (isOffline())
383
- return `${ENV_OFFLINE}=1`;
384
- if (getDiscoveryTimeoutMs() === 0)
385
- return `${ENV_TIMEOUT}=0`;
386
- return null;
387
- }
388
- async function resolveRuntimeApiKey() {
389
- const fresh = await resolveCredentials();
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);
390
897
  if (!fresh.apiKey)
391
- throw new Error("no credentials. Run /login litellm or set env vars.");
898
+ throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
392
899
  return fresh.apiKey;
393
900
  }
394
- function registerSkillTools(baseUrl) {
395
- if (!baseUrl)
901
+ function registerSkillTools(state = defaultState) {
902
+ if (!state.creds.baseUrl)
396
903
  return;
397
- for (const tool of createSkillToolDefinitions(baseUrl, resolveRuntimeApiKey)) {
904
+ for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
398
905
  pi.registerTool(tool);
399
906
  }
400
907
  }
401
- function seededRuntimeApiKey(seed) {
908
+ function seededRuntimeApiKey(state, seed) {
402
909
  let first = seed;
403
910
  return async () => {
404
911
  if (first) {
@@ -406,54 +913,66 @@ export default async function (pi) {
406
913
  first = undefined;
407
914
  return value;
408
915
  }
409
- return resolveRuntimeApiKey();
916
+ return resolveRuntimeApiKey(state);
410
917
  };
411
918
  }
412
- async function registerMcpTools(baseUrl, discoveryApiKey) {
413
- if (!baseUrl || !discoveryApiKey || discoveryDisabledReason())
919
+ async function registerMcpTools(state) {
920
+ if (!state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
414
921
  return;
415
922
  try {
416
- const tools = await createMcpToolDefinitions(baseUrl, seededRuntimeApiKey(discoveryApiKey));
923
+ const tools = await createMcpToolDefinitions(state.creds.baseUrl, seededRuntimeApiKey(state, state.liveDiscoveryApiKey), state.headers);
417
924
  for (const tool of tools) {
418
925
  pi.registerTool(tool);
419
926
  }
420
927
  }
421
928
  catch (error) {
422
- process.stderr.write(`LiteLLM: MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
929
+ process.stderr.write(`LiteLLM (${state.definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
423
930
  }
424
931
  }
425
- registerSkillTools(creds.baseUrl);
426
- await registerMcpTools(creds.baseUrl, liveDiscoveryApiKey);
427
- async function refreshModelsAndCosts() {
428
- const fresh = await resolveCredentials();
932
+ registerSkillTools(defaultState);
933
+ await registerMcpTools(defaultState);
934
+ async function refreshModelsAndCosts(state) {
935
+ const fresh = await resolveCredentials(state.definition);
429
936
  const freshFp = fresh.apiKeyFingerprint;
430
937
  if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
431
- throw new Error("no credentials. Run /login litellm or set env vars.");
938
+ throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
432
939
  }
433
- const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs: getDiscoveryTimeoutMs() });
940
+ const result = await discoverModels(fresh.baseUrl, fresh.apiKey, {
941
+ timeoutMs: getDiscoveryTimeoutMs(),
942
+ headers: state.headers,
943
+ });
434
944
  const now = Date.now();
435
- await writeCache(getCachePath(), {
945
+ await writeCache(getCachePath(state.definition.name), {
436
946
  baseUrl: fresh.baseUrl,
437
947
  apiKeyFingerprint: freshFp,
948
+ headersFingerprint: computeHeadersFingerprint(state.headers),
438
949
  fetchedAt: now,
439
950
  source: result.source,
440
951
  models: result.models,
441
952
  });
442
- registerProvider(fresh.baseUrl, result.models, fresh.apiKeyConfig);
443
- updateCosts(result.models);
444
- cacheFetchedAt = now;
445
- await registerMcpTools(fresh.baseUrl, fresh.apiKey);
446
- return { models: result.models, source: result.source };
447
- }
448
- function runRefresh() {
449
- refreshInProgress ??= refreshModelsAndCosts().finally(() => {
450
- refreshInProgress = null;
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;
451
967
  });
452
- return refreshInProgress;
968
+ return state.refreshInProgress;
453
969
  }
454
970
  async function runLogin(ctx) {
455
971
  const credential = await loginLiteLLM({
456
- onAuth: () => undefined,
972
+ onAuth: ({ url, instructions }) => {
973
+ openInBrowser(url);
974
+ ctx.ui.notify(instructions ? `${url} — ${instructions}` : url, "info");
975
+ },
457
976
  onDeviceCode: () => undefined,
458
977
  onPrompt: async ({ message, placeholder }) => {
459
978
  const value = await ctx.ui.input(message, placeholder);
@@ -464,17 +983,15 @@ export default async function (pi) {
464
983
  onProgress: (message) => ctx.ui.notify(message, "info"),
465
984
  onSelect: async () => undefined,
466
985
  signal: ctx.signal,
467
- }, (next) => {
468
- cacheFetchedAt = next.fetchedAt;
469
- registerProvider(next.baseUrl, next.models);
470
- updateCosts(next.models);
471
- });
986
+ }, defaultLoginOptions());
472
987
  ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
473
988
  ctx.modelRegistry.refresh();
474
989
  const credentialBaseUrl = credential.baseUrl;
475
990
  const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
476
- registerSkillTools(credentialBaseUrl);
477
- await registerMcpTools(credentialBaseUrl, credentialAccess);
991
+ defaultState.creds = { ...defaultState.creds, baseUrl: credentialBaseUrl };
992
+ defaultState.liveDiscoveryApiKey = credentialAccess;
993
+ registerSkillTools(defaultState);
994
+ await registerMcpTools(defaultState);
478
995
  ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
479
996
  }
480
997
  pi.on("input", async (event, ctx) => {
@@ -494,43 +1011,75 @@ export default async function (pi) {
494
1011
  });
495
1012
  pi.registerCommand("litellm-refresh", {
496
1013
  description: "Re-discover models from the LiteLLM proxy.",
497
- handler: async (_args, ctx) => {
1014
+ handler: async (args, ctx) => {
498
1015
  const disabledReason = discoveryDisabledReason();
499
1016
  if (disabledReason) {
500
1017
  ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
501
1018
  return;
502
1019
  }
503
- try {
504
- const result = await runRefresh();
505
- ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
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;
506
1027
  }
507
- catch (error) {
508
- const message = error instanceof Error ? error.message : String(error);
509
- ctx.ui.notify(`LiteLLM refresh failed: ${message}`, "error");
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;
1042
+ }
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;
510
1052
  }
1053
+ ctx.ui.notify(`LiteLLM: refreshed ${succeeded
1054
+ .map((result) => `${result.providerName}: ${result.models.length} models`)
1055
+ .join(", ")}; failed ${failures.join("; ")}`, "warning");
511
1056
  },
512
1057
  });
513
1058
  let sessionId;
514
1059
  pi.on("session_start", (_event, ctx) => {
515
1060
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
516
- if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
1061
+ if (discoveryDisabledReason())
517
1062
  return;
518
- void runRefresh().catch(() => undefined);
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
+ }
519
1068
  });
520
1069
  pi.on("before_provider_request", (event, ctx) => {
521
- if (ctx.model?.provider !== PROVIDER_NAME)
1070
+ if (!ctx.model?.provider || !providerNames.has(ctx.model.provider))
522
1071
  return;
523
1072
  if (typeof event.payload !== "object" || event.payload === null)
524
1073
  return;
525
- return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
1074
+ return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
526
1075
  });
527
1076
  pi.on("before_agent_start", async (event) => {
528
1077
  if (discoveryDisabledReason())
529
1078
  return;
530
- const fresh = await resolveCredentials();
1079
+ const fresh = await resolveCredentials(defaultState.definition);
531
1080
  if (!fresh.baseUrl || !fresh.apiKey)
532
1081
  return;
533
- const skills = await listSkills(fresh.baseUrl, fresh.apiKey);
1082
+ const skills = await listSkills(fresh.baseUrl, fresh.apiKey, defaultState.headers);
534
1083
  const section = createSkillsPromptSection(skills);
535
1084
  if (!section)
536
1085
  return;
@@ -539,7 +1088,7 @@ export default async function (pi) {
539
1088
  pi.on("message_end", (event) => {
540
1089
  if (event.message.role !== "assistant")
541
1090
  return;
542
- const message = normalizeThinkTags(event.message);
1091
+ const message = normalizeThinkTags(event.message, providerNames);
543
1092
  if (!message)
544
1093
  return;
545
1094
  return { message };