@oh-my-pi/pi-catalog 17.0.4 → 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 +13 -0
- package/dist/types/discovery/codex.d.ts +2 -2
- package/dist/types/model-cache.d.ts +5 -1
- package/dist/types/provider-models/special.d.ts +1 -0
- package/dist/types/types.d.ts +27 -9
- package/package.json +3 -3
- package/src/compat/anthropic.ts +1 -0
- package/src/compat/openai.ts +53 -26
- package/src/discovery/codex.ts +2 -2
- package/src/model-cache.ts +94 -11
- package/src/model-manager.ts +72 -5
- package/src/model-thinking.ts +15 -1
- package/src/models.json +379 -137
- package/src/provider-models/special.ts +4 -2
- package/src/types.ts +27 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
5
18
|
## [17.0.4] - 2026-07-18
|
|
6
19
|
|
|
7
20
|
### Changed
|
|
@@ -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?:
|
|
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 {
|
package/dist/types/types.d.ts
CHANGED
|
@@ -272,16 +272,29 @@ export interface OpenAICompat {
|
|
|
272
272
|
supportsStrictMode?: boolean;
|
|
273
273
|
/**
|
|
274
274
|
* Tool-schema dialect the endpoint validates `tools.function.parameters`
|
|
275
|
-
* against.
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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.
|
|
283
296
|
*/
|
|
284
|
-
toolSchemaFlavor?: "moonshot-mfjs" | "none";
|
|
297
|
+
toolSchemaFlavor?: "moonshot-mfjs" | "grammar" | "none";
|
|
285
298
|
/**
|
|
286
299
|
* Stream-watchdog idle-timeout floor in ms for slow reasoning hosts.
|
|
287
300
|
* Default: auto-detected (GLM coding-plan hosts, direct DeepSeek reasoning).
|
|
@@ -379,6 +392,11 @@ export interface AnthropicCompat {
|
|
|
379
392
|
* auto-detected (Z.AI hosts).
|
|
380
393
|
*/
|
|
381
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;
|
|
382
400
|
/**
|
|
383
401
|
* Replay unsigned `thinking` blocks from prior assistant turns as native
|
|
384
402
|
* thinking instead of demoting them to text. Official Anthropic enforces
|
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.
|
|
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.
|
|
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.
|
|
42
|
+
"@oh-my-pi/pi-ai": "17.0.5",
|
|
43
43
|
"@types/bun": "^1.3.14"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
package/src/compat/anthropic.ts
CHANGED
|
@@ -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`.
|
package/src/compat/openai.ts
CHANGED
|
@@ -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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
455
|
-
// speak the z.ai binary `thinking: { type }` field.
|
|
456
|
-
//
|
|
457
|
-
//
|
|
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
|
|
@@ -535,7 +557,8 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
|
|
|
535
557
|
// Kimi-family ids trigger MFJS on any host, not just native base URLs:
|
|
536
558
|
// proxies (OpenRouter, custom gateways) forward `tools.function.parameters`
|
|
537
559
|
// to Moonshot verbatim, which 400s on non-MFJS constructs.
|
|
538
|
-
toolSchemaFlavor:
|
|
560
|
+
toolSchemaFlavor:
|
|
561
|
+
isMoonshotNative || isKimiModel ? "moonshot-mfjs" : isLocalOpenAICompatBackend ? "grammar" : undefined,
|
|
539
562
|
streamIdleTimeoutMs,
|
|
540
563
|
stripDeepseekSpecialTokens:
|
|
541
564
|
isDeepseekModelIdOrName(spec.id) && (provider === "nvidia" || provider === "deepseek"),
|
|
@@ -557,7 +580,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
|
|
|
557
580
|
if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
|
|
558
581
|
compat.omitReasoningEffort = true;
|
|
559
582
|
}
|
|
560
|
-
|
|
583
|
+
mergeModelReasoningEffortMap(compat, spec.id, isMimoReasoningEffortModel);
|
|
561
584
|
|
|
562
585
|
const whenThinkingPolicy =
|
|
563
586
|
spec.compat?.whenThinking ?? (isOpenCodeProvider && spec.reasoning ? OPENCODE_WHEN_THINKING : undefined);
|
|
@@ -570,7 +593,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
|
|
|
570
593
|
if (whenThinkingPolicy.omitReasoningEffort === undefined && !variant.supportsReasoningEffort) {
|
|
571
594
|
variant.omitReasoningEffort = true;
|
|
572
595
|
}
|
|
573
|
-
|
|
596
|
+
mergeModelReasoningEffortMap(variant, spec.id, isMimoReasoningEffortModel);
|
|
574
597
|
compat.whenThinking = variant;
|
|
575
598
|
}
|
|
576
599
|
|
|
@@ -606,9 +629,13 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
|
|
|
606
629
|
const isAnthropicModel = id ? isClaudeModelId(id) || isAnthropicNamespacedModelId(id) : false;
|
|
607
630
|
const isDeepseekFamily = id ? isDeepseekModelIdOrName(id) || isDeepseekModelIdOrName(spec.name) : false;
|
|
608
631
|
const reasoningCapable = Boolean(spec.reasoning);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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);
|
|
612
639
|
|
|
613
640
|
const compat: ResolvedOpenAIResponsesCompat = {
|
|
614
641
|
supportsDeveloperRole: isAzure || isOpenAIUrl || hostMatchesUrl(baseUrl, "githubCopilot"),
|
|
@@ -675,7 +702,7 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
|
|
|
675
702
|
emptyLengthFinishIsContextError: spec.provider === "ollama",
|
|
676
703
|
usesOpenAIToolCallIdLimit: spec.provider === "openai",
|
|
677
704
|
promptCacheSessionHeader: spec.provider === "xai-oauth" ? "x-grok-conv-id" : undefined,
|
|
678
|
-
streamIdleTimeoutMs:
|
|
705
|
+
streamIdleTimeoutMs: isLocalServingBackend
|
|
679
706
|
? LOCAL_OPENAI_COMPAT_STREAM_IDLE_TIMEOUT_MS
|
|
680
707
|
: spec.compat?.streamIdleTimeoutMs,
|
|
681
708
|
};
|
package/src/discovery/codex.ts
CHANGED
|
@@ -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?:
|
|
72
|
+
fetchFn?: FetchImpl;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
package/src/model-cache.ts
CHANGED
|
@@ -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.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 =
|
|
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 (
|
|
158
|
-
|
|
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(
|
|
246
|
+
JSON.stringify(headerOmittedModelIds),
|
|
247
|
+
JSON.stringify(unrestorableHeaderModelIds),
|
|
248
|
+
JSON.stringify(cachedModels),
|
|
166
249
|
],
|
|
167
250
|
);
|
|
168
251
|
});
|
package/src/model-manager.ts
CHANGED
|
@@ -100,6 +100,47 @@ function passModelList<TApi extends Api>(value: unknown): Model<TApi>[] {
|
|
|
100
100
|
}
|
|
101
101
|
return out;
|
|
102
102
|
}
|
|
103
|
+
interface CachedHeaderRestoreResult<TApi extends Api> {
|
|
104
|
+
models: Model<TApi>[];
|
|
105
|
+
unresolvedModelIds: ReadonlySet<string>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Restore cache-omitted headers from the current static source.
|
|
110
|
+
*
|
|
111
|
+
* Dynamic-only header-bearing models cannot be reconstructed safely without
|
|
112
|
+
* persisting arbitrary credential values; callers must refetch them online or
|
|
113
|
+
* omit them from an offline result rather than return a broken model.
|
|
114
|
+
*/
|
|
115
|
+
function restoreCachedModelHeaders<TApi extends Api>(
|
|
116
|
+
cachedModels: readonly ModelSpec<TApi>[],
|
|
117
|
+
staticModels: readonly Model<TApi>[],
|
|
118
|
+
headerOmittedModelIds: readonly string[],
|
|
119
|
+
unrestorableHeaderModelIds: readonly string[],
|
|
120
|
+
): CachedHeaderRestoreResult<TApi> {
|
|
121
|
+
const models = passModelList<TApi>(cachedModels);
|
|
122
|
+
if (headerOmittedModelIds.length === 0) {
|
|
123
|
+
return { models, unresolvedModelIds: new Set() };
|
|
124
|
+
}
|
|
125
|
+
const omittedIds = new Set(headerOmittedModelIds);
|
|
126
|
+
const unrestorableIds = new Set(unrestorableHeaderModelIds);
|
|
127
|
+
const staticById = new Map(staticModels.map(model => [model.id, model]));
|
|
128
|
+
const unresolvedModelIds = new Set<string>();
|
|
129
|
+
const restored = models.map(model => {
|
|
130
|
+
if (!omittedIds.has(model.id)) return model;
|
|
131
|
+
if (unrestorableIds.has(model.id)) {
|
|
132
|
+
unresolvedModelIds.add(model.id);
|
|
133
|
+
return model;
|
|
134
|
+
}
|
|
135
|
+
const staticModel = staticById.get(model.id);
|
|
136
|
+
if (!staticModel?.headers) {
|
|
137
|
+
unresolvedModelIds.add(model.id);
|
|
138
|
+
return model;
|
|
139
|
+
}
|
|
140
|
+
return { ...model, headers: staticModel.headers };
|
|
141
|
+
});
|
|
142
|
+
return { models: restored, unresolvedModelIds };
|
|
143
|
+
}
|
|
103
144
|
|
|
104
145
|
/**
|
|
105
146
|
* Resolves provider models with source precedence:
|
|
@@ -119,10 +160,19 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
|
|
119
160
|
? passModelList<TApi>(options.staticModels)
|
|
120
161
|
: (getBundledModels(options.providerId as GeneratedProvider) as Model<TApi>[]);
|
|
121
162
|
const cache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
|
|
163
|
+
const restoredCache = restoreCachedModelHeaders(
|
|
164
|
+
cache?.models ?? [],
|
|
165
|
+
staticModels,
|
|
166
|
+
cache?.headerOmittedModelIds ?? [],
|
|
167
|
+
cache?.unrestorableHeaderModelIds ?? [],
|
|
168
|
+
);
|
|
169
|
+
const usableCachedModels = restoredCache.models.filter(model => !restoredCache.unresolvedModelIds.has(model.id));
|
|
170
|
+
const cacheHasUnresolvedHeaders = restoredCache.unresolvedModelIds.size > 0;
|
|
122
171
|
const dynamicModelsAuthoritative = options.dynamicModelsAuthoritative ?? false;
|
|
123
172
|
const staticFingerprint = fingerprintStatic(staticModels, dynamicModelsAuthoritative);
|
|
124
173
|
const cacheFingerprintMatches = cache?.staticFingerprint === staticFingerprint && staticFingerprint.length > 0;
|
|
125
|
-
const hasUsableFreshCache =
|
|
174
|
+
const hasUsableFreshCache =
|
|
175
|
+
(cache?.fresh ?? false) && !cacheHasUnresolvedHeaders && (!dynamicModelsAuthoritative || cacheFingerprintMatches);
|
|
126
176
|
const dynamicFetcher = options.fetchDynamicModels;
|
|
127
177
|
const hasDynamicFetcher = typeof dynamicFetcher === "function";
|
|
128
178
|
const hasAuthoritativeCache = ((cache?.authoritative ?? false) && hasUsableFreshCache) || !hasDynamicFetcher;
|
|
@@ -139,8 +189,14 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
|
|
139
189
|
// was merged in last time, the cache row IS the authoritative merge result.
|
|
140
190
|
// Re-running `mergeDynamicModels(static, cache)` would just rebuild the same
|
|
141
191
|
// objects (~800ms in the steady-state cold-start profile for `omp -p hi`).
|
|
142
|
-
if (
|
|
143
|
-
|
|
192
|
+
if (
|
|
193
|
+
!shouldFetchFromNetwork &&
|
|
194
|
+
cache?.fresh &&
|
|
195
|
+
hasAuthoritativeCache &&
|
|
196
|
+
cacheFingerprintMatches &&
|
|
197
|
+
!cacheHasUnresolvedHeaders
|
|
198
|
+
) {
|
|
199
|
+
return { models: collapseBuiltModelVariants(restoredCache.models), stale: false };
|
|
144
200
|
}
|
|
145
201
|
|
|
146
202
|
const [fetchedModelsDevModels, fetchedDynamicModels] = shouldFetchFromNetwork
|
|
@@ -153,7 +209,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
|
|
153
209
|
const cacheModels = dynamicFetchSucceeded
|
|
154
210
|
? []
|
|
155
211
|
: prepareCacheModelsForStaticMismatch(
|
|
156
|
-
|
|
212
|
+
usableCachedModels,
|
|
157
213
|
staticModels,
|
|
158
214
|
cacheFingerprintMatches,
|
|
159
215
|
options.dropCachedModelIdsOnStaticMismatch,
|
|
@@ -178,11 +234,21 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
|
|
178
234
|
true,
|
|
179
235
|
staticFingerprint,
|
|
180
236
|
dbPath,
|
|
237
|
+
staticModels,
|
|
181
238
|
);
|
|
182
239
|
} else {
|
|
183
240
|
// Dynamic fetch failed — update cache with a non-authoritative snapshot so
|
|
184
241
|
// stale state remains visible while retry backoff still applies.
|
|
185
242
|
const latestCache = readModelCache<TApi>(cacheProviderId, ttlMs, now, dbPath);
|
|
243
|
+
const latestRestoredCache = restoreCachedModelHeaders(
|
|
244
|
+
latestCache?.models ?? cache?.models ?? [],
|
|
245
|
+
staticModels,
|
|
246
|
+
latestCache?.headerOmittedModelIds ?? cache?.headerOmittedModelIds ?? [],
|
|
247
|
+
latestCache?.unrestorableHeaderModelIds ?? cache?.unrestorableHeaderModelIds ?? [],
|
|
248
|
+
);
|
|
249
|
+
const latestUsableCacheModels = latestRestoredCache.models.filter(
|
|
250
|
+
model => !latestRestoredCache.unresolvedModelIds.has(model.id),
|
|
251
|
+
);
|
|
186
252
|
writeModelCache(
|
|
187
253
|
cacheProviderId,
|
|
188
254
|
now(),
|
|
@@ -190,7 +256,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
|
|
190
256
|
mergeDynamicModels(
|
|
191
257
|
mergeModelSources(staticModels, modelsDevModels),
|
|
192
258
|
prepareCacheModelsForStaticMismatch(
|
|
193
|
-
|
|
259
|
+
latestUsableCacheModels,
|
|
194
260
|
staticModels,
|
|
195
261
|
cacheFingerprintMatches,
|
|
196
262
|
options.dropCachedModelIdsOnStaticMismatch,
|
|
@@ -200,6 +266,7 @@ export async function resolveProviderModels<TApi extends Api = Api, TModelsDevPa
|
|
|
200
266
|
false,
|
|
201
267
|
staticFingerprint,
|
|
202
268
|
dbPath,
|
|
269
|
+
staticModels,
|
|
203
270
|
);
|
|
204
271
|
}
|
|
205
272
|
}
|