pi-provider-litellm 3.4.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -61
- package/package.json +3 -3
- package/src/backend-identity.ts +11 -8
- package/src/index.ts +111 -395
- package/src/provider.ts +1 -1
- package/src/skills.ts +1 -2
- package/src/types.ts +0 -23
- 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
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
|
|
@@ -1530,12 +1525,12 @@ async function resolveApiKeyAuth(
|
|
|
1530
1525
|
function createProviderAuth(
|
|
1531
1526
|
definition: ProviderDefinition,
|
|
1532
1527
|
clearOAuthRuntimeRoot?: () => void,
|
|
1533
|
-
|
|
1528
|
+
loginHooks?: { start: () => void; complete: () => void },
|
|
1534
1529
|
oauthRuntimeRoot?: () => { apiKey: string; root: string } | undefined,
|
|
1535
1530
|
): ProviderAuth {
|
|
1536
1531
|
function completeLogin<T extends Credential>(credential: T): T {
|
|
1537
|
-
|
|
1538
|
-
return
|
|
1532
|
+
loginHooks?.complete();
|
|
1533
|
+
return credential;
|
|
1539
1534
|
}
|
|
1540
1535
|
return {
|
|
1541
1536
|
apiKey: {
|
|
@@ -1543,6 +1538,7 @@ function createProviderAuth(
|
|
|
1543
1538
|
login:
|
|
1544
1539
|
definition.name === PROVIDER_NAME
|
|
1545
1540
|
? async (interaction) => {
|
|
1541
|
+
loginHooks?.start();
|
|
1546
1542
|
const credential = await loginApiKey(interaction, definition);
|
|
1547
1543
|
return completeLogin(credential);
|
|
1548
1544
|
}
|
|
@@ -1615,6 +1611,7 @@ function createProviderAuth(
|
|
|
1615
1611
|
name: "LiteLLM SSO",
|
|
1616
1612
|
loginLabel: "Sign in with LiteLLM SSO",
|
|
1617
1613
|
login: async (interaction) => {
|
|
1614
|
+
loginHooks?.start();
|
|
1618
1615
|
const credential = await loginOAuth(interaction, definition);
|
|
1619
1616
|
return completeLogin(credential);
|
|
1620
1617
|
},
|
|
@@ -1871,11 +1868,19 @@ function normalizeThinkTags(
|
|
|
1871
1868
|
}
|
|
1872
1869
|
|
|
1873
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
|
+
}
|
|
1874
1880
|
const settings = await readGlobalLiteLLMSettings();
|
|
1875
1881
|
const definitions = getProviderDefinitions(settings);
|
|
1876
1882
|
const skillsEnabled = isFeatureEnabled(settings, "skills");
|
|
1877
1883
|
const mcpEnabled = isFeatureEnabled(settings, "mcp");
|
|
1878
|
-
const mcpExposure = parseMcpExposurePolicy(settings?.mcp);
|
|
1879
1884
|
const providerNames = new Set(definitions.map((definition) => definition.name));
|
|
1880
1885
|
const oauthRuntimeRoots = new Map<string, { apiKey: string; root: string }>();
|
|
1881
1886
|
let mcpUI: ExtensionContext["ui"] | undefined;
|
|
@@ -1901,7 +1906,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1901
1906
|
sessionStarted = false;
|
|
1902
1907
|
pendingMcpMessages.clear();
|
|
1903
1908
|
});
|
|
1904
|
-
for (const warning of mcpExposure.warnings) notifyMcp(`LiteLLM MCP: ${warning}`);
|
|
1905
1909
|
|
|
1906
1910
|
function discoveryDisabledReason(): string | null {
|
|
1907
1911
|
if (isOffline()) return `${ENV_OFFLINE}=1`;
|
|
@@ -1909,6 +1913,62 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1909
1913
|
return null;
|
|
1910
1914
|
}
|
|
1911
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
|
+
|
|
1912
1972
|
function requestBaseUrl(definition: ProviderDefinition): string {
|
|
1913
1973
|
try {
|
|
1914
1974
|
return `${resolveCredentialRoot(definition) ?? DEFAULT_LITELLM_BASE_URL}/v1`;
|
|
@@ -1941,127 +2001,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
1941
2001
|
apiKey: resolved.auth.apiKey,
|
|
1942
2002
|
headers: resolved.auth.headers,
|
|
1943
2003
|
allowInsecureHttp: definition.allowInsecureHttp,
|
|
1944
|
-
mcpPauseSource:
|
|
1945
|
-
resolved.source === GCLOUD_ADC_SOURCE
|
|
1946
|
-
? GCLOUD_ADC_SOURCE
|
|
1947
|
-
: resolved.source === ENV_API_KEY_HELPER
|
|
1948
|
-
? normalizeCommand(process.env[ENV_API_KEY_HELPER])
|
|
1949
|
-
: resolved.source?.startsWith("!")
|
|
1950
|
-
? resolved.source
|
|
1951
|
-
: undefined,
|
|
1952
2004
|
};
|
|
1953
2005
|
}
|
|
1954
2006
|
|
|
1955
|
-
// Each provider owns its own MCP catalog, keyed by provider name.
|
|
1956
|
-
const registeredMcpIdentities = new Map<string, string>();
|
|
1957
|
-
// Generated tool names each provider registered in its last successful pass; Pi cannot unregister,
|
|
1958
|
-
// so names that vanish from the catalog are withdrawn by re-registering them hidden.
|
|
1959
|
-
const registeredMcpToolNames = new Map<string, Set<string>>();
|
|
1960
|
-
let mcpSeeded = false;
|
|
1961
|
-
const mcpRegistrations = new Map<string, Promise<void>>();
|
|
1962
|
-
// Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
|
|
1963
|
-
// never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
|
|
1964
|
-
// retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
|
|
1965
|
-
// instance with its own state, which starts clean on its own.
|
|
1966
|
-
let mcpRegistrationFatal = false;
|
|
1967
2007
|
let defaultRuntimeAuth: LiteLLMRuntimeAuth | undefined;
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
let
|
|
1971
|
-
let mcpPausePersistent = true;
|
|
1972
|
-
// Keyed by provider: only the default provider has /login, and its login must not cancel an
|
|
1973
|
-
// alias's in-flight registration, which login does not retry.
|
|
1974
|
-
const mcpLoginGenerations = new Map<string, number>();
|
|
1975
|
-
const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
|
|
1976
|
-
|
|
1977
|
-
function getMcpPauseSalt(): Buffer {
|
|
1978
|
-
if (!mcpPauseSalt) {
|
|
1979
|
-
try {
|
|
1980
|
-
mkdirSync(mcpPauseDir, { recursive: true, mode: 0o700 });
|
|
1981
|
-
const saltPath = join(mcpPauseDir, "identity-key");
|
|
1982
|
-
if (!existsSync(saltPath)) {
|
|
1983
|
-
const temporary = join(mcpPauseDir, `.identity-key-${randomBytes(16).toString("hex")}`);
|
|
1984
|
-
try {
|
|
1985
|
-
writeFileSync(temporary, randomBytes(32), { mode: 0o600, flag: "wx" });
|
|
1986
|
-
try {
|
|
1987
|
-
// Publish a complete key without replacing one created by another Pi process.
|
|
1988
|
-
linkSync(temporary, saltPath);
|
|
1989
|
-
} catch (error) {
|
|
1990
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
1991
|
-
}
|
|
1992
|
-
} finally {
|
|
1993
|
-
rmSync(temporary, { force: true });
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
const salt = readFileSync(saltPath);
|
|
1997
|
-
if (salt.length !== 32) throw new Error("Invalid MCP pause identity key");
|
|
1998
|
-
mcpPauseSalt = salt;
|
|
1999
|
-
} catch {
|
|
2000
|
-
mcpPauseSalt = randomBytes(32);
|
|
2001
|
-
mcpPausePersistent = false;
|
|
2002
|
-
notifyMcp("LiteLLM MCP: could not read or initialize persisted discovery pause state.");
|
|
2003
|
-
}
|
|
2004
|
-
}
|
|
2005
|
-
return mcpPauseSalt;
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
function mcpSession(credential: Credential | OAuthCredentials | undefined): string | undefined {
|
|
2009
|
-
const session = (credential as { litellmMcpSession?: unknown } | undefined)?.litellmMcpSession;
|
|
2010
|
-
return typeof session === "string" ? session : undefined;
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
function mcpPausePath(auth: McpRuntimeAuth, credential: Credential): string {
|
|
2014
|
-
const salt = getMcpPauseSalt();
|
|
2015
|
-
const source =
|
|
2016
|
-
auth.mcpPauseSource ??
|
|
2017
|
-
(credential.type === "api_key" && credential.key?.startsWith("!") ? credential.key : undefined);
|
|
2018
|
-
const key = source ?? auth.apiKey;
|
|
2019
|
-
const session = mcpSession(credential) ?? credentialFingerprint(key, undefined, salt);
|
|
2020
|
-
const identity = credentialFingerprint(
|
|
2021
|
-
JSON.stringify([
|
|
2022
|
-
normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
|
|
2023
|
-
session,
|
|
2024
|
-
credential.type === "oauth" ? null : [source ? "source" : "key", key],
|
|
2025
|
-
]),
|
|
2026
|
-
auth.headers,
|
|
2027
|
-
salt,
|
|
2028
|
-
);
|
|
2029
|
-
return join(mcpPauseDir, `paused-${identity}`);
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
function pauseMcpDiscovery(auth: McpRuntimeAuth, credential: Credential): void {
|
|
2033
|
-
const path = mcpPausePath(auth, credential);
|
|
2034
|
-
mcpPauseInMemory.add(path);
|
|
2035
|
-
try {
|
|
2036
|
-
if (!mcpPausePersistent) throw new Error("MCP pause persistence unavailable");
|
|
2037
|
-
writeFileSync(path, "", { mode: 0o600 });
|
|
2038
|
-
} catch {
|
|
2039
|
-
notifyMcp("LiteLLM MCP: could not persist the discovery pause across restarts.");
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
function resumeMcpDiscovery(): void {
|
|
2044
|
-
mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
|
|
2045
|
-
registeredMcpIdentities.delete(PROVIDER_NAME);
|
|
2046
|
-
// The new login ID selects a fresh scope; other processes may still use the old scopes.
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
function isMcpPaused(auth: McpRuntimeAuth, credential: Credential): boolean {
|
|
2050
|
-
if (!mcpPauseInMemory.size && !existsSync(mcpPauseDir)) return false;
|
|
2051
|
-
const path = mcpPausePath(auth, credential);
|
|
2052
|
-
return mcpPauseInMemory.has(path) || (mcpPausePersistent && existsSync(path));
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
// Identifies the catalog a set of credentials points at, so a credential change forces
|
|
2056
|
-
// re-registration. The base URL stays readable because it is not secret and is useful when
|
|
2057
|
-
// reasoning about a refresh; everything credential-bearing is reduced to a non-reversible
|
|
2058
|
-
// fingerprint so no key or header value is held in the identity string.
|
|
2059
|
-
function mcpCatalogIdentity(auth: LiteLLMRuntimeAuth, credential: Credential): string {
|
|
2060
|
-
return JSON.stringify({
|
|
2061
|
-
baseUrl: normalizeBaseUrl(auth.baseUrl, auth.allowInsecureHttp),
|
|
2062
|
-
credential: credentialFingerprint(JSON.stringify([auth.apiKey, mcpSession(credential)]), auth.headers),
|
|
2063
|
-
});
|
|
2064
|
-
}
|
|
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;
|
|
2065
2011
|
|
|
2066
2012
|
async function getRuntimeAuth(
|
|
2067
2013
|
ctx: ExtensionContext,
|
|
@@ -2104,203 +2050,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2104
2050
|
return defaultRuntimeAuth;
|
|
2105
2051
|
}
|
|
2106
2052
|
|
|
2107
|
-
function waitForMcpRegistration(registration: Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
2108
|
-
if (!signal) return registration.catch(() => undefined);
|
|
2109
|
-
signal.throwIfAborted();
|
|
2110
|
-
return new Promise((resolve, reject) => {
|
|
2111
|
-
const onAbort = () => {
|
|
2112
|
-
signal.removeEventListener("abort", onAbort);
|
|
2113
|
-
reject(signal.reason);
|
|
2114
|
-
};
|
|
2115
|
-
const onComplete = () => {
|
|
2116
|
-
signal.removeEventListener("abort", onAbort);
|
|
2117
|
-
resolve();
|
|
2118
|
-
};
|
|
2119
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2120
|
-
if (signal.aborted) onAbort();
|
|
2121
|
-
else registration.then(onComplete, onComplete);
|
|
2122
|
-
});
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
// Pi before 0.99 reports no `exposure` and declares every registered tool.
|
|
2126
|
-
function piHonorsToolExposure(): boolean {
|
|
2127
|
-
return pi.getAllTools().some((tool) => "exposure" in tool);
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
const WITHDRAWN_MCP_MESSAGE = "This LiteLLM MCP tool is no longer offered by the proxy.";
|
|
2131
|
-
function withdrawMcpTools(names: ReadonlySet<string>): void {
|
|
2132
|
-
for (const name of names) {
|
|
2133
|
-
// Older Pi ignores `exposure`, so the stub stays callable and must fail without calling the proxy.
|
|
2134
|
-
pi.registerTool({
|
|
2135
|
-
name,
|
|
2136
|
-
label: "Withdrawn LiteLLM MCP tool",
|
|
2137
|
-
description: WITHDRAWN_MCP_MESSAGE,
|
|
2138
|
-
parameters: Type.Object({}),
|
|
2139
|
-
exposure: "hidden",
|
|
2140
|
-
async execute() {
|
|
2141
|
-
throw new Error(WITHDRAWN_MCP_MESSAGE);
|
|
2142
|
-
},
|
|
2143
|
-
});
|
|
2144
|
-
}
|
|
2145
|
-
if (piHonorsToolExposure()) pi.setActiveTools(pi.getActiveTools().filter((name) => !names.has(name)));
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
let warnedMcpExposureIgnored = false;
|
|
2149
|
-
let warnedMcpUnreachable = false;
|
|
2150
|
-
// As in Pi's own MCP extension: tools that are not declared to the model are reached through
|
|
2151
|
-
// codemode (`codemode`, `codemode-deferred`) or tool_search (`deferred`). Activate the one the
|
|
2152
|
-
// configured exposure needs, and say so once when neither is available.
|
|
2153
|
-
function activateMcpDiscoveryTools(exposures: readonly McpExposure[]): void {
|
|
2154
|
-
const needsCodemode = exposures.includes("codemode") || exposures.includes("codemode-deferred");
|
|
2155
|
-
const needsToolSearch = exposures.includes("deferred");
|
|
2156
|
-
if (!needsCodemode && !needsToolSearch) return;
|
|
2157
|
-
const tools = pi.getAllTools();
|
|
2158
|
-
// Without exposure support every registered tool is declared, so nothing needs activating.
|
|
2159
|
-
if (!piHonorsToolExposure()) {
|
|
2160
|
-
if (warnedMcpExposureIgnored) return;
|
|
2161
|
-
warnedMcpExposureIgnored = true;
|
|
2162
|
-
notifyMcp(
|
|
2163
|
-
"LiteLLM MCP: this Pi version ignores litellm.mcp.exposure; MCP tools are declared directly to the model.",
|
|
2164
|
-
);
|
|
2165
|
-
return;
|
|
2166
|
-
}
|
|
2167
|
-
const hasCodemode = tools.some((tool) => tool.name === "codemode");
|
|
2168
|
-
const hasToolSearch = tools.some((tool) => tool.name === "tool_search");
|
|
2169
|
-
const active = pi.getActiveTools();
|
|
2170
|
-
const activate = [
|
|
2171
|
-
...(needsCodemode && hasCodemode && !active.includes("codemode") ? ["codemode"] : []),
|
|
2172
|
-
...(needsToolSearch && hasToolSearch && !active.includes("tool_search") ? ["tool_search"] : []),
|
|
2173
|
-
];
|
|
2174
|
-
if (activate.length > 0) pi.setActiveTools([...active, ...activate]);
|
|
2175
|
-
const reachable = [...active, ...activate];
|
|
2176
|
-
if ((hasCodemode && reachable.includes("codemode")) || (hasToolSearch && reachable.includes("tool_search"))) return;
|
|
2177
|
-
if (warnedMcpUnreachable) return;
|
|
2178
|
-
warnedMcpUnreachable = true;
|
|
2179
|
-
notifyMcp(
|
|
2180
|
-
"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.",
|
|
2181
|
-
);
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
async function registerMcpTools(
|
|
2185
|
-
definition: ProviderDefinition,
|
|
2186
|
-
auth: McpRuntimeAuth,
|
|
2187
|
-
credential: Credential,
|
|
2188
|
-
signal?: AbortSignal,
|
|
2189
|
-
): Promise<void> {
|
|
2190
|
-
if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
|
|
2191
|
-
const loginGeneration = mcpLoginGeneration(definition.name);
|
|
2192
|
-
const identity = mcpCatalogIdentity(auth, credential);
|
|
2193
|
-
const isDefault = definition.name === PROVIDER_NAME;
|
|
2194
|
-
// The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
|
|
2195
|
-
const namespace = isDefault ? undefined : definition.name;
|
|
2196
|
-
const notify = isDefault
|
|
2197
|
-
? notifyMcp
|
|
2198
|
-
: Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
|
|
2199
|
-
const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
|
|
2200
|
-
let pending = mcpRegistrations.get(definition.name);
|
|
2201
|
-
while (pending) {
|
|
2202
|
-
await waitForMcpRegistration(pending, signal);
|
|
2203
|
-
signal?.throwIfAborted();
|
|
2204
|
-
if (
|
|
2205
|
-
mcpRegistrationFatal ||
|
|
2206
|
-
isMcpPaused(auth, credential) ||
|
|
2207
|
-
loginGeneration !== mcpLoginGeneration(definition.name) ||
|
|
2208
|
-
registeredMcpIdentities.get(definition.name) === identity
|
|
2209
|
-
)
|
|
2210
|
-
return;
|
|
2211
|
-
pending = mcpRegistrations.get(definition.name);
|
|
2212
|
-
}
|
|
2213
|
-
if (registeredMcpIdentities.get(definition.name) === identity) return;
|
|
2214
|
-
|
|
2215
|
-
const registration = (async () => {
|
|
2216
|
-
try {
|
|
2217
|
-
signal?.throwIfAborted();
|
|
2218
|
-
const { definitions, report } = await createMcpToolDefinitions(
|
|
2219
|
-
(ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
|
|
2220
|
-
isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
|
|
2221
|
-
signal,
|
|
2222
|
-
notify,
|
|
2223
|
-
namespace,
|
|
2224
|
-
mcpExposure.policy,
|
|
2225
|
-
);
|
|
2226
|
-
signal?.throwIfAborted();
|
|
2227
|
-
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
2228
|
-
const registeredNames: string[] = [];
|
|
2229
|
-
try {
|
|
2230
|
-
for (const definition of definitions) {
|
|
2231
|
-
// Checked per tool so a cancelled refresh stops promptly instead of driving a full
|
|
2232
|
-
// registry rebuild for every remaining tool.
|
|
2233
|
-
signal?.throwIfAborted();
|
|
2234
|
-
pi.registerTool(definition);
|
|
2235
|
-
registeredNames.push(definition.name);
|
|
2236
|
-
}
|
|
2237
|
-
} catch (error) {
|
|
2238
|
-
if (signal?.aborted) throw signal.reason;
|
|
2239
|
-
// Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
|
|
2240
|
-
// then stop retrying so a stale instance cannot churn discovery on every later refresh.
|
|
2241
|
-
mcpRegistrationFatal = true;
|
|
2242
|
-
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
2243
|
-
return;
|
|
2244
|
-
}
|
|
2245
|
-
reportMcpRegistrationSuccess(notify);
|
|
2246
|
-
reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
|
|
2247
|
-
const previousNames = registeredMcpToolNames.get(definition.name) ?? new Set<string>();
|
|
2248
|
-
const currentNames = new Set(registeredNames);
|
|
2249
|
-
// A partial or empty catalog may omit live tools, so it withdraws nothing and keeps the old names.
|
|
2250
|
-
const settled = definitions.length > 0 && !report.partialFailure;
|
|
2251
|
-
const withdrawn = settled ? new Set([...previousNames].filter((n) => !currentNames.has(n))) : new Set<string>();
|
|
2252
|
-
try {
|
|
2253
|
-
if (withdrawn.size > 0) withdrawMcpTools(withdrawn);
|
|
2254
|
-
} catch (error) {
|
|
2255
|
-
if (signal?.aborted) throw signal.reason;
|
|
2256
|
-
mcpRegistrationFatal = true;
|
|
2257
|
-
reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
if (definitions.length > 0) {
|
|
2261
|
-
if (!settled) for (const name of previousNames) currentNames.add(name);
|
|
2262
|
-
registeredMcpToolNames.set(definition.name, currentNames);
|
|
2263
|
-
}
|
|
2264
|
-
if (withdrawn.size > 0 && isVerboseDiscovery()) {
|
|
2265
|
-
notifyMcp(`${label}: withdrew ${withdrawn.size} MCP tools no longer offered by the proxy.`, "info");
|
|
2266
|
-
}
|
|
2267
|
-
activateMcpDiscoveryTools(report.exposures);
|
|
2268
|
-
if (isVerboseDiscovery()) {
|
|
2269
|
-
notifyMcp(
|
|
2270
|
-
`${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
|
|
2271
|
-
`(${report.discovered} raw, ${report.enveloped} enveloped).`,
|
|
2272
|
-
"info",
|
|
2273
|
-
);
|
|
2274
|
-
}
|
|
2275
|
-
// A catalog that produced nothing or came from a partial-failure response is not settled:
|
|
2276
|
-
// leaving the identity unset lets a later refresh retry discovery, which is network-only and
|
|
2277
|
-
// non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
|
|
2278
|
-
reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
|
|
2279
|
-
if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
|
|
2280
|
-
} catch (error) {
|
|
2281
|
-
if (signal?.aborted) throw signal.reason;
|
|
2282
|
-
if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
|
|
2283
|
-
if (error instanceof McpAccessDeniedError) {
|
|
2284
|
-
pauseMcpDiscovery(auth, credential);
|
|
2285
|
-
// Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
|
|
2286
|
-
notifyMcp(
|
|
2287
|
-
`${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
|
|
2288
|
-
);
|
|
2289
|
-
return;
|
|
2290
|
-
}
|
|
2291
|
-
notifyMcp(
|
|
2292
|
-
`LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
|
|
2293
|
-
);
|
|
2294
|
-
}
|
|
2295
|
-
})();
|
|
2296
|
-
mcpRegistrations.set(definition.name, registration);
|
|
2297
|
-
try {
|
|
2298
|
-
await registration;
|
|
2299
|
-
} finally {
|
|
2300
|
-
if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
|
|
2301
|
-
}
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
2053
|
async function runDiscovery(auth: LiteLLMRuntimeAuth, signal?: AbortSignal) {
|
|
2305
2054
|
const result = await discoverModels(auth.baseUrl, auth.apiKey, {
|
|
2306
2055
|
timeoutMs: getDiscoveryTimeoutMs(),
|
|
@@ -2353,26 +2102,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2353
2102
|
const auth = createProviderAuth(
|
|
2354
2103
|
definition,
|
|
2355
2104
|
() => oauthRuntimeRoots.delete(definition.name),
|
|
2356
|
-
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,
|
|
2357
2116
|
() => oauthRuntimeRoots.get(definition.name),
|
|
2358
2117
|
);
|
|
2359
2118
|
if (auth.oauth) {
|
|
2360
|
-
if (definition.name === PROVIDER_NAME) {
|
|
2361
|
-
const refresh = auth.oauth.refresh;
|
|
2362
|
-
auth.oauth.refresh = async (credential, signal) => {
|
|
2363
|
-
// Resolve legacy credentials' stable scope before an in-flight denial or token rotation.
|
|
2364
|
-
const session =
|
|
2365
|
-
mcpSession(credential) ??
|
|
2366
|
-
(mcpEnabled ? credentialFingerprint(credential.access, undefined, getMcpPauseSalt()) : undefined);
|
|
2367
|
-
const refreshed = await refresh(credential, signal);
|
|
2368
|
-
return session &&
|
|
2369
|
-
(mcpSession(credential) ||
|
|
2370
|
-
refreshed.access !== credential.access ||
|
|
2371
|
-
refreshed.refresh !== credential.refresh)
|
|
2372
|
-
? { ...refreshed, litellmMcpSession: session }
|
|
2373
|
-
: refreshed;
|
|
2374
|
-
};
|
|
2375
|
-
}
|
|
2376
2119
|
const toAuth = auth.oauth.toAuth;
|
|
2377
2120
|
auth.oauth.toAuth = async (credential) => {
|
|
2378
2121
|
const resolved = await toAuth(credential);
|
|
@@ -2407,7 +2150,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2407
2150
|
Object.assign(provider, { headers: resolveHeaders(definition) });
|
|
2408
2151
|
const refreshModels = provider.refreshModels!;
|
|
2409
2152
|
provider.refreshModels = async (context) => {
|
|
2410
|
-
const
|
|
2153
|
+
const refreshGeneration = loginGeneration;
|
|
2411
2154
|
if (context.allowNetwork) {
|
|
2412
2155
|
networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
|
|
2413
2156
|
}
|
|
@@ -2415,19 +2158,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2415
2158
|
await refreshModels(context);
|
|
2416
2159
|
} finally {
|
|
2417
2160
|
if (
|
|
2161
|
+
definition.name === PROVIDER_NAME &&
|
|
2418
2162
|
context.allowNetwork &&
|
|
2419
2163
|
!discoveryDisabledReason() &&
|
|
2420
|
-
|
|
2164
|
+
refreshGeneration === loginGeneration &&
|
|
2421
2165
|
context.credential
|
|
2422
2166
|
) {
|
|
2423
|
-
// Best-effort: refreshing the cached default auth
|
|
2424
|
-
//
|
|
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`.
|
|
2425
2169
|
try {
|
|
2426
2170
|
const auth = await authForCredential(definition, context.credential);
|
|
2427
|
-
if (
|
|
2428
|
-
if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
|
|
2429
|
-
void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
|
|
2430
|
-
}
|
|
2171
|
+
if (refreshGeneration === loginGeneration) defaultRuntimeAuth = auth;
|
|
2431
2172
|
} catch {
|
|
2432
2173
|
// ignored — authForCredential already reported/will report this via the paths that use it directly.
|
|
2433
2174
|
}
|
|
@@ -2522,8 +2263,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2522
2263
|
warnedFallbackRoutes.add(key);
|
|
2523
2264
|
const route = JSON.stringify(model.id);
|
|
2524
2265
|
const message =
|
|
2525
|
-
`LiteLLM (${JSON.stringify(model.provider)}):
|
|
2526
|
-
`for ${route}'s own deployments. If its fallbacks cross model families, pin the protocol with ` +
|
|
2266
|
+
`LiteLLM (${JSON.stringify(model.provider)}): the request for ${route} was answered by a fallback, but protocol and model handling were chosen ` +
|
|
2267
|
+
`for ${route}'s own deployments, not the fallback's. If its fallbacks cross model families, pin the protocol with ` +
|
|
2527
2268
|
"`model_info.supported_endpoints`.";
|
|
2528
2269
|
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
2529
2270
|
else process.stderr.write(`${message}\n`);
|
|
@@ -2551,40 +2292,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
2551
2292
|
return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
|
|
2552
2293
|
});
|
|
2553
2294
|
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
// that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
|
|
2562
|
-
// intact, still precedes any tool use, and leaves startup latency untouched.
|
|
2563
|
-
async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
|
|
2564
|
-
try {
|
|
2565
|
-
const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
|
|
2566
|
-
// executeHelpers:false — seeding must never run the user's key helper as a side effect.
|
|
2567
|
-
const auth = await authForCredential(definition, stored, false);
|
|
2568
|
-
// An env-configured provider has no stored credential and must still seed. The credential
|
|
2569
|
-
// only derives the pause identity, so the resolved key reproduces the stored api_key scope.
|
|
2570
|
-
const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
|
|
2571
|
-
await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
|
|
2572
|
-
} catch (error) {
|
|
2573
|
-
if (isVerboseDiscovery()) {
|
|
2574
|
-
process.stderr.write(
|
|
2575
|
-
`LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
|
|
2576
|
-
);
|
|
2577
|
-
}
|
|
2578
|
-
}
|
|
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
|
+
});
|
|
2579
2302
|
}
|
|
2580
2303
|
|
|
2581
|
-
pi.on("before_agent_start", async () => {
|
|
2582
|
-
if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
|
|
2583
|
-
mcpSeeded = true;
|
|
2584
|
-
// Catalogs are independent per provider, so every configured provider seeds in parallel.
|
|
2585
|
-
await Promise.all(definitions.map(seedMcpTools));
|
|
2586
|
-
});
|
|
2587
|
-
|
|
2588
2304
|
pi.on("message_end", (event, ctx) => {
|
|
2589
2305
|
if (event.message.role !== "assistant") return;
|
|
2590
2306
|
// Resolve the discovered model that produced this message so the display
|
package/src/provider.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type LiteLLMProviderOptions = {
|
|
|
29
29
|
discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & { baseUrl?: string }>;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
// Chat models may omit `type
|
|
32
|
+
// Chat models may omit `type`.
|
|
33
33
|
function isChatModel(model: AnyModel): model is Model<Api> {
|
|
34
34
|
return (model.type ?? "chat") === "chat";
|
|
35
35
|
}
|
package/src/skills.ts
CHANGED
|
@@ -169,8 +169,7 @@ function parseJsonObject(value: string, fieldName: string): Record<string, unkno
|
|
|
169
169
|
return parsed as Record<string, unknown>;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
// Pi
|
|
173
|
-
// Pi ignores both fields.
|
|
172
|
+
// Pi groups tools by namespace and lets permission extensions read annotation hints.
|
|
174
173
|
const SKILLS_NAMESPACE = { name: "litellm_skills", description: "LiteLLM Skills Gateway" };
|
|
175
174
|
|
|
176
175
|
export function createSkillToolDefinitions(
|