pi-provider-litellm 1.2.8 → 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
@@ -4,15 +4,17 @@ 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, 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;
@@ -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)
@@ -85,12 +233,13 @@ function openInBrowser(url) {
85
233
  .on("error", () => undefined)
86
234
  .unref();
87
235
  }
88
- async function generateVirtualKey(baseUrl, userToken, signal) {
236
+ async function generateVirtualKey(baseUrl, userToken, signal, headers) {
89
237
  const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
90
238
  try {
91
239
  const response = await fetch(`${baseUrl}/key/generate`, {
92
240
  method: "POST",
93
241
  headers: {
242
+ ...headers,
94
243
  Authorization: `Bearer ${userToken}`,
95
244
  "Content-Type": "application/json",
96
245
  },
@@ -119,22 +268,149 @@ function resolveOAuthApiKey(credentials) {
119
268
  ? executeApiKeyCommand(credentials.refresh)
120
269
  : getLiteLLMApiKey(credentials);
121
270
  }
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();
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();
128
393
  const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
129
394
  const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
130
395
  const authKey = entry?.type === "oauth"
131
396
  ? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
132
397
  : entry?.type === "api_key"
133
- ? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
398
+ ? (await AuthStorage.create(getAuthPath()).getApiKey(definition.name, { includeFallback: false }))?.trim()
134
399
  : undefined;
135
400
  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;
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;
138
414
  let apiKeyFingerprint;
139
415
  let apiKeyConfig;
140
416
  if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
@@ -151,6 +427,14 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
151
427
  apiKeyFingerprint = fingerprint(gcloudCacheKey);
152
428
  apiKeyConfig = getGcloudTokenCommand();
153
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
+ }
154
438
  else if (helperKey && envHelperCommand) {
155
439
  apiKeyFingerprint = fingerprint(envHelperCommand);
156
440
  apiKeyConfig = envHelperCommand;
@@ -163,7 +447,7 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
163
447
  apiKeyFingerprint = fingerprint(envKey);
164
448
  apiKeyConfig = `$${ENV_API_KEY}`;
165
449
  }
166
- const rawBase = authBase || envBase;
450
+ const rawBase = authBase || configuredBase;
167
451
  return {
168
452
  baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
169
453
  apiKey: apiKey || undefined,
@@ -186,6 +470,51 @@ function isOffline() {
186
470
  function isListModelsMode() {
187
471
  return process.argv.includes("--list-models");
188
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
+ }
189
518
  async function discoverWithFallback(baseUrl, apiKey, options) {
190
519
  try {
191
520
  return { result: await discoverModels(baseUrl, apiKey, options) };
@@ -198,7 +527,7 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
198
527
  };
199
528
  }
200
529
  }
201
- async function loginLiteLLM(callbacks, onCacheWrite) {
530
+ async function loginLiteLLM(callbacks, options) {
202
531
  const rawBaseUrl = (await callbacks.onPrompt({
203
532
  message: "Enter LiteLLM proxy URL (no trailing /v1):",
204
533
  placeholder: "https://litellm.example.com",
@@ -229,7 +558,7 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
229
558
  if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
230
559
  try {
231
560
  callbacks.onProgress?.("Generating virtual key...");
232
- const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal);
561
+ const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal, options.headers);
233
562
  apiKey = generated.key;
234
563
  refresh = "";
235
564
  expires =
@@ -263,16 +592,18 @@ async function loginLiteLLM(callbacks, onCacheWrite) {
263
592
  const { models, source } = await discoverModels(baseUrl, apiKey, {
264
593
  timeoutMs: LOGIN_TIMEOUT_MS,
265
594
  signal: callbacks.signal,
595
+ headers: options.headers,
266
596
  });
267
597
  const cache = {
268
598
  baseUrl,
269
599
  apiKeyFingerprint: fingerprint(refresh || apiKey),
600
+ headersFingerprint: computeHeadersFingerprint(options.headers),
270
601
  fetchedAt: Date.now(),
271
602
  source,
272
603
  models,
273
604
  };
274
- await writeCache(getCachePath(), cache);
275
- onCacheWrite?.(cache);
605
+ await writeCache(options.cachePath, cache);
606
+ await options.onCacheWrite?.(cache);
276
607
  callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
277
608
  return {
278
609
  access: apiKey,
@@ -297,7 +628,18 @@ function modifyLiteLLMModels(models, cred) {
297
628
  return models;
298
629
  return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
299
630
  }
300
- 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) {
301
643
  let next;
302
644
  const update = (key, value) => {
303
645
  if (payload[key] !== undefined)
@@ -305,11 +647,40 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
305
647
  next ??= { ...payload };
306
648
  next[key] = value;
307
649
  };
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" });
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
+ }
313
684
  }
314
685
  if (sessionId) {
315
686
  next ??= { ...payload };
@@ -317,8 +688,8 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
317
688
  }
318
689
  return next;
319
690
  }
320
- function normalizeThinkTags(message) {
321
- if (message.provider !== PROVIDER_NAME || !shouldSuppressReasoningContent(message.model))
691
+ function normalizeThinkTags(message, litellmProviderNames) {
692
+ if (!litellmProviderNames.has(message.provider) || !shouldSuppressReasoningContent(message.model))
322
693
  return;
323
694
  let changed = false;
324
695
  const content = [];
@@ -379,120 +750,162 @@ function normalizeThinkTags(message) {
379
750
  return { ...message, content };
380
751
  }
381
752
  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
- }
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;
411
761
  }
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;
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;
421
787
  }
422
- else {
423
- process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
424
- 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
+ }
425
802
  }
426
803
  }
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`);
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
+ }
441
836
  }
442
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 };
443
841
  }
842
+ const providerStates = await Promise.all(definitions.map(loadProviderState));
843
+ const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
444
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
+ }
445
885
  const oauth = {
446
886
  name: "LiteLLM",
447
- login: (callbacks) => loginLiteLLM(callbacks, (next) => {
448
- cacheFetchedAt = next.fetchedAt;
449
- registerProvider(next.baseUrl, next.models);
450
- updateCosts(next.models);
451
- }),
887
+ login: (callbacks) => loginLiteLLM(callbacks, defaultLoginOptions()),
452
888
  refreshToken: refreshLiteLLM,
453
889
  getApiKey: getLiteLLMApiKey,
454
890
  modifyModels: modifyLiteLLMModels,
455
891
  };
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();
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);
484
897
  if (!fresh.apiKey)
485
- 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.`);
486
899
  return fresh.apiKey;
487
900
  }
488
- function registerSkillTools(baseUrl) {
489
- if (!baseUrl)
901
+ function registerSkillTools(state = defaultState) {
902
+ if (!state.creds.baseUrl)
490
903
  return;
491
- for (const tool of createSkillToolDefinitions(baseUrl, resolveRuntimeApiKey)) {
904
+ for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
492
905
  pi.registerTool(tool);
493
906
  }
494
907
  }
495
- function seededRuntimeApiKey(seed) {
908
+ function seededRuntimeApiKey(state, seed) {
496
909
  let first = seed;
497
910
  return async () => {
498
911
  if (first) {
@@ -500,50 +913,59 @@ export default async function (pi) {
500
913
  first = undefined;
501
914
  return value;
502
915
  }
503
- return resolveRuntimeApiKey();
916
+ return resolveRuntimeApiKey(state);
504
917
  };
505
918
  }
506
- async function registerMcpTools(baseUrl, discoveryApiKey) {
507
- if (!baseUrl || !discoveryApiKey || discoveryDisabledReason())
919
+ async function registerMcpTools(state) {
920
+ if (!state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
508
921
  return;
509
922
  try {
510
- const tools = await createMcpToolDefinitions(baseUrl, seededRuntimeApiKey(discoveryApiKey));
923
+ const tools = await createMcpToolDefinitions(state.creds.baseUrl, seededRuntimeApiKey(state, state.liveDiscoveryApiKey), state.headers);
511
924
  for (const tool of tools) {
512
925
  pi.registerTool(tool);
513
926
  }
514
927
  }
515
928
  catch (error) {
516
- 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`);
517
930
  }
518
931
  }
519
- registerSkillTools(creds.baseUrl);
520
- await registerMcpTools(creds.baseUrl, liveDiscoveryApiKey);
521
- async function refreshModelsAndCosts() {
522
- const fresh = await resolveCredentials();
932
+ registerSkillTools(defaultState);
933
+ await registerMcpTools(defaultState);
934
+ async function refreshModelsAndCosts(state) {
935
+ const fresh = await resolveCredentials(state.definition);
523
936
  const freshFp = fresh.apiKeyFingerprint;
524
937
  if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
525
- 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.`);
526
939
  }
527
- 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
+ });
528
944
  const now = Date.now();
529
- await writeCache(getCachePath(), {
945
+ await writeCache(getCachePath(state.definition.name), {
530
946
  baseUrl: fresh.baseUrl,
531
947
  apiKeyFingerprint: freshFp,
948
+ headersFingerprint: computeHeadersFingerprint(state.headers),
532
949
  fetchedAt: now,
533
950
  source: result.source,
534
951
  models: result.models,
535
952
  });
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;
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;
545
967
  });
546
- return refreshInProgress;
968
+ return state.refreshInProgress;
547
969
  }
548
970
  async function runLogin(ctx) {
549
971
  const credential = await loginLiteLLM({
@@ -561,17 +983,15 @@ export default async function (pi) {
561
983
  onProgress: (message) => ctx.ui.notify(message, "info"),
562
984
  onSelect: async () => undefined,
563
985
  signal: ctx.signal,
564
- }, (next) => {
565
- cacheFetchedAt = next.fetchedAt;
566
- registerProvider(next.baseUrl, next.models);
567
- updateCosts(next.models);
568
- });
986
+ }, defaultLoginOptions());
569
987
  ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
570
988
  ctx.modelRegistry.refresh();
571
989
  const credentialBaseUrl = credential.baseUrl;
572
990
  const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
573
- registerSkillTools(credentialBaseUrl);
574
- await registerMcpTools(credentialBaseUrl, credentialAccess);
991
+ defaultState.creds = { ...defaultState.creds, baseUrl: credentialBaseUrl };
992
+ defaultState.liveDiscoveryApiKey = credentialAccess;
993
+ registerSkillTools(defaultState);
994
+ await registerMcpTools(defaultState);
575
995
  ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
576
996
  }
577
997
  pi.on("input", async (event, ctx) => {
@@ -591,43 +1011,75 @@ export default async function (pi) {
591
1011
  });
592
1012
  pi.registerCommand("litellm-refresh", {
593
1013
  description: "Re-discover models from the LiteLLM proxy.",
594
- handler: async (_args, ctx) => {
1014
+ handler: async (args, ctx) => {
595
1015
  const disabledReason = discoveryDisabledReason();
596
1016
  if (disabledReason) {
597
1017
  ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
598
1018
  return;
599
1019
  }
600
- try {
601
- const result = await runRefresh();
602
- 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;
603
1027
  }
604
- catch (error) {
605
- const message = error instanceof Error ? error.message : String(error);
606
- 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;
607
1047
  }
1048
+ const failures = failed.map(({ name, message }) => (settled.length === 1 ? message : `${name}: ${message}`));
1049
+ if (succeeded.length === 0) {
1050
+ ctx.ui.notify(`LiteLLM refresh failed: ${failures.join("; ")}`, "error");
1051
+ return;
1052
+ }
1053
+ ctx.ui.notify(`LiteLLM: refreshed ${succeeded
1054
+ .map((result) => `${result.providerName}: ${result.models.length} models`)
1055
+ .join(", ")}; failed ${failures.join("; ")}`, "warning");
608
1056
  },
609
1057
  });
610
1058
  let sessionId;
611
1059
  pi.on("session_start", (_event, ctx) => {
612
1060
  sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
613
- if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
1061
+ if (discoveryDisabledReason())
614
1062
  return;
615
- 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
+ }
616
1068
  });
617
1069
  pi.on("before_provider_request", (event, ctx) => {
618
- if (ctx.model?.provider !== PROVIDER_NAME)
1070
+ if (!ctx.model?.provider || !providerNames.has(ctx.model.provider))
619
1071
  return;
620
1072
  if (typeof event.payload !== "object" || event.payload === null)
621
1073
  return;
622
- return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
1074
+ return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
623
1075
  });
624
1076
  pi.on("before_agent_start", async (event) => {
625
1077
  if (discoveryDisabledReason())
626
1078
  return;
627
- const fresh = await resolveCredentials();
1079
+ const fresh = await resolveCredentials(defaultState.definition);
628
1080
  if (!fresh.baseUrl || !fresh.apiKey)
629
1081
  return;
630
- const skills = await listSkills(fresh.baseUrl, fresh.apiKey);
1082
+ const skills = await listSkills(fresh.baseUrl, fresh.apiKey, defaultState.headers);
631
1083
  const section = createSkillsPromptSection(skills);
632
1084
  if (!section)
633
1085
  return;
@@ -636,7 +1088,7 @@ export default async function (pi) {
636
1088
  pi.on("message_end", (event) => {
637
1089
  if (event.message.role !== "assistant")
638
1090
  return;
639
- const message = normalizeThinkTags(event.message);
1091
+ const message = normalizeThinkTags(event.message, providerNames);
640
1092
  if (!message)
641
1093
  return;
642
1094
  return { message };