pi-provider-litellm 1.2.7 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -10
- package/dist/cost.d.ts +5 -1
- package/dist/cost.js +0 -0
- package/dist/discover.d.ts +5 -0
- package/dist/discover.js +71 -29
- package/dist/index.js +720 -171
- package/dist/mcp-tools.d.ts +3 -3
- package/dist/mcp-tools.js +76 -64
- package/dist/skills.d.ts +7 -6
- package/dist/skills.js +112 -32
- package/dist/types.d.ts +10 -0
- package/package.json +6 -8
package/dist/index.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
import { execSync, spawn } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { fingerprint, readCache, writeCache } from "./cache.js";
|
|
6
6
|
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
7
|
-
import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
|
|
7
|
+
import { discoverModels, isGpt55Model, normalizeBaseUrl, shouldSuppressReasoningContent, withTimeout, } from "./discover.js";
|
|
8
8
|
import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
|
|
9
9
|
import { getSessionIdFromFile } from "./litellm.js";
|
|
10
10
|
import { createMcpToolDefinitions } from "./mcp-tools.js";
|
|
11
11
|
import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
|
|
12
12
|
const PROVIDER_NAME = "litellm";
|
|
13
|
+
const SETTINGS_KEY = "litellm";
|
|
13
14
|
const ENV_BASE_URL = "LITELLM_BASE_URL";
|
|
14
15
|
const ENV_API_KEY = "LITELLM_API_KEY";
|
|
15
16
|
const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
|
|
17
|
+
const ENV_HEADERS = "LITELLM_HEADERS";
|
|
16
18
|
const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
|
|
17
19
|
const ENV_OFFLINE = "LITELLM_OFFLINE";
|
|
18
20
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
@@ -25,14 +27,157 @@ const EXPIRE_TOKEN_IMMEDIATELY = 0;
|
|
|
25
27
|
function getAuthPath() {
|
|
26
28
|
return join(getAgentDir(), "auth.json");
|
|
27
29
|
}
|
|
28
|
-
function
|
|
29
|
-
return
|
|
30
|
+
function sanitizeCacheSegment(value) {
|
|
31
|
+
return (value
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
34
|
+
.replace(/^-+|-+$/g, "") || "provider");
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
function getCachePath(providerName = PROVIDER_NAME) {
|
|
37
|
+
if (providerName === PROVIDER_NAME)
|
|
38
|
+
return join(getAgentDir(), CACHE_FILENAME);
|
|
39
|
+
return join(getAgentDir(), `litellm-models-${sanitizeCacheSegment(providerName)}.json`);
|
|
40
|
+
}
|
|
41
|
+
// Same tolerance as pi core's models.json loader (stripJsonComments in dist/utils/json.js):
|
|
42
|
+
// strip `//` line comments and trailing commas, leaving string literals untouched.
|
|
43
|
+
function stripJsonComments(input) {
|
|
44
|
+
return input
|
|
45
|
+
.replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
|
|
46
|
+
.replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? m);
|
|
47
|
+
}
|
|
48
|
+
function isPlainObject(value) {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
52
|
+
// Mirrors pi core's ModelOverrideSchema: core rejects the whole models.json on invalid values,
|
|
53
|
+
// so anything dropped here would also have been flagged for built-in providers. Headers matter
|
|
54
|
+
// most: pi resolves each header value as a config string, so non-strings break requests.
|
|
55
|
+
const MODEL_OVERRIDE_VALIDATORS = {
|
|
56
|
+
name: (value) => typeof value === "string",
|
|
57
|
+
reasoning: (value) => typeof value === "boolean",
|
|
58
|
+
thinkingLevelMap: (value) => isPlainObject(value) && THINKING_LEVELS.every((level) => value[level] == null || typeof value[level] === "string"),
|
|
59
|
+
input: (value) => Array.isArray(value) && value.every((entry) => entry === "text" || entry === "image"),
|
|
60
|
+
contextWindow: (value) => typeof value === "number",
|
|
61
|
+
maxTokens: (value) => typeof value === "number",
|
|
62
|
+
headers: (value) => isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "string"),
|
|
63
|
+
compat: isPlainObject,
|
|
64
|
+
cost: (value) => isPlainObject(value) && Object.values(value).every((entry) => typeof entry === "number"),
|
|
65
|
+
};
|
|
66
|
+
function sanitizeModelOverride(modelId, raw) {
|
|
67
|
+
if (!isPlainObject(raw)) {
|
|
68
|
+
process.stderr.write(`LiteLLM: ignoring model override for ${modelId} in models.json (not an object).\n`);
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const override = {};
|
|
72
|
+
const dropped = [];
|
|
73
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
74
|
+
const isValid = MODEL_OVERRIDE_VALIDATORS[key];
|
|
75
|
+
if (isValid?.(value))
|
|
76
|
+
override[key] = value;
|
|
77
|
+
else
|
|
78
|
+
dropped.push(key);
|
|
79
|
+
}
|
|
80
|
+
if (dropped.length > 0) {
|
|
81
|
+
process.stderr.write(`LiteLLM: ignoring invalid model override field(s) for ${modelId} in models.json: ${dropped.join(", ")}.\n`);
|
|
82
|
+
}
|
|
83
|
+
return override;
|
|
84
|
+
}
|
|
85
|
+
// Overrides are read per registered provider name, matching pi core's own
|
|
86
|
+
// models.json layout (`providers.<name>.modelOverrides`) so aliases get the
|
|
87
|
+
// same override support as the default provider, not a special case of it.
|
|
88
|
+
async function readModelOverrides(providerName) {
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
raw = await readFile(join(getAgentDir(), "models.json"), "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return new Map();
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const config = JSON.parse(stripJsonComments(raw));
|
|
98
|
+
const overrides = new Map();
|
|
99
|
+
for (const [id, rawOverride] of Object.entries(config.providers?.[providerName]?.modelOverrides ?? {})) {
|
|
100
|
+
const override = sanitizeModelOverride(id, rawOverride);
|
|
101
|
+
if (override)
|
|
102
|
+
overrides.set(id, override);
|
|
103
|
+
}
|
|
104
|
+
return overrides;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
process.stderr.write(`LiteLLM: ignoring model overrides (failed to parse models.json: ${message}).\n`);
|
|
109
|
+
return new Map();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Merge semantics must match pi core's applyModelOverride/mergeCompat (dist/core/model-registry.js)
|
|
113
|
+
// so the same models.json entry behaves identically for litellm and built-in providers.
|
|
114
|
+
function mergeCompat(base, override) {
|
|
115
|
+
if (!override)
|
|
116
|
+
return base;
|
|
117
|
+
const merged = { ...base, ...override };
|
|
118
|
+
for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"]) {
|
|
119
|
+
const baseValue = base?.[key];
|
|
120
|
+
const overrideValue = override[key];
|
|
121
|
+
if (baseValue || overrideValue) {
|
|
122
|
+
merged[key] = { ...baseValue, ...overrideValue };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return merged;
|
|
126
|
+
}
|
|
127
|
+
function applyModelOverride(model, override) {
|
|
128
|
+
const next = { ...model };
|
|
129
|
+
if (override.name !== undefined)
|
|
130
|
+
next.name = override.name;
|
|
131
|
+
if (override.reasoning !== undefined)
|
|
132
|
+
next.reasoning = override.reasoning;
|
|
133
|
+
if (override.thinkingLevelMap !== undefined) {
|
|
134
|
+
next.thinkingLevelMap = { ...model.thinkingLevelMap, ...override.thinkingLevelMap };
|
|
135
|
+
}
|
|
136
|
+
if (override.input !== undefined)
|
|
137
|
+
next.input = override.input;
|
|
138
|
+
if (override.contextWindow !== undefined)
|
|
139
|
+
next.contextWindow = override.contextWindow;
|
|
140
|
+
if (override.maxTokens !== undefined)
|
|
141
|
+
next.maxTokens = override.maxTokens;
|
|
142
|
+
if (override.headers !== undefined)
|
|
143
|
+
next.headers = override.headers;
|
|
144
|
+
if (override.compat !== undefined)
|
|
145
|
+
next.compat = mergeCompat(model.compat, override.compat);
|
|
146
|
+
if (override.cost !== undefined)
|
|
147
|
+
next.cost = { ...model.cost, ...override.cost };
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
function applyModelOverrides(models, overrides) {
|
|
151
|
+
if (overrides.size === 0)
|
|
152
|
+
return models;
|
|
153
|
+
return models.map((model) => {
|
|
154
|
+
const override = overrides.get(model.id);
|
|
155
|
+
return override ? applyModelOverride(model, override) : model;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Re-reads models.json on every call so overrides edited mid-session take effect on the next
|
|
159
|
+
// refresh or login, matching pi core's live reload for built-in providers.
|
|
160
|
+
async function applyOverrides(providerName, models) {
|
|
161
|
+
return applyModelOverrides(models, await readModelOverrides(providerName));
|
|
162
|
+
}
|
|
163
|
+
async function readAuthEntry(providerName = PROVIDER_NAME) {
|
|
32
164
|
try {
|
|
33
165
|
const raw = await readFile(getAuthPath(), "utf8");
|
|
34
166
|
const parsed = JSON.parse(raw);
|
|
35
|
-
return parsed?.[
|
|
167
|
+
return parsed?.[providerName];
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function readGlobalLiteLLMSettings() {
|
|
174
|
+
try {
|
|
175
|
+
const raw = await readFile(join(getAgentDir(), "settings.json"), "utf8");
|
|
176
|
+
const parsed = JSON.parse(raw);
|
|
177
|
+
const settings = parsed[SETTINGS_KEY];
|
|
178
|
+
return settings && typeof settings === "object" && !Array.isArray(settings)
|
|
179
|
+
? settings
|
|
180
|
+
: undefined;
|
|
36
181
|
}
|
|
37
182
|
catch {
|
|
38
183
|
return undefined;
|
|
@@ -42,6 +187,9 @@ function cleanConfig(raw) {
|
|
|
42
187
|
const trimmed = raw?.trim();
|
|
43
188
|
return trimmed && trimmed !== "undefined" ? trimmed : undefined;
|
|
44
189
|
}
|
|
190
|
+
function stringSetting(value) {
|
|
191
|
+
return typeof value === "string" ? cleanConfig(value) : undefined;
|
|
192
|
+
}
|
|
45
193
|
function normalizeCommand(raw) {
|
|
46
194
|
const trimmed = cleanConfig(raw);
|
|
47
195
|
if (!trimmed)
|
|
@@ -72,6 +220,46 @@ function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
|
|
|
72
220
|
return opaqueFallback;
|
|
73
221
|
}
|
|
74
222
|
}
|
|
223
|
+
function openInBrowser(url) {
|
|
224
|
+
// Never invoke a shell here: cmd.exe re-parses metacharacters before `start`
|
|
225
|
+
// runs, which would make URL contents injectable. Launch is best-effort; the
|
|
226
|
+
// URL is also shown to the user, so launcher failures must not crash the process.
|
|
227
|
+
const [cmd, args] = process.platform === "darwin"
|
|
228
|
+
? ["open", [url]]
|
|
229
|
+
: process.platform === "win32"
|
|
230
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
231
|
+
: ["xdg-open", [url]];
|
|
232
|
+
spawn(cmd, args, { stdio: "ignore", detached: true })
|
|
233
|
+
.on("error", () => undefined)
|
|
234
|
+
.unref();
|
|
235
|
+
}
|
|
236
|
+
async function generateVirtualKey(baseUrl, userToken, signal, headers) {
|
|
237
|
+
const { signal: boundedSignal, cancel } = withTimeout(LOGIN_TIMEOUT_MS, signal);
|
|
238
|
+
try {
|
|
239
|
+
const response = await fetch(`${baseUrl}/key/generate`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: {
|
|
242
|
+
...headers,
|
|
243
|
+
Authorization: `Bearer ${userToken}`,
|
|
244
|
+
"Content-Type": "application/json",
|
|
245
|
+
},
|
|
246
|
+
body: JSON.stringify({}),
|
|
247
|
+
signal: boundedSignal,
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
const text = await response.text().catch(() => "");
|
|
251
|
+
throw new Error(`Virtual key generation failed (${response.status}): ${text}`);
|
|
252
|
+
}
|
|
253
|
+
const data = (await response.json());
|
|
254
|
+
if (typeof data.key !== "string" || !data.key)
|
|
255
|
+
throw new Error("No key in response from /key/generate");
|
|
256
|
+
const expiresMs = typeof data.expires === "string" ? Date.parse(data.expires) : Number.NaN;
|
|
257
|
+
return { key: data.key, expiresAt: Number.isNaN(expiresMs) ? undefined : expiresMs };
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
cancel();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
75
263
|
function getLiteLLMApiKey(credentials) {
|
|
76
264
|
return credentials.access;
|
|
77
265
|
}
|
|
@@ -80,22 +268,149 @@ function resolveOAuthApiKey(credentials) {
|
|
|
80
268
|
? executeApiKeyCommand(credentials.refresh)
|
|
81
269
|
: getLiteLLMApiKey(credentials);
|
|
82
270
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
271
|
+
function resolveTemplateConfigValue(config) {
|
|
272
|
+
let resolved = "";
|
|
273
|
+
for (let index = 0; index < config.length;) {
|
|
274
|
+
const dollarIndex = config.indexOf("$", index);
|
|
275
|
+
if (dollarIndex === -1)
|
|
276
|
+
return resolved + config.slice(index);
|
|
277
|
+
resolved += config.slice(index, dollarIndex);
|
|
278
|
+
const nextChar = config[dollarIndex + 1];
|
|
279
|
+
if (nextChar === "$" || nextChar === "!") {
|
|
280
|
+
resolved += nextChar;
|
|
281
|
+
index = dollarIndex + 2;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (nextChar === "{") {
|
|
285
|
+
const endIndex = config.indexOf("}", dollarIndex + 2);
|
|
286
|
+
if (endIndex === -1) {
|
|
287
|
+
resolved += "$";
|
|
288
|
+
index = dollarIndex + 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const name = config.slice(dollarIndex + 2, endIndex);
|
|
292
|
+
const envValue = process.env[name];
|
|
293
|
+
if (envValue === undefined)
|
|
294
|
+
return undefined;
|
|
295
|
+
resolved += envValue;
|
|
296
|
+
index = endIndex + 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const match = config.slice(dollarIndex + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
300
|
+
if (!match) {
|
|
301
|
+
resolved += "$";
|
|
302
|
+
index = dollarIndex + 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const envValue = process.env[match[0]];
|
|
306
|
+
if (envValue === undefined)
|
|
307
|
+
return undefined;
|
|
308
|
+
resolved += envValue;
|
|
309
|
+
index = dollarIndex + 1 + match[0].length;
|
|
310
|
+
}
|
|
311
|
+
return resolved;
|
|
312
|
+
}
|
|
313
|
+
function resolveConfigValue(config, { executeCommands }) {
|
|
314
|
+
if (config.startsWith("!"))
|
|
315
|
+
return executeCommands ? executeApiKeyCommand(config) : undefined;
|
|
316
|
+
return resolveTemplateConfigValue(config);
|
|
317
|
+
}
|
|
318
|
+
const warnedUnresolvedApiKeys = new Set();
|
|
319
|
+
function warnUnresolvedApiKeyConfig(providerName, config) {
|
|
320
|
+
const key = `${providerName} ${config}`;
|
|
321
|
+
if (warnedUnresolvedApiKeys.has(key))
|
|
322
|
+
return;
|
|
323
|
+
warnedUnresolvedApiKeys.add(key);
|
|
324
|
+
process.stderr.write(`LiteLLM (${providerName}): configured apiKey did not resolve (unset environment variable?); use $$ for a literal $.\n`);
|
|
325
|
+
}
|
|
326
|
+
function parseHeaderRecord(value) {
|
|
327
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
328
|
+
return undefined;
|
|
329
|
+
const headers = {};
|
|
330
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
331
|
+
if (!key.trim())
|
|
332
|
+
continue;
|
|
333
|
+
let resolved;
|
|
334
|
+
if (typeof raw === "string")
|
|
335
|
+
resolved = resolveTemplateConfigValue(raw);
|
|
336
|
+
else if (typeof raw === "number" || typeof raw === "boolean")
|
|
337
|
+
resolved = String(raw);
|
|
338
|
+
else {
|
|
339
|
+
process.stderr.write(`LiteLLM: ignoring non-primitive header value for "${key}".\n`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (resolved)
|
|
343
|
+
headers[key] = resolved;
|
|
344
|
+
}
|
|
345
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
346
|
+
}
|
|
347
|
+
function parseCustomHeaders(raw) {
|
|
348
|
+
const trimmed = cleanConfig(raw);
|
|
349
|
+
if (!trimmed)
|
|
350
|
+
return undefined;
|
|
351
|
+
try {
|
|
352
|
+
return parseHeaderRecord(JSON.parse(trimmed));
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
process.stderr.write(`LiteLLM: failed to parse custom headers (${error instanceof Error ? error.message : String(error)}).\n`);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function resolveHeaders(definition) {
|
|
360
|
+
if (typeof definition.headers === "string")
|
|
361
|
+
return parseCustomHeaders(resolveTemplateConfigValue(definition.headers));
|
|
362
|
+
return parseHeaderRecord(definition.headers);
|
|
363
|
+
}
|
|
364
|
+
// Headers can select a different tenant/customer at the same base URL with the
|
|
365
|
+
// same key, so the cache must invalidate on header changes too, not just on
|
|
366
|
+
// baseUrl/apiKey changes, or a stale tenant's models get reused silently.
|
|
367
|
+
function computeHeadersFingerprint(headers) {
|
|
368
|
+
if (!headers)
|
|
369
|
+
return undefined;
|
|
370
|
+
const sorted = Object.keys(headers)
|
|
371
|
+
.sort()
|
|
372
|
+
.map((key) => [key, headers[key]]);
|
|
373
|
+
return fingerprint(JSON.stringify(sorted));
|
|
374
|
+
}
|
|
375
|
+
// Pi core resolves registered header values with the same $VAR/!command syntax
|
|
376
|
+
// at request time, so already-resolved literals must be escaped or they get
|
|
377
|
+
// resolved a second time ($UNSET would then fail every request).
|
|
378
|
+
function escapeConfigValue(value) {
|
|
379
|
+
const escaped = value.replace(/\$/g, "$$$$");
|
|
380
|
+
return escaped.startsWith("!") ? `$${escaped}` : escaped;
|
|
381
|
+
}
|
|
382
|
+
function escapeHeaderConfig(headers) {
|
|
383
|
+
if (!headers)
|
|
384
|
+
return undefined;
|
|
385
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, escapeConfigValue(value)]));
|
|
386
|
+
}
|
|
387
|
+
async function resolveCredentials(definition, { executeHelpers = true } = {}) {
|
|
388
|
+
const entry = definition.useSavedAuth ? await readAuthEntry(definition.name) : undefined;
|
|
389
|
+
const configuredBase = cleanConfig(definition.baseUrl) ?? (definition.useDefaultEnv ? cleanConfig(process.env[ENV_BASE_URL]) : undefined);
|
|
390
|
+
const envKey = definition.useDefaultEnv ? cleanConfig(process.env[ENV_API_KEY]) : undefined;
|
|
391
|
+
const envHelperCommand = definition.useDefaultEnv ? getApiKeyHelperCommand() : undefined;
|
|
392
|
+
const useGcloudToken = definition.useGcloudTokenAuth && isGcloudTokenAuthEnabled();
|
|
89
393
|
const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
|
|
90
394
|
const gcloudCacheKey = useGcloudToken && !entry ? ((await getGcloudTokenCacheKey()) ?? undefined) : undefined;
|
|
91
395
|
const authKey = entry?.type === "oauth"
|
|
92
396
|
? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
|
|
93
397
|
: entry?.type === "api_key"
|
|
94
|
-
? (await AuthStorage.create(getAuthPath()).getApiKey(
|
|
398
|
+
? (await AuthStorage.create(getAuthPath()).getApiKey(definition.name, { includeFallback: false }))?.trim()
|
|
95
399
|
: undefined;
|
|
96
400
|
const gcloudKey = executeHelpers && gcloudCacheKey ? (await getGcloudToken())?.trim() : undefined;
|
|
97
|
-
|
|
98
|
-
|
|
401
|
+
// Resolved lazily so a `!command` key is not executed when a
|
|
402
|
+
// higher-precedence credential (saved auth, gcloud token) already won.
|
|
403
|
+
let configuredKey;
|
|
404
|
+
if (!authKey && !gcloudKey && definition.apiKeyConfig) {
|
|
405
|
+
configuredKey = resolveConfigValue(definition.apiKeyConfig, { executeCommands: executeHelpers });
|
|
406
|
+
if (configuredKey === undefined && !definition.apiKeyConfig.startsWith("!")) {
|
|
407
|
+
warnUnresolvedApiKeyConfig(definition.name, definition.apiKeyConfig);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const helperKey = !authKey && !gcloudKey && !configuredKey && executeHelpers && envHelperCommand
|
|
411
|
+
? executeApiKeyCommand(envHelperCommand)
|
|
412
|
+
: undefined;
|
|
413
|
+
const apiKey = authKey || gcloudKey || configuredKey || helperKey || envKey;
|
|
99
414
|
let apiKeyFingerprint;
|
|
100
415
|
let apiKeyConfig;
|
|
101
416
|
if (entry?.type === "oauth" && entry.refresh.startsWith("!")) {
|
|
@@ -112,6 +427,14 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
|
|
|
112
427
|
apiKeyFingerprint = fingerprint(gcloudCacheKey);
|
|
113
428
|
apiKeyConfig = getGcloudTokenCommand();
|
|
114
429
|
}
|
|
430
|
+
else if (configuredKey && definition.apiKeyConfig) {
|
|
431
|
+
apiKeyFingerprint = fingerprint(definition.apiKeyConfig.startsWith("!") ? definition.apiKeyConfig : configuredKey);
|
|
432
|
+
apiKeyConfig = definition.apiKeyConfig;
|
|
433
|
+
}
|
|
434
|
+
else if (!executeHelpers && definition.apiKeyConfig?.startsWith("!")) {
|
|
435
|
+
apiKeyFingerprint = fingerprint(definition.apiKeyConfig);
|
|
436
|
+
apiKeyConfig = definition.apiKeyConfig;
|
|
437
|
+
}
|
|
115
438
|
else if (helperKey && envHelperCommand) {
|
|
116
439
|
apiKeyFingerprint = fingerprint(envHelperCommand);
|
|
117
440
|
apiKeyConfig = envHelperCommand;
|
|
@@ -124,7 +447,7 @@ async function resolveCredentials({ executeHelpers = true } = {}) {
|
|
|
124
447
|
apiKeyFingerprint = fingerprint(envKey);
|
|
125
448
|
apiKeyConfig = `$${ENV_API_KEY}`;
|
|
126
449
|
}
|
|
127
|
-
const rawBase = authBase ||
|
|
450
|
+
const rawBase = authBase || configuredBase;
|
|
128
451
|
return {
|
|
129
452
|
baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
|
|
130
453
|
apiKey: apiKey || undefined,
|
|
@@ -147,6 +470,51 @@ function isOffline() {
|
|
|
147
470
|
function isListModelsMode() {
|
|
148
471
|
return process.argv.includes("--list-models");
|
|
149
472
|
}
|
|
473
|
+
function normalizeProviderSettings(raw) {
|
|
474
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
475
|
+
return undefined;
|
|
476
|
+
const record = raw;
|
|
477
|
+
if (record.enabled === false)
|
|
478
|
+
return undefined;
|
|
479
|
+
return record;
|
|
480
|
+
}
|
|
481
|
+
async function getProviderDefinitions() {
|
|
482
|
+
const settings = await readGlobalLiteLLMSettings();
|
|
483
|
+
const rawProviders = settings?.providers && typeof settings.providers === "object" ? settings.providers : undefined;
|
|
484
|
+
const providerSettings = rawProviders;
|
|
485
|
+
const defaultSettings = normalizeProviderSettings(providerSettings?.[PROVIDER_NAME]);
|
|
486
|
+
const makeDefinition = (name, raw, isDefault) => ({
|
|
487
|
+
name,
|
|
488
|
+
displayName: stringSetting(raw?.displayName) ?? (isDefault ? "LiteLLM" : name),
|
|
489
|
+
baseUrl: stringSetting(raw?.baseUrl),
|
|
490
|
+
apiKeyConfig: stringSetting(raw?.apiKey),
|
|
491
|
+
headers: raw?.headers ?? (isDefault ? `$${ENV_HEADERS}` : undefined),
|
|
492
|
+
useDefaultEnv: isDefault,
|
|
493
|
+
useGcloudTokenAuth: isDefault,
|
|
494
|
+
useSavedAuth: true,
|
|
495
|
+
enableOAuth: isDefault,
|
|
496
|
+
});
|
|
497
|
+
const definitions = [makeDefinition(PROVIDER_NAME, defaultSettings, true)];
|
|
498
|
+
const usedCacheSegments = new Map();
|
|
499
|
+
for (const [name, raw] of Object.entries(providerSettings ?? {})) {
|
|
500
|
+
if (name === PROVIDER_NAME)
|
|
501
|
+
continue;
|
|
502
|
+
const normalized = normalizeProviderSettings(raw);
|
|
503
|
+
if (!normalized)
|
|
504
|
+
continue;
|
|
505
|
+
// Distinct alias names can sanitize to the same cache file; registering
|
|
506
|
+
// both would let their model caches silently clobber each other.
|
|
507
|
+
const segment = sanitizeCacheSegment(name);
|
|
508
|
+
const existing = usedCacheSegments.get(segment);
|
|
509
|
+
if (existing) {
|
|
510
|
+
process.stderr.write(`LiteLLM: provider alias "${name}" would share a cache file with "${existing}"; skipping it. Rename the alias.\n`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
usedCacheSegments.set(segment, name);
|
|
514
|
+
definitions.push(makeDefinition(name, normalized, false));
|
|
515
|
+
}
|
|
516
|
+
return definitions;
|
|
517
|
+
}
|
|
150
518
|
async function discoverWithFallback(baseUrl, apiKey, options) {
|
|
151
519
|
try {
|
|
152
520
|
return { result: await discoverModels(baseUrl, apiKey, options) };
|
|
@@ -159,41 +527,98 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
|
|
|
159
527
|
};
|
|
160
528
|
}
|
|
161
529
|
}
|
|
162
|
-
async function loginLiteLLM(callbacks,
|
|
530
|
+
async function loginLiteLLM(callbacks, options) {
|
|
163
531
|
const rawBaseUrl = (await callbacks.onPrompt({
|
|
164
532
|
message: "Enter LiteLLM proxy URL (no trailing /v1):",
|
|
165
533
|
placeholder: "https://litellm.example.com",
|
|
166
534
|
})).trim();
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
throw new Error("Both base URL and API key are required");
|
|
535
|
+
if (!rawBaseUrl)
|
|
536
|
+
throw new Error("Base URL is required");
|
|
170
537
|
const baseUrl = normalizeBaseUrl(rawBaseUrl);
|
|
171
|
-
const
|
|
172
|
-
|
|
538
|
+
const method = (await callbacks.onPrompt({
|
|
539
|
+
message: "Select login method (1 = API key / !command, 2 = SSO / Enterprise JWT):",
|
|
540
|
+
})).trim();
|
|
541
|
+
let apiKey;
|
|
542
|
+
let refresh;
|
|
543
|
+
let expires;
|
|
544
|
+
if (method === "2") {
|
|
545
|
+
callbacks.onAuth?.({
|
|
546
|
+
url: `${baseUrl}/sso/key/generate`,
|
|
547
|
+
instructions: "Authenticate via SSO, then copy your token from the LiteLLM UI.",
|
|
548
|
+
});
|
|
549
|
+
const rawToken = (await callbacks.onPrompt({ message: "Paste your SSO token from the LiteLLM UI:" }))
|
|
550
|
+
.trim()
|
|
551
|
+
.replace(/^Bearer\s+/i, "")
|
|
552
|
+
.trim();
|
|
553
|
+
if (!rawToken)
|
|
554
|
+
throw new Error("SSO token is required");
|
|
555
|
+
const wantVirtualKey = (await callbacks.onPrompt({ message: "Generate a LiteLLM virtual key from this token? (y/n):" }))
|
|
556
|
+
.trim()
|
|
557
|
+
.toLowerCase();
|
|
558
|
+
if (wantVirtualKey !== "n" && wantVirtualKey !== "no") {
|
|
559
|
+
try {
|
|
560
|
+
callbacks.onProgress?.("Generating virtual key...");
|
|
561
|
+
const generated = await generateVirtualKey(baseUrl, rawToken, callbacks.signal, options.headers);
|
|
562
|
+
apiKey = generated.key;
|
|
563
|
+
refresh = "";
|
|
564
|
+
expires =
|
|
565
|
+
generated.expiresAt === undefined
|
|
566
|
+
? PERMANENT_TOKEN_EXPIRES_AT
|
|
567
|
+
: Math.max(Date.now(), generated.expiresAt - TOKEN_REFRESH_LEAD_MS);
|
|
568
|
+
callbacks.onProgress?.("Virtual key generated and will be used for API calls.");
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
572
|
+
callbacks.onProgress?.(`LiteLLM: virtual key generation failed (${message}); using SSO token directly.`);
|
|
573
|
+
apiKey = rawToken;
|
|
574
|
+
refresh = "";
|
|
575
|
+
expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
apiKey = rawToken;
|
|
580
|
+
refresh = "";
|
|
581
|
+
expires = tokenExpiresAt(rawToken, PERMANENT_TOKEN_EXPIRES_AT);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
|
|
586
|
+
if (!apiKeyInput)
|
|
587
|
+
throw new Error("Both base URL and API key are required");
|
|
588
|
+
refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
|
|
589
|
+
apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
|
|
590
|
+
expires = tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT);
|
|
591
|
+
}
|
|
173
592
|
const { models, source } = await discoverModels(baseUrl, apiKey, {
|
|
174
593
|
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
175
594
|
signal: callbacks.signal,
|
|
595
|
+
headers: options.headers,
|
|
176
596
|
});
|
|
177
597
|
const cache = {
|
|
178
598
|
baseUrl,
|
|
179
599
|
apiKeyFingerprint: fingerprint(refresh || apiKey),
|
|
600
|
+
headersFingerprint: computeHeadersFingerprint(options.headers),
|
|
180
601
|
fetchedAt: Date.now(),
|
|
181
602
|
source,
|
|
182
603
|
models,
|
|
183
604
|
};
|
|
184
|
-
await writeCache(
|
|
185
|
-
onCacheWrite?.(cache);
|
|
605
|
+
await writeCache(options.cachePath, cache);
|
|
606
|
+
await options.onCacheWrite?.(cache);
|
|
186
607
|
callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
|
|
187
608
|
return {
|
|
188
609
|
access: apiKey,
|
|
189
610
|
refresh,
|
|
190
|
-
expires
|
|
611
|
+
expires,
|
|
191
612
|
baseUrl,
|
|
192
613
|
};
|
|
193
614
|
}
|
|
194
615
|
async function refreshLiteLLM(credentials) {
|
|
195
|
-
if (!credentials.refresh.startsWith("!"))
|
|
616
|
+
if (!credentials.refresh.startsWith("!")) {
|
|
617
|
+
if (credentials.expires < PERMANENT_TOKEN_EXPIRES_AT) {
|
|
618
|
+
throw new Error("LiteLLM credential cannot be refreshed; run /login litellm again");
|
|
619
|
+
}
|
|
196
620
|
return credentials;
|
|
621
|
+
}
|
|
197
622
|
const access = executeApiKeyCommand(credentials.refresh);
|
|
198
623
|
return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
|
|
199
624
|
}
|
|
@@ -203,7 +628,18 @@ function modifyLiteLLMModels(models, cred) {
|
|
|
203
628
|
return models;
|
|
204
629
|
return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
|
|
205
630
|
}
|
|
206
|
-
function
|
|
631
|
+
function isReasoningItem(item) {
|
|
632
|
+
return typeof item === "object" && item !== null && item.type === "reasoning";
|
|
633
|
+
}
|
|
634
|
+
// Reasoning fields LiteLLM forwards to chat-completions providers. The Moonshot
|
|
635
|
+
// path defaults them off; the gpt-5.5 tool path strips them entirely.
|
|
636
|
+
const REASONING_SUPPRESSION_DEFAULTS = {
|
|
637
|
+
include_reasoning: false,
|
|
638
|
+
reasoning_content: false,
|
|
639
|
+
merge_reasoning_content_in_choices: true,
|
|
640
|
+
thinking: { type: "disabled" },
|
|
641
|
+
};
|
|
642
|
+
function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
|
|
207
643
|
let next;
|
|
208
644
|
const update = (key, value) => {
|
|
209
645
|
if (payload[key] !== undefined)
|
|
@@ -211,11 +647,40 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
|
|
|
211
647
|
next ??= { ...payload };
|
|
212
648
|
next[key] = value;
|
|
213
649
|
};
|
|
214
|
-
if (modelId && shouldSuppressReasoningContent(modelId)) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
650
|
+
if (api !== "openai-responses" && modelId && shouldSuppressReasoningContent(modelId)) {
|
|
651
|
+
for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS))
|
|
652
|
+
update(key, value);
|
|
653
|
+
}
|
|
654
|
+
// LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
|
|
655
|
+
// Drop reasoning until the gateway honors /v1/responses for this route.
|
|
656
|
+
if (api !== "openai-responses" &&
|
|
657
|
+
modelId &&
|
|
658
|
+
isGpt55Model(modelId) &&
|
|
659
|
+
Array.isArray(payload.tools) &&
|
|
660
|
+
payload.tools.length > 0) {
|
|
661
|
+
const reasoningKeys = ["reasoning", "reasoning_effort", ...Object.keys(REASONING_SUPPRESSION_DEFAULTS)];
|
|
662
|
+
for (const key of reasoningKeys) {
|
|
663
|
+
if (payload[key] === undefined)
|
|
664
|
+
continue;
|
|
665
|
+
next ??= { ...payload };
|
|
666
|
+
delete next[key];
|
|
667
|
+
}
|
|
668
|
+
const include = (next ?? payload).include;
|
|
669
|
+
if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
|
|
670
|
+
next ??= { ...payload };
|
|
671
|
+
const filteredInclude = include.filter((value) => value !== "reasoning.encrypted_content");
|
|
672
|
+
if (filteredInclude.length === 0)
|
|
673
|
+
delete next.include;
|
|
674
|
+
else
|
|
675
|
+
next.include = filteredInclude;
|
|
676
|
+
}
|
|
677
|
+
// Prior turns may have replayed reasoning items (with encrypted_content)
|
|
678
|
+
// into the input; they are rejected once reasoning is stripped.
|
|
679
|
+
const input = (next ?? payload).input;
|
|
680
|
+
if (Array.isArray(input) && input.some(isReasoningItem)) {
|
|
681
|
+
next ??= { ...payload };
|
|
682
|
+
next.input = input.filter((item) => !isReasoningItem(item));
|
|
683
|
+
}
|
|
219
684
|
}
|
|
220
685
|
if (sessionId) {
|
|
221
686
|
next ??= { ...payload };
|
|
@@ -223,8 +688,8 @@ function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
|
|
|
223
688
|
}
|
|
224
689
|
return next;
|
|
225
690
|
}
|
|
226
|
-
function normalizeThinkTags(message) {
|
|
227
|
-
if (message.provider
|
|
691
|
+
function normalizeThinkTags(message, litellmProviderNames) {
|
|
692
|
+
if (!litellmProviderNames.has(message.provider) || !shouldSuppressReasoningContent(message.model))
|
|
228
693
|
return;
|
|
229
694
|
let changed = false;
|
|
230
695
|
const content = [];
|
|
@@ -285,120 +750,162 @@ function normalizeThinkTags(message) {
|
|
|
285
750
|
return { ...message, content };
|
|
286
751
|
}
|
|
287
752
|
export default async function (pi) {
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
cache.apiKeyFingerprint === fp;
|
|
297
|
-
let models = cacheValid && cache ? cache.models : [];
|
|
298
|
-
const shouldFetch = creds.baseUrl !== undefined &&
|
|
299
|
-
fp !== undefined &&
|
|
300
|
-
!isOffline() &&
|
|
301
|
-
getDiscoveryTimeoutMs() > 0 &&
|
|
302
|
-
(!cacheValid || isListModelsMode());
|
|
303
|
-
let credentialWarning;
|
|
304
|
-
let liveDiscoveryApiKey;
|
|
305
|
-
if (shouldFetch) {
|
|
306
|
-
try {
|
|
307
|
-
creds = await resolveCredentials();
|
|
308
|
-
fp = creds.apiKeyFingerprint;
|
|
309
|
-
}
|
|
310
|
-
catch (error) {
|
|
311
|
-
if (!cacheValid || !cache)
|
|
312
|
-
throw error;
|
|
313
|
-
credentialWarning = error instanceof Error ? error.message : String(error);
|
|
314
|
-
process.stderr.write(`LiteLLM: discovery failed (${credentialWarning}); using cached models.\n`);
|
|
315
|
-
models = cache.models;
|
|
316
|
-
}
|
|
753
|
+
const definitions = await getProviderDefinitions();
|
|
754
|
+
const providerNames = new Set(definitions.map((definition) => definition.name));
|
|
755
|
+
function discoveryDisabledReason() {
|
|
756
|
+
if (isOffline())
|
|
757
|
+
return `${ENV_OFFLINE}=1`;
|
|
758
|
+
if (getDiscoveryTimeoutMs() === 0)
|
|
759
|
+
return `${ENV_TIMEOUT}=0`;
|
|
760
|
+
return null;
|
|
317
761
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
762
|
+
async function loadProviderState(definition) {
|
|
763
|
+
let creds = await resolveCredentials(definition, { executeHelpers: false });
|
|
764
|
+
const headers = resolveHeaders(definition);
|
|
765
|
+
const headersFp = computeHeadersFingerprint(headers);
|
|
766
|
+
const cache = await readCache(getCachePath(definition.name));
|
|
767
|
+
let fp = creds.apiKeyFingerprint;
|
|
768
|
+
let cacheFetchedAt = cache?.fetchedAt ?? 0;
|
|
769
|
+
const cacheValid = cache !== null &&
|
|
770
|
+
creds.baseUrl !== undefined &&
|
|
771
|
+
fp !== undefined &&
|
|
772
|
+
cache.baseUrl === creds.baseUrl &&
|
|
773
|
+
cache.apiKeyFingerprint === fp &&
|
|
774
|
+
cache.headersFingerprint === headersFp;
|
|
775
|
+
let models = cacheValid && cache ? cache.models : [];
|
|
776
|
+
const shouldFetch = creds.baseUrl !== undefined &&
|
|
777
|
+
fp !== undefined &&
|
|
778
|
+
!isOffline() &&
|
|
779
|
+
getDiscoveryTimeoutMs() > 0 &&
|
|
780
|
+
(!cacheValid || isListModelsMode());
|
|
781
|
+
let credentialWarning;
|
|
782
|
+
let liveDiscoveryApiKey;
|
|
783
|
+
if (shouldFetch) {
|
|
784
|
+
try {
|
|
785
|
+
creds = await resolveCredentials(definition);
|
|
786
|
+
fp = creds.apiKeyFingerprint;
|
|
327
787
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
788
|
+
catch (error) {
|
|
789
|
+
// A broken alias must not abort activation for the other providers;
|
|
790
|
+
// only the default provider keeps the historical fail-fast behavior.
|
|
791
|
+
if ((!cacheValid || !cache) && definition.useDefaultEnv)
|
|
792
|
+
throw error;
|
|
793
|
+
credentialWarning = error instanceof Error ? error.message : String(error);
|
|
794
|
+
if (cacheValid && cache) {
|
|
795
|
+
process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${credentialWarning}); using cached models.\n`);
|
|
796
|
+
models = cache.models;
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
process.stderr.write(`LiteLLM (${definition.name}): credential resolution failed (${credentialWarning}); registering provider with no models.\n`);
|
|
800
|
+
models = [];
|
|
801
|
+
}
|
|
331
802
|
}
|
|
332
803
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
804
|
+
if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
|
|
805
|
+
const timeoutMs = getDiscoveryTimeoutMs();
|
|
806
|
+
const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
|
|
807
|
+
timeoutMs,
|
|
808
|
+
headers,
|
|
809
|
+
});
|
|
810
|
+
if (warning) {
|
|
811
|
+
if (cacheValid && cache) {
|
|
812
|
+
process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${warning}); using cached models.\n`);
|
|
813
|
+
models = cache.models;
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
process.stderr.write(`LiteLLM (${definition.name}): discovery failed (${warning}); registering provider with no models.\n`);
|
|
817
|
+
models = [];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
models = result.models;
|
|
822
|
+
liveDiscoveryApiKey = creds.apiKey;
|
|
823
|
+
const next = {
|
|
824
|
+
baseUrl: creds.baseUrl,
|
|
825
|
+
apiKeyFingerprint: fp,
|
|
826
|
+
headersFingerprint: headersFp,
|
|
827
|
+
fetchedAt: Date.now(),
|
|
828
|
+
source: result.source,
|
|
829
|
+
models: result.models,
|
|
830
|
+
};
|
|
831
|
+
await writeCache(getCachePath(definition.name), next);
|
|
832
|
+
cacheFetchedAt = next.fetchedAt;
|
|
833
|
+
if (isListModelsMode()) {
|
|
834
|
+
process.stderr.write(`LiteLLM (${definition.name}): ${result.models.length} models discovered (source: ${result.source}).\n`);
|
|
835
|
+
}
|
|
347
836
|
}
|
|
348
837
|
}
|
|
838
|
+
// The cache keeps raw discovery output; overrides are applied freshly at each registration.
|
|
839
|
+
models = await applyOverrides(definition.name, models);
|
|
840
|
+
return { definition, creds, headers, models, cacheFetchedAt, liveDiscoveryApiKey, refreshInProgress: null };
|
|
349
841
|
}
|
|
842
|
+
const providerStates = await Promise.all(definitions.map(loadProviderState));
|
|
843
|
+
const defaultState = providerStates.find((state) => state.definition.name === PROVIDER_NAME) ?? providerStates[0];
|
|
350
844
|
let updateCosts = () => undefined;
|
|
845
|
+
const providerModelsForCosts = () => providerStates.map((state) => ({ provider: state.definition.name, models: state.models }));
|
|
846
|
+
const updateAllCosts = () => updateCosts(providerModelsForCosts());
|
|
847
|
+
function defaultApiKeyConfig(definition) {
|
|
848
|
+
if (definition.useDefaultEnv) {
|
|
849
|
+
return definition.apiKeyConfig ?? getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`;
|
|
850
|
+
}
|
|
851
|
+
// An alias must never inherit the default provider's env key; leaving the
|
|
852
|
+
// key unset makes requests fail loudly instead of leaking credentials.
|
|
853
|
+
return definition.apiKeyConfig;
|
|
854
|
+
}
|
|
855
|
+
function registerProvider(state, models = state.models, apiKeyConfig = state.creds.apiKeyConfig) {
|
|
856
|
+
const definition = state.definition;
|
|
857
|
+
pi.registerProvider(definition.name, {
|
|
858
|
+
name: definition.displayName,
|
|
859
|
+
baseUrl: state.creds.baseUrl ? `${state.creds.baseUrl}/v1` : "https://litellm.example.com/v1",
|
|
860
|
+
apiKey: apiKeyConfig ?? defaultApiKeyConfig(definition),
|
|
861
|
+
api: "openai-completions",
|
|
862
|
+
headers: escapeHeaderConfig(state.headers),
|
|
863
|
+
models,
|
|
864
|
+
oauth: definition.enableOAuth ? oauth : undefined,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
function defaultLoginOptions() {
|
|
868
|
+
return {
|
|
869
|
+
cachePath: getCachePath(PROVIDER_NAME),
|
|
870
|
+
headers: defaultState.headers,
|
|
871
|
+
onCacheWrite: async (next) => {
|
|
872
|
+
defaultState.cacheFetchedAt = next.fetchedAt;
|
|
873
|
+
const overridden = await applyOverrides(PROVIDER_NAME, next.models);
|
|
874
|
+
defaultState.models = overridden;
|
|
875
|
+
defaultState.creds = {
|
|
876
|
+
...defaultState.creds,
|
|
877
|
+
baseUrl: next.baseUrl,
|
|
878
|
+
apiKeyFingerprint: next.apiKeyFingerprint,
|
|
879
|
+
};
|
|
880
|
+
registerProvider(defaultState, overridden);
|
|
881
|
+
updateAllCosts();
|
|
882
|
+
},
|
|
883
|
+
};
|
|
884
|
+
}
|
|
351
885
|
const oauth = {
|
|
352
886
|
name: "LiteLLM",
|
|
353
|
-
login: (callbacks) => loginLiteLLM(callbacks, (
|
|
354
|
-
cacheFetchedAt = next.fetchedAt;
|
|
355
|
-
registerProvider(next.baseUrl, next.models);
|
|
356
|
-
updateCosts(next.models);
|
|
357
|
-
}),
|
|
887
|
+
login: (callbacks) => loginLiteLLM(callbacks, defaultLoginOptions()),
|
|
358
888
|
refreshToken: refreshLiteLLM,
|
|
359
889
|
getApiKey: getLiteLLMApiKey,
|
|
360
890
|
modifyModels: modifyLiteLLMModels,
|
|
361
891
|
};
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// resolveConfigValueOrThrow -> resolveConfigValueUncached, i.e. the command is re-executed on
|
|
368
|
-
// every request (it does NOT use the process-lifetime command cache, which only applies to
|
|
369
|
-
// resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
|
|
370
|
-
// remain registered for `/login litellm` users. See the regression test
|
|
371
|
-
// "re-runs the helper command on every request" in tests/index.test.ts.
|
|
372
|
-
apiKey: apiKeyConfig,
|
|
373
|
-
api: "openai-completions",
|
|
374
|
-
models,
|
|
375
|
-
oauth,
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
registerProvider(creds.baseUrl, models);
|
|
379
|
-
updateCosts = setupLiteLLMCostTracking(pi, models);
|
|
380
|
-
let refreshInProgress = null;
|
|
381
|
-
function discoveryDisabledReason() {
|
|
382
|
-
if (isOffline())
|
|
383
|
-
return `${ENV_OFFLINE}=1`;
|
|
384
|
-
if (getDiscoveryTimeoutMs() === 0)
|
|
385
|
-
return `${ENV_TIMEOUT}=0`;
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
async function resolveRuntimeApiKey() {
|
|
389
|
-
const fresh = await resolveCredentials();
|
|
892
|
+
for (const state of providerStates)
|
|
893
|
+
registerProvider(state);
|
|
894
|
+
updateCosts = setupLiteLLMCostTracking(pi, providerModelsForCosts());
|
|
895
|
+
async function resolveRuntimeApiKey(state = defaultState) {
|
|
896
|
+
const fresh = await resolveCredentials(state.definition);
|
|
390
897
|
if (!fresh.apiKey)
|
|
391
|
-
throw new Error(
|
|
898
|
+
throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
|
|
392
899
|
return fresh.apiKey;
|
|
393
900
|
}
|
|
394
|
-
function registerSkillTools(
|
|
395
|
-
if (!baseUrl)
|
|
901
|
+
function registerSkillTools(state = defaultState) {
|
|
902
|
+
if (!state.creds.baseUrl)
|
|
396
903
|
return;
|
|
397
|
-
for (const tool of createSkillToolDefinitions(baseUrl, resolveRuntimeApiKey)) {
|
|
904
|
+
for (const tool of createSkillToolDefinitions(state.creds.baseUrl, () => resolveRuntimeApiKey(state), state.headers)) {
|
|
398
905
|
pi.registerTool(tool);
|
|
399
906
|
}
|
|
400
907
|
}
|
|
401
|
-
function seededRuntimeApiKey(seed) {
|
|
908
|
+
function seededRuntimeApiKey(state, seed) {
|
|
402
909
|
let first = seed;
|
|
403
910
|
return async () => {
|
|
404
911
|
if (first) {
|
|
@@ -406,54 +913,66 @@ export default async function (pi) {
|
|
|
406
913
|
first = undefined;
|
|
407
914
|
return value;
|
|
408
915
|
}
|
|
409
|
-
return resolveRuntimeApiKey();
|
|
916
|
+
return resolveRuntimeApiKey(state);
|
|
410
917
|
};
|
|
411
918
|
}
|
|
412
|
-
async function registerMcpTools(
|
|
413
|
-
if (!baseUrl || !
|
|
919
|
+
async function registerMcpTools(state) {
|
|
920
|
+
if (!state.creds.baseUrl || !state.liveDiscoveryApiKey || discoveryDisabledReason())
|
|
414
921
|
return;
|
|
415
922
|
try {
|
|
416
|
-
const tools = await createMcpToolDefinitions(baseUrl, seededRuntimeApiKey(
|
|
923
|
+
const tools = await createMcpToolDefinitions(state.creds.baseUrl, seededRuntimeApiKey(state, state.liveDiscoveryApiKey), state.headers);
|
|
417
924
|
for (const tool of tools) {
|
|
418
925
|
pi.registerTool(tool);
|
|
419
926
|
}
|
|
420
927
|
}
|
|
421
928
|
catch (error) {
|
|
422
|
-
process.stderr.write(`LiteLLM: MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
|
|
929
|
+
process.stderr.write(`LiteLLM (${state.definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).\n`);
|
|
423
930
|
}
|
|
424
931
|
}
|
|
425
|
-
registerSkillTools(
|
|
426
|
-
await registerMcpTools(
|
|
427
|
-
async function refreshModelsAndCosts() {
|
|
428
|
-
const fresh = await resolveCredentials();
|
|
932
|
+
registerSkillTools(defaultState);
|
|
933
|
+
await registerMcpTools(defaultState);
|
|
934
|
+
async function refreshModelsAndCosts(state) {
|
|
935
|
+
const fresh = await resolveCredentials(state.definition);
|
|
429
936
|
const freshFp = fresh.apiKeyFingerprint;
|
|
430
937
|
if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
|
|
431
|
-
throw new Error(
|
|
938
|
+
throw new Error(`no credentials for ${state.definition.name}. Run /login litellm or set env vars.`);
|
|
432
939
|
}
|
|
433
|
-
const result = await discoverModels(fresh.baseUrl, fresh.apiKey, {
|
|
940
|
+
const result = await discoverModels(fresh.baseUrl, fresh.apiKey, {
|
|
941
|
+
timeoutMs: getDiscoveryTimeoutMs(),
|
|
942
|
+
headers: state.headers,
|
|
943
|
+
});
|
|
434
944
|
const now = Date.now();
|
|
435
|
-
await writeCache(getCachePath(), {
|
|
945
|
+
await writeCache(getCachePath(state.definition.name), {
|
|
436
946
|
baseUrl: fresh.baseUrl,
|
|
437
947
|
apiKeyFingerprint: freshFp,
|
|
948
|
+
headersFingerprint: computeHeadersFingerprint(state.headers),
|
|
438
949
|
fetchedAt: now,
|
|
439
950
|
source: result.source,
|
|
440
951
|
models: result.models,
|
|
441
952
|
});
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
953
|
+
const overridden = await applyOverrides(state.definition.name, result.models);
|
|
954
|
+
state.creds = fresh;
|
|
955
|
+
state.models = overridden;
|
|
956
|
+
state.liveDiscoveryApiKey = fresh.apiKey;
|
|
957
|
+
state.cacheFetchedAt = now;
|
|
958
|
+
registerProvider(state, overridden, fresh.apiKeyConfig);
|
|
959
|
+
updateAllCosts();
|
|
960
|
+
if (state.definition.name === PROVIDER_NAME)
|
|
961
|
+
await registerMcpTools(state);
|
|
962
|
+
return { providerName: state.definition.name, models: overridden, source: result.source };
|
|
963
|
+
}
|
|
964
|
+
function runRefresh(state) {
|
|
965
|
+
state.refreshInProgress ??= refreshModelsAndCosts(state).finally(() => {
|
|
966
|
+
state.refreshInProgress = null;
|
|
451
967
|
});
|
|
452
|
-
return refreshInProgress;
|
|
968
|
+
return state.refreshInProgress;
|
|
453
969
|
}
|
|
454
970
|
async function runLogin(ctx) {
|
|
455
971
|
const credential = await loginLiteLLM({
|
|
456
|
-
onAuth: () =>
|
|
972
|
+
onAuth: ({ url, instructions }) => {
|
|
973
|
+
openInBrowser(url);
|
|
974
|
+
ctx.ui.notify(instructions ? `${url} — ${instructions}` : url, "info");
|
|
975
|
+
},
|
|
457
976
|
onDeviceCode: () => undefined,
|
|
458
977
|
onPrompt: async ({ message, placeholder }) => {
|
|
459
978
|
const value = await ctx.ui.input(message, placeholder);
|
|
@@ -464,17 +983,15 @@ export default async function (pi) {
|
|
|
464
983
|
onProgress: (message) => ctx.ui.notify(message, "info"),
|
|
465
984
|
onSelect: async () => undefined,
|
|
466
985
|
signal: ctx.signal,
|
|
467
|
-
}, (
|
|
468
|
-
cacheFetchedAt = next.fetchedAt;
|
|
469
|
-
registerProvider(next.baseUrl, next.models);
|
|
470
|
-
updateCosts(next.models);
|
|
471
|
-
});
|
|
986
|
+
}, defaultLoginOptions());
|
|
472
987
|
ctx.modelRegistry.authStorage.set(PROVIDER_NAME, { type: "oauth", ...credential });
|
|
473
988
|
ctx.modelRegistry.refresh();
|
|
474
989
|
const credentialBaseUrl = credential.baseUrl;
|
|
475
990
|
const credentialAccess = typeof credential.access === "string" ? credential.access : undefined;
|
|
476
|
-
|
|
477
|
-
|
|
991
|
+
defaultState.creds = { ...defaultState.creds, baseUrl: credentialBaseUrl };
|
|
992
|
+
defaultState.liveDiscoveryApiKey = credentialAccess;
|
|
993
|
+
registerSkillTools(defaultState);
|
|
994
|
+
await registerMcpTools(defaultState);
|
|
478
995
|
ctx.ui.notify(`Logged in to LiteLLM. Credentials saved to ${getAuthPath()}`, "info");
|
|
479
996
|
}
|
|
480
997
|
pi.on("input", async (event, ctx) => {
|
|
@@ -494,43 +1011,75 @@ export default async function (pi) {
|
|
|
494
1011
|
});
|
|
495
1012
|
pi.registerCommand("litellm-refresh", {
|
|
496
1013
|
description: "Re-discover models from the LiteLLM proxy.",
|
|
497
|
-
handler: async (
|
|
1014
|
+
handler: async (args, ctx) => {
|
|
498
1015
|
const disabledReason = discoveryDisabledReason();
|
|
499
1016
|
if (disabledReason) {
|
|
500
1017
|
ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
|
|
501
1018
|
return;
|
|
502
1019
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
1020
|
+
const requestedProvider = args.trim();
|
|
1021
|
+
const statesToRefresh = requestedProvider
|
|
1022
|
+
? providerStates.filter((state) => state.definition.name === requestedProvider)
|
|
1023
|
+
: providerStates;
|
|
1024
|
+
if (statesToRefresh.length === 0) {
|
|
1025
|
+
ctx.ui.notify(`LiteLLM refresh failed: unknown provider ${requestedProvider}`, "error");
|
|
1026
|
+
return;
|
|
506
1027
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
1028
|
+
const settled = await Promise.allSettled(statesToRefresh.map(runRefresh));
|
|
1029
|
+
const succeeded = settled.filter((result) => result.status === "fulfilled").map((result) => result.value);
|
|
1030
|
+
const failed = settled
|
|
1031
|
+
.map((result, index) => ({ result, name: statesToRefresh[index].definition.name }))
|
|
1032
|
+
.filter(({ result }) => result.status === "rejected")
|
|
1033
|
+
.map(({ result, name }) => {
|
|
1034
|
+
const reason = result.reason;
|
|
1035
|
+
return { name, message: reason instanceof Error ? reason.message : String(reason) };
|
|
1036
|
+
});
|
|
1037
|
+
if (failed.length === 0) {
|
|
1038
|
+
if (succeeded.length === 1) {
|
|
1039
|
+
const result = succeeded[0];
|
|
1040
|
+
ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
ctx.ui.notify(`LiteLLM: ${succeeded.length} providers refreshed (${succeeded
|
|
1044
|
+
.map((result) => `${result.providerName}: ${result.models.length} models`)
|
|
1045
|
+
.join(", ")})`, "info");
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
const failures = failed.map(({ name, message }) => (settled.length === 1 ? message : `${name}: ${message}`));
|
|
1049
|
+
if (succeeded.length === 0) {
|
|
1050
|
+
ctx.ui.notify(`LiteLLM refresh failed: ${failures.join("; ")}`, "error");
|
|
1051
|
+
return;
|
|
510
1052
|
}
|
|
1053
|
+
ctx.ui.notify(`LiteLLM: refreshed ${succeeded
|
|
1054
|
+
.map((result) => `${result.providerName}: ${result.models.length} models`)
|
|
1055
|
+
.join(", ")}; failed ${failures.join("; ")}`, "warning");
|
|
511
1056
|
},
|
|
512
1057
|
});
|
|
513
1058
|
let sessionId;
|
|
514
1059
|
pi.on("session_start", (_event, ctx) => {
|
|
515
1060
|
sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
|
|
516
|
-
if (discoveryDisabledReason()
|
|
1061
|
+
if (discoveryDisabledReason())
|
|
517
1062
|
return;
|
|
518
|
-
|
|
1063
|
+
for (const state of providerStates) {
|
|
1064
|
+
if (!state.cacheFetchedAt || Date.now() - state.cacheFetchedAt <= CACHE_STALE_MS)
|
|
1065
|
+
continue;
|
|
1066
|
+
void runRefresh(state).catch(() => undefined);
|
|
1067
|
+
}
|
|
519
1068
|
});
|
|
520
1069
|
pi.on("before_provider_request", (event, ctx) => {
|
|
521
|
-
if (ctx.model?.provider
|
|
1070
|
+
if (!ctx.model?.provider || !providerNames.has(ctx.model.provider))
|
|
522
1071
|
return;
|
|
523
1072
|
if (typeof event.payload !== "object" || event.payload === null)
|
|
524
1073
|
return;
|
|
525
|
-
return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
|
|
1074
|
+
return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, ctx.model?.api, sessionId);
|
|
526
1075
|
});
|
|
527
1076
|
pi.on("before_agent_start", async (event) => {
|
|
528
1077
|
if (discoveryDisabledReason())
|
|
529
1078
|
return;
|
|
530
|
-
const fresh = await resolveCredentials();
|
|
1079
|
+
const fresh = await resolveCredentials(defaultState.definition);
|
|
531
1080
|
if (!fresh.baseUrl || !fresh.apiKey)
|
|
532
1081
|
return;
|
|
533
|
-
const skills = await listSkills(fresh.baseUrl, fresh.apiKey);
|
|
1082
|
+
const skills = await listSkills(fresh.baseUrl, fresh.apiKey, defaultState.headers);
|
|
534
1083
|
const section = createSkillsPromptSection(skills);
|
|
535
1084
|
if (!section)
|
|
536
1085
|
return;
|
|
@@ -539,7 +1088,7 @@ export default async function (pi) {
|
|
|
539
1088
|
pi.on("message_end", (event) => {
|
|
540
1089
|
if (event.message.role !== "assistant")
|
|
541
1090
|
return;
|
|
542
|
-
const message = normalizeThinkTags(event.message);
|
|
1091
|
+
const message = normalizeThinkTags(event.message, providerNames);
|
|
543
1092
|
if (!message)
|
|
544
1093
|
return;
|
|
545
1094
|
return { message };
|