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