@oh-my-pi/pi-catalog 17.0.2 → 17.0.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.4] - 2026-07-18
6
+
7
+ ### Changed
8
+
9
+ - Kimi-family models now use MFJS tool schema on all hosts, including proxies like OpenRouter that forward schemas to Moonshot
10
+
11
+ ## [17.0.3] - 2026-07-17
12
+
13
+ ### Fixed
14
+
15
+ - Logged LiteLLM rich-metadata endpoint failures once with their endpoint and status before falling back to incomplete `/v1/models` data ([#5801](https://github.com/can1357/oh-my-pi/issues/5801)).
16
+ - Fixed authenticated Kimi Code discovery to preserve live effort levels, default effort, mandatory-thinking state, and per-model protocol metadata ([#5893](https://github.com/can1357/oh-my-pi/issues/5893)).
17
+ - Fixed LiteLLM provider ignoring per-model pricing: `mapLiteLLMRichEntry` now reads `input_cost_per_token` / `output_cost_per_token` (plus cache costs) from LiteLLM rich metadata and maps them to `cost.input` / `cost.output`, falling back to the bundled reference only when LiteLLM omits cost, so proxied models no longer display as free ([#5818](https://github.com/can1357/oh-my-pi/issues/5818)).
18
+
5
19
  ## [17.0.2] - 2026-07-17
6
20
 
7
21
  ### Changed
@@ -119,7 +119,7 @@ export interface Usage {
119
119
  total: number;
120
120
  };
121
121
  }
122
- export type OpenAIReasoningFormat = "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
122
+ export type OpenAIReasoningFormat = "openai" | "openrouter" | "zai" | "kimi" | "qwen" | "qwen-chat-template";
123
123
  export type OpenAIReasoningDisableMode = "omit" | "lowest-effort" | "openrouter-enabled-false" | "zai-thinking-disabled" | "qwen-enable-thinking-false" | "qwen-template-false";
124
124
  export type OpenAIStreamMarkupHealingPattern = "kimi" | "dsml" | "thinking";
125
125
  /**
@@ -167,8 +167,10 @@ export interface OpenAICompat {
167
167
  requiresThinkingAsText?: boolean;
168
168
  /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
169
169
  requiresMistralToolIds?: boolean;
170
- /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" | "disabled" } (also used by Moonshot Kimi), "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
170
+ /** Format for reasoning/thinking parameter. `"kimi"` uses `thinking: { type, effort }`; other values select their provider-native reasoning fields. Default: `"openai"`. */
171
171
  thinkingFormat?: OpenAIReasoningFormat;
172
+ /** Kimi Code transport selected by live per-model protocol metadata. User settings take precedence. */
173
+ kimiApiFormat?: "openai" | "anthropic";
172
174
  /** Request-time disable encoding for the selected reasoning/thinking format. Default: derived from `thinkingFormat`. */
173
175
  reasoningDisableMode?: OpenAIReasoningDisableMode;
174
176
  /** Whether the provider rejects `reasoning.effort`/`reasoning_effort` even when the model reasons natively. Default: false unless reasoning effort is unsupported. */
@@ -274,8 +276,10 @@ export interface OpenAICompat {
274
276
  * normalization (collapse `const`→`enum`, infer `type` on bare enums, strip
275
277
  * unsupported validators/`prefixItems`) because Moonshot/Kimi native hosts
276
278
  * reject standard JSON Schema constructs with HTTP 400. Default:
277
- * auto-detected (`"moonshot-mfjs"` on api.moonshot.ai / api.kimi.com). Set
278
- * `"none"` to opt a custom Moonshot-compatible host out.
279
+ * auto-detected Moonshot native hosts (api.moonshot.ai / api.kimi.com)
280
+ * and Kimi-family model ids on any host, since proxies (OpenRouter, custom
281
+ * gateways) forward schemas to Moonshot verbatim. Set `"none"` to opt a
282
+ * host out.
279
283
  */
280
284
  toolSchemaFlavor?: "moonshot-mfjs" | "none";
281
285
  /**
@@ -432,6 +436,8 @@ export interface ResolvedOpenAISharedCompat {
432
436
  supportsReasoningParams: boolean;
433
437
  supportsSamplingParams: boolean;
434
438
  thinkingFormat: OpenAIReasoningFormat;
439
+ /** Kimi Code transport selected by live per-model protocol metadata. */
440
+ kimiApiFormat?: OpenAICompat["kimiApiFormat"];
435
441
  reasoningDisableMode: OpenAIReasoningDisableMode;
436
442
  omitReasoningEffort: boolean;
437
443
  includeEncryptedReasoning: boolean;
@@ -467,6 +473,8 @@ export interface ResolvedOpenAISharedCompat {
467
473
  openRouterRouting?: OpenAICompat["openRouterRouting"];
468
474
  /** Provider-specific wire model-id transform applied to the base id. */
469
475
  wireModelIdMode: "raw" | "firepass" | "fireworks" | "openrouter";
476
+ /** See {@link OpenAICompat.toolSchemaFlavor}. Read by both wire paths when converting tools. */
477
+ toolSchemaFlavor?: OpenAICompat["toolSchemaFlavor"];
470
478
  }
471
479
  /**
472
480
  * Fully-resolved chat-completions compat view: every detected default
@@ -474,14 +482,13 @@ export interface ResolvedOpenAISharedCompat {
474
482
  * `buildModel`; request handlers read fields and never detect, resolve, or
475
483
  * allocate.
476
484
  */
477
- export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "supportsSamplingParams" | "thinkingFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "supportsToolChoice" | "supportsForcedToolChoice" | "supportsNamedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "replayReasoningContent" | "qwenPreserveThinking" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "enableGeminiThinkingLoopGuard" | "whenThinking">> & {
485
+ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "supportsSamplingParams" | "thinkingFormat" | "kimiApiFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "supportsToolChoice" | "supportsForcedToolChoice" | "supportsNamedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "replayReasoningContent" | "qwenPreserveThinking" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "enableGeminiThinkingLoopGuard" | "whenThinking">> & {
478
486
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
479
487
  extraBody?: OpenAICompat["extraBody"];
480
488
  cacheControlFormat?: OpenAICompat["cacheControlFormat"];
481
489
  thinkingKeep?: OpenAICompat["thinkingKeep"];
482
490
  streamIdleTimeoutMs?: number;
483
491
  toolStrictMode: ResolvedToolStrictMode;
484
- toolSchemaFlavor?: OpenAICompat["toolSchemaFlavor"];
485
492
  /** The model sits behind Vercel AI Gateway. */
486
493
  isVercelGatewayHost: boolean;
487
494
  dropThinkingWhenReasoningEffort: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-catalog",
4
- "version": "17.0.2",
4
+ "version": "17.0.4",
5
5
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -34,12 +34,12 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@bufbuild/protobuf": "^2.12.1",
37
- "@oh-my-pi/pi-utils": "17.0.2",
37
+ "@oh-my-pi/pi-utils": "17.0.4",
38
38
  "arktype": "2.2.3",
39
39
  "zod": "^4"
40
40
  },
41
41
  "devDependencies": {
42
- "@oh-my-pi/pi-ai": "17.0.2",
42
+ "@oh-my-pi/pi-ai": "17.0.4",
43
43
  "@types/bun": "^1.3.14"
44
44
  },
45
45
  "engines": {
@@ -86,6 +86,7 @@ function resolveReasoningDisableMode(
86
86
  case "openrouter":
87
87
  return "openrouter-enabled-false";
88
88
  case "zai":
89
+ case "kimi":
89
90
  return "zai-thinking-disabled";
90
91
  case "qwen":
91
92
  return "qwen-enable-thinking-false";
@@ -460,6 +461,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
460
461
  // is rejected by NIM's `additionalProperties: false` request schema
461
462
  // (issue #2299).
462
463
  thinkingFormat,
464
+ kimiApiFormat: undefined,
463
465
  reasoningDisableMode: resolveReasoningDisableMode(thinkingFormat),
464
466
  omitReasoningEffort: false,
465
467
  includeEncryptedReasoning: true,
@@ -530,7 +532,10 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
530
532
  supportsStrictMode: detectStrictModeSupport(provider, baseUrl),
531
533
  extraBody: isDirectDeepseekReasoning ? { thinking: { type: "enabled" } } : undefined,
532
534
  toolStrictMode: isCerebras ? "all_strict" : "mixed",
533
- toolSchemaFlavor: isMoonshotNative ? "moonshot-mfjs" : undefined,
535
+ // Kimi-family ids trigger MFJS on any host, not just native base URLs:
536
+ // proxies (OpenRouter, custom gateways) forward `tools.function.parameters`
537
+ // to Moonshot verbatim, which 400s on non-MFJS constructs.
538
+ toolSchemaFlavor: isMoonshotNative || isKimiModel ? "moonshot-mfjs" : undefined,
534
539
  streamIdleTimeoutMs,
535
540
  stripDeepseekSpecialTokens:
536
541
  isDeepseekModelIdOrName(spec.id) && (provider === "nvidia" || provider === "deepseek"),
@@ -656,6 +661,9 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
656
661
  openRouterRouting: undefined,
657
662
  isOpenRouterHost: isOpenRouter,
658
663
  wireModelIdMode: isOpenRouter ? "openrouter" : "raw",
664
+ // Mirrors buildOpenAICompat: Kimi behind a Responses-capable proxy still
665
+ // lands on Moonshot's MFJS validator.
666
+ toolSchemaFlavor: isKimiModel ? "moonshot-mfjs" : undefined,
659
667
  alwaysSendMaxTokens: spec.id ? isKimiModelId(spec.id) : false,
660
668
  enableGeminiThinkingLoopGuard: modelFamilyToken(spec.id ?? "") === "gemini",
661
669
  supportsObfuscationOptOut: isOpenAIUrl || spec.provider === "openai",
@@ -7,7 +7,8 @@ import { getModelDbPath } from "@oh-my-pi/pi-utils";
7
7
  import type { Api, Model, ModelSpec } from "./types";
8
8
 
9
9
  // Rows persist ModelSpec JSON (sparse `compat`, never the resolved record);
10
- // the model manager rebuilds via `buildModel` on load. v8 invalidates Codex
10
+ // the model manager rebuilds via `buildModel` on load. v9 invalidates Kimi
11
+ // Code rows predating live effort and protocol metadata; v8 invalidated Codex
11
12
  // discovery rows predating provider-native V2 compaction metadata; v7
12
13
  // invalidated rows predating the Antigravity Gemini budget-mode migration
13
14
  // (cached specs still carrying `thinking.mode: "google-level"` and the old
@@ -15,7 +16,7 @@ import type { Api, Model, ModelSpec } from "./types";
15
16
  // unknown-limit sentinels (222222/8888); v5 invalidated rows predating
16
17
  // effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids);
17
18
  // v4 dropped the pre-efforts ThinkingConfig shape.
18
- const CACHE_SCHEMA_VERSION = 8;
19
+ const CACHE_SCHEMA_VERSION = 9;
19
20
 
20
21
  interface CacheRow {
21
22
  provider_id: string;
@@ -1,9 +1,10 @@
1
+ import * as logger from "@oh-my-pi/pi-utils/logger";
1
2
  import {
2
3
  fetchOpenAICompatibleModels,
3
4
  type OpenAICompatibleModelMapperContext,
4
5
  type OpenAICompatibleModelRecord,
5
6
  } from "../discovery/openai-compatible";
6
- import { Effort } from "../effort";
7
+ import { Effort, THINKING_EFFORTS } from "../effort";
7
8
  import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-model-id";
8
9
  import {
9
10
  isGlmVisionModelId,
@@ -2504,6 +2505,45 @@ export interface KimiCodeModelManagerConfig {
2504
2505
  fetch?: FetchImpl;
2505
2506
  }
2506
2507
 
2508
+ function mapKimiThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined {
2509
+ const raw = entry.think_efforts;
2510
+ if (!isRecord(raw) || raw.support !== true) return undefined;
2511
+ const validEfforts = raw.valid_efforts;
2512
+ if (!Array.isArray(validEfforts)) return undefined;
2513
+ const efforts = THINKING_EFFORTS.filter(effort => validEfforts.includes(effort));
2514
+ if (efforts.length === 0) return undefined;
2515
+
2516
+ const thinking: ThinkingConfig = { mode: "effort", efforts };
2517
+ if (entry.supports_thinking_type === "only") {
2518
+ thinking.requiresEffort = true;
2519
+ }
2520
+ if (typeof raw.default_effort === "string") {
2521
+ const defaultLevel = THINKING_EFFORTS.find(effort => effort === raw.default_effort);
2522
+ if (defaultLevel !== undefined && efforts.includes(defaultLevel)) {
2523
+ thinking.defaultLevel = defaultLevel;
2524
+ }
2525
+ }
2526
+ return thinking;
2527
+ }
2528
+
2529
+ function kimiSupportsReasoning(entry: OpenAICompatibleModelRecord, modelId: string): boolean {
2530
+ switch (entry.supports_thinking_type) {
2531
+ case "only":
2532
+ case "both":
2533
+ return true;
2534
+ case "no":
2535
+ return false;
2536
+ default:
2537
+ return entry.supports_reasoning === true || modelId.includes("thinking");
2538
+ }
2539
+ }
2540
+
2541
+ function mapKimiApiFormat(protocol: unknown): OpenAICompat["kimiApiFormat"] {
2542
+ if (protocol === "anthropic") return "anthropic";
2543
+ if (protocol === null) return "openai";
2544
+ return undefined;
2545
+ }
2546
+
2507
2547
  export function kimiCodeModelManagerOptions(
2508
2548
  config?: KimiCodeModelManagerConfig,
2509
2549
  ): ModelManagerOptions<"openai-completions"> {
@@ -2528,15 +2568,19 @@ export function kimiCodeModelManagerOptions(
2528
2568
  _context: OpenAICompatibleModelMapperContext<"openai-completions">,
2529
2569
  ): ModelSpec<"openai-completions"> => {
2530
2570
  const id = defaults.id;
2571
+ const reasoning = kimiSupportsReasoning(entry, id);
2572
+ const thinking = reasoning ? mapKimiThinking(entry) : undefined;
2531
2573
  return {
2532
2574
  ...defaults,
2533
2575
  name: typeof entry.display_name === "string" ? entry.display_name : defaults.name,
2534
- reasoning: entry.supports_reasoning === true || id.includes("thinking"),
2576
+ reasoning,
2535
2577
  input: entry.supports_image_in === true || id.includes("k2.5") ? ["text", "image"] : ["text"],
2536
2578
  contextWindow: typeof entry.context_length === "number" ? entry.context_length : 262144,
2537
2579
  maxTokens: 32000,
2580
+ thinking,
2538
2581
  compat: {
2539
- thinkingFormat: "zai",
2582
+ thinkingFormat: thinking ? "kimi" : "zai",
2583
+ kimiApiFormat: mapKimiApiFormat(entry.protocol),
2540
2584
  reasoningContentField: "reasoning_content",
2541
2585
  supportsDeveloperRole: false,
2542
2586
  },
@@ -3266,17 +3310,43 @@ type LiteLLMRichEndpointModel<TApi extends Api> = {
3266
3310
  hasMaxTokens: boolean;
3267
3311
  hasToolMetadata: boolean;
3268
3312
  hasSupportedOpenAIParams: boolean;
3313
+ hasCost: boolean;
3269
3314
  };
3315
+ type LiteLLMRichEndpointFailure = {
3316
+ endpoint: string;
3317
+ reason: "http-status" | "invalid-json" | "network-error";
3318
+ status?: number;
3319
+ error?: unknown;
3320
+ };
3321
+ type LiteLLMRichEndpointResult<TApi extends Api> =
3322
+ | { models: LiteLLMRichEndpointModel<TApi>[]; incompleteVisionMetadata: boolean }
3323
+ | { failure: LiteLLMRichEndpointFailure };
3270
3324
 
3271
3325
  const LITELLM_RICH_ENDPOINTS = ["/model_group/info", "/v2/model/info", "/model/info", "/v1/model/info"] as const;
3272
3326
  export const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128_000;
3273
3327
  export const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
3274
3328
  const UNKNOWN_PROXY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
3329
+ const warnedLiteLLMMetadataBases = new Set<string>();
3275
3330
  const LITELLM_UNUSABLE_SENTINEL_IDS: Record<string, true> = {
3276
3331
  "all-team-models": true,
3277
3332
  "all-proxy-models": true,
3278
3333
  "no-default-models": true,
3279
3334
  };
3335
+ function warnLiteLLMMetadataFallback(managementBaseUrl: string, failure: LiteLLMRichEndpointFailure): void {
3336
+ if (warnedLiteLLMMetadataBases.has(managementBaseUrl)) {
3337
+ return;
3338
+ }
3339
+ warnedLiteLLMMetadataBases.add(managementBaseUrl);
3340
+ logger.warn("LiteLLM rich model metadata unavailable; falling back to /v1/models", {
3341
+ endpoint: `${managementBaseUrl}${failure.endpoint}`,
3342
+ status: failure.status ?? "unavailable",
3343
+ reason: failure.reason,
3344
+ ...(failure.status === 403
3345
+ ? { requiredPermission: "Grant this LiteLLM key access to the model metadata endpoints" }
3346
+ : {}),
3347
+ ...(failure.error !== undefined ? { error: failure.error } : {}),
3348
+ });
3349
+ }
3280
3350
 
3281
3351
  export function normalizeLiteLLMManagementBaseUrl(baseUrl: string): string {
3282
3352
  const trimmed = baseUrl.trim().replace(/\/+$/g, "");
@@ -3365,6 +3435,32 @@ function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unk
3365
3435
  return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
3366
3436
  }
3367
3437
 
3438
+ /** Per-million USD cost from a `*_per_token` LiteLLM field, or `undefined` when absent/non-positive. */
3439
+ function getLiteLLMPerMillionCost(entry: LiteLLMRichModelEntry, key: string): number | undefined {
3440
+ const perToken = toNumber(getLiteLLMMetadataValue(entry, key));
3441
+ return perToken !== undefined && perToken > 0 ? perToken * 1_000_000 : undefined;
3442
+ }
3443
+
3444
+ /**
3445
+ * Map LiteLLM's per-token pricing (`input_cost_per_token`, `output_cost_per_token`,
3446
+ * cache costs) onto {@link ModelSpec.cost} in $/million tokens. Returns `undefined`
3447
+ * when LiteLLM reports neither an input nor an output price so callers keep the
3448
+ * bundled reference cost.
3449
+ */
3450
+ function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec<Api>["cost"] | undefined {
3451
+ const input = getLiteLLMPerMillionCost(entry, "input_cost_per_token");
3452
+ const output = getLiteLLMPerMillionCost(entry, "output_cost_per_token");
3453
+ if (input === undefined && output === undefined) {
3454
+ return undefined;
3455
+ }
3456
+ return {
3457
+ input: input ?? 0,
3458
+ output: output ?? 0,
3459
+ cacheRead: getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost") ?? 0,
3460
+ cacheWrite: getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost") ?? 0,
3461
+ };
3462
+ }
3463
+
3368
3464
  function getLiteLLMRichModelId(entry: LiteLLMRichModelEntry): string | undefined {
3369
3465
  return (
3370
3466
  toNonEmptyString(entry.model_group) ??
@@ -3487,7 +3583,7 @@ function mapLiteLLMRichEntry<TApi extends Api>(
3487
3583
  : (reference?.input ?? ["text"]),
3488
3584
  reasoning: typeof supportsReasoning === "boolean" ? supportsReasoning : (reference?.reasoning ?? false),
3489
3585
  thinking: reference?.thinking,
3490
- cost: reference?.cost ?? UNKNOWN_PROXY_COST,
3586
+ cost: getLiteLLMCost(entry) ?? reference?.cost ?? UNKNOWN_PROXY_COST,
3491
3587
  ...(supportsTools !== undefined ? { supportsTools } : {}),
3492
3588
  compat: compat as ModelSpec<TApi>["compat"],
3493
3589
  };
@@ -3499,7 +3595,7 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
3499
3595
  managementBaseUrl: string,
3500
3596
  runtimeBaseUrl: string,
3501
3597
  signal?: AbortSignal,
3502
- ): Promise<{ models: LiteLLMRichEndpointModel<TApi>[]; incompleteVisionMetadata: boolean } | null> {
3598
+ ): Promise<LiteLLMRichEndpointResult<TApi> | null> {
3503
3599
  const fetchImpl = discoveryFetch(options.fetch);
3504
3600
  const requestHeaders: Record<string, string> = {
3505
3601
  Accept: "application/json",
@@ -3515,17 +3611,17 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
3515
3611
  headers: requestHeaders,
3516
3612
  signal,
3517
3613
  });
3518
- } catch {
3519
- return null;
3614
+ } catch (error) {
3615
+ return { failure: { endpoint, reason: "network-error", error } };
3520
3616
  }
3521
3617
  if (!response.ok) {
3522
- return null;
3618
+ return response.status === 404 ? null : { failure: { endpoint, reason: "http-status", status: response.status } };
3523
3619
  }
3524
3620
  let payload: unknown;
3525
3621
  try {
3526
3622
  payload = await response.json();
3527
- } catch {
3528
- return null;
3623
+ } catch (error) {
3624
+ return { failure: { endpoint, reason: "invalid-json", status: response.status, error } };
3529
3625
  }
3530
3626
  const entries = extractLiteLLMRichEntries(payload);
3531
3627
  if (!entries || entries.length === 0) {
@@ -3554,6 +3650,7 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
3554
3650
  supportsFunctionCalling === false ||
3555
3651
  supportedOpenAIParams !== undefined,
3556
3652
  hasSupportedOpenAIParams: supportedOpenAIParams !== undefined,
3653
+ hasCost: getLiteLLMCost(entry) !== undefined,
3557
3654
  });
3558
3655
  }
3559
3656
  }
@@ -3576,11 +3673,27 @@ export async function fetchLiteLLMRichModels<TApi extends Api>(
3576
3673
  }
3577
3674
  const fetchModels = async (signal?: AbortSignal): Promise<ModelSpec<TApi>[] | null> => {
3578
3675
  const deduped = new Map<string, LiteLLMRichEndpointModel<TApi>>();
3676
+ let metadataFailure: LiteLLMRichEndpointFailure | undefined;
3579
3677
  for (const endpoint of LITELLM_RICH_ENDPOINTS) {
3580
3678
  const result = await fetchLiteLLMRichEndpoint(endpoint, options, managementBaseUrl, runtimeBaseUrl, signal);
3581
3679
  if (!result) {
3582
3680
  continue;
3583
3681
  }
3682
+ if ("failure" in result) {
3683
+ // A 401 is a retryable auth failure owned by the caller's auth-retry
3684
+ // path (withAuth refresh/sibling rotation in discoverLiteLLMModels);
3685
+ // recording it here would log a fallback warning on a stale first
3686
+ // credential before the refreshed retry ultimately serves rich
3687
+ // metadata. Forbidden (403) and other failures are never retried, so
3688
+ // they remain warn-worthy and keep priority.
3689
+ if (
3690
+ result.failure.status !== 401 &&
3691
+ (!metadataFailure || (metadataFailure.status !== 403 && result.failure.status === 403))
3692
+ ) {
3693
+ metadataFailure = result.failure;
3694
+ }
3695
+ continue;
3696
+ }
3584
3697
  const hadPriorModels = deduped.size > 0;
3585
3698
  for (const next of result.models) {
3586
3699
  const existing = deduped.get(next.model.id);
@@ -3600,6 +3713,7 @@ export async function fetchLiteLLMRichModels<TApi extends Api>(
3600
3713
  ? next.model.input
3601
3714
  : existing.model.input,
3602
3715
  reasoning: typeof next.supportsReasoning === "boolean" ? next.model.reasoning : existing.model.reasoning,
3716
+ cost: next.hasCost ? next.model.cost : existing.model.cost,
3603
3717
  compat: next.hasSupportedOpenAIParams ? next.model.compat : existing.model.compat,
3604
3718
  };
3605
3719
  if (next.hasToolMetadata) {
@@ -3619,6 +3733,9 @@ export async function fetchLiteLLMRichModels<TApi extends Api>(
3619
3733
  }
3620
3734
  }
3621
3735
  if (deduped.size === 0) {
3736
+ if (metadataFailure) {
3737
+ warnLiteLLMMetadataFallback(managementBaseUrl, metadataFailure);
3738
+ }
3622
3739
  return null;
3623
3740
  }
3624
3741
  return Array.from(deduped.values())
@@ -3638,13 +3755,13 @@ export function litellmModelManagerOptions(
3638
3755
  const baseUrl = config?.baseUrl ?? Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1";
3639
3756
  return {
3640
3757
  providerId: "litellm",
3641
- // rich-v4 invalidates rows cached before LiteLLM ids gained bundled
3642
- // reference fallback and before discovery continued past `/model_group/info`
3643
- // when that endpoint omitted vision metadata. Earlier versions handled
3644
- // reseller usage-suffix stripping and placeholder-only `all-team-models`
3645
- // filtering; bump the version whenever the mappers below change, or warm
3646
- // authoritative caches keep serving pre-change rows for the full TTL.
3647
- cacheProviderId: `litellm:rich-v4:${Bun.hash(baseUrl).toString(36)}`,
3758
+ // rich-v5 invalidates rows cached before rich metadata pricing was mapped.
3759
+ // Earlier versions added bundled reference fallback, continued discovery
3760
+ // past incomplete `/model_group/info`, stripped reseller usage suffixes,
3761
+ // and filtered placeholder-only `all-team-models` rows. Bump the version
3762
+ // whenever the mappers below change, or warm authoritative caches keep
3763
+ // serving pre-change rows for the full TTL.
3764
+ cacheProviderId: `litellm:rich-v5:${Bun.hash(baseUrl).toString(36)}`,
3648
3765
  // litellm is a local-only proxy and is never bundled in models.json (that
3649
3766
  // would leak the machine's localhost catalog). Prefer the proxy's richer
3650
3767
  // management metadata, then enrich ids against models.dev with the bundled
package/src/types.ts CHANGED
@@ -148,7 +148,7 @@ export interface Usage {
148
148
  };
149
149
  }
150
150
 
151
- export type OpenAIReasoningFormat = "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
151
+ export type OpenAIReasoningFormat = "openai" | "openrouter" | "zai" | "kimi" | "qwen" | "qwen-chat-template";
152
152
 
153
153
  export type OpenAIReasoningDisableMode =
154
154
  | "omit"
@@ -205,8 +205,10 @@ export interface OpenAICompat {
205
205
  requiresThinkingAsText?: boolean;
206
206
  /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
207
207
  requiresMistralToolIds?: boolean;
208
- /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" | "disabled" } (also used by Moonshot Kimi), "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
208
+ /** Format for reasoning/thinking parameter. `"kimi"` uses `thinking: { type, effort }`; other values select their provider-native reasoning fields. Default: `"openai"`. */
209
209
  thinkingFormat?: OpenAIReasoningFormat;
210
+ /** Kimi Code transport selected by live per-model protocol metadata. User settings take precedence. */
211
+ kimiApiFormat?: "openai" | "anthropic";
210
212
  /** Request-time disable encoding for the selected reasoning/thinking format. Default: derived from `thinkingFormat`. */
211
213
  reasoningDisableMode?: OpenAIReasoningDisableMode;
212
214
  /** Whether the provider rejects `reasoning.effort`/`reasoning_effort` even when the model reasons natively. Default: false unless reasoning effort is unsupported. */
@@ -312,8 +314,10 @@ export interface OpenAICompat {
312
314
  * normalization (collapse `const`→`enum`, infer `type` on bare enums, strip
313
315
  * unsupported validators/`prefixItems`) because Moonshot/Kimi native hosts
314
316
  * reject standard JSON Schema constructs with HTTP 400. Default:
315
- * auto-detected (`"moonshot-mfjs"` on api.moonshot.ai / api.kimi.com). Set
316
- * `"none"` to opt a custom Moonshot-compatible host out.
317
+ * auto-detected Moonshot native hosts (api.moonshot.ai / api.kimi.com)
318
+ * and Kimi-family model ids on any host, since proxies (OpenRouter, custom
319
+ * gateways) forward schemas to Moonshot verbatim. Set `"none"` to opt a
320
+ * host out.
317
321
  */
318
322
  toolSchemaFlavor?: "moonshot-mfjs" | "none";
319
323
  /**
@@ -475,6 +479,8 @@ export interface ResolvedOpenAISharedCompat {
475
479
  supportsReasoningParams: boolean;
476
480
  supportsSamplingParams: boolean;
477
481
  thinkingFormat: OpenAIReasoningFormat;
482
+ /** Kimi Code transport selected by live per-model protocol metadata. */
483
+ kimiApiFormat?: OpenAICompat["kimiApiFormat"];
478
484
  reasoningDisableMode: OpenAIReasoningDisableMode;
479
485
  omitReasoningEffort: boolean;
480
486
  includeEncryptedReasoning: boolean;
@@ -510,6 +516,8 @@ export interface ResolvedOpenAISharedCompat {
510
516
  openRouterRouting?: OpenAICompat["openRouterRouting"];
511
517
  /** Provider-specific wire model-id transform applied to the base id. */
512
518
  wireModelIdMode: "raw" | "firepass" | "fireworks" | "openrouter";
519
+ /** See {@link OpenAICompat.toolSchemaFlavor}. Read by both wire paths when converting tools. */
520
+ toolSchemaFlavor?: OpenAICompat["toolSchemaFlavor"];
513
521
  }
514
522
 
515
523
  /**
@@ -528,6 +536,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat &
528
536
  | "supportsReasoningParams"
529
537
  | "supportsSamplingParams"
530
538
  | "thinkingFormat"
539
+ | "kimiApiFormat"
531
540
  | "reasoningDisableMode"
532
541
  | "omitReasoningEffort"
533
542
  | "includeEncryptedReasoning"
@@ -579,7 +588,6 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat &
579
588
  thinkingKeep?: OpenAICompat["thinkingKeep"];
580
589
  streamIdleTimeoutMs?: number;
581
590
  toolStrictMode: ResolvedToolStrictMode;
582
- toolSchemaFlavor?: OpenAICompat["toolSchemaFlavor"];
583
591
  /** The model sits behind Vercel AI Gateway. */
584
592
  isVercelGatewayHost: boolean;
585
593
  dropThinkingWhenReasoningEffort: boolean;