@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,224 @@
1
+ import * as z from "zod/v4";
2
+ import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "../provider-models/discovery-constants";
3
+ import type { Api, FetchImpl, ModelSpec, Provider } from "../types";
4
+
5
+ const MODELS_PATH = "/models";
6
+
7
+ /**
8
+ * Minimal OpenAI-style model entry shape consumed by discovery.
9
+ *
10
+ * Providers may return additional fields; this type only captures
11
+ * fields that are useful for generic normalization.
12
+ */
13
+ export interface OpenAICompatibleModelRecord {
14
+ id?: unknown;
15
+ name?: unknown;
16
+ object?: unknown;
17
+ owned_by?: unknown;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ /**
22
+ * Tolerant envelope for OpenAI-compatible `/models` responses.
23
+ *
24
+ * Common providers return `{ data: [...] }`, but variants such as
25
+ * `{ models: [...] }`, `{ result: [...] }`, or direct arrays are also
26
+ * accepted during extraction.
27
+ */
28
+ export interface OpenAICompatibleModelsEnvelope {
29
+ data?: unknown;
30
+ models?: unknown;
31
+ result?: unknown;
32
+ items?: unknown;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ const openAICompatibleModelRecordSchema = z
37
+ .object({
38
+ id: z.string().min(1),
39
+ name: z.string().optional().nullable(),
40
+ object: z.unknown().optional(),
41
+ owned_by: z.unknown().optional(),
42
+ })
43
+ .loose();
44
+
45
+ const openAICompatibleModelsEnvelopeSchema = z
46
+ .object({
47
+ data: z.unknown().optional(),
48
+ models: z.unknown().optional(),
49
+ result: z.unknown().optional(),
50
+ items: z.unknown().optional(),
51
+ })
52
+ .loose();
53
+
54
+ const openAICompatibleModelsPayloadSchema = z.union([z.array(z.unknown()), openAICompatibleModelsEnvelopeSchema]);
55
+
56
+ type ParsedOpenAICompatibleModelRecord = z.infer<typeof openAICompatibleModelRecordSchema>;
57
+
58
+ /**
59
+ * Context passed to custom OpenAI-compatible model mappers.
60
+ */
61
+ export interface OpenAICompatibleModelMapperContext<TApi extends Api> {
62
+ api: TApi;
63
+ provider: Provider;
64
+ baseUrl: string;
65
+ }
66
+
67
+ /**
68
+ * Options for fetching and normalizing OpenAI-compatible `/models` catalogs.
69
+ */
70
+ export interface FetchOpenAICompatibleModelsOptions<TApi extends Api> {
71
+ /** API type assigned to normalized models. */
72
+ api: TApi;
73
+ /** Provider id assigned to normalized models. */
74
+ provider: Provider;
75
+ /** Provider base URL used for both fetch and normalized model records. */
76
+ baseUrl: string;
77
+ /** Optional bearer token for Authorization header. */
78
+ apiKey?: string;
79
+ /** Additional request headers. */
80
+ headers?: Record<string, string>;
81
+ /** Optional AbortSignal for request cancellation. */
82
+ signal?: AbortSignal;
83
+ /** Optional fetch implementation override for testing/custom runtimes. */
84
+ fetch?: FetchImpl;
85
+ /**
86
+ * Optional post-normalization filter.
87
+ * Return false to skip a model.
88
+ */
89
+ filterModel?: (entry: OpenAICompatibleModelRecord, model: ModelSpec<TApi>) => boolean;
90
+ /**
91
+ * Optional mapper override for provider-specific quirks.
92
+ * Return null to skip a model.
93
+ */
94
+ mapModel?: (
95
+ entry: OpenAICompatibleModelRecord,
96
+ defaults: ModelSpec<TApi>,
97
+ context: OpenAICompatibleModelMapperContext<TApi>,
98
+ ) => ModelSpec<TApi> | null;
99
+ }
100
+
101
+ /**
102
+ * Fetches and normalizes an OpenAI-compatible `/models` catalog.
103
+ *
104
+ * Returns `null` on transport/protocol failures.
105
+ * Returns `[]` only when the endpoint responds successfully with no usable models.
106
+ */
107
+ export async function fetchOpenAICompatibleModels<TApi extends Api>(
108
+ options: FetchOpenAICompatibleModelsOptions<TApi>,
109
+ ): Promise<ModelSpec<TApi>[] | null> {
110
+ const baseUrl = normalizeBaseUrl(options.baseUrl);
111
+ if (!baseUrl) {
112
+ return null;
113
+ }
114
+
115
+ const requestHeaders: Record<string, string> = {
116
+ Accept: "application/json",
117
+ ...options.headers,
118
+ };
119
+ if (options.apiKey) {
120
+ requestHeaders.Authorization = `Bearer ${options.apiKey}`;
121
+ }
122
+
123
+ const fetchImpl = options.fetch ?? globalThis.fetch;
124
+ let response: Response;
125
+ try {
126
+ response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, {
127
+ method: "GET",
128
+ headers: requestHeaders,
129
+ signal: options.signal,
130
+ });
131
+ } catch {
132
+ return null;
133
+ }
134
+
135
+ if (!response.ok) {
136
+ return null;
137
+ }
138
+
139
+ let payload: unknown;
140
+ try {
141
+ payload = await response.json();
142
+ } catch {
143
+ return null;
144
+ }
145
+
146
+ const entries = extractModelEntries(payload);
147
+ if (entries === null) {
148
+ return null;
149
+ }
150
+
151
+ const context: OpenAICompatibleModelMapperContext<TApi> = {
152
+ api: options.api,
153
+ provider: options.provider,
154
+ baseUrl,
155
+ };
156
+
157
+ const deduped = new Map<string, ModelSpec<TApi>>();
158
+ for (const entry of entries) {
159
+ const defaults: ModelSpec<TApi> = {
160
+ id: entry.id,
161
+ name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : entry.id,
162
+ api: options.api,
163
+ provider: options.provider,
164
+ baseUrl,
165
+ reasoning: false,
166
+ input: ["text"],
167
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
168
+ contextWindow: UNK_CONTEXT_WINDOW,
169
+ maxTokens: UNK_MAX_TOKENS,
170
+ };
171
+
172
+ const mapped = options.mapModel?.(entry, defaults, context) ?? defaults;
173
+ if (!mapped || typeof mapped.id !== "string" || mapped.id.length === 0) {
174
+ continue;
175
+ }
176
+ if (options.filterModel && !options.filterModel(entry, mapped)) {
177
+ continue;
178
+ }
179
+ deduped.set(mapped.id, mapped);
180
+ }
181
+
182
+ return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
183
+ }
184
+
185
+ function normalizeBaseUrl(baseUrl: string): string {
186
+ const trimmed = baseUrl.trim();
187
+ if (!trimmed) {
188
+ return "";
189
+ }
190
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
191
+ }
192
+
193
+ function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecord[] | null {
194
+ return extractModelEntriesFromNode(payload);
195
+ }
196
+
197
+ function extractModelEntriesFromNode(node: unknown): ParsedOpenAICompatibleModelRecord[] | null {
198
+ const parsedPayload = openAICompatibleModelsPayloadSchema.safeParse(node);
199
+ if (!parsedPayload.success) {
200
+ return null;
201
+ }
202
+ if (Array.isArray(parsedPayload.data)) {
203
+ const parsedEntries = parsedPayload.data
204
+ .map(entry => openAICompatibleModelRecordSchema.safeParse(entry))
205
+ .flatMap(entry => (entry.success ? [entry.data] : []));
206
+ return parsedEntries;
207
+ }
208
+ for (const candidate of [
209
+ parsedPayload.data.data,
210
+ parsedPayload.data.models,
211
+ parsedPayload.data.result,
212
+ parsedPayload.data.items,
213
+ ]) {
214
+ if (candidate === undefined) {
215
+ continue;
216
+ }
217
+ const nested = extractModelEntriesFromNode(candidate);
218
+ if (nested !== null) {
219
+ return nested;
220
+ }
221
+ }
222
+
223
+ return null;
224
+ }
package/src/effort.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** User-facing thinking levels, ordered least to most intensive. */
2
+ export const enum Effort {
3
+ Minimal = "minimal",
4
+ Low = "low",
5
+ Medium = "medium",
6
+ High = "high",
7
+ XHigh = "xhigh",
8
+ }
9
+
10
+ export const THINKING_EFFORTS: readonly Effort[] = [
11
+ Effort.Minimal,
12
+ Effort.Low,
13
+ Effort.Medium,
14
+ Effort.High,
15
+ Effort.XHigh,
16
+ ];
@@ -0,0 +1,30 @@
1
+ const FIREWORKS_WIRE_PREFIX = "accounts/fireworks/models/";
2
+ const FIREPASS_WIRE_PREFIX = "accounts/fireworks/routers/";
3
+ const VERSION_SEPARATOR_PATTERN = /(?<=\d)p(?=\d)/g;
4
+ const VERSION_DOT_PATTERN = /(?<=\d)\.(?=\d)/g;
5
+
6
+ export function toFireworksPublicModelId(modelId: string): string {
7
+ const stripped = modelId.startsWith(FIREWORKS_WIRE_PREFIX) ? modelId.slice(FIREWORKS_WIRE_PREFIX.length) : modelId;
8
+ return stripped.replace(VERSION_SEPARATOR_PATTERN, ".");
9
+ }
10
+
11
+ export function toFireworksWireModelId(modelId: string): string {
12
+ const stripped = modelId.startsWith(FIREWORKS_WIRE_PREFIX) ? modelId.slice(FIREWORKS_WIRE_PREFIX.length) : modelId;
13
+ return `${FIREWORKS_WIRE_PREFIX}${stripped.replace(VERSION_DOT_PATTERN, "p")}`;
14
+ }
15
+
16
+ /**
17
+ * Fire Pass exposes its Kimi K2.6 Turbo subscription through a dedicated router
18
+ * endpoint at `accounts/fireworks/routers/<id>` rather than the `models/` namespace.
19
+ * We keep a friendly public id (e.g. `kimi-k2.6-turbo`) in the catalog and translate
20
+ * to the wire form (`accounts/fireworks/routers/kimi-k2p6-turbo`) at request time.
21
+ */
22
+ export function toFirepassPublicModelId(modelId: string): string {
23
+ const stripped = modelId.startsWith(FIREPASS_WIRE_PREFIX) ? modelId.slice(FIREPASS_WIRE_PREFIX.length) : modelId;
24
+ return stripped.replace(VERSION_SEPARATOR_PATTERN, ".");
25
+ }
26
+
27
+ export function toFirepassWireModelId(modelId: string): string {
28
+ const stripped = modelId.startsWith(FIREPASS_WIRE_PREFIX) ? modelId.slice(FIREPASS_WIRE_PREFIX.length) : modelId;
29
+ return `${FIREPASS_WIRE_PREFIX}${stripped.replace(VERSION_DOT_PATTERN, "p")}`;
30
+ }
package/src/hosts.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Known model-endpoint host classification — the single vocabulary for the
3
+ * `provider === id || baseUrl.includes(marker)` idiom that gates wire-level
4
+ * behavior (compat detection, routing, header shaping, watchdog floors).
5
+ *
6
+ * Markers are case-insensitive substrings matched against the base URL, NOT
7
+ * parsed hostnames: proxies regularly embed the upstream host in a path
8
+ * segment, and the historical call sites all used substring semantics.
9
+ * Callers that need strict hostname matching — where a substring false
10
+ * positive is dangerous, e.g. the Anthropic official-endpoint OAuth gate —
11
+ * parse the URL and compare the hostname themselves.
12
+ */
13
+
14
+ interface HostClassSpec {
15
+ /** Provider ids that imply this host class regardless of baseUrl. */
16
+ readonly providers?: readonly string[];
17
+ /** Provider-id prefixes that imply this host class (e.g. `xiaomi-token-plan-`). */
18
+ readonly providerPrefixes?: readonly string[];
19
+ /** Case-insensitive substrings matched against the base URL. */
20
+ readonly urlMarkers: readonly string[];
21
+ // Strict hostname matching is intentionally not modeled here: the one
22
+ // auth-sensitive consumer (Anthropic official-endpoint) parses the URL
23
+ // itself; every other call site is benign and uses substring matching.
24
+ }
25
+
26
+ export const KNOWN_HOSTS = {
27
+ openai: { providers: ["openai"], urlMarkers: ["api.openai.com"] },
28
+ azureOpenAI: {
29
+ providers: ["azure"],
30
+ urlMarkers: [".openai.azure.com", "azure.com/openai", "models.inference.ai.azure.com"],
31
+ },
32
+ openrouter: { providers: ["openrouter"], urlMarkers: ["openrouter.ai"] },
33
+ vercelAIGateway: { providers: ["vercel-ai-gateway"], urlMarkers: ["ai-gateway.vercel.sh"] },
34
+ githubCopilot: { providers: ["github-copilot"], urlMarkers: ["githubcopilot.com", "copilot-api."] },
35
+ anthropic: { providers: ["anthropic"], urlMarkers: ["api.anthropic.com"] },
36
+ /** DeepSeek's first-party API only — gates direct-API quirks (max_tokens field, thinking extraBody). */
37
+ deepseekDirect: { providers: ["deepseek"], urlMarkers: ["api.deepseek.com"] },
38
+ /** Any DeepSeek-operated host (first-party API, web-chat fronts). Wider than `deepseekDirect` on purpose. */
39
+ deepseekFamily: { providers: ["deepseek"], urlMarkers: ["deepseek.com"] },
40
+ cerebras: { providers: ["cerebras"], urlMarkers: ["cerebras.ai"] },
41
+ zai: { providers: ["zai"], urlMarkers: ["api.z.ai"] },
42
+ zhipu: { providers: ["zhipu-coding-plan"], urlMarkers: ["open.bigmodel.cn"] },
43
+ kilo: { providers: ["kilo"], urlMarkers: ["api.kilo.ai"] },
44
+ alibabaDashscope: { providers: ["alibaba-coding-plan"], urlMarkers: ["dashscope"] },
45
+ xiaomi: { providers: ["xiaomi"], providerPrefixes: ["xiaomi-token-plan-"], urlMarkers: ["xiaomimimo.com"] },
46
+ xai: { providers: ["xai"], urlMarkers: ["api.x.ai"] },
47
+ mistral: { providers: ["mistral"], urlMarkers: ["mistral.ai"] },
48
+ together: { providers: ["together"], urlMarkers: ["api.together.xyz"] },
49
+ /** URL-only on purpose: the `fireworks`/`firepass` providers route per-model and not every model is Fireworks-shaped. */
50
+ fireworks: { urlMarkers: ["fireworks.ai"] },
51
+ groq: { providers: ["groq"], urlMarkers: ["api.groq.com"] },
52
+ minimax: {
53
+ providers: ["minimax", "minimax-code", "minimax-code-cn"],
54
+ urlMarkers: ["api.minimax.io", "api.minimaxi.com"],
55
+ },
56
+ qwenPortal: { providers: ["qwen-portal"], urlMarkers: ["portal.qwen.ai"] },
57
+ moonshotNative: { providers: ["moonshot", "kimi-code"], urlMarkers: ["api.moonshot.ai", "api.kimi.com"] },
58
+ opencode: { providers: ["opencode-go", "opencode-zen"], urlMarkers: ["opencode.ai"] },
59
+ chutes: { urlMarkers: ["chutes.ai"] },
60
+ } as const satisfies Record<string, HostClassSpec>;
61
+
62
+ export type KnownHost = keyof typeof KNOWN_HOSTS;
63
+
64
+ /** URL-only host check (for call sites that have no provider id, e.g. raw env config). */
65
+ export function hostMatchesUrl(baseUrl: string | undefined, host: KnownHost): boolean {
66
+ if (!baseUrl) return false;
67
+ const spec: HostClassSpec = KNOWN_HOSTS[host];
68
+ const normalized = baseUrl.toLowerCase();
69
+ for (const marker of spec.urlMarkers) {
70
+ if (normalized.includes(marker)) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /** Provider-or-URL host check — the canonical `provider === id || baseUrl.includes(marker)` idiom. */
76
+ export function modelMatchesHost(model: { provider: string; baseUrl: string }, host: KnownHost): boolean {
77
+ const spec: HostClassSpec = KNOWN_HOSTS[host];
78
+ if (spec.providers) {
79
+ for (const provider of spec.providers) {
80
+ if (model.provider === provider) return true;
81
+ }
82
+ }
83
+ if (spec.providerPrefixes) {
84
+ for (const prefix of spec.providerPrefixes) {
85
+ if (model.provider.startsWith(prefix)) return true;
86
+ }
87
+ }
88
+ return hostMatchesUrl(model.baseUrl, host);
89
+ }
90
+
91
+ // --- Endpoint-shape predicates (URL path/verb shapes, not vendor hosts) ---
92
+
93
+ /** Vertex AI express-mode OpenAI-compatible endpoint (`…/endpoints/openapi`). */
94
+ export function isVertexExpressOpenAIUrl(baseUrl: string): boolean {
95
+ return baseUrl.includes("/endpoints/openapi");
96
+ }
97
+
98
+ /** Vertex AI Anthropic raw-predict endpoints (`:streamRawPredict` / `:rawPredict`). */
99
+ export function isVertexRawPredictUrl(baseUrl: string): boolean {
100
+ return baseUrl.includes(":streamRawPredict") || baseUrl.includes(":rawPredict");
101
+ }
102
+
103
+ /** Azure OpenAI deployment-scoped path (`…/deployments/<name>/…`). */
104
+ export function isAzureDeploymentsUrl(baseUrl: string): boolean {
105
+ return baseUrl.includes("/deployments/");
106
+ }
107
+
108
+ /** Alibaba DashScope consumer `compatible-mode` endpoint (rejects multimodal arrays for some text-only SKUs). */
109
+ export function isDashscopeCompatibleModeUrl(baseUrl: string): boolean {
110
+ const normalized = baseUrl.toLowerCase();
111
+ return (
112
+ normalized.includes("dashscope") && normalized.includes("aliyuncs.com") && normalized.includes("/compatible-mode")
113
+ );
114
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Memoized reference datasets over the bundled model catalog.
3
+ *
4
+ * Lazy: walking every bundled model (~12K) triggers thinking enrichment, so
5
+ * the walk is deferred off module load and performed once for both datasets
6
+ * (canonical equivalence + proxy reference lookup). Consumers that need
7
+ * non-bundled reference data use the pure builders directly
8
+ * ({@link buildCanonicalReferenceData} / {@link buildModelReferenceIndex}).
9
+ */
10
+ import { getBundledModels, getBundledProviders } from "../models";
11
+ import type { Api, Model } from "../types";
12
+ import { buildCanonicalReferenceData, type CanonicalReferenceData } from "./equivalence";
13
+ import { buildModelReferenceIndex, type ModelReferenceIndex } from "./reference";
14
+
15
+ let bundledModels: readonly Model<Api>[] | undefined;
16
+
17
+ function getBundledModelList(): readonly Model<Api>[] {
18
+ bundledModels ??= getBundledProviders().flatMap(
19
+ provider => getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[],
20
+ );
21
+ return bundledModels;
22
+ }
23
+
24
+ let canonicalReference: CanonicalReferenceData | undefined;
25
+
26
+ /** Canonical-equivalence reference data over the bundled catalog. */
27
+ export function getBundledCanonicalReferenceData(): CanonicalReferenceData {
28
+ canonicalReference ??= buildCanonicalReferenceData(getBundledModelList());
29
+ return canonicalReference;
30
+ }
31
+
32
+ let referenceIndex: ModelReferenceIndex | undefined;
33
+
34
+ /** Proxy-reference index over the bundled catalog. */
35
+ export function getBundledModelReferenceIndex(): ModelReferenceIndex {
36
+ referenceIndex ??= buildModelReferenceIndex(getBundledModelList());
37
+ return referenceIndex;
38
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Model-id classification: parse a model id into its family (gemini / anthropic /
3
+ * openai), kind/variant, and version. This is the shared layer both catalog
4
+ * policy rules (`model-thinking.ts`) and downstream consumers build on —
5
+ * classification lives here, the rules that consume it stay with their domain.
6
+ */
7
+
8
+ export type SemVer = {
9
+ major: number;
10
+ minor: number;
11
+ patch: number;
12
+ };
13
+
14
+ export type GeminiKind = "pro" | "flash";
15
+ export type AnthropicKind = "opus" | "sonnet" | "fable" | "mythos";
16
+ export type OpenAIVariant = "base" | "codex" | "codex-max" | "codex-mini" | "codex-spark" | "mini" | "max" | "nano";
17
+
18
+ export interface GeminiModel {
19
+ family: "gemini";
20
+ kind: GeminiKind;
21
+ version: SemVer;
22
+ }
23
+
24
+ export interface AnthropicModel {
25
+ family: "anthropic";
26
+ kind: AnthropicKind;
27
+ version: SemVer;
28
+ }
29
+
30
+ export interface OpenAIModel {
31
+ family: "openai";
32
+ variant: OpenAIVariant;
33
+ version: SemVer;
34
+ }
35
+
36
+ export interface UnknownModel {
37
+ family: "unknown";
38
+ id: string;
39
+ }
40
+
41
+ export type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel;
42
+
43
+ /** Strip a provider namespace prefix (`openai/gpt-5.4` → `gpt-5.4`). */
44
+ export function bareModelId(modelId: string): string {
45
+ const p = modelId.lastIndexOf("/");
46
+ return p !== -1 ? modelId.slice(p + 1) : modelId;
47
+ }
48
+
49
+ export function parseKnownModel(modelId: string): ParsedModel {
50
+ const canonicalId = bareModelId(modelId);
51
+ return (
52
+ parseGeminiModel(canonicalId) ??
53
+ parseAnthropicModel(canonicalId) ??
54
+ parseOpenAIModel(canonicalId) ?? { family: "unknown", id: canonicalId }
55
+ );
56
+ }
57
+
58
+ const GEMINI_SUFFIX = "-preview";
59
+ export function parseGeminiModel(modelId: string): GeminiModel | null {
60
+ if (modelId.endsWith(GEMINI_SUFFIX)) {
61
+ modelId = modelId.slice(0, -GEMINI_SUFFIX.length);
62
+ }
63
+ const match = /gemini-(\d+(?:\.\d+){0,2})-(pro|flash)\b/.exec(modelId);
64
+ if (!match) {
65
+ return null;
66
+ }
67
+ const version = parseSemVer(match[1]);
68
+ if (!version) {
69
+ return null;
70
+ }
71
+ return { family: "gemini", kind: match[2] as GeminiKind, version };
72
+ }
73
+
74
+ export function parseAnthropicModel(modelId: string): AnthropicModel | null {
75
+ const match = /claude-(opus|sonnet|fable|mythos)-(\d{1,2}(?:[.-]\d{1,2}){0,2})\b/.exec(modelId);
76
+ if (!match) {
77
+ return null;
78
+ }
79
+ const version = parseSemVer(match[2]);
80
+ if (!version) {
81
+ return null;
82
+ }
83
+ return { family: "anthropic", kind: match[1] as AnthropicKind, version };
84
+ }
85
+
86
+ export function parseOpenAIModel(modelId: string): OpenAIModel | null {
87
+ const match = /gpt-(\d+(?:\.\d+){0,2})(?:-(codex-spark|codex-mini|codex-max|codex|mini|max|nano))?\b/.exec(modelId);
88
+ if (!match) {
89
+ return null;
90
+ }
91
+ const version = parseSemVer(match[1]);
92
+ if (!version) {
93
+ return null;
94
+ }
95
+ return { family: "openai", variant: (match[2] as OpenAIVariant | undefined) ?? "base", version };
96
+ }
97
+
98
+ export function isFableOrMythos(kind: AnthropicKind): boolean {
99
+ return kind === "fable" || kind === "mythos";
100
+ }
101
+
102
+ function createSemVer(major: number, minor: number, patch = 0): SemVer {
103
+ return { major, minor, patch };
104
+ }
105
+
106
+ // extend this table if we need anything more than 9.10
107
+ const precomputeTable: Record<string, SemVer> = {};
108
+ for (let major = 0; major <= 9; major++) {
109
+ for (let minor = 0; minor <= 10; minor++) {
110
+ const version = createSemVer(major, minor, 0);
111
+ precomputeTable[`${major}.${minor}`] = version;
112
+ precomputeTable[`${major}-${minor}`] = version;
113
+ }
114
+ precomputeTable[`${major}`] = createSemVer(major, 0, 0);
115
+ }
116
+
117
+ export function parseSemVer(version: string): SemVer | null {
118
+ return precomputeTable[version] ?? null;
119
+ }
120
+
121
+ export function semverGte(left: SemVer | string, right: SemVer | string): boolean {
122
+ return compareSemVer(left, right) >= 0;
123
+ }
124
+
125
+ export function semverEqual(left: SemVer | string, right: SemVer | string): boolean {
126
+ return compareSemVer(left, right) === 0;
127
+ }
128
+
129
+ export function compareSemVer(left: SemVer | string | null, right: SemVer | string | null): number {
130
+ left = typeof left === "string" ? parseSemVer(left) : left;
131
+ right = typeof right === "string" ? parseSemVer(right) : right;
132
+ if (!left || !right) return (left ? 1 : 0) - (right ? 1 : 0);
133
+
134
+ if (left.major !== right.major) {
135
+ return left.major - right.major;
136
+ }
137
+ if (left.minor !== right.minor) {
138
+ return left.minor - right.minor;
139
+ }
140
+ return left.patch - right.patch;
141
+ }