@oh-my-pi/pi-catalog 17.0.3 → 17.0.5

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.5] - 2026-07-18
6
+
7
+ ### Added
8
+
9
+ - Added an Anthropic compatibility flag to allow non-official OAuth endpoints to opt into configured Claude Code fingerprint header overrides.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed a security issue where sensitive provider-defined request headers (such as API keys or credentials) were serialized in plaintext within the model cache (models.db). The cache now omits these headers, securely invalidates older cached rows, and restores or refetches them dynamically.
14
+ - Fixed OpenAI Codex discovery to respect caller-supplied fetch configurations (such as proxies or custom CAs) and correctly replace stale bundled models with the authenticated account catalog.
15
+ - Fixed stream timeouts and retry loops during long prefills on local loopback or RFC1918 backends (such as litellm proxies fronting local servers) by applying the local stream-timeout floor to these backends.
16
+ - Fixed Kimi K3 models served through generic OpenAI-compatible routes exposing unsupported reasoning efforts instead of the mandatory low/high/max scale.
17
+
18
+ ## [17.0.4] - 2026-07-18
19
+
20
+ ### Changed
21
+
22
+ - Kimi-family models now use MFJS tool schema on all hosts, including proxies like OpenRouter that forward schemas to Moonshot
23
+
5
24
  ## [17.0.3] - 2026-07-17
6
25
 
7
26
  ### Fixed
@@ -1,4 +1,4 @@
1
- import type { ModelSpec } from "../types.js";
1
+ import type { FetchImpl, ModelSpec } from "../types.js";
2
2
  /**
3
3
  * Fetch options for OpenAI Codex model discovery.
4
4
  */
@@ -18,7 +18,7 @@ export interface CodexModelDiscoveryOptions {
18
18
  /** Abort signal for network request cancellation. */
19
19
  signal?: AbortSignal;
20
20
  /** Optional fetch implementation override for tests. */
21
- fetchFn?: typeof fetch;
21
+ fetchFn?: FetchImpl;
22
22
  }
23
23
  /**
24
24
  * Normalized Codex discovery response.
@@ -4,6 +4,10 @@ interface CacheEntry<TApi extends Api = Api> {
4
4
  fresh: boolean;
5
5
  authoritative: boolean;
6
6
  updatedAt: number;
7
+ /** Model ids whose live headers were intentionally omitted from disk. */
8
+ headerOmittedModelIds: readonly string[];
9
+ /** Header-bearing model ids that cannot be rebuilt from the static source. */
10
+ unrestorableHeaderModelIds: readonly string[];
7
11
  /**
8
12
  * Hash of the static catalog slice that was merged into `models` when this
9
13
  * row was written. `resolveProviderModels` compares against the current
@@ -13,5 +17,5 @@ interface CacheEntry<TApi extends Api = Api> {
13
17
  staticFingerprint: string;
14
18
  }
15
19
  export declare function readModelCache<TApi extends Api>(providerId: string, ttlMs: number, now: () => number, dbPath?: string): CacheEntry<TApi> | null;
16
- export declare function writeModelCache<TApi extends Api>(providerId: string, updatedAt: number, models: Model<TApi>[], authoritative: boolean, staticFingerprint: string, dbPath?: string): void;
20
+ export declare function writeModelCache<TApi extends Api>(providerId: string, updatedAt: number, models: Model<TApi>[], authoritative: boolean, staticFingerprint: string, dbPath?: string, staticHeaderSources?: readonly Model<TApi>[]): void;
17
21
  export {};
@@ -5,6 +5,7 @@ export interface OpenAICodexModelManagerConfig {
5
5
  accessToken?: string;
6
6
  accountId?: string;
7
7
  clientVersion?: string;
8
+ fetch?: FetchImpl;
8
9
  }
9
10
  export declare function openaiCodexModelManagerOptions(config?: OpenAICodexModelManagerConfig): ModelManagerOptions<"openai-codex-responses">;
10
11
  export interface CursorModelManagerConfig {
@@ -272,14 +272,29 @@ export interface OpenAICompat {
272
272
  supportsStrictMode?: boolean;
273
273
  /**
274
274
  * Tool-schema dialect the endpoint validates `tools.function.parameters`
275
- * against. `"moonshot-mfjs"` triggers Moonshot Flavored JSON Schema
276
- * normalization (collapse `const`→`enum`, infer `type` on bare enums, strip
277
- * unsupported validators/`prefixItems`) because Moonshot/Kimi native hosts
278
- * reject standard JSON Schema constructs with HTTP 400. Default:
279
- * auto-detected (`"moonshot-mfjs"` on api.moonshot.ai / api.kimi.com). Set
280
- * `"none"` to opt a custom Moonshot-compatible host out.
275
+ * against.
276
+ *
277
+ * `"moonshot-mfjs"` triggers Moonshot Flavored JSON Schema normalization
278
+ * (collapse `const`→`enum`, infer `type` on bare enums, strip unsupported
279
+ * validators/`prefixItems`) because Moonshot/Kimi native hosts reject
280
+ * standard JSON Schema constructs with HTTP 400.
281
+ *
282
+ * `"grammar"` triggers grammar-sampler normalization (widen bare boolean
283
+ * `true`/`{}` subschemas in genuine subschema slots into a value-accepting
284
+ * union of primitives) because grammar-constrained backends (llama.cpp, LM
285
+ * Studio, vLLM) build a GBNF grammar from the JSON Schema and 400 with
286
+ * `Unrecognized schema: true` on a bare boolean subschema (issue #5914).
287
+ * Boolean `additionalProperties`/`unevaluatedProperties` are preserved — the
288
+ * grammar converter reads them as closed/open-object semantics, and
289
+ * `additionalProperties: false` pins the strict object shape.
290
+ *
291
+ * Default: auto-detected — `"moonshot-mfjs"` on Moonshot native hosts
292
+ * (api.moonshot.ai / api.kimi.com) and Kimi-family model ids on any host,
293
+ * since proxies (OpenRouter, custom gateways) forward schemas to Moonshot
294
+ * verbatim; `"grammar"` on local OpenAI-compatible backends. Set `"none"`
295
+ * to opt a host out.
281
296
  */
282
- toolSchemaFlavor?: "moonshot-mfjs" | "none";
297
+ toolSchemaFlavor?: "moonshot-mfjs" | "grammar" | "none";
283
298
  /**
284
299
  * Stream-watchdog idle-timeout floor in ms for slow reasoning hosts.
285
300
  * Default: auto-detected (GLM coding-plan hosts, direct DeepSeek reasoning).
@@ -377,6 +392,11 @@ export interface AnthropicCompat {
377
392
  * auto-detected (Z.AI hosts).
378
393
  */
379
394
  requiresToolResultId?: boolean;
395
+ /**
396
+ * Allow configured Claude Code fingerprint headers to replace generated
397
+ * OAuth defaults on non-official Anthropic endpoints.
398
+ */
399
+ allowAnthropicHeaderOverrides?: boolean;
380
400
  /**
381
401
  * Replay unsigned `thinking` blocks from prior assistant turns as native
382
402
  * thinking instead of demoting them to text. Official Anthropic enforces
@@ -471,6 +491,8 @@ export interface ResolvedOpenAISharedCompat {
471
491
  openRouterRouting?: OpenAICompat["openRouterRouting"];
472
492
  /** Provider-specific wire model-id transform applied to the base id. */
473
493
  wireModelIdMode: "raw" | "firepass" | "fireworks" | "openrouter";
494
+ /** See {@link OpenAICompat.toolSchemaFlavor}. Read by both wire paths when converting tools. */
495
+ toolSchemaFlavor?: OpenAICompat["toolSchemaFlavor"];
474
496
  }
475
497
  /**
476
498
  * Fully-resolved chat-completions compat view: every detected default
@@ -485,7 +507,6 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<Op
485
507
  thinkingKeep?: OpenAICompat["thinkingKeep"];
486
508
  streamIdleTimeoutMs?: number;
487
509
  toolStrictMode: ResolvedToolStrictMode;
488
- toolSchemaFlavor?: OpenAICompat["toolSchemaFlavor"];
489
510
  /** The model sits behind Vercel AI Gateway. */
490
511
  isVercelGatewayHost: boolean;
491
512
  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.3",
4
+ "version": "17.0.5",
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.3",
37
+ "@oh-my-pi/pi-utils": "17.0.5",
38
38
  "arktype": "2.2.3",
39
39
  "zod": "^4"
40
40
  },
41
41
  "devDependencies": {
42
- "@oh-my-pi/pi-ai": "17.0.3",
42
+ "@oh-my-pi/pi-ai": "17.0.5",
43
43
  "@types/bun": "^1.3.14"
44
44
  },
45
45
  "engines": {
@@ -109,6 +109,7 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res
109
109
  signingEndpoint,
110
110
  disableStrictTools: isAzure,
111
111
  disableAdaptiveThinking: false,
112
+ allowAnthropicHeaderOverrides: false,
112
113
  supportsEagerToolInputStreaming: official,
113
114
  // Long cache retention is only sent to the official API by default;
114
115
  // proxies opt in explicitly via `compat.supportsLongCacheRetention: true`.
@@ -154,14 +154,32 @@ const OPENCODE_WHEN_THINKING: NonNullable<OpenAICompat["whenThinking"]> = {
154
154
  reasoningContentField: "reasoning_content",
155
155
  };
156
156
 
157
+ const KIMI_K3_REASONING_EFFORT_MAP: NonNullable<OpenAICompat["reasoningEffortMap"]> = {
158
+ minimal: "low",
159
+ medium: "high",
160
+ xhigh: "max",
161
+ max: "max",
162
+ };
163
+
157
164
  const MIMO_REASONING_EFFORT_MAP: NonNullable<OpenAICompat["reasoningEffortMap"]> = {
158
165
  minimal: "low",
159
166
  xhigh: "high",
160
167
  };
161
168
 
162
- function mergeMimoReasoningEffortMap(compat: ResolvedOpenAISharedCompat, enabled: boolean): void {
163
- if (!enabled) return;
164
- compat.reasoningEffortMap = { ...MIMO_REASONING_EFFORT_MAP, ...compat.reasoningEffortMap };
169
+ function mergeModelReasoningEffortMap(
170
+ compat: ResolvedOpenAISharedCompat,
171
+ modelId: string,
172
+ isMimoReasoningEffortModel: boolean,
173
+ ): void {
174
+ let detected: NonNullable<OpenAICompat["reasoningEffortMap"]>;
175
+ if (isKimiK3ModelId(modelId)) {
176
+ detected = KIMI_K3_REASONING_EFFORT_MAP;
177
+ } else if (isMimoReasoningEffortModel) {
178
+ detected = MIMO_REASONING_EFFORT_MAP;
179
+ } else {
180
+ return;
181
+ }
182
+ compat.reasoningEffortMap = { ...detected, ...compat.reasoningEffortMap };
165
183
  }
166
184
 
167
185
  function detectStrictModeSupport(provider: string, baseUrl: string): boolean {
@@ -249,11 +267,10 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
249
267
  const isKimiModel = isKimiModelId(spec.id);
250
268
  const isMoonshotNative = modelMatchesHost(hostModel, "moonshotNative");
251
269
  const isMoonshotKimi = isKimiModel && isMoonshotNative;
252
- // Kimi K3 (native) always reasons via OpenAI-style `reasoning_effort: "max"`
253
- // and does NOT accept the K2.x binary `thinking: { type }` block, so it must
254
- // stay on the "openai" thinking dialect even though it is a Moonshot-native
255
- // Kimi model (#5756).
256
- const isMoonshotKimiK3 = isMoonshotKimi && isKimiK3ModelId(spec.id);
270
+ // Native Kimi K3 uses OpenAI-style `reasoning_effort` with mandatory
271
+ // low/high/max thinking, not the K2.x binary `thinking: { type }` block.
272
+ const isKimiK3 = isKimiK3ModelId(spec.id);
273
+ const isMoonshotKimiK3 = isMoonshotKimi && isKimiK3;
257
274
  const requiresEnabledThinking = isMoonshotKimi && matchesKimiK27CodeFamily(spec);
258
275
  const usesMoonshotKimiPreservedThinking = isMoonshotKimi && isKimiK26ModelId(spec.id);
259
276
  const isAnthropicModel =
@@ -306,6 +323,14 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
306
323
  const isLocalOpenAICompatBackend =
307
324
  !PROXY_OPENAI_COMPAT_PROVIDERS.has(provider) &&
308
325
  (LOCAL_OPENAI_COMPAT_PROVIDERS.has(provider) || hasLocalLoopbackBaseUrl(baseUrl));
326
+ // Stream-timeout floor applies to ANY loopback/RFC1918 backend, INCLUDING
327
+ // local proxies (litellm) excluded from `isLocalOpenAICompatBackend` above:
328
+ // widening the first-event/idle abort ceiling only helps a slow local
329
+ // upstream and never pushes an extra wire field, so the proxy carve-out (a
330
+ // `replayReasoningContent` safety measure) must not also strip the timeout
331
+ // floor. Without this, a loopback litellm fronting a cold/reprocessing
332
+ // llama-server aborts prefill at the 100s default and retry-loops (#4786).
333
+ const isLocalServingBackend = isLocalOpenAICompatBackend || hasLocalLoopbackBaseUrl(baseUrl);
309
334
 
310
335
  const useMaxTokens =
311
336
  isMistral ||
@@ -378,7 +403,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
378
403
  ? KIMI_REASONING_STREAM_IDLE_TIMEOUT_MS
379
404
  : spec.reasoning && isDirectDeepseekApi
380
405
  ? DEEPSEEK_REASONING_STREAM_IDLE_TIMEOUT_MS
381
- : isLocalOpenAICompatBackend
406
+ : isLocalServingBackend
382
407
  ? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS
383
408
  : undefined;
384
409
 
@@ -423,7 +448,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
423
448
  // OpenAI proprietary reasoning models (o-series, gpt-5+) reject explicit
424
449
  // temperature/top_p/… with a 400 on every serving host (#5606).
425
450
  supportsSamplingParams: !isOpenAISamplingRestrictedModelId(spec.id),
426
- reasoningEffortMap: isMimoReasoningEffortModel ? MIMO_REASONING_EFFORT_MAP : {},
451
+ reasoningEffortMap: {},
427
452
  supportsUsageInStreaming: !isCerebras,
428
453
  // pi-ai's thinking-loop guard is gemini-only; default the flag from the
429
454
  // family classifier so OpenAI-compat proxies serving Gemini are covered.
@@ -436,11 +461,9 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
436
461
  // every call since the family can otherwise emit very long reasoning traces
437
462
  // before the final answer.
438
463
  alwaysSendMaxTokens: isKimiModel,
439
- // Native Kimi K3 always reasons via `reasoning_effort: "max"` (never the
464
+ // Native Kimi K3 always reasons through `reasoning_effort` (never the
440
465
  // K2.x binary `thinking` block that #827's forced-tool-choice conflict is
441
- // about), so suppressing its effort would strip the mandatory `max` from
442
- // normal forced-tool turns (e.g. plan-mode `toolChoice: "required"`) and
443
- // leave K3 in an unsupported mode (#5758 review).
466
+ // about), so suppressing its effort would leave K3 in an unsupported mode.
444
467
  disableReasoningOnForcedToolChoice: (isKimiModel && !isMoonshotKimiK3) || isAnthropicModel,
445
468
  disableReasoningOnToolChoice: isDeepseekFamily && Boolean(spec.reasoning) && !isOpenRouter,
446
469
  supportsToolChoice: !isDirectDeepseekReasoning,
@@ -451,11 +474,10 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
451
474
  requiresAssistantAfterToolResult: isMistral,
452
475
  requiresThinkingAsText: isMistral,
453
476
  requiresMistralToolIds: isMistral,
454
- // Only Kimi's native hosts (Moonshot / Kimi-code, matched by `isMoonshotKimi`)
455
- // speak the z.ai binary `thinking: { type }` field. Kimi reached through
456
- // OpenAI-compatible proxies Fireworks' Fire Pass router, OpenCode's gateway,
457
- // etc. — drives reasoning via OpenAI-style `reasoning_effort`
458
- // (low|medium|high|xhigh|max|none), so those stay on the "openai" path.
477
+ // Only Kimi's native K2.x hosts (Moonshot / Kimi-code, matched by
478
+ // `isMoonshotKimi`) speak the z.ai binary `thinking: { type }` field.
479
+ // K3 and Kimi reached through OpenAI-compatible proxies drive reasoning
480
+ // via OpenAI-style `reasoning_effort`.
459
481
  // NVIDIA NIM hosts Qwen with the vLLM convention
460
482
  // (`chat_template_kwargs.enable_thinking`); top-level `enable_thinking`
461
483
  // is rejected by NIM's `additionalProperties: false` request schema
@@ -532,7 +554,11 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
532
554
  supportsStrictMode: detectStrictModeSupport(provider, baseUrl),
533
555
  extraBody: isDirectDeepseekReasoning ? { thinking: { type: "enabled" } } : undefined,
534
556
  toolStrictMode: isCerebras ? "all_strict" : "mixed",
535
- toolSchemaFlavor: isMoonshotNative ? "moonshot-mfjs" : undefined,
557
+ // Kimi-family ids trigger MFJS on any host, not just native base URLs:
558
+ // proxies (OpenRouter, custom gateways) forward `tools.function.parameters`
559
+ // to Moonshot verbatim, which 400s on non-MFJS constructs.
560
+ toolSchemaFlavor:
561
+ isMoonshotNative || isKimiModel ? "moonshot-mfjs" : isLocalOpenAICompatBackend ? "grammar" : undefined,
536
562
  streamIdleTimeoutMs,
537
563
  stripDeepseekSpecialTokens:
538
564
  isDeepseekModelIdOrName(spec.id) && (provider === "nvidia" || provider === "deepseek"),
@@ -554,7 +580,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
554
580
  if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
555
581
  compat.omitReasoningEffort = true;
556
582
  }
557
- mergeMimoReasoningEffortMap(compat, isMimoReasoningEffortModel);
583
+ mergeModelReasoningEffortMap(compat, spec.id, isMimoReasoningEffortModel);
558
584
 
559
585
  const whenThinkingPolicy =
560
586
  spec.compat?.whenThinking ?? (isOpenCodeProvider && spec.reasoning ? OPENCODE_WHEN_THINKING : undefined);
@@ -567,7 +593,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
567
593
  if (whenThinkingPolicy.omitReasoningEffort === undefined && !variant.supportsReasoningEffort) {
568
594
  variant.omitReasoningEffort = true;
569
595
  }
570
- mergeMimoReasoningEffortMap(variant, isMimoReasoningEffortModel);
596
+ mergeModelReasoningEffortMap(variant, spec.id, isMimoReasoningEffortModel);
571
597
  compat.whenThinking = variant;
572
598
  }
573
599
 
@@ -603,9 +629,13 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
603
629
  const isAnthropicModel = id ? isClaudeModelId(id) || isAnthropicNamespacedModelId(id) : false;
604
630
  const isDeepseekFamily = id ? isDeepseekModelIdOrName(id) || isDeepseekModelIdOrName(spec.name) : false;
605
631
  const reasoningCapable = Boolean(spec.reasoning);
606
- const isLocalOpenAICompatBackend =
607
- !PROXY_OPENAI_COMPAT_PROVIDERS.has(spec.provider) &&
608
- (LOCAL_OPENAI_COMPAT_PROVIDERS.has(spec.provider) || hasLocalLoopbackBaseUrl(baseUrl));
632
+ // `replayReasoningContent` is Responses-only-false, so the proxy carve-out is
633
+ // irrelevant here; the stream-timeout floor still applies to ANY loopback /
634
+ // RFC1918 backend, including local proxies (litellm), so a slow local
635
+ // upstream is not aborted at the 100s default and retry-looped (#4786).
636
+ const isLocalServingBackend =
637
+ (!PROXY_OPENAI_COMPAT_PROVIDERS.has(spec.provider) && LOCAL_OPENAI_COMPAT_PROVIDERS.has(spec.provider)) ||
638
+ hasLocalLoopbackBaseUrl(baseUrl);
609
639
 
610
640
  const compat: ResolvedOpenAIResponsesCompat = {
611
641
  supportsDeveloperRole: isAzure || isOpenAIUrl || hostMatchesUrl(baseUrl, "githubCopilot"),
@@ -658,6 +688,9 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
658
688
  openRouterRouting: undefined,
659
689
  isOpenRouterHost: isOpenRouter,
660
690
  wireModelIdMode: isOpenRouter ? "openrouter" : "raw",
691
+ // Mirrors buildOpenAICompat: Kimi behind a Responses-capable proxy still
692
+ // lands on Moonshot's MFJS validator.
693
+ toolSchemaFlavor: isKimiModel ? "moonshot-mfjs" : undefined,
661
694
  alwaysSendMaxTokens: spec.id ? isKimiModelId(spec.id) : false,
662
695
  enableGeminiThinkingLoopGuard: modelFamilyToken(spec.id ?? "") === "gemini",
663
696
  supportsObfuscationOptOut: isOpenAIUrl || spec.provider === "openai",
@@ -669,7 +702,7 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
669
702
  emptyLengthFinishIsContextError: spec.provider === "ollama",
670
703
  usesOpenAIToolCallIdLimit: spec.provider === "openai",
671
704
  promptCacheSessionHeader: spec.provider === "xai-oauth" ? "x-grok-conv-id" : undefined,
672
- streamIdleTimeoutMs: isLocalOpenAICompatBackend
705
+ streamIdleTimeoutMs: isLocalServingBackend
673
706
  ? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS
674
707
  : spec.compat?.streamIdleTimeoutMs,
675
708
  };
@@ -1,6 +1,6 @@
1
1
  import { type } from "arktype";
2
2
  import { parseKnownModel, semverEqual } from "../identity/classify";
3
- import type { ModelSpec } from "../types";
3
+ import type { FetchImpl, ModelSpec } from "../types";
4
4
  import { discoveryFetch } from "../utils";
5
5
  import { CODEX_BASE_URL, CODEX_CLIENT_VERSION, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../wire/codex";
6
6
 
@@ -69,7 +69,7 @@ export interface CodexModelDiscoveryOptions {
69
69
  /** Abort signal for network request cancellation. */
70
70
  signal?: AbortSignal;
71
71
  /** Optional fetch implementation override for tests. */
72
- fetchFn?: typeof fetch;
72
+ fetchFn?: FetchImpl;
73
73
  }
74
74
 
75
75
  /**
@@ -7,16 +7,20 @@ 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. v9 invalidates Kimi
11
- // Code rows predating live effort and protocol metadata; v8 invalidated Codex
12
- // discovery rows predating provider-native V2 compaction metadata; v7
13
- // invalidated rows predating the Antigravity Gemini budget-mode migration
14
- // (cached specs still carrying `thinking.mode: "google-level"` and the old
15
- // 3.5-flash effort routing); v6 invalidated rows that may contain the retired
16
- // unknown-limit sentinels (222222/8888); v5 invalidated rows predating
10
+ // the model manager rebuilds via `buildModel` on load. Request headers are
11
+ // intentionally omitted: arbitrary provider-defined header names can carry
12
+ // credentials. v10 deletes rows that may contain persisted headers and records
13
+ // which model ids lost headers and which cannot be rebuilt from static inputs,
14
+ // so the manager can restore the safe subset or refetch dynamic-only headers;
15
+ // v9 invalidated Kimi Code rows predating live effort and protocol metadata;
16
+ // v8 invalidated Codex discovery rows predating provider-native V2 compaction
17
+ // metadata; v7 invalidated rows predating the Antigravity Gemini budget-mode
18
+ // migration (cached specs still carrying `thinking.mode: "google-level"` and
19
+ // the old 3.5-flash effort routing); v6 invalidated rows that may contain the
20
+ // retired unknown-limit sentinels (222222/8888); v5 invalidated rows predating
17
21
  // effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids);
18
22
  // v4 dropped the pre-efforts ThinkingConfig shape.
19
- const CACHE_SCHEMA_VERSION = 9;
23
+ const CACHE_SCHEMA_VERSION = 10;
20
24
 
21
25
  interface CacheRow {
22
26
  provider_id: string;
@@ -25,6 +29,8 @@ interface CacheRow {
25
29
  authoritative: number;
26
30
  static_fingerprint: string;
27
31
  models: string;
32
+ header_omitted_model_ids: string;
33
+ unrestorable_header_model_ids: string;
28
34
  }
29
35
 
30
36
  interface TableInfoRow {
@@ -36,6 +42,10 @@ interface CacheEntry<TApi extends Api = Api> {
36
42
  fresh: boolean;
37
43
  authoritative: boolean;
38
44
  updatedAt: number;
45
+ /** Model ids whose live headers were intentionally omitted from disk. */
46
+ headerOmittedModelIds: readonly string[];
47
+ /** Header-bearing model ids that cannot be rebuilt from the static source. */
48
+ unrestorableHeaderModelIds: readonly string[];
39
49
  /**
40
50
  * Hash of the static catalog slice that was merged into `models` when this
41
51
  * row was written. `resolveProviderModels` compares against the current
@@ -53,6 +63,10 @@ function openDb(resolvedPath: string): Database {
53
63
  // Install the busy handler BEFORE any lock-taking statement. See
54
64
  // https://github.com/can1357/oh-my-pi/issues/2421.
55
65
  db.run("PRAGMA busy_timeout = 3000");
66
+ // Schema invalidation can delete rows containing credentials written by old
67
+ // versions. Overwrite deleted SQLite cells instead of leaving their bytes in
68
+ // free pages where a raw scan of models.db can still recover them (#5780).
69
+ db.run("PRAGMA secure_delete = ON");
56
70
  db.run("PRAGMA journal_mode = WAL");
57
71
  db.run(`
58
72
  CREATE TABLE IF NOT EXISTS model_cache (
@@ -61,6 +75,8 @@ function openDb(resolvedPath: string): Database {
61
75
  updated_at INTEGER NOT NULL,
62
76
  authoritative INTEGER NOT NULL DEFAULT 0,
63
77
  static_fingerprint TEXT NOT NULL DEFAULT '',
78
+ header_omitted_model_ids TEXT NOT NULL DEFAULT '[]',
79
+ unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]',
64
80
  models TEXT NOT NULL
65
81
  )
66
82
  `);
@@ -99,6 +115,12 @@ function migrateCacheSchema(db: Database): void {
99
115
  if (!columns.some(column => column.name === "static_fingerprint")) {
100
116
  db.run("ALTER TABLE model_cache ADD COLUMN static_fingerprint TEXT NOT NULL DEFAULT ''");
101
117
  }
118
+ if (!columns.some(column => column.name === "header_omitted_model_ids")) {
119
+ db.run("ALTER TABLE model_cache ADD COLUMN header_omitted_model_ids TEXT NOT NULL DEFAULT '[]'");
120
+ }
121
+ if (!columns.some(column => column.name === "unrestorable_header_model_ids")) {
122
+ db.run("ALTER TABLE model_cache ADD COLUMN unrestorable_header_model_ids TEXT NOT NULL DEFAULT '[]'");
123
+ }
102
124
  } finally {
103
125
  stmt.finalize();
104
126
  }
@@ -125,6 +147,14 @@ export function readModelCache<TApi extends Api>(
125
147
  return null;
126
148
  }
127
149
  const models = JSON.parse(row.models) as ModelSpec<TApi>[];
150
+ const parsedHeaderModelIds: unknown = JSON.parse(row.header_omitted_model_ids);
151
+ const headerOmittedModelIds = Array.isArray(parsedHeaderModelIds)
152
+ ? parsedHeaderModelIds.filter((id): id is string => typeof id === "string")
153
+ : [];
154
+ const parsedUnrestorableModelIds: unknown = JSON.parse(row.unrestorable_header_model_ids);
155
+ const unrestorableHeaderModelIds = Array.isArray(parsedUnrestorableModelIds)
156
+ ? parsedUnrestorableModelIds.filter((id): id is string => typeof id === "string")
157
+ : [];
128
158
  const ageMs = now() - row.updated_at;
129
159
  const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
130
160
  return {
@@ -132,6 +162,8 @@ export function readModelCache<TApi extends Api>(
132
162
  fresh,
133
163
  authoritative: row.authoritative === 1,
134
164
  updatedAt: row.updated_at,
165
+ headerOmittedModelIds,
166
+ unrestorableHeaderModelIds,
135
167
  staticFingerprint: row.static_fingerprint ?? "",
136
168
  };
137
169
  } finally {
@@ -143,6 +175,39 @@ export function readModelCache<TApi extends Api>(
143
175
  }
144
176
  }
145
177
 
178
+ /** Whether a live model carries at least one request header. */
179
+ function hasModelHeaders(model: Model<Api>): boolean {
180
+ const headers = model.headers;
181
+ if (!headers) return false;
182
+ for (const _key in headers) return true;
183
+ return false;
184
+ }
185
+
186
+ /**
187
+ * Project a live model to cache-safe metadata.
188
+ *
189
+ * Headers are never persisted: custom/runtime providers may use arbitrary
190
+ * credential header names, so no name-based filter can be complete. The
191
+ * separately persisted model-id list lets the manager restore matching static
192
+ * headers and reject/refetch dynamic-only cached models that need live headers.
193
+ */
194
+ function toCachedModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
195
+ const { headers: _headers, compatConfig, ...rest } = model;
196
+ return { ...rest, compat: compatConfig };
197
+ }
198
+
199
+ /** Whether two in-memory header records are byte-for-byte equivalent. */
200
+ function headersEqual(left: Record<string, string> | undefined, right: Record<string, string> | undefined): boolean {
201
+ if (!left || !right) return left === right;
202
+ for (const key in left) {
203
+ if (right[key] !== left[key]) return false;
204
+ }
205
+ for (const key in right) {
206
+ if (!(key in left)) return false;
207
+ }
208
+ return true;
209
+ }
210
+
146
211
  export function writeModelCache<TApi extends Api>(
147
212
  providerId: string,
148
213
  updatedAt: number,
@@ -150,19 +215,37 @@ export function writeModelCache<TApi extends Api>(
150
215
  authoritative: boolean,
151
216
  staticFingerprint: string,
152
217
  dbPath?: string,
218
+ staticHeaderSources: readonly Model<TApi>[] = [],
153
219
  ): void {
154
220
  try {
155
221
  withModelCacheDb(dbPath, db => {
222
+ const headerOmittedModelIds: string[] = [];
223
+ const unrestorableHeaderModelIds: string[] = [];
224
+ const cachedModels: ModelSpec<TApi>[] = [];
225
+ const staticById = new Map(staticHeaderSources.map(model => [model.id, model]));
226
+ for (const model of models) {
227
+ if (hasModelHeaders(model)) {
228
+ headerOmittedModelIds.push(model.id);
229
+ if (!headersEqual(model.headers, staticById.get(model.id)?.headers)) {
230
+ unrestorableHeaderModelIds.push(model.id);
231
+ }
232
+ }
233
+ cachedModels.push(toCachedModelSpec(model));
234
+ }
156
235
  db.run(
157
- `INSERT OR REPLACE INTO model_cache (provider_id, version, updated_at, authoritative, static_fingerprint, models)
158
- VALUES (?, ?, ?, ?, ?, ?)`,
236
+ `INSERT OR REPLACE INTO model_cache (
237
+ provider_id, version, updated_at, authoritative, static_fingerprint,
238
+ header_omitted_model_ids, unrestorable_header_model_ids, models
239
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
159
240
  [
160
241
  providerId,
161
242
  CACHE_SCHEMA_VERSION,
162
243
  updatedAt,
163
244
  authoritative ? 1 : 0,
164
245
  staticFingerprint,
165
- JSON.stringify(models.map(model => ({ ...model, compat: model.compatConfig, compatConfig: undefined }))),
246
+ JSON.stringify(headerOmittedModelIds),
247
+ JSON.stringify(unrestorableHeaderModelIds),
248
+ JSON.stringify(cachedModels),
166
249
  ],
167
250
  );
168
251
  });