@oh-my-pi/pi-catalog 15.10.11

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/build.d.ts +3 -0
  3. package/dist/types/compat/anthropic.d.ts +11 -0
  4. package/dist/types/compat/apply.d.ts +7 -0
  5. package/dist/types/compat/openai.d.ts +21 -0
  6. package/dist/types/discovery/antigravity.d.ts +61 -0
  7. package/dist/types/discovery/codex.d.ts +38 -0
  8. package/dist/types/discovery/cursor-gen/agent_pb.d.ts +13022 -0
  9. package/dist/types/discovery/cursor.d.ts +23 -0
  10. package/dist/types/discovery/gemini.d.ts +25 -0
  11. package/dist/types/discovery/index.d.ts +4 -0
  12. package/dist/types/discovery/openai-compatible.d.ts +72 -0
  13. package/dist/types/effort.d.ts +9 -0
  14. package/dist/types/fireworks-model-id.d.ts +10 -0
  15. package/dist/types/hosts.d.ts +128 -0
  16. package/dist/types/identity/bundled.d.ts +6 -0
  17. package/dist/types/identity/classify.d.ts +45 -0
  18. package/dist/types/identity/equivalence.d.ts +46 -0
  19. package/dist/types/identity/family.d.ts +45 -0
  20. package/dist/types/identity/id.d.ts +12 -0
  21. package/dist/types/identity/index.d.ts +9 -0
  22. package/dist/types/identity/markers.d.ts +4 -0
  23. package/dist/types/identity/priority.d.ts +1 -0
  24. package/dist/types/identity/reference.d.ts +22 -0
  25. package/dist/types/identity/selection.d.ts +20 -0
  26. package/dist/types/index.d.ts +15 -0
  27. package/dist/types/model-cache.d.ts +17 -0
  28. package/dist/types/model-manager.d.ts +64 -0
  29. package/dist/types/model-thinking.d.ts +67 -0
  30. package/dist/types/models.d.ts +12 -0
  31. package/dist/types/provider-models/bundled-references.d.ts +11 -0
  32. package/dist/types/provider-models/descriptor-types.d.ts +74 -0
  33. package/dist/types/provider-models/descriptors.d.ts +384 -0
  34. package/dist/types/provider-models/discovery-constants.d.ts +11 -0
  35. package/dist/types/provider-models/google.d.ts +27 -0
  36. package/dist/types/provider-models/index.d.ts +6 -0
  37. package/dist/types/provider-models/ollama.d.ts +9 -0
  38. package/dist/types/provider-models/openai-compat.d.ts +385 -0
  39. package/dist/types/provider-models/special.d.ts +16 -0
  40. package/dist/types/types.d.ts +405 -0
  41. package/dist/types/utils.d.ts +5 -0
  42. package/dist/types/wire/codex.d.ts +26 -0
  43. package/dist/types/wire/gemini-headers.d.ts +18 -0
  44. package/dist/types/wire/github-copilot.d.ts +18 -0
  45. package/package.json +100 -0
  46. package/src/build.ts +40 -0
  47. package/src/compat/anthropic.ts +67 -0
  48. package/src/compat/apply.ts +15 -0
  49. package/src/compat/openai.ts +365 -0
  50. package/src/discovery/antigravity.ts +261 -0
  51. package/src/discovery/codex.ts +371 -0
  52. package/src/discovery/cursor-gen/agent_pb.ts +15274 -0
  53. package/src/discovery/cursor.ts +307 -0
  54. package/src/discovery/gemini.ts +249 -0
  55. package/src/discovery/index.ts +4 -0
  56. package/src/discovery/openai-compatible.ts +224 -0
  57. package/src/effort.ts +16 -0
  58. package/src/fireworks-model-id.ts +30 -0
  59. package/src/hosts.ts +114 -0
  60. package/src/identity/bundled.ts +38 -0
  61. package/src/identity/classify.ts +141 -0
  62. package/src/identity/equivalence.ts +870 -0
  63. package/src/identity/family.ts +88 -0
  64. package/src/identity/id.ts +81 -0
  65. package/src/identity/index.ts +9 -0
  66. package/src/identity/markers.ts +49 -0
  67. package/src/identity/priority.ts +56 -0
  68. package/src/identity/reference.ts +134 -0
  69. package/src/identity/selection.ts +65 -0
  70. package/src/index.ts +15 -0
  71. package/src/model-cache.ts +132 -0
  72. package/src/model-manager.ts +472 -0
  73. package/src/model-thinking.ts +407 -0
  74. package/src/models.json +75308 -0
  75. package/src/models.json.d.ts +9 -0
  76. package/src/models.ts +64 -0
  77. package/src/provider-models/bundled-references.ts +54 -0
  78. package/src/provider-models/descriptor-types.ts +79 -0
  79. package/src/provider-models/descriptors.ts +456 -0
  80. package/src/provider-models/discovery-constants.ts +11 -0
  81. package/src/provider-models/google.ts +105 -0
  82. package/src/provider-models/index.ts +6 -0
  83. package/src/provider-models/ollama.ts +154 -0
  84. package/src/provider-models/openai-compat.ts +3106 -0
  85. package/src/provider-models/special.ts +67 -0
  86. package/src/types.ts +470 -0
  87. package/src/utils.ts +27 -0
  88. package/src/wire/codex.ts +43 -0
  89. package/src/wire/gemini-headers.ts +41 -0
  90. package/src/wire/github-copilot.ts +72 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Model-family id predicates: the shared vocabulary for "is this id a member
3
+ * of family X" checks that gate wire-level behavior across hosts (a Kimi or
4
+ * DeepSeek model keeps its quirks no matter which OpenAI-compatible proxy
5
+ * serves it). Looser per-feature heuristics (e.g. stream-markup healing)
6
+ * deliberately keep their own patterns — only provably-shared matchers live
7
+ * here.
8
+ */
9
+
10
+ import { bareModelId, isFableOrMythos, parseAnthropicModel, semverGte } from "./classify";
11
+
12
+ /** Kimi family ids in any namespace form (`moonshotai/kimi-*`, `kimi-k2.6`, `vendor/kimi.x`). */
13
+ export function isKimiModelId(modelId: string): boolean {
14
+ return modelId.includes("moonshotai/kimi") || /(^|\/)kimi[-.]/i.test(modelId);
15
+ }
16
+
17
+ /** Kimi K2.6 specifically (preserved-thinking transport on Moonshot-native hosts). */
18
+ export function isKimiK26ModelId(modelId: string): boolean {
19
+ return /(^|\/)kimi-k2\.6(?:[-:]|$)/i.test(modelId);
20
+ }
21
+
22
+ /** Claude ids in any namespace form (`claude-*`, `vendor/claude.x`). */
23
+ export function isClaudeModelId(modelId: string): boolean {
24
+ return /(^|\/)claude[-.]/i.test(modelId);
25
+ }
26
+
27
+ /** `anthropic/`-namespaced ids (aggregator catalogs like OpenRouter). */
28
+ export function isAnthropicNamespacedModelId(modelId: string): boolean {
29
+ return /(^|\/)anthropic\//i.test(modelId);
30
+ }
31
+
32
+ /** Qwen family ids (substring match — Qwen SKUs have no stable prefix shape). */
33
+ export function isQwenModelId(modelId: string): boolean {
34
+ return modelId.toLowerCase().includes("qwen");
35
+ }
36
+
37
+ /** DeepSeek family by id or display name (proxies often rename the id but keep the name). */
38
+ export function isDeepseekModelIdOrName(value: string): boolean {
39
+ return value.toLowerCase().includes("deepseek");
40
+ }
41
+
42
+ /** Xiaomi MiMo family by id or display name. */
43
+ export function isMimoModelIdOrName(value: string): boolean {
44
+ return value.toLowerCase().includes("mimo");
45
+ }
46
+
47
+ /**
48
+ * Adaptive thinking `display` is supported starting with Claude Opus 4.7 and
49
+ * the Claude Fable/Mythos 5 generation. Older adaptive-thinking models
50
+ * (Opus 4.6, Sonnet 4.6+) reject the field. Classifier-based, so dotted and
51
+ * dashed version forms both match while bare dated ids
52
+ * (`claude-opus-4-20250514` = Opus 4.0) stay excluded.
53
+ */
54
+ export function supportsAdaptiveThinkingDisplay(modelId: string): boolean {
55
+ const parsed = parseAnthropicModel(bareModelId(modelId));
56
+ if (!parsed) return false;
57
+ if (isFableOrMythos(parsed.kind)) return semverGte(parsed.version, "5");
58
+ return parsed.kind === "opus" && semverGte(parsed.version, "4.7");
59
+ }
60
+
61
+ /**
62
+ * Returns true for Anthropic models with Opus 4.7+/Fable/Mythos API restrictions:
63
+ * - Sampling parameters (temperature/top_p/top_k) return 400 error
64
+ * - Thinking content is omitted by default (needs display: "summarized")
65
+ */
66
+ export function hasOpus47ApiRestrictions(modelId: string): boolean {
67
+ const parsed = parseAnthropicModel(bareModelId(modelId));
68
+ if (!parsed) return false;
69
+ return (parsed.kind === "opus" && semverGte(parsed.version, "4.7")) || isFableOrMythos(parsed.kind);
70
+ }
71
+
72
+ /**
73
+ * Mid-conversation `role: "system"` messages (system instructions appended at
74
+ * non-first positions in the `messages` array) are supported starting with
75
+ * Claude Opus 4.8 and the Claude Fable/Mythos 5 generation. Earlier Claude
76
+ * models reject the role.
77
+ * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
78
+ */
79
+ export function supportsMidConversationSystemMessages(modelId: string): boolean {
80
+ const parsed = parseAnthropicModel(bareModelId(modelId));
81
+ if (!parsed) return false;
82
+ return (parsed.kind === "opus" && semverGte(parsed.version, "4.8")) || isFableOrMythos(parsed.kind);
83
+ }
84
+
85
+ export function isAnthropicFableOrMythosModel(modelId: string): boolean {
86
+ const parsed = parseAnthropicModel(bareModelId(modelId));
87
+ return parsed !== null && isFableOrMythos(parsed.kind);
88
+ }
@@ -0,0 +1,81 @@
1
+ const LEADING_BRACKETED_AFFIX_PATTERN = /^(?:\s*(?:\[|【)[^\]】]+(?:\]|】)\s*)+/u;
2
+ const TRAILING_BRACKETED_AFFIX_PATTERN = /(?:\s*(?:\[|【)[^\]】]+(?:\]|】)\s*)+$/u;
3
+ const MODEL_ID_SEGMENT_PATTERN = /[a-z0-9.:-]+/g;
4
+ const MODEL_FAMILY_PREFIX_PATTERN =
5
+ /^(claude|gemini|gpt|grok|glm|qwen|deepseek|kimi|mimo|doubao|ernie|gpt-oss|gemma|minimax|step|command|jamba|llama|o[1345])/i;
6
+
7
+ function normalizeModelIdWhitespace(value: string): string {
8
+ return value.trim().replace(/\s+/g, " ");
9
+ }
10
+
11
+ /** Ordering for model-like segments: longest first, ties broken lexicographically. */
12
+ function compareSegmentPreference(left: string, right: string): number {
13
+ return left.length !== right.length ? right.length - left.length : left.localeCompare(right);
14
+ }
15
+
16
+ export function getModelLikeIdSegments(modelId: string): string[] {
17
+ const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
18
+ if (!matches) return [];
19
+ const segments = new Set<string>();
20
+ for (const segment of matches) {
21
+ if (MODEL_FAMILY_PREFIX_PATTERN.test(segment) && /\d/.test(segment)) segments.add(segment);
22
+ }
23
+ return [...segments].sort(compareSegmentPreference);
24
+ }
25
+
26
+ export function getLongestModelLikeIdSegment(modelId: string): string | undefined {
27
+ const matches = normalizeModelIdWhitespace(modelId).toLowerCase().match(MODEL_ID_SEGMENT_PATTERN);
28
+ if (!matches) return undefined;
29
+ let best: string | undefined;
30
+ for (const segment of matches) {
31
+ if (
32
+ MODEL_FAMILY_PREFIX_PATTERN.test(segment) &&
33
+ /\d/.test(segment) &&
34
+ (best === undefined || compareSegmentPreference(segment, best) < 0)
35
+ ) {
36
+ best = segment;
37
+ }
38
+ }
39
+ return best;
40
+ }
41
+
42
+ function hasBracketAffixMarker(value: string): boolean {
43
+ for (let index = 0; index < value.length; index++) {
44
+ const code = value.charCodeAt(index);
45
+ if (code === 91 || code === 93 || code === 0x3010 || code === 0x3011) {
46
+ return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Strip reseller / wrapper tags that are injected as bracketed affixes around an
54
+ * upstream model id, e.g.
55
+ * "[Kiro] claude-opus-4-8" -> "claude-opus-4-8"
56
+ * "[gcli转] gemini-3.1-pro-preview [假流]" -> "gemini-3.1-pro-preview"
57
+ *
58
+ * Candidates are returned most-stripped first: both ends, then leading-only, then trailing-only.
59
+ */
60
+ export function getBracketStrippedModelIdCandidates(modelId: string): string[] {
61
+ if (!hasBracketAffixMarker(modelId)) return [];
62
+ const normalized = normalizeModelIdWhitespace(modelId);
63
+ if (!normalized) return [];
64
+
65
+ const strippedLeading = normalized.replace(LEADING_BRACKETED_AFFIX_PATTERN, "");
66
+ const withoutLeading = normalizeModelIdWhitespace(strippedLeading);
67
+ const withoutTrailing = normalizeModelIdWhitespace(normalized.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
68
+ const withoutBoth = normalizeModelIdWhitespace(strippedLeading.replace(TRAILING_BRACKETED_AFFIX_PATTERN, ""));
69
+
70
+ const candidates = new Set<string>();
71
+ for (const candidate of [withoutBoth, withoutLeading, withoutTrailing]) {
72
+ if (candidate && candidate !== normalized) {
73
+ candidates.add(candidate);
74
+ }
75
+ }
76
+ return [...candidates];
77
+ }
78
+
79
+ export function stripBracketedModelIdAffixes(modelId: string): string | undefined {
80
+ return getBracketStrippedModelIdCandidates(modelId)[0];
81
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./bundled";
2
+ export * from "./classify";
3
+ export * from "./equivalence";
4
+ export * from "./family";
5
+ export * from "./id";
6
+ export * from "./markers";
7
+ export * from "./priority";
8
+ export * from "./reference";
9
+ export * from "./selection";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Trailing-marker vocabulary shared by canonical-id resolution and
3
+ * proxy-reference lookup. A "marker" is a routing/quantization/effort suffix
4
+ * a reseller or aggregator appends to an upstream model id
5
+ * (`-thinking`, `:nitro`, `-fp8`, …) that does not change model identity.
6
+ */
7
+ const TRAILING_MARKERS = [
8
+ "thinking",
9
+ "customtools",
10
+ "high",
11
+ "low",
12
+ "medium",
13
+ "minimal",
14
+ "xhigh",
15
+ "free",
16
+ "cloud",
17
+ "exacto",
18
+ "nitro",
19
+ "original",
20
+ "optimized",
21
+ "nvfp4",
22
+ "fp8",
23
+ "fp4",
24
+ "bf16",
25
+ "int8",
26
+ "int4",
27
+ ] as const;
28
+
29
+ /**
30
+ * Markers treated as identity-preserving ONLY when recovering bundled metadata
31
+ * for a proxied model id, never during canonical-id coalescing: Perplexity's
32
+ * `sonar-pro-search` is a distinct model from `sonar-pro`, so canonical
33
+ * resolution must not strip `search`, while a proxy id like
34
+ * `claude-opus-4-6-search` should still inherit the upstream pricing/limits.
35
+ */
36
+ const REFERENCE_ONLY_TRAILING_MARKERS = ["search"] as const;
37
+
38
+ function buildTrailingMarkerPattern(markers: readonly string[]): RegExp {
39
+ return new RegExp(`[-:](?:${markers.join("|")})$`, "i");
40
+ }
41
+
42
+ /** Marker pattern used by canonical-id resolution (`search` excluded). */
43
+ export const CANONICAL_TRAILING_MARKER_PATTERN = buildTrailingMarkerPattern(TRAILING_MARKERS);
44
+
45
+ /** Marker pattern used by proxy-reference lookup (`search` included). */
46
+ export const REFERENCE_TRAILING_MARKER_PATTERN = buildTrailingMarkerPattern([
47
+ ...TRAILING_MARKERS,
48
+ ...REFERENCE_ONLY_TRAILING_MARKERS,
49
+ ]);
@@ -0,0 +1,56 @@
1
+ const DEFAULT_MODEL_PROVIDER_ORDER = [
2
+ // First-party / native account providers. Prefer these over relays when the
3
+ // same upstream model is available in more than one place.
4
+ "openai-codex",
5
+ "anthropic",
6
+ "openai",
7
+ "google-gemini-cli",
8
+ "google",
9
+ "google-vertex",
10
+ "kimi-code",
11
+ "moonshot",
12
+ "qwen-portal",
13
+ "zai",
14
+ "xai-oauth",
15
+ "xai",
16
+ "mistral",
17
+ "deepseek",
18
+ "groq",
19
+
20
+ // High-quality aggregators / hosted inference providers.
21
+ "fireworks",
22
+ "cerebras",
23
+ "openrouter",
24
+ "aimlapi",
25
+ "together",
26
+
27
+ // Generic gateways and editor/proxy providers. These are useful when picked
28
+ // explicitly, but should not win ambiguous automatic role selection.
29
+ "alibaba-coding-plan",
30
+ "google-antigravity",
31
+ "opencode-zen",
32
+ "gitlab-duo",
33
+ "opencode-go",
34
+ "kilo",
35
+ "vercel-ai-gateway",
36
+ "cloudflare-ai-gateway",
37
+ "nanogpt",
38
+ "github-copilot",
39
+ ] as const;
40
+
41
+ function addProviderRank(rank: Map<string, number>, provider: string): void {
42
+ const normalized = provider.trim().toLowerCase();
43
+ if (!normalized || rank.has(normalized)) return;
44
+ rank.set(normalized, rank.size);
45
+ }
46
+
47
+ export function buildModelProviderPriorityRank(configuredProviderOrder?: readonly string[]): Map<string, number> {
48
+ const rank = new Map<string, number>();
49
+ for (const provider of configuredProviderOrder ?? []) {
50
+ addProviderRank(rank, provider);
51
+ }
52
+ for (const provider of DEFAULT_MODEL_PROVIDER_ORDER) {
53
+ addProviderRank(rank, provider);
54
+ }
55
+ return rank;
56
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Proxy/reseller reference lookup: given a custom model id served through a
3
+ * proxy (`[Kiro] claude-opus-4-8`, `gpt-5.4:cloud`, `vendor/claude-sonnet-4-6-thinking`),
4
+ * find the bundled upstream model so missing pricing/capability metadata can be
5
+ * inherited while keeping the custom transport.
6
+ *
7
+ * Kept separate from canonical-id resolution (`./equivalence`): this lookup
8
+ * may strip `search`-style markers and prefers cache-pricing-complete
9
+ * references, both of which would be wrong for canonical coalescing.
10
+ */
11
+ import type { Api, Model } from "../types";
12
+ import { getBracketStrippedModelIdCandidates, getLongestModelLikeIdSegment, getModelLikeIdSegments } from "./id";
13
+ import { REFERENCE_TRAILING_MARKER_PATTERN } from "./markers";
14
+
15
+ export interface ModelReferenceIndex {
16
+ exact: Map<string, Model<Api>>;
17
+ suffixAlias: Map<string, Model<Api>>;
18
+ }
19
+
20
+ // Custom provider entries often front a known upstream model through a local proxy.
21
+ // Prefer the reference with the largest limits and complete cache pricing, then
22
+ // first-party OpenAI entries.
23
+ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Model<Api>): boolean {
24
+ if (!existing) return true;
25
+ if (candidate.contextWindow !== existing.contextWindow) {
26
+ return candidate.contextWindow > existing.contextWindow;
27
+ }
28
+ if (candidate.maxTokens !== existing.maxTokens) {
29
+ return candidate.maxTokens > existing.maxTokens;
30
+ }
31
+ const existingHasCachePricing = existing.cost.cacheRead > 0 || existing.cost.cacheWrite > 0;
32
+ const candidateHasCachePricing = candidate.cost.cacheRead > 0 || candidate.cost.cacheWrite > 0;
33
+ if (candidateHasCachePricing !== existingHasCachePricing) {
34
+ return candidateHasCachePricing;
35
+ }
36
+ return existing.provider !== "openai" && candidate.provider === "openai";
37
+ }
38
+
39
+ function normalizeReferenceKey(value: string): string {
40
+ return value.trim().toLowerCase();
41
+ }
42
+
43
+ /**
44
+ * Build a reference index from a model catalog (typically the bundled models).
45
+ * Pure: callers are responsible for memoizing the result.
46
+ */
47
+ export function buildModelReferenceIndex(models: Iterable<Model<Api>>): ModelReferenceIndex {
48
+ const exact = new Map<string, Model<Api>>();
49
+ for (const candidate of models) {
50
+ const key = normalizeReferenceKey(candidate.id);
51
+ if (shouldReplaceReference(exact.get(key), candidate)) {
52
+ exact.set(key, candidate);
53
+ }
54
+ }
55
+ return { exact, suffixAlias: buildSuffixAliasMap(exact) };
56
+ }
57
+
58
+ function buildSuffixAliasMap(exactReferences: ReadonlyMap<string, Model<Api>>): Map<string, Model<Api>> {
59
+ const aliases = new Map<string, Model<Api>>();
60
+ for (const reference of exactReferences.values()) {
61
+ const slashIndex = reference.id.lastIndexOf("/");
62
+ if (slashIndex === -1) {
63
+ continue;
64
+ }
65
+ const suffix = reference.id.slice(slashIndex + 1);
66
+ const alias = getLongestModelLikeIdSegment(suffix);
67
+ if (!alias) {
68
+ continue;
69
+ }
70
+ if (shouldReplaceReference(aliases.get(alias), reference)) {
71
+ aliases.set(alias, reference);
72
+ }
73
+ }
74
+ return aliases;
75
+ }
76
+
77
+ function stripReferenceTrailingMarker(candidate: string): string | undefined {
78
+ const match = REFERENCE_TRAILING_MARKER_PATTERN.exec(candidate);
79
+ return match ? candidate.slice(0, match.index) : undefined;
80
+ }
81
+
82
+ function getReferenceCandidateIds(modelId: string): string[] {
83
+ const candidates = new Set<string>();
84
+ const queue = [modelId];
85
+ for (let index = 0; index < queue.length; index += 1) {
86
+ const candidate = queue[index]?.trim();
87
+ if (!candidate || candidates.has(candidate)) continue;
88
+ candidates.add(candidate);
89
+
90
+ for (const stripped of getBracketStrippedModelIdCandidates(candidate)) {
91
+ queue.push(stripped);
92
+ }
93
+ for (const segment of getModelLikeIdSegments(candidate)) {
94
+ queue.push(segment);
95
+ }
96
+
97
+ for (const suffix of [":cloud", "-cloud"] as const) {
98
+ if (candidate.toLowerCase().endsWith(suffix)) {
99
+ queue.push(candidate.slice(0, -suffix.length));
100
+ }
101
+ }
102
+
103
+ const slashIndex = candidate.lastIndexOf("/");
104
+ if (slashIndex !== -1) {
105
+ queue.push(candidate.slice(slashIndex + 1));
106
+ }
107
+
108
+ const colonToDash = candidate.replace(/:/g, "-");
109
+ if (colonToDash !== candidate) {
110
+ queue.push(colonToDash);
111
+ }
112
+
113
+ const lowercased = candidate.toLowerCase();
114
+ if (lowercased !== candidate) {
115
+ queue.push(lowercased);
116
+ }
117
+
118
+ const strippedMarker = stripReferenceTrailingMarker(candidate);
119
+ if (strippedMarker) {
120
+ queue.push(strippedMarker);
121
+ }
122
+ }
123
+ return [...candidates];
124
+ }
125
+
126
+ /** Resolve a (possibly proxied/affixed) model id to its bundled upstream reference. */
127
+ export function resolveModelReference(modelId: string, index: ModelReferenceIndex): Model<Api> | undefined {
128
+ for (const candidate of getReferenceCandidateIds(modelId)) {
129
+ const key = normalizeReferenceKey(candidate);
130
+ const reference = index.exact.get(key) ?? index.suffixAlias.get(key);
131
+ if (reference) return reference;
132
+ }
133
+ return undefined;
134
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Canonical-variant selection: pick the preferred variant of a canonical
3
+ * model record given caller-supplied provider and candidate orderings.
4
+ */
5
+ import type { Api, Model } from "../types";
6
+ import { type CanonicalModelVariant, formatCanonicalVariantSelector } from "./equivalence";
7
+
8
+ export interface CanonicalVariantPreferences {
9
+ /** Lowercased provider id → rank (lower wins). */
10
+ providerRank: ReadonlyMap<string, number>;
11
+ /** Variant selector (`provider/id`) → candidate-list position (lower wins). */
12
+ modelOrder: ReadonlyMap<string, number>;
13
+ }
14
+
15
+ /** Selector → index map over an ordered candidate list, for `modelOrder` tiebreaks. */
16
+ export function buildCanonicalModelOrder(candidates: readonly Model<Api>[]): Map<string, number> {
17
+ const modelOrder = new Map<string, number>();
18
+ for (let index = 0; index < candidates.length; index += 1) {
19
+ modelOrder.set(formatCanonicalVariantSelector(candidates[index]!), index);
20
+ }
21
+ return modelOrder;
22
+ }
23
+
24
+ const SOURCE_RANK: Record<CanonicalModelVariant["source"], number> = {
25
+ override: 1,
26
+ bundled: 1,
27
+ heuristic: 2,
28
+ fallback: 3,
29
+ };
30
+
31
+ /**
32
+ * Pick the preferred variant. Sort order: configured provider rank →
33
+ * exact-id match → variant source (override/bundled > heuristic > fallback)
34
+ * → shorter id → candidate-list order.
35
+ */
36
+ export function resolveCanonicalVariant(
37
+ variants: readonly CanonicalModelVariant[],
38
+ preferences: CanonicalVariantPreferences,
39
+ ): CanonicalModelVariant | undefined {
40
+ if (variants.length === 0) {
41
+ return undefined;
42
+ }
43
+ const { providerRank, modelOrder } = preferences;
44
+ return [...variants].sort((left, right) => {
45
+ const leftProviderRank = providerRank.get(left.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
46
+ const rightProviderRank = providerRank.get(right.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
47
+ if (leftProviderRank !== rightProviderRank) {
48
+ return leftProviderRank - rightProviderRank;
49
+ }
50
+ const leftExact = left.model.id === left.canonicalId ? 0 : 1;
51
+ const rightExact = right.model.id === right.canonicalId ? 0 : 1;
52
+ if (leftExact !== rightExact) {
53
+ return leftExact - rightExact;
54
+ }
55
+ if (SOURCE_RANK[left.source] !== SOURCE_RANK[right.source]) {
56
+ return SOURCE_RANK[left.source] - SOURCE_RANK[right.source];
57
+ }
58
+ if (left.model.id.length !== right.model.id.length) {
59
+ return left.model.id.length - right.model.id.length;
60
+ }
61
+ const leftOrder = modelOrder.get(left.selector) ?? Number.MAX_SAFE_INTEGER;
62
+ const rightOrder = modelOrder.get(right.selector) ?? Number.MAX_SAFE_INTEGER;
63
+ return leftOrder - rightOrder;
64
+ })[0];
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./compat/openai";
2
+ export * from "./discovery";
3
+ export * from "./effort";
4
+ export * from "./fireworks-model-id";
5
+ export * from "./identity";
6
+ export * from "./model-cache";
7
+ export * from "./model-manager";
8
+ export * from "./model-thinking";
9
+ export * from "./models";
10
+ export * from "./provider-models";
11
+ export * from "./types";
12
+ export * from "./utils";
13
+ export * from "./wire/codex";
14
+ export * from "./wire/gemini-headers";
15
+ export * from "./wire/github-copilot";
@@ -0,0 +1,132 @@
1
+ /**
2
+ * SQLite-backed model cache for atomic cross-process access.
3
+ * Replaces per-provider JSON files with a single cache.db.
4
+ */
5
+ import { Database } from "bun:sqlite";
6
+ import { getModelDbPath } from "@oh-my-pi/pi-utils";
7
+ import type { Api, Model, ModelSpec } from "./types";
8
+
9
+ // Rows persist ModelSpec JSON (sparse `compat`, never the resolved record);
10
+ // the model manager rebuilds via `buildModel` on load. v4 invalidates rows
11
+ // carrying the pre-efforts ThinkingConfig shape (minLevel/maxLevel/levels).
12
+ const CACHE_SCHEMA_VERSION = 4;
13
+
14
+ interface CacheRow {
15
+ provider_id: string;
16
+ version: number;
17
+ updated_at: number;
18
+ authoritative: number;
19
+ static_fingerprint: string;
20
+ models: string;
21
+ }
22
+
23
+ interface TableInfoRow {
24
+ name: string;
25
+ }
26
+
27
+ interface CacheEntry<TApi extends Api = Api> {
28
+ models: ModelSpec<TApi>[];
29
+ fresh: boolean;
30
+ authoritative: boolean;
31
+ updatedAt: number;
32
+ /**
33
+ * Hash of the static catalog slice that was merged into `models` when this
34
+ * row was written. `resolveProviderModels` compares against the current
35
+ * static fingerprint and bypasses the static+cache re-merge when they
36
+ * match — the cache already incorporates the same static state.
37
+ */
38
+ staticFingerprint: string;
39
+ }
40
+
41
+ let sharedDb: Database | null = null;
42
+ let sharedDbPath: string | null = null;
43
+
44
+ function getDb(dbPath?: string): Database {
45
+ const resolvedPath = dbPath ?? getModelDbPath();
46
+ if (sharedDb && sharedDbPath === resolvedPath) {
47
+ return sharedDb;
48
+ }
49
+ if (sharedDb) {
50
+ sharedDb.close();
51
+ }
52
+ const db = new Database(resolvedPath, { create: true });
53
+ db.run("PRAGMA journal_mode = WAL");
54
+ db.run("PRAGMA busy_timeout = 3000");
55
+ db.run(`
56
+ CREATE TABLE IF NOT EXISTS model_cache (
57
+ provider_id TEXT PRIMARY KEY,
58
+ version INTEGER NOT NULL,
59
+ updated_at INTEGER NOT NULL,
60
+ authoritative INTEGER NOT NULL DEFAULT 0,
61
+ static_fingerprint TEXT NOT NULL DEFAULT '',
62
+ models TEXT NOT NULL
63
+ )
64
+ `);
65
+ migrateCacheSchema(db);
66
+
67
+ sharedDb = db;
68
+ sharedDbPath = resolvedPath;
69
+ return db;
70
+ }
71
+
72
+ function migrateCacheSchema(db: Database): void {
73
+ const columns = db.prepare("PRAGMA table_info(model_cache)").all() as TableInfoRow[];
74
+ if (!columns.some(column => column.name === "static_fingerprint")) {
75
+ db.run("ALTER TABLE model_cache ADD COLUMN static_fingerprint TEXT NOT NULL DEFAULT ''");
76
+ }
77
+ db.run("UPDATE model_cache SET version = ? WHERE version = 2", [CACHE_SCHEMA_VERSION]);
78
+ }
79
+
80
+ export function readModelCache<TApi extends Api>(
81
+ providerId: string,
82
+ ttlMs: number,
83
+ now: () => number,
84
+ dbPath?: string,
85
+ ): CacheEntry<TApi> | null {
86
+ try {
87
+ const db = getDb(dbPath);
88
+ const row = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?").get(providerId);
89
+ if (!row || row.version !== CACHE_SCHEMA_VERSION) {
90
+ return null;
91
+ }
92
+ const models = JSON.parse(row.models) as ModelSpec<TApi>[];
93
+ const ageMs = now() - row.updated_at;
94
+ const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
95
+ return {
96
+ models,
97
+ fresh,
98
+ authoritative: row.authoritative === 1,
99
+ updatedAt: row.updated_at,
100
+ staticFingerprint: row.static_fingerprint ?? "",
101
+ };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export function writeModelCache<TApi extends Api>(
108
+ providerId: string,
109
+ updatedAt: number,
110
+ models: Model<TApi>[],
111
+ authoritative: boolean,
112
+ staticFingerprint: string,
113
+ dbPath?: string,
114
+ ): void {
115
+ try {
116
+ const db = getDb(dbPath);
117
+ db.run(
118
+ `INSERT OR REPLACE INTO model_cache (provider_id, version, updated_at, authoritative, static_fingerprint, models)
119
+ VALUES (?, ?, ?, ?, ?, ?)`,
120
+ [
121
+ providerId,
122
+ CACHE_SCHEMA_VERSION,
123
+ updatedAt,
124
+ authoritative ? 1 : 0,
125
+ staticFingerprint,
126
+ JSON.stringify(models.map(model => ({ ...model, compat: model.compatConfig, compatConfig: undefined }))),
127
+ ],
128
+ );
129
+ } catch {
130
+ // Cache writes are best-effort; failures should not break model resolution.
131
+ }
132
+ }