pi-provider-litellm 3.2.0 → 3.4.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;
@@ -36,19 +43,110 @@ interface RawLiteLLMMcpTool {
36
43
  description?: unknown;
37
44
  inputSchema?: unknown;
38
45
  input_schema?: unknown;
46
+ annotations?: unknown;
39
47
  server_id?: unknown;
40
48
  server_name?: unknown;
41
49
  mcp_info?: {
42
50
  server_id?: unknown;
43
51
  server_name?: unknown;
52
+ description?: unknown;
44
53
  };
45
54
  }
46
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
+
47
144
  interface PreparedTool {
48
145
  tool: LiteLLMMcpTool;
49
146
  name: string;
50
147
  parameters: TSchema;
51
148
  syntheticArgsEnvelope: boolean;
149
+ exposure: McpExposure;
52
150
  }
53
151
 
54
152
  interface InvalidMcpTool {
@@ -104,6 +202,8 @@ export interface McpPreparationReport {
104
202
  // Non-printed identities paired with `dropped`, used to detect membership changes safely.
105
203
  dropMemberships: Array<{ reason: McpDropReason; identities: string[] }>;
106
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[];
107
207
  }
108
208
 
109
209
  // Bounded discovery result: `raw` is what the proxy returned, so losses can be reconciled.
@@ -460,6 +560,21 @@ function parseDiscoveryJson(text: string): unknown {
460
560
  return parseJson(text, "MCP discovery");
461
561
  }
462
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
+
463
578
  function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
464
579
  const raw = asRecord(value) as RawLiteLLMMcpTool | undefined;
465
580
  if (!raw) return undefined;
@@ -474,6 +589,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
474
589
  const hasSnakeInputSchema = Object.hasOwn(raw, "input_schema");
475
590
  const suppliedSchema = hasInputSchema ? raw.inputSchema : hasSnakeInputSchema ? raw.input_schema : undefined;
476
591
  const inputSchema = asRecord(suppliedSchema);
592
+ const annotations = toolAnnotations(raw.annotations);
593
+ const serverDescription = stringValue(raw.mcp_info?.description);
477
594
  return {
478
595
  name,
479
596
  server_name: serverName,
@@ -482,6 +599,8 @@ function normalizeMcpTool(value: unknown): LiteLLMMcpTool | undefined {
482
599
  input_schema: inputSchema ?? {},
483
600
  // Present but not an object: malformed, as distinct from absent.
484
601
  ...(inputSchema === undefined && (hasInputSchema || hasSnakeInputSchema) ? { input_schema_malformed: true } : {}),
602
+ ...(annotations ? { annotations } : {}),
603
+ ...(serverDescription ? { server_description: serverDescription } : {}),
485
604
  };
486
605
  }
487
606
 
@@ -682,6 +801,34 @@ function mcpCallError(serverId: string, toolName: string, detail: string): Error
682
801
  );
683
802
  }
684
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
+
685
832
  export async function executeMcpTool(
686
833
  baseUrl: string,
687
834
  apiKey: string,
@@ -692,7 +839,7 @@ export async function executeMcpTool(
692
839
  parentSignal?: AbortSignal,
693
840
  allowInsecureHttp = false,
694
841
  onDiagnostic?: DiagnosticSink,
695
- ): Promise<string> {
842
+ ): Promise<McpToolOutcome> {
696
843
  parentSignal?.throwIfAborted();
697
844
  const signal = boundedSignal(CALL_TIMEOUT_MS, parentSignal);
698
845
  let response: Response;
@@ -721,9 +868,11 @@ export async function executeMcpTool(
721
868
  if (resultRecord?.isError === true || bodyRecord?.isError === true) {
722
869
  const message =
723
870
  mcpErrorMessage(resultRecord?.content ?? bodyRecord?.content ?? result) ?? serializeResult(result);
724
- throw mcpCallError(serverId, toolName, message);
871
+ const text = mcpCallError(serverId, toolName, message).message;
872
+ return { text, structuredContent: structuredResult(result, text, true), isError: true };
725
873
  }
726
- return serializeResult(result);
874
+ const text = serializeResult(result);
875
+ return { text, structuredContent: structuredResult(result, text, false), isError: false };
727
876
  } catch (error) {
728
877
  if (parentSignal?.aborted) throw parentSignal.reason;
729
878
  throw error;
@@ -758,6 +907,16 @@ function buildPiToolName(tool: LiteLLMMcpTool, namespace?: string): string {
758
907
  return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
759
908
  }
760
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)}`;
917
+ return `${base.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
918
+ }
919
+
761
920
  function schemaDepth(value: unknown, depth = 0): number {
762
921
  if (!value || typeof value !== "object") return depth;
763
922
  if (depth > MAX_SCHEMA_DEPTH) return depth;
@@ -1147,6 +1306,7 @@ function buildParameters(tool: LiteLLMMcpTool): BuiltParameters | McpDropReason
1147
1306
  export function prepareTools(
1148
1307
  discovery: McpDiscovery,
1149
1308
  namespace?: string,
1309
+ exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
1150
1310
  ): {
1151
1311
  prepared: PreparedTool[];
1152
1312
  report: McpPreparationReport;
@@ -1185,6 +1345,7 @@ export function prepareTools(
1185
1345
  tool,
1186
1346
  parameters: built.parameters,
1187
1347
  syntheticArgsEnvelope: built.syntheticArgsEnvelope,
1348
+ exposure: resolveMcpExposure(exposurePolicy, tool.server_name, tool.name),
1188
1349
  degraded: built.degraded,
1189
1350
  });
1190
1351
  }
@@ -1222,6 +1383,7 @@ export function prepareTools(
1222
1383
  dropped: [...drops.entries()].map(([reason, tools]) => ({ reason, tools })),
1223
1384
  dropMemberships: [...dropMemberships.entries()].map(([reason, identities]) => ({ reason, identities })),
1224
1385
  degraded: [...degrades.entries()].map(([reason, tools]) => ({ reason, tools })),
1386
+ exposures: [...new Set(prepared.map((tool) => tool.exposure))].sort(),
1225
1387
  };
1226
1388
  return { prepared, report };
1227
1389
  }
@@ -1273,6 +1435,7 @@ export async function createMcpToolDefinitions(
1273
1435
  signal?: AbortSignal,
1274
1436
  onDiagnostic?: DiagnosticSink,
1275
1437
  namespace?: string,
1438
+ exposurePolicy: McpExposurePolicy = DEFAULT_MCP_EXPOSURE_POLICY,
1276
1439
  ): Promise<{ definitions: ToolDefinition[]; report: McpPreparationReport }> {
1277
1440
  const discoveryAuth = await getAuth();
1278
1441
  const discovery = await discoverMcpTools(
@@ -1284,7 +1447,7 @@ export async function createMcpToolDefinitions(
1284
1447
  discoveryAuth.allowInsecureHttp,
1285
1448
  onDiagnostic,
1286
1449
  );
1287
- const { prepared, report } = prepareTools(discovery, namespace);
1450
+ const { prepared, report } = prepareTools(discovery, namespace, exposurePolicy);
1288
1451
  emitPreparationDiagnostics(report, onDiagnostic);
1289
1452
  const lostTotal = report.dropped.reduce((total, entry) => total + entry.tools.length, 0) + report.overflow;
1290
1453
  const lostDetail = [
@@ -1298,7 +1461,7 @@ export async function createMcpToolDefinitions(
1298
1461
  `${lostDetail ? ` (${lostDetail})` : ""}`,
1299
1462
  );
1300
1463
 
1301
- const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope }) => {
1464
+ const definitions = prepared.map(({ tool: mcpTool, name, parameters, syntheticArgsEnvelope, exposure }) => {
1302
1465
  const description = truncateUtf8(
1303
1466
  `${mcpTool.description} (via ${mcpTool.server_name} MCP server)`,
1304
1467
  MAX_DESCRIPTION_BYTES,
@@ -1314,6 +1477,16 @@ export async function createMcpToolDefinitions(
1314
1477
  // untrusted string that reaches Pi's UI or the model.
1315
1478
  const label = truncateUtf8(`${mcpTool.server_name}: ${mcpTool.name}`, MAX_LABEL_BYTES, SHORT_TRUNCATION_MARKER);
1316
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
+ };
1317
1490
 
1318
1491
  return defineTool({
1319
1492
  name,
@@ -1321,13 +1494,17 @@ export async function createMcpToolDefinitions(
1321
1494
  description,
1322
1495
  promptSnippet,
1323
1496
  executionMode: "parallel",
1497
+ exposure: toToolExposure(exposure),
1498
+ namespace: toolNamespace,
1499
+ ...(mcpTool.annotations ? { annotations: mcpTool.annotations } : {}),
1324
1500
  parameters,
1501
+ outputSchema: MCP_RESULT_SCHEMA,
1325
1502
  async execute(_toolCallId, params: Static<typeof parameters>, toolSignal, _onUpdate, ctx) {
1326
1503
  const auth = await getAuth(ctx);
1327
1504
  const rawParams = params as Record<string, unknown>;
1328
1505
  const args = syntheticArgsEnvelope ? asRecord(rawParams.args) : rawParams;
1329
1506
  if (!args) throw new Error("Synthetic MCP tool arguments must contain an object-valued args property");
1330
- const text = await executeMcpTool(
1507
+ const { text, structuredContent, isError } = await executeMcpTool(
1331
1508
  auth.baseUrl,
1332
1509
  auth.apiKey,
1333
1510
  mcpTool.server_id ?? mcpTool.server_name,
@@ -1338,8 +1515,13 @@ export async function createMcpToolDefinitions(
1338
1515
  auth.allowInsecureHttp,
1339
1516
  onDiagnostic,
1340
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);
1341
1520
  return {
1342
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 } : {}),
1343
1525
  details: {
1344
1526
  server: boundedDetail(mcpTool.server_name),
1345
1527
  serverId: boundedDetail(mcpTool.server_id ?? mcpTool.server_name),
@@ -1,4 +1,5 @@
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
 
@@ -37,7 +38,7 @@ type FamilyEvidence = SemanticFamily | "conflicting";
37
38
 
38
39
  export type MessagesBackendCompat = Pick<
39
40
  NonNullable<Model<"anthropic-messages">["compat"]>,
40
- "forceAdaptiveThinking" | "supportsTemperature" | "supportsStrictTools"
41
+ "forceAdaptiveThinking" | "supportsTemperature"
41
42
  >;
42
43
 
43
44
  type OpenAICompat = NonNullable<Model<"openai-completions">["compat"]>;
@@ -57,6 +58,9 @@ export interface CatalogResolution {
57
58
  semanticFamily?: FamilyEvidence;
58
59
  semanticModel?: SemanticModel;
59
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;
60
64
  messagesThinkingLevelMap?: DiscoveredModel["thinkingLevelMap"];
61
65
  reasoning?: boolean;
62
66
  effortLevels?: string[];
@@ -86,6 +90,7 @@ export interface ReducedModelGroup {
86
90
  semanticModel?: SemanticModel;
87
91
  semanticFamily?: FamilyEvidence;
88
92
  messagesCompat?: MessagesBackendCompat;
93
+ messagesStrictTools?: boolean;
89
94
  // Set when deployments disagreed on catalog provider identity, so catalog
90
95
  // limits, pricing, and reasoning metadata were withheld for the whole group.
91
96
  catalogAuthorityAmbiguous?: boolean;
@@ -738,6 +743,7 @@ export function hasMixedIncompatibleDeploymentModes(entries: readonly ModelInfoE
738
743
  }
739
744
 
740
745
  const KIMI_FAMILY_PATTERN = /(?:^|[./_-])(?:moonshotai|moonshot|kimi)(?:$|[./_:-])/i;
746
+ const MOONSHOT_ROUTES: ReadonlySet<string> = new Set(["moonshot", "moonshotai"]);
741
747
  const FORCED_THINKING_PATTERN = /(?:^|[./_-])thinking(?:$|[./_:-])/i;
742
748
 
743
749
  function kimiDeploymentEvidence(entry: ModelInfoEntry): {
@@ -754,21 +760,12 @@ function kimiDeploymentEvidence(entry: ModelInfoEntry): {
754
760
  .map((candidate) => wireString(candidate)?.trim())
755
761
  .filter((candidate): candidate is string => Boolean(candidate));
756
762
  const kimi = identities.filter((identity) => KIMI_FAMILY_PATTERN.test(identity));
757
- const routingModel = wireString(entry.litellm_params?.model)?.trim();
758
- const routingProviders = [
759
- wireString(entry.litellm_params?.custom_llm_provider)?.trim(),
760
- routingModel?.includes("/") ? routingModel.split("/", 1)[0] : undefined,
761
- ]
762
- .filter((provider): provider is string => Boolean(provider))
763
- .map((provider) => provider.toLowerCase());
764
763
  return {
765
764
  identified: kimi.length > 0,
766
765
  forcedThinking: kimi.some((identity) => FORCED_THINKING_PATTERN.test(identity)),
767
766
  // Visibility parameters are accepted by Moonshot's API, not by every host
768
767
  // that serves a Kimi model. Both declared routing signals must name Moonshot.
769
- moonshotTransport:
770
- routingProviders.length > 0 &&
771
- routingProviders.every((provider) => provider === "moonshot" || provider === "moonshotai"),
768
+ moonshotTransport: routesOnlyThrough(entry, MOONSHOT_ROUTES),
772
769
  };
773
770
  }
774
771
 
@@ -798,9 +795,15 @@ export function reduceModelGroup(
798
795
  const semanticModel = unanimous(catalogs.map((catalog) => catalog?.semanticModel));
799
796
  const semanticFamily = unanimous(catalogs.map((catalog) => catalog?.semanticFamily));
800
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;
801
+ // LiteLLM v1.100+ reports `supported_endpoints: null` for every model its model map does not
802
+ // list, so null means unknown, like an absent field. Only a list without Messages, or a
803
+ // malformed non-null value, withholds the transport.
801
804
  const messagesEndpointAllowed = deployments.every((entry) => {
802
805
  const endpoints = entry.model_info?.supported_endpoints;
803
- return endpoints === undefined || (Array.isArray(endpoints) && endpoints.includes("/v1/messages"));
806
+ return endpoints == null || (Array.isArray(endpoints) && endpoints.includes("/v1/messages"));
804
807
  });
805
808
  const api = candidateModes.every((mode) => mode === "responses")
806
809
  ? "openai-responses"
@@ -968,6 +971,7 @@ export function reduceModelGroup(
968
971
  ...(semanticModel ? { semanticModel } : {}),
969
972
  ...(semanticFamily ? { semanticFamily } : {}),
970
973
  ...(messagesCompat ? { messagesCompat: JSON.parse(messagesCompat) } : {}),
974
+ ...(messagesStrictTools !== undefined ? { messagesStrictTools } : {}),
971
975
  ...(catalogAuthorityAmbiguous ? { catalogAuthorityAmbiguous: true } : {}),
972
976
  deploymentFamilies: catalogs.map((catalog) => catalog?.semanticFamily),
973
977
  normalizeThinkTags: unanimousNormalKimi,
package/src/provider.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import {
2
+ type AnyModel,
3
+ type Api,
2
4
  type ApiStreamOptions,
3
- type Context,
4
5
  type Credential,
5
6
  createProvider,
7
+ getCurrentTools,
6
8
  type Model,
7
9
  type Provider,
8
10
  type ProviderAuth,
9
11
  type SimpleStreamOptions,
12
+ type TranscriptContext,
10
13
  } from "@earendil-works/pi-ai";
11
14
  import { LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
12
15
  import { enrichCachedModel, normalizeBaseUrl, restoreCachedModelPolicy } from "./discover.js";
@@ -26,6 +29,11 @@ export type LiteLLMProviderOptions = {
26
29
  discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & { baseUrl?: string }>;
27
30
  };
28
31
 
32
+ // Chat models may omit `type`; Pi's `isModelType()` is newer than the peer floor, so keep the check local.
33
+ function isChatModel(model: AnyModel): model is Model<Api> {
34
+ return (model.type ?? "chat") === "chat";
35
+ }
36
+
29
37
  export function toNativeModels(
30
38
  provider: string,
31
39
  baseUrl: string,
@@ -112,7 +120,7 @@ function modelRootError(
112
120
  function requestModel(
113
121
  provider: string,
114
122
  model: Model<LiteLLMApi>,
115
- context: Context,
123
+ context: TranscriptContext,
116
124
  credentialRoot: string | undefined,
117
125
  allowInsecureHttp = false,
118
126
  ): Model<LiteLLMApi> {
@@ -120,14 +128,13 @@ function requestModel(
120
128
  const active = activeCredentialRoot(credentialRoot, allowInsecureHttp);
121
129
  const error = modelRootError(model, active, allowInsecureHttp);
122
130
  if (error) throw error;
123
- if (
124
- model.api === "openai-completions" &&
125
- (model as LiteLLMModel).litellmBackendFamily === "openai" &&
126
- context.tools &&
127
- context.tools.length > CHAT_TOOL_CAP
128
- ) {
131
+ const toolCount =
132
+ model.api === "openai-completions" && (model as LiteLLMModel).litellmBackendFamily === "openai"
133
+ ? getCurrentTools(context.messages).length
134
+ : 0;
135
+ if (toolCount > CHAT_TOOL_CAP) {
129
136
  throw new Error(
130
- `LiteLLM model ${model.id} uses Chat Completions with ${context.tools.length} tools, exceeding the ` +
137
+ `LiteLLM model ${model.id} uses Chat Completions with ${toolCount} tools, exceeding the ` +
131
138
  `${CHAT_TOOL_CAP}-tool cap; route this model via Responses or reduce enabled extensions`,
132
139
  );
133
140
  }
@@ -189,7 +196,7 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
189
196
  const refreshModels = provider.refreshModels;
190
197
  const guardedProvider: Provider<LiteLLMApi> = {
191
198
  ...provider,
192
- stream: <T extends LiteLLMApi>(model: Model<T>, context: Context, requestOptions?: ApiStreamOptions<T>) =>
199
+ stream: <T extends LiteLLMApi>(model: Model<T>, context: TranscriptContext, requestOptions?: ApiStreamOptions<T>) =>
193
200
  provider.stream(
194
201
  requestModel(
195
202
  options.id,
@@ -201,7 +208,7 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
201
208
  context,
202
209
  requestOptions,
203
210
  ),
204
- streamSimple: (model: Model<LiteLLMApi>, context: Context, requestOptions?: SimpleStreamOptions) =>
211
+ streamSimple: (model: Model<LiteLLMApi>, context: TranscriptContext, requestOptions?: SimpleStreamOptions) =>
205
212
  provider.streamSimple(
206
213
  requestModel(
207
214
  options.id,
@@ -215,13 +222,21 @@ export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider
215
222
  ),
216
223
  };
217
224
  if (!refreshModels) return guardedProvider;
225
+ const seedKey = (model: { id: string; baseUrl: string }) => `${model.id}\0${model.baseUrl}`;
226
+ const seeded = new Map((options.models ?? []).map((model) => [seedKey(model), model]));
218
227
  return {
219
228
  ...guardedProvider,
220
229
  refreshModels: async (context) => {
221
- const storedModels = context.stored?.models ?? [];
230
+ // Pi 0.99 persists models of every type; this provider only publishes chat models, and the
231
+ // policy helpers below read chat-only fields.
232
+ const storedModels = (context.stored?.models ?? []).filter(isChatModel);
222
233
  let legacyCount = 0;
223
234
  const models = storedModels.map((model) => {
224
235
  if ((model as LiteLLMModel).litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION) {
236
+ // Pi applies stored models over the seed, so a stale entry would otherwise mask what
237
+ // startup discovery just proved for the same route.
238
+ const fresh = seeded.get(seedKey(model));
239
+ if (fresh) return fresh;
225
240
  legacyCount++;
226
241
  return restoreCachedModelPolicy(model);
227
242
  }
@@ -0,0 +1,68 @@
1
+ import type { DiscoveryOptions } from "./types.js";
2
+
3
+ export interface ProxyVersion {
4
+ major: number;
5
+ minor: number;
6
+ patch: number;
7
+ // Any suffix (rc, dev, post, local build). It orders before the release it names,
8
+ // so a pre-release never claims a fix that only the release is known to carry.
9
+ prerelease: boolean;
10
+ }
11
+
12
+ export type ProxyVersionFloor = readonly [major: number, minor: number, patch: number];
13
+
14
+ const DEFAULT_TIMEOUT_MS = 5000;
15
+ const MAX_VERSION_LENGTH = 64;
16
+ const VERSION_PATTERN = /^v?(\d{1,5})\.(\d{1,5})\.(\d{1,5})(.*)$/s;
17
+ const VERSION_HEADER = "x-litellm-version";
18
+ // LiteLLM stamps its version on inference routes only; `/model/info`, `/v1/models`
19
+ // and `/health/*` do not carry it. A made-up Responses id names no deployment, so
20
+ // the read is answered with an error that still carries the header and calls no model.
21
+ const VERSION_PROBE_PATH = "/v1/responses/resp_version_probe";
22
+
23
+ // The value is proxy-supplied: bound it before matching and keep only the numbers.
24
+ export function parseProxyVersion(value: unknown): ProxyVersion | undefined {
25
+ if (typeof value !== "string" || value.length > MAX_VERSION_LENGTH) return undefined;
26
+ const match = VERSION_PATTERN.exec(value.trim());
27
+ if (!match) return undefined;
28
+ return {
29
+ major: Number(match[1]),
30
+ minor: Number(match[2]),
31
+ patch: Number(match[3]),
32
+ prerelease: match[4] !== "",
33
+ };
34
+ }
35
+
36
+ // An unknown version satisfies no floor, so a version-gated workaround stays on
37
+ // whenever the proxy does not say what it is.
38
+ export function proxyVersionAtLeast(version: ProxyVersion | undefined, floor: ProxyVersionFloor): boolean {
39
+ if (!version) return false;
40
+ const actual = [version.major, version.minor, version.patch];
41
+ for (const [index, required] of floor.entries()) {
42
+ if (actual[index] !== required) return actual[index]! > required;
43
+ }
44
+ return !version.prerelease;
45
+ }
46
+
47
+ // Best effort by design: the header rides on an error path LiteLLM does not
48
+ // document, so a missing header, an unreachable proxy or a timeout all mean
49
+ // "unknown" and never fail discovery.
50
+ export async function probeProxyVersion(
51
+ base: string,
52
+ apiKey: string,
53
+ options: Pick<DiscoveryOptions, "timeoutMs" | "signal" | "headers">,
54
+ ): Promise<ProxyVersion | undefined> {
55
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
56
+ try {
57
+ const response = await fetch(`${base}${VERSION_PROBE_PATH}`, {
58
+ headers: { ...options.headers, Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
59
+ signal: options.signal
60
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
61
+ : AbortSignal.timeout(timeoutMs),
62
+ });
63
+ await response.body?.cancel().catch(() => undefined);
64
+ return parseProxyVersion(response.headers.get(VERSION_HEADER));
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
package/src/skills.ts CHANGED
@@ -169,6 +169,10 @@ function parseJsonObject(value: string, fieldName: string): Record<string, unkno
169
169
  return parsed as Record<string, unknown>;
170
170
  }
171
171
 
172
+ // Pi 0.99 groups tools by namespace and lets permission extensions read annotation hints; older
173
+ // Pi ignores both fields.
174
+ const SKILLS_NAMESPACE = { name: "litellm_skills", description: "LiteLLM Skills Gateway" };
175
+
172
176
  export function createSkillToolDefinitions(
173
177
  getAuth: (ctx?: ExtensionContext) => Promise<LiteLLMRuntimeAuth>,
174
178
  ): ToolDefinition[] {
@@ -178,6 +182,8 @@ export function createSkillToolDefinitions(
178
182
  label: "LiteLLM Skills",
179
183
  description: "List skills registered on the LiteLLM proxy Skills Gateway.",
180
184
  promptSnippet: "List LiteLLM Skills Gateway skills",
185
+ namespace: SKILLS_NAMESPACE,
186
+ annotations: { readOnlyHint: true, openWorldHint: false },
181
187
  parameters: Type.Object({}),
182
188
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
183
189
  const auth = await getAuth(ctx);
@@ -189,6 +195,8 @@ export function createSkillToolDefinitions(
189
195
  name: "litellm_skill_create",
190
196
  label: "Create LiteLLM Skill",
191
197
  description: "Create and enable a skill on the LiteLLM proxy Skill Hub from source metadata.",
198
+ namespace: SKILLS_NAMESPACE,
199
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
192
200
  parameters: CreateSkillParams,
193
201
  async execute(_toolCallId, params: Static<typeof CreateSkillParams>, _signal, _onUpdate, ctx) {
194
202
  const auth = await getAuth(ctx);
@@ -207,6 +215,8 @@ export function createSkillToolDefinitions(
207
215
  name: "litellm_skill_delete",
208
216
  label: "Delete LiteLLM Skill",
209
217
  description: "Delete a skill from the LiteLLM proxy Skills Gateway.",
218
+ namespace: SKILLS_NAMESPACE,
219
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
210
220
  parameters: DeleteSkillParams,
211
221
  async execute(_toolCallId, params: Static<typeof DeleteSkillParams>, _signal, _onUpdate, ctx) {
212
222
  const auth = await getAuth(ctx);