pi-provider-litellm 3.0.1 → 3.2.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
@@ -31,7 +31,14 @@ import {
31
31
  } from "./mcp-tools.js";
32
32
  import { createLiteLLMProvider, DEFAULT_LITELLM_BASE_URL, isPlaceholderHost, toNativeModels } from "./provider.js";
33
33
  import { createSkillsPromptSection, createSkillToolDefinitions, listSkills } from "./skills.js";
34
- import type { LiteLLMApi, LiteLLMModel, LiteLLMModelPolicy, LiteLLMRuntimeAuth, ResolvedCredentials } from "./types.js";
34
+ import type {
35
+ DiscoveryOptions,
36
+ LiteLLMApi,
37
+ LiteLLMModel,
38
+ LiteLLMModelPolicy,
39
+ LiteLLMRuntimeAuth,
40
+ ResolvedCredentials,
41
+ } from "./types.js";
35
42
 
36
43
  const PROVIDER_NAME = "litellm";
37
44
  const SETTINGS_KEY = "litellm";
@@ -44,6 +51,7 @@ const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
44
51
  const ENV_CLI_JWT_EXPIRATION_HOURS = "LITELLM_CLI_JWT_EXPIRATION_HOURS";
45
52
  const ENV_OFFLINE = "LITELLM_OFFLINE";
46
53
  const ENV_VERBOSE_DISCOVERY = "LITELLM_VERBOSE_DISCOVERY";
54
+ const ENV_MODELS_DEV = "LITELLM_MODELS_DEV";
47
55
  const MODELS_DEV_CACHE_FILENAME = "litellm-models-dev.json";
48
56
  const DEFAULT_TIMEOUT_MS = 5000;
49
57
  const SEED_TIMEOUT_MS = 3000;
@@ -444,6 +452,16 @@ function isHostOffline(): boolean {
444
452
  return process.env.PI_OFFLINE !== undefined;
445
453
  }
446
454
 
455
+ /**
456
+ * LiteLLM's /model/info is the metadata authority, so models.dev is an opt-in escape hatch.
457
+ * Without a cache path and with refresh suppressed, discovery reads neither the models.dev
458
+ * network nor a cache left by an earlier release; Pi's own catalog still applies.
459
+ */
460
+ function modelsDevDiscoveryOptions(): Pick<DiscoveryOptions, "modelsDev" | "modelsDevCachePath"> {
461
+ if (process.env[ENV_MODELS_DEV] !== "1") return { modelsDev: false };
462
+ return { modelsDev: !isHostOffline(), modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME) };
463
+ }
464
+
447
465
  /**
448
466
  * Absolute budget for activation-time discovery. Pi cannot paint its UI until every extension has
449
467
  * activated, so this is deliberately capped rather than following LITELLM_DISCOVERY_TIMEOUT_MS,
@@ -1625,8 +1643,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1625
1643
  };
1626
1644
  }
1627
1645
 
1628
- let registeredMcpIdentity: string | undefined;
1629
- let mcpRegistration: Promise<void> | undefined;
1646
+ // Each provider owns its own MCP catalog, keyed by provider name.
1647
+ const registeredMcpIdentities = new Map<string, string>();
1648
+ let mcpSeeded = false;
1649
+ const mcpRegistrations = new Map<string, Promise<void>>();
1630
1650
  // Pi's registerTool throws only from assertActive(), whose staleness flag is set with `??=` and
1631
1651
  // never cleared, so a refusal is fatal for this extension instance rather than a per-tool or
1632
1652
  // retryable condition. Once seen, stop attempting registration here; a reload creates a fresh
@@ -1637,7 +1657,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1637
1657
  const mcpPauseInMemory = new Set<string>();
1638
1658
  let mcpPauseSalt: Buffer | undefined;
1639
1659
  let mcpPausePersistent = true;
1640
- let mcpLoginGeneration = 0;
1660
+ // Keyed by provider: only the default provider has /login, and its login must not cancel an
1661
+ // alias's in-flight registration, which login does not retry.
1662
+ const mcpLoginGenerations = new Map<string, number>();
1663
+ const mcpLoginGeneration = (name: string) => mcpLoginGenerations.get(name) ?? 0;
1641
1664
 
1642
1665
  function getMcpPauseSalt(): Buffer {
1643
1666
  if (!mcpPauseSalt) {
@@ -1706,8 +1729,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1706
1729
  }
1707
1730
 
1708
1731
  function resumeMcpDiscovery(): void {
1709
- mcpLoginGeneration += 1;
1710
- registeredMcpIdentity = undefined;
1732
+ mcpLoginGenerations.set(PROVIDER_NAME, mcpLoginGeneration(PROVIDER_NAME) + 1);
1733
+ registeredMcpIdentities.delete(PROVIDER_NAME);
1711
1734
  // The new login ID selects a fresh scope; other processes may still use the old scopes.
1712
1735
  }
1713
1736
 
@@ -1728,17 +1751,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1728
1751
  });
1729
1752
  }
1730
1753
 
1731
- async function getRuntimeAuth(ctx: ExtensionContext): Promise<LiteLLMRuntimeAuth | undefined> {
1732
- const auth = await ctx.modelRegistry.getProviderAuth(PROVIDER_NAME);
1754
+ async function getRuntimeAuth(
1755
+ ctx: ExtensionContext,
1756
+ definition = definitions[0]!,
1757
+ ): Promise<LiteLLMRuntimeAuth | undefined> {
1758
+ const auth = await ctx.modelRegistry.getProviderAuth(definition.name);
1733
1759
  if (!auth) return undefined;
1734
- const provider = ctx.modelRegistry.getProvider(PROVIDER_NAME);
1760
+ const provider = ctx.modelRegistry.getProvider(definition.name);
1735
1761
  const apiKey = auth.auth.apiKey;
1736
1762
  const baseUrl = cleanConfig(auth.auth.baseUrl) ?? cleanConfig(auth.env?.[ENV_BASE_URL]) ?? provider?.baseUrl;
1737
1763
  if (!baseUrl || !apiKey) return undefined;
1738
- const runtimeRoot = requireCredentialRoot(
1739
- normalizeBaseUrl(baseUrl, definitions[0]?.allowInsecureHttp),
1740
- PROVIDER_NAME,
1741
- );
1764
+ const runtimeRoot = requireCredentialRoot(normalizeBaseUrl(baseUrl, definition.allowInsecureHttp), definition.name);
1742
1765
  const headers = Object.fromEntries(
1743
1766
  Object.entries(auth.auth.headers ?? provider?.headers ?? {}).filter(
1744
1767
  (entry): entry is [string, string] => typeof entry[1] === "string",
@@ -1748,16 +1771,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1748
1771
  baseUrl: runtimeRoot,
1749
1772
  apiKey,
1750
1773
  headers: Object.keys(headers).length > 0 ? headers : undefined,
1751
- allowInsecureHttp: definitions[0]?.allowInsecureHttp,
1774
+ allowInsecureHttp: definition.allowInsecureHttp,
1752
1775
  };
1753
1776
  }
1754
1777
 
1778
+ function missingCredentials(definition: ProviderDefinition): string {
1779
+ const fix = definition.name === PROVIDER_NAME ? "Run /login litellm or set env vars" : "Set its baseUrl and apiKey";
1780
+ return `no credentials for ${definition.name}. ${fix}.`;
1781
+ }
1782
+
1783
+ async function requireRuntimeAuth(ctx: ExtensionContext, definition = definitions[0]!): Promise<LiteLLMRuntimeAuth> {
1784
+ const auth = await getRuntimeAuth(ctx, definition);
1785
+ if (auth) return auth;
1786
+ throw new Error(missingCredentials(definition));
1787
+ }
1788
+
1755
1789
  async function resolveDefaultRuntimeAuth(ctx?: ExtensionContext): Promise<LiteLLMRuntimeAuth> {
1756
- if (ctx?.modelRegistry) {
1757
- const auth = await getRuntimeAuth(ctx);
1758
- if (auth) return auth;
1759
- throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
1760
- }
1790
+ if (ctx?.modelRegistry) return requireRuntimeAuth(ctx);
1761
1791
  if (!defaultRuntimeAuth) throw new Error("no credentials for litellm. Run /login litellm or set env vars.");
1762
1792
  return defaultRuntimeAuth;
1763
1793
  }
@@ -1780,34 +1810,49 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1780
1810
  });
1781
1811
  }
1782
1812
 
1783
- async function registerMcpTools(auth: McpRuntimeAuth, credential: Credential, signal?: AbortSignal): Promise<void> {
1813
+ async function registerMcpTools(
1814
+ definition: ProviderDefinition,
1815
+ auth: McpRuntimeAuth,
1816
+ credential: Credential,
1817
+ signal?: AbortSignal,
1818
+ ): Promise<void> {
1784
1819
  if (!mcpEnabled || discoveryDisabledReason() || mcpRegistrationFatal || isMcpPaused(auth, credential)) return;
1785
- const loginGeneration = mcpLoginGeneration;
1820
+ const loginGeneration = mcpLoginGeneration(definition.name);
1786
1821
  const identity = mcpCatalogIdentity(auth, credential);
1787
- while (mcpRegistration) {
1788
- await waitForMcpRegistration(mcpRegistration, signal);
1822
+ const isDefault = definition.name === PROVIDER_NAME;
1823
+ // The default provider keeps its unprefixed tool names and diagnostics; an alias is scoped by name.
1824
+ const namespace = isDefault ? undefined : definition.name;
1825
+ const notify = isDefault
1826
+ ? notifyMcp
1827
+ : Object.assign((message: string) => notifyMcp(message), { incidentScope: definition.name });
1828
+ const label = isDefault ? "LiteLLM MCP" : `LiteLLM MCP (${JSON.stringify(definition.name)})`;
1829
+ let pending = mcpRegistrations.get(definition.name);
1830
+ while (pending) {
1831
+ await waitForMcpRegistration(pending, signal);
1789
1832
  signal?.throwIfAborted();
1790
1833
  if (
1791
1834
  mcpRegistrationFatal ||
1792
1835
  isMcpPaused(auth, credential) ||
1793
- loginGeneration !== mcpLoginGeneration ||
1794
- registeredMcpIdentity === identity
1836
+ loginGeneration !== mcpLoginGeneration(definition.name) ||
1837
+ registeredMcpIdentities.get(definition.name) === identity
1795
1838
  )
1796
1839
  return;
1840
+ pending = mcpRegistrations.get(definition.name);
1797
1841
  }
1798
- if (registeredMcpIdentity === identity) return;
1842
+ if (registeredMcpIdentities.get(definition.name) === identity) return;
1799
1843
 
1800
1844
  const registration = (async () => {
1801
1845
  try {
1802
1846
  signal?.throwIfAborted();
1803
1847
  const { definitions, report } = await createMcpToolDefinitions(
1804
- (ctx) => (ctx?.modelRegistry ? resolveDefaultRuntimeAuth(ctx) : Promise.resolve(auth)),
1805
- isVerboseDiscovery() ? (message) => notifyMcp(`LiteLLM MCP: ${message}`, "info") : undefined,
1848
+ (ctx) => (ctx?.modelRegistry ? requireRuntimeAuth(ctx, definition) : Promise.resolve(auth)),
1849
+ isVerboseDiscovery() ? (message) => notifyMcp(`${label}: ${message}`, "info") : undefined,
1806
1850
  signal,
1807
- notifyMcp,
1851
+ notify,
1852
+ namespace,
1808
1853
  );
1809
1854
  signal?.throwIfAborted();
1810
- if (loginGeneration !== mcpLoginGeneration) return;
1855
+ if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
1811
1856
  const registeredNames: string[] = [];
1812
1857
  try {
1813
1858
  for (const definition of definitions) {
@@ -1822,14 +1867,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1822
1867
  // Fatal for this instance: report once, with a bounded Pi-authored cause and no proxy text,
1823
1868
  // then stop retrying so a stale instance cannot churn discovery on every later refresh.
1824
1869
  mcpRegistrationFatal = true;
1825
- reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notifyMcp);
1870
+ reportMcpRegistrationFatal(registeredNames.length, definitions.length, error, notify);
1826
1871
  return;
1827
1872
  }
1828
- reportMcpRegistrationSuccess();
1829
- reportMcpPartialDiscovery(report.partialFailure, registeredNames, notifyMcp);
1873
+ reportMcpRegistrationSuccess(notify);
1874
+ reportMcpPartialDiscovery(report.partialFailure, registeredNames, notify);
1830
1875
  if (isVerboseDiscovery()) {
1831
1876
  notifyMcp(
1832
- `LiteLLM MCP: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
1877
+ `${label}: registered ${registeredNames.length} of ${definitions.length} prepared MCP tools ` +
1833
1878
  `(${report.discovered} raw, ${report.enveloped} enveloped).`,
1834
1879
  "info",
1835
1880
  );
@@ -1837,26 +1882,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1837
1882
  // A catalog that produced nothing or came from a partial-failure response is not settled:
1838
1883
  // leaving the identity unset lets a later refresh retry discovery, which is network-only and
1839
1884
  // non-blocking. Re-registering surviving tools is safe because Pi replaces tools by name.
1840
- reportMcpCatalogOutcome(report.discovered, definitions.length, notifyMcp);
1841
- if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentity = identity;
1885
+ reportMcpCatalogOutcome(report.discovered, definitions.length, notify);
1886
+ if (definitions.length > 0 && !report.partialFailure) registeredMcpIdentities.set(definition.name, identity);
1842
1887
  } catch (error) {
1843
1888
  if (signal?.aborted) throw signal.reason;
1844
- if (loginGeneration !== mcpLoginGeneration) return;
1889
+ if (loginGeneration !== mcpLoginGeneration(definition.name)) return;
1845
1890
  if (error instanceof McpAccessDeniedError) {
1846
1891
  pauseMcpDiscovery(auth, credential);
1847
- notifyMcp("LiteLLM MCP: access denied; discovery paused until /login litellm succeeds.");
1892
+ // Aliases have no /login; their pause scope is their URL, key, and headers, so a change lifts it.
1893
+ notifyMcp(
1894
+ `${label}: access denied; discovery paused until ${isDefault ? "/login litellm succeeds" : "its credentials change"}.`,
1895
+ );
1848
1896
  return;
1849
1897
  }
1850
1898
  notifyMcp(
1851
- `LiteLLM (${PROVIDER_NAME}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
1899
+ `LiteLLM (${definition.name}): MCP tool discovery failed (${error instanceof Error ? error.message : String(error)}).`,
1852
1900
  );
1853
1901
  }
1854
1902
  })();
1855
- mcpRegistration = registration;
1903
+ mcpRegistrations.set(definition.name, registration);
1856
1904
  try {
1857
1905
  await registration;
1858
1906
  } finally {
1859
- if (mcpRegistration === registration) mcpRegistration = undefined;
1907
+ if (mcpRegistrations.get(definition.name) === registration) mcpRegistrations.delete(definition.name);
1860
1908
  }
1861
1909
  }
1862
1910
 
@@ -1866,8 +1914,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1866
1914
  signal,
1867
1915
  headers: auth.headers,
1868
1916
  allowInsecureHttp: auth.allowInsecureHttp,
1869
- modelsDev: !isHostOffline(),
1870
- modelsDevCachePath: join(getAgentDir(), MODELS_DEV_CACHE_FILENAME),
1917
+ ...modelsDevDiscoveryOptions(),
1871
1918
  silent: !isVerboseDiscovery(),
1872
1919
  onProgress: isVerboseDiscovery() ? (message) => process.stderr.write(`LiteLLM: ${message}\n`) : undefined,
1873
1920
  });
@@ -1879,7 +1926,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1879
1926
  // network phase, so the provider would stay empty until the user opens /model. Discover here
1880
1927
  // instead, the way 1.x did, and hand the catalog over as the provider's baseline models.
1881
1928
  async function seedModels(definition: ProviderDefinition): Promise<Model<LiteLLMApi>[]> {
1882
- if (discoveryDisabledReason() || isHostOffline()) return [];
1929
+ const disabledReason = discoveryDisabledReason() ?? (isHostOffline() ? "PI_OFFLINE" : null);
1930
+ if (disabledReason) {
1931
+ if (isVerboseDiscovery()) {
1932
+ process.stderr.write(`LiteLLM (${definition.name}): startup discovery skipped (${disabledReason}).\n`);
1933
+ }
1934
+ return [];
1935
+ }
1883
1936
  try {
1884
1937
  const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
1885
1938
  // executeHelpers:false — activation must never run the user's key helper as a side effect,
@@ -1899,6 +1952,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1899
1952
  }
1900
1953
 
1901
1954
  const seeded = await Promise.all(definitions.map(seedModels));
1955
+ // Pi runs a provider's network phase only once a credential resolves, so /litellm-refresh reads an
1956
+ // unchanged count as "no credentials" rather than as a successful refresh.
1957
+ const networkRefreshAttempts = new Map<string, number>();
1902
1958
 
1903
1959
  for (const [index, definition] of definitions.entries()) {
1904
1960
  const auth = createProviderAuth(
@@ -1958,24 +2014,26 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1958
2014
  Object.assign(provider, { headers: resolveHeaders(definition) });
1959
2015
  const refreshModels = provider.refreshModels!;
1960
2016
  provider.refreshModels = async (context) => {
1961
- const loginGeneration = mcpLoginGeneration;
2017
+ const loginGeneration = mcpLoginGeneration(definition.name);
2018
+ if (context.allowNetwork) {
2019
+ networkRefreshAttempts.set(definition.name, (networkRefreshAttempts.get(definition.name) ?? 0) + 1);
2020
+ }
1962
2021
  try {
1963
2022
  await refreshModels(context);
1964
2023
  } finally {
1965
2024
  if (
1966
- definition.name === PROVIDER_NAME &&
1967
2025
  context.allowNetwork &&
1968
2026
  !discoveryDisabledReason() &&
1969
- loginGeneration === mcpLoginGeneration &&
2027
+ loginGeneration === mcpLoginGeneration(definition.name) &&
1970
2028
  context.credential
1971
2029
  ) {
1972
2030
  // Best-effort: refreshing the cached default auth / MCP catalog must not let a bad or
1973
2031
  // placeholder credential override refreshModels' own try/throw outcome via `finally`.
1974
2032
  try {
1975
2033
  const auth = await authForCredential(definition, context.credential);
1976
- if (loginGeneration === mcpLoginGeneration) {
1977
- defaultRuntimeAuth = auth;
1978
- void registerMcpTools(auth, context.credential, context.signal).catch(() => undefined);
2034
+ if (loginGeneration === mcpLoginGeneration(definition.name)) {
2035
+ if (definition.name === PROVIDER_NAME) defaultRuntimeAuth = auth;
2036
+ void registerMcpTools(definition, auth, context.credential, context.signal).catch(() => undefined);
1979
2037
  }
1980
2038
  } catch {
1981
2039
  // ignored — authForCredential already reported/will report this via the paths that use it directly.
@@ -1986,6 +2044,54 @@ export default async function (pi: ExtensionAPI): Promise<void> {
1986
2044
  pi.registerProvider(provider);
1987
2045
  }
1988
2046
 
2047
+ // Pi documents PI_OFFLINE as disabling model catalog refreshes, including the one /model starts, and a
2048
+ // provider cannot tell that refresh from an automatic one. This is the explicit refresh: it reaches only
2049
+ // the configured proxies, so LITELLM_OFFLINE=1 and a zero timeout still disable it and models.dev stays off.
2050
+ pi.registerCommand("litellm-refresh", {
2051
+ description: "Refresh LiteLLM model catalogs from the proxy, even with PI_OFFLINE set",
2052
+ getArgumentCompletions: (prefix) => {
2053
+ const matches = [...providerNames].filter((name) => name.startsWith(prefix));
2054
+ return matches.length > 0 ? matches.map((name) => ({ value: name, label: name })) : null;
2055
+ },
2056
+ handler: async (args, ctx) => {
2057
+ const notify = (message: string, level: "info" | "warning"): void => {
2058
+ if (ctx.hasUI) ctx.ui.notify(message, level);
2059
+ else process.stderr.write(`${message}\n`);
2060
+ };
2061
+ const disabledReason = discoveryDisabledReason();
2062
+ if (disabledReason) {
2063
+ notify(`LiteLLM: model refresh skipped (${disabledReason}).`, "warning");
2064
+ return;
2065
+ }
2066
+ const requested = args.trim();
2067
+ const selected = requested ? definitions.filter(({ name }) => name === requested) : definitions;
2068
+ if (selected.length === 0) {
2069
+ const configured = [...providerNames].map((name) => JSON.stringify(name)).join(", ");
2070
+ notify(`LiteLLM: unknown provider ${JSON.stringify(requested)}; configured: ${configured}.`, "warning");
2071
+ return;
2072
+ }
2073
+ const attempts = new Map(selected.map(({ name }) => [name, networkRefreshAttempts.get(name) ?? 0]));
2074
+ const result = await ctx.modelRegistry.refresh({
2075
+ providers: selected.map(({ name }) => name),
2076
+ allowNetwork: true,
2077
+ force: true,
2078
+ });
2079
+ const models = ctx.modelRegistry.getAll();
2080
+ for (const definition of selected) {
2081
+ const label = `LiteLLM (${JSON.stringify(definition.name)})`;
2082
+ const error = result.errors.get(definition.name);
2083
+ if (error) {
2084
+ notify(`${label}: model refresh failed (${error.message}).`, "warning");
2085
+ } else if ((networkRefreshAttempts.get(definition.name) ?? 0) === attempts.get(definition.name)) {
2086
+ notify(`${label}: model refresh skipped; ${missingCredentials(definition)}`, "warning");
2087
+ } else {
2088
+ const count = models.filter((model) => model.provider === definition.name).length;
2089
+ notify(`${label}: refreshed ${count} model${count === 1 ? "" : "s"}.`, "info");
2090
+ }
2091
+ }
2092
+ },
2093
+ });
2094
+
1989
2095
  setupLiteLLMCostTracking(pi, [...providerNames]);
1990
2096
 
1991
2097
  if (skillsEnabled) {
@@ -2009,6 +2115,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2009
2115
  );
2010
2116
  });
2011
2117
 
2118
+ // Router fallbacks are invisible to discovery, so protocol and model policy were chosen for the
2119
+ // requested route even when another group answered. Name the route only; header text is proxy-supplied.
2120
+ const warnedFallbackRoutes = new Set<string>();
2121
+ pi.on("after_provider_response", (event, ctx) => {
2122
+ const model = ctx.model;
2123
+ if (!model?.provider || !providerNames.has(model.provider)) return;
2124
+ if (event.status < 200 || event.status >= 300) return;
2125
+ const attemptedFallbacks = Number(event.headers["x-litellm-attempted-fallbacks"]);
2126
+ if (!Number.isSafeInteger(attemptedFallbacks) || attemptedFallbacks <= 0) return;
2127
+ const key = `${model.provider}\0${model.id}`;
2128
+ if (warnedFallbackRoutes.has(key)) return;
2129
+ warnedFallbackRoutes.add(key);
2130
+ const route = JSON.stringify(model.id);
2131
+ const message =
2132
+ `LiteLLM (${JSON.stringify(model.provider)}): a fallback served ${route}, but protocol and model handling were chosen ` +
2133
+ `for ${route}'s own deployments. If its fallbacks cross model families, pin the protocol with ` +
2134
+ "`model_info.supported_endpoints`.";
2135
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
2136
+ else process.stderr.write(`${message}\n`);
2137
+ });
2138
+
2012
2139
  // Skills enrichment is best-effort: an expired credential or an unreachable proxy must not
2013
2140
  // report an extension error on every turn. The failing model request states the real problem.
2014
2141
  pi.on("before_agent_start", async (event, ctx) => {
@@ -2031,6 +2158,40 @@ export default async function (pi: ExtensionAPI): Promise<void> {
2031
2158
  return { systemPrompt: `${event.systemPrompt}\n\n${section}` };
2032
2159
  });
2033
2160
 
2161
+ // MCP tools otherwise register only from refreshModels' `finally`, which needs
2162
+ // context.allowNetwork. Pi core sets that solely for the interactive TUI and the RPC
2163
+ // background refresh, so `-p` and `--list-models` register no MCP tool at all, even when
2164
+ // credentials resolve and the proxy is reachable — the defect #136 fixed for models.
2165
+ //
2166
+ // Seeding here rather than at activation is deliberate: registerMcpTools serialises callers
2167
+ // per provider, so an activation-time call would become an in-flight registration
2168
+ // that Pi's own refresh then waits on. Running once before the first turn keeps that ordering
2169
+ // intact, still precedes any tool use, and leaves startup latency untouched.
2170
+ async function seedMcpTools(definition: ProviderDefinition): Promise<void> {
2171
+ try {
2172
+ const stored = readStoredCredential(definition.name, join(getAgentDir(), "auth.json"));
2173
+ // executeHelpers:false — seeding must never run the user's key helper as a side effect.
2174
+ const auth = await authForCredential(definition, stored, false);
2175
+ // An env-configured provider has no stored credential and must still seed. The credential
2176
+ // only derives the pause identity, so the resolved key reproduces the stored api_key scope.
2177
+ const credential: Credential = stored ?? { type: "api_key", key: auth.apiKey };
2178
+ await registerMcpTools(definition, auth, credential, AbortSignal.timeout(getSeedTimeoutMs()));
2179
+ } catch (error) {
2180
+ if (isVerboseDiscovery()) {
2181
+ process.stderr.write(
2182
+ `LiteLLM (${definition.name}): MCP seeding skipped (${error instanceof Error ? error.message : String(error)}).\n`,
2183
+ );
2184
+ }
2185
+ }
2186
+ }
2187
+
2188
+ pi.on("before_agent_start", async () => {
2189
+ if (!mcpEnabled || mcpSeeded || discoveryDisabledReason() || isHostOffline()) return;
2190
+ mcpSeeded = true;
2191
+ // Catalogs are independent per provider, so every configured provider seeds in parallel.
2192
+ await Promise.all(definitions.map(seedMcpTools));
2193
+ });
2194
+
2034
2195
  pi.on("message_end", (event, ctx) => {
2035
2196
  if (event.message.role !== "assistant") return;
2036
2197
  // Resolve the discovered model that produced this message so the display
package/src/mcp-tools.ts CHANGED
@@ -27,7 +27,9 @@ const TRUNCATION_MARKER = "\n[truncated by pi-provider-litellm]";
27
27
  const DESCRIPTION_TRUNCATION_MARKER = "… [truncated]";
28
28
  const SHORT_TRUNCATION_MARKER = "…";
29
29
 
30
- type DiagnosticSink = (message: string) => void;
30
+ // `incidentScope` separates the suppression state of catalogs from different providers, so one
31
+ // provider's clean pass cannot clear, and its differing report cannot re-trigger, another's incident.
32
+ type DiagnosticSink = ((message: string) => void) & { readonly incidentScope?: string };
31
33
 
32
34
  interface RawLiteLLMMcpTool {
33
35
  name?: unknown;
@@ -130,13 +132,16 @@ function emitSafetyDiagnostic(
130
132
  process.stderr.write(line);
131
133
  },
132
134
  ): void {
133
- if (lastEmittedIncident.get(incident) === identity) return;
134
- lastEmittedIncident.set(incident, identity);
135
- onDiagnostic(`LiteLLM MCP: ${message}\n`);
135
+ const scope = onDiagnostic.incidentScope;
136
+ const key = scope === undefined ? incident : `${scope}\0${incident}`;
137
+ if (lastEmittedIncident.get(key) === identity) return;
138
+ lastEmittedIncident.set(key, identity);
139
+ onDiagnostic(`LiteLLM MCP${scope === undefined ? "" : ` (${JSON.stringify(scope)})`}: ${message}\n`);
136
140
  }
137
141
 
138
- function clearIncident(incident: string): void {
139
- lastEmittedIncident.delete(incident);
142
+ function clearIncident(incident: string, onDiagnostic?: DiagnosticSink): void {
143
+ const scope = onDiagnostic?.incidentScope;
144
+ lastEmittedIncident.delete(scope === undefined ? incident : `${scope}\0${incident}`);
140
145
  }
141
146
 
142
147
  function membershipIdentity(tools: readonly string[]): string {
@@ -184,8 +189,8 @@ export function reportMcpRegistrationFatal(
184
189
 
185
190
  // A successful pass clears any fatal incident left by an earlier extension instance, so the same
186
191
  // failure is reported if it later recurs.
187
- export function reportMcpRegistrationSuccess(): void {
188
- clearIncident("registration-fatal");
192
+ export function reportMcpRegistrationSuccess(onDiagnostic?: DiagnosticSink): void {
193
+ clearIncident("registration-fatal", onDiagnostic);
189
194
  }
190
195
 
191
196
  // `registered` is the generated names of the tools that survived, so a failure that changes
@@ -196,7 +201,7 @@ export function reportMcpPartialDiscovery(
196
201
  onDiagnostic?: DiagnosticSink,
197
202
  ): void {
198
203
  if (!partialFailure) {
199
- clearIncident("discovery-partial-failure");
204
+ clearIncident("discovery-partial-failure", onDiagnostic);
200
205
  return;
201
206
  }
202
207
  emitSafetyDiagnostic(
@@ -212,7 +217,7 @@ export function reportMcpPartialDiscovery(
212
217
  // an unchanged message.
213
218
  export function reportMcpCatalogOutcome(raw: number, registered: number, onDiagnostic?: DiagnosticSink): void {
214
219
  if (registered > 0) {
215
- clearIncident("empty-catalog");
220
+ clearIncident("empty-catalog", onDiagnostic);
216
221
  return;
217
222
  }
218
223
  emitSafetyDiagnostic(
@@ -281,7 +286,7 @@ async function readBoundedText(
281
286
  onDiagnostic?: DiagnosticSink,
282
287
  ): Promise<string> {
283
288
  if (!response.body) {
284
- clearIncident(`${surface}-body-cap`);
289
+ clearIncident(`${surface}-body-cap`, onDiagnostic);
285
290
  return "";
286
291
  }
287
292
  const reader = response.body.getReader();
@@ -312,7 +317,7 @@ async function readBoundedText(
312
317
  }
313
318
  // A response that stayed within the cap clears the incident, so a later breach on this surface
314
319
  // is reported again instead of being suppressed as an unchanged message.
315
- clearIncident(`${surface}-body-cap`);
320
+ clearIncident(`${surface}-body-cap`, onDiagnostic);
316
321
  return Buffer.concat(
317
322
  chunks.map((chunk) => Buffer.from(chunk)),
318
323
  size,
@@ -743,9 +748,13 @@ function toolIdentity(tool: LiteLLMMcpTool): string {
743
748
  // A conditional hash would make a survivor's name depend on which *other* tools happened to be in
744
749
  // the same catalog, so adding or removing a sibling would rename it — and because Pi cannot
745
750
  // unregister, the old name would linger and the model would see one tool twice.
746
- function buildPiToolName(tool: LiteLLMMcpTool): string {
747
- const hash = createHash("sha256").update(toolIdentity(tool)).digest("hex").slice(0, TOOL_NAME_HASH_LENGTH);
748
- const base = `mcp_${sanitizeName(tool.server_name)}_${sanitizeName(tool.name)}`;
751
+ // An alias provider's catalog is namespaced by the provider name, in both the prefix and the hash,
752
+ // so two providers exposing the same server never replace each other's tools.
753
+ function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
754
+ const identity = namespace === undefined ? toolIdentity(tool) : JSON.stringify([namespace, toolIdentity(tool)]);
755
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, TOOL_NAME_HASH_LENGTH);
756
+ const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
757
+ const base = `${prefix}_${sanitizeName(tool.server_name)}_${sanitizeName(tool.name)}`;
749
758
  return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
750
759
  }
751
760
 
@@ -1135,7 +1144,10 @@ function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason
1135
1144
  return { parameters: Type.Unsafe(inputSchema), syntheticArgsEnvelope: false };
1136
1145
  }
1137
1146
 
1138
- export function prepareTools(discovery: McpDiscovery): {
1147
+ export function prepareTools(
1148
+ discovery: McpDiscovery,
1149
+ namespace?: string,
1150
+ ): {
1139
1151
  prepared: PreparedTool[];
1140
1152
  report: McpPreparationReport;
1141
1153
  } {
@@ -1160,12 +1172,12 @@ export function prepareTools(discovery: McpDiscovery): {
1160
1172
  for (const tool of discovery.tools) {
1161
1173
  const built = buildParameters(tool);
1162
1174
  if (typeof built === "string") {
1163
- recordDrop(built, buildPiToolName(tool));
1175
+ recordDrop(built, buildPiToolName(tool, namespace));
1164
1176
  continue;
1165
1177
  }
1166
1178
  const identity = toolIdentity(tool);
1167
1179
  if (acceptedIdentities.has(identity)) {
1168
- recordDrop("duplicate-identity", buildPiToolName(tool));
1180
+ recordDrop("duplicate-identity", buildPiToolName(tool, namespace));
1169
1181
  continue;
1170
1182
  }
1171
1183
  acceptedIdentities.add(identity);
@@ -1178,12 +1190,12 @@ export function prepareTools(discovery: McpDiscovery): {
1178
1190
  }
1179
1191
 
1180
1192
  const candidates = accepted.slice(0, MAX_REGISTERED_TOOLS);
1181
- const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool));
1193
+ const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool, namespace));
1182
1194
  const finalNames = new Set<string>();
1183
1195
  const prepared: PreparedTool[] = [];
1184
1196
  for (const candidate of candidates) {
1185
1197
  // Membership-independent: the name depends only on this tool's identity.
1186
- const name = buildPiToolName(candidate.tool);
1198
+ const name = buildPiToolName(candidate.tool, namespace);
1187
1199
  if (finalNames.has(name)) {
1188
1200
  recordDrop("name-collision", name);
1189
1201
  continue;
@@ -1251,7 +1263,7 @@ function emitPreparationDiagnostics(report: McpPreparationReport, onDiagnostic?:
1251
1263
  // Clear classes that did not recur, so the same incident is reported again if it comes back
1252
1264
  // after a clean refresh instead of being suppressed as an unchanged identity.
1253
1265
  for (const reason of [...Object.keys(INCIDENT_REASON_TEXT), "tool-cap"]) {
1254
- if (!seen.has(reason)) clearIncident(reason);
1266
+ if (!seen.has(reason)) clearIncident(reason, onDiagnostic);
1255
1267
  }
1256
1268
  }
1257
1269
 
@@ -1260,6 +1272,7 @@ export async function createMcpToolDefinitions(
1260
1272
  onProgress?: (message: string) => void,
1261
1273
  signal?: AbortSignal,
1262
1274
  onDiagnostic?: DiagnosticSink,
1275
+ namespace?: string,
1263
1276
  ): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
1264
1277
  const discoveryAuth = await getAuth();
1265
1278
  const discovery = await discoverMcpTools(
@@ -1271,7 +1284,7 @@ export async function createMcpToolDefinitions(
1271
1284
  discoveryAuth.allowInsecureHttp,
1272
1285
  onDiagnostic,
1273
1286
  );
1274
- const { prepared, report } = prepareTools(discovery);
1287
+ const { prepared, report } = prepareTools(discovery, namespace);
1275
1288
  emitPreparationDiagnostics(report, onDiagnostic);
1276
1289
  const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
1277
1290
  const lostDetail = [