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/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 { Type } from "@earendil-works/pi-ai";
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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, isGpt55Model, normalizeBaseUrl } from "./discover.js";
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
- if (!result.ok) throw new Error(`${result.message}; run /login litellm again`);
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
- onLogin?: () => void,
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
- onLogin?.();
1537
- return onLogin ? { ...credential, litellmMcpSession: randomBytes(16).toString("hex") } : credential;
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
- // LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
1717
- // Drop reasoning until the gateway honors /v1/responses for this route.
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
- modelId &&
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
- const mcpPauseDir = join(getAgentDir(), "litellm-mcp-pauses");
1956
- const mcpPauseInMemory = new Set<string>();
1957
- let mcpPauseSalt: Buffer | undefined;
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 ? resumeMcpDiscovery : undefined,
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 loginGeneration = mcpLoginGeneration(definition.name);
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
- loginGeneration === mcpLoginGeneration(definition.name) &&
2164
+ refreshGeneration === loginGeneration &&
2408
2165
  context.credential
2409
2166
  ) {
2410
- // Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
2411
- // placeholder credential override refreshModels' own try/throw outcome via `finally`.
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 (loginGeneration === mcpLoginGeneration(definition.name)) {
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
- // MCP tools otherwise register only from refreshModels' `finally`, which needs
2542
- // context.allowNetwork. Pi core sets that solely for the interactive TUI and the RPC
2543
- // background refresh, so `-p` and `--list-models` register no MCP tool at all, even when
2544
- // credentials resolve and the proxy is reachable — the defect #136 fixed for models.
2545
- //
2546
- // Seeding here rather than at activation is deliberate: registerMcpTools serialises callers
2547
- // per provider, so an activation-time call would become an in-flight registration
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