pi-provider-litellm 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp-tools.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { createHash, createHmac, randomBytes } from "node:crypto";
2
- import type { Static, TSchema } from "@earendil-works/pi-ai";
2
+ import type { JsonValue, Static, TSchema } from "@earendil-works/pi-ai";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
- import { defineTool, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ defineTool,
6
+ type ExtensionContext,
7
+ type ToolDefinition,
8
+ type ToolExposure,
9
+ } from "@earendil-works/pi-coding-agent";
5
10
  import { normalizeBaseUrl } from "./discover.js";
6
- import type { LiteLLMMcpTool, LiteLLMRuntimeAuth } from "./types.js";
11
+ import type { LiteLLMMcpTool, LiteLLMRuntimeAuth, McpToolAnnotations } from "./types.js";
7
12
 
8
13
  const LIST_TIMEOUT_MS = 10_000;
9
14
  const CALL_TIMEOUT_MS = 30_000;
@@ -15,6 +20,8 @@ const MAX_DESCRIPTION_BYTES = 4 * 1024;
15
20
  const MAX_SCHEMA_BYTES = 64 * 1024;
16
21
  const MAX_SCHEMA_DEPTH = 16;
17
22
  const MAX_RESULT_BYTES = 64 * 1024;
23
+ // Pi 0.99 caps its own structured bash results at the same size.
24
+ const MAX_STRUCTURED_RESULT_BYTES = 1024 * 1024;
18
25
  const MAX_TOOL_NAME_LENGTH = 64;
19
26
  const TOOL_NAME_HASH_LENGTH = 10;
20
27
  const MAX_LABEL_BYTES = 256;
@@ -27,26 +34,119 @@ const TRUNCATION_MARKER = "\n[truncated by pi-provider-litellm]";
27
34
  const DESCRIPTION_TRUNCATION_MARKER = "… [truncated]";
28
35
  const SHORT_TRUNCATION_MARKER = "…";
29
36
 
30
- type DiagnosticSink = (message: string) => void;
37
+ // `incidentScope` separates the suppression state of catalogs from different providers, so one
38
+ // provider's clean pass cannot clear, and its differing report cannot re-trigger, another's incident.
39
+ type DiagnosticSink = ((message: string) => void) & { readonly incidentScope?: string };
31
40
 
32
41
  interface RawLiteLLMMcpTool {
33
42
  name?: unknown;
34
43
  description?: unknown;
35
44
  inputSchema?: unknown;
36
45
  input_schema?: unknown;
46
+ annotations?: unknown;
37
47
  server_id?: unknown;
38
48
  server_name?: unknown;
39
49
  mcp_info?: {
40
50
  server_id?: unknown;
41
51
  server_name?: unknown;
52
+ description?: unknown;
42
53
  };
43
54
  }
44
55
 
56
+ // The exposure values of a Pi `mcp.json` server entry, so `litellm.mcp.exposure` and
57
+ // `litellm.mcp.toolExposure` mean what Pi's own MCP configuration means.
58
+ export type McpExposure = "direct" | "codemode" | "codemode-deferred" | "deferred" | "hidden";
59
+ const MCP_EXPOSURES: readonly McpExposure[] = ["direct", "codemode", "codemode-deferred", "deferred", "hidden"];
60
+
61
+ export interface McpExposurePolicy {
62
+ // Exposure of every tool without a `toolExposure` match.
63
+ exposure: McpExposure;
64
+ // `[key, exposure]` in settings order. A key without `/` matches the tool name as the server offers
65
+ // it; a key with `/` matches `<server>/<tool>`. `*` matches any characters.
66
+ toolExposure: ReadonlyArray<readonly [string, McpExposure]>;
67
+ }
68
+
69
+ export const DEFAULT_MCP_EXPOSURE_POLICY: McpExposurePolicy = { exposure: "direct", toolExposure: [] };
70
+
71
+ function isMcpExposure(value: unknown): value is McpExposure {
72
+ return typeof value === "string" && (MCP_EXPOSURES as readonly string[]).includes(value);
73
+ }
74
+
75
+ // Reads `exposure` and `toolExposure` from the `litellm.mcp` settings block. Unusable values fall back
76
+ // to `direct`, today's behavior, and are reported in extension-owned text.
77
+ export function parseMcpExposurePolicy(raw: unknown): { policy: McpExposurePolicy; warnings: string[] } {
78
+ const settings = asRecord(raw);
79
+ const warnings: string[] = [];
80
+ const expected = MCP_EXPOSURES.map((value) => JSON.stringify(value)).join(", ");
81
+ let exposure: McpExposure = "direct";
82
+ if (settings?.exposure !== undefined) {
83
+ if (isMcpExposure(settings.exposure)) exposure = settings.exposure;
84
+ else warnings.push(`litellm.mcp.exposure must be one of ${expected}; using "direct".`);
85
+ }
86
+ const toolExposure: Array<readonly [string, McpExposure]> = [];
87
+ if (settings?.toolExposure !== undefined) {
88
+ const overrides = asRecord(settings.toolExposure);
89
+ if (!overrides)
90
+ warnings.push("litellm.mcp.toolExposure must be an object mapping tool name patterns to exposures; ignoring it.");
91
+ else
92
+ for (const [key, value] of Object.entries(overrides)) {
93
+ if (isMcpExposure(value)) toolExposure.push([key, value]);
94
+ else warnings.push(`litellm.mcp.toolExposure[${JSON.stringify(key)}] must be one of ${expected}; ignoring it.`);
95
+ }
96
+ }
97
+ return { policy: { exposure, toolExposure }, warnings };
98
+ }
99
+
100
+ // Glob match where `*` matches any characters; segments must appear in order without overlapping.
101
+ function wildcardMatches(pattern: string, subject: string): boolean {
102
+ const segments = pattern.split("*");
103
+ if (segments.length === 1) return pattern === subject;
104
+ const first = segments[0] ?? "";
105
+ const last = segments[segments.length - 1] ?? "";
106
+ if (!subject.startsWith(first) || !subject.endsWith(last)) return false;
107
+ const end = subject.length - last.length;
108
+ let position = first.length;
109
+ for (const segment of segments.slice(1, -1)) {
110
+ const index = subject.indexOf(segment, position);
111
+ if (index === -1 || index + segment.length > end) return false;
112
+ position = index + segment.length;
113
+ }
114
+ return position <= end;
115
+ }
116
+
117
+ // As in Pi's mcp.json: an exact key wins over patterns, and among patterns the first in settings
118
+ // order wins. A `<server>/<tool>` exact key is more specific than a bare tool name.
119
+ export function resolveMcpExposure(policy: McpExposurePolicy, serverName: string, toolName: string): McpExposure {
120
+ const qualified = `${serverName}/${toolName}`;
121
+ let qualifiedExact: McpExposure | undefined;
122
+ let bareExact: McpExposure | undefined;
123
+ let firstPattern: McpExposure | undefined;
124
+ for (const [key, exposure] of policy.toolExposure) {
125
+ const isQualified = key.includes("/");
126
+ const subject = isQualified ? qualified : toolName;
127
+ if (!key.includes("*")) {
128
+ if (key !== subject) continue;
129
+ if (isQualified) qualifiedExact ??= exposure;
130
+ else bareExact ??= exposure;
131
+ } else if (firstPattern === undefined && wildcardMatches(key, subject)) {
132
+ firstPattern = exposure;
133
+ }
134
+ }
135
+ return qualifiedExact ?? bareExact ?? firstPattern ?? policy.exposure;
136
+ }
137
+
138
+ // Pi's tool API has one `deferred`; the two deferred MCP exposures differ only in which discovery
139
+ // tool (codemode or tool_search) is activated to reach them.
140
+ export function toToolExposure(exposure: McpExposure): ToolExposure {
141
+ return exposure === "codemode-deferred" ? "deferred" : exposure;
142
+ }
143
+
45
144
  interface PreparedTool {
46
145
  tool: LiteLLMMcpTool;
47
146
  name: string;
48
147
  parameters: TSchema;
49
148
  syntheticArgsEnvelope: boolean;
149
+ exposure: McpExposure;
50
150
  }
51
151
 
52
152
  interface InvalidMcpTool {
@@ -102,6 +202,8 @@ export interface McpPreparationReport {
102
202
  // Non-printed identities paired with `dropped`, used to detect membership changes safely.
103
203
  dropMemberships: Array<{ reason: McpDropReason; identities: string[] }>;
104
204
  degraded: Array<{ reason: McpDegradeReason; tools: string[] }>;
205
+ // Distinct exposures of the prepared tools, so the caller can activate codemode or tool_search.
206
+ exposures: McpExposure[];
105
207
  }
106
208
 
107
209
  // Bounded discovery result: `raw` is what the proxy returned, so losses can be reconciled.
@@ -130,13 +232,16 @@ function emitSafetyDiagnostic(
130
232
  process.stderr.write(line);
131
233
  },
132
234
  ): void {
133
- if (lastEmittedIncident.get(incident) === identity) return;
134
- lastEmittedIncident.set(incident, identity);
135
- onDiagnostic(`LiteLLM MCP: ${message}\n`);
235
+ const scope = onDiagnostic.incidentScope;
236
+ const key = scope === undefined ? incident : `${scope}\0${incident}`;
237
+ if (lastEmittedIncident.get(key) === identity) return;
238
+ lastEmittedIncident.set(key, identity);
239
+ onDiagnostic(`LiteLLM MCP${scope === undefined ? "" : ` (${JSON.stringify(scope)})`}: ${message}\n`);
136
240
  }
137
241
 
138
- function clearIncident(incident: string): void {
139
- lastEmittedIncident.delete(incident);
242
+ function clearIncident(incident: string, onDiagnostic?: DiagnosticSink): void {
243
+ const scope = onDiagnostic?.incidentScope;
244
+ lastEmittedIncident.delete(scope === undefined ? incident : `${scope}\0${incident}`);
140
245
  }
141
246
 
142
247
  function membershipIdentity(tools: readonly string[]): string {
@@ -184,8 +289,8 @@ export function reportMcpRegistrationFatal(
184
289
 
185
290
  // A successful pass clears any fatal incident left by an earlier extension instance, so the same
186
291
  // failure is reported if it later recurs.
187
- export function reportMcpRegistrationSuccess(): void {
188
- clearIncident("registration-fatal");
292
+ export function reportMcpRegistrationSuccess(onDiagnostic?: DiagnosticSink): void {
293
+ clearIncident("registration-fatal", onDiagnostic);
189
294
  }
190
295
 
191
296
  // `registered` is the generated names of the tools that survived, so a failure that changes
@@ -196,7 +301,7 @@ export function reportMcpPartialDiscovery(
196
301
  onDiagnostic?: DiagnosticSink,
197
302
  ): void {
198
303
  if (!partialFailure) {
199
- clearIncident("discovery-partial-failure");
304
+ clearIncident("discovery-partial-failure", onDiagnostic);
200
305
  return;
201
306
  }
202
307
  emitSafetyDiagnostic(
@@ -212,7 +317,7 @@ export function reportMcpPartialDiscovery(
212
317
  // an unchanged message.
213
318
  export function reportMcpCatalogOutcome(raw: number, registered: number, onDiagnostic?: DiagnosticSink): void {
214
319
  if (registered > 0) {
215
- clearIncident("empty-catalog");
320
+ clearIncident("empty-catalog", onDiagnostic);
216
321
  return;
217
322
  }
218
323
  emitSafetyDiagnostic(
@@ -281,7 +386,7 @@ async function readBoundedText(
281
386
  onDiagnostic?: DiagnosticSink,
282
387
  ): Promise<string> {
283
388
  if (!response.body) {
284
- clearIncident(`${surface}-body-cap`);
389
+ clearIncident(`${surface}-body-cap`, onDiagnostic);
285
390
  return "";
286
391
  }
287
392
  const reader = response.body.getReader();
@@ -312,7 +417,7 @@ async function readBoundedText(
312
417
  }
313
418
  // A response that stayed within the cap clears the incident, so a later breach on this surface
314
419
  // is reported again instead of being suppressed as an unchanged message.
315
- clearIncident(`${surface}-body-cap`);
420
+ clearIncident(`${surface}-body-cap`, onDiagnostic);
316
421
  return Buffer.concat(
317
422
  chunks.map((chunk) => Buffer.from(chunk)),
318
423
  size,
@@ -455,6 +560,21 @@ function parseDiscoveryJson(text: string): unknown {
455
560
  return parseJson(text, "MCP discovery");
456
561
  }
457
562
 
563
+ const ANNOTATION_HINTS = ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const;
564
+
565
+ // Only the boolean hints, as Pi's own MCP client keeps them; other proxy-supplied annotation fields
566
+ // (such as `title`) never reach Pi.
567
+ function toolAnnotations(value: unknown): McpToolAnnotations | undefined {
568
+ const raw = asRecord(value);
569
+ if (!raw) return undefined;
570
+ const hints: McpToolAnnotations = {};
571
+ for (const hint of ANNOTATION_HINTS) {
572
+ const flag = raw[hint];
573
+ if (typeof flag === "boolean") hints[hint] = flag;
574
+ }
575
+ return Object.keys(hints).length > 0 ? hints : undefined;
576
+ }
577
+
458
578
  function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
459
579
  const raw = asRecord(value) as RawLiteLLMMcpTool | undefined;
460
580
  if (!raw) return undefined;
@@ -469,6 +589,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
469
589
  const hasSnakeInputSchema = Object.hasOwn(raw, "input_schema");
470
590
  const suppliedSchema = hasInputSchema ? raw.inputSchema : hasSnakeInputSchema ? raw.input_schema : undefined;
471
591
  const inputSchema = asRecord(suppliedSchema);
592
+ const annotations = toolAnnotations(raw.annotations);
593
+ const serverDescription = stringValue(raw.mcp_info?.description);
472
594
  return {
473
595
  name,
474
596
  server_name: serverName,
@@ -477,6 +599,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
477
599
  input_schema: inputSchema ?? {},
478
600
  // Present but not an object: malformed, as distinct from absent.
479
601
  ...(inputSchema === undefined && (hasInputSchema || hasSnakeInputSchema) ? { input_schema_malformed: true } : {}),
602
+ ...(annotations ? { annotations } : {}),
603
+ ...(serverDescription ? { server_description: serverDescription } : {}),
480
604
  };
481
605
  }
482
606
 
@@ -677,6 +801,34 @@ function mcpCallError(serverId: string, toolName: string, detail: string): Error
677
801
  );
678
802
  }
679
803
 
804
+ export interface McpToolOutcome {
805
+ text: string;
806
+ structuredContent: Record<string, unknown>;
807
+ isError: boolean;
808
+ }
809
+
810
+ // Same shape as Pi's own MCP `createMcpResultSchema()`, which codemode detects to render
811
+ // `CallToolResult`. Extension-owned: a proxy-supplied `outputSchema` is never forwarded.
812
+ const MCP_RESULT_SCHEMA = {
813
+ type: "object",
814
+ properties: {
815
+ content: { type: "array", items: { type: "object" } },
816
+ isError: { type: "boolean" },
817
+ _meta: { type: "object" },
818
+ },
819
+ required: ["content"],
820
+ } as unknown as TSchema;
821
+
822
+ function structuredResult(result: unknown, text: string, isError: boolean): Record<string, unknown> {
823
+ const record = asRecord(result);
824
+ if (record && Array.isArray(record.content)) {
825
+ const { _meta: _ignored, ...rest } = record;
826
+ const structured = isError ? { ...rest, isError: true } : rest;
827
+ if (Buffer.byteLength(JSON.stringify(structured)) <= MAX_STRUCTURED_RESULT_BYTES) return structured;
828
+ }
829
+ return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
830
+ }
831
+
680
832
  export async function executeMcpTool(
681
833
  baseUrl: string,
682
834
  apiKey: string,
@@ -687,7 +839,7 @@ export async function executeMcpTool(
687
839
  parentSignal?: AbortSignal,
688
840
  allowInsecureHttp = false,
689
841
  onDiagnostic?: DiagnosticSink,
690
- ): Promise<string> {
842
+ ): Promise<McpToolOutcome> {
691
843
  parentSignal?.throwIfAborted();
692
844
  const signal = boundedSignal(CALL_TIMEOUT_MS, parentSignal);
693
845
  let response: Response;
@@ -716,9 +868,11 @@ export async function executeMcpTool(
716
868
  if (resultRecord?.isError === true || bodyRecord?.isError === true) {
717
869
  const message =
718
870
  mcpErrorMessage(resultRecord?.content ?? bodyRecord?.content ?? result) ?? serializeResult(result);
719
- throw mcpCallError(serverId, toolName, message);
871
+ const text = mcpCallError(serverId, toolName, message).message;
872
+ return { text, structuredContent: structuredResult(result, text, true), isError: true };
720
873
  }
721
- return serializeResult(result);
874
+ const text = serializeResult(result);
875
+ return { text, structuredContent: structuredResult(result, text, false), isError: false };
722
876
  } catch (error) {
723
877
  if (parentSignal?.aborted) throw parentSignal.reason;
724
878
  throw error;
@@ -743,9 +897,23 @@ function toolIdentity(tool: LiteLLMMcpTool): string {
743
897
  // A conditional hash would make a survivor's name depend on which *other* tools happened to be in
744
898
  // the same catalog, so adding or removing a sibling would rename it — and because Pi cannot
745
899
  // 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)}`;
900
+ // An alias provider's catalog is namespaced by the provider name, in both the prefix and the hash,
901
+ // so two providers exposing the same server never replace each other's tools.
902
+ function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
903
+ const identity = namespace === undefined ? toolIdentity(tool) : JSON.stringify([namespace, toolIdentity(tool)]);
904
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, TOOL_NAME_HASH_LENGTH);
905
+ const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
906
+ const base = `${prefix}_${sanitizeName(tool.server_name)}_${sanitizeName(tool.name)}`;
907
+ return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
908
+ }
909
+
910
+ // A namespace groups one server's tools, so its name is a pure function of server identity too:
911
+ // `My-Server` and `My_Server` sanitize alike, and long names truncate alike, so a hash keeps them apart.
912
+ function buildNamespaceName(tool: LiteLLMMcpTool, namespace?: string): string {
913
+ const identity = JSON.stringify([namespace ?? null, tool.server_id ?? tool.server_name, tool.server_name]);
914
+ const hash = createHash("sha256").update(identity).digest("hex").slice(0, 6);
915
+ const prefix = namespace === undefined ? "mcp" : `mcp_${sanitizeName(namespace)}`;
916
+ const base = `${prefix}_${sanitizeName(tool.server_name)}`;
749
917
  return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
750
918
  }
751
919
 
@@ -1135,7 +1303,11 @@ function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason
1135
1303
  return { parameters: Type.Unsafe(inputSchema), syntheticArgsEnvelope: false };
1136
1304
  }
1137
1305
 
1138
- export function prepareTools(discovery: McpDiscovery): {
1306
+ export function prepareTools(
1307
+ discovery: McpDiscovery,
1308
+ namespace?: string,
1309
+ exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
1310
+ ): {
1139
1311
  prepared: PreparedTool[];
1140
1312
  report: McpPreparationReport;
1141
1313
  } {
@@ -1160,12 +1332,12 @@ export function prepareTools(discovery: McpDiscovery): {
1160
1332
  for (const tool of discovery.tools) {
1161
1333
  const built = buildParameters(tool);
1162
1334
  if (typeof built === "string") {
1163
- recordDrop(built, buildPiToolName(tool));
1335
+ recordDrop(built, buildPiToolName(tool, namespace));
1164
1336
  continue;
1165
1337
  }
1166
1338
  const identity = toolIdentity(tool);
1167
1339
  if (acceptedIdentities.has(identity)) {
1168
- recordDrop("duplicate-identity", buildPiToolName(tool));
1340
+ recordDrop("duplicate-identity", buildPiToolName(tool, namespace));
1169
1341
  continue;
1170
1342
  }
1171
1343
  acceptedIdentities.add(identity);
@@ -1173,17 +1345,18 @@ export function prepareTools(discovery: McpDiscovery): {
1173
1345
  tool,
1174
1346
  parameters: built.parameters,
1175
1347
  syntheticArgsEnvelope: built.syntheticArgsEnvelope,
1348
+ exposure: resolveMcpExposure(exposurePolicy, tool.server_name, tool.name),
1176
1349
  degraded: built.degraded,
1177
1350
  });
1178
1351
  }
1179
1352
 
1180
1353
  const candidates = accepted.slice(0, MAX_REGISTERED_TOOLS);
1181
- const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool));
1354
+ const overflowTools = accepted.slice(MAX_REGISTERED_TOOLS).map(({ tool }) => buildPiToolName(tool, namespace));
1182
1355
  const finalNames = new Set<string>();
1183
1356
  const prepared: PreparedTool[] = [];
1184
1357
  for (const candidate of candidates) {
1185
1358
  // Membership-independent: the name depends only on this tool's identity.
1186
- const name = buildPiToolName(candidate.tool);
1359
+ const name = buildPiToolName(candidate.tool, namespace);
1187
1360
  if (finalNames.has(name)) {
1188
1361
  recordDrop("name-collision", name);
1189
1362
  continue;
@@ -1210,6 +1383,7 @@ export function prepareTools(discovery: McpDiscovery): {
1210
1383
  dropped: [...drops.entries()].map(([reason, tools]) => ({ reason, tools })),
1211
1384
  dropMemberships: [...dropMemberships.entries()].map(([reason, identities]) => ({ reason, identities })),
1212
1385
  degraded: [...degrades.entries()].map(([reason, tools]) => ({ reason, tools })),
1386
+ exposures: [...new Set(prepared.map((tool) => tool.exposure))].sort(),
1213
1387
  };
1214
1388
  return { prepared, report };
1215
1389
  }
@@ -1251,7 +1425,7 @@ function emitPreparationDiagnostics(report: McpPreparationReport, onDiagnostic?:
1251
1425
  // Clear classes that did not recur, so the same incident is reported again if it comes back
1252
1426
  // after a clean refresh instead of being suppressed as an unchanged identity.
1253
1427
  for (const reason of [...Object.keys(INCIDENT_REASON_TEXT), "tool-cap"]) {
1254
- if (!seen.has(reason)) clearIncident(reason);
1428
+ if (!seen.has(reason)) clearIncident(reason, onDiagnostic);
1255
1429
  }
1256
1430
  }
1257
1431
 
@@ -1260,6 +1434,8 @@ export async function createMcpToolDefinitions(
1260
1434
  onProgress?: (message: string) => void,
1261
1435
  signal?: AbortSignal,
1262
1436
  onDiagnostic?: DiagnosticSink,
1437
+ namespace?: string,
1438
+ exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
1263
1439
  ): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
1264
1440
  const discoveryAuth = await getAuth();
1265
1441
  const discovery = await discoverMcpTools(
@@ -1271,7 +1447,7 @@ export async function createMcpToolDefinitions(
1271
1447
  discoveryAuth.allowInsecureHttp,
1272
1448
  onDiagnostic,
1273
1449
  );
1274
- const { prepared, report } = prepareTools(discovery);
1450
+ const { prepared, report } = prepareTools(discovery, namespace, exposurePolicy);
1275
1451
  emitPreparationDiagnostics(report, onDiagnostic);
1276
1452
  const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
1277
1453
  const lostDetail = [
@@ -1285,7 +1461,7 @@ export async function createMcpToolDefinitions(
1285
1461
  `${lostDetail ? ` (${lostDetail})` : ""}`,
1286
1462
  );
1287
1463
 
1288
- const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope }) => {
1464
+ const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope, exposure }) => {
1289
1465
  const description = truncateUtf8(
1290
1466
  `${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
1291
1467
  MAX_DESCRIPTION_BYTES,
@@ -1301,6 +1477,16 @@ export async function createMcpToolDefinitions(
1301
1477
  // untrusted string that reaches Pi's UI or the model.
1302
1478
  const label = truncateUtf8(`${mcpTool.server_name}: ${mcpTool.name}`, MAX_LABEL_BYTES, SHORT_TRUNCATION_MARKER);
1303
1479
  const boundedDetail = (value: string): string => truncateUtf8(value, MAX_DETAIL_BYTES, SHORT_TRUNCATION_MARKER);
1480
+ // Pi 0.99 groups tools by namespace, as its own MCP client does per server. The prefix matches the
1481
+ // generated tool names, so a group is recognisable in codemode listings; older Pi ignores it.
1482
+ const toolNamespace = {
1483
+ name: buildNamespaceName(mcpTool, namespace),
1484
+ description: truncateUtf8(
1485
+ mcpTool.server_description ?? `LiteLLM MCP server ${mcpTool.server_name}`,
1486
+ MAX_LABEL_BYTES,
1487
+ SHORT_TRUNCATION_MARKER,
1488
+ ),
1489
+ };
1304
1490
 
1305
1491
  return defineTool({
1306
1492
  name,
@@ -1308,13 +1494,17 @@ export async function createMcpToolDefinitions(
1308
1494
  description,
1309
1495
  promptSnippet,
1310
1496
  executionMode: "parallel",
1497
+ exposure: toToolExposure(exposure),
1498
+ namespace: toolNamespace,
1499
+ ...(mcpTool.annotations ? { annotations: mcpTool.annotations } : {}),
1311
1500
  parameters,
1501
+ outputSchema: MCP_RESULT_SCHEMA,
1312
1502
  async execute(_toolCallId, params: Static<typeof parameters>, toolSignal, _onUpdate, ctx) {
1313
1503
  const auth = await getAuth(ctx);
1314
1504
  const rawParams = params as Record<string, unknown>;
1315
1505
  const args = syntheticArgsEnvelope ? asRecord(rawParams.args) : rawParams;
1316
1506
  if (!args) throw new Error("Synthetic MCP tool arguments must contain an object-valued args property");
1317
- const text = await executeMcpTool(
1507
+ const { text, structuredContent, isError } = await executeMcpTool(
1318
1508
  auth.baseUrl,
1319
1509
  auth.apiKey,
1320
1510
  mcpTool.server_id ?? mcpTool.server_name,
@@ -1325,8 +1515,13 @@ export async function createMcpToolDefinitions(
1325
1515
  auth.allowInsecureHttp,
1326
1516
  onDiagnostic,
1327
1517
  );
1518
+ // Pi before 0.99 ignores a returned `isError`; `ctx.executeTool` exists only from 0.99.
1519
+ if (isError && typeof (ctx as { executeTool?: unknown }).executeTool !== "function") throw new Error(text);
1328
1520
  return {
1329
1521
  content: [{ type: "text", text }],
1522
+ // The body came from JSON.parse, so it is a JsonValue; Pi's type cannot see that.
1523
+ structuredContent: structuredContent as JsonValue,
1524
+ ...(isError ? { isError: true } : {}),
1330
1525
  details: {
1331
1526
  server: boundedDetail(mcpTool.server_name),
1332
1527
  serverId: boundedDetail(mcpTool.server_id ?? mcpTool.server_name),
@@ -1,8 +1,34 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { routesOnlyThrough } from "./backend-identity.js";
2
3
  import { intersectThinkingLevelMaps, THINKING_LEVEL_DEFINITIONS } from "./thinking-levels.js";
3
4
  import type { DiscoveredModel, ModelInfoEntry } from "./types.js";
4
5
 
5
- export const DEFAULT_CONTEXT_WINDOW = 128_000;
6
+ const BUILTIN_CONTEXT_WINDOW = 128_000;
7
+
8
+ /**
9
+ * Context window used when neither /model/info nor the catalog reports one.
10
+ * LITELLM_DEFAULT_CONTEXT_WINDOW raises it for proxies whose model map cannot
11
+ * populate `max_input_tokens`; an unset or unusable value keeps the 128K default.
12
+ */
13
+ export function defaultContextWindow(): number {
14
+ // Number, not parseInt: parseInt takes a numeric prefix, so `1.5` would become a
15
+ // 1-token window and `922000junk` would pass as a limit instead of being rejected.
16
+ const parsed = Number(process.env.LITELLM_DEFAULT_CONTEXT_WINDOW ?? "");
17
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : BUILTIN_CONTEXT_WINDOW;
18
+ }
19
+
20
+ /**
21
+ * Whether a stored window is one this extension assumed rather than read. A model cached
22
+ * under the built-in default must still read as evidence-free once an operator configures
23
+ * LITELLM_DEFAULT_CONTEXT_WINDOW, or the setting would never reach cached entries.
24
+ * ponytail: a window stored under a setting that was later lowered is indistinguishable
25
+ * from measured evidence and stays until the next online discovery; persist a fallback
26
+ * marker on the model if that ever matters.
27
+ */
28
+ export function isFallbackContextWindow(contextWindow: number): boolean {
29
+ return contextWindow === BUILTIN_CONTEXT_WINDOW || contextWindow === defaultContextWindow();
30
+ }
31
+
6
32
  export const DEFAULT_MAX_TOKENS = 16_384;
7
33
 
8
34
  export type SemanticFamily = "claude" | "deepseek" | "gemini" | "kimi" | "openai";
@@ -12,7 +38,7 @@ type FamilyEvidence = SemanticFamily | "conflicting";
12
38
 
13
39
  export type MessagesBackendCompat = Pick<
14
40
  NonNullable<Model<"anthropic-messages">["compat"]>,
15
- "forceAdaptiveThinking" | "supportsTemperature" | "supportsStrictTools"
41
+ "forceAdaptiveThinking" | "supportsTemperature"
16
42
  >;
17
43
 
18
44
  type OpenAICompat = NonNullable<Model<"openai-completions">["compat"]>;
@@ -32,6 +58,9 @@ export interface CatalogResolution {
32
58
  semanticFamily?: FamilyEvidence;
33
59
  semanticModel?: SemanticModel;
34
60
  messagesCompat?: MessagesBackendCompat;
61
+ // Evidence for the actual Messages route, kept separate from model-generation
62
+ // serializer policy so strict-tool disagreement cannot change the transport.
63
+ messagesStrictTools?: boolean;
35
64
  messagesThinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
36
65
  reasoning?: boolean;
37
66
  effortLevels?: string[];
@@ -52,6 +81,7 @@ export interface ReducedModelGroup {
52
81
  thinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
53
82
  vision: boolean;
54
83
  contextWindow: number;
84
+ contextWindowDefaulted?: boolean;
55
85
  maxTokens: number;
56
86
  cost: DiscoveredModel["cost"];
57
87
  hasCompleteCost: boolean;
@@ -60,6 +90,7 @@ export interface ReducedModelGroup {
60
90
  semanticModel?: SemanticModel;
61
91
  semanticFamily?: FamilyEvidence;
62
92
  messagesCompat?: MessagesBackendCompat;
93
+ messagesStrictTools?: boolean;
63
94
  // Set when deployments disagreed on catalog provider identity, so catalog
64
95
  // limits, pricing, and reasoning metadata were withheld for the whole group.
65
96
  catalogAuthorityAmbiguous?: boolean;
@@ -712,6 +743,7 @@ export function hasMixedIncompatibleDeploymentModes(entries: readonly ModelInfoE
712
743
  }
713
744
 
714
745
  const KIMI_FAMILY_PATTERN = /(?:^|[./_-])(?:moonshotai|moonshot|kimi)(?:$|[./_:-])/i;
746
+ const MOONSHOT_ROUTES: ReadonlySet<string> = new Set(["moonshot", "moonshotai"]);
715
747
  const FORCED_THINKING_PATTERN = /(?:^|[./_-])thinking(?:$|[./_:-])/i;
716
748
 
717
749
  function kimiDeploymentEvidence(entry: ModelInfoEntry): {
@@ -728,21 +760,12 @@ function kimiDeploymentEvidence(entry: ModelInfoEntry): {
728
760
  .map((candidate) => wireString(candidate)?.trim())
729
761
  .filter((candidate): candidate is string => Boolean(candidate));
730
762
  const kimi = identities.filter((identity) => KIMI_FAMILY_PATTERN.test(identity));
731
- const routingModel = wireString(entry.litellm_params?.model)?.trim();
732
- const routingProviders = [
733
- wireString(entry.litellm_params?.custom_llm_provider)?.trim(),
734
- routingModel?.includes("/") ? routingModel.split("/", 1)[0] : undefined,
735
- ]
736
- .filter((provider): provider is string => Boolean(provider))
737
- .map((provider) => provider.toLowerCase());
738
763
  return {
739
764
  identified: kimi.length > 0,
740
765
  forcedThinking: kimi.some((identity) => FORCED_THINKING_PATTERN.test(identity)),
741
766
  // Visibility parameters are accepted by Moonshot's API, not by every host
742
767
  // that serves a Kimi model. Both declared routing signals must name Moonshot.
743
- moonshotTransport:
744
- routingProviders.length > 0 &&
745
- routingProviders.every((provider) => provider === "moonshot" || provider === "moonshotai"),
768
+ moonshotTransport: routesOnlyThrough(entry, MOONSHOT_ROUTES),
746
769
  };
747
770
  }
748
771
 
@@ -772,6 +795,9 @@ export function reduceModelGroup(
772
795
  const semanticModel = unanimous(catalogs.map((catalog) => catalog?.semanticModel));
773
796
  const semanticFamily = unanimous(catalogs.map((catalog) => catalog?.semanticFamily));
774
797
  const messagesCompat = unanimous(catalogs.map((catalog) => stableJson(catalog?.messagesCompat)));
798
+ // Strict tools require affirmative evidence from every routable deployment.
799
+ const messagesStrictTools =
800
+ catalogs.length > 0 && catalogs.every((catalog) => catalog?.messagesStrictTools === true) ? true : undefined;
775
801
  const messagesEndpointAllowed = deployments.every((entry) => {
776
802
  const endpoints = entry.model_info?.supported_endpoints;
777
803
  return endpoints === undefined || (Array.isArray(endpoints) && endpoints.includes("/v1/messages"));
@@ -801,7 +827,8 @@ export function reduceModelGroup(
801
827
  (entry) => wireBoolean(entry.model_info?.supports_reasoning) === false,
802
828
  );
803
829
  const vision = visionEvidence.every((value) => value ?? false);
804
- const contextWindow = min(contextWindowEvidence.map((value) => value ?? DEFAULT_CONTEXT_WINDOW));
830
+ const fallbackContextWindow = defaultContextWindow();
831
+ const contextWindow = min(contextWindowEvidence.map((value) => value ?? fallbackContextWindow));
805
832
  const maxTokens = min(maxTokensEvidence.map((value) => value ?? DEFAULT_MAX_TOKENS));
806
833
 
807
834
  const costValues = COST_FIELDS.map((field) =>
@@ -930,6 +957,9 @@ export function reduceModelGroup(
930
957
  ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
931
958
  vision,
932
959
  contextWindow,
960
+ ...(contextWindowEvidence.includes(undefined) && contextWindow === fallbackContextWindow
961
+ ? { contextWindowDefaulted: true }
962
+ : {}),
933
963
  maxTokens,
934
964
  cost,
935
965
  hasCompleteCost,
@@ -938,6 +968,7 @@ export function reduceModelGroup(
938
968
  ...(semanticModel ? { semanticModel } : {}),
939
969
  ...(semanticFamily ? { semanticFamily } : {}),
940
970
  ...(messagesCompat ? { messagesCompat: JSON.parse(messagesCompat) } : {}),
971
+ ...(messagesStrictTools !== undefined ? { messagesStrictTools } : {}),
941
972
  ...(catalogAuthorityAmbiguous ? { catalogAuthorityAmbiguous: true } : {}),
942
973
  deploymentFamilies: catalogs.map((catalog) => catalog?.semanticFamily),
943
974
  normalizeThinkTags: unanimousNormalKimi,