pi-provider-litellm 2.0.1 → 2.0.3

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/dist/discover.js CHANGED
@@ -85,6 +85,8 @@ function catalogLookupIds(id) {
85
85
  const match = /^(?:claude-)?(opus|sonnet|haiku)-(\d+)-(\d+)$/.exec(anthropicAlias);
86
86
  if (match)
87
87
  lookupIds.add(`claude-${match[1]}-${match[2]}-${match[3]}`);
88
+ if (anthropicAlias === "fable-5")
89
+ lookupIds.add("claude-fable-5");
88
90
  return [...lookupIds];
89
91
  }
90
92
  function findCatalogModelInProvider(provider, lookupIds) {
@@ -439,8 +441,31 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
439
441
  model_info: { ...previous?.model_info, ...entry.model_info },
440
442
  });
441
443
  }
442
- const models = [...entries.values()].map(mapFromModelInfo).filter((m) => m !== undefined);
443
- return { source: "model_info", models };
444
+ let models = [...entries.values()].map(mapFromModelInfo).filter((m) => m !== undefined);
445
+ // LiteLLM's /model/info does NOT expand wildcard model_name entries (e.g.
446
+ // "lemonade/*" backed by model: openai/* + check_provider_endpoint: true)
447
+ // — it returns the literal wildcard only. The discovered ids live in
448
+ // /v1/models instead. When /model/info contains any wildcard id, also query
449
+ // /v1/models and merge the expanded (non-wildcard) entries in, dropping the
450
+ // raw wildcard row so it doesn't surface as a phantom model choice.
451
+ // Ref: docs.litellm.ai/docs/proxy/model_discovery
452
+ if (models.some((m) => m.id.includes("*"))) {
453
+ progress?.("/model/info has wildcard entries, expanding via /v1/models...");
454
+ let modelsDev;
455
+ if (options.modelsDev !== false) {
456
+ progress?.("Loading models.dev catalog for metadata enrichment...");
457
+ modelsDev = await getModelsDevCatalog(options);
458
+ }
459
+ const listResult = await fetchJson(`${base}/v1/models`, apiKey, options);
460
+ if (listResult.ok) {
461
+ const expanded = (listResult.data.data ?? [])
462
+ .map((entry) => mapFromModelsList(entry, modelsDev))
463
+ .filter((m) => m !== undefined && !m.id.includes("*"));
464
+ const seen = new Set(models.map((m) => m.id));
465
+ models = [...models.filter((m) => !m.id.includes("*")), ...expanded.filter((m) => !seen.has(m.id))];
466
+ }
467
+ }
468
+ return { source: "model_info", models: deduplicateModels(models) };
444
469
  }
445
470
  if (![401, 403, 404].includes(infoResult.status)) {
446
471
  throw new Error(`/model/info returned ${infoResult.status}`);
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { setupLiteLLMCostTracking } from "./cost.js";
6
- import { discoverModels, isGpt55Model, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
6
+ import { discoverModels, isGpt55Model, isMoonshotModel, normalizeBaseUrl, shouldSuppressReasoningContent, } from "./discover.js";
7
7
  import { getGcloudToken, getGcloudTokenCacheKey, getGcloudTokenCommand, isGcloudTokenAuthEnabled, } from "./gcloud-token.js";
8
8
  import { getSessionIdFromFile } from "./litellm.js";
9
9
  import { createMcpToolDefinitions } from "./mcp-tools.js";
@@ -579,6 +579,56 @@ function createProviderAuth(definition) {
579
579
  function isReasoningItem(item) {
580
580
  return typeof item === "object" && item !== null && item.type === "reasoning";
581
581
  }
582
+ // Returns the original array reference when no normalization is needed.
583
+ function normalizeStrictToolMessages(messages) {
584
+ const normalized = [];
585
+ let imageParts = [];
586
+ let changed = false;
587
+ const flushImages = () => {
588
+ if (imageParts.length === 0)
589
+ return;
590
+ normalized.push({
591
+ role: "user",
592
+ content: [{ type: "text", text: "Attached image(s) from tool result:" }, ...imageParts],
593
+ });
594
+ imageParts = [];
595
+ };
596
+ for (const message of messages) {
597
+ if (message.role !== "tool")
598
+ flushImages();
599
+ if (message.role === "assistant" &&
600
+ Array.isArray(message.tool_calls) &&
601
+ message.tool_calls.length > 0 &&
602
+ message.content == null) {
603
+ normalized.push({ ...message, content: "" });
604
+ changed = true;
605
+ }
606
+ else if (message.role === "tool" && Array.isArray(message.content)) {
607
+ const previousImageCount = imageParts.length;
608
+ const content = message.content
609
+ .map((part) => {
610
+ if (typeof part === "string")
611
+ return part;
612
+ if (part?.type === "text")
613
+ return part.text || "";
614
+ if (part?.type === "image_url")
615
+ imageParts.push(part);
616
+ return "";
617
+ })
618
+ .join("");
619
+ normalized.push({
620
+ ...message,
621
+ content: content || (imageParts.length > previousImageCount ? "(see attached image)" : "(no tool output)"),
622
+ });
623
+ changed = true;
624
+ }
625
+ else {
626
+ normalized.push(message);
627
+ }
628
+ }
629
+ flushImages();
630
+ return changed ? normalized : messages;
631
+ }
582
632
  // Reasoning fields LiteLLM forwards to chat-completions providers. The Moonshot
583
633
  // path defaults them off; the gpt-5.5 tool path strips them entirely.
584
634
  const REASONING_SUPPRESSION_DEFAULTS = {
@@ -587,6 +637,7 @@ const REASONING_SUPPRESSION_DEFAULTS = {
587
637
  merge_reasoning_content_in_choices: true,
588
638
  thinking: { type: "disabled" },
589
639
  };
640
+ const GEMINI_MODEL_PATTERN = /(?:^|[-_/.:])gemini(?=$|[-_/.:])/i;
590
641
  function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
591
642
  let next;
592
643
  const update = (key, value) => {
@@ -596,8 +647,10 @@ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
596
647
  next[key] = value;
597
648
  };
598
649
  if (api !== "openai-responses" && modelId && shouldSuppressReasoningContent(modelId)) {
599
- for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS))
600
- update(key, value);
650
+ for (const [key, value] of Object.entries(REASONING_SUPPRESSION_DEFAULTS)) {
651
+ if (key !== "thinking")
652
+ update(key, value);
653
+ }
601
654
  }
602
655
  // LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
603
656
  // Drop reasoning until the gateway honors /v1/responses for this route.
@@ -630,6 +683,39 @@ function prepareLiteLLMRequestPayload(payload, modelId, api, sessionId) {
630
683
  next.input = input.filter((item) => !isReasoningItem(item));
631
684
  }
632
685
  }
686
+ // Moonshot/Kimi applies strict OpenAI schema validation: assistant tool calls
687
+ // must carry string content, and tool results must be plain text.
688
+ if (modelId && isMoonshotModel(modelId)) {
689
+ const messages = (next ?? payload).messages;
690
+ if (Array.isArray(messages)) {
691
+ const normalized = normalizeStrictToolMessages(messages);
692
+ if (normalized !== messages) {
693
+ next ??= { ...payload };
694
+ next.messages = normalized;
695
+ }
696
+ }
697
+ }
698
+ if (modelId && GEMINI_MODEL_PATTERN.test(modelId)) {
699
+ const currentPayload = next ?? payload;
700
+ if (typeof currentPayload.reasoning_effort === "string") {
701
+ const lower = currentPayload.reasoning_effort.toLowerCase();
702
+ if (currentPayload.reasoning_effort !== lower) {
703
+ next ??= { ...payload };
704
+ next.reasoning_effort = lower;
705
+ }
706
+ }
707
+ const reasoningPayload = next ?? payload;
708
+ if (isPlainObject(reasoningPayload.reasoning) && typeof reasoningPayload.reasoning.effort === "string") {
709
+ const lower = reasoningPayload.reasoning.effort.toLowerCase();
710
+ if (reasoningPayload.reasoning.effort !== lower) {
711
+ next ??= { ...payload };
712
+ next.reasoning = {
713
+ ...next.reasoning,
714
+ effort: lower,
715
+ };
716
+ }
717
+ }
718
+ }
633
719
  if (sessionId) {
634
720
  next ??= { ...payload };
635
721
  next.litellm_session_id = sessionId;
@@ -862,7 +948,11 @@ export default async function (pi) {
862
948
  context.credential) {
863
949
  const auth = await authForCredential(definition, context.credential);
864
950
  defaultRuntimeAuth = auth;
865
- await registerMcpTools(auth, context.signal);
951
+ const registration = registerMcpTools(auth, context.signal);
952
+ if (context.signal)
953
+ await registration;
954
+ else
955
+ void registration.catch(() => undefined);
866
956
  }
867
957
  }
868
958
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,8 +65,8 @@
65
65
  },
66
66
  "devDependencies": {
67
67
  "@biomejs/biome": "^2.4.15",
68
- "@earendil-works/pi-ai": "^0.81.1",
69
- "@earendil-works/pi-coding-agent": "^0.81.1",
68
+ "@earendil-works/pi-ai": "^0.82.1",
69
+ "@earendil-works/pi-coding-agent": "^0.82.1",
70
70
  "@kitlangton/terminal-control": "0.6.0",
71
71
  "@types/node": "^26.0.0",
72
72
  "@typescript/native-preview": "7.0.0-dev.20260707.2",