pi-provider-litellm 3.3.0 → 4.0.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 +18 -60
- package/package.json +3 -3
- package/src/backend-identity.ts +1 -1
- package/src/discover.ts +67 -8
- package/src/index.ts +128 -399
- package/src/model-groups.ts +4 -1
- package/src/provider.ts +1 -1
- package/src/skills.ts +1 -2
- package/src/types.ts +9 -24
- package/src/mcp-tools.ts +0 -1536
package/src/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
3
|
import { readFile } from "node:fs/promises";
|
|
5
4
|
import { createServer } from "node:http";
|
|
6
5
|
import { join } from "node:path";
|
|
@@ -15,24 +14,15 @@ import type {
|
|
|
15
14
|
OAuthCredentials,
|
|
16
15
|
ProviderAuth,
|
|
17
16
|
} from "@earendil-works/pi-ai";
|
|
18
|
-
import {
|
|
19
|
-
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext, McpServerConfig } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
// A namespace import, so a Pi without `VERSION` reaches the version check instead of failing to link.
|
|
19
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
22
|
-
import { discoverModels,
|
|
22
|
+
import { discoverModels, isGpt6OrNewerModel, isGpt55OrNewerModel, normalizeBaseUrl } from "./discover.js";
|
|
23
23
|
import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
|
|
24
|
-
import {
|
|
25
|
-
createMcpToolDefinitions,
|
|
26
|
-
credentialFingerprint,
|
|
27
|
-
McpAccessDeniedError,
|
|
28
|
-
type McpExposure,
|
|
29
|
-
parseMcpExposurePolicy,
|
|
30
|
-
reportMcpCatalogOutcome,
|
|
31
|
-
reportMcpPartialDiscovery,
|
|
32
|
-
reportMcpRegistrationFatal,
|
|
33
|
-
reportMcpRegistrationSuccess,
|
|
34
|
-
} from "./mcp-tools.js";
|
|
35
24
|
import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
|
|
25
|
+
import { parseProxyVersion, proxyVersionAtLeast } from "./proxy-version.js";
|
|
36
26
|
import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
|
|
37
27
|
import type {
|
|
38
28
|
DiscoveryOptions,
|
|
@@ -83,8 +73,6 @@ type RawProviderSettings = {
|
|
|
83
73
|
oidc?: unknown;
|
|
84
74
|
};
|
|
85
75
|
|
|
86
|
-
type McpRuntimeAuth = LiteLLMRuntimeAuth & { mcpPauseSource?: string };
|
|
87
|
-
|
|
88
76
|
type ProviderDefinition = {
|
|
89
77
|
name: string;
|
|
90
78
|
displayName: string;
|
|
@@ -461,6 +449,13 @@ function isHostOffline(): boolean {
|
|
|
461
449
|
return process.env.PI_OFFLINE !== undefined;
|
|
462
450
|
}
|
|
463
451
|
|
|
452
|
+
// Pi resolves `$NAME`, `${NAME}`, and a leading `!command` in MCP header values. Header values here
|
|
453
|
+
// are already resolved, so they are escaped back to literals: `$$` is a `$`, and `$!` a leading `!`.
|
|
454
|
+
function piLiteral(value: string): string {
|
|
455
|
+
const escaped = value.replaceAll("$", () => "$$");
|
|
456
|
+
return escaped.startsWith("!") ? `$${escaped}` : escaped;
|
|
457
|
+
}
|
|
458
|
+
|
|
464
459
|
/**
|
|
465
460
|
* LiteLLM's /model/info is the metadata authority, so models.dev is an opt-in escape hatch.
|
|
466
461
|
* Without a cache path and with refresh suppressed, discovery reads neither the models.dev
|
|
@@ -1360,7 +1355,8 @@ async function refreshWithBackoff(
|
|
|
1360
1355
|
return credentials;
|
|
1361
1356
|
}
|
|
1362
1357
|
pkceTransientRefreshBackoff.delete(credentials.refresh);
|
|
1363
|
-
|
|
1358
|
+
// A transient failure leaves the stored credential intact, and a new login would need the same endpoint.
|
|
1359
|
+
if (!result.ok) throw new Error(result.transient ? result.message : `${result.message}; run /login litellm again`);
|
|
1364
1360
|
return { ...credentials, ...result.token };
|
|
1365
1361
|
}
|
|
1366
1362
|
|
|
@@ -1529,12 +1525,12 @@ async function resolveApiKeyAuth(
|
|
|
1529
1525
|
function createProviderAuth(
|
|
1530
1526
|
definition: ProviderDefinition,
|
|
1531
1527
|
clearOAuthRuntimeRoot?: () => void,
|
|
1532
|
-
|
|
1528
|
+
loginHooks?: { start: () => void; complete: () => void },
|
|
1533
1529
|
oauthRuntimeRoot?: () => { apiKey: string; root: string } | undefined,
|
|
1534
1530
|
): ProviderAuth {
|
|
1535
1531
|
function completeLogin<T extends Credential>(credential: T): T {
|
|
1536
|
-
|
|
1537
|
-
return
|
|
1532
|
+
loginHooks?.complete();
|
|
1533
|
+
return credential;
|
|
1538
1534
|
}
|
|
1539
1535
|
return {
|
|
1540
1536
|
apiKey: {
|
|
@@ -1542,6 +1538,7 @@ function createProviderAuth(
|
|
|
1542
1538
|
login:
|
|
1543
1539
|
definition.name === PROVIDER_NAME
|
|
1544
1540
|
? async (interaction) => {
|
|
1541
|
+
loginHooks?.start();
|
|
1545
1542
|
const credential = await loginApiKey(interaction, definition);
|
|
1546
1543
|
return completeLogin(credential);
|
|
1547
1544
|
}
|
|
@@ -1614,6 +1611,7 @@ function createProviderAuth(
|
|
|
1614
1611
|
name: "LiteLLM SSO",
|
|
1615
1612
|
loginLabel: "Sign in with LiteLLM SSO",
|
|
1616
1613
|
login: async (interaction) => {
|
|
1614
|
+
loginHooks?.start();
|
|
1617
1615
|
const credential = await loginOAuth(interaction, definition);
|
|
1618
1616
|
return completeLogin(credential);
|
|
1619
1617
|
},
|
|
@@ -1713,12 +1711,13 @@ export function prepareLiteLLMRequestPayload(
|
|
|
1713
1711
|
for (const [key, value] of Object.entries(REASONING_VISIBILITY_DEFAULTS)) update(key, value);
|
|
1714
1712
|
}
|
|
1715
1713
|
|
|
1716
|
-
//
|
|
1717
|
-
// Drop
|
|
1714
|
+
// GPT-5.5 and later reject reasoning alongside function tools on Chat Completions.
|
|
1715
|
+
// Drop it until the gateway serves /v1/responses for the route. Discovery decides
|
|
1716
|
+
// from deployment evidence; only a model with no backend evidence uses its route name.
|
|
1717
|
+
const routeOnlyId = model?.litellmBackendFamily === undefined ? modelId : undefined;
|
|
1718
1718
|
if (
|
|
1719
1719
|
openAIApi === "openai-completions" &&
|
|
1720
|
-
|
|
1721
|
-
isGpt55Model(modelId) &&
|
|
1720
|
+
(modelPolicy?.dropToolReasoning === true || (routeOnlyId !== undefined && isGpt55OrNewerModel(routeOnlyId))) &&
|
|
1722
1721
|
Array.isArray(payload.tools) &&
|
|
1723
1722
|
payload.tools.length > 0
|
|
1724
1723
|
) {
|
|
@@ -1727,6 +1726,17 @@ export function prepareLiteLLMRequestPayload(
|
|
|
1727
1726
|
next ??= { ...payload };
|
|
1728
1727
|
delete next[key];
|
|
1729
1728
|
}
|
|
1729
|
+
// Send the model's own off effort when it declares one. GPT-6 also rejects an
|
|
1730
|
+
// omitted effort (it falls back to its default), and its error names `none` as
|
|
1731
|
+
// the fix, so it gets `none` even without carrier evidence for an off level.
|
|
1732
|
+
const declaredOff = model?.thinkingLevelMap?.off;
|
|
1733
|
+
const explicitOff =
|
|
1734
|
+
modelPolicy?.explicitToolReasoningOff === true || (routeOnlyId !== undefined && isGpt6OrNewerModel(routeOnlyId));
|
|
1735
|
+
const off = typeof declaredOff === "string" ? declaredOff : explicitOff ? "none" : undefined;
|
|
1736
|
+
if (off !== undefined && (next ?? payload).reasoning_effort !== off) {
|
|
1737
|
+
next ??= { ...payload };
|
|
1738
|
+
next.reasoning_effort = off;
|
|
1739
|
+
}
|
|
1730
1740
|
const include = (next ?? payload).include;
|
|
1731
1741
|
if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
|
|
1732
1742
|
next ??= { ...payload };
|
|
@@ -1858,11 +1868,19 @@ function normalizeThinkTags(
|
|
|
1858
1868
|
}
|
|
1859
1869
|
|
|
1860
1870
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
1871
|
+
// Pi installs packages without resolving peerDependencies, so only this check keeps an older Pi from
|
|
1872
|
+
// loading the extension. It runs before anything registers; Pi reports the error and starts without it.
|
|
1873
|
+
// Pi's version parses like the proxy's: a pre-release orders before the release it names.
|
|
1874
|
+
if (!proxyVersionAtLeast(parseProxyVersion(piCodingAgent.VERSION), [0, 99, 2])) {
|
|
1875
|
+
throw new Error(
|
|
1876
|
+
`pi-provider-litellm needs Pi 0.99.2 or newer; this is Pi ${piCodingAgent.VERSION}. ` +
|
|
1877
|
+
"Update Pi, or install the last release for older Pi: pi install npm:pi-provider-litellm@3.4.0",
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1861
1880
|
const settings = await readGlobalLiteLLMSettings();
|
|
1862
1881
|
const definitions = getProviderDefinitions(settings);
|
|
1863
1882
|
const skillsEnabled = isFeatureEnabled(settings, "skills");
|
|
1864
1883
|
const mcpEnabled = isFeatureEnabled(settings, "mcp");
|
|
1865
|
-
const mcpExposure = parseMcpExposurePolicy(settings?.mcp);
|
|
1866
1884
|
const providerNames = new Set(definitions.map((definition) => definition.name));
|
|
1867
1885
|
const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
|
|
1868
1886
|
let mcpUI: ExtensionContext["ui"] | undefined;
|
|
@@ -1888,7 +1906,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1888
1906
|
sessionStarted = false;
|
|
1889
1907
|
pendingMcpMessages.clear();
|
|
1890
1908
|
});
|
|
1891
|
-
for (const warning of mcpExposure.warnings) notifyMcp(`LiteLLM MCP: ${warning}`);
|
|
1892
1909
|
|
|
1893
1910
|
function discoveryDisabledReason(): string | null {
|
|
1894
1911
|
if (isOffline()) return `${ENV_OFFLINE}=1`;
|
|
@@ -1896,6 +1913,62 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1896
1913
|
return null;
|
|
1897
1914
|
}
|
|
1898
1915
|
|
|
1916
|
+
// LiteLLM serves MCP over streamable HTTP at `/mcp`, so each provider registers it as a Pi MCP
|
|
1917
|
+
// server and Pi's client does the rest. `auth.provider` makes Pi send the provider's current
|
|
1918
|
+
// credential on every request, so token refreshes and key helpers apply without registering
|
|
1919
|
+
// again; only a new proxy root or new headers change the registration.
|
|
1920
|
+
const mcpSettings = isPlainObject(settings?.mcp) ? settings.mcp : undefined;
|
|
1921
|
+
// The last config attempted for each provider, and whether Pi accepted it. Headers can carry
|
|
1922
|
+
// credentials, so the config is remembered as a keyed digest.
|
|
1923
|
+
const mcpAttempts = new Map<string, { identity: string; registered: boolean }>();
|
|
1924
|
+
const mcpIdentitySalt = randomBytes(32);
|
|
1925
|
+
|
|
1926
|
+
function mcpServerConfig(definition: ProviderDefinition): McpServerConfig | undefined {
|
|
1927
|
+
if (discoveryDisabledReason() || isHostOffline()) return undefined;
|
|
1928
|
+
let root: string;
|
|
1929
|
+
try {
|
|
1930
|
+
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
1931
|
+
root = requireCredentialRoot(resolveCredentialRoot(definition, stored), definition.name);
|
|
1932
|
+
} catch {
|
|
1933
|
+
return undefined;
|
|
1934
|
+
}
|
|
1935
|
+
const headers = resolveHeaders(definition);
|
|
1936
|
+
// Pi validates exposure and toolExposure; an unusable value is reported when registering.
|
|
1937
|
+
return {
|
|
1938
|
+
url: `${root}/mcp`,
|
|
1939
|
+
auth: { provider: definition.name },
|
|
1940
|
+
...(headers ? { headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, piLiteral(v)])) } : {}),
|
|
1941
|
+
...(mcpSettings?.exposure !== undefined ? { exposure: mcpSettings.exposure } : {}),
|
|
1942
|
+
...(mcpSettings?.toolExposure !== undefined ? { toolExposure: mcpSettings.toolExposure } : {}),
|
|
1943
|
+
} as McpServerConfig;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function dropMcpServer(definition: ProviderDefinition): void {
|
|
1947
|
+
const attempt = mcpAttempts.get(definition.name);
|
|
1948
|
+
mcpAttempts.delete(definition.name);
|
|
1949
|
+
if (attempt?.registered) pi.unregisterMcpServer(definition.name);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function syncMcpServer(definition: ProviderDefinition): void {
|
|
1953
|
+
const config = mcpServerConfig(definition);
|
|
1954
|
+
const identity = config && createHmac("sha256", mcpIdentitySalt).update(JSON.stringify(config)).digest("hex");
|
|
1955
|
+
if (identity === mcpAttempts.get(definition.name)?.identity) return;
|
|
1956
|
+
// Close the old connection before opening one to a new root, and leave nothing behind when
|
|
1957
|
+
// Pi refuses the new config.
|
|
1958
|
+
dropMcpServer(definition);
|
|
1959
|
+
if (!config || !identity) return;
|
|
1960
|
+
try {
|
|
1961
|
+
pi.registerMcpServer(definition.name, config);
|
|
1962
|
+
mcpAttempts.set(definition.name, { identity, registered: true });
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
// Remembered so the refusal is reported once rather than on every turn.
|
|
1965
|
+
mcpAttempts.set(definition.name, { identity, registered: false });
|
|
1966
|
+
notifyMcp(
|
|
1967
|
+
`LiteLLM MCP (${JSON.stringify(definition.name)}): ${error instanceof Error ? error.message : String(error)}`,
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1899
1972
|
function requestBaseUrl(definition: ProviderDefinition): string {
|
|
1900
1973
|
try {
|
|
1901
1974
|
return `${resolveCredentialRoot(definition) ?? DEFAULT_LITELLM_BASE_URL}/v1`;
|
|
@@ -1928,127 +2001,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1928
2001
|
apiKey: resolved.auth.apiKey,
|
|
1929
2002
|
headers: resolved.auth.headers,
|
|
1930
2003
|
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1931
|
-
mcpPauseSource:
|
|
1932
|
-
resolved.source === GCLOUD_ADC_SOURCE
|
|
1933
|
-
? GCLOUD_ADC_SOURCE
|
|
1934
|
-
: resolved.source === ENV_API_KEY_HELPER
|
|
1935
|
-
? normalizeCommand(process.env[ENV_API_KEY_HELPER])
|
|
1936
|
-
: resolved.source?.startsWith("!")
|
|
1937
|
-
? resolved.source
|
|
1938
|
-
: undefined,
|
|
1939
2004
|
};
|
|
1940
2005
|
}
|
|
1941
2006
|
|
|
1942
|
-
// Each provider owns its own MCP catalog, keyed by provider name.
|
|
1943
|
-
const registeredMcpIdentities = new Map<string, string>();
|
|
1944
|
-
// Generated tool names each provider registered in its last successful pass; Pi cannot unregister,
|
|
1945
|
-
// so names that vanish from the catalog are withdrawn by re-registering them hidden.
|
|
1946
|
-
const registeredMcpToolNames = new Map<string, Set<string>>();
|
|
1947
|
-
let mcpSeeded = false;
|
|
1948
|
-
const mcpRegistrations = new Map<string, Promise<void>>();
|
|
1949
|
-
// Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
|
|
1950
|
-
// never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
|
|
1951
|
-
// retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
|
|
1952
|
-
// instance with its own state, which starts clean on its own.
|
|
1953
|
-
let mcpRegistrationFatal = false;
|
|
1954
2007
|
let defaultRuntimeAuth: LiteLLMRuntimeAuth | undefined;
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
let
|
|
1958
|
-
let mcpPausePersistent = true;
|
|
1959
|
-
// Keyed by provider: only the default provider has /login, and its login must not cancel an
|
|
1960
|
-
// alias's in-flight registration, which login does not retry.
|
|
1961
|
-
const mcpLoginGenerations = new Map<string, number>();
|
|
1962
|
-
const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
|
|
1963
|
-
|
|
1964
|
-
function getMcpPauseSalt(): Buffer {
|
|
1965
|
-
if (!mcpPauseSalt) {
|
|
1966
|
-
try {
|
|
1967
|
-
mkdirSync(mcpPauseDir, { recursive: true, mode: 0o700 });
|
|
1968
|
-
const saltPath = join(mcpPauseDir, "identity-key");
|
|
1969
|
-
if (!existsSync(saltPath)) {
|
|
1970
|
-
const temporary = join(mcpPauseDir, `.identity-key-${randomBytes(16).toString("hex")}`);
|
|
1971
|
-
try {
|
|
1972
|
-
writeFileSync(temporary, randomBytes(32), { mode: 0o600, flag: "wx" });
|
|
1973
|
-
try {
|
|
1974
|
-
// Publish a complete key without replacing one created by another Pi process.
|
|
1975
|
-
linkSync(temporary, saltPath);
|
|
1976
|
-
} catch (error) {
|
|
1977
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1978
|
-
}
|
|
1979
|
-
} finally {
|
|
1980
|
-
rmSync(temporary, { force: true });
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
const salt = readFileSync(saltPath);
|
|
1984
|
-
if (salt.length !== 32) throw new Error("Invalid MCP pause identity key");
|
|
1985
|
-
mcpPauseSalt = salt;
|
|
1986
|
-
} catch {
|
|
1987
|
-
mcpPauseSalt = randomBytes(32);
|
|
1988
|
-
mcpPausePersistent = false;
|
|
1989
|
-
notifyMcp("LiteLLM MCP: could not read or initialize persisted discovery pause state.");
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
return mcpPauseSalt;
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
function mcpSession(credential: Credential | OAuthCredentials | undefined): string | undefined {
|
|
1996
|
-
const session = (credential as { litellmMcpSession?: unknown } | undefined)?.litellmMcpSession;
|
|
1997
|
-
return typeof session === "string" ? session : undefined;
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
function mcpPausePath(auth: McpRuntimeAuth, credential: Credential): string {
|
|
2001
|
-
const salt = getMcpPauseSalt();
|
|
2002
|
-
const source =
|
|
2003
|
-
auth.mcpPauseSource ??
|
|
2004
|
-
(credential.type === "api_key" && credential.key?.startsWith("!") ? credential.key : undefined);
|
|
2005
|
-
const key = source ?? auth.apiKey;
|
|
2006
|
-
const session = mcpSession(credential) ?? credentialFingerprint(key, undefined, salt);
|
|
2007
|
-
const identity = credentialFingerprint(
|
|
2008
|
-
JSON.stringify([
|
|
2009
|
-
normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
|
|
2010
|
-
session,
|
|
2011
|
-
credential.type === "oauth" ? null : [source ? "source" : "key", key],
|
|
2012
|
-
]),
|
|
2013
|
-
auth.headers,
|
|
2014
|
-
salt,
|
|
2015
|
-
);
|
|
2016
|
-
return join(mcpPauseDir, `paused-${identity}`);
|
|
2017
|
-
}
|
|
2018
|
-
|
|
2019
|
-
function pauseMcpDiscovery(auth: McpRuntimeAuth, credential: Credential): void {
|
|
2020
|
-
const path = mcpPausePath(auth, credential);
|
|
2021
|
-
mcpPauseInMemory.add(path);
|
|
2022
|
-
try {
|
|
2023
|
-
if (!mcpPausePersistent) throw new Error("MCP pause persistence unavailable");
|
|
2024
|
-
writeFileSync(path, "", { mode: 0o600 });
|
|
2025
|
-
} catch {
|
|
2026
|
-
notifyMcp("LiteLLM MCP: could not persist the discovery pause across restarts.");
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
function resumeMcpDiscovery(): void {
|
|
2031
|
-
mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
|
|
2032
|
-
registeredMcpIdentities.delete(PROVIDER_NAME);
|
|
2033
|
-
// The new login ID selects a fresh scope; other processes may still use the old scopes.
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
function isMcpPaused(auth: McpRuntimeAuth, credential: Credential): boolean {
|
|
2037
|
-
if (!mcpPauseInMemory.size && !existsSync(mcpPauseDir)) return false;
|
|
2038
|
-
const path = mcpPausePath(auth, credential);
|
|
2039
|
-
return mcpPauseInMemory.has(path) || (mcpPausePersistent && existsSync(path));
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
// Identifies the catalog a set of credentials points at, so a credential change forces
|
|
2043
|
-
// re-registration. The base URL stays readable because it is not secret and is useful when
|
|
2044
|
-
// reasoning about a refresh; everything credential-bearing is reduced to a non-reversible
|
|
2045
|
-
// fingerprint so no key or header value is held in the identity string.
|
|
2046
|
-
function mcpCatalogIdentity(auth: LiteLLMRuntimeAuth, credential: Credential): string {
|
|
2047
|
-
return JSON.stringify({
|
|
2048
|
-
baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
|
|
2049
|
-
credential: credentialFingerprint(JSON.stringify([auth.apiKey, mcpSession(credential)]), auth.headers),
|
|
2050
|
-
});
|
|
2051
|
-
}
|
|
2008
|
+
// Bumped by /login litellm so a model refresh that started before the login cannot cache the old
|
|
2009
|
+
// credentials as the default auth.
|
|
2010
|
+
let loginGeneration = 0;
|
|
2052
2011
|
|
|
2053
2012
|
async function getRuntimeAuth(
|
|
2054
2013
|
ctx: ExtensionContext,
|
|
@@ -2091,203 +2050,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2091
2050
|
return defaultRuntimeAuth;
|
|
2092
2051
|
}
|
|
2093
2052
|
|
|
2094
|
-
function waitForMcpRegistration(registration: Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
2095
|
-
if (!signal) return registration.catch(() => undefined);
|
|
2096
|
-
signal.throwIfAborted();
|
|
2097
|
-
return new Promise((resolve, reject) => {
|
|
2098
|
-
const onAbort = () => {
|
|
2099
|
-
signal.removeEventListener("abort", onAbort);
|
|
2100
|
-
reject(signal.reason);
|
|
2101
|
-
};
|
|
2102
|
-
const onComplete = () => {
|
|
2103
|
-
signal.removeEventListener("abort", onAbort);
|
|
2104
|
-
resolve();
|
|
2105
|
-
};
|
|
2106
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2107
|
-
if (signal.aborted) onAbort();
|
|
2108
|
-
else registration.then(onComplete, onComplete);
|
|
2109
|
-
});
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
// Pi before 0.99 reports no `exposure` and declares every registered tool.
|
|
2113
|
-
function piHonorsToolExposure(): boolean {
|
|
2114
|
-
return pi.getAllTools().some((tool) => "exposure" in tool);
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
const WITHDRAWN_MCP_MESSAGE = "This LiteLLM MCP tool is no longer offered by the proxy.";
|
|
2118
|
-
function withdrawMcpTools(names: ReadonlySet<string>): void {
|
|
2119
|
-
for (const name of names) {
|
|
2120
|
-
// Older Pi ignores `exposure`, so the stub stays callable and must fail without calling the proxy.
|
|
2121
|
-
pi.registerTool({
|
|
2122
|
-
name,
|
|
2123
|
-
label: "Withdrawn LiteLLM MCP tool",
|
|
2124
|
-
description: WITHDRAWN_MCP_MESSAGE,
|
|
2125
|
-
parameters: Type.Object({}),
|
|
2126
|
-
exposure: "hidden",
|
|
2127
|
-
async execute() {
|
|
2128
|
-
throw new Error(WITHDRAWN_MCP_MESSAGE);
|
|
2129
|
-
},
|
|
2130
|
-
});
|
|
2131
|
-
}
|
|
2132
|
-
if (piHonorsToolExposure()) pi.setActiveTools(pi.getActiveTools().filter((name) => !names.has(name)));
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
let warnedMcpExposureIgnored = false;
|
|
2136
|
-
let warnedMcpUnreachable = false;
|
|
2137
|
-
// As in Pi's own MCP extension: tools that are not declared to the model are reached through
|
|
2138
|
-
// codemode (`codemode`, `codemode-deferred`) or tool_search (`deferred`). Activate the one the
|
|
2139
|
-
// configured exposure needs, and say so once when neither is available.
|
|
2140
|
-
function activateMcpDiscoveryTools(exposures: readonly McpExposure[]): void {
|
|
2141
|
-
const needsCodemode = exposures.includes("codemode") || exposures.includes("codemode-deferred");
|
|
2142
|
-
const needsToolSearch = exposures.includes("deferred");
|
|
2143
|
-
if (!needsCodemode && !needsToolSearch) return;
|
|
2144
|
-
const tools = pi.getAllTools();
|
|
2145
|
-
// Without exposure support every registered tool is declared, so nothing needs activating.
|
|
2146
|
-
if (!piHonorsToolExposure()) {
|
|
2147
|
-
if (warnedMcpExposureIgnored) return;
|
|
2148
|
-
warnedMcpExposureIgnored = true;
|
|
2149
|
-
notifyMcp(
|
|
2150
|
-
"LiteLLM MCP: this Pi version ignores litellm.mcp.exposure; MCP tools are declared directly to the model.",
|
|
2151
|
-
);
|
|
2152
|
-
return;
|
|
2153
|
-
}
|
|
2154
|
-
const hasCodemode = tools.some((tool) => tool.name === "codemode");
|
|
2155
|
-
const hasToolSearch = tools.some((tool) => tool.name === "tool_search");
|
|
2156
|
-
const active = pi.getActiveTools();
|
|
2157
|
-
const activate = [
|
|
2158
|
-
...(needsCodemode && hasCodemode && !active.includes("codemode") ? ["codemode"] : []),
|
|
2159
|
-
...(needsToolSearch && hasToolSearch && !active.includes("tool_search") ? ["tool_search"] : []),
|
|
2160
|
-
];
|
|
2161
|
-
if (activate.length > 0) pi.setActiveTools([...active, ...activate]);
|
|
2162
|
-
const reachable = [...active, ...activate];
|
|
2163
|
-
if ((hasCodemode && reachable.includes("codemode")) || (hasToolSearch && reachable.includes("tool_search"))) return;
|
|
2164
|
-
if (warnedMcpUnreachable) return;
|
|
2165
|
-
warnedMcpUnreachable = true;
|
|
2166
|
-
notifyMcp(
|
|
2167
|
-
"LiteLLM MCP: tools with codemode or deferred exposure are reachable only through Pi's codemode or tool_search tool, and neither is available; they cannot be called.",
|
|
2168
|
-
);
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
async function registerMcpTools(
|
|
2172
|
-
definition: ProviderDefinition,
|
|
2173
|
-
auth: McpRuntimeAuth,
|
|
2174
|
-
credential: Credential,
|
|
2175
|
-
signal?: AbortSignal,
|
|
2176
|
-
): Promise<void> {
|
|
2177
|
-
if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
|
|
2178
|
-
const loginGeneration = mcpLoginGeneration(definition.name);
|
|
2179
|
-
const identity = mcpCatalogIdentity(auth, credential);
|
|
2180
|
-
const isDefault = definition.name === PROVIDER_NAME;
|
|
2181
|
-
// The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
|
|
2182
|
-
const namespace = isDefault ? undefined : definition.name;
|
|
2183
|
-
const notify = isDefault
|
|
2184
|
-
? notifyMcp
|
|
2185
|
-
: Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
|
|
2186
|
-
const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
|
|
2187
|
-
let pending = mcpRegistrations.get(definition.name);
|
|
2188
|
-
while (pending) {
|
|
2189
|
-
await waitForMcpRegistration(pending, signal);
|
|
2190
|
-
signal?.throwIfAborted();
|
|
2191
|
-
if (
|
|
2192
|
-
mcpRegistrationFatal ||
|
|
2193
|
-
isMcpPaused(auth, credential) ||
|
|
2194
|
-
loginGeneration !== mcpLoginGeneration(definition.name) ||
|
|
2195
|
-
registeredMcpIdentities.get(definition.name) === identity
|
|
2196
|
-
)
|
|
2197
|
-
return;
|
|
2198
|
-
pending = mcpRegistrations.get(definition.name);
|
|
2199
|
-
}
|
|
2200
|
-
if (registeredMcpIdentities.get(definition.name) === identity) return;
|
|
2201
|
-
|
|
2202
|
-
const registration = (async () => {
|
|
2203
|
-
try {
|
|
2204
|
-
signal?.throwIfAborted();
|
|
2205
|
-
const { definitions, report } = await createMcpToolDefinitions(
|
|
2206
|
-
(ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
|
|
2207
|
-
isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
|
|
2208
|
-
signal,
|
|
2209
|
-
notify,
|
|
2210
|
-
namespace,
|
|
2211
|
-
mcpExposure.policy,
|
|
2212
|
-
);
|
|
2213
|
-
signal?.throwIfAborted();
|
|
2214
|
-
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
2215
|
-
const registeredNames: string[] = [];
|
|
2216
|
-
try {
|
|
2217
|
-
for (const definition of definitions) {
|
|
2218
|
-
// Checked per tool so a cancelled refresh stops promptly instead of driving a full
|
|
2219
|
-
// registry rebuild for every remaining tool.
|
|
2220
|
-
signal?.throwIfAborted();
|
|
2221
|
-
pi.registerTool(definition);
|
|
2222
|
-
registeredNames.push(definition.name);
|
|
2223
|
-
}
|
|
2224
|
-
} catch (error) {
|
|
2225
|
-
if (signal?.aborted) throw signal.reason;
|
|
2226
|
-
// Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
|
|
2227
|
-
// then stop retrying so a stale instance cannot churn discovery on every later refresh.
|
|
2228
|
-
mcpRegistrationFatal = true;
|
|
2229
|
-
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
2230
|
-
return;
|
|
2231
|
-
}
|
|
2232
|
-
reportMcpRegistrationSuccess(notify);
|
|
2233
|
-
reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
|
|
2234
|
-
const previousNames = registeredMcpToolNames.get(definition.name) ?? new Set<string>();
|
|
2235
|
-
const currentNames = new Set(registeredNames);
|
|
2236
|
-
// A partial or empty catalog may omit live tools, so it withdraws nothing and keeps the old names.
|
|
2237
|
-
const settled = definitions.length > 0 && !report.partialFailure;
|
|
2238
|
-
const withdrawn = settled ? new Set([...previousNames].filter((n) => !currentNames.has(n))) : new Set<string>();
|
|
2239
|
-
try {
|
|
2240
|
-
if (withdrawn.size > 0) withdrawMcpTools(withdrawn);
|
|
2241
|
-
} catch (error) {
|
|
2242
|
-
if (signal?.aborted) throw signal.reason;
|
|
2243
|
-
mcpRegistrationFatal = true;
|
|
2244
|
-
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
2245
|
-
return;
|
|
2246
|
-
}
|
|
2247
|
-
if (definitions.length > 0) {
|
|
2248
|
-
if (!settled) for (const name of previousNames) currentNames.add(name);
|
|
2249
|
-
registeredMcpToolNames.set(definition.name, currentNames);
|
|
2250
|
-
}
|
|
2251
|
-
if (withdrawn.size > 0 && isVerboseDiscovery()) {
|
|
2252
|
-
notifyMcp(`${label}: withdrew ${withdrawn.size} MCP tools no longer offered by the proxy.`, "info");
|
|
2253
|
-
}
|
|
2254
|
-
activateMcpDiscoveryTools(report.exposures);
|
|
2255
|
-
if (isVerboseDiscovery()) {
|
|
2256
|
-
notifyMcp(
|
|
2257
|
-
`${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
|
|
2258
|
-
`(${report.discovered} raw, ${report.enveloped} enveloped).`,
|
|
2259
|
-
"info",
|
|
2260
|
-
);
|
|
2261
|
-
}
|
|
2262
|
-
// A catalog that produced nothing or came from a partial-failure response is not settled:
|
|
2263
|
-
// leaving the identity unset lets a later refresh retry discovery, which is network-only and
|
|
2264
|
-
// non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
|
|
2265
|
-
reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
|
|
2266
|
-
if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
|
|
2267
|
-
} catch (error) {
|
|
2268
|
-
if (signal?.aborted) throw signal.reason;
|
|
2269
|
-
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
2270
|
-
if (error instanceof McpAccessDeniedError) {
|
|
2271
|
-
pauseMcpDiscovery(auth, credential);
|
|
2272
|
-
// Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
|
|
2273
|
-
notifyMcp(
|
|
2274
|
-
`${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
|
|
2275
|
-
);
|
|
2276
|
-
return;
|
|
2277
|
-
}
|
|
2278
|
-
notifyMcp(
|
|
2279
|
-
`LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
|
|
2280
|
-
);
|
|
2281
|
-
}
|
|
2282
|
-
})();
|
|
2283
|
-
mcpRegistrations.set(definition.name, registration);
|
|
2284
|
-
try {
|
|
2285
|
-
await registration;
|
|
2286
|
-
} finally {
|
|
2287
|
-
if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
|
|
2288
|
-
}
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
2053
|
async function runDiscovery(auth: LiteLLMRuntimeAuth, signal?: AbortSignal) {
|
|
2292
2054
|
const result = await discoverModels(auth.baseUrl, auth.apiKey, {
|
|
2293
2055
|
timeoutMs: getDiscoveryTimeoutMs(),
|
|
@@ -2340,26 +2102,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2340
2102
|
const auth = createProviderAuth(
|
|
2341
2103
|
definition,
|
|
2342
2104
|
() => oauthRuntimeRoots.delete(definition.name),
|
|
2343
|
-
definition.name === PROVIDER_NAME
|
|
2105
|
+
definition.name === PROVIDER_NAME
|
|
2106
|
+
? {
|
|
2107
|
+
// Disconnect before the login can store a new credential: Pi's MCP client reads the
|
|
2108
|
+
// provider's current token on every request, including the session teardown, so a
|
|
2109
|
+
// connection still open to the old proxy root would send it the new credential.
|
|
2110
|
+
start: () => dropMcpServer(definition),
|
|
2111
|
+
complete: () => {
|
|
2112
|
+
loginGeneration++;
|
|
2113
|
+
},
|
|
2114
|
+
}
|
|
2115
|
+
: undefined,
|
|
2344
2116
|
() => oauthRuntimeRoots.get(definition.name),
|
|
2345
2117
|
);
|
|
2346
2118
|
if (auth.oauth) {
|
|
2347
|
-
if (definition.name === PROVIDER_NAME) {
|
|
2348
|
-
const refresh = auth.oauth.refresh;
|
|
2349
|
-
auth.oauth.refresh = async (credential, signal) => {
|
|
2350
|
-
// Resolve legacy credentials' stable scope before an in-flight denial or token rotation.
|
|
2351
|
-
const session =
|
|
2352
|
-
mcpSession(credential) ??
|
|
2353
|
-
(mcpEnabled ? credentialFingerprint(credential.access, undefined, getMcpPauseSalt()) : undefined);
|
|
2354
|
-
const refreshed = await refresh(credential, signal);
|
|
2355
|
-
return session &&
|
|
2356
|
-
(mcpSession(credential) ||
|
|
2357
|
-
refreshed.access !== credential.access ||
|
|
2358
|
-
refreshed.refresh !== credential.refresh)
|
|
2359
|
-
? { ...refreshed, litellmMcpSession: session }
|
|
2360
|
-
: refreshed;
|
|
2361
|
-
};
|
|
2362
|
-
}
|
|
2363
2119
|
const toAuth = auth.oauth.toAuth;
|
|
2364
2120
|
auth.oauth.toAuth = async (credential) => {
|
|
2365
2121
|
const resolved = await toAuth(credential);
|
|
@@ -2394,7 +2150,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2394
2150
|
Object.assign(provider, { headers: resolveHeaders(definition) });
|
|
2395
2151
|
const refreshModels = provider.refreshModels!;
|
|
2396
2152
|
provider.refreshModels = async (context) => {
|
|
2397
|
-
const
|
|
2153
|
+
const refreshGeneration = loginGeneration;
|
|
2398
2154
|
if (context.allowNetwork) {
|
|
2399
2155
|
networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
|
|
2400
2156
|
}
|
|
@@ -2402,19 +2158,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2402
2158
|
await refreshModels(context);
|
|
2403
2159
|
} finally {
|
|
2404
2160
|
if (
|
|
2161
|
+
definition.name === PROVIDER_NAME &&
|
|
2405
2162
|
context.allowNetwork &&
|
|
2406
2163
|
!discoveryDisabledReason() &&
|
|
2407
|
-
|
|
2164
|
+
refreshGeneration === loginGeneration &&
|
|
2408
2165
|
context.credential
|
|
2409
2166
|
) {
|
|
2410
|
-
// Best-effort: refreshing the cached default auth
|
|
2411
|
-
//
|
|
2167
|
+
// Best-effort: refreshing the cached default auth must not let a bad or placeholder
|
|
2168
|
+
// credential override refreshModels' own try/throw outcome via `finally`.
|
|
2412
2169
|
try {
|
|
2413
2170
|
const auth = await authForCredential(definition, context.credential);
|
|
2414
|
-
if (
|
|
2415
|
-
if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
|
|
2416
|
-
void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
|
|
2417
|
-
}
|
|
2171
|
+
if (refreshGeneration === loginGeneration) defaultRuntimeAuth = auth;
|
|
2418
2172
|
} catch {
|
|
2419
2173
|
// ignored — authForCredential already reported/will report this via the paths that use it directly.
|
|
2420
2174
|
}
|
|
@@ -2538,40 +2292,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2538
2292
|
return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
|
|
2539
2293
|
});
|
|
2540
2294
|
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
// that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
|
|
2549
|
-
// intact, still precedes any tool use, and leaves startup latency untouched.
|
|
2550
|
-
async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
|
|
2551
|
-
try {
|
|
2552
|
-
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
2553
|
-
// executeHelpers:false — seeding must never run the user's key helper as a side effect.
|
|
2554
|
-
const auth = await authForCredential(definition, stored, false);
|
|
2555
|
-
// An env-configured provider has no stored credential and must still seed. The credential
|
|
2556
|
-
// only derives the pause identity, so the resolved key reproduces the stored api_key scope.
|
|
2557
|
-
const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
|
|
2558
|
-
await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
|
|
2559
|
-
} catch (error) {
|
|
2560
|
-
if (isVerboseDiscovery()) {
|
|
2561
|
-
process.stderr.write(
|
|
2562
|
-
`LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
|
|
2563
|
-
);
|
|
2564
|
-
}
|
|
2565
|
-
}
|
|
2295
|
+
if (mcpEnabled) {
|
|
2296
|
+
// Servers registered while the extension loads connect when the session starts. Each turn
|
|
2297
|
+
// picks up a login that changed the proxy root, and a logout that removed it.
|
|
2298
|
+
for (const definition of definitions) syncMcpServer(definition);
|
|
2299
|
+
pi.on("before_agent_start", () => {
|
|
2300
|
+
for (const definition of definitions) syncMcpServer(definition);
|
|
2301
|
+
});
|
|
2566
2302
|
}
|
|
2567
2303
|
|
|
2568
|
-
pi.on("before_agent_start", async () => {
|
|
2569
|
-
if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
|
|
2570
|
-
mcpSeeded = true;
|
|
2571
|
-
// Catalogs are independent per provider, so every configured provider seeds in parallel.
|
|
2572
|
-
await Promise.all(definitions.map(seedMcpTools));
|
|
2573
|
-
});
|
|
2574
|
-
|
|
2575
2304
|
pi.on("message_end", (event, ctx) => {
|
|
2576
2305
|
if (event.message.role !== "assistant") return;
|
|
2577
2306
|
// Resolve the discovered model that produced this message so the display
|