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/README.md +152 -15
- package/package.json +2 -2
- package/src/backend-identity.ts +1 -1
- package/src/discover.ts +32 -7
- package/src/images.ts +258 -0
- package/src/index.ts +709 -110
- package/src/models-store.ts +117 -0
- package/src/provider.ts +148 -33
- package/src/types.ts +4 -0
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 {
|
|
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 {
|
|
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(),
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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) ??
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const useGcloudToken = definition.
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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 &&
|
|
423
|
-
apiKeyConfig =
|
|
424
|
-
} else if (!executeHelpers &&
|
|
425
|
-
apiKeyConfig =
|
|
654
|
+
} else if (helperKey && helperCommand) {
|
|
655
|
+
apiKeyConfig = helperCommand;
|
|
656
|
+
} else if (!executeHelpers && helperCommand) {
|
|
657
|
+
apiKeyConfig = helperCommand;
|
|
426
658
|
} else if (envKey) {
|
|
427
|
-
apiKeyConfig =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
expires: expiresAt
|
|
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
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1993
|
+
apiKey = envKey;
|
|
1994
|
+
apiKeyConfig = ENV_API_KEY;
|
|
1501
1995
|
source = ENV_API_KEY;
|
|
1502
1996
|
}
|
|
1503
1997
|
}
|
|
1504
1998
|
}
|
|
1505
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2542
|
+
pi.registerMcpServer(definition.name, refused ? { ...config, enabled: false } : config);
|
|
2543
|
+
attempt.registered = true;
|
|
1970
2544
|
} catch (error) {
|
|
1971
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2319
|
-
|
|
2320
|
-
|
|
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
|
|