pi-provider-litellm 4.2.0 → 4.3.1

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/src/index.ts CHANGED
@@ -20,9 +20,23 @@ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
20
20
  import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
21
21
  import { budgetDisplaySetting, setupLiteLLMBudget } from "./budget.js";
22
22
  import { setupLiteLLMCostTracking } from "./cost.js";
23
- import { discoverModels, isGpt6OrNewerModel, isGpt55OrNewerModel, normalizeBaseUrl } from "./discover.js";
23
+ import {
24
+ discoverModels,
25
+ isGpt6OrNewerModel,
26
+ isGpt55OrNewerModel,
27
+ normalizeBaseUrl,
28
+ trimTrailingSlashes,
29
+ } from "./discover.js";
24
30
  import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
25
- import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
31
+ import type { LiteLLMImageModel } from "./images.js";
32
+ import { readStoredModels } from "./models-store.js";
33
+ import {
34
+ createLiteLLMProvider,
35
+ DEFAULT_LITELLM_BASE_URL,
36
+ isPlaceholderHost,
37
+ toNativeImageModels,
38
+ toNativeModels,
39
+ } from "./provider.js";
26
40
  import { parseProxyVersion, proxyVersionAtLeast } from "./proxy-version.js";
27
41
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
28
42
  import type {
@@ -36,9 +50,13 @@ import type {
36
50
 
37
51
  const PROVIDER_NAME = "litellm";
38
52
  const SETTINGS_KEY = "litellm";
53
+ const SETTINGS_FILENAME = "settings.json";
54
+ const MODELS_FILENAME = "models.json";
39
55
  const ENV_BASE_URL = "LITELLM_BASE_URL";
40
56
  const ENV_API_KEY = "LITELLM_API_KEY";
41
57
  const ENV_DISPLAY_NAME = "LITELLM_DISPLAY_NAME";
58
+ const ENV_PROVIDERS = "LITELLM_PROVIDERS";
59
+ const ENV_PROVIDERS_JSON = "LITELLM_PROVIDERS_JSON";
42
60
  const GCLOUD_ADC_SOURCE = "gcloud ADC";
43
61
  const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
44
62
  const ENV_HEADERS = "LITELLM_HEADERS";
@@ -72,10 +90,14 @@ type RawProviderSettings = {
72
90
  headers?: unknown;
73
91
  enabled?: unknown;
74
92
  allowInsecureHttp?: unknown;
93
+ useGcloudTokenAuth?: unknown;
94
+ enableOAuth?: unknown;
75
95
  oidc?: unknown;
96
+ enableMcp?: unknown;
97
+ mcp?: unknown;
76
98
  };
77
99
 
78
- type ProviderDefinition = {
100
+ export type ProviderDefinition = {
79
101
  name: string;
80
102
  displayName: string;
81
103
  baseUrl?: string;
@@ -87,16 +109,152 @@ type ProviderDefinition = {
87
109
  allowInsecureHttp: boolean;
88
110
  /** Raw `oidc` setting, validated at login so a bad value never breaks startup. */
89
111
  oidc?: unknown;
112
+ envPrefix?: string;
113
+ enableMcp?: boolean;
90
114
  };
91
115
 
116
+ const RESERVED_PROVIDER_NAMES = new Set(["mcp", "skills", "codemode", "tool_search", "litellm"]);
117
+ const PROVIDER_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
118
+ const PROVIDER_ENV_VAR_REGEX =
119
+ /^LITELLM_PROVIDER_([A-Z0-9_]+?)_(BASE_URL|API_KEY_HELPER|API_KEY|HEADERS|DISPLAY_NAME|NAME|ALLOW_INSECURE_HTTP|USE_GCLOUD_AUTH|ENABLE_OAUTH|ENABLE_MCP|MCP_ENABLED)$/;
120
+
121
+ export function isValidProviderName(name: string): boolean {
122
+ if (typeof name !== "string") return false;
123
+ if (!PROVIDER_NAME_REGEX.test(name)) return false;
124
+ if (RESERVED_PROVIDER_NAMES.has(name)) return false;
125
+ return true;
126
+ }
127
+
128
+ export function parseCanonicalProviderList(raw: string | undefined): string[] {
129
+ if (typeof raw !== "string") return [];
130
+ const trimmed = raw.trim();
131
+ if (!trimmed) return [];
132
+ return trimmed
133
+ .split(/[\s,]+/)
134
+ .map((item) => item.trim())
135
+ .filter(Boolean);
136
+ }
137
+
138
+ export function parseProvidersJson(raw: string | undefined): Record<string, RawProviderSettings> | undefined {
139
+ if (typeof raw !== "string") return undefined;
140
+ const trimmed = raw.trim();
141
+ if (!trimmed) return undefined;
142
+ try {
143
+ const parsed = JSON.parse(trimmed);
144
+ if (!isPlainObject(parsed)) return undefined;
145
+ const result: Record<string, RawProviderSettings> = {};
146
+ for (const [key, value] of Object.entries(parsed)) {
147
+ if (isPlainObject(value)) {
148
+ result[key] = value as RawProviderSettings;
149
+ }
150
+ }
151
+ return Object.keys(result).length > 0 ? result : undefined;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ function parseBooleanSetting(value: unknown): boolean {
158
+ if (typeof value === "boolean") return value;
159
+ if (typeof value === "string") {
160
+ const trimmed = value.trim().toLowerCase();
161
+ return trimmed === "1" || trimmed === "true";
162
+ }
163
+ return false;
164
+ }
165
+
166
+ export type ParsedProviderEnv = {
167
+ token: string;
168
+ envPrefix: string;
169
+ name?: string;
170
+ displayName?: string;
171
+ baseUrl?: string;
172
+ apiKey?: string;
173
+ apiKeyHelper?: string;
174
+ headers?: unknown;
175
+ allowInsecureHttp?: boolean;
176
+ useGcloudTokenAuth?: boolean;
177
+ enableOAuth?: boolean;
178
+ enableMcp?: boolean;
179
+ };
180
+
181
+ export function parseProviderEnvVars(env: NodeJS.ProcessEnv): Record<string, ParsedProviderEnv> {
182
+ const result: Record<string, ParsedProviderEnv> = {};
183
+ for (const key of Object.keys(env)) {
184
+ const match = key.match(PROVIDER_ENV_VAR_REGEX);
185
+ if (!match) continue;
186
+ const token = match[1]!;
187
+ const field = match[2]!;
188
+ const val = env[key];
189
+ if (val === undefined) continue;
190
+
191
+ if (!result[token]) {
192
+ result[token] = {
193
+ token,
194
+ envPrefix: `LITELLM_PROVIDER_${token}`,
195
+ };
196
+ }
197
+ const entry = result[token]!;
198
+ switch (field) {
199
+ case "BASE_URL":
200
+ entry.baseUrl = cleanConfig(val);
201
+ break;
202
+ case "API_KEY":
203
+ entry.apiKey = cleanConfig(val);
204
+ break;
205
+ case "API_KEY_HELPER":
206
+ entry.apiKeyHelper = cleanConfig(val);
207
+ break;
208
+ case "HEADERS":
209
+ entry.headers = cleanConfig(val);
210
+ break;
211
+ case "DISPLAY_NAME":
212
+ entry.displayName = cleanConfig(val);
213
+ break;
214
+ case "NAME":
215
+ entry.name = cleanConfig(val);
216
+ break;
217
+ case "ALLOW_INSECURE_HTTP":
218
+ entry.allowInsecureHttp = parseBooleanSetting(val);
219
+ break;
220
+ case "USE_GCLOUD_AUTH":
221
+ entry.useGcloudTokenAuth = parseBooleanSetting(val);
222
+ break;
223
+ case "ENABLE_OAUTH":
224
+ entry.enableOAuth = parseBooleanSetting(val);
225
+ break;
226
+ case "ENABLE_MCP":
227
+ case "MCP_ENABLED":
228
+ entry.enableMcp = parseBooleanSetting(val);
229
+ break;
230
+ }
231
+ }
232
+ return result;
233
+ }
234
+
92
235
  function isPlainObject(value: unknown): value is Record<string, unknown> {
93
236
  return typeof value === "object" && value !== null && !Array.isArray(value);
94
237
  }
95
238
 
239
+ /** Remove a leading UTF-8 byte order mark, as Pi's own `stripBom` does. */
240
+ function stripBom(content: string): string {
241
+ return content.startsWith("\uFEFF") ? content.slice(1) : content;
242
+ }
243
+
244
+ /** Pi's `stripJsonComments`, mirrored so a file Pi accepts is never silently ignored here. */
245
+ function stripJsonComments(input: string): string {
246
+ return input
247
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => (match[0] === '"' ? match : ""))
248
+ .replace(
249
+ /"(?:\\.|[^"\\])*"|,(\s*[}\]])/g,
250
+ (match, tail: string | undefined) => tail ?? (match[0] === '"' ? match : ""),
251
+ );
252
+ }
253
+
96
254
  async function readGlobalLiteLLMSettings(): Promise<Record<string, unknown> | undefined> {
97
255
  try {
98
- const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
99
- const parsed = JSON.parse(raw) as Record<string, unknown>;
256
+ const raw = await readFile(join(getAgentDir(), SETTINGS_FILENAME), "utf8");
257
+ const parsed = JSON.parse(stripBom(raw)) as Record<string, unknown>;
100
258
  const settings = parsed[SETTINGS_KEY];
101
259
  return settings && typeof settings === "object" && !Array.isArray(settings)
102
260
  ? (settings as Record<string, unknown>)
@@ -106,6 +264,45 @@ async function readGlobalLiteLLMSettings(): Promise<Record<string, unknown> | un
106
264
  }
107
265
  }
108
266
 
267
+ // Pi's own provider file. Only `providers` is read: `skills`/`mcp`/`budget` stay settings.json-only,
268
+ // so a provider block here cannot switch a feature off. Aliases mirror settings.json's shape, and
269
+ // the native `providers.litellm` slot wins when a file sets both. Global agent dir only.
270
+ async function readModelsProviderSettings(): Promise<Record<string, unknown> | undefined> {
271
+ try {
272
+ // models.json is JSONC, unlike settings.json; a strict parse would drop a config Pi accepts.
273
+ const raw = await readFile(join(getAgentDir(), MODELS_FILENAME), "utf8");
274
+ const parsed = JSON.parse(stripJsonComments(stripBom(raw))) as Record<string, unknown>;
275
+ const providers: Record<string, unknown> = {};
276
+ const aliases = isPlainObject(parsed[SETTINGS_KEY]) ? parsed[SETTINGS_KEY].providers : undefined;
277
+ if (isPlainObject(aliases)) Object.assign(providers, aliases);
278
+ const native = isPlainObject(parsed.providers) ? parsed.providers[PROVIDER_NAME] : undefined;
279
+ if (isPlainObject(native)) {
280
+ const existing = providers[PROVIDER_NAME];
281
+ providers[PROVIDER_NAME] = isPlainObject(existing) ? { ...existing, ...native } : native;
282
+ }
283
+ return Object.keys(providers).length > 0 ? { providers } : undefined;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ // A provider block may live in either file; merge per field with models.json winning. Non-provider
290
+ // settings.json keys pass through untouched, which is what keeps `skills`/`mcp`/`budget` working.
291
+ function mergeProviderSettings(
292
+ settings: Record<string, unknown> | undefined,
293
+ models: Record<string, unknown> | undefined,
294
+ ): Record<string, unknown> | undefined {
295
+ const modelsProviders = isPlainObject(models?.providers) ? models.providers : undefined;
296
+ if (!modelsProviders) return settings;
297
+ const settingsProviders = isPlainObject(settings?.providers) ? settings.providers : undefined;
298
+ const providers: Record<string, unknown> = { ...settingsProviders };
299
+ for (const [name, raw] of Object.entries(modelsProviders)) {
300
+ const base = settingsProviders?.[name];
301
+ providers[name] = isPlainObject(base) && isPlainObject(raw) ? { ...base, ...raw } : raw;
302
+ }
303
+ return { ...settings, providers };
304
+ }
305
+
109
306
  function cleanConfig(raw: string | undefined): string | undefined {
110
307
  const trimmed = raw?.trim();
111
308
  return trimmed && trimmed !== "undefined" ? trimmed : undefined;
@@ -126,6 +323,7 @@ function resolveCredentialRoot(
126
323
  credentialBaseUrl ??
127
324
  cleanConfig(requestBaseUrl) ??
128
325
  cleanConfig(definition.baseUrl) ??
326
+ (definition.envPrefix ? cleanConfig(process.env[`${definition.envPrefix}_BASE_URL`]) : undefined) ??
129
327
  (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
130
328
  return baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined;
131
329
  }
@@ -376,28 +574,38 @@ function parseCustomHeaders(raw: string | undefined): Record<string, string> | u
376
574
  }
377
575
 
378
576
  function resolveHeaders(definition: ProviderDefinition): Record<string, string> | undefined {
379
- if (typeof definition.headers === "string") return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
380
- return parseHeaderRecord(definition.headers);
577
+ const rawHeaders =
578
+ definition.headers ??
579
+ (definition.envPrefix ? process.env[`${definition.envPrefix}_HEADERS`] : undefined) ??
580
+ (definition.useDefaultEnv ? process.env[ENV_HEADERS] : undefined);
581
+ if (typeof rawHeaders === "string") return parseCustomHeaders(resolveTemplateConfigValue(rawHeaders));
582
+ return parseHeaderRecord(rawHeaders);
381
583
  }
382
584
 
383
585
  async function resolveHeadersFromContext(
384
586
  definition: ProviderDefinition,
385
587
  env: (name: string) => Promise<string | undefined>,
386
588
  ): Promise<Record<string, string> | undefined> {
387
- if (typeof definition.headers === "string")
388
- return parseCustomHeaders(await resolveTemplateConfigValueFromContext(definition.headers, env));
389
- return parseHeaderRecord(definition.headers);
589
+ const rawHeaders =
590
+ definition.headers ??
591
+ (definition.envPrefix ? await env(`${definition.envPrefix}_HEADERS`) : undefined) ??
592
+ (definition.useDefaultEnv ? await env(ENV_HEADERS) : undefined);
593
+ if (typeof rawHeaders === "string")
594
+ return parseCustomHeaders(await resolveTemplateConfigValueFromContext(rawHeaders, env));
595
+ return parseHeaderRecord(rawHeaders);
390
596
  }
391
597
 
392
- async function resolveCredentials(
598
+ export async function resolveCredentials(
393
599
  definition: ProviderDefinition,
394
600
  { executeHelpers = true } = {},
395
601
  ): Promise<ResolvedCredentials> {
396
602
  const configuredBase =
397
- cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
398
- const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
399
- const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
400
- const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
603
+ cleanConfig(definition.baseUrl) ??
604
+ (definition.envPrefix ? cleanConfig(process.env[`${definition.envPrefix}_BASE_URL`]) : undefined) ??
605
+ (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
606
+ const useGcloudToken = definition.useDefaultEnv
607
+ ? definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled()
608
+ : definition.useGcloudTokenAuth;
401
609
  const gcloudKey = executeHelpers && useGcloudToken ? (await getGcloudToken())?.trim() : undefined;
402
610
  // Resolved lazily so a `!command` key is not executed when a
403
611
  // higher-precedence credential (saved auth, gcloud token) already won.
@@ -408,10 +616,34 @@ async function resolveCredentials(
408
616
  warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
409
617
  }
410
618
  }
411
- const helperKey =
412
- !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
413
- ? executeApiKeyCommand(envHelperCommand)
414
- : undefined;
619
+
620
+ let helperKey: string | undefined;
621
+ let helperCommand: string | undefined;
622
+ let envKey: string | undefined;
623
+ let envKeySource: string | undefined;
624
+
625
+ if (!gcloudKey && !configuredKey && definition.envPrefix) {
626
+ helperCommand = normalizeCommand(process.env[`${definition.envPrefix}_API_KEY_HELPER`]);
627
+ if (helperCommand && executeHelpers) {
628
+ helperKey = executeApiKeyCommand(helperCommand);
629
+ }
630
+ if (!helperCommand) {
631
+ envKey = cleanConfig(process.env[`${definition.envPrefix}_API_KEY`]);
632
+ if (envKey) envKeySource = `$${definition.envPrefix}_API_KEY`;
633
+ }
634
+ }
635
+
636
+ if (!gcloudKey && !configuredKey && !helperKey && !helperCommand && !envKey && definition.useDefaultEnv) {
637
+ helperCommand = getApiKeyHelperCommand();
638
+ if (helperCommand && executeHelpers) {
639
+ helperKey = executeApiKeyCommand(helperCommand);
640
+ }
641
+ if (!helperCommand) {
642
+ envKey = cleanConfig(process.env[ENV_API_KEY]);
643
+ if (envKey) envKeySource = `$${ENV_API_KEY}`;
644
+ }
645
+ }
646
+
415
647
  const apiKey = gcloudKey || configuredKey || helperKey || envKey;
416
648
 
417
649
  let apiKeyConfig: string | undefined;
@@ -419,12 +651,12 @@ async function resolveCredentials(
419
651
  apiKeyConfig = definition.apiKeyConfig;
420
652
  } else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
421
653
  apiKeyConfig = definition.apiKeyConfig;
422
- } else if (helperKey && envHelperCommand) {
423
- apiKeyConfig = envHelperCommand;
424
- } else if (!executeHelpers && envHelperCommand) {
425
- apiKeyConfig = envHelperCommand;
654
+ } else if (helperKey && helperCommand) {
655
+ apiKeyConfig = helperCommand;
656
+ } else if (!executeHelpers && helperCommand) {
657
+ apiKeyConfig = helperCommand;
426
658
  } else if (envKey) {
427
- apiKeyConfig = `$${ENV_API_KEY}`;
659
+ apiKeyConfig = envKeySource;
428
660
  }
429
661
  return {
430
662
  baseUrl: configuredBase ? normalizeBaseUrl(configuredBase, definition.allowInsecureHttp) : undefined,
@@ -486,7 +718,20 @@ function normalizeProviderSettings(raw: unknown): RawProviderSettings | undefine
486
718
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
487
719
  const record = raw as RawProviderSettings;
488
720
  if (record.enabled === false) return undefined;
489
- return record;
721
+ const enableMcp =
722
+ record.enableMcp !== undefined
723
+ ? parseBooleanSetting(record.enableMcp)
724
+ : isPlainObject(record.mcp)
725
+ ? record.mcp.enabled !== undefined
726
+ ? parseBooleanSetting(record.mcp.enabled)
727
+ : undefined
728
+ : record.mcp !== undefined
729
+ ? parseBooleanSetting(record.mcp)
730
+ : undefined;
731
+ return {
732
+ ...record,
733
+ ...(enableMcp !== undefined ? { enableMcp } : {}),
734
+ };
490
735
  }
491
736
 
492
737
  function isFeatureEnabled(
@@ -497,35 +742,191 @@ function isFeatureEnabled(
497
742
  return !isPlainObject(raw) || raw.enabled !== false;
498
743
  }
499
744
 
500
- function getProviderDefinitions(settings: Record<string, unknown> | undefined): ProviderDefinition[] {
745
+ type IntermediateProvider = {
746
+ name: string;
747
+ displayName?: string;
748
+ baseUrl?: string;
749
+ apiKeyConfig?: string;
750
+ headers?: unknown;
751
+ allowInsecureHttp?: boolean;
752
+ useGcloudTokenAuth?: boolean;
753
+ enableOAuth?: boolean;
754
+ envPrefix?: string;
755
+ enableMcp?: boolean;
756
+ };
757
+
758
+ export function getProviderDefinitions(settings: Record<string, unknown> | undefined): ProviderDefinition[] {
501
759
  const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
502
760
  const providerSettings = rawProviders as Record<string, unknown> | undefined;
503
761
  const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
504
762
  const defaultDisplayName = cleanConfig(process.env[ENV_DISPLAY_NAME]) ?? "LiteLLM";
763
+ const parsedEnv = parseProviderEnvVars(process.env);
764
+ const primaryEnv = parsedEnv[PROVIDER_NAME.toUpperCase()];
765
+
766
+ const primaryDefinition: ProviderDefinition = {
767
+ name: PROVIDER_NAME,
768
+ displayName: stringSetting(defaultSettings?.displayName) ?? defaultDisplayName,
769
+ baseUrl: stringSetting(defaultSettings?.baseUrl),
770
+ apiKeyConfig: stringSetting(defaultSettings?.apiKey),
771
+ headers: defaultSettings?.headers ?? `$${ENV_HEADERS}`,
772
+ useDefaultEnv: true,
773
+ useGcloudTokenAuth: true,
774
+ enableOAuth: true,
775
+ allowInsecureHttp: defaultSettings?.allowInsecureHttp === true,
776
+ oidc: defaultSettings?.oidc,
777
+ enableMcp: typeof defaultSettings?.enableMcp === "boolean" ? defaultSettings.enableMcp : primaryEnv?.enableMcp,
778
+ };
505
779
 
506
- const makeDefinition = (
507
- name: string,
508
- raw: RawProviderSettings | undefined,
509
- isDefault: boolean,
510
- ): ProviderDefinition => ({
511
- name,
512
- displayName: stringSetting(raw?.displayName) ?? (isDefault ? defaultDisplayName : name),
513
- baseUrl: stringSetting(raw?.baseUrl),
514
- apiKeyConfig: stringSetting(raw?.apiKey),
515
- headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
516
- useDefaultEnv: isDefault,
517
- useGcloudTokenAuth: isDefault,
518
- enableOAuth: isDefault,
519
- allowInsecureHttp: raw?.allowInsecureHttp === true,
520
- oidc: isDefault ? raw?.oidc : undefined,
521
- });
780
+ const secondaryProviders = new Map<string, IntermediateProvider>();
781
+ const disabledProviders = new Set<string>();
782
+
783
+ // 1. Incorporate LITELLM_PROVIDERS_JSON
784
+ const jsonProviders = parseProvidersJson(process.env[ENV_PROVIDERS_JSON]);
785
+ if (jsonProviders) {
786
+ for (const [name, raw] of Object.entries(jsonProviders)) {
787
+ if (!isValidProviderName(name)) continue;
788
+ if (raw.enabled === false) {
789
+ disabledProviders.add(name);
790
+ continue;
791
+ }
792
+ const normalized = normalizeProviderSettings(raw);
793
+ if (!normalized) continue;
794
+ secondaryProviders.set(name, {
795
+ name,
796
+ displayName: stringSetting(normalized.displayName),
797
+ baseUrl: stringSetting(normalized.baseUrl),
798
+ apiKeyConfig: stringSetting(normalized.apiKey),
799
+ headers: normalized.headers,
800
+ allowInsecureHttp: parseBooleanSetting(normalized.allowInsecureHttp),
801
+ useGcloudTokenAuth: parseBooleanSetting(normalized.useGcloudTokenAuth),
802
+ enableOAuth: parseBooleanSetting(normalized.enableOAuth),
803
+ enableMcp: typeof normalized.enableMcp === "boolean" ? normalized.enableMcp : undefined,
804
+ });
805
+ }
806
+ }
807
+
808
+ // 2. Scan env vars
809
+ const claimedTokens = new Set<string>([PROVIDER_NAME.toUpperCase()]);
810
+
811
+ // 3. Declared IDs claim their env token before step 4 derives new IDs: LITELLM_PROVIDERS first, then JSON keys,
812
+ // then settings.json aliases that have scoped variables (step 5 still applies their settings on top)
813
+ const canonicalList = parseCanonicalProviderList(process.env[ENV_PROVIDERS]);
814
+ const settingsIds = Object.entries(providerSettings ?? {})
815
+ .filter(([name, raw]) => normalizeProviderSettings(raw) && parsedEnv[name.toUpperCase().replace(/-/g, "_")])
816
+ .map(([name]) => name);
817
+ const tokenOwners = new Map<string, string>([[PROVIDER_NAME.toUpperCase(), PROVIDER_NAME]]);
818
+ for (const canonicalId of new Set([...canonicalList, ...secondaryProviders.keys(), ...settingsIds])) {
819
+ if (!isValidProviderName(canonicalId)) continue;
820
+ if (disabledProviders.has(canonicalId)) continue;
821
+ const token = canonicalId.toUpperCase().replace(/-/g, "_");
822
+ const owner = tokenOwners.get(token);
823
+ const hasCollision = owner !== undefined && owner !== canonicalId;
824
+ if (!hasCollision && !tokenOwners.has(token)) {
825
+ tokenOwners.set(token, canonicalId);
826
+ }
827
+ const envData = !hasCollision ? parsedEnv[token] : undefined;
828
+ if (envData) claimedTokens.add(token);
829
+
830
+ const existing = secondaryProviders.get(canonicalId);
831
+ secondaryProviders.set(canonicalId, {
832
+ name: canonicalId,
833
+ displayName: envData?.displayName ?? existing?.displayName,
834
+ baseUrl: existing?.baseUrl,
835
+ apiKeyConfig: existing?.apiKeyConfig,
836
+ headers: existing?.headers,
837
+ allowInsecureHttp: envData?.allowInsecureHttp ?? existing?.allowInsecureHttp,
838
+ useGcloudTokenAuth: envData?.useGcloudTokenAuth ?? existing?.useGcloudTokenAuth,
839
+ enableOAuth: envData?.enableOAuth ?? existing?.enableOAuth,
840
+ envPrefix: envData?.envPrefix ?? existing?.envPrefix,
841
+ enableMcp: envData?.enableMcp ?? existing?.enableMcp,
842
+ });
843
+ }
844
+
845
+ // 4. Incorporate remaining unlisted prefix-scanned tokens
846
+ for (const [token, envData] of Object.entries(parsedEnv)) {
847
+ if (claimedTokens.has(token)) continue;
848
+ const providerId = envData.name ?? token.toLowerCase().replace(/_/g, "-");
849
+ if (!isValidProviderName(providerId)) continue;
850
+ if (disabledProviders.has(providerId) || disabledProviders.has(token.toLowerCase())) continue;
851
+
852
+ tokenOwners.set(token, providerId);
853
+
854
+ const existing = secondaryProviders.get(providerId);
855
+ secondaryProviders.set(providerId, {
856
+ name: providerId,
857
+ displayName: envData.displayName ?? existing?.displayName,
858
+ baseUrl: existing?.baseUrl,
859
+ apiKeyConfig: existing?.apiKeyConfig,
860
+ headers: existing?.headers,
861
+ allowInsecureHttp: envData.allowInsecureHttp ?? existing?.allowInsecureHttp,
862
+ useGcloudTokenAuth: envData.useGcloudTokenAuth ?? existing?.useGcloudTokenAuth,
863
+ enableOAuth: envData.enableOAuth ?? existing?.enableOAuth,
864
+ envPrefix: envData.envPrefix ?? existing?.envPrefix,
865
+ enableMcp: envData.enableMcp ?? existing?.enableMcp,
866
+ });
867
+ }
522
868
 
523
- const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
869
+ // 5. Merge with settings.json (disk settings override env vars; enabled === false excludes)
524
870
  for (const [name, raw] of Object.entries(providerSettings ?? {})) {
525
871
  if (name === PROVIDER_NAME) continue;
526
- const normalized = normalizeProviderSettings(raw);
527
- if (!normalized) continue;
528
- definitions.push(makeDefinition(name, normalized, false));
872
+ if (typeof name !== "string" || name.length === 0 || RESERVED_PROVIDER_NAMES.has(name)) continue;
873
+
874
+ const rawRecord = normalizeProviderSettings(raw);
875
+
876
+ if (!rawRecord) {
877
+ secondaryProviders.delete(name);
878
+ continue;
879
+ }
880
+
881
+ const existing = secondaryProviders.get(name);
882
+ const token = name.toUpperCase().replace(/-/g, "_");
883
+ let matchingEnvPrefix = existing ? existing.envPrefix : undefined;
884
+ if (!existing) {
885
+ const owner = tokenOwners.get(token);
886
+ if ((owner === undefined || owner === name) && !claimedTokens.has(token) && parsedEnv[token]) {
887
+ matchingEnvPrefix = `LITELLM_PROVIDER_${token}`;
888
+ tokenOwners.set(token, name);
889
+ claimedTokens.add(token);
890
+ }
891
+ }
892
+
893
+ secondaryProviders.set(name, {
894
+ name,
895
+ displayName: stringSetting(rawRecord.displayName) ?? existing?.displayName,
896
+ baseUrl: stringSetting(rawRecord.baseUrl) ?? existing?.baseUrl,
897
+ apiKeyConfig: stringSetting(rawRecord.apiKey) ?? existing?.apiKeyConfig,
898
+ headers: rawRecord.headers ?? existing?.headers,
899
+ allowInsecureHttp:
900
+ typeof rawRecord.allowInsecureHttp === "boolean"
901
+ ? rawRecord.allowInsecureHttp
902
+ : (existing?.allowInsecureHttp ?? false),
903
+ useGcloudTokenAuth:
904
+ typeof rawRecord.useGcloudTokenAuth === "boolean"
905
+ ? rawRecord.useGcloudTokenAuth
906
+ : (existing?.useGcloudTokenAuth ?? false),
907
+ enableOAuth:
908
+ typeof rawRecord.enableOAuth === "boolean" ? rawRecord.enableOAuth : (existing?.enableOAuth ?? false),
909
+ envPrefix: matchingEnvPrefix,
910
+ enableMcp: typeof rawRecord.enableMcp === "boolean" ? rawRecord.enableMcp : existing?.enableMcp,
911
+ });
912
+ }
913
+
914
+ const definitions: ProviderDefinition[] = [primaryDefinition];
915
+ for (const [name, entry] of secondaryProviders) {
916
+ definitions.push({
917
+ name,
918
+ displayName: entry.displayName ?? name,
919
+ baseUrl: entry.baseUrl,
920
+ apiKeyConfig: entry.apiKeyConfig,
921
+ headers: entry.headers,
922
+ useDefaultEnv: false,
923
+ useGcloudTokenAuth: entry.useGcloudTokenAuth ?? false,
924
+ enableOAuth: entry.enableOAuth ?? false,
925
+ allowInsecureHttp: entry.allowInsecureHttp ?? false,
926
+ oidc: undefined,
927
+ envPrefix: entry.envPrefix,
928
+ enableMcp: entry.enableMcp,
929
+ });
529
930
  }
530
931
  return definitions;
531
932
  }
@@ -541,6 +942,10 @@ function knownBaseUrl(definition: ProviderDefinition): { url: string; source: st
541
942
  const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
542
943
  const candidates: [string | undefined, string][] = [
543
944
  [cleanConfig(definition.baseUrl), "configured"],
945
+ [
946
+ definition.envPrefix ? cleanConfig(process.env[`${definition.envPrefix}_BASE_URL`]) : undefined,
947
+ `$${definition.envPrefix}_BASE_URL`,
948
+ ],
544
949
  [definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined, `$${ENV_BASE_URL}`],
545
950
  [
546
951
  stored?.type === "oauth"
@@ -634,7 +1039,7 @@ function authRequestHeaders(headers?: Record<string, string>, contentType?: stri
634
1039
  function canonicalIssuer(value: string): string {
635
1040
  const url = new URL(value);
636
1041
  if (url.username || url.password || url.search || url.hash) throw new Error("LiteLLM CLI auth has invalid issuer");
637
- return `${url.origin}${url.pathname.replace(/\/+$/, "") || "/"}`;
1042
+ return `${url.origin}${trimTrailingSlashes(url.pathname) || "/"}`;
638
1043
  }
639
1044
 
640
1045
  function sameOriginUrl(value: unknown, issuer: URL, field: string): string {
@@ -993,6 +1398,7 @@ type OidcCredentials = OAuthCredentials & {
993
1398
  clientId: string;
994
1399
  tokenEndpoint: string;
995
1400
  subject: string;
1401
+ scope: string;
996
1402
  };
997
1403
 
998
1404
  type OidcTokenResult =
@@ -1031,15 +1437,18 @@ function parseOidcConfig(raw: unknown): OidcConfig {
1031
1437
 
1032
1438
  // IdP requests never carry the proxy's headers: LITELLM_HEADERS and provider headers can hold gateway credentials.
1033
1439
  async function discoverOidc(issuer: string, signal?: AbortSignal): Promise<OidcDiscovery> {
1034
- const withoutSlash = (value: string) => value.replace(/\/+$/, "");
1035
- const response = await fetch(`${withoutSlash(issuer)}${OIDC_DISCOVERY_PATH}`, {
1440
+ const response = await fetch(`${trimTrailingSlashes(issuer)}${OIDC_DISCOVERY_PATH}`, {
1036
1441
  headers: authRequestHeaders(),
1037
1442
  redirect: "manual",
1038
1443
  signal: boundedLoginSignal(signal),
1039
1444
  });
1040
1445
  if (!response.ok) throw new Error(`OIDC discovery failed (HTTP ${response.status})`);
1041
1446
  const data = await readAuthJson(response, signal, "OIDC discovery");
1042
- if (!isPlainObject(data) || typeof data.issuer !== "string" || withoutSlash(data.issuer) !== withoutSlash(issuer))
1447
+ if (
1448
+ !isPlainObject(data) ||
1449
+ typeof data.issuer !== "string" ||
1450
+ trimTrailingSlashes(data.issuer) !== trimTrailingSlashes(issuer)
1451
+ )
1043
1452
  throw new Error("OIDC discovery issuer does not match the configured issuer");
1044
1453
  const methods = data.code_challenge_methods_supported;
1045
1454
  if (methods !== undefined && (!Array.isArray(methods) || !methods.includes("S256")))
@@ -1100,9 +1509,9 @@ async function requestOidcToken(
1100
1509
  access: idToken,
1101
1510
  // The refresh token is optional, and an IdP that does not rotate it keeps the existing one valid.
1102
1511
  refresh: isAuthToken(refreshToken) ? refreshToken : existingRefreshToken,
1103
- // Refresh ahead of expiry, but by at most half the remaining lifetime, so a short-lived id_token is
1104
- // still used rather than treated as already expired.
1105
- expires: expiresAt - Math.ceil(Math.min(TOKEN_REFRESH_LEAD_MS, (expiresAt - Date.now()) / 2)),
1512
+ // The exact expiry, as for PKCE: Pi already refreshes five minutes before it, and a transient refresh
1513
+ // failure then keeps the id_token until it actually expires.
1514
+ expires: expiresAt,
1106
1515
  subject: claims.sub,
1107
1516
  },
1108
1517
  };
@@ -1158,6 +1567,7 @@ async function loginOidc(interaction: AuthInteraction, baseUrl: string, config:
1158
1567
  clientId: config.clientId,
1159
1568
  tokenEndpoint: discovery.tokenEndpoint,
1160
1569
  subject: result.token.subject,
1570
+ scope: config.scope,
1161
1571
  };
1162
1572
  return { ...credential, type: "oauth" };
1163
1573
  },
@@ -1312,11 +1722,69 @@ async function loginWithPastedToken(
1312
1722
  return { type: "oauth", access, refresh: "", expires, baseUrl };
1313
1723
  }
1314
1724
 
1725
+ const SIGN_IN_OIDC = "oidc";
1726
+ const SIGN_IN_PREVIOUS_OIDC = "previous-oidc";
1727
+
1728
+ /**
1729
+ * The stored login's identity provider, so signing in with it again takes one keypress. Enter picks it, so only a
1730
+ * login to this proxy that recorded its scope qualifies: a guess could hand the IdP's token to another proxy or drop
1731
+ * `offline_access`.
1732
+ */
1733
+ function previousOidcConfig(definition: ProviderDefinition, baseUrl: string): OidcConfig | undefined {
1734
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
1735
+ if (
1736
+ stored?.type !== "oauth" ||
1737
+ stored.flow !== OIDC_FLOW ||
1738
+ stored.baseUrl !== baseUrl ||
1739
+ typeof stored.scope !== "string"
1740
+ )
1741
+ return undefined;
1742
+ try {
1743
+ return parseOidcConfig({ issuer: stored.issuer, clientId: stored.clientId, scope: stored.scope });
1744
+ } catch {
1745
+ return undefined;
1746
+ }
1747
+ }
1748
+
1749
+ /**
1750
+ * Without an `oidc` setting, asks whether to sign in through LiteLLM or directly with an identity provider
1751
+ * where the user has a public client. Any answer but an identity provider selects the LiteLLM-hosted flows.
1752
+ */
1753
+ async function promptOidcConfig(
1754
+ interaction: AuthInteraction,
1755
+ definition: ProviderDefinition,
1756
+ baseUrl: string,
1757
+ ): Promise<OidcConfig | undefined> {
1758
+ const previous = previousOidcConfig(definition, baseUrl);
1759
+ const choice = await interaction.prompt({
1760
+ type: "select",
1761
+ message: "Sign in with:",
1762
+ options: [
1763
+ ...(previous
1764
+ ? [{ id: SIGN_IN_PREVIOUS_OIDC, label: `${previous.issuer} with client ${previous.clientId} (previous login)` }]
1765
+ : []),
1766
+ { id: "litellm", label: "LiteLLM SSO" },
1767
+ { id: SIGN_IN_OIDC, label: "Your identity provider (OIDC client ID)…" },
1768
+ ],
1769
+ });
1770
+ if (choice === SIGN_IN_PREVIOUS_OIDC) return previous;
1771
+ if (choice !== SIGN_IN_OIDC) return undefined;
1772
+ const ask = async (message: string, placeholder?: string) =>
1773
+ (await interaction.prompt({ type: "text", message, placeholder })).trim();
1774
+ // Checked like the setting. redirectPorts stays settings-only, for IdPs that pin exact loopback ports.
1775
+ return parseOidcConfig({
1776
+ issuer: await ask("Enter your identity provider's issuer URL:", "https://idp.example.com"),
1777
+ clientId: await ask("Enter the OAuth client ID (a public client without a secret):"),
1778
+ scope: (await ask("Enter scopes (blank for openid):", "openid offline_access")) || undefined,
1779
+ });
1780
+ }
1781
+
1315
1782
  async function loginOAuth(interaction: AuthInteraction, definition: ProviderDefinition): Promise<OAuthCredential> {
1316
- const oidc = definition.oidc === undefined ? undefined : parseOidcConfig(definition.oidc);
1783
+ const configured = definition.oidc === undefined ? undefined : parseOidcConfig(definition.oidc);
1317
1784
  const baseUrl = await promptBaseUrl(interaction, definition);
1318
1785
  // Direct OIDC sends nothing to the proxy. The IdP is never derived from it either: the proxy's own OAuth
1319
1786
  // metadata describes LiteLLM's authorization server, not the IdP that signs the JWTs it accepts.
1787
+ const oidc = configured ?? (await promptOidcConfig(interaction, definition, baseUrl));
1320
1788
  if (oidc) return loginOidc(interaction, baseUrl, oidc);
1321
1789
  const headers = resolveHeaders(definition);
1322
1790
  const discovery = await discoverPkce(baseUrl, interaction.signal, headers);
@@ -1444,11 +1912,9 @@ async function configuredBaseUrl(
1444
1912
  ctx: { env(name: string): Promise<string | undefined> },
1445
1913
  credential?: ApiKeyCredential,
1446
1914
  ): Promise<string | undefined> {
1447
- return (
1448
- cleanConfig(credential?.env?.[ENV_BASE_URL]) ??
1449
- cleanConfig(definition.baseUrl) ??
1450
- (definition.useDefaultEnv ? cleanConfig(await ctx.env(ENV_BASE_URL)) : undefined)
1451
- );
1915
+ const scopedEnv = definition.envPrefix ? cleanConfig(await ctx.env(`${definition.envPrefix}_BASE_URL`)) : undefined;
1916
+ const defaultEnv = definition.useDefaultEnv ? cleanConfig(await ctx.env(ENV_BASE_URL)) : undefined;
1917
+ return cleanConfig(credential?.env?.[ENV_BASE_URL]) ?? cleanConfig(definition.baseUrl) ?? scopedEnv ?? defaultEnv;
1452
1918
  }
1453
1919
 
1454
1920
  async function resolveApiKeyAuth(
@@ -1470,39 +1936,73 @@ async function resolveApiKeyAuth(
1470
1936
  apiKey: stored,
1471
1937
  };
1472
1938
  } else {
1473
- creds = await resolveCredentials(
1474
- { ...definition, apiKeyConfig: undefined, useDefaultEnv: false },
1475
- { executeHelpers },
1476
- );
1477
- if (!creds.apiKey && definition.apiKeyConfig) {
1939
+ // 1) Google ADC
1940
+ const useGcloudToken = definition.useDefaultEnv
1941
+ ? definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled()
1942
+ : definition.useGcloudTokenAuth;
1943
+ const gcloudKey = executeHelpers && useGcloudToken ? (await getGcloudToken())?.trim() : undefined;
1944
+ let apiKey = gcloudKey;
1945
+ let apiKeyConfig: string | undefined;
1946
+ if (gcloudKey) {
1947
+ source = GCLOUD_ADC_SOURCE;
1948
+ }
1949
+
1950
+ // 2) definition.apiKeyConfig
1951
+ if (!apiKey && definition.apiKeyConfig) {
1478
1952
  const configured = definition.apiKeyConfig.startsWith("!")
1479
1953
  ? executeHelpers
1480
1954
  ? executeApiKeyCommand(definition.apiKeyConfig)
1481
1955
  : undefined
1482
1956
  : await resolveTemplateConfigValueFromContext(definition.apiKeyConfig, ctx.env);
1483
1957
  if (configured) {
1484
- creds.apiKey = configured;
1485
- creds.apiKeyConfig = definition.apiKeyConfig;
1958
+ apiKey = configured;
1959
+ apiKeyConfig = definition.apiKeyConfig;
1960
+ source = definition.apiKeyConfig;
1486
1961
  } else if (!definition.apiKeyConfig.startsWith("!")) {
1487
1962
  warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
1488
1963
  }
1489
1964
  }
1490
- if (!creds.apiKey && definition.useDefaultEnv) {
1965
+
1966
+ // 3) Scoped envPrefix: _API_KEY_HELPER then _API_KEY
1967
+ if (!apiKey && definition.envPrefix) {
1968
+ const helper = normalizeCommand(await ctx.env(`${definition.envPrefix}_API_KEY_HELPER`));
1969
+ if (helper) {
1970
+ apiKey = executeHelpers ? executeApiKeyCommand(helper) : undefined;
1971
+ apiKeyConfig = helper;
1972
+ source = `$${definition.envPrefix}_API_KEY_HELPER`;
1973
+ } else {
1974
+ const envKey = cleanConfig(await ctx.env(`${definition.envPrefix}_API_KEY`));
1975
+ if (envKey) {
1976
+ apiKey = envKey;
1977
+ apiKeyConfig = `$${definition.envPrefix}_API_KEY`;
1978
+ source = `$${definition.envPrefix}_API_KEY`;
1979
+ }
1980
+ }
1981
+ }
1982
+
1983
+ // 4) Default env: LITELLM_API_KEY_HELPER then LITELLM_API_KEY
1984
+ if (!apiKey && definition.useDefaultEnv) {
1491
1985
  const helper = normalizeCommand(await ctx.env(ENV_API_KEY_HELPER));
1492
1986
  if (helper) {
1493
- creds.apiKey = executeHelpers ? executeApiKeyCommand(helper) : undefined;
1494
- creds.apiKeyConfig = helper;
1987
+ apiKey = executeHelpers ? executeApiKeyCommand(helper) : undefined;
1988
+ apiKeyConfig = helper;
1495
1989
  source = ENV_API_KEY_HELPER;
1496
1990
  } else {
1497
1991
  const envKey = cleanConfig(await ctx.env(ENV_API_KEY));
1498
1992
  if (envKey) {
1499
- creds.apiKey = envKey;
1500
- creds.apiKeyConfig = ENV_API_KEY;
1993
+ apiKey = envKey;
1994
+ apiKeyConfig = ENV_API_KEY;
1501
1995
  source = ENV_API_KEY;
1502
1996
  }
1503
1997
  }
1504
1998
  }
1505
- if (!creds.baseUrl && baseUrl) creds.baseUrl = normalizeBaseUrl(baseUrl, definition.allowInsecureHttp);
1999
+
2000
+ creds = {
2001
+ baseUrl: baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined,
2002
+ apiKey: apiKey || undefined,
2003
+ apiKeyConfig,
2004
+ apiKeyFromGcloudAdc: Boolean(gcloudKey),
2005
+ };
1506
2006
  }
1507
2007
  if (!creds.apiKey) return undefined;
1508
2008
  const normalizedRoot = baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined;
@@ -1541,18 +2041,16 @@ function createProviderAuth(
1541
2041
  return {
1542
2042
  apiKey: {
1543
2043
  name: `${definition.displayName} API key`,
1544
- login:
1545
- definition.name === PROVIDER_NAME
1546
- ? async (interaction) => {
1547
- loginHooks?.start();
1548
- const credential = await loginApiKey(interaction, definition);
1549
- return completeLogin(credential);
1550
- }
1551
- : undefined,
2044
+ login: async (interaction) => {
2045
+ loginHooks?.start();
2046
+ const credential = await loginApiKey(interaction, definition);
2047
+ return completeLogin(credential);
2048
+ },
1552
2049
  check: async ({ ctx, credential }) => {
1553
2050
  const baseUrl =
1554
2051
  credential?.env?.[ENV_BASE_URL] ??
1555
2052
  definition.baseUrl ??
2053
+ (definition.envPrefix ? await ctx.env(`${definition.envPrefix}_BASE_URL`) : undefined) ??
1556
2054
  (definition.useDefaultEnv ? await ctx.env(ENV_BASE_URL) : undefined);
1557
2055
  if (!cleanConfig(baseUrl)) return undefined;
1558
2056
  if (credential?.key) return { type: "api_key", source: "stored credential" };
@@ -1565,6 +2063,14 @@ function createProviderAuth(
1565
2063
  : await resolveTemplateConfigValueFromContext(definition.apiKeyConfig, ctx.env);
1566
2064
  if (configuredKey) return definition.apiKeyConfig;
1567
2065
  }
2066
+ if (definition.envPrefix) {
2067
+ if (cleanConfig(await ctx.env(`${definition.envPrefix}_API_KEY_HELPER`))) {
2068
+ return `$${definition.envPrefix}_API_KEY_HELPER`;
2069
+ }
2070
+ if (cleanConfig(await ctx.env(`${definition.envPrefix}_API_KEY`))) {
2071
+ return `$${definition.envPrefix}_API_KEY`;
2072
+ }
2073
+ }
1568
2074
  if (definition.useDefaultEnv && cleanConfig(await ctx.env(ENV_API_KEY_HELPER))) return ENV_API_KEY_HELPER;
1569
2075
  if (definition.useDefaultEnv && cleanConfig(await ctx.env(ENV_API_KEY))) return ENV_API_KEY;
1570
2076
  return undefined;
@@ -1574,8 +2080,10 @@ function createProviderAuth(
1574
2080
  // key, the helper and the environment key. Whether the refresh token still mints
1575
2081
  // is only knowable at request time, and this must not make a network call; if it
1576
2082
  // fails, `resolve` falls back and reports the credential it actually used.
1577
- if (definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled() && (await hasGcloudAdcCredentials()))
1578
- return { type: "api_key", source: GCLOUD_ADC_SOURCE };
2083
+ const useGcloudToken = definition.useDefaultEnv
2084
+ ? definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled()
2085
+ : definition.useGcloudTokenAuth;
2086
+ if (useGcloudToken && (await hasGcloudAdcCredentials())) return { type: "api_key", source: GCLOUD_ADC_SOURCE };
1579
2087
  const fallback = await fallbackSource();
1580
2088
  return fallback ? { type: "api_key", source: fallback } : undefined;
1581
2089
  },
@@ -1883,7 +2391,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1883
2391
  "Update Pi, or install the last release for older Pi: pi install npm:pi-provider-litellm@3.4.0",
1884
2392
  );
1885
2393
  }
1886
- const settings = await readGlobalLiteLLMSettings();
2394
+ const settings = mergeProviderSettings(await readGlobalLiteLLMSettings(), await readModelsProviderSettings());
1887
2395
  const definitions = getProviderDefinitions(settings);
1888
2396
  const skillsEnabled = isFeatureEnabled(settings, "skills");
1889
2397
  const mcpEnabled = isFeatureEnabled(settings, "mcp");
@@ -1930,7 +2438,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1930
2438
  const mcpAttempts = new Map<string, { identity: string; registered: boolean }>();
1931
2439
  const mcpIdentitySalt = randomBytes(32);
1932
2440
 
1933
- function mcpServerConfig(definition: ProviderDefinition): McpServerConfig | undefined {
2441
+ function mcpServerConfig(definition: ProviderDefinition): (McpServerConfig & { url: string }) | undefined {
2442
+ if (definition.enableMcp === false) return undefined;
1934
2443
  if (discoveryDisabledReason() || isHostOffline()) return undefined;
1935
2444
  let root: string;
1936
2445
  try {
@@ -1947,7 +2456,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1947
2456
  ...(headers ? { headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, piLiteral(v)])) } : {}),
1948
2457
  ...(mcpSettings?.exposure !== undefined ? { exposure: mcpSettings.exposure } : {}),
1949
2458
  ...(mcpSettings?.toolExposure !== undefined ? { toolExposure: mcpSettings.toolExposure } : {}),
1950
- } as McpServerConfig;
2459
+ } as McpServerConfig & { url: string };
1951
2460
  }
1952
2461
 
1953
2462
  function dropMcpServer(definition: ProviderDefinition): void {
@@ -1956,7 +2465,66 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1956
2465
  if (attempt?.registered) pi.unregisterMcpServer(definition.name);
1957
2466
  }
1958
2467
 
1959
- function syncMcpServer(definition: ProviderDefinition): void {
2468
+ // LiteLLM 1.102.0 and later answer `initialize` with 403 when the key has no MCP servers granted,
2469
+ // and Pi warns at every start about a server that fails to connect. Asking first lets such a
2470
+ // server be registered disabled: Pi neither connects to it nor warns, and /mcp still lists it, so
2471
+ // the user can enable it once servers are granted.
2472
+ async function mcpAccessRefused(definition: ProviderDefinition, url: string): Promise<boolean> {
2473
+ try {
2474
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
2475
+ // The check must send the credential Pi will send. Like seeding, it runs no key helper and
2476
+ // mints no ADC token, and resolving without them would fall through to another key.
2477
+ const useGcloudToken = definition.useDefaultEnv
2478
+ ? definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled()
2479
+ : definition.useGcloudTokenAuth;
2480
+ if (
2481
+ (stored?.type === "api_key" && stored.key?.startsWith("!")) ||
2482
+ definition.apiKeyConfig?.startsWith("!") ||
2483
+ useGcloudToken
2484
+ ) {
2485
+ return false;
2486
+ }
2487
+ const auth = await authForCredential(definition, stored, false);
2488
+ // The credential goes only to its own root.
2489
+ if (`${auth.baseUrl}/mcp` !== url) return false;
2490
+ // The credential and headers Pi's client sends, so the proxy answers as it will answer Pi.
2491
+ const headers = { ...auth.headers, Authorization: `Bearer ${auth.apiKey}` };
2492
+ const response = await fetch(url, {
2493
+ method: "POST",
2494
+ headers: { ...headers, accept: "application/json, text/event-stream", "content-type": "application/json" },
2495
+ body: JSON.stringify({
2496
+ jsonrpc: "2.0",
2497
+ id: 1,
2498
+ method: "initialize",
2499
+ params: {
2500
+ protocolVersion: "2025-06-18",
2501
+ capabilities: {},
2502
+ clientInfo: { name: "pi", version: piCodingAgent.VERSION },
2503
+ },
2504
+ }),
2505
+ redirect: "manual",
2506
+ signal: AbortSignal.timeout(getSeedTimeoutMs()),
2507
+ });
2508
+ await response.body?.cancel().catch(() => undefined);
2509
+ // Pi opens its own session; end the one this check opened.
2510
+ const session = response.headers.get("mcp-session-id");
2511
+ if (session) {
2512
+ void fetch(url, {
2513
+ method: "DELETE",
2514
+ headers: { ...headers, "mcp-session-id": session },
2515
+ redirect: "manual",
2516
+ signal: AbortSignal.timeout(getSeedTimeoutMs()),
2517
+ })
2518
+ .then((closed) => closed.body?.cancel())
2519
+ .catch(() => undefined);
2520
+ }
2521
+ return response.status === 403;
2522
+ } catch {
2523
+ return false;
2524
+ }
2525
+ }
2526
+
2527
+ async function syncMcpServer(definition: ProviderDefinition): Promise<void> {
1960
2528
  const config = mcpServerConfig(definition);
1961
2529
  const identity = config && createHmac("sha256", mcpIdentitySalt).update(JSON.stringify(config)).digest("hex");
1962
2530
  if (identity === mcpAttempts.get(definition.name)?.identity) return;
@@ -1964,12 +2532,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1964
2532
  // Pi refuses the new config.
1965
2533
  dropMcpServer(definition);
1966
2534
  if (!config || !identity) return;
2535
+ // Claimed before the access check, so the next turn does not repeat it, and a login that starts
2536
+ // meanwhile drops the claim before this registration can land.
2537
+ const attempt = { identity, registered: false };
2538
+ mcpAttempts.set(definition.name, attempt);
2539
+ const refused = await mcpAccessRefused(definition, config.url);
2540
+ if (mcpAttempts.get(definition.name) !== attempt) return;
1967
2541
  try {
1968
- pi.registerMcpServer(definition.name, config);
1969
- mcpAttempts.set(definition.name, { identity, registered: true });
2542
+ pi.registerMcpServer(definition.name, refused ? { ...config, enabled: false } : config);
2543
+ attempt.registered = true;
1970
2544
  } catch (error) {
1971
- // Remembered so the refusal is reported once rather than on every turn.
1972
- mcpAttempts.set(definition.name, { identity, registered: false });
2545
+ // The claim stays, so the refusal is reported once rather than on every turn.
1973
2546
  notifyMcp(
1974
2547
  `LiteLLM MCP (${JSON.stringify(definition.name)}): ${error instanceof Error ? error.message : String(error)}`,
1975
2548
  );
@@ -2074,7 +2647,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2074
2647
  // Pi's startup path only ever refreshes with allowNetwork:false, and it returns before the
2075
2648
  // network phase, so the provider would stay empty until the user opens /model. Discover here
2076
2649
  // instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
2077
- async function seedModels(definition: ProviderDefinition): Promise<Model<LiteLLMApi>[]> {
2650
+ //
2651
+ // That is true on a first run. Once Pi has persisted the catalog it is not: with discovery off and a populated store,
2652
+ // Pi offers the stored models and a request works. So the round trip -- about 1.8 s against a real proxy, on the
2653
+ // critical path of every start -- is replaced by reading that catalog while it is fresh (models-store.ts). Nothing is
2654
+ // written: Pi owns the file. The stored models are handed over rather than an empty list, so that a store Pi keeps
2655
+ // somewhere else cannot leave the provider with nothing.
2656
+ async function seedModels(definition: ProviderDefinition): Promise<(Model<LiteLLMApi> | LiteLLMImageModel)[]> {
2078
2657
  const disabledReason = discoveryDisabledReason() ?? (isHostOffline() ? "PI_OFFLINE" : null);
2079
2658
  if (disabledReason) {
2080
2659
  if (isVerboseDiscovery()) {
@@ -2082,13 +2661,31 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2082
2661
  }
2083
2662
  return [];
2084
2663
  }
2664
+ const agentDir = getAgentDir();
2085
2665
  try {
2086
- const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
2666
+ const stored = readStoredCredential(definition.name, join(agentDir, "auth.json"));
2087
2667
  // executeHelpers:false — activation must never run the user's key helper as a side effect,
2088
2668
  // so helper-backed setups keep waiting for Pi's own refresh.
2089
2669
  const auth = await authForCredential(definition, stored, false);
2670
+ const storedModels = await readStoredModels({
2671
+ agentDir,
2672
+ provider: definition.name,
2673
+ baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
2674
+ allowInsecureHttp: definition.allowInsecureHttp,
2675
+ });
2676
+ if (storedModels) {
2677
+ if (isVerboseDiscovery()) {
2678
+ process.stderr.write(
2679
+ `LiteLLM (${definition.name}): startup discovery skipped (${storedModels.length} model(s) from Pi's store).\n`,
2680
+ );
2681
+ }
2682
+ return storedModels;
2683
+ }
2090
2684
  const result = await runDiscovery(auth, AbortSignal.timeout(getSeedTimeoutMs()));
2091
- return toNativeModels(definition.name, result.baseUrl, result.models, definition.allowInsecureHttp);
2685
+ return [
2686
+ ...toNativeModels(definition.name, result.baseUrl, result.models, definition.allowInsecureHttp),
2687
+ ...toNativeImageModels(definition.name, result.baseUrl, result.imageModels ?? [], definition.allowInsecureHttp),
2688
+ ];
2092
2689
  } catch (error) {
2093
2690
  // No credentials yet is the normal unconfigured case; anything else is worth one line.
2094
2691
  if (isVerboseDiscovery()) {
@@ -2100,26 +2697,30 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2100
2697
  }
2101
2698
  }
2102
2699
 
2103
- const seeded = await Promise.all(definitions.map(seedModels));
2700
+ // The MCP access check overlaps seeding rather than adding to startup. Servers registered while
2701
+ // the extension loads connect when the session starts.
2702
+ const [seeded] = await Promise.all([
2703
+ Promise.all(definitions.map(seedModels)),
2704
+ mcpEnabled ? Promise.all(definitions.map(syncMcpServer)) : undefined,
2705
+ ]);
2104
2706
  // Pi runs a provider's network phase only once a credential resolves, so /litellm-refresh reads an
2105
2707
  // unchanged count as "no credentials" rather than as a successful refresh.
2106
2708
  const networkRefreshAttempts = new Map<string, number>();
2107
2709
 
2108
2710
  for (const [index, definition] of definitions.entries()) {
2711
+ const loginHooks = {
2712
+ // Disconnect before the login can store a new credential: Pi's MCP client reads the
2713
+ // provider's current token on every request, including the session teardown, so a
2714
+ // connection still open to the old proxy root would send it the new credential.
2715
+ start: () => dropMcpServer(definition),
2716
+ complete: () => {
2717
+ loginGeneration++;
2718
+ },
2719
+ };
2109
2720
  const auth = createProviderAuth(
2110
2721
  definition,
2111
2722
  () => oauthRuntimeRoots.delete(definition.name),
2112
- definition.name === PROVIDER_NAME
2113
- ? {
2114
- // Disconnect before the login can store a new credential: Pi's MCP client reads the
2115
- // provider's current token on every request, including the session teardown, so a
2116
- // connection still open to the old proxy root would send it the new credential.
2117
- start: () => dropMcpServer(definition),
2118
- complete: () => {
2119
- loginGeneration++;
2120
- },
2121
- }
2122
- : undefined,
2723
+ loginHooks,
2123
2724
  () => oauthRuntimeRoots.get(definition.name),
2124
2725
  );
2125
2726
  if (auth.oauth) {
@@ -2315,11 +2916,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2315
2916
  });
2316
2917
 
2317
2918
  if (mcpEnabled) {
2318
- // Servers registered while the extension loads connect when the session starts. Each turn
2319
- // picks up a login that changed the proxy root, and a logout that removed it.
2320
- for (const definition of definitions) syncMcpServer(definition);
2321
- pi.on("before_agent_start", () => {
2322
- for (const definition of definitions) syncMcpServer(definition);
2919
+ // Each turn picks up a login that changed the proxy root, and a logout that removed it.
2920
+ pi.on("before_agent_start", async () => {
2921
+ await Promise.all(definitions.map(syncMcpServer));
2323
2922
  });
2324
2923
  }
2325
2924