pi-provider-litellm 3.3.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/README.md CHANGED
@@ -205,7 +205,7 @@ adding transport-specific fields to request bodies.
205
205
 
206
206
  ## Model transport
207
207
 
208
- A `/model/info` route group uses native Anthropic `/v1/messages` only when every deployment explicitly reports Chat mode, identifies a Claude backend through an Anthropic-, Bedrock-, or Vertex-capable adapter, and every declared backend identity resolves the same Anthropic compatibility policy. A `claude-<name>-<major>-<minor>` backend id resolves through its `claude-<name>-<major>` entry when the exact id is absent; an unknown generation stays on Chat Completions. When `supported_endpoints` is present, it must include `/v1/messages`; an explicit endpoint list without it keeps the group on Chat Completions. An unresolved routing or base-model identity denies native Messages rather than being discarded from the unanimity check. Mixed-generation Claude groups can use Messages when their serializer policies match, with reasoning levels restricted to their common supported set. Groups with incompatible policies, mixed families, or unknown backend evidence remain on Chat Completions; unanimous explicit Responses mode takes precedence. Routes discovered through `/health` never select native Messages: Messages candidates are downgraded to Chat Completions with reasoning controls closed, while correlated Responses detail remains on Responses. Evidence-free `/v1/models` and wholly health-only `/health` groups take transport and presentation metadata from the bounded Pi catalog lookup: `openai-responses` when the catalog says so, otherwise Chat Completions. They expose no speculative reasoning selector and never select native Messages. Catalog identity such as Amazon Bedrock remains separate from the selected wire protocol.
208
+ A `/model/info` route group uses native Anthropic `/v1/messages` only when every deployment explicitly reports Chat mode, identifies a Claude backend through an Anthropic-, Bedrock-, or Vertex-capable adapter, and every declared backend identity resolves the same Anthropic compatibility policy. A `claude-<name>-<major>-<minor>` backend id resolves through its `claude-<name>-<major>` entry when the exact id is absent; an unknown generation stays on Chat Completions. When `supported_endpoints` is a list, it must include `/v1/messages`; an explicit endpoint list without it keeps the group on Chat Completions. A `null` value, which LiteLLM v1.100 and later report for models absent from their model map, counts as absent. An unresolved routing or base-model identity denies native Messages rather than being discarded from the unanimity check. Mixed-generation Claude groups can use Messages when their serializer policies match, with reasoning levels restricted to their common supported set. Groups with incompatible policies, mixed families, or unknown backend evidence remain on Chat Completions; unanimous explicit Responses mode takes precedence. Routes discovered through `/health` never select native Messages: Messages candidates are downgraded to Chat Completions with reasoning controls closed, while correlated Responses detail remains on Responses. Evidence-free `/v1/models` and wholly health-only `/health` groups take transport and presentation metadata from the bounded Pi catalog lookup: `openai-responses` when the catalog says so, otherwise Chat Completions. They expose no speculative reasoning selector and never select native Messages. Catalog identity such as Amazon Bedrock remains separate from the selected wire protocol.
209
209
 
210
210
  Native Messages authenticates with `x-api-key`; every transport carries the `x-litellm-session-id` header described above. Strict JSON-schema tools are enabled separately from Messages transport and thinking compatibility: every deployment must affirm support for the actual route/API. Generic `supports_response_schema` and `supports_native_structured_output` metadata does not grant strict tools. Only deployments that LiteLLM reports with the `anthropic` adapter carry that evidence today. Claude served through Bedrock or Vertex therefore uses ordinary tool serialization, and a tool that requires strict sampling fails before the request is sent.
211
211
 
@@ -405,6 +405,8 @@ Reasoning levels require deployment evidence for the parameter Pi sends: `reason
405
405
 
406
406
  LiteLLM omits `supported_openai_params` for a deployment its model map does not describe. Such a deployment keeps the `reasoning_effort` carrier when its `model_info` explicitly sets `supports_reasoning: true`, which is the operator's opt-in. A `supported_openai_params` list that omits `reasoning_effort` still denies effort levels unless `litellm_params.allowed_openai_params` adds it. Kimi and DeepSeek generations are excluded because their contracts name their own carriers. Evidence-free fallback models retain catalog presentation metadata but expose no speculative selector.
407
407
 
408
+ GPT-5.5 and later reject `reasoning_effort` together with function tools on `/v1/chat/completions`. On a Chat route where any deployment's `model_info.base_model` or `litellm_params.model` names such a backend, tool requests turn reasoning off instead of failing or being answered by a router fallback: `reasoning_effort` is set to the model's declared off value, or removed when it declares none. GPT-6 and later fall back to their default effort when the field is omitted, so they get `none` either way. A model with no backend evidence falls back to its route name. Requests without tools keep their reasoning level. A model that cannot turn reasoning off, such as GPT-6 Astra, cannot call tools on Chat Completions at all. To keep reasoning on tool turns, serve the route through `/v1/responses` (see [Protocols and prompt caching](#protocols-and-prompt-caching)).
409
+
408
410
  Kimi K2.5/K2.6 use an on/off thinking switch; K2.7 Code/Highspeed is always thinking. Kimi K3 and DeepSeek V4 use public effort evidence with LiteLLM overrides. Responses levels are translated to valid `reasoning.effort` values; Chat-only compatibility fields never reach Responses models. Legacy cached level maps remain usable while discovery refreshes, and stored Moonshot compatibility can restore safe display normalization.
409
411
 
410
412
  The development probe runs against minimized snapshots with `npm run probe:proxy -- --snapshot tests/fixtures/proxy/prod-model-info-2026-09-04.json`. A bounded live matrix is available with `--live --base-url <proxy-url> --max-requests 100`; its request bodies use the discovered carrier and request policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-litellm",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "LiteLLM proxy provider extension for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,4 @@
1
- export const LITELLM_DISCOVERY_VERSION = 3 as const;
1
+ export const LITELLM_DISCOVERY_VERSION = 4 as const;
2
2
 
3
3
  export type BackendFamily = "claude" | "deepseek" | "gemini" | "kimi" | "openai";
4
4
 
package/src/discover.ts CHANGED
@@ -93,17 +93,31 @@ export function normalizeBaseUrl(input: string, allowInsecureHttp = false): stri
93
93
  const ANTHROPIC_MODEL_PATTERN = /(?:^|[-_/.:])(?:anthropic\/|(?:claude|fable|opus|sonnet|haiku)(?=$|[-_/.:]))/i;
94
94
  const MOONSHOT_MODEL_PATTERN = /^(moonshotai\/|moonshot\/|kimi[-/])/i;
95
95
  const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
96
- // Deployments expose the gpt-5.5 route under varying names (`llm-gateway/gpt-5.5`,
97
- // bare `gpt-5.5`, dated ids like `gpt-5.5-20260504143601`); match them all so the
98
- // tool+reasoning workaround survives route renames.
99
- const GPT55_MODEL_PATTERN = /(?:^|\/)gpt-5\.5(?:$|[-.])/i;
96
+ // GPT ids appear bare, prefixed, or dated (`gpt-6-sol`, `llm-gateway/gpt-5.5`,
97
+ // `gpt-5.5-20260504143601`); match them all so the tool+reasoning workaround
98
+ // survives renames. `gpt-4o` and `gpt-oss` carry no generation number here.
99
+ const GPT_GENERATION_PATTERN = /(?:^|\/)gpt-(\d+)(?:\.(\d+))?(?=$|[-.])/i;
100
100
 
101
101
  export function isMoonshotModel(modelId: string): boolean {
102
102
  return MOONSHOT_MODEL_PATTERN.test(modelId);
103
103
  }
104
104
 
105
- export function isGpt55Model(modelId: string): boolean {
106
- return GPT55_MODEL_PATTERN.test(modelId);
105
+ function gptGeneration(modelId: string): [major: number, minor: number] | undefined {
106
+ const match = GPT_GENERATION_PATTERN.exec(modelId);
107
+ return match ? [Number(match[1]), Number(match[2] ?? 0)] : undefined;
108
+ }
109
+
110
+ // GPT-5.5 and later reject `reasoning_effort` alongside function tools on
111
+ // /v1/chat/completions ("use /v1/responses").
112
+ export function isGpt55OrNewerModel(modelId: string): boolean {
113
+ const generation = gptGeneration(modelId);
114
+ return generation !== undefined && (generation[0] > 5 || (generation[0] === 5 && generation[1] >= 5));
115
+ }
116
+
117
+ // GPT-6 also rejects an omitted effort there: it falls back to its default effort.
118
+ export function isGpt6OrNewerModel(modelId: string): boolean {
119
+ const generation = gptGeneration(modelId);
120
+ return generation !== undefined && generation[0] >= 6;
107
121
  }
108
122
 
109
123
  function shouldSuppressReasoningContent(modelId: string): boolean {
@@ -736,13 +750,29 @@ function mapFromModelInfoGroup(
736
750
  !hasConflictingFamily &&
737
751
  (reduced.deploymentFamilies.includes("kimi") || (unlabeled && isMoonshotModel(reduced.id)));
738
752
  if (moonshotEvidence && !unanimousMoonshot) options.withheldRepairRoutes?.push(reduced.id);
739
- const modelPolicy = requestPolicy(
753
+ const familyPolicy = requestPolicy(
740
754
  reduced.id,
741
755
  reduced.deploymentFamilies,
742
756
  reduced.normalizeThinkTags,
743
757
  reduced.suppressReasoningVisibility,
744
758
  unanimousMoonshot,
745
759
  );
760
+ // One GPT-5.5+ deployment is enough: a tool request routed to it fails with
761
+ // reasoning attached, while dropping reasoning elsewhere only loses effort.
762
+ const chatBackendIds =
763
+ api === "openai-completions"
764
+ ? entries.flatMap((entry) => resolveBackendIdentity({ ...entry, model_name: undefined })?.modelId ?? [])
765
+ : [];
766
+ const modelPolicy: LiteLLMModelPolicy | undefined = chatBackendIds.some(isGpt55OrNewerModel)
767
+ ? {
768
+ normalizeStrictToolMessages: false,
769
+ normalizeThinkTags: false,
770
+ suppressReasoningVisibility: false,
771
+ ...familyPolicy,
772
+ dropToolReasoning: true,
773
+ ...(chatBackendIds.some(isGpt6OrNewerModel) ? { explicitToolReasoningOff: true } : {}),
774
+ }
775
+ : familyPolicy;
746
776
  return {
747
777
  id: reduced.id,
748
778
  // Reduced groups never borrow the ` (no metadata)` sentinel, which authorizes
@@ -1008,6 +1038,10 @@ function applyWildcardEvidence(
1008
1038
  ...(policies.every((policy) => policy.normalizeGeminiReasoningEffort)
1009
1039
  ? { normalizeGeminiReasoningEffort: true as const }
1010
1040
  : {}),
1041
+ ...(policies.some((policy) => policy.dropToolReasoning) ? { dropToolReasoning: true as const } : {}),
1042
+ ...(policies.some((policy) => policy.explicitToolReasoningOff)
1043
+ ? { explicitToolReasoningOff: true as const }
1044
+ : {}),
1011
1045
  }
1012
1046
  : undefined;
1013
1047
  const {
@@ -1062,6 +1096,12 @@ function mapFromWildcardExpansion(
1062
1096
  ...(modelPolicies.every((policy) => policy?.normalizeGeminiReasoningEffort === true)
1063
1097
  ? { normalizeGeminiReasoningEffort: true as const }
1064
1098
  : {}),
1099
+ ...(api === "openai-completions" && modelPolicies.some((policy) => policy?.dropToolReasoning === true)
1100
+ ? { dropToolReasoning: true as const }
1101
+ : {}),
1102
+ ...(api === "openai-completions" && modelPolicies.some((policy) => policy?.explicitToolReasoningOff === true)
1103
+ ? { explicitToolReasoningOff: true as const }
1104
+ : {}),
1065
1105
  }
1066
1106
  : !hasFamilyEvidence && isMoonshotModel(id)
1067
1107
  ? moonshotPolicy(id)
@@ -1329,7 +1369,26 @@ export function restoreCachedModelPolicy(model: Model<Api>): Model<Api> {
1329
1369
  // re-proven offline, so fail closed until the forced network refresh below.
1330
1370
  restored = { ...cached, compat: Object.keys(compat).length > 0 ? compat : undefined };
1331
1371
  }
1332
- if (restored.litellmPolicy || !hasMoonshotCompatEvidence(restored.compat)) return restored;
1372
+ if (restored.litellmPolicy) return restored;
1373
+ // The cache no longer holds the deployment rows that prove a GPT-5.5+ backend, so an
1374
+ // OpenAI-family Chat entry fails toward the tool-reasoning workaround until refreshed.
1375
+ if (
1376
+ restored.api === "openai-completions" &&
1377
+ restored.litellmBackendFamily === "openai" &&
1378
+ isGpt55OrNewerModel(restored.id)
1379
+ ) {
1380
+ return {
1381
+ ...restored,
1382
+ litellmPolicy: {
1383
+ normalizeStrictToolMessages: false,
1384
+ normalizeThinkTags: false,
1385
+ suppressReasoningVisibility: false,
1386
+ dropToolReasoning: true,
1387
+ ...(isGpt6OrNewerModel(restored.id) ? { explicitToolReasoningOff: true } : {}),
1388
+ },
1389
+ } as LiteLLMModel;
1390
+ }
1391
+ if (!hasMoonshotCompatEvidence(restored.compat)) return restored;
1333
1392
  return { ...restored, litellmPolicy: moonshotPolicy(restored.id) } as LiteLLMModel;
1334
1393
  }
1335
1394
 
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import { Type } from "@earendil-works/pi-ai";
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
  import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
21
21
  import { setupLiteLLMCostTracking } from "./cost.js";
22
- import { discoverModels, isGpt55Model, normalizeBaseUrl } from "./discover.js";
22
+ import { discoverModels, isGpt6OrNewerModel, isGpt55OrNewerModel, normalizeBaseUrl } from "./discover.js";
23
23
  import { getGcloudToken, hasGcloudAdcCredentials, isGcloudTokenAuthEnabled } from "./gcloud-token.js";
24
24
  import {
25
25
  createMcpToolDefinitions,
@@ -1360,7 +1360,8 @@ async function refreshWithBackoff(
1360
1360
  return credentials;
1361
1361
  }
1362
1362
  pkceTransientRefreshBackoff.delete(credentials.refresh);
1363
- if (!result.ok) throw new Error(`${result.message}; run /login litellm again`);
1363
+ // A transient failure leaves the stored credential intact, and a new login would need the same endpoint.
1364
+ if (!result.ok) throw new Error(result.transient ? result.message : `${result.message}; run /login litellm again`);
1364
1365
  return { ...credentials, ...result.token };
1365
1366
  }
1366
1367
 
@@ -1713,12 +1714,13 @@ export function prepareLiteLLMRequestPayload(
1713
1714
  for (const [key, value] of Object.entries(REASONING_VISIBILITY_DEFAULTS)) update(key, value);
1714
1715
  }
1715
1716
 
1716
- // LiteLLM still routes gpt-5.5 tool+reasoning requests through chat completions.
1717
- // Drop reasoning until the gateway honors /v1/responses for this route.
1717
+ // GPT-5.5 and later reject reasoning alongside function tools on Chat Completions.
1718
+ // Drop it until the gateway serves /v1/responses for the route. Discovery decides
1719
+ // from deployment evidence; only a model with no backend evidence uses its route name.
1720
+ const routeOnlyId = model?.litellmBackendFamily === undefined ? modelId : undefined;
1718
1721
  if (
1719
1722
  openAIApi === "openai-completions" &&
1720
- modelId &&
1721
- isGpt55Model(modelId) &&
1723
+ (modelPolicy?.dropToolReasoning === true || (routeOnlyId !== undefined && isGpt55OrNewerModel(routeOnlyId))) &&
1722
1724
  Array.isArray(payload.tools) &&
1723
1725
  payload.tools.length > 0
1724
1726
  ) {
@@ -1727,6 +1729,17 @@ export function prepareLiteLLMRequestPayload(
1727
1729
  next ??= { ...payload };
1728
1730
  delete next[key];
1729
1731
  }
1732
+ // Send the model's own off effort when it declares one. GPT-6 also rejects an
1733
+ // omitted effort (it falls back to its default), and its error names `none` as
1734
+ // the fix, so it gets `none` even without carrier evidence for an off level.
1735
+ const declaredOff = model?.thinkingLevelMap?.off;
1736
+ const explicitOff =
1737
+ modelPolicy?.explicitToolReasoningOff === true || (routeOnlyId !== undefined && isGpt6OrNewerModel(routeOnlyId));
1738
+ const off = typeof declaredOff === "string" ? declaredOff : explicitOff ? "none" : undefined;
1739
+ if (off !== undefined && (next ?? payload).reasoning_effort !== off) {
1740
+ next ??= { ...payload };
1741
+ next.reasoning_effort = off;
1742
+ }
1730
1743
  const include = (next ?? payload).include;
1731
1744
  if (Array.isArray(include) && include.includes("reasoning.encrypted_content")) {
1732
1745
  next ??= { ...payload };
@@ -798,9 +798,12 @@ export function reduceModelGroup(
798
798
  // Strict tools require affirmative evidence from every routable deployment.
799
799
  const messagesStrictTools =
800
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"
package/src/types.ts CHANGED
@@ -28,6 +28,14 @@ export interface LiteLLMModelPolicy {
28
28
  // Gemini-backed deployments require lowercase effort values. Discovery carries
29
29
  // this evidence so the request hook does not infer a backend from the route name.
30
30
  normalizeGeminiReasoningEffort?: boolean;
31
+ // GPT-5.5 and later reject `reasoning_effort` alongside function tools on
32
+ // /v1/chat/completions. Discovery sets this for Chat routes from deployment
33
+ // evidence so tool requests drop reasoning instead of failing over.
34
+ dropToolReasoning?: boolean;
35
+ // GPT-6 falls back to its default effort when `reasoning_effort` is omitted, and
36
+ // that is rejected alongside tools too, so these tool requests send `none` unless
37
+ // the model declares its own off effort.
38
+ explicitToolReasoningOff?: boolean;
31
39
  }
32
40
 
33
41
  export type DiscoveredModelFor<TApi extends LiteLLMApi> = Omit<Model<TApi>, "provider" | "baseUrl"> & {
@@ -86,7 +94,7 @@ export interface ModelInfoEntry {
86
94
  mode?: string | null;
87
95
  base_model?: string;
88
96
  litellm_provider?: string;
89
- supported_endpoints?: string[];
97
+ supported_endpoints?: string[] | null;
90
98
  supported_openai_params?: string[];
91
99
  input_cost_per_token?: number;
92
100
  output_cost_per_token?: number;