pi-provider-litellm 1.2.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,18 +1,20 @@
1
- import { execSync, spawn } from "node:child_process";
1
+ import { execSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
5
5
  import { fingerprint, readCache, writeCache } from "./cache.js";
6
6
  import { setupLiteLLMCostTracking } from "./cost.js";
7
- import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent, withTimeout } 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;
@@ -22,17 +24,176 @@ const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
22
24
  const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
23
25
  const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
24
26
  const EXPIRE_TOKEN_IMMEDIATELY = 0;
27
+ function credentialsFromRefresh(state, credential) {
28
+ const apiKey = credential.type === "oauth" ? credential.access : credential.key;
29
+ const credentialBaseUrl = credential.type === "oauth" ? credential.baseUrl : undefined;
30
+ const fingerprintSource = credential.type === "api_key" && state.creds.apiKeyConfig?.startsWith("!")
31
+ ? state.creds.apiKeyConfig
32
+ : credential.type === "oauth" && credential.refresh.startsWith("!")
33
+ ? credential.refresh
34
+ : apiKey;
35
+ return {
36
+ ...state.creds,
37
+ baseUrl: typeof credentialBaseUrl === "string" ? normalizeBaseUrl(credentialBaseUrl) : state.creds.baseUrl,
38
+ apiKey,
39
+ apiKeyFingerprint: fingerprintSource ? fingerprint(fingerprintSource) : undefined,
40
+ };
41
+ }
25
42
  function getAuthPath() {
26
43
  return join(getAgentDir(), "auth.json");
27
44
  }
28
- function getCachePath() {
29
- return join(getAgentDir(), CACHE_FILENAME);
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
+ function isPlainObject(value) {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
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;
30
172
  }
31
- async function readAuthEntry() {
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
+ async function readGlobalLiteLLMSettings() {
32
190
  try {
33
- const raw = await readFile(getAuthPath(), "utf8");
191
+ const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
34
192
  const parsed = JSON.parse(raw);
35
- return parsed?.[PROVIDER_NAME];
193
+ const settings = parsed[SETTINGS_KEY];
194
+ return settings && typeof settings === "object" && !Array.isArray(settings)
195
+ ? settings
196
+ : undefined;
36
197
  }
37
198
  catch {
38
199
  return undefined;
@@ -42,6 +203,9 @@ function cleanConfig(raw) {
42
203
  const trimmed = raw?.trim();
43
204
  return trimmed && trimmed !== "undefined" ? trimmed : undefined;
44
205
  }
206
+ function stringSetting(value) {
207
+ return typeof value === "string" ? cleanConfig(value) : undefined;
208
+ }
45
209
  function normalizeCommand(raw) {
46
210
  const trimmed = cleanConfig(raw);
47
211
  if (!trimmed)
@@ -72,25 +236,13 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
72
236
  return opaqueFallback;
73
237
  }
74
238
  }
75
- function openInBrowser(url) {
76
- // Never invoke a shell here: cmd.exe re-parses metacharacters before `start`
77
- // runs, which would make URL contents injectable. Launch is best-effort; the
78
- // URL is also shown to the user, so launcher failures must not crash the process.
79
- const [cmd, args] = process.platform === "darwin"
80
- ? ["open", [url]]
81
- : process.platform === "win32"
82
- ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
83
- : ["xdg-open", [url]];
84
- spawn(cmd, args, { stdio: "ignore", detached: true })
85
- .on("error", () => undefined)
86
- .unref();
87
- }
88
- async function generateVirtualKey(baseUrl, userToken, signal) {
239
+ async function generateVirtualKey(baseUrl, userToken, signal, headers) {
89
240
  const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
90
241
  try {
91
242
  const response = await fetch(`${baseUrl}/key/generate`, {
92
243
  method: "POST",
93
244
  headers: {
245
+ ...headers,
94
246
  Authorization: `Bearer ${userToken}`,
95
247
  "Content-Type": "application/json",
96
248
  },
@@ -119,22 +271,149 @@ function resolveOAuthApiKey(credentials) {
119
271
  ? executeApiKeyCommand(credentials.refresh)
120
272
  : getLiteLLMApiKey(credentials);
121
273
  }
122
- async function resolveCredentials({ executeHelpers = true } = {}) {
123
- const entry = await readAuthEntry();
124
- const envBase = cleanConfig(process.env[ENV_BASE_URL]);
125
- const envKey = cleanConfig(process.env[ENV_API_KEY]);
126
- const envHelperCommand = getApiKeyHelperCommand();
127
- const useGcloudToken = isGcloudTokenAuthEnabled();
274
+ function resolveTemplateConfigValue(config) {
275
+ let resolved = "";
276
+ for (let index = 0; index < config.length;) {
277
+ const dollarIndex = config.indexOf("$", index);
278
+ if (dollarIndex === -1)
279
+ return resolved + config.slice(index);
280
+ resolved += config.slice(index, dollarIndex);
281
+ const nextChar = config[dollarIndex + 1];
282
+ if (nextChar === "$" || nextChar === "!") {
283
+ resolved += nextChar;
284
+ index = dollarIndex + 2;
285
+ continue;
286
+ }
287
+ if (nextChar === "{") {
288
+ const endIndex = config.indexOf("}", dollarIndex + 2);
289
+ if (endIndex === -1) {
290
+ resolved += "$";
291
+ index = dollarIndex + 1;
292
+ continue;
293
+ }
294
+ const name = config.slice(dollarIndex + 2, endIndex);
295
+ const envValue = process.env[name];
296
+ if (envValue === undefined)
297
+ return undefined;
298
+ resolved += envValue;
299
+ index = endIndex + 1;
300
+ continue;
301
+ }
302
+ const match = config.slice(dollarIndex + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
303
+ if (!match) {
304
+ resolved += "$";
305
+ index = dollarIndex + 1;
306
+ continue;
307
+ }
308
+ const envValue = process.env[match[0]];
309
+ if (envValue === undefined)
310
+ return undefined;
311
+ resolved += envValue;
312
+ index = dollarIndex + 1 + match[0].length;
313
+ }
314
+ return resolved;
315
+ }
316
+ function resolveConfigValue(config, { executeCommands }) {
317
+ if (config.startsWith("!"))
318
+ return executeCommands ? executeApiKeyCommand(config) : undefined;
319
+ return resolveTemplateConfigValue(config);
320
+ }
321
+ const warnedUnresolvedApiKeys = new Set();
322
+ function warnUnresolvedApiKeyConfig(providerName, config) {
323
+ const key = `${providerName} ${config}`;
324
+ if (warnedUnresolvedApiKeys.has(key))
325
+ return;
326
+ warnedUnresolvedApiKeys.add(key);
327
+ process.stderr.write(`LiteLLM (${providerName}): configured apiKey did not resolve (unset environment variable?); use $$ for a literal $.\n`);
328
+ }
329
+ function parseHeaderRecord(value) {
330
+ if (!value || typeof value !== "object" || Array.isArray(value))
331
+ return undefined;
332
+ const headers = {};
333
+ for (const [key, raw] of Object.entries(value)) {
334
+ if (!key.trim())
335
+ continue;
336
+ let resolved;
337
+ if (typeof raw === "string")
338
+ resolved = resolveTemplateConfigValue(raw);
339
+ else if (typeof raw === "number" || typeof raw === "boolean")
340
+ resolved = String(raw);
341
+ else {
342
+ process.stderr.write(`LiteLLM: ignoring non-primitive header value for "${key}".\n`);
343
+ continue;
344
+ }
345
+ if (resolved)
346
+ headers[key] = resolved;
347
+ }
348
+ return Object.keys(headers).length > 0 ? headers : undefined;
349
+ }
350
+ function parseCustomHeaders(raw) {
351
+ const trimmed = cleanConfig(raw);
352
+ if (!trimmed)
353
+ return undefined;
354
+ try {
355
+ return parseHeaderRecord(JSON.parse(trimmed));
356
+ }
357
+ catch (error) {
358
+ process.stderr.write(`LiteLLM: failed to parse custom headers (${error instanceof Error ? error.message : String(error)}).\n`);
359
+ return undefined;
360
+ }
361
+ }
362
+ function resolveHeaders(definition) {
363
+ if (typeof definition.headers === "string")
364
+ return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
365
+ return parseHeaderRecord(definition.headers);
366
+ }
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)]));
389
+ }
390
+ async function resolveCredentials(definition, { executeHelpers = true } = {}) {
391
+ const entry = definition.useSavedAuth ? await readAuthEntry(definition.name) : undefined;
392
+ const configuredBase = cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
393
+ const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
394
+ const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
395
+ const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
128
396
  const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
129
397
  const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
130
398
  const authKey = entry?.type === "oauth"
131
399
  ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
132
- : entry?.type === "api_key"
133
- ? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
400
+ : entry?.type === "api_key" && typeof entry.key === "string"
401
+ ? resolveConfigValue(entry.key, { executeCommands: executeHelpers })?.trim()
134
402
  : undefined;
135
403
  const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
136
- const helperKey = !authKey && !gcloudKey && executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined;
137
- const apiKey = authKey || gcloudKey || helperKey || envKey;
404
+ // Resolved lazily so a `!command` key is not executed when a
405
+ // higher-precedence credential (saved auth, gcloud token) already won.
406
+ let configuredKey;
407
+ if (!authKey && !gcloudKey && definition.apiKeyConfig) {
408
+ configuredKey = resolveConfigValue(definition.apiKeyConfig, { executeCommands: executeHelpers });
409
+ if (configuredKey === undefined && !definition.apiKeyConfig.startsWith("!")) {
410
+ warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
411
+ }
412
+ }
413
+ const helperKey = !authKey && !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
414
+ ? executeApiKeyCommand(envHelperCommand)
415
+ : undefined;
416
+ const apiKey = authKey || gcloudKey || configuredKey || helperKey || envKey;
138
417
  let apiKeyFingerprint;
139
418
  let apiKeyConfig;
140
419
  if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
@@ -151,6 +430,14 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
151
430
  apiKeyFingerprint = fingerprint(gcloudCacheKey);
152
431
  apiKeyConfig = getGcloudTokenCommand();
153
432
  }
433
+ else if (configuredKey && definition.apiKeyConfig) {
434
+ apiKeyFingerprint = fingerprint(definition.apiKeyConfig.startsWith("!") ? definition.apiKeyConfig : configuredKey);
435
+ apiKeyConfig = definition.apiKeyConfig;
436
+ }
437
+ else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
438
+ apiKeyFingerprint = fingerprint(definition.apiKeyConfig);
439
+ apiKeyConfig = definition.apiKeyConfig;
440
+ }
154
441
  else if (helperKey && envHelperCommand) {
155
442
  apiKeyFingerprint = fingerprint(envHelperCommand);
156
443
  apiKeyConfig = envHelperCommand;
@@ -163,7 +450,7 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
163
450
  apiKeyFingerprint = fingerprint(envKey);
164
451
  apiKeyConfig = `$${ENV_API_KEY}`;
165
452
  }
166
- const rawBase = authBase || envBase;
453
+ const rawBase = authBase || configuredBase;
167
454
  return {
168
455
  baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
169
456
  apiKey: apiKey || undefined,
@@ -186,6 +473,54 @@ function isOffline() {
186
473
  function isListModelsMode() {
187
474
  return process.argv.includes("--list-models");
188
475
  }
476
+ function normalizeProviderSettings(raw) {
477
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
478
+ return undefined;
479
+ const record = raw;
480
+ if (record.enabled === false)
481
+ return undefined;
482
+ return record;
483
+ }
484
+ function isFeatureEnabled(settings, feature) {
485
+ const raw = settings?.[feature];
486
+ return !isPlainObject(raw) || raw.enabled !== false;
487
+ }
488
+ function getProviderDefinitions(settings) {
489
+ const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
490
+ const providerSettings = rawProviders;
491
+ const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
492
+ const makeDefinition = (name, raw, isDefault) => ({
493
+ name,
494
+ displayName: stringSetting(raw?.displayName) ?? (isDefault ? "LiteLLM" : name),
495
+ baseUrl: stringSetting(raw?.baseUrl),
496
+ apiKeyConfig: stringSetting(raw?.apiKey),
497
+ headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
498
+ useDefaultEnv: isDefault,
499
+ useGcloudTokenAuth: isDefault,
500
+ useSavedAuth: true,
501
+ enableOAuth: isDefault,
502
+ });
503
+ const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
504
+ const usedCacheSegments = new Map();
505
+ for (const [name, raw] of Object.entries(providerSettings ?? {})) {
506
+ if (name === PROVIDER_NAME)
507
+ continue;
508
+ const normalized = normalizeProviderSettings(raw);
509
+ if (!normalized)
510
+ 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
+ definitions.push(makeDefinition(name, normalized, false));
521
+ }
522
+ return definitions;
523
+ }
189
524
  async function discoverWithFallback(baseUrl, apiKey, options) {
190
525
  try {
191
526
  return { result: await discoverModels(baseUrl, apiKey, options) };
@@ -198,7 +533,7 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
198
533
  };
199
534
  }
200
535
  }
201
- async function loginLiteLLM(callbacks, onCacheWrite) {
536
+ async function loginLiteLLM(callbacks, options) {
202
537
  const rawBaseUrl = (await callbacks.onPrompt({
203
538
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
204
539
  placeholder: "https://litellm.example.com",
@@ -229,7 +564,7 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
229
564
  if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
230
565
  try {
231
566
  callbacks.onProgress?.("Generating virtual key...");
232
- const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal);
567
+ const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal, options.headers);
233
568
  apiKey = generated.key;
234
569
  refresh = "";
235
570
  expires =
@@ -263,16 +598,19 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
263
598
  const { models, source } = await discoverModels(baseUrl, apiKey, {
264
599
  timeoutMs: LOGIN_TIMEOUT_MS,
265
600
  signal: callbacks.signal,
601
+ headers: options.headers,
602
+ onProgress: (message) => callbacks.onProgress?.(`LiteLLM: ${message}`),
266
603
  });
267
604
  const cache = {
268
605
  baseUrl,
269
606
  apiKeyFingerprint: fingerprint(refresh || apiKey),
607
+ headersFingerprint: computeHeadersFingerprint(options.headers),
270
608
  fetchedAt: Date.now(),
271
609
  source,
272
610
  models,
273
611
  };
274
- await writeCache(getCachePath(), cache);
275
- onCacheWrite?.(cache);
612
+ await writeCache(options.cachePath, cache);
613
+ await options.onCacheWrite?.(cache);
276
614
  callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
277
615
  return {
278
616
  access: apiKey,
@@ -297,7 +635,18 @@ function modifyLiteLLMModels(models, cred) {
297
635
  return models;
298
636
  return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
299
637
  }
300
- function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
638
+ function isReasoningItem(item) {
639
+ return typeof item === "object" && item !== null && item.type === "reasoning";
640
+ }
641
+ // Reasoning fields LiteLLM forwards to chat-completions providers. The Moonshot
642
+ // path defaults them off; the gpt-5.5 tool path strips them entirely.
643
+ const REASONING_SUPPRESSION_DEFAULTS = {
644
+ include_reasoning: false,
645
+ reasoning_content: false,
646
+ merge_reasoning_content_in_choices: true,
647
+ thinking: { type: "disabled" },
648
+ };
649
+ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
301
650
  let next;
302
651
  const update = (key, value) => {
303
652
  if (payload[key] !== undefined)
@@ -305,11 +654,40 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
305
654
  next ??= { ...payload };
306
655
  next[key] = value;
307
656
  };
308
- if (modelId && shouldSuppressReasoningContent(modelId)) {
309
- update("include_reasoning", false);
310
- update("reasoning_content", false);
311
- update("merge_reasoning_content_in_choices", true);
312
- update("thinking", { type: "disabled" });
657
+ if (api !== "openai-responses" && modelId && shouldSuppressReasoningContent(modelId)) {
658
+ for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS))
659
+ update(key, value);
660
+ }
661
+ // LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
662
+ // Drop reasoning until the gateway honors /v1/responses for this route.
663
+ if (api !== "openai-responses" &&
664
+ modelId &&
665
+ isGpt55Model(modelId) &&
666
+ Array.isArray(payload.tools) &&
667
+ payload.tools.length > 0) {
668
+ const reasoningKeys = ["reasoning", "reasoning_effort", ...Object.keys(REASONING_SUPPRESSION_DEFAULTS)];
669
+ for (const key of reasoningKeys) {
670
+ if (payload[key] === undefined)
671
+ continue;
672
+ next ??= { ...payload };
673
+ delete next[key];
674
+ }
675
+ const include = (next ?? payload).include;
676
+ if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
677
+ next ??= { ...payload };
678
+ const filteredInclude = include.filter((value) => value !== "reasoning.encrypted_content");
679
+ if (filteredInclude.length === 0)
680
+ delete next.include;
681
+ else
682
+ next.include = filteredInclude;
683
+ }
684
+ // Prior turns may have replayed reasoning items (with encrypted_content)
685
+ // into the input; they are rejected once reasoning is stripped.
686
+ const input = (next ?? payload).input;
687
+ if (Array.isArray(input) && input.some(isReasoningItem)) {
688
+ next ??= { ...payload };
689
+ next.input = input.filter((item) => !isReasoningItem(item));
690
+ }
313
691
  }
314
692
  if (sessionId) {
315
693
  next ??= { ...payload };
@@ -317,8 +695,8 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
317
695
  }
318
696
  return next;
319
697
  }
320
- function normalizeThinkTags(message) {
321
- if (message.provider !== PROVIDER_NAME || !shouldSuppressReasoningContent(message.model))
698
+ function normalizeThinkTags(message, litellmProviderNames) {
699
+ if (!litellmProviderNames.has(message.provider) || !shouldSuppressReasoningContent(message.model))
322
700
  return;
323
701
  let changed = false;
324
702
  const content = [];
@@ -379,120 +757,176 @@ function normalizeThinkTags(message) {
379
757
  return { ...message, content };
380
758
  }
381
759
  export default async function (pi) {
382
- let creds = await resolveCredentials({ executeHelpers: false });
383
- const cache = await readCache(getCachePath());
384
- let fp = creds.apiKeyFingerprint;
385
- let cacheFetchedAt = cache?.fetchedAt ?? 0;
386
- const cacheValid = cache !== null &&
387
- creds.baseUrl !== undefined &&
388
- fp !== undefined &&
389
- cache.baseUrl === creds.baseUrl &&
390
- cache.apiKeyFingerprint === fp;
391
- let models = cacheValid && cache ? cache.models : [];
392
- const shouldFetch = creds.baseUrl !== undefined &&
393
- fp !== undefined &&
394
- !isOffline() &&
395
- getDiscoveryTimeoutMs() > 0 &&
396
- (!cacheValid || isListModelsMode());
397
- let credentialWarning;
398
- let liveDiscoveryApiKey;
399
- if (shouldFetch) {
400
- try {
401
- creds = await resolveCredentials();
402
- fp = creds.apiKeyFingerprint;
403
- }
404
- catch (error) {
405
- if (!cacheValid || !cache)
406
- throw error;
407
- credentialWarning = error instanceof Error ? error.message : String(error);
408
- process.stderr.write(`LiteLLM: discovery failed (${credentialWarning}); using cached models.\n`);
409
- models = cache.models;
410
- }
760
+ const settings = await readGlobalLiteLLMSettings();
761
+ const definitions = getProviderDefinitions(settings);
762
+ const skillsEnabled = isFeatureEnabled(settings, "skills");
763
+ const mcpEnabled = isFeatureEnabled(settings, "mcp");
764
+ const providerNames = new Set(definitions.map((definition) => definition.name));
765
+ function discoveryDisabledReason() {
766
+ if (isOffline())
767
+ return `${ENV_OFFLINE}=1`;
768
+ if (getDiscoveryTimeoutMs() === 0)
769
+ return `${ENV_TIMEOUT}=0`;
770
+ return null;
411
771
  }
412
- if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
413
- const timeoutMs = getDiscoveryTimeoutMs();
414
- const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
415
- timeoutMs,
416
- });
417
- if (warning) {
418
- if (cacheValid && cache) {
419
- process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
420
- models = cache.models;
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;
421
795
  }
422
- else {
423
- process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
424
- models = [];
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
+ }
425
810
  }
426
811
  }
427
- else {
428
- models = result.models;
429
- liveDiscoveryApiKey = creds.apiKey;
430
- const next = {
431
- baseUrl: creds.baseUrl,
432
- apiKeyFingerprint: fp,
433
- fetchedAt: Date.now(),
434
- source: result.source,
435
- models: result.models,
436
- };
437
- await writeCache(getCachePath(), next);
438
- cacheFetchedAt = next.fetchedAt;
439
- if (isListModelsMode()) {
440
- process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
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
+ }
441
845
  }
442
846
  }
847
+ // The cache keeps raw discovery output; overrides are applied freshly at each registration.
848
+ models = await applyOverrides(definition.name, models);
849
+ return {
850
+ definition,
851
+ creds,
852
+ headers,
853
+ models,
854
+ cacheFetchedAt,
855
+ refreshOnStart,
856
+ liveDiscoveryApiKey,
857
+ refreshInProgress: null,
858
+ };
443
859
  }
860
+ const providerStates = await Promise.all(definitions.map(loadProviderState));
861
+ const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
444
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,
886
+ });
887
+ }
888
+ function defaultLoginOptions() {
889
+ 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
+ },
904
+ };
905
+ }
445
906
  const oauth = {
446
907
  name: "LiteLLM",
447
- login: (callbacks) => loginLiteLLM(callbacks, (next) => {
448
- cacheFetchedAt = next.fetchedAt;
449
- registerProvider(next.baseUrl, next.models);
450
- updateCosts(next.models);
451
- }),
908
+ login: (callbacks) => loginLiteLLM(callbacks, defaultLoginOptions()),
452
909
  refreshToken: refreshLiteLLM,
453
910
  getApiKey: getLiteLLMApiKey,
454
911
  modifyModels: modifyLiteLLMModels,
455
912
  };
456
- function registerProvider(baseUrl, models, apiKeyConfig = creds.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`) {
457
- pi.registerProvider(PROVIDER_NAME, {
458
- baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
459
- // When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
460
- // Pi's per-request auth path (ModelRegistry.getApiKeyAndHeaders) resolves provider keys via
461
- // resolveConfigValueOrThrow -> resolveConfigValueUncached, i.e. the command is re-executed on
462
- // every request (it does NOT use the process-lifetime command cache, which only applies to
463
- // resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
464
- // remain registered for `/login litellm` users. See the regression test
465
- // "re-runs the helper command on every request" in tests/index.test.ts.
466
- apiKey: apiKeyConfig,
467
- api: "openai-completions",
468
- models,
469
- oauth,
470
- });
471
- }
472
- registerProvider(creds.baseUrl, models);
473
- updateCosts = setupLiteLLMCostTracking(pi, models);
474
- let refreshInProgress = null;
475
- function discoveryDisabledReason() {
476
- if (isOffline())
477
- return `${ENV_OFFLINE}=1`;
478
- if (getDiscoveryTimeoutMs() === 0)
479
- return `${ENV_TIMEOUT}=0`;
480
- return null;
481
- }
482
- async function resolveRuntimeApiKey() {
483
- const fresh = await resolveCredentials();
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);
484
918
  if (!fresh.apiKey)
485
- throw new Error("no credentials. Run /login litellm or set env vars.");
919
+ throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
486
920
  return fresh.apiKey;
487
921
  }
488
- function registerSkillTools(baseUrl) {
489
- if (!baseUrl)
922
+ function registerSkillTools(state = defaultState) {
923
+ if (!skillsEnabled || !state.creds.baseUrl)
490
924
  return;
491
- for (const tool of createSkillToolDefinitions(baseUrl, resolveRuntimeApiKey)) {
925
+ for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
492
926
  pi.registerTool(tool);
493
927
  }
494
928
  }
495
- function seededRuntimeApiKey(seed) {
929
+ function seededRuntimeApiKey(state, seed) {
496
930
  let first = seed;
497
931
  return async () => {
498
932
  if (first) {
@@ -500,134 +934,139 @@ export default async function (pi) {
500
934
  first = undefined;
501
935
  return value;
502
936
  }
503
- return resolveRuntimeApiKey();
937
+ return resolveRuntimeApiKey(state);
504
938
  };
505
939
  }
506
- async function registerMcpTools(baseUrl, discoveryApiKey) {
507
- if (!baseUrl || !discoveryApiKey || discoveryDisabledReason())
940
+ async function registerMcpTools(state, onProgress) {
941
+ if (!mcpEnabled || !state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
508
942
  return;
509
943
  try {
510
- const tools = await createMcpToolDefinitions(baseUrl, seededRuntimeApiKey(discoveryApiKey));
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`));
511
945
  for (const tool of tools) {
512
946
  pi.registerTool(tool);
513
947
  }
514
948
  }
515
949
  catch (error) {
516
- process.stderr.write(`LiteLLM: MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
950
+ process.stderr.write(`LiteLLM (${state.definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
517
951
  }
518
952
  }
519
- registerSkillTools(creds.baseUrl);
520
- await registerMcpTools(creds.baseUrl, liveDiscoveryApiKey);
521
- async function refreshModelsAndCosts() {
522
- const fresh = await resolveCredentials();
953
+ registerSkillTools(defaultState);
954
+ async function refreshModelsAndCosts(state, onProgress, credential, signal) {
955
+ const fresh = credential ? credentialsFromRefresh(state, credential) : await resolveCredentials(state.definition);
523
956
  const freshFp = fresh.apiKeyFingerprint;
524
957
  if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
525
- throw new Error("no credentials. Run /login litellm or set env vars.");
958
+ throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
526
959
  }
527
- const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs: getDiscoveryTimeoutMs() });
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();
528
970
  const now = Date.now();
529
- await writeCache(getCachePath(), {
971
+ await writeCache(getCachePath(state.definition.name), {
530
972
  baseUrl: fresh.baseUrl,
531
973
  apiKeyFingerprint: freshFp,
974
+ headersFingerprint: computeHeadersFingerprint(state.headers),
532
975
  fetchedAt: now,
533
976
  source: result.source,
534
977
  models: result.models,
535
978
  });
536
- registerProvider(fresh.baseUrl, result.models, fresh.apiKeyConfig);
537
- updateCosts(result.models);
538
- cacheFetchedAt = now;
539
- await registerMcpTools(fresh.baseUrl, fresh.apiKey);
540
- return { models: result.models, source: result.source };
541
- }
542
- function runRefresh() {
543
- refreshInProgress ??= refreshModelsAndCosts().finally(() => {
544
- refreshInProgress = null;
545
- });
546
- return refreshInProgress;
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 };
547
990
  }
548
- async function runLogin(ctx) {
549
- const credential = await loginLiteLLM({
550
- onAuth: ({ url, instructions }) => {
551
- openInBrowser(url);
552
- ctx.ui.notify(instructions ? `${url} — ${instructions}` : url, "info");
553
- },
554
- onDeviceCode: () => undefined,
555
- onPrompt: async ({ message, placeholder }) => {
556
- const value = await ctx.ui.input(message, placeholder);
557
- if (value === undefined)
558
- throw new Error("Login cancelled");
559
- return value;
560
- },
561
- onProgress: (message) => ctx.ui.notify(message, "info"),
562
- onSelect: async () => undefined,
563
- signal: ctx.signal,
564
- }, (next) => {
565
- cacheFetchedAt = next.fetchedAt;
566
- registerProvider(next.baseUrl, next.models);
567
- updateCosts(next.models);
991
+ function runRefresh(state, onProgress, credential, signal) {
992
+ state.refreshInProgress ??= refreshModelsAndCosts(state, onProgress, credential, signal).finally(() => {
993
+ state.refreshInProgress = null;
568
994
  });
569
- ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
570
- ctx.modelRegistry.refresh();
571
- const credentialBaseUrl = credential.baseUrl;
572
- const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
573
- registerSkillTools(credentialBaseUrl);
574
- await registerMcpTools(credentialBaseUrl, credentialAccess);
575
- ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
576
- }
577
- pi.on("input", async (event, ctx) => {
578
- if (event.text.trim() !== `/login ${PROVIDER_NAME}`)
579
- return { action: "continue" };
580
- if (!ctx.hasUI)
581
- return { action: "continue" };
582
- try {
583
- await runLogin(ctx);
584
- }
585
- catch (error) {
586
- const message = error instanceof Error ? error.message : String(error);
587
- if (message !== "Login cancelled")
588
- ctx.ui.notify(`LiteLLM login failed: ${message}`, "error");
589
- }
590
- return { action: "handled" };
591
- });
995
+ return state.refreshInProgress;
996
+ }
592
997
  pi.registerCommand("litellm-refresh", {
593
998
  description: "Re-discover models from the LiteLLM proxy.",
594
- handler: async (_args, ctx) => {
999
+ handler: async (args, ctx) => {
595
1000
  const disabledReason = discoveryDisabledReason();
596
1001
  if (disabledReason) {
597
1002
  ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
598
1003
  return;
599
1004
  }
600
- try {
601
- const result = await runRefresh();
602
- ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
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;
603
1012
  }
604
- catch (error) {
605
- const message = error instanceof Error ? error.message : String(error);
606
- ctx.ui.notify(`LiteLLM refresh failed: ${message}`, "error");
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;
1027
+ }
1028
+ ctx.ui.notify(`LiteLLM: ${succeeded.length} providers refreshed (${succeeded
1029
+ .map((result) => `${result.providerName}: ${result.models.length} models`)
1030
+ .join(", ")})`, "info");
1031
+ return;
607
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
+ }
1038
+ ctx.ui.notify(`LiteLLM: refreshed ${succeeded
1039
+ .map((result) => `${result.providerName}: ${result.models.length} models`)
1040
+ .join(", ")}; failed ${failures.join("; ")}`, "warning");
608
1041
  },
609
1042
  });
610
1043
  let sessionId;
611
1044
  pi.on("session_start", (_event, ctx) => {
612
1045
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
613
- if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
1046
+ if (discoveryDisabledReason())
614
1047
  return;
615
- void runRefresh().catch(() => undefined);
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
+ }
616
1055
  });
617
1056
  pi.on("before_provider_request", (event, ctx) => {
618
- if (ctx.model?.provider !== PROVIDER_NAME)
1057
+ if (!ctx.model?.provider || !providerNames.has(ctx.model.provider))
619
1058
  return;
620
1059
  if (typeof event.payload !== "object" || event.payload === null)
621
1060
  return;
622
- return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
1061
+ return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
623
1062
  });
624
1063
  pi.on("before_agent_start", async (event) => {
625
- if (discoveryDisabledReason())
1064
+ if (!skillsEnabled || discoveryDisabledReason())
626
1065
  return;
627
- const fresh = await resolveCredentials();
1066
+ const fresh = await resolveCredentials(defaultState.definition);
628
1067
  if (!fresh.baseUrl || !fresh.apiKey)
629
1068
  return;
630
- const skills = await listSkills(fresh.baseUrl, fresh.apiKey);
1069
+ const skills = await listSkills(fresh.baseUrl, fresh.apiKey, defaultState.headers);
631
1070
  const section = createSkillsPromptSection(skills);
632
1071
  if (!section)
633
1072
  return;
@@ -636,7 +1075,7 @@ export default async function (pi) {
636
1075
  pi.on("message_end", (event) => {
637
1076
  if (event.message.role !== "assistant")
638
1077
  return;
639
- const message = normalizeThinkTags(event.message);
1078
+ const message = normalizeThinkTags(event.message, providerNames);
640
1079
  if (!message)
641
1080
  return;
642
1081
  return { message };