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