pi-provider-litellm 2.3.0 → 3.0.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 +148 -24
- package/package.json +13 -18
- package/src/backend-identity.ts +100 -0
- package/src/cost.ts +52 -0
- package/src/discover.ts +1318 -0
- package/src/gcloud-token.ts +160 -0
- package/src/index.ts +2044 -0
- package/src/mcp-tools.ts +1341 -0
- package/src/model-groups.ts +947 -0
- package/src/protocols.ts +42 -0
- package/src/provider.ts +248 -0
- package/src/public-catalog.ts +272 -0
- package/src/skills.ts +275 -0
- package/src/thinking-levels.ts +35 -0
- package/src/types.ts +164 -0
- package/dist/cost.d.ts +0 -2
- package/dist/cost.js +0 -49
- package/dist/discover.d.ts +0 -12
- package/dist/discover.js +0 -380
- package/dist/gcloud-token-cli.d.ts +0 -1
- package/dist/gcloud-token-cli.js +0 -8
- package/dist/gcloud-token.d.ts +0 -7
- package/dist/gcloud-token.js +0 -133
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -1226
- package/dist/litellm.d.ts +0 -1
- package/dist/litellm.js +0 -10
- package/dist/mcp-tools.d.ts +0 -5
- package/dist/mcp-tools.js +0 -181
- package/dist/provider.d.ts +0 -16
- package/dist/provider.js +0 -40
- package/dist/skills.d.ts +0 -14
- package/dist/skills.js +0 -225
- package/dist/types.d.ts +0 -108
- package/dist/types.js +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,2044 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import type {
|
|
9
|
+
ApiKeyCredential,
|
|
10
|
+
AssistantMessage,
|
|
11
|
+
AuthInteraction,
|
|
12
|
+
Credential,
|
|
13
|
+
Model,
|
|
14
|
+
OAuthCredential,
|
|
15
|
+
OAuthCredentials,
|
|
16
|
+
ProviderAuth,
|
|
17
|
+
} from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
21
|
+
import { discoverModels, isGpt55Model, normalizeBaseUrl } from "./discover.js";
|
|
22
|
+
import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
|
|
23
|
+
import {
|
|
24
|
+
createMcpToolDefinitions,
|
|
25
|
+
credentialFingerprint,
|
|
26
|
+
McpAccessDeniedError,
|
|
27
|
+
reportMcpCatalogOutcome,
|
|
28
|
+
reportMcpPartialDiscovery,
|
|
29
|
+
reportMcpRegistrationFatal,
|
|
30
|
+
reportMcpRegistrationSuccess,
|
|
31
|
+
} from "./mcp-tools.js";
|
|
32
|
+
import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
|
|
33
|
+
import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
|
|
34
|
+
import type { LiteLLMApi, LiteLLMModel, LiteLLMModelPolicy, LiteLLMRuntimeAuth, ResolvedCredentials } from "./types.js";
|
|
35
|
+
|
|
36
|
+
const PROVIDER_NAME = "litellm";
|
|
37
|
+
const SETTINGS_KEY = "litellm";
|
|
38
|
+
const ENV_BASE_URL = "LITELLM_BASE_URL";
|
|
39
|
+
const ENV_API_KEY = "LITELLM_API_KEY";
|
|
40
|
+
const GCLOUD_ADC_SOURCE = "gcloud ADC";
|
|
41
|
+
const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
|
|
42
|
+
const ENV_HEADERS = "LITELLM_HEADERS";
|
|
43
|
+
const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
|
|
44
|
+
const ENV_CLI_JWT_EXPIRATION_HOURS = "LITELLM_CLI_JWT_EXPIRATION_HOURS";
|
|
45
|
+
const ENV_OFFLINE = "LITELLM_OFFLINE";
|
|
46
|
+
const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
|
|
47
|
+
const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
|
|
48
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
49
|
+
const SEED_TIMEOUT_MS = 3000;
|
|
50
|
+
const LOGIN_TIMEOUT_MS = 10_000;
|
|
51
|
+
const CLI_SSO_POLL_INTERVAL_MS = 2_000;
|
|
52
|
+
const CLI_SSO_EXPIRES_IN_SECONDS = 600;
|
|
53
|
+
const CLI_AUTH_DISCOVERY_PATH = "/.well-known/litellm-cli-auth";
|
|
54
|
+
const PKCE_CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
55
|
+
const PKCE_FLOW = "litellm_cli_pkce";
|
|
56
|
+
const DEFAULT_CLI_JWT_EXPIRATION_HOURS = 24;
|
|
57
|
+
const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
|
|
58
|
+
const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
|
|
59
|
+
const EXPIRE_TOKEN_IMMEDIATELY = 0;
|
|
60
|
+
const PKCE_TRANSIENT_REFRESH_BACKOFF_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
type RawProviderSettings = {
|
|
63
|
+
displayName?: unknown;
|
|
64
|
+
baseUrl?: unknown;
|
|
65
|
+
apiKey?: unknown;
|
|
66
|
+
headers?: unknown;
|
|
67
|
+
enabled?: unknown;
|
|
68
|
+
allowInsecureHttp?: unknown;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type McpRuntimeAuth = LiteLLMRuntimeAuth & { mcpPauseSource?: string };
|
|
72
|
+
|
|
73
|
+
type ProviderDefinition = {
|
|
74
|
+
name: string;
|
|
75
|
+
displayName: string;
|
|
76
|
+
baseUrl?: string;
|
|
77
|
+
apiKeyConfig?: string;
|
|
78
|
+
headers?: unknown;
|
|
79
|
+
useDefaultEnv: boolean;
|
|
80
|
+
useGcloudTokenAuth: boolean;
|
|
81
|
+
enableOAuth: boolean;
|
|
82
|
+
allowInsecureHttp: boolean;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readGlobalLiteLLMSettings(): Promise<Record<string, unknown> | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
|
|
92
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
93
|
+
const settings = parsed[SETTINGS_KEY];
|
|
94
|
+
return settings && typeof settings === "object" && !Array.isArray(settings)
|
|
95
|
+
? (settings as Record<string, unknown>)
|
|
96
|
+
: undefined;
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cleanConfig(raw: string | undefined): string | undefined {
|
|
103
|
+
const trimmed = raw?.trim();
|
|
104
|
+
return trimmed && trimmed !== "undefined" ? trimmed : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveCredentialRoot(
|
|
108
|
+
definition: ProviderDefinition,
|
|
109
|
+
credential?: Credential,
|
|
110
|
+
requestBaseUrl?: string,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
const credentialBaseUrl =
|
|
113
|
+
credential?.type === "oauth"
|
|
114
|
+
? cleanConfig(typeof credential.baseUrl === "string" ? credential.baseUrl : undefined)
|
|
115
|
+
: credential?.type === "api_key"
|
|
116
|
+
? cleanConfig(credential.env?.[ENV_BASE_URL])
|
|
117
|
+
: undefined;
|
|
118
|
+
const baseUrl =
|
|
119
|
+
credentialBaseUrl ??
|
|
120
|
+
cleanConfig(requestBaseUrl) ??
|
|
121
|
+
cleanConfig(definition.baseUrl) ??
|
|
122
|
+
(definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
|
|
123
|
+
return baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requireCredentialRoot(root: string | undefined, providerName: string): string {
|
|
127
|
+
if (!root) throw new Error(`no LiteLLM base URL for ${providerName}. Run /login litellm or set env vars.`);
|
|
128
|
+
if (isPlaceholderHost(new URL(root).hostname)) {
|
|
129
|
+
throw new Error(`placeholder LiteLLM base URL for ${providerName}. Run /login litellm or set env vars.`);
|
|
130
|
+
}
|
|
131
|
+
return root;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stringSetting(value: unknown): string | undefined {
|
|
135
|
+
return typeof value === "string" ? cleanConfig(value) : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeCommand(raw: string | undefined): string | undefined {
|
|
139
|
+
const trimmed = cleanConfig(raw);
|
|
140
|
+
if (!trimmed) return undefined;
|
|
141
|
+
return trimmed.startsWith("!") ? trimmed : `!${trimmed}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getApiKeyHelperCommand(): string | undefined {
|
|
145
|
+
return normalizeCommand(process.env[ENV_API_KEY_HELPER]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseApiKeyCommand(commandConfig: string): string[] {
|
|
149
|
+
const command = commandConfig.startsWith("!") ? commandConfig.slice(1) : commandConfig;
|
|
150
|
+
if (/[\n\r;&|<>`$(){}*?~%^!]|\[|\]/.test(command))
|
|
151
|
+
throw new Error("LiteLLM API key helper shell syntax is not supported");
|
|
152
|
+
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
let current = "";
|
|
155
|
+
let quote: "'" | '"' | undefined;
|
|
156
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
157
|
+
const character = command[index]!;
|
|
158
|
+
const next = command[index + 1];
|
|
159
|
+
if (character === "\\" && quote !== "'" && next && (next === "\\" || next === quote || /\s/.test(next))) {
|
|
160
|
+
current += next;
|
|
161
|
+
index += 1;
|
|
162
|
+
} else if ((character === "'" || character === '"') && (!quote || quote === character)) {
|
|
163
|
+
quote = quote ? undefined : character;
|
|
164
|
+
} else if (!quote && /\s/.test(character)) {
|
|
165
|
+
if (current) {
|
|
166
|
+
parts.push(current);
|
|
167
|
+
current = "";
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
current += character;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (quote) throw new Error(`LiteLLM API key helper command has an unterminated quote: ${command}`);
|
|
175
|
+
if (current) parts.push(current);
|
|
176
|
+
if (parts.length === 0) throw new Error("LiteLLM API key helper command is empty");
|
|
177
|
+
if (/\.(?:cmd|bat)$/i.test(parts[0]!))
|
|
178
|
+
throw new Error("LiteLLM API key helper shell scripts are not supported; use an executable");
|
|
179
|
+
return parts;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function executeApiKeyCommand(commandConfig: string): string {
|
|
183
|
+
const [command, ...args] = parseApiKeyCommand(commandConfig);
|
|
184
|
+
const output = execFileSync(command, args, {
|
|
185
|
+
encoding: "utf8",
|
|
186
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
187
|
+
timeout: 10_000,
|
|
188
|
+
}).trim();
|
|
189
|
+
if (!output) throw new Error(`LiteLLM API key helper produced no output: ${command}`);
|
|
190
|
+
return output;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function tokenExpiresAt(apiKey: string, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT): number {
|
|
194
|
+
const [, payload] = apiKey.split(".");
|
|
195
|
+
if (!payload) return opaqueFallback;
|
|
196
|
+
try {
|
|
197
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
|
|
198
|
+
return typeof claims.exp === "number"
|
|
199
|
+
? Math.max(Date.now(), claims.exp * 1000 - TOKEN_REFRESH_LEAD_MS)
|
|
200
|
+
: opaqueFallback;
|
|
201
|
+
} catch {
|
|
202
|
+
return opaqueFallback;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function configuredCliJwtExpiresAt(): number {
|
|
207
|
+
const configured = Number(process.env[ENV_CLI_JWT_EXPIRATION_HOURS]);
|
|
208
|
+
const hours = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_CLI_JWT_EXPIRATION_HOURS;
|
|
209
|
+
return Date.now() + hours * 60 * 60 * 1_000;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function boundedLoginSignal(signal?: AbortSignal): AbortSignal {
|
|
213
|
+
return signal
|
|
214
|
+
? AbortSignal.any([signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)])
|
|
215
|
+
: AbortSignal.timeout(LOGIN_TIMEOUT_MS);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function generateVirtualKey(
|
|
219
|
+
baseUrl: string,
|
|
220
|
+
userToken: string,
|
|
221
|
+
signal?: AbortSignal,
|
|
222
|
+
headers?: Record<string, string>,
|
|
223
|
+
): Promise<{ key: string; expiresAt?: number }> {
|
|
224
|
+
const response = await fetch(`${baseUrl}/key/generate`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: {
|
|
227
|
+
...headers,
|
|
228
|
+
Authorization: `Bearer ${userToken}`,
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify({}),
|
|
232
|
+
signal: boundedLoginSignal(signal),
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
throw new Error(`Virtual key generation failed (${response.status})`);
|
|
236
|
+
}
|
|
237
|
+
const data = (await response.json()) as { key?: unknown; expires?: unknown };
|
|
238
|
+
if (typeof data.key !== "string" || !data.key) throw new Error("No key in response from /key/generate");
|
|
239
|
+
const expiresMs = typeof data.expires === "string" ? Date.parse(data.expires) : Number.NaN;
|
|
240
|
+
return { key: data.key, expiresAt: Number.isNaN(expiresMs) ? undefined : expiresMs };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function resolveTemplateConfigValue(config: string): string | undefined {
|
|
244
|
+
let resolved = "";
|
|
245
|
+
for (let index = 0; index < config.length; ) {
|
|
246
|
+
const dollarIndex = config.indexOf("$", index);
|
|
247
|
+
if (dollarIndex === -1) return resolved + config.slice(index);
|
|
248
|
+
resolved += config.slice(index, dollarIndex);
|
|
249
|
+
const nextChar = config[dollarIndex + 1];
|
|
250
|
+
if (nextChar === "$" || nextChar === "!") {
|
|
251
|
+
resolved += nextChar;
|
|
252
|
+
index = dollarIndex + 2;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (nextChar === "{") {
|
|
256
|
+
const endIndex = config.indexOf("}", dollarIndex + 2);
|
|
257
|
+
if (endIndex === -1) {
|
|
258
|
+
resolved += "$";
|
|
259
|
+
index = dollarIndex + 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const name = config.slice(dollarIndex + 2, endIndex);
|
|
263
|
+
const envValue = process.env[name];
|
|
264
|
+
if (envValue === undefined) return undefined;
|
|
265
|
+
resolved += envValue;
|
|
266
|
+
index = endIndex + 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const match = config.slice(dollarIndex + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
270
|
+
if (!match) {
|
|
271
|
+
resolved += "$";
|
|
272
|
+
index = dollarIndex + 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const envValue = process.env[match[0]];
|
|
276
|
+
if (envValue === undefined) return undefined;
|
|
277
|
+
resolved += envValue;
|
|
278
|
+
index = dollarIndex + 1 + match[0].length;
|
|
279
|
+
}
|
|
280
|
+
return resolved;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function resolveTemplateConfigValueFromContext(
|
|
284
|
+
config: string,
|
|
285
|
+
env: (name: string) => Promise<string | undefined>,
|
|
286
|
+
): Promise<string | undefined> {
|
|
287
|
+
let resolved = "";
|
|
288
|
+
for (let index = 0; index < config.length; ) {
|
|
289
|
+
const dollarIndex = config.indexOf("$", index);
|
|
290
|
+
if (dollarIndex === -1) return resolved + config.slice(index);
|
|
291
|
+
resolved += config.slice(index, dollarIndex);
|
|
292
|
+
const nextChar = config[dollarIndex + 1];
|
|
293
|
+
if (nextChar === "$" || nextChar === "!") {
|
|
294
|
+
resolved += nextChar;
|
|
295
|
+
index = dollarIndex + 2;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (nextChar === "{") {
|
|
299
|
+
const endIndex = config.indexOf("}", dollarIndex + 2);
|
|
300
|
+
if (endIndex === -1) {
|
|
301
|
+
resolved += "$";
|
|
302
|
+
index = dollarIndex + 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const envValue = await env(config.slice(dollarIndex + 2, endIndex));
|
|
306
|
+
if (envValue === undefined) return undefined;
|
|
307
|
+
resolved += envValue;
|
|
308
|
+
index = endIndex + 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const match = config.slice(dollarIndex + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
312
|
+
if (!match) {
|
|
313
|
+
resolved += "$";
|
|
314
|
+
index = dollarIndex + 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const envValue = await env(match[0]);
|
|
318
|
+
if (envValue === undefined) return undefined;
|
|
319
|
+
resolved += envValue;
|
|
320
|
+
index = dollarIndex + 1 + match[0].length;
|
|
321
|
+
}
|
|
322
|
+
return resolved;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function resolveConfigValue(config: string, { executeCommands }: { executeCommands: boolean }): string | undefined {
|
|
326
|
+
if (config.startsWith("!")) return executeCommands ? executeApiKeyCommand(config) : undefined;
|
|
327
|
+
return resolveTemplateConfigValue(config);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const warnedUnresolvedApiKeys = new Set<string>();
|
|
331
|
+
|
|
332
|
+
function warnUnresolvedApiKeyConfig(providerName: string, config: string): void {
|
|
333
|
+
const key = `${providerName} ${config}`;
|
|
334
|
+
if (warnedUnresolvedApiKeys.has(key)) return;
|
|
335
|
+
warnedUnresolvedApiKeys.add(key);
|
|
336
|
+
process.stderr.write(
|
|
337
|
+
`LiteLLM (${providerName}): configured apiKey did not resolve (unset environment variable?); use $$ for a literal $.\n`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function parseHeaderRecord(value: unknown): Record<string, string> | undefined {
|
|
342
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
343
|
+
const headers: Record<string, string> = {};
|
|
344
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
345
|
+
if (!key.trim()) continue;
|
|
346
|
+
let resolved: string | undefined;
|
|
347
|
+
if (typeof raw === "string") resolved = resolveTemplateConfigValue(raw);
|
|
348
|
+
else if (typeof raw === "number" || typeof raw === "boolean") resolved = String(raw);
|
|
349
|
+
else {
|
|
350
|
+
process.stderr.write(`LiteLLM: ignoring non-primitive header value for "${key}".\n`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (resolved) headers[key] = resolved;
|
|
354
|
+
}
|
|
355
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function parseCustomHeaders(raw: string | undefined): Record<string, string> | undefined {
|
|
359
|
+
const trimmed = cleanConfig(raw);
|
|
360
|
+
if (!trimmed) return undefined;
|
|
361
|
+
try {
|
|
362
|
+
return parseHeaderRecord(JSON.parse(trimmed));
|
|
363
|
+
} catch (error) {
|
|
364
|
+
process.stderr.write(
|
|
365
|
+
`LiteLLM: failed to parse custom headers (${error instanceof Error ? error.message : String(error)}).\n`,
|
|
366
|
+
);
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function resolveHeaders(definition: ProviderDefinition): Record<string, string> | undefined {
|
|
372
|
+
if (typeof definition.headers === "string") return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
|
|
373
|
+
return parseHeaderRecord(definition.headers);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function resolveHeadersFromContext(
|
|
377
|
+
definition: ProviderDefinition,
|
|
378
|
+
env: (name: string) => Promise<string | undefined>,
|
|
379
|
+
): Promise<Record<string, string> | undefined> {
|
|
380
|
+
if (typeof definition.headers === "string")
|
|
381
|
+
return parseCustomHeaders(await resolveTemplateConfigValueFromContext(definition.headers, env));
|
|
382
|
+
return parseHeaderRecord(definition.headers);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function resolveCredentials(
|
|
386
|
+
definition: ProviderDefinition,
|
|
387
|
+
{ executeHelpers = true } = {},
|
|
388
|
+
): Promise<ResolvedCredentials> {
|
|
389
|
+
const configuredBase =
|
|
390
|
+
cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
|
|
391
|
+
const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
|
|
392
|
+
const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
|
|
393
|
+
const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
|
|
394
|
+
const gcloudKey = executeHelpers && useGcloudToken ? (await getGcloudToken())?.trim() : undefined;
|
|
395
|
+
// Resolved lazily so a `!command` key is not executed when a
|
|
396
|
+
// higher-precedence credential (saved auth, gcloud token) already won.
|
|
397
|
+
let configuredKey: string | undefined;
|
|
398
|
+
if (!gcloudKey && definition.apiKeyConfig) {
|
|
399
|
+
configuredKey = resolveConfigValue(definition.apiKeyConfig, { executeCommands: executeHelpers });
|
|
400
|
+
if (configuredKey === undefined && !definition.apiKeyConfig.startsWith("!")) {
|
|
401
|
+
warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const helperKey =
|
|
405
|
+
!gcloudKey && !configuredKey && executeHelpers && envHelperCommand
|
|
406
|
+
? executeApiKeyCommand(envHelperCommand)
|
|
407
|
+
: undefined;
|
|
408
|
+
const apiKey = gcloudKey || configuredKey || helperKey || envKey;
|
|
409
|
+
|
|
410
|
+
let apiKeyConfig: string | undefined;
|
|
411
|
+
if (configuredKey && definition.apiKeyConfig) {
|
|
412
|
+
apiKeyConfig = definition.apiKeyConfig;
|
|
413
|
+
} else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
|
|
414
|
+
apiKeyConfig = definition.apiKeyConfig;
|
|
415
|
+
} else if (helperKey && envHelperCommand) {
|
|
416
|
+
apiKeyConfig = envHelperCommand;
|
|
417
|
+
} else if (!executeHelpers && envHelperCommand) {
|
|
418
|
+
apiKeyConfig = envHelperCommand;
|
|
419
|
+
} else if (envKey) {
|
|
420
|
+
apiKeyConfig = `$${ENV_API_KEY}`;
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
baseUrl: configuredBase ? normalizeBaseUrl(configuredBase, definition.allowInsecureHttp) : undefined,
|
|
424
|
+
apiKey: apiKey || undefined,
|
|
425
|
+
apiKeyConfig,
|
|
426
|
+
apiKeyFromGcloudAdc: Boolean(gcloudKey),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function getDiscoveryTimeoutMs(): number {
|
|
431
|
+
const raw = process.env[ENV_TIMEOUT];
|
|
432
|
+
if (raw === undefined) return DEFAULT_TIMEOUT_MS;
|
|
433
|
+
const parsed = Number.parseInt(raw, 10);
|
|
434
|
+
if (Number.isNaN(parsed) || parsed < 0) return DEFAULT_TIMEOUT_MS;
|
|
435
|
+
return parsed;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isOffline(): boolean {
|
|
439
|
+
return process.env[ENV_OFFLINE] === "1";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Pi disables all model network access when PI_OFFLINE is set; activation must honour it too. */
|
|
443
|
+
function isHostOffline(): boolean {
|
|
444
|
+
return process.env.PI_OFFLINE !== undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Absolute budget for activation-time discovery. Pi cannot paint its UI until every extension has
|
|
449
|
+
* activated, so this is deliberately capped rather than following LITELLM_DISCOVERY_TIMEOUT_MS,
|
|
450
|
+
* which is a per-request timeout that deployments raise to tens of seconds. Missing the budget only
|
|
451
|
+
* costs the startup seed; Pi's own refresh and /model still populate the catalog.
|
|
452
|
+
*/
|
|
453
|
+
function getSeedTimeoutMs(): number {
|
|
454
|
+
return Math.min(getDiscoveryTimeoutMs(), SEED_TIMEOUT_MS);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isVerboseDiscovery(): boolean {
|
|
458
|
+
return process.env[ENV_VERBOSE_DISCOVERY] === "1";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function normalizeProviderSettings(raw: unknown): RawProviderSettings | undefined {
|
|
462
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
463
|
+
const record = raw as RawProviderSettings;
|
|
464
|
+
if (record.enabled === false) return undefined;
|
|
465
|
+
return record;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function isFeatureEnabled(settings: Record<string, unknown> | undefined, feature: "skills" | "mcp"): boolean {
|
|
469
|
+
const raw = settings?.[feature];
|
|
470
|
+
return !isPlainObject(raw) || raw.enabled !== false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function getProviderDefinitions(settings: Record<string, unknown> | undefined): ProviderDefinition[] {
|
|
474
|
+
const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
|
|
475
|
+
const providerSettings = rawProviders as Record<string, unknown> | undefined;
|
|
476
|
+
const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
|
|
477
|
+
|
|
478
|
+
const makeDefinition = (
|
|
479
|
+
name: string,
|
|
480
|
+
raw: RawProviderSettings | undefined,
|
|
481
|
+
isDefault: boolean,
|
|
482
|
+
): ProviderDefinition => ({
|
|
483
|
+
name,
|
|
484
|
+
displayName: stringSetting(raw?.displayName) ?? (isDefault ? "LiteLLM" : name),
|
|
485
|
+
baseUrl: stringSetting(raw?.baseUrl),
|
|
486
|
+
apiKeyConfig: stringSetting(raw?.apiKey),
|
|
487
|
+
headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
|
|
488
|
+
useDefaultEnv: isDefault,
|
|
489
|
+
useGcloudTokenAuth: isDefault,
|
|
490
|
+
enableOAuth: isDefault,
|
|
491
|
+
allowInsecureHttp: raw?.allowInsecureHttp === true,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
|
|
495
|
+
for (const [name, raw] of Object.entries(providerSettings ?? {})) {
|
|
496
|
+
if (name === PROVIDER_NAME) continue;
|
|
497
|
+
const normalized = normalizeProviderSettings(raw);
|
|
498
|
+
if (!normalized) continue;
|
|
499
|
+
definitions.push(makeDefinition(name, normalized, false));
|
|
500
|
+
}
|
|
501
|
+
return definitions;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const DIFFERENT_BASE_URL = "enter-a-different-url";
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The proxy URL is the one thing a user has to retype on every re-login, and an expired SSO token is
|
|
508
|
+
* the common reason to re-login. Offer whatever we already know: settings, env, or the credential the
|
|
509
|
+
* dead session was issued against.
|
|
510
|
+
*/
|
|
511
|
+
function knownBaseUrl(definition: ProviderDefinition): { url: string; source: string } | undefined {
|
|
512
|
+
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
513
|
+
const candidates: [string | undefined, string][] = [
|
|
514
|
+
[cleanConfig(definition.baseUrl), "configured"],
|
|
515
|
+
[definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined, `$${ENV_BASE_URL}`],
|
|
516
|
+
[
|
|
517
|
+
stored?.type === "oauth"
|
|
518
|
+
? cleanConfig(typeof stored.baseUrl === "string" ? stored.baseUrl : undefined)
|
|
519
|
+
: cleanConfig(stored?.env?.[ENV_BASE_URL]),
|
|
520
|
+
"previous login",
|
|
521
|
+
],
|
|
522
|
+
];
|
|
523
|
+
for (const [candidate, source] of candidates) {
|
|
524
|
+
if (!candidate) continue;
|
|
525
|
+
try {
|
|
526
|
+
return { url: normalizeBaseUrl(candidate, definition.allowInsecureHttp), source };
|
|
527
|
+
} catch {
|
|
528
|
+
// A URL we could not use anyway is not worth offering; try the next candidate.
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function promptBaseUrl(interaction: AuthInteraction, definition: ProviderDefinition): Promise<string> {
|
|
535
|
+
const known = knownBaseUrl(definition);
|
|
536
|
+
if (known) {
|
|
537
|
+
const choice = await interaction.prompt({
|
|
538
|
+
type: "select",
|
|
539
|
+
message: "LiteLLM proxy URL:",
|
|
540
|
+
// Pi's login selector renders labels only, so the source has to ride along in the label.
|
|
541
|
+
options: [
|
|
542
|
+
{ id: known.url, label: `${known.url} (${known.source})` },
|
|
543
|
+
{ id: DIFFERENT_BASE_URL, label: "Enter a different URL…" },
|
|
544
|
+
],
|
|
545
|
+
});
|
|
546
|
+
if (choice === known.url) return known.url;
|
|
547
|
+
}
|
|
548
|
+
const rawBaseUrl = (
|
|
549
|
+
await interaction.prompt({
|
|
550
|
+
type: "text",
|
|
551
|
+
message: "Enter LiteLLM proxy URL (no trailing /v1):",
|
|
552
|
+
placeholder: "https://litellm.example.com",
|
|
553
|
+
})
|
|
554
|
+
).trim();
|
|
555
|
+
if (!rawBaseUrl) throw new Error("Base URL is required");
|
|
556
|
+
return normalizeBaseUrl(rawBaseUrl, definition.allowInsecureHttp);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function loginApiKey(interaction: AuthInteraction, definition: ProviderDefinition): Promise<ApiKeyCredential> {
|
|
560
|
+
const baseUrl = await promptBaseUrl(interaction, definition);
|
|
561
|
+
const key = (await interaction.prompt({ type: "secret", message: "Enter API key:" })).trim();
|
|
562
|
+
if (!key) throw new Error("Both base URL and API key are required");
|
|
563
|
+
return { type: "api_key", key, env: { [ENV_BASE_URL]: baseUrl } };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
type CliSsoStart = { loginId: string; pollSecret: string; userCode: string; expiresInSeconds: number };
|
|
567
|
+
|
|
568
|
+
type CliAuthDiscovery = {
|
|
569
|
+
issuer: string;
|
|
570
|
+
authorizationEndpoint: string;
|
|
571
|
+
tokenEndpoint: string;
|
|
572
|
+
registrationEndpoint: string;
|
|
573
|
+
resource: string;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
type PkceCredentials = OAuthCredentials & {
|
|
577
|
+
flow: typeof PKCE_FLOW;
|
|
578
|
+
baseUrl: string;
|
|
579
|
+
clientId: string;
|
|
580
|
+
tokenEndpoint: string;
|
|
581
|
+
resource: string;
|
|
582
|
+
userId?: string;
|
|
583
|
+
teamId?: string;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
type PkceToken = {
|
|
587
|
+
access: string;
|
|
588
|
+
refresh: string;
|
|
589
|
+
expires: number;
|
|
590
|
+
userId?: string;
|
|
591
|
+
teamId?: string;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
type PkceTokenResult = { ok: true; token: PkceToken } | { ok: false; transient: boolean; message: string };
|
|
595
|
+
|
|
596
|
+
function authRequestHeaders(headers?: Record<string, string>, contentType?: string): Headers {
|
|
597
|
+
const result = new Headers(headers);
|
|
598
|
+
result.set("Accept", "application/json");
|
|
599
|
+
if (contentType) result.set("Content-Type", contentType);
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function canonicalIssuer(value: string): string {
|
|
604
|
+
const url = new URL(value);
|
|
605
|
+
if (url.username || url.password || url.search || url.hash) throw new Error("LiteLLM CLI auth has invalid issuer");
|
|
606
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "") || "/"}`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function sameOriginUrl(value: unknown, issuer: URL, field: string): string {
|
|
610
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`LiteLLM CLI auth discovery has invalid ${field}`);
|
|
611
|
+
let url: URL;
|
|
612
|
+
try {
|
|
613
|
+
url = new URL(value);
|
|
614
|
+
} catch {
|
|
615
|
+
throw new Error(`LiteLLM CLI auth discovery has invalid ${field}`);
|
|
616
|
+
}
|
|
617
|
+
if (url.protocol !== issuer.protocol || url.origin !== issuer.origin)
|
|
618
|
+
throw new Error(`LiteLLM CLI auth discovery has cross-origin ${field}`);
|
|
619
|
+
if (url.username || url.password || url.hash) throw new Error(`LiteLLM CLI auth discovery has invalid ${field}`);
|
|
620
|
+
return value.trim();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function isAuthToken(value: unknown): value is string {
|
|
624
|
+
return typeof value === "string" && /^[\x21-\x7e]+$/.test(value);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function readAuthJson(response: Response, signal: AbortSignal | undefined, stage: string): Promise<unknown> {
|
|
628
|
+
try {
|
|
629
|
+
return await response.json();
|
|
630
|
+
} catch {
|
|
631
|
+
if (signal?.aborted) throw signal.reason;
|
|
632
|
+
throw new Error(`LiteLLM CLI auth ${stage} returned invalid JSON`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function discoverPkce(
|
|
637
|
+
baseUrl: string,
|
|
638
|
+
signal?: AbortSignal,
|
|
639
|
+
headers?: Record<string, string>,
|
|
640
|
+
): Promise<CliAuthDiscovery | undefined> {
|
|
641
|
+
canonicalIssuer(baseUrl);
|
|
642
|
+
const response = await fetch(`${baseUrl}${CLI_AUTH_DISCOVERY_PATH}`, {
|
|
643
|
+
headers: authRequestHeaders(headers),
|
|
644
|
+
redirect: "manual",
|
|
645
|
+
signal: boundedLoginSignal(signal),
|
|
646
|
+
});
|
|
647
|
+
if (response.status === 404) return undefined;
|
|
648
|
+
if (!response.ok) throw new Error(`LiteLLM CLI auth discovery failed (HTTP ${response.status})`);
|
|
649
|
+
const data = await readAuthJson(response, signal, "discovery");
|
|
650
|
+
if (!isPlainObject(data) || data.contract_version !== 1)
|
|
651
|
+
throw new Error("LiteLLM CLI auth discovery has unsupported contract version");
|
|
652
|
+
if (!Array.isArray(data.code_challenge_methods_supported) || !data.code_challenge_methods_supported.includes("S256"))
|
|
653
|
+
throw new Error("LiteLLM CLI auth discovery does not support S256");
|
|
654
|
+
if (typeof data.issuer !== "string" || !data.issuer.trim())
|
|
655
|
+
throw new Error("LiteLLM CLI auth discovery has invalid issuer");
|
|
656
|
+
let issuer: URL;
|
|
657
|
+
try {
|
|
658
|
+
issuer = new URL(data.issuer);
|
|
659
|
+
} catch {
|
|
660
|
+
throw new Error("LiteLLM CLI auth discovery has invalid issuer");
|
|
661
|
+
}
|
|
662
|
+
if (canonicalIssuer(data.issuer) !== canonicalIssuer(baseUrl))
|
|
663
|
+
throw new Error("LiteLLM CLI auth discovery issuer does not match the proxy URL");
|
|
664
|
+
return {
|
|
665
|
+
issuer: data.issuer,
|
|
666
|
+
authorizationEndpoint: sameOriginUrl(data.authorization_endpoint, issuer, "authorization endpoint"),
|
|
667
|
+
tokenEndpoint: sameOriginUrl(data.token_endpoint, issuer, "token endpoint"),
|
|
668
|
+
registrationEndpoint: sameOriginUrl(data.registration_endpoint, issuer, "registration endpoint"),
|
|
669
|
+
resource: sameOriginUrl(data.resource, issuer, "resource"),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function requestPkceToken(
|
|
674
|
+
endpoint: string,
|
|
675
|
+
form: URLSearchParams,
|
|
676
|
+
signal: AbortSignal | undefined,
|
|
677
|
+
headers?: Record<string, string>,
|
|
678
|
+
existingRefreshToken?: string,
|
|
679
|
+
): Promise<PkceTokenResult> {
|
|
680
|
+
let response: Response;
|
|
681
|
+
try {
|
|
682
|
+
response = await fetch(endpoint, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
headers: authRequestHeaders(headers, "application/x-www-form-urlencoded"),
|
|
685
|
+
body: form,
|
|
686
|
+
redirect: "manual",
|
|
687
|
+
signal: boundedLoginSignal(signal),
|
|
688
|
+
});
|
|
689
|
+
} catch {
|
|
690
|
+
if (signal?.aborted) throw signal.reason;
|
|
691
|
+
return { ok: false, transient: true, message: "LiteLLM token exchange failed (network error)" };
|
|
692
|
+
}
|
|
693
|
+
let data: Record<string, unknown> | undefined;
|
|
694
|
+
try {
|
|
695
|
+
const parsed = (await response.json()) as unknown;
|
|
696
|
+
if (isPlainObject(parsed)) data = parsed;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (signal?.aborted) throw signal.reason;
|
|
699
|
+
if (response.ok && !(error instanceof SyntaxError)) {
|
|
700
|
+
return { ok: false, transient: true, message: "LiteLLM token exchange failed (network error)" };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
signal?.throwIfAborted();
|
|
704
|
+
if (!response.ok) {
|
|
705
|
+
return {
|
|
706
|
+
ok: false,
|
|
707
|
+
transient: response.status === 429 || response.status >= 500,
|
|
708
|
+
message:
|
|
709
|
+
data?.error === "invalid_grant"
|
|
710
|
+
? "LiteLLM token exchange rejected (invalid_grant)"
|
|
711
|
+
: `LiteLLM token exchange failed (HTTP ${response.status})`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const expires = typeof data?.expires_in === "number" ? Date.now() + data.expires_in * 1_000 : NaN;
|
|
715
|
+
if (
|
|
716
|
+
!isAuthToken(data?.access_token) ||
|
|
717
|
+
(!isAuthToken(data.refresh_token) && !isAuthToken(existingRefreshToken)) ||
|
|
718
|
+
typeof data.token_type !== "string" ||
|
|
719
|
+
data.token_type.toLowerCase() !== "bearer" ||
|
|
720
|
+
typeof data.expires_in !== "number" ||
|
|
721
|
+
!Number.isFinite(data.expires_in) ||
|
|
722
|
+
data.expires_in <= 0 ||
|
|
723
|
+
!Number.isSafeInteger(expires)
|
|
724
|
+
) {
|
|
725
|
+
return { ok: false, transient: false, message: "LiteLLM token exchange returned an invalid response" };
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
ok: true,
|
|
729
|
+
token: {
|
|
730
|
+
access: data.access_token,
|
|
731
|
+
// A refresh may not rotate the refresh token; keep the existing one when the server omits it.
|
|
732
|
+
refresh: isAuthToken(data.refresh_token) ? data.refresh_token : existingRefreshToken!,
|
|
733
|
+
expires,
|
|
734
|
+
userId: typeof data.user_id === "string" && data.user_id ? data.user_id : undefined,
|
|
735
|
+
teamId: typeof data.team_id === "string" && data.team_id ? data.team_id : undefined,
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function loginPkce(
|
|
741
|
+
interaction: AuthInteraction,
|
|
742
|
+
baseUrl: string,
|
|
743
|
+
discovery: CliAuthDiscovery,
|
|
744
|
+
headers?: Record<string, string>,
|
|
745
|
+
): Promise<OAuthCredential> {
|
|
746
|
+
const server = createServer();
|
|
747
|
+
let settleCallback: ((result: { code?: string; error?: Error }) => void) | undefined;
|
|
748
|
+
server.on("request", (request, response) => {
|
|
749
|
+
response.setHeader("Cache-Control", "no-store");
|
|
750
|
+
let url: URL;
|
|
751
|
+
try {
|
|
752
|
+
url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
753
|
+
} catch {
|
|
754
|
+
response.writeHead(400).end("Invalid callback URL");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (url.pathname !== "/callback") {
|
|
758
|
+
response.writeHead(404).end();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (request.method !== "GET") {
|
|
762
|
+
response.writeHead(405, { Allow: "GET" }).end();
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (url.searchParams.get("state") !== state) {
|
|
766
|
+
response.writeHead(400).end("Invalid OAuth state");
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const error = url.searchParams.get("error");
|
|
770
|
+
if (error) {
|
|
771
|
+
response.writeHead(400).end("OAuth login failed");
|
|
772
|
+
settleCallback?.({ error: new Error("LiteLLM PKCE login was denied") });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const code = url.searchParams.get("code");
|
|
776
|
+
if (!code) {
|
|
777
|
+
response.writeHead(400).end("Missing OAuth code");
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
response.writeHead(200, {
|
|
781
|
+
"Cache-Control": "no-store",
|
|
782
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
783
|
+
});
|
|
784
|
+
response.end("<!doctype html><title>LiteLLM login complete</title><p>You can close this window.</p>");
|
|
785
|
+
settleCallback?.({ code });
|
|
786
|
+
});
|
|
787
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
788
|
+
const state = randomBytes(32).toString("base64url");
|
|
789
|
+
try {
|
|
790
|
+
await new Promise<void>((resolve, reject) => {
|
|
791
|
+
const onError = (error: Error) => reject(error);
|
|
792
|
+
server.once("error", onError);
|
|
793
|
+
server.listen(0, "127.0.0.1", () => {
|
|
794
|
+
server.off("error", onError);
|
|
795
|
+
resolve();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
const address = server.address();
|
|
799
|
+
if (!address || typeof address === "string") throw new Error("LiteLLM PKCE callback failed to start");
|
|
800
|
+
const redirectUri = `http://127.0.0.1:${address.port}/callback`;
|
|
801
|
+
const registrationResponse = await fetch(discovery.registrationEndpoint, {
|
|
802
|
+
method: "POST",
|
|
803
|
+
headers: authRequestHeaders(headers, "application/json"),
|
|
804
|
+
body: JSON.stringify({
|
|
805
|
+
client_name: "pi-provider-litellm",
|
|
806
|
+
redirect_uris: [redirectUri],
|
|
807
|
+
token_endpoint_auth_method: "none",
|
|
808
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
809
|
+
response_types: ["code"],
|
|
810
|
+
}),
|
|
811
|
+
redirect: "manual",
|
|
812
|
+
signal: boundedLoginSignal(interaction.signal),
|
|
813
|
+
});
|
|
814
|
+
if (!registrationResponse.ok)
|
|
815
|
+
throw new Error(`LiteLLM PKCE client registration failed (HTTP ${registrationResponse.status})`);
|
|
816
|
+
const registration = await readAuthJson(registrationResponse, interaction.signal, "registration");
|
|
817
|
+
if (
|
|
818
|
+
!isPlainObject(registration) ||
|
|
819
|
+
!isAuthToken(registration.client_id) ||
|
|
820
|
+
!Array.isArray(registration.redirect_uris) ||
|
|
821
|
+
!registration.redirect_uris.includes(redirectUri)
|
|
822
|
+
) {
|
|
823
|
+
throw new Error("LiteLLM PKCE client registration returned an invalid response");
|
|
824
|
+
}
|
|
825
|
+
const authorizationUrl = new URL(discovery.authorizationEndpoint);
|
|
826
|
+
for (const [key, value] of Object.entries({
|
|
827
|
+
client_id: registration.client_id,
|
|
828
|
+
redirect_uri: redirectUri,
|
|
829
|
+
response_type: "code",
|
|
830
|
+
resource: discovery.resource,
|
|
831
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
832
|
+
code_challenge_method: "S256",
|
|
833
|
+
state,
|
|
834
|
+
}))
|
|
835
|
+
authorizationUrl.searchParams.set(key, value);
|
|
836
|
+
interaction.notify({
|
|
837
|
+
type: "auth_url",
|
|
838
|
+
url: authorizationUrl.toString(),
|
|
839
|
+
instructions: "Open this URL in a browser to sign in with LiteLLM.",
|
|
840
|
+
});
|
|
841
|
+
const code = await new Promise<string>((resolve, reject) => {
|
|
842
|
+
const timeout = setTimeout(() => finish(new Error("LiteLLM PKCE login timed out")), PKCE_CALLBACK_TIMEOUT_MS);
|
|
843
|
+
const onAbort = () => finish(interaction.signal?.reason ?? new Error("LiteLLM PKCE login cancelled"));
|
|
844
|
+
const finish = (error?: Error, value?: string) => {
|
|
845
|
+
clearTimeout(timeout);
|
|
846
|
+
interaction.signal?.removeEventListener("abort", onAbort);
|
|
847
|
+
settleCallback = undefined;
|
|
848
|
+
if (error) reject(error);
|
|
849
|
+
else resolve(value!);
|
|
850
|
+
};
|
|
851
|
+
settleCallback = ({ code, error }) => finish(error, code);
|
|
852
|
+
interaction.signal?.addEventListener("abort", onAbort, { once: true });
|
|
853
|
+
if (interaction.signal?.aborted) onAbort();
|
|
854
|
+
});
|
|
855
|
+
const result = await requestPkceToken(
|
|
856
|
+
discovery.tokenEndpoint,
|
|
857
|
+
new URLSearchParams({
|
|
858
|
+
grant_type: "authorization_code",
|
|
859
|
+
code,
|
|
860
|
+
redirect_uri: redirectUri,
|
|
861
|
+
client_id: registration.client_id,
|
|
862
|
+
code_verifier: verifier,
|
|
863
|
+
resource: discovery.resource,
|
|
864
|
+
}),
|
|
865
|
+
interaction.signal,
|
|
866
|
+
headers,
|
|
867
|
+
);
|
|
868
|
+
if (!result.ok) throw new Error(result.message);
|
|
869
|
+
const credential: PkceCredentials = {
|
|
870
|
+
type: "oauth",
|
|
871
|
+
access: result.token.access,
|
|
872
|
+
refresh: result.token.refresh,
|
|
873
|
+
expires: result.token.expires,
|
|
874
|
+
baseUrl,
|
|
875
|
+
flow: PKCE_FLOW,
|
|
876
|
+
clientId: registration.client_id,
|
|
877
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
878
|
+
resource: discovery.resource,
|
|
879
|
+
userId: result.token.userId,
|
|
880
|
+
teamId: result.token.teamId,
|
|
881
|
+
};
|
|
882
|
+
return { ...credential, type: "oauth" };
|
|
883
|
+
} finally {
|
|
884
|
+
await new Promise<void>((resolve) => {
|
|
885
|
+
server.close(() => resolve());
|
|
886
|
+
server.closeAllConnections();
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function startCliSso(
|
|
892
|
+
baseUrl: string,
|
|
893
|
+
signal?: AbortSignal,
|
|
894
|
+
headers?: Record<string, string>,
|
|
895
|
+
): Promise<CliSsoStart | undefined> {
|
|
896
|
+
const response = await fetch(`${baseUrl}/sso/cli/start`, {
|
|
897
|
+
method: "POST",
|
|
898
|
+
headers,
|
|
899
|
+
signal: boundedLoginSignal(signal),
|
|
900
|
+
});
|
|
901
|
+
if (response.status === 404 || response.status === 405) return undefined;
|
|
902
|
+
if (!response.ok) throw new Error(`LiteLLM CLI SSO start failed (HTTP ${response.status})`);
|
|
903
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
904
|
+
if (
|
|
905
|
+
typeof data.login_id !== "string" ||
|
|
906
|
+
!data.login_id ||
|
|
907
|
+
typeof data.poll_secret !== "string" ||
|
|
908
|
+
!data.poll_secret ||
|
|
909
|
+
typeof data.user_code !== "string" ||
|
|
910
|
+
!data.user_code
|
|
911
|
+
) {
|
|
912
|
+
throw new Error("LiteLLM CLI SSO start returned an invalid response");
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
loginId: data.login_id,
|
|
916
|
+
pollSecret: data.poll_secret,
|
|
917
|
+
userCode: data.user_code,
|
|
918
|
+
expiresInSeconds:
|
|
919
|
+
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
|
|
920
|
+
? data.expires_in
|
|
921
|
+
: CLI_SSO_EXPIRES_IN_SECONDS,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function waitForNextCliSsoPoll(interaction: AuthInteraction): Promise<void> {
|
|
926
|
+
try {
|
|
927
|
+
await delay(CLI_SSO_POLL_INTERVAL_MS, undefined, interaction.signal ? { signal: interaction.signal } : undefined);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (interaction.signal?.aborted) throw interaction.signal.reason;
|
|
930
|
+
throw error;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function pollCliSso(
|
|
935
|
+
baseUrl: string,
|
|
936
|
+
start: CliSsoStart,
|
|
937
|
+
interaction: AuthInteraction,
|
|
938
|
+
headers?: Record<string, string>,
|
|
939
|
+
): Promise<{ access: string; expiresInSeconds?: number }> {
|
|
940
|
+
const deadline = Date.now() + start.expiresInSeconds * 1_000;
|
|
941
|
+
const pollUrl = new URL(`${baseUrl}/sso/cli/poll/${encodeURIComponent(start.loginId)}`);
|
|
942
|
+
let teamId: string | undefined;
|
|
943
|
+
while (Date.now() < deadline) {
|
|
944
|
+
if (teamId) pollUrl.searchParams.set("team_id", teamId);
|
|
945
|
+
let response: Response;
|
|
946
|
+
try {
|
|
947
|
+
response = await fetch(pollUrl, {
|
|
948
|
+
headers: { ...headers, "x-litellm-cli-poll-secret": start.pollSecret },
|
|
949
|
+
signal: boundedLoginSignal(interaction.signal),
|
|
950
|
+
});
|
|
951
|
+
} catch {
|
|
952
|
+
if (interaction.signal?.aborted) throw interaction.signal.reason;
|
|
953
|
+
await waitForNextCliSsoPoll(interaction);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
if (response.status === 429 || response.status >= 500) {
|
|
957
|
+
await waitForNextCliSsoPoll(interaction);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (response.status === 400) throw new Error("LiteLLM CLI SSO login expired or is invalid");
|
|
961
|
+
if (!response.ok) throw new Error(`LiteLLM CLI SSO polling failed (HTTP ${response.status})`);
|
|
962
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
963
|
+
if (data.status === "ready" && typeof data.key === "string" && data.key) {
|
|
964
|
+
return {
|
|
965
|
+
access: data.key,
|
|
966
|
+
expiresInSeconds:
|
|
967
|
+
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0
|
|
968
|
+
? data.expires_in
|
|
969
|
+
: undefined,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
if (data.status === "ready" && data.requires_team_selection === true && !teamId) {
|
|
973
|
+
const details = Array.isArray(data.team_details) ? data.team_details : [];
|
|
974
|
+
const teams = details.flatMap((team) => {
|
|
975
|
+
if (!team || typeof team !== "object") return [];
|
|
976
|
+
const record = team as Record<string, unknown>;
|
|
977
|
+
const id = typeof record.team_id === "string" ? record.team_id : record.id;
|
|
978
|
+
const alias = record.team_alias;
|
|
979
|
+
return typeof id === "string" && id ? [{ id, label: typeof alias === "string" && alias ? alias : id }] : [];
|
|
980
|
+
});
|
|
981
|
+
if (teams.length === 0 && Array.isArray(data.teams))
|
|
982
|
+
teams.push(...data.teams.flatMap((id) => (typeof id === "string" && id ? [{ id, label: id }] : [])));
|
|
983
|
+
if (teams.length === 0) throw new Error("LiteLLM CLI SSO requested team selection without any teams");
|
|
984
|
+
const selected = await interaction.prompt({ type: "select", message: "Select a LiteLLM team:", options: teams });
|
|
985
|
+
if (!teams.some((team) => team.id === selected)) throw new Error("Invalid LiteLLM team selection");
|
|
986
|
+
teamId = selected;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (data.status !== "pending") throw new Error("LiteLLM CLI SSO polling returned an invalid response");
|
|
990
|
+
await waitForNextCliSsoPoll(interaction);
|
|
991
|
+
}
|
|
992
|
+
throw new Error("LiteLLM CLI SSO login expired");
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function loginWithPastedToken(
|
|
996
|
+
interaction: AuthInteraction,
|
|
997
|
+
baseUrl: string,
|
|
998
|
+
headers?: Record<string, string>,
|
|
999
|
+
): Promise<OAuthCredential> {
|
|
1000
|
+
interaction.notify({
|
|
1001
|
+
type: "auth_url",
|
|
1002
|
+
url: `${baseUrl}/sso/key/generate`,
|
|
1003
|
+
instructions: "Authenticate via SSO, then copy your token from the LiteLLM UI.",
|
|
1004
|
+
});
|
|
1005
|
+
const rawToken = (await interaction.prompt({ type: "secret", message: "Paste your SSO token from the LiteLLM UI:" }))
|
|
1006
|
+
.trim()
|
|
1007
|
+
.replace(/^Bearer\s+/i, "")
|
|
1008
|
+
.trim();
|
|
1009
|
+
if (!rawToken) throw new Error("SSO token is required");
|
|
1010
|
+
const wantVirtualKey = (
|
|
1011
|
+
await interaction.prompt({ type: "text", message: "Generate a LiteLLM virtual key from this token? (y/n):" })
|
|
1012
|
+
)
|
|
1013
|
+
.trim()
|
|
1014
|
+
.toLowerCase();
|
|
1015
|
+
let access = rawToken;
|
|
1016
|
+
let expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
|
|
1017
|
+
if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
|
|
1018
|
+
try {
|
|
1019
|
+
interaction.notify({ type: "progress", message: "Generating virtual key..." });
|
|
1020
|
+
const generated = await generateVirtualKey(baseUrl, rawToken, interaction.signal, headers);
|
|
1021
|
+
access = generated.key;
|
|
1022
|
+
expires =
|
|
1023
|
+
generated.expiresAt === undefined
|
|
1024
|
+
? PERMANENT_TOKEN_EXPIRES_AT
|
|
1025
|
+
: Math.max(Date.now(), generated.expiresAt - TOKEN_REFRESH_LEAD_MS);
|
|
1026
|
+
interaction.notify({ type: "progress", message: "Virtual key generated and will be used for API calls." });
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
if (interaction.signal?.aborted) throw interaction.signal.reason;
|
|
1029
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1030
|
+
interaction.notify({
|
|
1031
|
+
type: "progress",
|
|
1032
|
+
message: `LiteLLM: virtual key generation failed (${message}); using SSO token directly.`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return { type: "oauth", access, refresh: "", expires, baseUrl };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function loginOAuth(interaction: AuthInteraction, definition: ProviderDefinition): Promise<OAuthCredential> {
|
|
1040
|
+
const headers = resolveHeaders(definition);
|
|
1041
|
+
const baseUrl = await promptBaseUrl(interaction, definition);
|
|
1042
|
+
const discovery = await discoverPkce(baseUrl, interaction.signal, headers);
|
|
1043
|
+
if (discovery) return loginPkce(interaction, baseUrl, discovery, headers);
|
|
1044
|
+
const cliSso = await startCliSso(baseUrl, interaction.signal, headers);
|
|
1045
|
+
if (!cliSso) return loginWithPastedToken(interaction, baseUrl, headers);
|
|
1046
|
+
interaction.notify({
|
|
1047
|
+
type: "device_code",
|
|
1048
|
+
userCode: cliSso.userCode,
|
|
1049
|
+
verificationUri: `${baseUrl}/sso/key/generate?${new URLSearchParams({ source: "litellm-cli", key: cliSso.loginId })}`,
|
|
1050
|
+
expiresInSeconds: cliSso.expiresInSeconds,
|
|
1051
|
+
});
|
|
1052
|
+
const result = await pollCliSso(baseUrl, cliSso, interaction, headers);
|
|
1053
|
+
return {
|
|
1054
|
+
type: "oauth",
|
|
1055
|
+
access: result.access,
|
|
1056
|
+
refresh: "",
|
|
1057
|
+
expires: result.expiresInSeconds
|
|
1058
|
+
? Date.now() + result.expiresInSeconds * 1_000
|
|
1059
|
+
: tokenExpiresAt(result.access, configuredCliJwtExpiresAt()),
|
|
1060
|
+
baseUrl,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Keyed by refresh token (so one credential's failures never delay another's refresh):
|
|
1065
|
+
// how long to skip re-attempting a refresh after a transient failure, so callers
|
|
1066
|
+
// serialized behind Pi's credential lock don't each fire another request at the
|
|
1067
|
+
// still-failing token endpoint (e.g. during an outage or rate limit).
|
|
1068
|
+
const pkceTransientRefreshBackoff = new Map<string, number>();
|
|
1069
|
+
|
|
1070
|
+
async function refreshLiteLLM(
|
|
1071
|
+
credentials: OAuthCredentials,
|
|
1072
|
+
definition: ProviderDefinition,
|
|
1073
|
+
signal?: AbortSignal,
|
|
1074
|
+
): Promise<OAuthCredentials> {
|
|
1075
|
+
signal?.throwIfAborted();
|
|
1076
|
+
if (credentials.flow === PKCE_FLOW) {
|
|
1077
|
+
if (
|
|
1078
|
+
typeof credentials.baseUrl !== "string" ||
|
|
1079
|
+
!isAuthToken(credentials.clientId) ||
|
|
1080
|
+
!isAuthToken(credentials.access) ||
|
|
1081
|
+
!isAuthToken(credentials.refresh) ||
|
|
1082
|
+
!Number.isSafeInteger(credentials.expires)
|
|
1083
|
+
) {
|
|
1084
|
+
throw new Error("Invalid LiteLLM PKCE credential; run /login litellm again");
|
|
1085
|
+
}
|
|
1086
|
+
if (Date.now() < credentials.expires) {
|
|
1087
|
+
const backoffUntil = pkceTransientRefreshBackoff.get(credentials.refresh);
|
|
1088
|
+
if (backoffUntil !== undefined && Date.now() < backoffUntil) return credentials;
|
|
1089
|
+
}
|
|
1090
|
+
const baseUrl = requireCredentialRoot(
|
|
1091
|
+
normalizeBaseUrl(credentials.baseUrl, definition.allowInsecureHttp),
|
|
1092
|
+
definition.name,
|
|
1093
|
+
);
|
|
1094
|
+
canonicalIssuer(baseUrl);
|
|
1095
|
+
const issuer = new URL(baseUrl);
|
|
1096
|
+
const tokenEndpoint = sameOriginUrl(credentials.tokenEndpoint, issuer, "token endpoint");
|
|
1097
|
+
const resource = sameOriginUrl(credentials.resource, issuer, "resource");
|
|
1098
|
+
const result = await requestPkceToken(
|
|
1099
|
+
tokenEndpoint,
|
|
1100
|
+
new URLSearchParams({
|
|
1101
|
+
grant_type: "refresh_token",
|
|
1102
|
+
refresh_token: credentials.refresh,
|
|
1103
|
+
client_id: credentials.clientId,
|
|
1104
|
+
resource,
|
|
1105
|
+
}),
|
|
1106
|
+
signal,
|
|
1107
|
+
resolveHeaders(definition),
|
|
1108
|
+
credentials.refresh,
|
|
1109
|
+
);
|
|
1110
|
+
if (!result.ok && result.transient && Date.now() < credentials.expires) {
|
|
1111
|
+
pkceTransientRefreshBackoff.set(credentials.refresh, Date.now() + PKCE_TRANSIENT_REFRESH_BACKOFF_MS);
|
|
1112
|
+
return credentials;
|
|
1113
|
+
}
|
|
1114
|
+
pkceTransientRefreshBackoff.delete(credentials.refresh);
|
|
1115
|
+
if (!result.ok) throw new Error(`${result.message}; run /login litellm again`);
|
|
1116
|
+
return { ...credentials, ...result.token };
|
|
1117
|
+
}
|
|
1118
|
+
if (!credentials.refresh.startsWith("!")) {
|
|
1119
|
+
if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
|
|
1120
|
+
throw new Error("LiteLLM credential cannot be refreshed; run /login litellm again");
|
|
1121
|
+
}
|
|
1122
|
+
return credentials;
|
|
1123
|
+
}
|
|
1124
|
+
const access = executeApiKeyCommand(credentials.refresh);
|
|
1125
|
+
return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
async function configuredBaseUrl(
|
|
1129
|
+
definition: ProviderDefinition,
|
|
1130
|
+
ctx: { env(name: string): Promise<string | undefined> },
|
|
1131
|
+
credential?: ApiKeyCredential,
|
|
1132
|
+
): Promise<string | undefined> {
|
|
1133
|
+
return (
|
|
1134
|
+
cleanConfig(credential?.env?.[ENV_BASE_URL]) ??
|
|
1135
|
+
cleanConfig(definition.baseUrl) ??
|
|
1136
|
+
(definition.useDefaultEnv ? cleanConfig(await ctx.env(ENV_BASE_URL)) : undefined)
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function resolveApiKeyAuth(
|
|
1141
|
+
definition: ProviderDefinition,
|
|
1142
|
+
ctx: { env(name: string): Promise<string | undefined> },
|
|
1143
|
+
credential?: ApiKeyCredential,
|
|
1144
|
+
executeHelpers = true,
|
|
1145
|
+
) {
|
|
1146
|
+
const baseUrl = await configuredBaseUrl(definition, ctx, credential);
|
|
1147
|
+
const stored = credential?.key
|
|
1148
|
+
? resolveConfigValue(credential.key, { executeCommands: executeHelpers })?.trim()
|
|
1149
|
+
: undefined;
|
|
1150
|
+
let source: string | undefined;
|
|
1151
|
+
let creds: ResolvedCredentials;
|
|
1152
|
+
if (stored) {
|
|
1153
|
+
source = "stored credential";
|
|
1154
|
+
creds = {
|
|
1155
|
+
baseUrl: baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined,
|
|
1156
|
+
apiKey: stored,
|
|
1157
|
+
};
|
|
1158
|
+
} else {
|
|
1159
|
+
creds = await resolveCredentials(
|
|
1160
|
+
{ ...definition, apiKeyConfig: undefined, useDefaultEnv: false },
|
|
1161
|
+
{ executeHelpers },
|
|
1162
|
+
);
|
|
1163
|
+
if (!creds.apiKey && definition.apiKeyConfig) {
|
|
1164
|
+
const configured = definition.apiKeyConfig.startsWith("!")
|
|
1165
|
+
? executeHelpers
|
|
1166
|
+
? executeApiKeyCommand(definition.apiKeyConfig)
|
|
1167
|
+
: undefined
|
|
1168
|
+
: await resolveTemplateConfigValueFromContext(definition.apiKeyConfig, ctx.env);
|
|
1169
|
+
if (configured) {
|
|
1170
|
+
creds.apiKey = configured;
|
|
1171
|
+
creds.apiKeyConfig = definition.apiKeyConfig;
|
|
1172
|
+
} else if (!definition.apiKeyConfig.startsWith("!")) {
|
|
1173
|
+
warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (!creds.apiKey && definition.useDefaultEnv) {
|
|
1177
|
+
const helper = normalizeCommand(await ctx.env(ENV_API_KEY_HELPER));
|
|
1178
|
+
if (helper) {
|
|
1179
|
+
creds.apiKey = executeHelpers ? executeApiKeyCommand(helper) : undefined;
|
|
1180
|
+
creds.apiKeyConfig = helper;
|
|
1181
|
+
source = ENV_API_KEY_HELPER;
|
|
1182
|
+
} else {
|
|
1183
|
+
const envKey = cleanConfig(await ctx.env(ENV_API_KEY));
|
|
1184
|
+
if (envKey) {
|
|
1185
|
+
creds.apiKey = envKey;
|
|
1186
|
+
creds.apiKeyConfig = ENV_API_KEY;
|
|
1187
|
+
source = ENV_API_KEY;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (!creds.baseUrl && baseUrl) creds.baseUrl = normalizeBaseUrl(baseUrl, definition.allowInsecureHttp);
|
|
1192
|
+
}
|
|
1193
|
+
if (!creds.apiKey) return undefined;
|
|
1194
|
+
const normalizedRoot = baseUrl ? normalizeBaseUrl(baseUrl, definition.allowInsecureHttp) : undefined;
|
|
1195
|
+
// Pin the request host to the root resolved from the credential. Pi's provider composer
|
|
1196
|
+
// routes an off-catalog model through a global API implementation that trusts model.baseUrl
|
|
1197
|
+
// verbatim, bypassing the provider's own host guard; Models.applyAuth overrides model.baseUrl
|
|
1198
|
+
// with auth.baseUrl on every path, so this keeps the credential from reaching a stale or
|
|
1199
|
+
// attacker-supplied host. Fail auth resolution when no usable root resolves rather than
|
|
1200
|
+
// returning a key with no pinned baseUrl, which would let the global fallback use a stale
|
|
1201
|
+
// or foreign model.baseUrl instead.
|
|
1202
|
+
const pinnedRoot = requireCredentialRoot(
|
|
1203
|
+
resolveCredentialRoot(definition, credential, normalizedRoot),
|
|
1204
|
+
definition.name,
|
|
1205
|
+
);
|
|
1206
|
+
return {
|
|
1207
|
+
auth: {
|
|
1208
|
+
apiKey: creds.apiKey,
|
|
1209
|
+
headers: await resolveHeadersFromContext(definition, ctx.env),
|
|
1210
|
+
baseUrl: pinnedRoot,
|
|
1211
|
+
},
|
|
1212
|
+
env: normalizedRoot ? { [ENV_BASE_URL]: normalizedRoot } : undefined,
|
|
1213
|
+
source: source ?? (creds.apiKeyFromGcloudAdc ? GCLOUD_ADC_SOURCE : undefined) ?? creds.apiKeyConfig ?? ENV_API_KEY,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function createProviderAuth(
|
|
1218
|
+
definition: ProviderDefinition,
|
|
1219
|
+
clearOAuthRuntimeRoot?: () => void,
|
|
1220
|
+
onLogin?: () => void,
|
|
1221
|
+
oauthRuntimeRoot?: () => { apiKey: string; root: string } | undefined,
|
|
1222
|
+
): ProviderAuth {
|
|
1223
|
+
function completeLogin<T extends Credential>(credential: T): T {
|
|
1224
|
+
onLogin?.();
|
|
1225
|
+
return onLogin ? { ...credential, litellmMcpSession: randomBytes(16).toString("hex") } : credential;
|
|
1226
|
+
}
|
|
1227
|
+
return {
|
|
1228
|
+
apiKey: {
|
|
1229
|
+
name: `${definition.displayName} API key`,
|
|
1230
|
+
login:
|
|
1231
|
+
definition.name === PROVIDER_NAME
|
|
1232
|
+
? async (interaction) => {
|
|
1233
|
+
const credential = await loginApiKey(interaction, definition);
|
|
1234
|
+
return completeLogin(credential);
|
|
1235
|
+
}
|
|
1236
|
+
: undefined,
|
|
1237
|
+
check: async ({ ctx, credential }) => {
|
|
1238
|
+
const baseUrl =
|
|
1239
|
+
credential?.env?.[ENV_BASE_URL] ??
|
|
1240
|
+
definition.baseUrl ??
|
|
1241
|
+
(definition.useDefaultEnv ? await ctx.env(ENV_BASE_URL) : undefined);
|
|
1242
|
+
if (!cleanConfig(baseUrl)) return undefined;
|
|
1243
|
+
if (credential?.key) return { type: "api_key", source: "stored credential" };
|
|
1244
|
+
|
|
1245
|
+
// Credentials `resolve` would fall back to if ADC cannot mint a token.
|
|
1246
|
+
const fallbackSource = async (): Promise<string | undefined> => {
|
|
1247
|
+
if (definition.apiKeyConfig) {
|
|
1248
|
+
const configuredKey = definition.apiKeyConfig.startsWith("!")
|
|
1249
|
+
? definition.apiKeyConfig
|
|
1250
|
+
: await resolveTemplateConfigValueFromContext(definition.apiKeyConfig, ctx.env);
|
|
1251
|
+
if (configuredKey) return definition.apiKeyConfig;
|
|
1252
|
+
}
|
|
1253
|
+
if (definition.useDefaultEnv && cleanConfig(await ctx.env(ENV_API_KEY_HELPER))) return ENV_API_KEY_HELPER;
|
|
1254
|
+
if (definition.useDefaultEnv && cleanConfig(await ctx.env(ENV_API_KEY))) return ENV_API_KEY;
|
|
1255
|
+
return undefined;
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
// Mirror the precedence in `resolveCredentials`, where ADC outranks the config
|
|
1259
|
+
// key, the helper and the environment key. Whether the refresh token still mints
|
|
1260
|
+
// is only knowable at request time, and this must not make a network call; if it
|
|
1261
|
+
// fails, `resolve` falls back and reports the credential it actually used.
|
|
1262
|
+
if (definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled() && (await hasGcloudAdcCredentials()))
|
|
1263
|
+
return { type: "api_key", source: GCLOUD_ADC_SOURCE };
|
|
1264
|
+
const fallback = await fallbackSource();
|
|
1265
|
+
return fallback ? { type: "api_key", source: fallback } : undefined;
|
|
1266
|
+
},
|
|
1267
|
+
resolve: async ({ ctx, credential }) => {
|
|
1268
|
+
// Pi re-resolves auth through this api-key path whenever a caller passes an explicit
|
|
1269
|
+
// apiKey override — compaction and summarization both hand back the key they just
|
|
1270
|
+
// resolved — which bypasses the stored OAuth credential carrying the base URL. The
|
|
1271
|
+
// api-key path exports its root as env so it survives that round trip, but OAuth's
|
|
1272
|
+
// toAuth has no env to export, leaving an SSO session no record of its root. Read it
|
|
1273
|
+
// back from auth.json, the same fallback check() and seedModels() use, and export it
|
|
1274
|
+
// so the request carries the root rather than depending on in-memory state. auth.json is
|
|
1275
|
+
// shared, though: another Pi process logging in replaces the stored token while this one
|
|
1276
|
+
// still holds its own, so fall back to the root this process resolved for that exact
|
|
1277
|
+
// token. Only when nothing configures a root at all: a base URL that is set still
|
|
1278
|
+
// resolves, and still fails, on its own terms rather than silently rerouting.
|
|
1279
|
+
if (credential?.key && !(await configuredBaseUrl(definition, ctx, credential))) {
|
|
1280
|
+
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
1281
|
+
const remembered = oauthRuntimeRoot?.();
|
|
1282
|
+
const root =
|
|
1283
|
+
stored?.type === "oauth" && stored.access === credential.key
|
|
1284
|
+
? requireCredentialRoot(resolveCredentialRoot(definition, stored), definition.name)
|
|
1285
|
+
: remembered?.apiKey === credential.key
|
|
1286
|
+
? remembered.root
|
|
1287
|
+
: undefined;
|
|
1288
|
+
if (root) {
|
|
1289
|
+
return {
|
|
1290
|
+
auth: { apiKey: credential.key, headers: resolveHeaders(definition), baseUrl: root },
|
|
1291
|
+
env: { [ENV_BASE_URL]: root },
|
|
1292
|
+
source: "OAuth",
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
clearOAuthRuntimeRoot?.();
|
|
1297
|
+
return resolveApiKeyAuth(definition, ctx, credential);
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
oauth: definition.enableOAuth
|
|
1301
|
+
? {
|
|
1302
|
+
name: "LiteLLM SSO",
|
|
1303
|
+
loginLabel: "Sign in with LiteLLM SSO",
|
|
1304
|
+
login: async (interaction) => {
|
|
1305
|
+
const credential = await loginOAuth(interaction, definition);
|
|
1306
|
+
return completeLogin(credential);
|
|
1307
|
+
},
|
|
1308
|
+
refresh: async (credential, signal) => ({
|
|
1309
|
+
...(await refreshLiteLLM(credential, definition, signal)),
|
|
1310
|
+
type: "oauth" as const,
|
|
1311
|
+
}),
|
|
1312
|
+
toAuth: async (credential) => ({
|
|
1313
|
+
apiKey: credential.access,
|
|
1314
|
+
headers: resolveHeaders(definition),
|
|
1315
|
+
}),
|
|
1316
|
+
}
|
|
1317
|
+
: undefined,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function isReasoningItem(item: unknown): boolean {
|
|
1322
|
+
return typeof item === "object" && item !== null && (item as { type?: unknown }).type === "reasoning";
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Returns the original array reference when no normalization is needed.
|
|
1326
|
+
function normalizeStrictToolMessages(messages: any[]): any[] {
|
|
1327
|
+
const normalized: any[] = [];
|
|
1328
|
+
let imageParts: any[] = [];
|
|
1329
|
+
let changed = false;
|
|
1330
|
+
const flushImages = (): void => {
|
|
1331
|
+
if (imageParts.length === 0) return;
|
|
1332
|
+
normalized.push({
|
|
1333
|
+
role: "user",
|
|
1334
|
+
content: [{ type: "text", text: "Attached image(s) from tool result:" }, ...imageParts],
|
|
1335
|
+
});
|
|
1336
|
+
imageParts = [];
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
for (const message of messages) {
|
|
1340
|
+
if (message.role !== "tool") flushImages();
|
|
1341
|
+
if (
|
|
1342
|
+
message.role === "assistant" &&
|
|
1343
|
+
Array.isArray(message.tool_calls) &&
|
|
1344
|
+
message.tool_calls.length > 0 &&
|
|
1345
|
+
message.content == null
|
|
1346
|
+
) {
|
|
1347
|
+
normalized.push({ ...message, content: "" });
|
|
1348
|
+
changed = true;
|
|
1349
|
+
} else if (message.role === "tool" && Array.isArray(message.content)) {
|
|
1350
|
+
const previousImageCount = imageParts.length;
|
|
1351
|
+
const content = message.content
|
|
1352
|
+
.map((part: any) => {
|
|
1353
|
+
if (typeof part === "string") return part;
|
|
1354
|
+
if (part?.type === "text") return part.text || "";
|
|
1355
|
+
if (part?.type === "image_url") imageParts.push(part);
|
|
1356
|
+
return "";
|
|
1357
|
+
})
|
|
1358
|
+
.join("");
|
|
1359
|
+
normalized.push({
|
|
1360
|
+
...message,
|
|
1361
|
+
content: content || (imageParts.length > previousImageCount ? "(see attached image)" : "(no tool output)"),
|
|
1362
|
+
});
|
|
1363
|
+
changed = true;
|
|
1364
|
+
} else {
|
|
1365
|
+
normalized.push(message);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
flushImages();
|
|
1369
|
+
return changed ? normalized : messages;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Visibility fields suppress duplicate Kimi reasoning without changing the
|
|
1373
|
+
// model-specific generation control selected by pi-ai.
|
|
1374
|
+
const REASONING_VISIBILITY_DEFAULTS: Record<string, unknown> = {
|
|
1375
|
+
include_reasoning: false,
|
|
1376
|
+
reasoning_content: false,
|
|
1377
|
+
merge_reasoning_content_in_choices: true,
|
|
1378
|
+
};
|
|
1379
|
+
const REASONING_REQUEST_KEYS = [
|
|
1380
|
+
"reasoning",
|
|
1381
|
+
"reasoning_effort",
|
|
1382
|
+
...Object.keys(REASONING_VISIBILITY_DEFAULTS),
|
|
1383
|
+
"thinking",
|
|
1384
|
+
];
|
|
1385
|
+
export function prepareLiteLLMRequestPayload(
|
|
1386
|
+
payload: Record<string, unknown>,
|
|
1387
|
+
model: LiteLLMModel | undefined,
|
|
1388
|
+
modelPolicy: LiteLLMModelPolicy | undefined,
|
|
1389
|
+
): Record<string, unknown> | undefined {
|
|
1390
|
+
const modelId = model?.id;
|
|
1391
|
+
const api = model?.api;
|
|
1392
|
+
const openAIApi = api ?? "openai-completions";
|
|
1393
|
+
let next: Record<string, unknown> | undefined;
|
|
1394
|
+
const update = (key: string, value: unknown): void => {
|
|
1395
|
+
if (payload[key] !== undefined) return;
|
|
1396
|
+
next ??= { ...payload };
|
|
1397
|
+
next[key] = value;
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
if (openAIApi === "openai-completions" && modelPolicy?.suppressReasoningVisibility) {
|
|
1401
|
+
for (const [key, value] of Object.entries(REASONING_VISIBILITY_DEFAULTS)) update(key, value);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
|
|
1405
|
+
// Drop reasoning until the gateway honors /v1/responses for this route.
|
|
1406
|
+
if (
|
|
1407
|
+
openAIApi === "openai-completions" &&
|
|
1408
|
+
modelId &&
|
|
1409
|
+
isGpt55Model(modelId) &&
|
|
1410
|
+
Array.isArray(payload.tools) &&
|
|
1411
|
+
payload.tools.length > 0
|
|
1412
|
+
) {
|
|
1413
|
+
for (const key of REASONING_REQUEST_KEYS) {
|
|
1414
|
+
if (payload[key] === undefined) continue;
|
|
1415
|
+
next ??= { ...payload };
|
|
1416
|
+
delete next[key];
|
|
1417
|
+
}
|
|
1418
|
+
const include = (next ?? payload).include;
|
|
1419
|
+
if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
|
|
1420
|
+
next ??= { ...payload };
|
|
1421
|
+
const filteredInclude = include.filter((value) => value !== "reasoning.encrypted_content");
|
|
1422
|
+
if (filteredInclude.length === 0) delete next.include;
|
|
1423
|
+
else next.include = filteredInclude;
|
|
1424
|
+
}
|
|
1425
|
+
// Prior turns may have replayed reasoning items (with encrypted_content)
|
|
1426
|
+
// into the input; they are rejected once reasoning is stripped.
|
|
1427
|
+
const input = (next ?? payload).input;
|
|
1428
|
+
if (Array.isArray(input) && input.some(isReasoningItem)) {
|
|
1429
|
+
next ??= { ...payload };
|
|
1430
|
+
next.input = input.filter((item) => !isReasoningItem(item));
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Moonshot/Kimi applies strict OpenAI schema validation: assistant tool calls
|
|
1435
|
+
// must carry string content, and tool results must be plain text. This outbound
|
|
1436
|
+
// rewrite requires the deployment-backed policy; route text is not evidence.
|
|
1437
|
+
if (openAIApi === "openai-completions" && modelPolicy?.normalizeStrictToolMessages) {
|
|
1438
|
+
const messages = (next ?? payload).messages;
|
|
1439
|
+
if (Array.isArray(messages)) {
|
|
1440
|
+
const normalized = normalizeStrictToolMessages(messages);
|
|
1441
|
+
if (normalized !== messages) {
|
|
1442
|
+
next ??= { ...payload };
|
|
1443
|
+
next.messages = normalized;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (
|
|
1449
|
+
(openAIApi === "openai-completions" || openAIApi === "openai-responses") &&
|
|
1450
|
+
modelPolicy?.normalizeGeminiReasoningEffort
|
|
1451
|
+
) {
|
|
1452
|
+
const currentPayload = next ?? payload;
|
|
1453
|
+
if (typeof currentPayload.reasoning_effort === "string") {
|
|
1454
|
+
const lower = currentPayload.reasoning_effort.toLowerCase();
|
|
1455
|
+
if (currentPayload.reasoning_effort !== lower) {
|
|
1456
|
+
next ??= { ...payload };
|
|
1457
|
+
next.reasoning_effort = lower;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const reasoningPayload = next ?? payload;
|
|
1462
|
+
if (isPlainObject(reasoningPayload.reasoning) && typeof reasoningPayload.reasoning.effort === "string") {
|
|
1463
|
+
const lower = reasoningPayload.reasoning.effort.toLowerCase();
|
|
1464
|
+
if (reasoningPayload.reasoning.effort !== lower) {
|
|
1465
|
+
next ??= { ...payload };
|
|
1466
|
+
next.reasoning = {
|
|
1467
|
+
...(next.reasoning as Record<string, unknown>),
|
|
1468
|
+
effort: lower,
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return next;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function normalizeThinkTags(
|
|
1478
|
+
message: AssistantMessage,
|
|
1479
|
+
litellmProviderNames: Set<string>,
|
|
1480
|
+
model: LiteLLMModel | undefined,
|
|
1481
|
+
): AssistantMessage | undefined {
|
|
1482
|
+
if (
|
|
1483
|
+
!litellmProviderNames.has(message.provider) ||
|
|
1484
|
+
(model?.api !== undefined && model.api !== "openai-completions") ||
|
|
1485
|
+
!model?.litellmPolicy?.normalizeThinkTags
|
|
1486
|
+
)
|
|
1487
|
+
return;
|
|
1488
|
+
|
|
1489
|
+
let changed = false;
|
|
1490
|
+
const content: AssistantMessage["content"] = [];
|
|
1491
|
+
const appendText = (text: string): void => {
|
|
1492
|
+
if (!text) return;
|
|
1493
|
+
const last = content.at(-1);
|
|
1494
|
+
if (last?.type === "text") {
|
|
1495
|
+
last.text += text;
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
content.push({ type: "text", text });
|
|
1499
|
+
};
|
|
1500
|
+
const appendThinking = (thinking: string): void => {
|
|
1501
|
+
if (!thinking) return;
|
|
1502
|
+
const last = content.at(-1);
|
|
1503
|
+
if (last?.type === "thinking") {
|
|
1504
|
+
last.thinking += thinking;
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
content.push({ type: "thinking", thinking });
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
|
|
1511
|
+
const block = message.content[blockIndex];
|
|
1512
|
+
if (block.type !== "text") {
|
|
1513
|
+
content.push(block);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
let index = 0;
|
|
1518
|
+
while (index < block.text.length) {
|
|
1519
|
+
const start = block.text.indexOf("<think>", index);
|
|
1520
|
+
if (start === -1) {
|
|
1521
|
+
appendText(block.text.slice(index));
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
changed = true;
|
|
1526
|
+
appendText(block.text.slice(index, start));
|
|
1527
|
+
const thinkingStart = start + "<think>".length;
|
|
1528
|
+
const end = block.text.indexOf("</think>", thinkingStart);
|
|
1529
|
+
if (end === -1) {
|
|
1530
|
+
const isBeforeNonTextContent = message.content
|
|
1531
|
+
.slice(blockIndex + 1)
|
|
1532
|
+
.some((nextBlock) => nextBlock.type !== "text");
|
|
1533
|
+
if (isBeforeNonTextContent) appendThinking(block.text.slice(thinkingStart));
|
|
1534
|
+
else appendText(block.text.slice(thinkingStart));
|
|
1535
|
+
index = block.text.length;
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
appendThinking(block.text.slice(thinkingStart, end));
|
|
1540
|
+
index = end + "</think>".length;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (!changed) return;
|
|
1545
|
+
return { ...message, content };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
1549
|
+
const settings = await readGlobalLiteLLMSettings();
|
|
1550
|
+
const definitions = getProviderDefinitions(settings);
|
|
1551
|
+
const skillsEnabled = isFeatureEnabled(settings, "skills");
|
|
1552
|
+
const mcpEnabled = isFeatureEnabled(settings, "mcp");
|
|
1553
|
+
const providerNames = new Set(definitions.map((definition) => definition.name));
|
|
1554
|
+
const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
|
|
1555
|
+
let mcpUI: ExtensionContext["ui"] | undefined;
|
|
1556
|
+
let sessionStarted = false;
|
|
1557
|
+
const pendingMcpMessages = new Map<string, "info" | "warning">();
|
|
1558
|
+
|
|
1559
|
+
function notifyMcp(message: string, level: "info" | "warning" = "warning"): void {
|
|
1560
|
+
const text = message.trimEnd();
|
|
1561
|
+
if (mcpUI) mcpUI.notify(text, level);
|
|
1562
|
+
// Pi starts the terminal before supplying an ExtensionContext. Buffer until session_start.
|
|
1563
|
+
else if (!sessionStarted && process.stderr.isTTY) pendingMcpMessages.set(text, level);
|
|
1564
|
+
else process.stderr.write(`${text}\n`);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1568
|
+
sessionStarted = true;
|
|
1569
|
+
mcpUI = ctx.hasUI ? ctx.ui : undefined;
|
|
1570
|
+
for (const [message, level] of pendingMcpMessages) notifyMcp(message, level);
|
|
1571
|
+
pendingMcpMessages.clear();
|
|
1572
|
+
});
|
|
1573
|
+
pi.on("session_shutdown", () => {
|
|
1574
|
+
mcpUI = undefined;
|
|
1575
|
+
sessionStarted = false;
|
|
1576
|
+
pendingMcpMessages.clear();
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
function discoveryDisabledReason(): string | null {
|
|
1580
|
+
if (isOffline()) return `${ENV_OFFLINE}=1`;
|
|
1581
|
+
if (getDiscoveryTimeoutMs() === 0) return `${ENV_TIMEOUT}=0`;
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function requestBaseUrl(definition: ProviderDefinition): string {
|
|
1586
|
+
try {
|
|
1587
|
+
return `${resolveCredentialRoot(definition) ?? DEFAULT_LITELLM_BASE_URL}/v1`;
|
|
1588
|
+
} catch {
|
|
1589
|
+
return `${DEFAULT_LITELLM_BASE_URL}/v1`;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async function authForCredential(definition: ProviderDefinition, credential?: Credential, executeHelpers = true) {
|
|
1594
|
+
if (credential?.type === "oauth") {
|
|
1595
|
+
const baseUrl = requireCredentialRoot(resolveCredentialRoot(definition, credential), definition.name);
|
|
1596
|
+
return {
|
|
1597
|
+
baseUrl,
|
|
1598
|
+
apiKey: credential.access,
|
|
1599
|
+
headers: resolveHeaders(definition),
|
|
1600
|
+
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
const resolved = await resolveApiKeyAuth(
|
|
1604
|
+
definition,
|
|
1605
|
+
{ env: async (name) => process.env[name] },
|
|
1606
|
+
credential,
|
|
1607
|
+
executeHelpers,
|
|
1608
|
+
);
|
|
1609
|
+
if (!resolved?.auth.apiKey || !resolved.auth.baseUrl) {
|
|
1610
|
+
throw new Error(`no credentials for ${definition.name}. Run /login litellm or set env vars.`);
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
baseUrl: resolved.auth.baseUrl,
|
|
1614
|
+
apiKey: resolved.auth.apiKey,
|
|
1615
|
+
headers: resolved.auth.headers,
|
|
1616
|
+
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1617
|
+
mcpPauseSource:
|
|
1618
|
+
resolved.source === GCLOUD_ADC_SOURCE
|
|
1619
|
+
? GCLOUD_ADC_SOURCE
|
|
1620
|
+
: resolved.source === ENV_API_KEY_HELPER
|
|
1621
|
+
? normalizeCommand(process.env[ENV_API_KEY_HELPER])
|
|
1622
|
+
: resolved.source?.startsWith("!")
|
|
1623
|
+
? resolved.source
|
|
1624
|
+
: undefined,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
let registeredMcpIdentity: string | undefined;
|
|
1629
|
+
let mcpRegistration: Promise<void> | undefined;
|
|
1630
|
+
// Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
|
|
1631
|
+
// never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
|
|
1632
|
+
// retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
|
|
1633
|
+
// instance with its own state, which starts clean on its own.
|
|
1634
|
+
let mcpRegistrationFatal = false;
|
|
1635
|
+
let defaultRuntimeAuth: LiteLLMRuntimeAuth | undefined;
|
|
1636
|
+
const mcpPauseDir = join(getAgentDir(), "litellm-mcp-pauses");
|
|
1637
|
+
const mcpPauseInMemory = new Set<string>();
|
|
1638
|
+
let mcpPauseSalt: Buffer | undefined;
|
|
1639
|
+
let mcpPausePersistent = true;
|
|
1640
|
+
let mcpLoginGeneration = 0;
|
|
1641
|
+
|
|
1642
|
+
function getMcpPauseSalt(): Buffer {
|
|
1643
|
+
if (!mcpPauseSalt) {
|
|
1644
|
+
try {
|
|
1645
|
+
mkdirSync(mcpPauseDir, { recursive: true, mode: 0o700 });
|
|
1646
|
+
const saltPath = join(mcpPauseDir, "identity-key");
|
|
1647
|
+
if (!existsSync(saltPath)) {
|
|
1648
|
+
const temporary = join(mcpPauseDir, `.identity-key-${randomBytes(16).toString("hex")}`);
|
|
1649
|
+
try {
|
|
1650
|
+
writeFileSync(temporary, randomBytes(32), { mode: 0o600, flag: "wx" });
|
|
1651
|
+
try {
|
|
1652
|
+
// Publish a complete key without replacing one created by another Pi process.
|
|
1653
|
+
linkSync(temporary, saltPath);
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1656
|
+
}
|
|
1657
|
+
} finally {
|
|
1658
|
+
rmSync(temporary, { force: true });
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
const salt = readFileSync(saltPath);
|
|
1662
|
+
if (salt.length !== 32) throw new Error("Invalid MCP pause identity key");
|
|
1663
|
+
mcpPauseSalt = salt;
|
|
1664
|
+
} catch {
|
|
1665
|
+
mcpPauseSalt = randomBytes(32);
|
|
1666
|
+
mcpPausePersistent = false;
|
|
1667
|
+
notifyMcp("LiteLLM MCP: could not read or initialize persisted discovery pause state.");
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return mcpPauseSalt;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function mcpSession(credential: Credential | OAuthCredentials | undefined): string | undefined {
|
|
1674
|
+
const session = (credential as { litellmMcpSession?: unknown } | undefined)?.litellmMcpSession;
|
|
1675
|
+
return typeof session === "string" ? session : undefined;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function mcpPausePath(auth: McpRuntimeAuth, credential: Credential): string {
|
|
1679
|
+
const salt = getMcpPauseSalt();
|
|
1680
|
+
const source =
|
|
1681
|
+
auth.mcpPauseSource ??
|
|
1682
|
+
(credential.type === "api_key" && credential.key?.startsWith("!") ? credential.key : undefined);
|
|
1683
|
+
const key = source ?? auth.apiKey;
|
|
1684
|
+
const session = mcpSession(credential) ?? credentialFingerprint(key, undefined, salt);
|
|
1685
|
+
const identity = credentialFingerprint(
|
|
1686
|
+
JSON.stringify([
|
|
1687
|
+
normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
|
|
1688
|
+
session,
|
|
1689
|
+
credential.type === "oauth" ? null : [source ? "source" : "key", key],
|
|
1690
|
+
]),
|
|
1691
|
+
auth.headers,
|
|
1692
|
+
salt,
|
|
1693
|
+
);
|
|
1694
|
+
return join(mcpPauseDir, `paused-${identity}`);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function pauseMcpDiscovery(auth: McpRuntimeAuth, credential: Credential): void {
|
|
1698
|
+
const path = mcpPausePath(auth, credential);
|
|
1699
|
+
mcpPauseInMemory.add(path);
|
|
1700
|
+
try {
|
|
1701
|
+
if (!mcpPausePersistent) throw new Error("MCP pause persistence unavailable");
|
|
1702
|
+
writeFileSync(path, "", { mode: 0o600 });
|
|
1703
|
+
} catch {
|
|
1704
|
+
notifyMcp("LiteLLM MCP: could not persist the discovery pause across restarts.");
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function resumeMcpDiscovery(): void {
|
|
1709
|
+
mcpLoginGeneration += 1;
|
|
1710
|
+
registeredMcpIdentity = undefined;
|
|
1711
|
+
// The new login ID selects a fresh scope; other processes may still use the old scopes.
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function isMcpPaused(auth: McpRuntimeAuth, credential: Credential): boolean {
|
|
1715
|
+
if (!mcpPauseInMemory.size && !existsSync(mcpPauseDir)) return false;
|
|
1716
|
+
const path = mcpPausePath(auth, credential);
|
|
1717
|
+
return mcpPauseInMemory.has(path) || (mcpPausePersistent && existsSync(path));
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Identifies the catalog a set of credentials points at, so a credential change forces
|
|
1721
|
+
// re-registration. The base URL stays readable because it is not secret and is useful when
|
|
1722
|
+
// reasoning about a refresh; everything credential-bearing is reduced to a non-reversible
|
|
1723
|
+
// fingerprint so no key or header value is held in the identity string.
|
|
1724
|
+
function mcpCatalogIdentity(auth: LiteLLMRuntimeAuth, credential: Credential): string {
|
|
1725
|
+
return JSON.stringify({
|
|
1726
|
+
baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
|
|
1727
|
+
credential: credentialFingerprint(JSON.stringify([auth.apiKey, mcpSession(credential)]), auth.headers),
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
async function getRuntimeAuth(ctx: ExtensionContext): Promise<LiteLLMRuntimeAuth | undefined> {
|
|
1732
|
+
const auth = await ctx.modelRegistry.getProviderAuth(PROVIDER_NAME);
|
|
1733
|
+
if (!auth) return undefined;
|
|
1734
|
+
const provider = ctx.modelRegistry.getProvider(PROVIDER_NAME);
|
|
1735
|
+
const apiKey = auth.auth.apiKey;
|
|
1736
|
+
const baseUrl = cleanConfig(auth.auth.baseUrl) ?? cleanConfig(auth.env?.[ENV_BASE_URL]) ?? provider?.baseUrl;
|
|
1737
|
+
if (!baseUrl || !apiKey) return undefined;
|
|
1738
|
+
const runtimeRoot = requireCredentialRoot(
|
|
1739
|
+
normalizeBaseUrl(baseUrl, definitions[0]?.allowInsecureHttp),
|
|
1740
|
+
PROVIDER_NAME,
|
|
1741
|
+
);
|
|
1742
|
+
const headers = Object.fromEntries(
|
|
1743
|
+
Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter(
|
|
1744
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
1745
|
+
),
|
|
1746
|
+
);
|
|
1747
|
+
return {
|
|
1748
|
+
baseUrl: runtimeRoot,
|
|
1749
|
+
apiKey,
|
|
1750
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
1751
|
+
allowInsecureHttp: definitions[0]?.allowInsecureHttp,
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function resolveDefaultRuntimeAuth(ctx?: ExtensionContext): Promise<LiteLLMRuntimeAuth> {
|
|
1756
|
+
if (ctx?.modelRegistry) {
|
|
1757
|
+
const auth = await getRuntimeAuth(ctx);
|
|
1758
|
+
if (auth) return auth;
|
|
1759
|
+
throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
|
|
1760
|
+
}
|
|
1761
|
+
if (!defaultRuntimeAuth) throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
|
|
1762
|
+
return defaultRuntimeAuth;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function waitForMcpRegistration(registration: Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
1766
|
+
if (!signal) return registration.catch(() => undefined);
|
|
1767
|
+
signal.throwIfAborted();
|
|
1768
|
+
return new Promise((resolve, reject) => {
|
|
1769
|
+
const onAbort = () => {
|
|
1770
|
+
signal.removeEventListener("abort", onAbort);
|
|
1771
|
+
reject(signal.reason);
|
|
1772
|
+
};
|
|
1773
|
+
const onComplete = () => {
|
|
1774
|
+
signal.removeEventListener("abort", onAbort);
|
|
1775
|
+
resolve();
|
|
1776
|
+
};
|
|
1777
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1778
|
+
if (signal.aborted) onAbort();
|
|
1779
|
+
else registration.then(onComplete, onComplete);
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async function registerMcpTools(auth: McpRuntimeAuth, credential: Credential, signal?: AbortSignal): Promise<void> {
|
|
1784
|
+
if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
|
|
1785
|
+
const loginGeneration = mcpLoginGeneration;
|
|
1786
|
+
const identity = mcpCatalogIdentity(auth, credential);
|
|
1787
|
+
while (mcpRegistration) {
|
|
1788
|
+
await waitForMcpRegistration(mcpRegistration, signal);
|
|
1789
|
+
signal?.throwIfAborted();
|
|
1790
|
+
if (
|
|
1791
|
+
mcpRegistrationFatal ||
|
|
1792
|
+
isMcpPaused(auth, credential) ||
|
|
1793
|
+
loginGeneration !== mcpLoginGeneration ||
|
|
1794
|
+
registeredMcpIdentity === identity
|
|
1795
|
+
)
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (registeredMcpIdentity === identity) return;
|
|
1799
|
+
|
|
1800
|
+
const registration = (async () => {
|
|
1801
|
+
try {
|
|
1802
|
+
signal?.throwIfAborted();
|
|
1803
|
+
const { definitions, report } = await createMcpToolDefinitions(
|
|
1804
|
+
(ctx) => (ctx?.modelRegistry ? resolveDefaultRuntimeAuth(ctx) : Promise.resolve(auth)),
|
|
1805
|
+
isVerboseDiscovery() ? (message) => notifyMcp(`LiteLLM MCP: ${message}`, "info") : undefined,
|
|
1806
|
+
signal,
|
|
1807
|
+
notifyMcp,
|
|
1808
|
+
);
|
|
1809
|
+
signal?.throwIfAborted();
|
|
1810
|
+
if (loginGeneration !== mcpLoginGeneration) return;
|
|
1811
|
+
const registeredNames: string[] = [];
|
|
1812
|
+
try {
|
|
1813
|
+
for (const definition of definitions) {
|
|
1814
|
+
// Checked per tool so a cancelled refresh stops promptly instead of driving a full
|
|
1815
|
+
// registry rebuild for every remaining tool.
|
|
1816
|
+
signal?.throwIfAborted();
|
|
1817
|
+
pi.registerTool(definition);
|
|
1818
|
+
registeredNames.push(definition.name);
|
|
1819
|
+
}
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
if (signal?.aborted) throw signal.reason;
|
|
1822
|
+
// Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
|
|
1823
|
+
// then stop retrying so a stale instance cannot churn discovery on every later refresh.
|
|
1824
|
+
mcpRegistrationFatal = true;
|
|
1825
|
+
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notifyMcp);
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
reportMcpRegistrationSuccess();
|
|
1829
|
+
reportMcpPartialDiscovery(report.partialFailure, registeredNames, notifyMcp);
|
|
1830
|
+
if (isVerboseDiscovery()) {
|
|
1831
|
+
notifyMcp(
|
|
1832
|
+
`LiteLLM MCP: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
|
|
1833
|
+
`(${report.discovered} raw, ${report.enveloped} enveloped).`,
|
|
1834
|
+
"info",
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
// A catalog that produced nothing or came from a partial-failure response is not settled:
|
|
1838
|
+
// leaving the identity unset lets a later refresh retry discovery, which is network-only and
|
|
1839
|
+
// non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
|
|
1840
|
+
reportMcpCatalogOutcome(report.discovered, definitions.length, notifyMcp);
|
|
1841
|
+
if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentity = identity;
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
if (signal?.aborted) throw signal.reason;
|
|
1844
|
+
if (loginGeneration !== mcpLoginGeneration) return;
|
|
1845
|
+
if (error instanceof McpAccessDeniedError) {
|
|
1846
|
+
pauseMcpDiscovery(auth, credential);
|
|
1847
|
+
notifyMcp("LiteLLM MCP: access denied; discovery paused until /login litellm succeeds.");
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
notifyMcp(
|
|
1851
|
+
`LiteLLM (${PROVIDER_NAME}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
})();
|
|
1855
|
+
mcpRegistration = registration;
|
|
1856
|
+
try {
|
|
1857
|
+
await registration;
|
|
1858
|
+
} finally {
|
|
1859
|
+
if (mcpRegistration === registration) mcpRegistration = undefined;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async function runDiscovery(auth: LiteLLMRuntimeAuth, signal?: AbortSignal) {
|
|
1864
|
+
const result = await discoverModels(auth.baseUrl, auth.apiKey, {
|
|
1865
|
+
timeoutMs: getDiscoveryTimeoutMs(),
|
|
1866
|
+
signal,
|
|
1867
|
+
headers: auth.headers,
|
|
1868
|
+
allowInsecureHttp: auth.allowInsecureHttp,
|
|
1869
|
+
modelsDev: !isHostOffline(),
|
|
1870
|
+
modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
|
|
1871
|
+
silent: !isVerboseDiscovery(),
|
|
1872
|
+
onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
|
|
1873
|
+
});
|
|
1874
|
+
signal?.throwIfAborted();
|
|
1875
|
+
return { ...result, baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp) };
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Pi's startup path only ever refreshes with allowNetwork:false, and it returns before the
|
|
1879
|
+
// network phase, so the provider would stay empty until the user opens /model. Discover here
|
|
1880
|
+
// instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
|
|
1881
|
+
async function seedModels(definition: ProviderDefinition): Promise<Model<LiteLLMApi>[]> {
|
|
1882
|
+
if (discoveryDisabledReason() || isHostOffline()) return [];
|
|
1883
|
+
try {
|
|
1884
|
+
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
1885
|
+
// executeHelpers:false — activation must never run the user's key helper as a side effect,
|
|
1886
|
+
// so helper-backed setups keep waiting for Pi's own refresh.
|
|
1887
|
+
const auth = await authForCredential(definition, stored, false);
|
|
1888
|
+
const result = await runDiscovery(auth, AbortSignal.timeout(getSeedTimeoutMs()));
|
|
1889
|
+
return toNativeModels(definition.name, result.baseUrl, result.models, definition.allowInsecureHttp);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
// No credentials yet is the normal unconfigured case; anything else is worth one line.
|
|
1892
|
+
if (isVerboseDiscovery()) {
|
|
1893
|
+
process.stderr.write(
|
|
1894
|
+
`LiteLLM (${definition.name}): startup discovery skipped (${error instanceof Error ? error.message : String(error)}).\n`,
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
return [];
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const seeded = await Promise.all(definitions.map(seedModels));
|
|
1902
|
+
|
|
1903
|
+
for (const [index, definition] of definitions.entries()) {
|
|
1904
|
+
const auth = createProviderAuth(
|
|
1905
|
+
definition,
|
|
1906
|
+
() => oauthRuntimeRoots.delete(definition.name),
|
|
1907
|
+
definition.name === PROVIDER_NAME ? resumeMcpDiscovery : undefined,
|
|
1908
|
+
() => oauthRuntimeRoots.get(definition.name),
|
|
1909
|
+
);
|
|
1910
|
+
if (auth.oauth) {
|
|
1911
|
+
if (definition.name === PROVIDER_NAME) {
|
|
1912
|
+
const refresh = auth.oauth.refresh;
|
|
1913
|
+
auth.oauth.refresh = async (credential, signal) => {
|
|
1914
|
+
// Resolve legacy credentials' stable scope before an in-flight denial or token rotation.
|
|
1915
|
+
const session =
|
|
1916
|
+
mcpSession(credential) ??
|
|
1917
|
+
(mcpEnabled ? credentialFingerprint(credential.access, undefined, getMcpPauseSalt()) : undefined);
|
|
1918
|
+
const refreshed = await refresh(credential, signal);
|
|
1919
|
+
return session &&
|
|
1920
|
+
(mcpSession(credential) ||
|
|
1921
|
+
refreshed.access !== credential.access ||
|
|
1922
|
+
refreshed.refresh !== credential.refresh)
|
|
1923
|
+
? { ...refreshed, litellmMcpSession: session }
|
|
1924
|
+
: refreshed;
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
const toAuth = auth.oauth.toAuth;
|
|
1928
|
+
auth.oauth.toAuth = async (credential) => {
|
|
1929
|
+
const resolved = await toAuth(credential);
|
|
1930
|
+
const root = requireCredentialRoot(resolveCredentialRoot(definition, credential), definition.name);
|
|
1931
|
+
oauthRuntimeRoots.set(definition.name, { apiKey: credential.access, root });
|
|
1932
|
+
// Pin the request host to the credential root so Pi's global-API fallback for an
|
|
1933
|
+
// off-catalog model cannot send this credential to a stale model.baseUrl.
|
|
1934
|
+
return { ...resolved, baseUrl: root };
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
const provider = createLiteLLMProvider({
|
|
1938
|
+
id: definition.name,
|
|
1939
|
+
name: definition.displayName,
|
|
1940
|
+
baseUrl: requestBaseUrl(definition),
|
|
1941
|
+
auth,
|
|
1942
|
+
models: seeded[index],
|
|
1943
|
+
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1944
|
+
resolveCredentialRoot: (credential, requestRoot, apiKey) => {
|
|
1945
|
+
if (credential) return resolveCredentialRoot(definition, credential, requestRoot);
|
|
1946
|
+
const explicit = cleanConfig(requestRoot);
|
|
1947
|
+
if (explicit) return normalizeBaseUrl(explicit, definition.allowInsecureHttp);
|
|
1948
|
+
const oauthRuntimeRoot = oauthRuntimeRoots.get(definition.name);
|
|
1949
|
+
if (apiKey && oauthRuntimeRoot?.apiKey === apiKey) return oauthRuntimeRoot.root;
|
|
1950
|
+
return resolveCredentialRoot(definition);
|
|
1951
|
+
},
|
|
1952
|
+
discover: async (credential, signal) => {
|
|
1953
|
+
const disabledReason = discoveryDisabledReason();
|
|
1954
|
+
if (disabledReason) throw new Error(`discovery disabled (${disabledReason})`);
|
|
1955
|
+
return runDiscovery(await authForCredential(definition, credential), signal);
|
|
1956
|
+
},
|
|
1957
|
+
});
|
|
1958
|
+
Object.assign(provider, { headers: resolveHeaders(definition) });
|
|
1959
|
+
const refreshModels = provider.refreshModels!;
|
|
1960
|
+
provider.refreshModels = async (context) => {
|
|
1961
|
+
const loginGeneration = mcpLoginGeneration;
|
|
1962
|
+
try {
|
|
1963
|
+
await refreshModels(context);
|
|
1964
|
+
} finally {
|
|
1965
|
+
if (
|
|
1966
|
+
definition.name === PROVIDER_NAME &&
|
|
1967
|
+
context.allowNetwork &&
|
|
1968
|
+
!discoveryDisabledReason() &&
|
|
1969
|
+
loginGeneration === mcpLoginGeneration &&
|
|
1970
|
+
context.credential
|
|
1971
|
+
) {
|
|
1972
|
+
// Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
|
|
1973
|
+
// placeholder credential override refreshModels' own try/throw outcome via `finally`.
|
|
1974
|
+
try {
|
|
1975
|
+
const auth = await authForCredential(definition, context.credential);
|
|
1976
|
+
if (loginGeneration === mcpLoginGeneration) {
|
|
1977
|
+
defaultRuntimeAuth = auth;
|
|
1978
|
+
void registerMcpTools(auth, context.credential, context.signal).catch(() => undefined);
|
|
1979
|
+
}
|
|
1980
|
+
} catch {
|
|
1981
|
+
// ignored — authForCredential already reported/will report this via the paths that use it directly.
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
};
|
|
1986
|
+
pi.registerProvider(provider);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
setupLiteLLMCostTracking(pi, [...providerNames]);
|
|
1990
|
+
|
|
1991
|
+
if (skillsEnabled) {
|
|
1992
|
+
for (const tool of createSkillToolDefinitions(resolveDefaultRuntimeAuth)) {
|
|
1993
|
+
pi.registerTool(tool);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
pi.on("before_provider_headers", (event, ctx) => {
|
|
1998
|
+
if (!ctx.model?.provider || !providerNames.has(ctx.model.provider)) return;
|
|
1999
|
+
event.headers["x-litellm-session-id"] = ctx.sessionManager.getSessionId();
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
2003
|
+
if (!ctx.model?.provider || !providerNames.has(ctx.model.provider)) return;
|
|
2004
|
+
if (typeof event.payload !== "object" || event.payload === null) return;
|
|
2005
|
+
return prepareLiteLLMRequestPayload(
|
|
2006
|
+
event.payload as Record<string, unknown>,
|
|
2007
|
+
ctx.model as LiteLLMModel,
|
|
2008
|
+
(ctx.model as LiteLLMModel | undefined)?.litellmPolicy,
|
|
2009
|
+
);
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
// Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
|
|
2013
|
+
// report an extension error on every turn. The failing model request states the real problem.
|
|
2014
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
2015
|
+
defaultRuntimeAuth = undefined;
|
|
2016
|
+
if (!skillsEnabled || discoveryDisabledReason()) return;
|
|
2017
|
+
let section: string | undefined;
|
|
2018
|
+
try {
|
|
2019
|
+
const auth = await getRuntimeAuth(ctx);
|
|
2020
|
+
if (!auth) return;
|
|
2021
|
+
defaultRuntimeAuth = auth;
|
|
2022
|
+
const skills = await listSkills(auth.baseUrl, auth.apiKey, auth.headers, auth.allowInsecureHttp);
|
|
2023
|
+
section = createSkillsPromptSection(skills);
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
if (isVerboseDiscovery()) {
|
|
2026
|
+
process.stderr.write(`LiteLLM: skipping Skills (${error instanceof Error ? error.message : String(error)}).\n`);
|
|
2027
|
+
}
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
if (!section) return;
|
|
2031
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
pi.on("message_end", (event, ctx) => {
|
|
2035
|
+
if (event.message.role !== "assistant") return;
|
|
2036
|
+
// Resolve the discovered model that produced this message so the display
|
|
2037
|
+
// conclusion comes from deployment evidence rather than the route name. An
|
|
2038
|
+
// unresolvable model carries no conclusion and is left untouched.
|
|
2039
|
+
const model = ctx?.modelRegistry?.find(event.message.provider, event.message.model) as LiteLLMModel | undefined;
|
|
2040
|
+
const message = normalizeThinkTags(event.message as AssistantMessage, providerNames, model);
|
|
2041
|
+
if (!message) return;
|
|
2042
|
+
return { message };
|
|
2043
|
+
});
|
|
2044
|
+
}
|