jeopi-catalog 16.2.13

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 (137) hide show
  1. package/CHANGELOG.md +523 -0
  2. package/README.md +43 -0
  3. package/dist/types/build.d.ts +3 -0
  4. package/dist/types/compat/anthropic.d.ts +11 -0
  5. package/dist/types/compat/apply.d.ts +7 -0
  6. package/dist/types/compat/devin.d.ts +9 -0
  7. package/dist/types/compat/openai.d.ts +28 -0
  8. package/dist/types/discovery/antigravity.d.ts +70 -0
  9. package/dist/types/discovery/codex.d.ts +38 -0
  10. package/dist/types/discovery/cursor-gen/agent_pb.d.ts +13022 -0
  11. package/dist/types/discovery/cursor.d.ts +23 -0
  12. package/dist/types/discovery/devin-gen/buf/validate/validate_pb.d.ts +1715 -0
  13. package/dist/types/discovery/devin-gen/exa/analytics_pb/analytics_pb.d.ts +693 -0
  14. package/dist/types/discovery/devin-gen/exa/api_server_pb/api_server_pb.d.ts +9158 -0
  15. package/dist/types/discovery/devin-gen/exa/auth_pb/auth_pb.d.ts +52 -0
  16. package/dist/types/discovery/devin-gen/exa/auto_cascade_common_pb/auto_cascade_common_pb.d.ts +276 -0
  17. package/dist/types/discovery/devin-gen/exa/bug_checker_pb/bug_checker_pb.d.ts +78 -0
  18. package/dist/types/discovery/devin-gen/exa/cascade_plugins_pb/cascade_plugins_pb.d.ts +999 -0
  19. package/dist/types/discovery/devin-gen/exa/chat_pb/chat_pb.d.ts +1704 -0
  20. package/dist/types/discovery/devin-gen/exa/code_edit/code_edit_pb/code_edit_pb.d.ts +656 -0
  21. package/dist/types/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb.d.ts +15014 -0
  22. package/dist/types/discovery/devin-gen/exa/context_module_pb/context_module_pb.d.ts +607 -0
  23. package/dist/types/discovery/devin-gen/exa/cortex_pb/cortex_pb.d.ts +11830 -0
  24. package/dist/types/discovery/devin-gen/exa/diff_action_pb/diff_action_pb.d.ts +248 -0
  25. package/dist/types/discovery/devin-gen/exa/index_pb/index_pb.d.ts +1747 -0
  26. package/dist/types/discovery/devin-gen/exa/knowledge_base_pb/knowledge_base_pb.d.ts +509 -0
  27. package/dist/types/discovery/devin-gen/exa/language_server_pb/language_server_pb.d.ts +9048 -0
  28. package/dist/types/discovery/devin-gen/exa/opensearch_clients_pb/opensearch_clients_pb.d.ts +1760 -0
  29. package/dist/types/discovery/devin-gen/exa/prompt_pb/prompt_pb.d.ts +286 -0
  30. package/dist/types/discovery/devin-gen/exa/reactive_component_pb/reactive_component_pb.d.ts +405 -0
  31. package/dist/types/discovery/devin-gen/exa/trust_pb/trust_pb.d.ts +582 -0
  32. package/dist/types/discovery/devin.d.ts +24 -0
  33. package/dist/types/discovery/gemini.d.ts +25 -0
  34. package/dist/types/discovery/gitlab-duo-workflow.d.ts +30 -0
  35. package/dist/types/discovery/index.d.ts +5 -0
  36. package/dist/types/discovery/openai-compatible.d.ts +74 -0
  37. package/dist/types/effort.d.ts +9 -0
  38. package/dist/types/fireworks-model-id.d.ts +23 -0
  39. package/dist/types/hosts.d.ts +137 -0
  40. package/dist/types/identity/bundled.d.ts +3 -0
  41. package/dist/types/identity/classify.d.ts +69 -0
  42. package/dist/types/identity/dialect.d.ts +3 -0
  43. package/dist/types/identity/family.d.ts +139 -0
  44. package/dist/types/identity/id.d.ts +12 -0
  45. package/dist/types/identity/index.d.ts +8 -0
  46. package/dist/types/identity/markers.d.ts +4 -0
  47. package/dist/types/identity/priority.d.ts +1 -0
  48. package/dist/types/identity/reference.d.ts +23 -0
  49. package/dist/types/index.d.ts +17 -0
  50. package/dist/types/model-cache.d.ts +17 -0
  51. package/dist/types/model-manager.d.ts +68 -0
  52. package/dist/types/model-thinking.d.ts +79 -0
  53. package/dist/types/models.d.ts +12 -0
  54. package/dist/types/provider-models/bundled-references.d.ts +11 -0
  55. package/dist/types/provider-models/descriptor-types.d.ts +74 -0
  56. package/dist/types/provider-models/descriptors.d.ts +427 -0
  57. package/dist/types/provider-models/google.d.ts +27 -0
  58. package/dist/types/provider-models/index.d.ts +6 -0
  59. package/dist/types/provider-models/ollama.d.ts +9 -0
  60. package/dist/types/provider-models/openai-compat.d.ts +450 -0
  61. package/dist/types/provider-models/special.d.ts +33 -0
  62. package/dist/types/types.d.ts +656 -0
  63. package/dist/types/utils.d.ts +20 -0
  64. package/dist/types/variant-collapse.d.ts +133 -0
  65. package/dist/types/wire/codex.d.ts +26 -0
  66. package/dist/types/wire/coreweave.d.ts +12 -0
  67. package/dist/types/wire/gemini-headers.d.ts +37 -0
  68. package/dist/types/wire/github-copilot.d.ts +45 -0
  69. package/package.json +101 -0
  70. package/src/build.ts +47 -0
  71. package/src/compat/anthropic.ts +93 -0
  72. package/src/compat/apply.ts +15 -0
  73. package/src/compat/devin.ts +12 -0
  74. package/src/compat/openai.ts +689 -0
  75. package/src/discovery/antigravity.ts +253 -0
  76. package/src/discovery/codex.ts +371 -0
  77. package/src/discovery/cursor-gen/agent_pb.ts +15274 -0
  78. package/src/discovery/cursor.ts +314 -0
  79. package/src/discovery/devin-gen/buf/validate/validate_pb.ts +1862 -0
  80. package/src/discovery/devin-gen/exa/analytics_pb/analytics_pb.ts +871 -0
  81. package/src/discovery/devin-gen/exa/api_server_pb/api_server_pb.ts +11083 -0
  82. package/src/discovery/devin-gen/exa/auth_pb/auth_pb.ts +71 -0
  83. package/src/discovery/devin-gen/exa/auto_cascade_common_pb/auto_cascade_common_pb.ts +348 -0
  84. package/src/discovery/devin-gen/exa/bug_checker_pb/bug_checker_pb.ts +103 -0
  85. package/src/discovery/devin-gen/exa/cascade_plugins_pb/cascade_plugins_pb.ts +1198 -0
  86. package/src/discovery/devin-gen/exa/chat_pb/chat_pb.ts +2063 -0
  87. package/src/discovery/devin-gen/exa/code_edit/code_edit_pb/code_edit_pb.ts +810 -0
  88. package/src/discovery/devin-gen/exa/codeium_common_pb/codeium_common_pb.ts +18354 -0
  89. package/src/discovery/devin-gen/exa/context_module_pb/context_module_pb.ts +732 -0
  90. package/src/discovery/devin-gen/exa/cortex_pb/cortex_pb.ts +14277 -0
  91. package/src/discovery/devin-gen/exa/diff_action_pb/diff_action_pb.ts +312 -0
  92. package/src/discovery/devin-gen/exa/index_pb/index_pb.ts +2106 -0
  93. package/src/discovery/devin-gen/exa/knowledge_base_pb/knowledge_base_pb.ts +623 -0
  94. package/src/discovery/devin-gen/exa/language_server_pb/language_server_pb.ts +10918 -0
  95. package/src/discovery/devin-gen/exa/opensearch_clients_pb/opensearch_clients_pb.ts +2125 -0
  96. package/src/discovery/devin-gen/exa/prompt_pb/prompt_pb.ts +361 -0
  97. package/src/discovery/devin-gen/exa/reactive_component_pb/reactive_component_pb.ts +450 -0
  98. package/src/discovery/devin-gen/exa/trust_pb/trust_pb.ts +712 -0
  99. package/src/discovery/devin.ts +151 -0
  100. package/src/discovery/gemini.ts +260 -0
  101. package/src/discovery/gitlab-duo-workflow.ts +855 -0
  102. package/src/discovery/index.ts +5 -0
  103. package/src/discovery/openai-compatible.ts +249 -0
  104. package/src/effort.ts +16 -0
  105. package/src/fireworks-model-id.ts +50 -0
  106. package/src/hosts.ts +129 -0
  107. package/src/identity/bundled.ts +28 -0
  108. package/src/identity/classify.ts +214 -0
  109. package/src/identity/dialect.ts +42 -0
  110. package/src/identity/family.ts +296 -0
  111. package/src/identity/id.ts +81 -0
  112. package/src/identity/index.ts +8 -0
  113. package/src/identity/markers.ts +49 -0
  114. package/src/identity/priority.ts +57 -0
  115. package/src/identity/reference.ts +148 -0
  116. package/src/index.ts +17 -0
  117. package/src/model-cache.ts +171 -0
  118. package/src/model-manager.ts +520 -0
  119. package/src/model-thinking.ts +760 -0
  120. package/src/models.json +88898 -0
  121. package/src/models.json.d.ts +9 -0
  122. package/src/models.ts +64 -0
  123. package/src/provider-models/bundled-references.ts +58 -0
  124. package/src/provider-models/descriptor-types.ts +79 -0
  125. package/src/provider-models/descriptors.ts +503 -0
  126. package/src/provider-models/google.ts +107 -0
  127. package/src/provider-models/index.ts +6 -0
  128. package/src/provider-models/ollama.ts +172 -0
  129. package/src/provider-models/openai-compat.ts +4283 -0
  130. package/src/provider-models/special.ts +159 -0
  131. package/src/types.ts +786 -0
  132. package/src/utils.ts +69 -0
  133. package/src/variant-collapse.ts +1065 -0
  134. package/src/wire/codex.ts +43 -0
  135. package/src/wire/coreweave.ts +51 -0
  136. package/src/wire/gemini-headers.ts +74 -0
  137. package/src/wire/github-copilot.ts +121 -0
@@ -0,0 +1,4283 @@
1
+ import {
2
+ fetchOpenAICompatibleModels,
3
+ type OpenAICompatibleModelMapperContext,
4
+ type OpenAICompatibleModelRecord,
5
+ } from "../discovery/openai-compatible";
6
+ import { Effort } from "../effort";
7
+ import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-model-id";
8
+ import { isGlmVisionModelId, isGrokReasoningEffortCapable, isReasoningGlmModelId } from "../identity/family";
9
+ import type { ModelManagerOptions } from "../model-manager";
10
+ import { getBundledModels } from "../models";
11
+ import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types";
12
+ import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
13
+ import { coreWeaveProjectHeaders } from "../wire/coreweave";
14
+ import {
15
+ COPILOT_API_HEADERS,
16
+ getGitHubCopilotBaseUrl,
17
+ isPersonalGitHubCopilotBaseUrl,
18
+ parseGitHubCopilotApiKey,
19
+ } from "../wire/github-copilot";
20
+ import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references";
21
+
22
+ const MODELS_DEV_URL = "https://models.dev/api.json";
23
+
24
+ /**
25
+ * Uses a cancellable timer rather than the native abort-timeout helper so
26
+ * successful fast discovery requests do not leave armed timeout signals for
27
+ * concurrent GC to trip over later.
28
+ */
29
+ async function withCatalogDiscoveryTimeout<T>(timeoutMs: number, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(
32
+ () => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
33
+ timeoutMs,
34
+ );
35
+ try {
36
+ return await run(controller.signal);
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+
42
+ const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
43
+ const ANTHROPIC_OAUTH_BETA =
44
+ "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11";
45
+
46
+ export interface ModelsDevModel {
47
+ id?: string;
48
+ name?: string;
49
+ tool_call?: boolean;
50
+ reasoning?: boolean;
51
+ limit?: {
52
+ context?: number;
53
+ output?: number;
54
+ };
55
+ cost?: {
56
+ input?: number;
57
+ output?: number;
58
+ cache_read?: number;
59
+ cache_write?: number;
60
+ };
61
+ modalities?: {
62
+ input?: string[];
63
+ };
64
+ status?: string;
65
+ provider?: { npm?: string };
66
+ }
67
+
68
+ function toModelName(value: unknown, fallback: string): string {
69
+ if (typeof value !== "string") {
70
+ return fallback;
71
+ }
72
+ const trimmed = value.trim();
73
+ return trimmed.length > 0 ? trimmed : fallback;
74
+ }
75
+
76
+ function toInputCapabilities(value: unknown): ("text" | "image")[] {
77
+ if (!Array.isArray(value)) {
78
+ return ["text"];
79
+ }
80
+ const supportsImage = value.some(item => item === "image");
81
+ return supportsImage ? ["text", "image"] : ["text"];
82
+ }
83
+
84
+ async function fetchModelsDevPayload(fetchImpl: FetchImpl = discoveryFetch()): Promise<unknown> {
85
+ const response = await fetchImpl(MODELS_DEV_URL, {
86
+ method: "GET",
87
+ headers: { Accept: "application/json" },
88
+ });
89
+ if (!response.ok) {
90
+ throw new Error(`models.dev fetch failed: ${response.status}`);
91
+ }
92
+ return response.json();
93
+ }
94
+
95
+ function mapAnthropicModelsDev(payload: unknown, baseUrl: string): ModelSpec<"anthropic-messages">[] {
96
+ if (!isRecord(payload)) {
97
+ return [];
98
+ }
99
+ const anthropicPayload = payload.anthropic;
100
+ if (!isRecord(anthropicPayload)) {
101
+ return [];
102
+ }
103
+ const modelsValue = anthropicPayload.models;
104
+ if (!isRecord(modelsValue)) {
105
+ return [];
106
+ }
107
+
108
+ const models: ModelSpec<"anthropic-messages">[] = [];
109
+ for (const [modelId, rawModel] of Object.entries(modelsValue)) {
110
+ if (!isRecord(rawModel)) {
111
+ continue;
112
+ }
113
+ const model = rawModel as ModelsDevModel;
114
+ if (model.tool_call !== true) {
115
+ continue;
116
+ }
117
+ models.push({
118
+ id: modelId,
119
+ name: toModelName(model.name, modelId),
120
+ api: "anthropic-messages",
121
+ provider: "anthropic",
122
+ baseUrl,
123
+ reasoning: model.reasoning === true,
124
+ input: toInputCapabilities(model.modalities?.input),
125
+ cost: {
126
+ input: toNumber(model.cost?.input) ?? 0,
127
+ output: toNumber(model.cost?.output) ?? 0,
128
+ cacheRead: toNumber(model.cost?.cache_read) ?? 0,
129
+ cacheWrite: toNumber(model.cost?.cache_write) ?? 0,
130
+ },
131
+ contextWindow: toPositiveNumber(model.limit?.context, null),
132
+ maxTokens: toPositiveNumber(model.limit?.output, null),
133
+ });
134
+ }
135
+
136
+ models.sort((left, right) => left.id.localeCompare(right.id));
137
+ return models;
138
+ }
139
+
140
+ function buildAnthropicDiscoveryHeaders(apiKey: string): Record<string, string> {
141
+ const oauthToken = isAnthropicOAuthToken(apiKey);
142
+ const headers: Record<string, string> = {
143
+ "anthropic-version": "2023-06-01",
144
+ "anthropic-dangerous-direct-browser-access": "true",
145
+ "anthropic-beta": ANTHROPIC_OAUTH_BETA,
146
+ };
147
+ if (oauthToken) {
148
+ headers.Authorization = `Bearer ${apiKey}`;
149
+ } else {
150
+ headers["x-api-key"] = apiKey;
151
+ }
152
+ return headers;
153
+ }
154
+
155
+ function buildAnthropicReferenceMap(
156
+ modelsDevModels: readonly ModelSpec<"anthropic-messages">[],
157
+ ): Map<string, ModelSpec<"anthropic-messages">> {
158
+ const merged = new Map<string, ModelSpec<"anthropic-messages">>();
159
+ for (const model of modelsDevModels) {
160
+ merged.set(model.id, model);
161
+ }
162
+ // Anthropic /v1/models does not carry token limits, so bundled metadata stays canonical
163
+ // for known models while models.dev only fills gaps for newly discovered ids.
164
+ const bundledModels = getBundledModels("anthropic").filter(
165
+ (model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages",
166
+ );
167
+ for (const model of bundledModels) {
168
+ merged.set(model.id, toModelSpec(model));
169
+ }
170
+ return merged;
171
+ }
172
+
173
+ /**
174
+ * Curated Anthropic models that are live or limited-availability on the
175
+ * first-party `/v1/models` endpoint but that models.dev has not catalogued yet.
176
+ * Seeded into model generation so the bundled catalog is never gated on
177
+ * models.dev's update cadence; deduped behind upstream catalog / models.dev
178
+ * entries once those appear. Token limits and pricing are pinned either directly or
179
+ * in `applyAnthropicCatalogPolicy`, and `thinking` is re-baked
180
+ * by the generator's policy pass (scripts/generated-policies.ts).
181
+ */
182
+ export const ANTHROPIC_CURATED_FALLBACK_MODELS: readonly ModelSpec<"anthropic-messages">[] = [
183
+ {
184
+ id: "claude-sonnet-5",
185
+ name: "Claude Sonnet 5",
186
+ api: "anthropic-messages",
187
+ provider: "anthropic",
188
+ baseUrl: "https://api.anthropic.com",
189
+ reasoning: true,
190
+ input: ["text", "image"],
191
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
192
+ contextWindow: 1_000_000,
193
+ maxTokens: 128_000,
194
+ },
195
+ {
196
+ id: "claude-fable-5",
197
+ name: "Claude Fable 5",
198
+ api: "anthropic-messages",
199
+ provider: "anthropic",
200
+ baseUrl: "https://api.anthropic.com",
201
+ reasoning: true,
202
+ input: ["text", "image"],
203
+ cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
204
+ contextWindow: 1_000_000,
205
+ maxTokens: 128_000,
206
+ },
207
+ {
208
+ id: "claude-mythos-5",
209
+ name: "Claude Mythos 5",
210
+ api: "anthropic-messages",
211
+ provider: "anthropic",
212
+ baseUrl: "https://api.anthropic.com",
213
+ reasoning: true,
214
+ input: ["text", "image"],
215
+ cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
216
+ contextWindow: 1_000_000,
217
+ maxTokens: 128_000,
218
+ },
219
+ ];
220
+
221
+ function mapWithBundledReference<TApi extends Api>(
222
+ entry: OpenAICompatibleModelRecord,
223
+ defaults: ModelSpec<TApi>,
224
+ reference: ModelSpec<TApi> | undefined,
225
+ ): ModelSpec<TApi> {
226
+ const name = toModelName(entry.name, reference?.name ?? defaults.name);
227
+ if (!reference) {
228
+ return {
229
+ ...defaults,
230
+ name,
231
+ };
232
+ }
233
+ return {
234
+ ...reference,
235
+ id: defaults.id,
236
+ name,
237
+ api: defaults.api,
238
+ provider: defaults.provider,
239
+ baseUrl: defaults.baseUrl,
240
+ contextWindow: toPositiveNumber(entry.context_length, reference.contextWindow),
241
+ maxTokens: toPositiveNumber(entry.max_completion_tokens, reference.maxTokens),
242
+ };
243
+ }
244
+
245
+ function normalizeAnthropicBaseUrl(baseUrl: string | undefined, fallback: string): string {
246
+ const value = baseUrl?.trim();
247
+ if (!value) {
248
+ return fallback;
249
+ }
250
+ return value.endsWith("/") ? value.slice(0, -1) : value;
251
+ }
252
+
253
+ function toAnthropicDiscoveryBaseUrl(baseUrl: string): string {
254
+ return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
255
+ }
256
+
257
+ function normalizeOllamaBaseUrl(baseUrl?: string): string {
258
+ const value = baseUrl?.trim();
259
+ if (!value) {
260
+ return "http://127.0.0.1:11434/v1";
261
+ }
262
+ const trimmed = value.endsWith("/") ? value.slice(0, -1) : value;
263
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
264
+ }
265
+
266
+ function toOllamaNativeBaseUrl(baseUrl: string): string {
267
+ return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
268
+ }
269
+
270
+ async function fetchOllamaNativeModels(
271
+ baseUrl: string,
272
+ resolveMetadata: (modelId: string) => Promise<OllamaResolvedMetadata>,
273
+ fetchImpl: FetchImpl = discoveryFetch(),
274
+ ): Promise<ModelSpec<"openai-responses">[] | null> {
275
+ const nativeBaseUrl = toOllamaNativeBaseUrl(baseUrl);
276
+ let response: Response;
277
+ try {
278
+ response = await fetchImpl(`${nativeBaseUrl}/api/tags`, {
279
+ method: "GET",
280
+ headers: { Accept: "application/json" },
281
+ });
282
+ } catch {
283
+ return null;
284
+ }
285
+ if (!response.ok) {
286
+ return null;
287
+ }
288
+ const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
289
+ const entries = payload.models ?? [];
290
+ const resolved = await Promise.all(
291
+ entries.map(async (entry): Promise<ModelSpec<"openai-responses"> | null> => {
292
+ const id = entry.model ?? entry.name;
293
+ if (!id) return null;
294
+ const metadata = await resolveMetadata(id);
295
+ return {
296
+ id,
297
+ name: entry.name ?? id,
298
+ api: "openai-responses",
299
+ provider: "ollama",
300
+ baseUrl,
301
+ reasoning: metadata.reasoning ?? false,
302
+ thinking: metadata.thinking,
303
+ input: metadata.input ?? ["text"],
304
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
305
+ contextWindow: metadata.contextWindow,
306
+ maxTokens: metadata.maxTokens,
307
+ };
308
+ }),
309
+ );
310
+ const models: ModelSpec<"openai-responses">[] = resolved.filter(
311
+ (m): m is ModelSpec<"openai-responses"> => m !== null,
312
+ );
313
+ return models.sort((left, right) => left.id.localeCompare(right.id));
314
+ }
315
+
316
+ /**
317
+ * Fallback context window for Ollama models when `/api/show` is unavailable
318
+ * or omits a `model_info.<arch>.context_length` field. Matches the size
319
+ * Ollama's cloud catalog reports for stock models.
320
+ */
321
+ const OLLAMA_FALLBACK_CONTEXT_WINDOW = 128_000;
322
+ /** Cap max output tokens at a value that matches OMP's other openai-responses defaults. */
323
+ const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
324
+
325
+ interface OllamaResolvedMetadata {
326
+ contextWindow: number;
327
+ maxTokens: number;
328
+ capabilities?: string[];
329
+ reasoning?: boolean;
330
+ thinking?: ThinkingConfig;
331
+ input?: ("text" | "image")[];
332
+ }
333
+
334
+ interface OllamaShowMetadata {
335
+ contextWindow?: number;
336
+ maxTokens?: number;
337
+ capabilities?: string[];
338
+ reasoning?: boolean;
339
+ thinking?: ThinkingConfig;
340
+ input?: ("text" | "image")[];
341
+ }
342
+
343
+ function getOllamaContextWindow(modelInfo: Record<string, unknown> | undefined): number | undefined {
344
+ if (!modelInfo) {
345
+ return undefined;
346
+ }
347
+ for (const [key, value] of Object.entries(modelInfo)) {
348
+ if (typeof value !== "number" || value <= 0) {
349
+ continue;
350
+ }
351
+ if (key.endsWith(".context_length") || key.endsWith(".num_ctx") || key.endsWith(".context_window")) {
352
+ return value;
353
+ }
354
+ }
355
+ }
356
+
357
+ function getOllamaCapabilities(value: unknown): string[] | undefined {
358
+ if (!Array.isArray(value)) {
359
+ return undefined;
360
+ }
361
+ return value.filter((item): item is string => typeof item === "string");
362
+ }
363
+
364
+ function getOllamaThinkingConfig(capabilities: string[] | undefined): ThinkingConfig | undefined {
365
+ if (!capabilities?.includes("thinking")) {
366
+ return undefined;
367
+ }
368
+ return { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] };
369
+ }
370
+
371
+ /**
372
+ * Query Ollama's `/api/show` endpoint for a single model and pull native
373
+ * context and capability metadata from the response. Returns `undefined` when
374
+ * the endpoint is unavailable so callers can layer their own fallback.
375
+ */
376
+ async function fetchOllamaShowMetadata(
377
+ nativeBaseUrl: string,
378
+ modelId: string,
379
+ fetchImpl: FetchImpl = discoveryFetch(),
380
+ ): Promise<OllamaShowMetadata | undefined> {
381
+ try {
382
+ const response = await fetchImpl(`${nativeBaseUrl}/api/show`, {
383
+ method: "POST",
384
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
385
+ body: JSON.stringify({ model: modelId }),
386
+ });
387
+ if (!response.ok) {
388
+ return undefined;
389
+ }
390
+ const payload = (await response.json()) as { capabilities?: unknown; model_info?: Record<string, unknown> };
391
+ const capabilities = getOllamaCapabilities(payload.capabilities);
392
+ const contextWindow = getOllamaContextWindow(payload.model_info);
393
+ return {
394
+ contextWindow,
395
+ maxTokens: contextWindow ? OLLAMA_DEFAULT_MAX_TOKENS : undefined,
396
+ capabilities,
397
+ reasoning: capabilities ? capabilities.includes("thinking") : undefined,
398
+ thinking: getOllamaThinkingConfig(capabilities),
399
+ input: capabilities
400
+ ? capabilities.includes("vision")
401
+ ? (["text", "image"] as Array<"text" | "image">)
402
+ : (["text"] as Array<"text">)
403
+ : undefined,
404
+ };
405
+ } catch {
406
+ // fall through; caller decides on the fallback
407
+ }
408
+ return undefined;
409
+ }
410
+
411
+ /**
412
+ * Build a resolver that fetches `/api/show` metadata per model id and caches
413
+ * the result in-memory for the lifetime of the manager. Successful lookups are
414
+ * cached so repeated `fetchDynamicModels` calls do not refetch; failed
415
+ * lookups stay uncached so a later refresh can recover.
416
+ */
417
+ function createOllamaMetadataResolver(
418
+ nativeBaseUrl: string,
419
+ fetchImpl?: FetchImpl,
420
+ ): (modelId: string) => Promise<OllamaResolvedMetadata> {
421
+ const cache = new Map<string, Promise<OllamaResolvedMetadata>>();
422
+ return modelId => {
423
+ const cached = cache.get(modelId);
424
+ if (cached) return cached;
425
+ const pending = (async () => {
426
+ const metadata = await fetchOllamaShowMetadata(nativeBaseUrl, modelId, fetchImpl);
427
+ if (!metadata) {
428
+ cache.delete(modelId);
429
+ return { contextWindow: OLLAMA_FALLBACK_CONTEXT_WINDOW, maxTokens: OLLAMA_DEFAULT_MAX_TOKENS };
430
+ }
431
+ return {
432
+ ...metadata,
433
+ contextWindow: metadata.contextWindow ?? OLLAMA_FALLBACK_CONTEXT_WINDOW,
434
+ maxTokens: metadata.maxTokens ?? OLLAMA_DEFAULT_MAX_TOKENS,
435
+ };
436
+ })();
437
+ cache.set(modelId, pending);
438
+ void pending.catch(() => cache.delete(modelId));
439
+ return pending;
440
+ };
441
+ }
442
+
443
+ const OPENAI_NON_RESPONSES_PREFIXES = [
444
+ "text-embedding",
445
+ "whisper-",
446
+ "tts-",
447
+ "omni-moderation",
448
+ "omni-transcribe",
449
+ "omni-speech",
450
+ "gpt-image-",
451
+ "gpt-realtime",
452
+ ] as const;
453
+
454
+ function isLikelyOpenAIResponsesModelId(id: string, references: Map<string, ModelSpec<"openai-responses">>): boolean {
455
+ const trimmed = id.trim();
456
+ if (!trimmed) {
457
+ return false;
458
+ }
459
+ if (references.has(trimmed)) {
460
+ return true;
461
+ }
462
+ const normalized = trimmed.toLowerCase();
463
+ if (OPENAI_NON_RESPONSES_PREFIXES.some(prefix => normalized.startsWith(prefix))) {
464
+ return false;
465
+ }
466
+ if (normalized.includes("embedding")) {
467
+ return false;
468
+ }
469
+ return (
470
+ normalized.startsWith("gpt-") ||
471
+ normalized.startsWith("o1") ||
472
+ normalized.startsWith("o3") ||
473
+ normalized.startsWith("o4") ||
474
+ normalized.startsWith("chatgpt")
475
+ );
476
+ }
477
+
478
+ const NANO_GPT_NON_TEXT_MODEL_TOKENS = [
479
+ "embedding",
480
+ "image",
481
+ "vision",
482
+ "audio",
483
+ "speech",
484
+ "transcribe",
485
+ "moderation",
486
+ "realtime",
487
+ "whisper",
488
+ "tts",
489
+ ] as const;
490
+
491
+ /** Regex matching NanoGPT `:thinking` suffixed model IDs (with or without a level). */
492
+ const NANO_GPT_THINKING_SUFFIX_RE = /:thinking(:[^:]+)?$/;
493
+
494
+ function isLikelyNanoGptTextModelId(id: string): boolean {
495
+ const normalized = id.trim().toLowerCase();
496
+ if (!normalized) {
497
+ return false;
498
+ }
499
+ if (NANO_GPT_THINKING_SUFFIX_RE.test(normalized)) {
500
+ return false;
501
+ }
502
+ return !NANO_GPT_NON_TEXT_MODEL_TOKENS.some(token => normalized.includes(token));
503
+ }
504
+
505
+ type SimpleProviderDiscoveryHeaders = Record<string, string> | (() => Record<string, string> | undefined);
506
+ type SimpleProviderConfig = {
507
+ apiKey?: string;
508
+ baseUrl?: string;
509
+ fetch?: FetchImpl;
510
+ headers?: SimpleProviderDiscoveryHeaders;
511
+ };
512
+
513
+ function resolveSimpleProviderHeaders(
514
+ headers: SimpleProviderDiscoveryHeaders | undefined,
515
+ ): Record<string, string> | undefined {
516
+ return typeof headers === "function" ? headers() : headers;
517
+ }
518
+
519
+ export function createSimpleOpenAICompletionsOptions(
520
+ providerId: Parameters<typeof getBundledModels>[0],
521
+ defaultBaseUrl: string,
522
+ config?: SimpleProviderConfig,
523
+ ): ModelManagerOptions<"openai-completions"> {
524
+ const apiKey = config?.apiKey;
525
+ const baseUrl = config?.baseUrl ?? defaultBaseUrl;
526
+ const references = createBundledReferenceMap<"openai-completions">(providerId);
527
+ return {
528
+ providerId,
529
+ ...(apiKey && {
530
+ fetchDynamicModels: () =>
531
+ fetchOpenAICompatibleModels({
532
+ api: "openai-completions",
533
+ provider: providerId,
534
+ baseUrl,
535
+ apiKey,
536
+ headers: resolveSimpleProviderHeaders(config?.headers),
537
+ mapModel: (entry, defaults) => {
538
+ const reference = references.get(defaults.id);
539
+ return mapWithBundledReference(entry, defaults, reference);
540
+ },
541
+ fetch: config?.fetch,
542
+ }),
543
+ }),
544
+ };
545
+ }
546
+
547
+ function createSimpleOpenAIResponsesOptions(
548
+ providerId: Parameters<typeof getBundledModels>[0],
549
+ defaultBaseUrl: string,
550
+ config?: SimpleProviderConfig,
551
+ ): ModelManagerOptions<"openai-responses"> {
552
+ const apiKey = config?.apiKey;
553
+ const baseUrl = config?.baseUrl ?? defaultBaseUrl;
554
+ const references = createBundledReferenceMap<"openai-responses">(providerId);
555
+ return {
556
+ providerId,
557
+ ...(apiKey && {
558
+ fetchDynamicModels: () =>
559
+ fetchOpenAICompatibleModels({
560
+ api: "openai-responses",
561
+ provider: providerId,
562
+ baseUrl,
563
+ apiKey,
564
+ headers: resolveSimpleProviderHeaders(config?.headers),
565
+ mapModel: (entry, defaults) => {
566
+ const reference = references.get(defaults.id);
567
+ return mapWithBundledReference(entry, defaults, reference);
568
+ },
569
+ fetch: config?.fetch,
570
+ }),
571
+ }),
572
+ };
573
+ }
574
+
575
+ function createSimpleAnthropicProviderOptions(
576
+ providerId: Parameters<typeof getBundledModels>[0],
577
+ defaultBaseUrlFallback: string,
578
+ config?: SimpleProviderConfig,
579
+ ): ModelManagerOptions<"anthropic-messages"> {
580
+ const apiKey = config?.apiKey;
581
+ const baseUrl = normalizeAnthropicBaseUrl(config?.baseUrl, defaultBaseUrlFallback);
582
+ const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(baseUrl);
583
+ const references = createBundledReferenceMap<"anthropic-messages">(providerId);
584
+ return {
585
+ providerId,
586
+ ...(apiKey && {
587
+ fetchDynamicModels: () =>
588
+ fetchOpenAICompatibleModels({
589
+ api: "anthropic-messages",
590
+ provider: providerId,
591
+ baseUrl: discoveryBaseUrl,
592
+ headers: buildAnthropicDiscoveryHeaders(apiKey),
593
+ mapModel: (entry, defaults) => {
594
+ const reference = references.get(defaults.id);
595
+ const model = mapWithBundledReference(entry, defaults, reference);
596
+ return {
597
+ ...model,
598
+ name: toModelName(entry.display_name, model.name),
599
+ baseUrl,
600
+ };
601
+ },
602
+ fetch: config?.fetch,
603
+ }),
604
+ }),
605
+ };
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Umans AI Coding Plan
610
+ // ---------------------------------------------------------------------------
611
+
612
+ const UMANS_BASE_URL = "https://api.code.umans.ai";
613
+ const UMANS_MODELS_INFO_PATH = "/models/info";
614
+ const UMANS_REASONING_EFFORT_BY_LEVEL: Record<string, Effort> = {
615
+ minimal: Effort.Minimal,
616
+ low: Effort.Low,
617
+ medium: Effort.Medium,
618
+ high: Effort.High,
619
+ xhigh: Effort.XHigh,
620
+ max: Effort.XHigh,
621
+ };
622
+ const UMANS_MAX_REASONING_EFFORT_MAP = { [Effort.XHigh]: "max" } as const;
623
+ const UMANS_DEFAULT_REASONING_EFFORTS = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const;
624
+ const UMANS_VIA_HANDOFF_MODEL_IDS = ["umans-glm-5.1", "umans-glm-5.2"] as const;
625
+
626
+ export interface UmansModelManagerConfig {
627
+ apiKey?: string;
628
+ baseUrl?: string;
629
+ fetch?: FetchImpl;
630
+ }
631
+
632
+ interface UmansModelInfo {
633
+ name?: unknown;
634
+ display_name?: unknown;
635
+ capabilities?: unknown;
636
+ }
637
+
638
+ function normalizeUmansBaseUrl(baseUrl: string | undefined): string {
639
+ const normalized = normalizeAnthropicBaseUrl(baseUrl, UMANS_BASE_URL);
640
+ return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized;
641
+ }
642
+
643
+ /**
644
+ * Umans `models/info` reports `supports_vision: true` for natively
645
+ * vision-capable models and a non-empty string sentinel (e.g.
646
+ * `"via-handoff"`) for models that route image inputs through a vision
647
+ * handoff pre-analysis step instead of accepting raw image blocks. Only
648
+ * `true` means the model accepts image content directly; sentinel values
649
+ * MUST map to text-only so the agent's vision-handoff path runs instead
650
+ * of triggering an upstream HTTP 400 (`This model does not support image
651
+ * inputs`).
652
+ */
653
+ function umansSupportsVision(value: unknown): boolean {
654
+ return value === true;
655
+ }
656
+
657
+ function umansReasoningSupported(value: unknown): boolean {
658
+ return isRecord(value) ? value.supported === true : value === true;
659
+ }
660
+
661
+ function mapUmansReasoningEfforts(value: unknown): readonly Effort[] {
662
+ if (!isRecord(value) || !Array.isArray(value.levels)) {
663
+ return UMANS_DEFAULT_REASONING_EFFORTS;
664
+ }
665
+ const efforts: Effort[] = [];
666
+ for (const level of value.levels) {
667
+ if (typeof level !== "string") continue;
668
+ const effort = UMANS_REASONING_EFFORT_BY_LEVEL[level];
669
+ if (effort !== undefined && !efforts.includes(effort)) {
670
+ efforts.push(effort);
671
+ }
672
+ }
673
+ return efforts.length > 0 ? efforts : UMANS_DEFAULT_REASONING_EFFORTS;
674
+ }
675
+
676
+ function umansHasMaxReasoningLevel(value: unknown): boolean {
677
+ return isRecord(value) && Array.isArray(value.levels) && value.levels.includes("max");
678
+ }
679
+
680
+ function mapUmansThinkingConfig(value: unknown): ThinkingConfig | undefined {
681
+ if (!umansReasoningSupported(value)) return undefined;
682
+ const efforts = mapUmansReasoningEfforts(value);
683
+ const thinking: ThinkingConfig = {
684
+ mode: umansHasMaxReasoningLevel(value) ? "anthropic-budget-effort" : "budget",
685
+ efforts,
686
+ };
687
+ if (thinking.mode === "anthropic-budget-effort") {
688
+ thinking.effortMap = UMANS_MAX_REASONING_EFFORT_MAP;
689
+ }
690
+ if (isRecord(value)) {
691
+ if (value.can_disable === false) {
692
+ thinking.requiresEffort = true;
693
+ }
694
+ if (typeof value.default_level === "string") {
695
+ const defaultLevel = UMANS_REASONING_EFFORT_BY_LEVEL[value.default_level];
696
+ if (defaultLevel !== undefined && efforts.includes(defaultLevel)) {
697
+ thinking.defaultLevel = defaultLevel;
698
+ }
699
+ }
700
+ }
701
+ return thinking;
702
+ }
703
+
704
+ function mapUmansModelInfo(
705
+ modelId: string,
706
+ raw: UmansModelInfo,
707
+ baseUrl: string,
708
+ reference: ModelSpec<"anthropic-messages"> | undefined,
709
+ ): ModelSpec<"anthropic-messages"> | null {
710
+ if (!modelId) return null;
711
+ const capabilities = isRecord(raw.capabilities) ? raw.capabilities : {};
712
+ const supportsTools = capabilities.supports_tools;
713
+ const thinking = mapUmansThinkingConfig(capabilities.reasoning);
714
+ return {
715
+ ...reference,
716
+ id: modelId,
717
+ name: toModelName(raw.display_name, toModelName(raw.name, modelId)),
718
+ api: "anthropic-messages",
719
+ provider: "umans",
720
+ baseUrl,
721
+ compat: { ...reference?.compat, escapeBuiltinToolNames: true },
722
+ reasoning: thinking !== undefined,
723
+ ...(thinking ? { thinking } : {}),
724
+ input: umansSupportsVision(capabilities.supports_vision) ? ["text", "image"] : ["text"],
725
+ ...(supportsTools === false ? { supportsTools: false } : {}),
726
+ cost: reference?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
727
+ contextWindow: toPositiveNumber(capabilities.context_window, reference?.contextWindow ?? null),
728
+ maxTokens: toPositiveNumber(
729
+ capabilities.recommended_max_tokens,
730
+ toPositiveNumber(capabilities.max_completion_tokens, reference?.maxTokens ?? null),
731
+ ),
732
+ };
733
+ }
734
+
735
+ async function fetchUmansModelsInfo(options: {
736
+ baseUrl: string;
737
+ apiKey?: string;
738
+ fetch?: FetchImpl;
739
+ references: Map<string, ModelSpec<"anthropic-messages">>;
740
+ }): Promise<ModelSpec<"anthropic-messages">[] | null> {
741
+ const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(options.baseUrl);
742
+ const requestHeaders: Record<string, string> = { Accept: "application/json" };
743
+ if (options.apiKey) {
744
+ requestHeaders["x-api-key"] = options.apiKey;
745
+ }
746
+ const fetchImpl = discoveryFetch(options.fetch);
747
+ let payload: unknown;
748
+ try {
749
+ const response = await fetchImpl(`${discoveryBaseUrl}${UMANS_MODELS_INFO_PATH}`, {
750
+ method: "GET",
751
+ headers: requestHeaders,
752
+ });
753
+ if (!response.ok) {
754
+ return null;
755
+ }
756
+ payload = await response.json();
757
+ } catch (error) {
758
+ throw new Error("Failed to fetch Umans models info", { cause: error });
759
+ }
760
+ if (!isRecord(payload)) {
761
+ return null;
762
+ }
763
+ const models: ModelSpec<"anthropic-messages">[] = [];
764
+ for (const [modelId, value] of Object.entries(payload)) {
765
+ if (!isRecord(value)) continue;
766
+ const mapped = mapUmansModelInfo(modelId, value, options.baseUrl, options.references.get(modelId));
767
+ if (mapped) {
768
+ models.push(mapped);
769
+ }
770
+ }
771
+ return models.sort((left, right) => left.id.localeCompare(right.id));
772
+ }
773
+
774
+ export function umansModelManagerOptions(config?: UmansModelManagerConfig): ModelManagerOptions<"anthropic-messages"> {
775
+ const apiKey = config?.apiKey;
776
+ const baseUrl = normalizeUmansBaseUrl(config?.baseUrl);
777
+ const references = createBundledReferenceMap<"anthropic-messages">("umans");
778
+ return {
779
+ providerId: "umans",
780
+ dynamicModelsAuthoritative: true,
781
+ dropCachedModelIdsOnStaticMismatch: UMANS_VIA_HANDOFF_MODEL_IDS,
782
+ fetchDynamicModels: () => fetchUmansModelsInfo({ baseUrl, apiKey, fetch: config?.fetch, references }),
783
+ };
784
+ }
785
+ // ---------------------------------------------------------------------------
786
+ // 1. OpenAI
787
+ // ---------------------------------------------------------------------------
788
+
789
+ export interface OpenAIModelManagerConfig {
790
+ apiKey?: string;
791
+ baseUrl?: string;
792
+ fetch?: FetchImpl;
793
+ }
794
+
795
+ export function openaiModelManagerOptions(config?: OpenAIModelManagerConfig): ModelManagerOptions<"openai-responses"> {
796
+ const apiKey = config?.apiKey;
797
+ const baseUrl = config?.baseUrl ?? "https://api.openai.com/v1";
798
+ const references = createBundledReferenceMap<"openai-responses">("openai");
799
+ return {
800
+ providerId: "openai",
801
+ ...(apiKey && {
802
+ fetchDynamicModels: () =>
803
+ fetchOpenAICompatibleModels({
804
+ api: "openai-responses",
805
+ provider: "openai",
806
+ baseUrl,
807
+ apiKey,
808
+ filterModel: (_entry, model) => isLikelyOpenAIResponsesModelId(model.id, references),
809
+ mapModel: (entry, defaults) => {
810
+ const reference = references.get(defaults.id);
811
+ return mapWithBundledReference(entry, defaults, reference);
812
+ },
813
+ fetch: config?.fetch,
814
+ }),
815
+ }),
816
+ };
817
+ }
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // 2. Groq
821
+ // ---------------------------------------------------------------------------
822
+
823
+ export interface GroqModelManagerConfig {
824
+ apiKey?: string;
825
+ baseUrl?: string;
826
+ fetch?: FetchImpl;
827
+ }
828
+
829
+ export function groqModelManagerOptions(config?: GroqModelManagerConfig): ModelManagerOptions<"openai-completions"> {
830
+ return createSimpleOpenAICompletionsOptions("groq", "https://api.groq.com/openai/v1", config);
831
+ }
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // 3. Cerebras
835
+ // ---------------------------------------------------------------------------
836
+
837
+ const CEREBRAS_IMAGE_INPUT_MODEL_IDS = new Set(["gemma-4-31b"]);
838
+
839
+ function applyCerebrasDiscoveryOverrides(model: ModelSpec<"openai-completions">): ModelSpec<"openai-completions"> {
840
+ if (!CEREBRAS_IMAGE_INPUT_MODEL_IDS.has(model.id)) {
841
+ return model;
842
+ }
843
+ return {
844
+ ...model,
845
+ input: ["text", "image"],
846
+ };
847
+ }
848
+
849
+ export interface CerebrasModelManagerConfig {
850
+ apiKey?: string;
851
+ baseUrl?: string;
852
+ fetch?: FetchImpl;
853
+ }
854
+
855
+ export function cerebrasModelManagerOptions(
856
+ config?: CerebrasModelManagerConfig,
857
+ ): ModelManagerOptions<"openai-completions"> {
858
+ const apiKey = config?.apiKey;
859
+ const baseUrl = config?.baseUrl ?? "https://api.cerebras.ai/v1";
860
+ const references = createBundledReferenceMap<"openai-completions">("cerebras");
861
+ return {
862
+ providerId: "cerebras",
863
+ ...(apiKey && {
864
+ fetchDynamicModels: () =>
865
+ fetchOpenAICompatibleModels({
866
+ api: "openai-completions",
867
+ provider: "cerebras",
868
+ baseUrl,
869
+ apiKey,
870
+ mapModel: (entry, defaults) => {
871
+ const reference = references.get(defaults.id);
872
+ const model = mapWithBundledReference(entry, defaults, reference);
873
+ return applyCerebrasDiscoveryOverrides(model);
874
+ },
875
+ fetch: config?.fetch,
876
+ }),
877
+ }),
878
+ };
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // 4. Hugging Face
883
+ // ---------------------------------------------------------------------------
884
+
885
+ export interface HuggingfaceModelManagerConfig {
886
+ apiKey?: string;
887
+ baseUrl?: string;
888
+ fetch?: FetchImpl;
889
+ }
890
+
891
+ export function huggingfaceModelManagerOptions(
892
+ config?: HuggingfaceModelManagerConfig,
893
+ ): ModelManagerOptions<"openai-completions"> {
894
+ return createSimpleOpenAICompletionsOptions("huggingface", "https://router.huggingface.co/v1", config);
895
+ }
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // 5. NVIDIA
899
+ // ---------------------------------------------------------------------------
900
+
901
+ export interface NvidiaModelManagerConfig {
902
+ apiKey?: string;
903
+ baseUrl?: string;
904
+ fetch?: FetchImpl;
905
+ }
906
+
907
+ export function nvidiaModelManagerOptions(
908
+ config?: NvidiaModelManagerConfig,
909
+ ): ModelManagerOptions<"openai-completions"> {
910
+ return createSimpleOpenAICompletionsOptions("nvidia", "https://integrate.api.nvidia.com/v1", config);
911
+ }
912
+
913
+ // ---------------------------------------------------------------------------
914
+ // 6. xAI
915
+ // ---------------------------------------------------------------------------
916
+
917
+ export interface XaiModelManagerConfig {
918
+ apiKey?: string;
919
+ baseUrl?: string;
920
+ fetch?: FetchImpl;
921
+ }
922
+
923
+ export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-completions"> {
924
+ return createSimpleOpenAICompletionsOptions("xai", "https://api.x.ai/v1", config);
925
+ }
926
+
927
+ export interface XaiOAuthModelManagerConfig {
928
+ apiKey?: string;
929
+ baseUrl?: string;
930
+ fetch?: FetchImpl;
931
+ }
932
+
933
+ interface XAICuratedModel {
934
+ id: string;
935
+ contextWindow: number;
936
+ name?: string;
937
+ /** Whether the model reasons natively. Defaults to true for Grok-4.x family. */
938
+ reasoning?: boolean;
939
+ /**
940
+ * Whether xAI accepts the `reasoning.effort` wire param for this model.
941
+ * Default true. When false: the picker hides the effort dial (via
942
+ * getSupportedEfforts in model-thinking.ts) AND the wire omits the param —
943
+ * both derive from `isGrokReasoningEffortCapable` (identity/family.ts), the
944
+ * single allowlist shared by this curated layer and the compat builder.
945
+ */
946
+ supportsReasoningEffort?: boolean;
947
+ /**
948
+ * Input modalities this model accepts. Defaults to `["text"]` when absent.
949
+ * Vision-capable Grok models MUST list `"image"` here so the curated layer
950
+ * overrides `fetchOpenAICompatibleModels`' default of `["text"]` (which
951
+ * otherwise strips image capability on every online refresh).
952
+ */
953
+ input?: ("text" | "image")[];
954
+ }
955
+
956
+ // Source of truth for the xai-oauth chat picker. Top of list = headline.
957
+ // Context windows from hermes-agent/agent/model_metadata.py:205-220
958
+ // ("Values sourced from models.dev (2026-04)"). grok-build is xAI's
959
+ // coding-fine-tuned chat model; 512K context per user spec (2026-05-17).
960
+ //
961
+ // supportsReasoningEffort=false entries reason natively but reject the wire
962
+ // `reasoning.effort` param (api.x.ai returns HTTP 400). The corresponding
963
+ // omit/include/history replay defaults live in catalog compat so every
964
+ // OpenAI-family endpoint consumes the same constraint.
965
+ export const XAI_OAUTH_CURATED_MODELS: readonly XAICuratedModel[] = [
966
+ {
967
+ id: "grok-build",
968
+ contextWindow: 512_000,
969
+ name: "Grok Build",
970
+ supportsReasoningEffort: false,
971
+ input: ["text", "image"],
972
+ },
973
+ {
974
+ id: "grok-build-0.1",
975
+ contextWindow: 256_000,
976
+ name: "Grok Build 0.1",
977
+ supportsReasoningEffort: false,
978
+ input: ["text", "image"],
979
+ },
980
+ { id: "grok-4.3", contextWindow: 1_000_000, name: "Grok 4.3", input: ["text", "image"] },
981
+ // grok-4.20-multi-agent-0309 is text-only per the bundled catalog; omit `input` for the default.
982
+ { id: "grok-4.20-multi-agent-0309", contextWindow: 2_000_000, name: "Grok 4.20 (Multi-Agent)" },
983
+ {
984
+ id: "grok-4.20-0309-reasoning",
985
+ contextWindow: 2_000_000,
986
+ name: "Grok 4.20 (Reasoning)",
987
+ supportsReasoningEffort: false,
988
+ input: ["text", "image"],
989
+ },
990
+ {
991
+ id: "grok-4.20-0309-non-reasoning",
992
+ contextWindow: 2_000_000,
993
+ name: "Grok 4.20 (Non-Reasoning)",
994
+ reasoning: false,
995
+ input: ["text", "image"],
996
+ },
997
+ // Cursor's "Composer 2.5 Fast" exposed via SuperGrok: non-reasoning,
998
+ // text-only, 200K context (mirrors Cursor's composer-* catalog entries).
999
+ // Off the Grok effort-capable allowlist; reasoning:false also hides the effort dial.
1000
+ {
1001
+ id: "grok-composer-2.5-fast",
1002
+ contextWindow: 200_000,
1003
+ name: "Grok Composer 2.5 Fast",
1004
+ reasoning: false,
1005
+ input: ["text"],
1006
+ },
1007
+ ] as const;
1008
+
1009
+ // xAI /v1/models returns chat, image, voice, and STT entries. Tool surfaces
1010
+ // route through dedicated tools (generate_image, tts) with their own model
1011
+ // strings; the chat picker MUST exclude these prefixes or selecting them 400s.
1012
+ const XAI_NON_CHAT_PREFIXES = ["grok-imagine-", "grok-stt-", "grok-voice-"] as const;
1013
+
1014
+ function withXaiOAuthCompatDefaults(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> {
1015
+ const compat = {
1016
+ ...(model.compat ?? {}),
1017
+ includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? false,
1018
+ filterReasoningHistory: model.compat?.filterReasoningHistory ?? true,
1019
+ omitReasoningEffort: model.compat?.omitReasoningEffort ?? !isGrokReasoningEffortCapable(model.id),
1020
+ };
1021
+ return { ...model, compat };
1022
+ }
1023
+
1024
+ // Hermes-agent parity: only the `minimal -> low` clamp is applied (see
1025
+ // hermes-agent/agent/transports/codex.py:92 `_effort_clamp = {"minimal":
1026
+ // "low"}`). Hermes sends `xhigh` to xAI verbatim and we match that contract
1027
+ // — let xAI decide if the level is valid for the specific Grok model.
1028
+ // `resolveModelThinking` folds this into `model.thinking.effortMap`, downstream
1029
+ // of the omitReasoningEffort gate in pi-ai's stream.ts.
1030
+ const XAI_REASONING_EFFORT_MAP = { minimal: "low" } as const;
1031
+
1032
+ // xai-oauth's /v1/models exposes no per-request output limit on the OAuth
1033
+ // (Grok Build / SuperGrok) surface, so the curated catalog owns `maxTokens`
1034
+ // like it owns `contextWindow`: each entry mirrors its context window. The
1035
+ // openai-responses wire clamps the actual request to
1036
+ // min(requested, model.maxTokens, OPENAI_MAX_OUTPUT_TOKENS=64000), so this is
1037
+ // just "no model-specific sub-cap below 64k", not an unbounded output budget.
1038
+
1039
+ // Single source of truth for curated → Model fan-in. Used by the static-seed
1040
+ // and the dynamic overlay/inject paths (applyXAIOAuthCuration) so curated
1041
+ // reasoning/effort flags survive an online refresh (xAI's /v1/models lacks
1042
+ // reasoning metadata and fetchOpenAICompatibleModels defaults reasoning to
1043
+ // false). Caller supplies a `base` Model (either a freshly synthesised seed
1044
+ // or a dynamic-fetched entry); the helper layers curated fields on top.
1045
+ // The `minimal -> low` effort clamp (XAI_REASONING_EFFORT_MAP) is always
1046
+ // merged in so dynamic-fetched models — which arrive without curated
1047
+ // compat keys — still get the clamp applyResponsesReasoningParams expects.
1048
+ function mergeCuratedIntoModel(
1049
+ base: ModelSpec<"openai-responses">,
1050
+ curated: XAICuratedModel,
1051
+ ): ModelSpec<"openai-responses"> {
1052
+ const effort = curated.supportsReasoningEffort;
1053
+ const compat = {
1054
+ ...(base.compat ?? {}),
1055
+ reasoningEffortMap: { ...XAI_REASONING_EFFORT_MAP, ...(base.compat?.reasoningEffortMap ?? {}) },
1056
+ includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? false,
1057
+ filterReasoningHistory: base.compat?.filterReasoningHistory ?? true,
1058
+ omitReasoningEffort: base.compat?.omitReasoningEffort ?? !isGrokReasoningEffortCapable(base.id),
1059
+ ...(effort === undefined ? {} : { supportsReasoningEffort: effort }),
1060
+ };
1061
+ return {
1062
+ ...base,
1063
+ contextWindow: curated.contextWindow,
1064
+ maxTokens: curated.contextWindow,
1065
+ name: curated.name ?? base.name,
1066
+ reasoning: curated.reasoning ?? true,
1067
+ input: curated.input ?? base.input,
1068
+ compat,
1069
+ };
1070
+ }
1071
+
1072
+ /**
1073
+ * Overlay/inject curated xai-oauth metadata onto dynamic-fetch results so
1074
+ * a successful `online refresh` doesn't regress vision capability, context
1075
+ * window, reasoning flags, or the effort-dial allowlist.
1076
+ *
1077
+ * Three passes:
1078
+ * 1. Filter `XAI_NON_CHAT_PREFIXES` (picker pollution defense for tool
1079
+ * surfaces routed through dedicated tools — generate_image, tts).
1080
+ * 2. Overlay curated metadata onto dynamic-fetch matches. xAI's /v1/models
1081
+ * does not return context_window or reasoning metadata, so without
1082
+ * this overlay the runtime falls back to the bundled-reference default
1083
+ * (effectively 128k context) and `reasoning: false` (suppressing the
1084
+ * effort dial and stripping thinking metadata downstream).
1085
+ * 3. Inject curated entries missing from the dynamic fetch. Clones the
1086
+ * first surviving entry as a template so required Model fields (api,
1087
+ * provider, baseUrl, cost, etc.) inherit sane defaults. If `filtered`
1088
+ * is empty (offline / no auth) injection is skipped — the descriptor's
1089
+ * defaultModel covers the fallback.
1090
+ *
1091
+ * Order: curated models first in declaration order; then dynamic remainder
1092
+ * in original order.
1093
+ */
1094
+ function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]): ModelSpec<"openai-responses">[] {
1095
+ const filtered = dynamic.filter(e => !XAI_NON_CHAT_PREFIXES.some(p => e.id.startsWith(p)));
1096
+
1097
+ const byId = new Map<string, ModelSpec<"openai-responses">>(filtered.map(e => [e.id, e]));
1098
+ for (const curated of XAI_OAUTH_CURATED_MODELS) {
1099
+ const existing = byId.get(curated.id);
1100
+ if (existing) {
1101
+ byId.set(curated.id, mergeCuratedIntoModel(existing, curated));
1102
+ }
1103
+ }
1104
+
1105
+ const template = filtered[0];
1106
+ if (template) {
1107
+ for (const curated of XAI_OAUTH_CURATED_MODELS) {
1108
+ if (!byId.has(curated.id)) {
1109
+ // Reset id/name on the template before merging so the helper's
1110
+ // `curated.name ?? base.name` clause falls back to curated.id
1111
+ // (the inject contract), not to the unrelated template's label.
1112
+ const base: ModelSpec<"openai-responses"> = { ...template, id: curated.id, name: curated.id };
1113
+ byId.set(curated.id, mergeCuratedIntoModel(base, curated));
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ const curatedIds = new Set(XAI_OAUTH_CURATED_MODELS.map(c => c.id));
1119
+ const curatedFirst = XAI_OAUTH_CURATED_MODELS.map(c => byId.get(c.id)).filter(
1120
+ (e): e is ModelSpec<"openai-responses"> => e !== undefined,
1121
+ );
1122
+ const rest = filtered.filter(e => !curatedIds.has(e.id)).map(withXaiOAuthCompatDefaults);
1123
+ return [...curatedFirst, ...rest];
1124
+ }
1125
+
1126
+ /**
1127
+ * Render `XAI_OAUTH_CURATED_MODELS` as full `ModelSpec<"openai-responses">` entries.
1128
+ *
1129
+ * Single source of truth for the curated to Model fan-in, consumed by both
1130
+ * - {@link xaiOAuthModelManagerOptions} (runtime static seed handed to the model
1131
+ * manager so the picker is populated on a fresh login), and
1132
+ * - \`packages/catalog/scripts/generate-models.ts\` (bundles the same entries into
1133
+ * `models.json`, so the synchronous `ModelRegistry.#loadModels()` boot path
1134
+ * sees `xai-oauth` without waiting for a refresh — fixes the boot-time
1135
+ * default-model reset when `modelRoles.default = "xai-oauth/<id>"`).
1136
+ *
1137
+ * `reasoning` defaults to `true` for the Grok-4.x family; the explicit
1138
+ * `grok-4.20-0309-non-reasoning` entry opts out via `XAICuratedModel.reasoning`.
1139
+ * `maxTokens` mirrors each model's `contextWindow` (the OAuth surface reports
1140
+ * no per-request output limit); the openai-responses wire still clamps the
1141
+ * actual request to OPENAI_MAX_OUTPUT_TOKENS. Mirrors
1142
+ * `hermes-agent/hermes_cli/models.py:_XAI_STATIC_FALLBACK`.
1143
+ */
1144
+ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-responses">[] {
1145
+ const resolvedBaseUrl = baseUrl ?? "https://api.x.ai/v1";
1146
+ return XAI_OAUTH_CURATED_MODELS.map(curated => {
1147
+ // Synthesise a bare base then layer curated metadata via the same helper
1148
+ // the dynamic overlay/inject paths use. `name: curated.id` is a sentinel
1149
+ // the helper rewrites to `curated.name ?? base.name`, so curated.name
1150
+ // wins when set.
1151
+ const base: ModelSpec<"openai-responses"> = {
1152
+ id: curated.id,
1153
+ name: curated.id,
1154
+ api: "openai-responses",
1155
+ provider: "xai-oauth",
1156
+ baseUrl: resolvedBaseUrl,
1157
+ reasoning: true,
1158
+ input: ["text"],
1159
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1160
+ contextWindow: curated.contextWindow,
1161
+ maxTokens: curated.contextWindow,
1162
+ compat: { reasoningEffortMap: XAI_REASONING_EFFORT_MAP },
1163
+ };
1164
+ return mergeCuratedIntoModel(base, curated);
1165
+ });
1166
+ }
1167
+
1168
+ export function xaiOAuthModelManagerOptions(
1169
+ config?: XaiOAuthModelManagerConfig,
1170
+ ): ModelManagerOptions<"openai-responses"> {
1171
+ const defaultBaseUrl = "https://api.x.ai/v1";
1172
+ const resolvedBaseUrl = config?.baseUrl ?? defaultBaseUrl;
1173
+ const base = createSimpleOpenAIResponsesOptions(
1174
+ "xai-oauth" as Parameters<typeof getBundledModels>[0],
1175
+ defaultBaseUrl,
1176
+ config,
1177
+ );
1178
+ // Static seed handed to the runtime model manager so the picker populates on
1179
+ // a fresh login even before `fetchDynamicModels` fires (it is gated on
1180
+ // `config.apiKey` at construction time, and OAuth tokens resolve later via
1181
+ // AuthStorage). \`generate-models.ts\` calls the same builder so \`models.json\`
1182
+ // carries these entries too — making the synchronous `#loadModels()` boot
1183
+ // path honor `modelRoles.default = "xai-oauth/<id>"` without `await refresh()`.
1184
+ const staticModels = buildXaiOAuthStaticSeed(resolvedBaseUrl);
1185
+ if (!base.fetchDynamicModels) {
1186
+ return { ...base, staticModels };
1187
+ }
1188
+ // Wrap fetchDynamicModels so an `online refresh` against xAI's /v1/models
1189
+ // runs through applyXAIOAuthCuration — preserves curated context windows,
1190
+ // vision modality, reasoning flags, and filters tool-only model ids
1191
+ // (grok-imagine-*, grok-stt-*, grok-voice-*) from the chat picker.
1192
+ const inner = base.fetchDynamicModels;
1193
+ return {
1194
+ ...base,
1195
+ staticModels,
1196
+ fetchDynamicModels: async () => {
1197
+ const dynamic = await inner();
1198
+ return dynamic == null ? dynamic : applyXAIOAuthCuration(dynamic);
1199
+ },
1200
+ };
1201
+ }
1202
+
1203
+ // ---------------------------------------------------------------------------
1204
+ // 6.4 AIML API
1205
+ // ---------------------------------------------------------------------------
1206
+
1207
+ const AIML_API_NON_CHAT_MODEL_ID_PATTERN =
1208
+ /(?:^|[/:._-])(?:audio|embed|embedding|embeddings|i2i|i2v|image|speech|t2i|t2v|tts|video)(?:$|[/:._-])/i;
1209
+
1210
+ const AIML_API_NON_CHAT_MODEL_ID_SUBSTRINGS = ["dall-e", "dalle", "flux", "imagen", "sora", "veo", "whisper"] as const;
1211
+
1212
+ export function isLikelyAimlApiChatModelId(id: string): boolean {
1213
+ const normalized = id.trim().toLowerCase();
1214
+ if (!normalized) return false;
1215
+ return (
1216
+ !AIML_API_NON_CHAT_MODEL_ID_PATTERN.test(normalized) &&
1217
+ !AIML_API_NON_CHAT_MODEL_ID_SUBSTRINGS.some(token => normalized.includes(token))
1218
+ );
1219
+ }
1220
+
1221
+ export interface AimlApiModelManagerConfig {
1222
+ apiKey?: string;
1223
+ baseUrl?: string;
1224
+ fetch?: FetchImpl;
1225
+ }
1226
+
1227
+ export function aimlApiModelManagerOptions(
1228
+ config?: AimlApiModelManagerConfig,
1229
+ ): ModelManagerOptions<"openai-completions"> {
1230
+ const apiKey = config?.apiKey;
1231
+ const baseUrl = config?.baseUrl ?? "https://api.aimlapi.com/v1";
1232
+ const references = createBundledReferenceMap<"openai-completions">("aimlapi");
1233
+ return {
1234
+ providerId: "aimlapi",
1235
+ dynamicModelsAuthoritative: true,
1236
+ ...(apiKey && {
1237
+ fetchDynamicModels: () =>
1238
+ fetchOpenAICompatibleModels({
1239
+ api: "openai-completions",
1240
+ provider: "aimlapi",
1241
+ baseUrl,
1242
+ apiKey,
1243
+ filterModel: (_entry, model) => isLikelyAimlApiChatModelId(model.id),
1244
+ mapModel: (entry, defaults) => {
1245
+ const reference = references.get(defaults.id);
1246
+ return mapWithBundledReference(entry, defaults, reference);
1247
+ },
1248
+ fetch: config?.fetch,
1249
+ }),
1250
+ }),
1251
+ };
1252
+ }
1253
+
1254
+ // ---------------------------------------------------------------------------
1255
+ // 6.5 DeepSeek
1256
+ // ---------------------------------------------------------------------------
1257
+
1258
+ export interface DeepSeekModelManagerConfig {
1259
+ apiKey?: string;
1260
+ baseUrl?: string;
1261
+ fetch?: FetchImpl;
1262
+ }
1263
+
1264
+ export function deepseekModelManagerOptions(
1265
+ config?: DeepSeekModelManagerConfig,
1266
+ ): ModelManagerOptions<"openai-completions"> {
1267
+ return createSimpleOpenAICompletionsOptions("deepseek", "https://api.deepseek.com", config);
1268
+ }
1269
+ // ---------------------------------------------------------------------------
1270
+ // 6.7 Zhipu Coding Plan
1271
+ // ---------------------------------------------------------------------------
1272
+
1273
+ export interface ZhipuCodingPlanModelManagerConfig {
1274
+ apiKey?: string;
1275
+ baseUrl?: string;
1276
+ fetch?: FetchImpl;
1277
+ }
1278
+
1279
+ export function zhipuCodingPlanModelManagerOptions(
1280
+ config?: ZhipuCodingPlanModelManagerConfig,
1281
+ ): ModelManagerOptions<"openai-completions"> {
1282
+ const apiKey = config?.apiKey;
1283
+ const baseUrl = config?.baseUrl ?? "https://open.bigmodel.cn/api/coding/paas/v4";
1284
+ return {
1285
+ providerId: "zhipu-coding-plan",
1286
+ ...(apiKey && {
1287
+ fetchDynamicModels: () =>
1288
+ fetchOpenAICompatibleModels({
1289
+ api: "openai-completions",
1290
+ provider: "zhipu-coding-plan",
1291
+ baseUrl,
1292
+ apiKey,
1293
+ mapModel: (
1294
+ _entry: OpenAICompatibleModelRecord,
1295
+ defaults: ModelSpec<"openai-completions">,
1296
+ _context: OpenAICompatibleModelMapperContext<"openai-completions">,
1297
+ ): ModelSpec<"openai-completions"> => {
1298
+ const id = defaults.id;
1299
+ return {
1300
+ ...defaults,
1301
+ reasoning: isReasoningGlmModelId(id) || id.includes("thinking"),
1302
+ input: isGlmVisionModelId(id) ? (["text", "image"] as const) : ["text"],
1303
+ compat: {
1304
+ thinkingFormat: "zai",
1305
+ reasoningContentField: "reasoning_content",
1306
+ supportsDeveloperRole: false,
1307
+ },
1308
+ };
1309
+ },
1310
+ fetch: config?.fetch,
1311
+ }),
1312
+ }),
1313
+ };
1314
+ }
1315
+
1316
+ // ---------------------------------------------------------------------------
1317
+ // 7.5 Fireworks
1318
+ // ---------------------------------------------------------------------------
1319
+
1320
+ /**
1321
+ * Fireworks-published cap for the Kimi K2 family. Fireworks' `/v1/models`
1322
+ * envelope generically reports `max_completion_tokens: 65536` for every Kimi
1323
+ * deployment, but Kimi K2 (instruct / thinking / turbo) on Fireworks is
1324
+ * documented to ship long reasoning traces that should be bounded — capping
1325
+ * at 32,768 prevents handing callers a budget the router cannot honor.
1326
+ * See https://github.com/can1357/oh-my-pi/issues/1849.
1327
+ */
1328
+ export const FIREWORKS_KIMI_MAX_TOKENS = 32_768;
1329
+
1330
+ /**
1331
+ * Returns true for any Kimi K2.x public model id served by Fireworks-backed
1332
+ * providers (`fireworks` direct, `firepass` router). Matches both the public
1333
+ * catalog id (`kimi-k2.5`, `kimi-k2.6`, `kimi-k2.6-turbo`) and the canonical
1334
+ * Fireworks wire id (`accounts/fireworks/{models,routers}/kimi-k2…`).
1335
+ */
1336
+ export function isFireworksKimiK2ModelId(modelId: string): boolean {
1337
+ const trimmed = modelId.toLowerCase();
1338
+ if (trimmed.startsWith("kimi-k2")) return true;
1339
+ return /\/kimi-k2(?:p\d+)?(?:[._-]|$)/.test(trimmed);
1340
+ }
1341
+
1342
+ /**
1343
+ * Clamp the Kimi K2 family's `maxTokens` to {@link FIREWORKS_KIMI_MAX_TOKENS}
1344
+ * on Fireworks-backed providers, leaving every other model untouched.
1345
+ */
1346
+ export function clampFireworksKimiMaxTokens(modelId: string, candidate: number): number;
1347
+ export function clampFireworksKimiMaxTokens(modelId: string, candidate: number | null): number | null;
1348
+ export function clampFireworksKimiMaxTokens(modelId: string, candidate: number | null): number | null {
1349
+ if (candidate === null) return null;
1350
+ return isFireworksKimiK2ModelId(modelId) ? Math.min(candidate, FIREWORKS_KIMI_MAX_TOKENS) : candidate;
1351
+ }
1352
+
1353
+ /**
1354
+ * Kimi K2.7 Code's documented recommended output budget. Some provider
1355
+ * discovery rows report the context-sized `max_completion_tokens` instead.
1356
+ */
1357
+ export const KIMI_K27_CODE_RECOMMENDED_MAX_TOKENS = 32_768;
1358
+
1359
+ export function isKimiK27CodeModelId(modelId: string): boolean {
1360
+ return /(?:^|\/)kimi[-._]?k2(?:[._-]?|p)7[-._]?code(?:[-._]?highspeed)?$/i.test(modelId);
1361
+ }
1362
+
1363
+ export function clampKimiK27CodeMaxTokens(modelId: string, candidate: number): number;
1364
+ export function clampKimiK27CodeMaxTokens(modelId: string, candidate: number | null): number | null;
1365
+ export function clampKimiK27CodeMaxTokens(modelId: string, candidate: number | null): number | null {
1366
+ if (candidate === null) return null;
1367
+ return isKimiK27CodeModelId(modelId) ? Math.min(candidate, KIMI_K27_CODE_RECOMMENDED_MAX_TOKENS) : candidate;
1368
+ }
1369
+
1370
+ /**
1371
+ * Fireworks Fast variants we surface. Each inherits the base model's
1372
+ * limits/modalities/thinking and overrides only the cost with the Standard-column
1373
+ * Fast prices from the Serverless pricing table; `cacheWrite` stays 0 (Fireworks
1374
+ * bills no cache-write). Derived from the bundled base entries so metadata stays
1375
+ * in lockstep, and the runtime auto-falls back to the base id on a failed fast
1376
+ * request. See https://docs.fireworks.ai/serverless/pricing.
1377
+ */
1378
+ const FIREWORKS_FAST_VARIANT_SPECS: ReadonlyArray<{
1379
+ base: string;
1380
+ name: string;
1381
+ cost: { input: number; output: number; cacheRead: number };
1382
+ }> = [
1383
+ { base: "kimi-k2.7-code", name: "Kimi K2.7 Code Fast", cost: { input: 1.9, output: 8, cacheRead: 0.38 } },
1384
+ { base: "kimi-k2.6", name: "Kimi K2.6 Fast", cost: { input: 2, output: 8, cacheRead: 0.3 } },
1385
+ { base: "glm-5.1", name: "GLM-5.1 Fast", cost: { input: 2.8, output: 8.8, cacheRead: 0.52 } },
1386
+ ];
1387
+
1388
+ /**
1389
+ * Build the Fireworks Fast seed by projecting each base bundled spec into a
1390
+ * `<id>-fast` variant. Pushed into the generated catalog (Fast routers never
1391
+ * appear in the serverless control-plane list, so discovery cannot surface
1392
+ * them) and deduped behind any identical previous-snapshot entry.
1393
+ */
1394
+ export function buildFireworksFastSeed(): ModelSpec<"openai-completions">[] {
1395
+ const bundled = createBundledReferenceMap<"openai-completions">("fireworks");
1396
+ const seeds: ModelSpec<"openai-completions">[] = [];
1397
+ for (const variant of FIREWORKS_FAST_VARIANT_SPECS) {
1398
+ const base = bundled.get(variant.base);
1399
+ if (!base) continue;
1400
+ seeds.push({
1401
+ ...base,
1402
+ id: `${variant.base}${FIREWORKS_FAST_SUFFIX}`,
1403
+ name: variant.name,
1404
+ cost: {
1405
+ input: variant.cost.input,
1406
+ output: variant.cost.output,
1407
+ cacheRead: variant.cost.cacheRead,
1408
+ cacheWrite: 0,
1409
+ },
1410
+ });
1411
+ }
1412
+ return seeds;
1413
+ }
1414
+
1415
+ /**
1416
+ * Fireworks DeepSeek V4 accepts effort via `reasoning_effort` but rejects the
1417
+ * DeepSeek-native binary `thinking` toggle when both are present.
1418
+ */
1419
+ export function stripFireworksDeepSeekThinkingToggle(
1420
+ model: ModelSpec<"openai-completions">,
1421
+ publicModelId: string,
1422
+ ): ModelSpec<"openai-completions"> {
1423
+ if (!publicModelId.startsWith("deepseek-v4")) return model;
1424
+ const compat = model.compat;
1425
+ if (!compat?.extraBody || !("thinking" in compat.extraBody)) return model;
1426
+
1427
+ const extraBody = { ...compat.extraBody };
1428
+ delete extraBody.thinking;
1429
+ if (Object.keys(extraBody).length > 0) {
1430
+ return { ...model, compat: { ...compat, extraBody } };
1431
+ }
1432
+
1433
+ const nextCompat = { ...compat };
1434
+ delete nextCompat.extraBody;
1435
+ return { ...model, compat: nextCompat };
1436
+ }
1437
+
1438
+ export interface FireworksModelManagerConfig {
1439
+ apiKey?: string;
1440
+ baseUrl?: string;
1441
+ fetch?: FetchImpl;
1442
+ }
1443
+
1444
+ const FIREWORKS_CONTROL_PLANE_ACCOUNT = "fireworks";
1445
+ const FIREWORKS_SERVERLESS_FILTER = "supports_serverless=true";
1446
+ const FIREWORKS_CONTROL_PLANE_PAGE_SIZE = 200;
1447
+ const FIREWORKS_CONTROL_PLANE_MAX_PAGES = 25;
1448
+
1449
+ /**
1450
+ * One record from the Fireworks control-plane catalog
1451
+ * (`GET /v1/accounts/{account}/models`). This is distinct from the
1452
+ * OpenAI-compatible `/v1/models` inference envelope: the control plane
1453
+ * enumerates the full serverless catalog with camelCase capability metadata,
1454
+ * including on-demand models (e.g. `kimi-k2p7-code`) that never surface in
1455
+ * `/v1/models`. Discovering here is what keeps new serverless models appearing
1456
+ * without catalog edits — see the Fireworks docs `List Models` API.
1457
+ */
1458
+ interface FireworksControlPlaneModel {
1459
+ /** Resource name, e.g. `accounts/fireworks/models/kimi-k2p7-code`. */
1460
+ name?: unknown;
1461
+ displayName?: unknown;
1462
+ contextLength?: unknown;
1463
+ supportsImageInput?: unknown;
1464
+ supportsTools?: unknown;
1465
+ supportsServerless?: unknown;
1466
+ state?: unknown;
1467
+ }
1468
+
1469
+ /**
1470
+ * Derive the control-plane list endpoint from the inference base URL. The
1471
+ * inference API lives under `/inference/v1` while the control plane is
1472
+ * `/v1/accounts/<account>/models` on the same origin, so we route off origin.
1473
+ * Returns null for unparseable overrides (custom gateways) so discovery falls
1474
+ * back to the cached/bundled catalog.
1475
+ */
1476
+ function toFireworksControlPlaneModelsUrl(baseUrl: string, account: string): string | null {
1477
+ try {
1478
+ return `${new URL(baseUrl).origin}/v1/accounts/${account}/models`;
1479
+ } catch {
1480
+ return null;
1481
+ }
1482
+ }
1483
+
1484
+ function mapFireworksControlPlaneModel(
1485
+ record: FireworksControlPlaneModel,
1486
+ publicModelId: string,
1487
+ reference: ModelSpec<"openai-completions"> | undefined,
1488
+ baseUrl: string,
1489
+ ): ModelSpec<"openai-completions"> {
1490
+ const name = toModelName(record.displayName, reference?.name ?? publicModelId);
1491
+ const supportsImage = toBoolean(record.supportsImageInput) === true;
1492
+ const supportsTools = toBoolean(record.supportsTools);
1493
+ const contextWindow = toPositiveNumber(record.contextLength, reference?.contextWindow ?? null);
1494
+ // The control plane reports no max-output budget; default the Kimi family to
1495
+ // its published cap, everyone else to the discovery fallback, then clamp.
1496
+ const fallbackMaxTokens = isFireworksKimiK2ModelId(publicModelId) ? FIREWORKS_KIMI_MAX_TOKENS : null;
1497
+ const maxTokens = clampFireworksKimiMaxTokens(publicModelId, reference?.maxTokens ?? fallbackMaxTokens);
1498
+ const base: ModelSpec<"openai-completions"> = reference ?? {
1499
+ id: publicModelId,
1500
+ name,
1501
+ api: "openai-completions",
1502
+ provider: "fireworks",
1503
+ baseUrl,
1504
+ reasoning: false,
1505
+ input: ["text"],
1506
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1507
+ contextWindow,
1508
+ maxTokens,
1509
+ };
1510
+ const model: ModelSpec<"openai-completions"> = {
1511
+ ...base,
1512
+ id: publicModelId,
1513
+ api: "openai-completions",
1514
+ provider: "fireworks",
1515
+ baseUrl,
1516
+ name,
1517
+ // The control plane exposes capability flags but no reasoning bit. Every
1518
+ // serverless chat LLM Fireworks ships reasons, and `buildModel` derives
1519
+ // the Fireworks effort map from the id at build time — so default
1520
+ // unbundled models to reasoning while bundled references keep their value.
1521
+ reasoning: reference?.reasoning ?? true,
1522
+ input: supportsImage ? ["text", "image"] : (reference?.input ?? ["text"]),
1523
+ contextWindow,
1524
+ maxTokens,
1525
+ ...(supportsTools === false ? { supportsTools: false } : {}),
1526
+ };
1527
+ return stripFireworksDeepSeekThinkingToggle(model, publicModelId);
1528
+ }
1529
+
1530
+ /**
1531
+ * Discover Fireworks serverless models via the control-plane `List Models`
1532
+ * API (`supports_serverless=true`), paginating the full catalog. Returns null
1533
+ * on any transport/protocol failure so the model manager keeps the cached or
1534
+ * bundled catalog rather than caching a truncated list as authoritative.
1535
+ */
1536
+ async function fetchFireworksServerlessModels(options: {
1537
+ baseUrl: string;
1538
+ apiKey: string;
1539
+ resolveReference: (publicModelId: string) => ModelSpec<"openai-completions"> | undefined;
1540
+ fetch?: FetchImpl;
1541
+ }): Promise<ModelSpec<"openai-completions">[] | null> {
1542
+ const listUrl = toFireworksControlPlaneModelsUrl(options.baseUrl, FIREWORKS_CONTROL_PLANE_ACCOUNT);
1543
+ if (!listUrl) return null;
1544
+ const fetchImpl = discoveryFetch(options.fetch);
1545
+ const collected = new Map<string, ModelSpec<"openai-completions">>();
1546
+ let pageToken = "";
1547
+ for (let page = 0; page < FIREWORKS_CONTROL_PLANE_MAX_PAGES; page++) {
1548
+ const url = new URL(listUrl);
1549
+ url.searchParams.set("filter", FIREWORKS_SERVERLESS_FILTER);
1550
+ url.searchParams.set("pageSize", String(FIREWORKS_CONTROL_PLANE_PAGE_SIZE));
1551
+ if (pageToken) url.searchParams.set("pageToken", pageToken);
1552
+ let response: Response;
1553
+ try {
1554
+ response = await fetchImpl(url.toString(), {
1555
+ method: "GET",
1556
+ headers: { Accept: "application/json", Authorization: `Bearer ${options.apiKey}` },
1557
+ });
1558
+ } catch {
1559
+ return null;
1560
+ }
1561
+ if (!response.ok) return null;
1562
+ let payload: unknown;
1563
+ try {
1564
+ payload = await response.json();
1565
+ } catch {
1566
+ return null;
1567
+ }
1568
+ if (!isRecord(payload)) return null;
1569
+ const models = Array.isArray(payload.models) ? payload.models : [];
1570
+ for (const entry of models) {
1571
+ if (!isRecord(entry)) continue;
1572
+ const record = entry as FireworksControlPlaneModel;
1573
+ if (toBoolean(record.supportsServerless) !== true) continue;
1574
+ if (typeof record.state === "string" && record.state !== "READY") continue;
1575
+ const wireName = typeof record.name === "string" ? record.name : "";
1576
+ if (!wireName) continue;
1577
+ const publicModelId = toFireworksPublicModelId(wireName);
1578
+ if (!publicModelId) continue;
1579
+ collected.set(
1580
+ publicModelId,
1581
+ mapFireworksControlPlaneModel(
1582
+ record,
1583
+ publicModelId,
1584
+ options.resolveReference(publicModelId),
1585
+ options.baseUrl,
1586
+ ),
1587
+ );
1588
+ }
1589
+ const next = typeof payload.nextPageToken === "string" ? payload.nextPageToken : "";
1590
+ if (!next) break;
1591
+ pageToken = next;
1592
+ }
1593
+ return Array.from(collected.values());
1594
+ }
1595
+
1596
+ function createModelsDevReferenceMap<TApi extends Api>(
1597
+ models: readonly ModelSpec<Api>[],
1598
+ ): Map<string, ModelSpec<TApi>> {
1599
+ const references = new Map<string, ModelSpec<TApi>>();
1600
+ for (const model of models) {
1601
+ const candidate = model as ModelSpec<TApi>;
1602
+ const existing = references.get(candidate.id);
1603
+ if (!existing) {
1604
+ references.set(candidate.id, candidate);
1605
+ continue;
1606
+ }
1607
+ if ((candidate.contextWindow ?? 0) > (existing.contextWindow ?? 0)) {
1608
+ references.set(candidate.id, candidate);
1609
+ continue;
1610
+ }
1611
+ if (
1612
+ candidate.contextWindow === existing.contextWindow &&
1613
+ (candidate.maxTokens ?? 0) > (existing.maxTokens ?? 0)
1614
+ ) {
1615
+ references.set(candidate.id, candidate);
1616
+ }
1617
+ }
1618
+ return references;
1619
+ }
1620
+
1621
+ async function loadModelsDevReferences<TApi extends Api>(fetchImpl?: FetchImpl): Promise<Map<string, ModelSpec<TApi>>> {
1622
+ try {
1623
+ const payload = await fetchModelsDevPayload(fetchImpl);
1624
+ return createModelsDevReferenceMap<TApi>(
1625
+ mapModelsDevToModels(payload as Record<string, unknown>, MODELS_DEV_PROVIDER_DESCRIPTORS),
1626
+ );
1627
+ } catch {
1628
+ return new Map<string, ModelSpec<TApi>>();
1629
+ }
1630
+ }
1631
+ export function fireworksModelManagerOptions(
1632
+ config?: FireworksModelManagerConfig,
1633
+ ): ModelManagerOptions<"openai-completions"> {
1634
+ const apiKey = config?.apiKey;
1635
+ const baseUrl = config?.baseUrl ?? "https://api.fireworks.ai/inference/v1";
1636
+ const bundledReferences = createReferenceResolver(createBundledReferenceMap<"openai-completions">("fireworks"));
1637
+ return {
1638
+ providerId: "fireworks",
1639
+ ...(apiKey && {
1640
+ fetchDynamicModels: async () => {
1641
+ const modelsDevReferences = await loadModelsDevReferences<"openai-completions">(config?.fetch);
1642
+ return fetchFireworksServerlessModels({
1643
+ baseUrl,
1644
+ apiKey,
1645
+ resolveReference: publicModelId =>
1646
+ modelsDevReferences.get(publicModelId) ?? bundledReferences(publicModelId),
1647
+ fetch: config?.fetch,
1648
+ });
1649
+ },
1650
+ }),
1651
+ };
1652
+ }
1653
+
1654
+ // ---------------------------------------------------------------------------
1655
+ // 7.6 Fire Pass (Fireworks Kimi K2.6 Turbo subscription)
1656
+ // ---------------------------------------------------------------------------
1657
+
1658
+ export interface FirepassModelManagerConfig {
1659
+ apiKey?: string;
1660
+ baseUrl?: string;
1661
+ fetch?: FetchImpl;
1662
+ }
1663
+
1664
+ /**
1665
+ * Fire Pass is a Fireworks subscription product that exposes a single router
1666
+ * model (Kimi K2.6 Turbo) under `accounts/fireworks/routers/kimi-k2p6-turbo`.
1667
+ * The dedicated `fpk_…` keys do not authorize `/v1/models`, so this manager
1668
+ * never performs dynamic discovery — the bundled catalog entry is canonical.
1669
+ * See https://docs.fireworks.ai/firepass.
1670
+ */
1671
+ export function firepassModelManagerOptions(
1672
+ _config?: FirepassModelManagerConfig,
1673
+ ): ModelManagerOptions<"openai-completions"> {
1674
+ return {
1675
+ providerId: "firepass",
1676
+ };
1677
+ }
1678
+
1679
+ // ---------------------------------------------------------------------------
1680
+ // 7.7 Wafer Serverless
1681
+ // ---------------------------------------------------------------------------
1682
+
1683
+ export interface WaferModelManagerConfig {
1684
+ apiKey?: string;
1685
+ baseUrl?: string;
1686
+ fetch?: FetchImpl;
1687
+ }
1688
+
1689
+ const WAFER_DEFAULT_BASE_URL = "https://pass.wafer.ai/v1";
1690
+ const WAFER_MAX_TOKENS_CAP = 65536;
1691
+
1692
+ /**
1693
+ * Mapper for Wafer Serverless `/v1/models` records.
1694
+ *
1695
+ * Wafer wraps each entry with a `wafer` envelope describing capabilities and
1696
+ * pricing. The mapper folds that metadata into the canonical
1697
+ * `ModelSpec<"openai-completions">` shape and applies upstream-specific thinking
1698
+ * compat when the entry advertises reasoning support. Wafer pricing is exposed
1699
+ * through internal wholesale units; the public Serverless rate equals
1700
+ * `cents × 125 / 10000`.
1701
+ */
1702
+ interface WaferRecord {
1703
+ context_length?: unknown;
1704
+ tier?: unknown;
1705
+ provider?: unknown;
1706
+ capabilities?: { vision?: unknown; reasoning?: unknown; tools?: unknown };
1707
+ pricing?: {
1708
+ input_cents_per_million?: unknown;
1709
+ output_cents_per_million?: unknown;
1710
+ cache_read_cents_per_million?: unknown;
1711
+ };
1712
+ display_name?: unknown;
1713
+ }
1714
+
1715
+ function readWaferRecord(entry: OpenAICompatibleModelRecord): WaferRecord | undefined {
1716
+ const raw = (entry as { wafer?: unknown }).wafer;
1717
+ return raw && typeof raw === "object" ? (raw as WaferRecord) : undefined;
1718
+ }
1719
+
1720
+ function mapWaferModel(
1721
+ providerId: "wafer-serverless",
1722
+ baseUrl: string,
1723
+ entry: OpenAICompatibleModelRecord,
1724
+ defaults: ModelSpec<"openai-completions">,
1725
+ ): ModelSpec<"openai-completions"> {
1726
+ const wafer = readWaferRecord(entry);
1727
+ const capabilities = wafer?.capabilities ?? {};
1728
+ const reasoning = capabilities.reasoning === true;
1729
+ const vision = capabilities.vision === true;
1730
+ const supportsTools = toBoolean(capabilities.tools) === false ? false : undefined;
1731
+ const contextWindow = toPositiveNumber(
1732
+ wafer?.context_length,
1733
+ toPositiveNumber((entry as { max_model_len?: unknown }).max_model_len, defaults.contextWindow),
1734
+ );
1735
+ const maxTokens = contextWindow !== null ? Math.min(contextWindow, WAFER_MAX_TOKENS_CAP) : null;
1736
+ const pricing = wafer?.pricing ?? {};
1737
+ const cost = {
1738
+ input: (toPositiveNumber(pricing.input_cents_per_million, 0) * 125) / 10000,
1739
+ output: (toPositiveNumber(pricing.output_cents_per_million, 0) * 125) / 10000,
1740
+ cacheRead: (toPositiveNumber(pricing.cache_read_cents_per_million, 0) * 125) / 10000,
1741
+ cacheWrite: 0,
1742
+ };
1743
+ const name = toModelName(wafer?.display_name, defaults.name);
1744
+ const base: ModelSpec<"openai-completions"> = {
1745
+ ...defaults,
1746
+ id: defaults.id,
1747
+ name,
1748
+ api: "openai-completions",
1749
+ provider: providerId,
1750
+ baseUrl,
1751
+ reasoning,
1752
+ input: vision ? (["text", "image"] as const) : ["text"],
1753
+ cost,
1754
+ contextWindow,
1755
+ maxTokens,
1756
+ ...(supportsTools === false ? { supportsTools } : {}),
1757
+ };
1758
+ if (reasoning) {
1759
+ // Wafer's `wafer.provider` envelope tells us which upstream backend serves
1760
+ // the model. Each upstream accepts a different thinking-control parameter
1761
+ // on the wire — Wafer passes the body through, so we must mirror the
1762
+ // upstream's native shape:
1763
+ // - zai (GLM) and moonshotai (Kimi) → `thinking: { type: "enabled" | "disabled" }`
1764
+ // - qwen (Alibaba) → top-level `enable_thinking: boolean`
1765
+ // - deepseek → `reasoning_effort` (DeepSeek effort map; the model always
1766
+ // reasons when invoked, replay of `reasoning_content` is required on
1767
+ // tool-call turns — both handled by `detectOpenAICompat` from the id).
1768
+ // For unknown upstreams we omit `thinkingFormat` and let the per-id
1769
+ // detection in `detectOpenAICompat` pick a safe default.
1770
+ const upstream = typeof wafer?.provider === "string" ? wafer.provider : undefined;
1771
+ const thinkingFormat: "zai" | "qwen" | undefined =
1772
+ upstream === "zai" || upstream === "moonshotai" ? "zai" : upstream === "qwen" ? "qwen" : undefined;
1773
+ return {
1774
+ ...base,
1775
+ compat: {
1776
+ ...(thinkingFormat ? { thinkingFormat } : {}),
1777
+ reasoningContentField: "reasoning_content",
1778
+ supportsDeveloperRole: false,
1779
+ },
1780
+ };
1781
+ }
1782
+ return {
1783
+ ...base,
1784
+ compat: { supportsDeveloperRole: false },
1785
+ };
1786
+ }
1787
+
1788
+ export function waferServerlessModelManagerOptions(
1789
+ config?: WaferModelManagerConfig,
1790
+ ): ModelManagerOptions<"openai-completions"> {
1791
+ const apiKey = config?.apiKey;
1792
+ const baseUrl = config?.baseUrl ?? WAFER_DEFAULT_BASE_URL;
1793
+ const providerId = "wafer-serverless" as const;
1794
+ return {
1795
+ providerId,
1796
+ ...(apiKey && {
1797
+ fetchDynamicModels: () =>
1798
+ fetchOpenAICompatibleModels({
1799
+ api: "openai-completions",
1800
+ provider: providerId,
1801
+ baseUrl,
1802
+ apiKey,
1803
+ mapModel: (entry, defaults) => mapWaferModel(providerId, baseUrl, entry, defaults),
1804
+ fetch: config?.fetch,
1805
+ }),
1806
+ }),
1807
+ };
1808
+ }
1809
+
1810
+ // ---------------------------------------------------------------------------
1811
+ // 7. Mistral
1812
+ // ---------------------------------------------------------------------------
1813
+
1814
+ export interface MistralModelManagerConfig {
1815
+ apiKey?: string;
1816
+ baseUrl?: string;
1817
+ fetch?: FetchImpl;
1818
+ }
1819
+
1820
+ export function mistralModelManagerOptions(
1821
+ config?: MistralModelManagerConfig,
1822
+ ): ModelManagerOptions<"openai-completions"> {
1823
+ return createSimpleOpenAICompletionsOptions("mistral", "https://api.mistral.ai/v1", config);
1824
+ }
1825
+
1826
+ // ---------------------------------------------------------------------------
1827
+ // 8. OpenCode
1828
+ // ---------------------------------------------------------------------------
1829
+
1830
+ export interface OpenCodeModelManagerConfig {
1831
+ apiKey?: string;
1832
+ baseUrl?: string;
1833
+ fetch?: FetchImpl;
1834
+ }
1835
+
1836
+ function normalizeOpenCodeBasePath(baseUrl: string | undefined, fallbackBasePath: string): string {
1837
+ const value = normalizeAnthropicBaseUrl(baseUrl, fallbackBasePath);
1838
+ return value.endsWith("/v1") ? value.slice(0, -3) : value;
1839
+ }
1840
+
1841
+ function openCodeBaseUrlForApi(api: Api, basePath: string): string {
1842
+ return api === "anthropic-messages" ? basePath : `${basePath}/v1`;
1843
+ }
1844
+
1845
+ function openCodeModelManagerOptions(
1846
+ providerId: "opencode-go" | "opencode-zen",
1847
+ defaultBasePath: string,
1848
+ config?: OpenCodeModelManagerConfig,
1849
+ ): ModelManagerOptions<Api> {
1850
+ const apiKey = config?.apiKey;
1851
+ const basePath = normalizeOpenCodeBasePath(config?.baseUrl, defaultBasePath);
1852
+ const discoveryBaseUrl = openCodeBaseUrlForApi("openai-completions", basePath);
1853
+ const references = createBundledReferenceMap<Api>(providerId);
1854
+ return {
1855
+ providerId,
1856
+ ...(apiKey && {
1857
+ fetchDynamicModels: () =>
1858
+ fetchOpenAICompatibleModels<Api>({
1859
+ api: "openai-completions",
1860
+ provider: providerId,
1861
+ baseUrl: discoveryBaseUrl,
1862
+ apiKey,
1863
+ mapModel: (entry, defaults) => {
1864
+ const reference = references.get(defaults.id);
1865
+ const name = toModelName(entry.name, reference?.name ?? defaults.name);
1866
+ if (!reference) {
1867
+ return {
1868
+ ...defaults,
1869
+ name,
1870
+ };
1871
+ }
1872
+ return {
1873
+ ...reference,
1874
+ id: defaults.id,
1875
+ name,
1876
+ baseUrl: openCodeBaseUrlForApi(reference.api, basePath),
1877
+ contextWindow: toPositiveNumber(entry.context_length, reference.contextWindow),
1878
+ maxTokens: toPositiveNumber(entry.max_completion_tokens, reference.maxTokens),
1879
+ };
1880
+ },
1881
+ fetch: config?.fetch,
1882
+ }),
1883
+ }),
1884
+ };
1885
+ }
1886
+
1887
+ export function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api> {
1888
+ return openCodeModelManagerOptions("opencode-zen", "https://opencode.ai/zen", config);
1889
+ }
1890
+
1891
+ export function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api> {
1892
+ return openCodeModelManagerOptions("opencode-go", "https://opencode.ai/zen/go", config);
1893
+ }
1894
+
1895
+ // ---------------------------------------------------------------------------
1896
+ // 9. Ollama
1897
+ // ---------------------------------------------------------------------------
1898
+
1899
+ export interface OllamaModelManagerConfig {
1900
+ apiKey?: string;
1901
+ baseUrl?: string;
1902
+ fetch?: FetchImpl;
1903
+ }
1904
+
1905
+ export function ollamaModelManagerOptions(config?: OllamaModelManagerConfig): ModelManagerOptions<"openai-responses"> {
1906
+ const apiKey = config?.apiKey;
1907
+ const baseUrl = normalizeOllamaBaseUrl(config?.baseUrl);
1908
+ const nativeBaseUrl = toOllamaNativeBaseUrl(baseUrl);
1909
+ const references = createBundledReferenceMap<"openai-responses">("ollama" as Parameters<typeof getBundledModels>[0]);
1910
+ const resolveMetadata = createOllamaMetadataResolver(nativeBaseUrl, config?.fetch);
1911
+ return {
1912
+ providerId: "ollama",
1913
+ fetchDynamicModels: async () => {
1914
+ const openAiCompatible = await fetchOpenAICompatibleModels({
1915
+ api: "openai-responses",
1916
+ provider: "ollama",
1917
+ baseUrl,
1918
+ apiKey,
1919
+ mapModel: (entry, defaults) => {
1920
+ const reference = references.get(defaults.id);
1921
+ if (!reference) {
1922
+ return {
1923
+ ...defaults,
1924
+ name: toModelName(entry.name, defaults.name),
1925
+ contextWindow: OLLAMA_FALLBACK_CONTEXT_WINDOW,
1926
+ maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
1927
+ };
1928
+ }
1929
+ return mapWithBundledReference(entry, defaults, reference);
1930
+ },
1931
+ fetch: config?.fetch,
1932
+ });
1933
+ if (openAiCompatible && openAiCompatible.length > 0) {
1934
+ await Promise.all(
1935
+ openAiCompatible.map(async model => {
1936
+ const metadata = await resolveMetadata(model.id);
1937
+ model.contextWindow = metadata.contextWindow;
1938
+ if (metadata.reasoning !== undefined) {
1939
+ model.reasoning = metadata.reasoning;
1940
+ model.thinking = metadata.thinking;
1941
+ }
1942
+ if (metadata.input) {
1943
+ model.input = metadata.input;
1944
+ }
1945
+ }),
1946
+ );
1947
+ return openAiCompatible;
1948
+ }
1949
+ const nativeFallback = await fetchOllamaNativeModels(baseUrl, resolveMetadata, config?.fetch);
1950
+ if (nativeFallback && nativeFallback.length > 0) {
1951
+ return nativeFallback;
1952
+ }
1953
+ return openAiCompatible;
1954
+ },
1955
+ };
1956
+ }
1957
+
1958
+ // ---------------------------------------------------------------------------
1959
+ // 10. OpenRouter
1960
+ // ---------------------------------------------------------------------------
1961
+
1962
+ export interface OpenRouterModelManagerConfig {
1963
+ apiKey?: string;
1964
+ baseUrl?: string;
1965
+ fetch?: FetchImpl;
1966
+ }
1967
+
1968
+ export function openrouterModelManagerOptions(
1969
+ config?: OpenRouterModelManagerConfig,
1970
+ ): ModelManagerOptions<"openrouter"> {
1971
+ const apiKey = config?.apiKey;
1972
+ const baseUrl = config?.baseUrl ?? "https://openrouter.ai/api/v1";
1973
+ const references = createBundledReferenceMap<"openrouter">("openrouter");
1974
+ return {
1975
+ providerId: "openrouter",
1976
+ // Older builds cached OpenRouter discovery rows as `api: "openai-completions"`.
1977
+ // Namespace the refreshed pseudo-API cache separately so those rows cannot
1978
+ // override bundled `api: "openrouter"` models during online-if-uncached startup.
1979
+ cacheProviderId: "openrouter:pseudo-api",
1980
+ fetchDynamicModels: () =>
1981
+ fetchOpenAICompatibleModels({
1982
+ api: "openrouter",
1983
+ provider: "openrouter",
1984
+ baseUrl,
1985
+ apiKey,
1986
+ filterModel: (entry: OpenAICompatibleModelRecord) => {
1987
+ const params = entry.supported_parameters;
1988
+ return Array.isArray(params) && params.includes("tools");
1989
+ },
1990
+ mapModel: (
1991
+ entry: OpenAICompatibleModelRecord,
1992
+ defaults: ModelSpec<"openrouter">,
1993
+ _context: OpenAICompatibleModelMapperContext<"openrouter">,
1994
+ ): ModelSpec<"openrouter"> => {
1995
+ const reference = references.get(defaults.id);
1996
+ const baseModel = mapWithBundledReference(entry, defaults, reference);
1997
+ const pricing = entry.pricing as Record<string, unknown> | undefined;
1998
+ const params = Array.isArray(entry.supported_parameters) ? (entry.supported_parameters as string[]) : [];
1999
+ const modality = String((entry.architecture as Record<string, unknown> | undefined)?.modality ?? "");
2000
+ const topProvider = entry.top_provider as Record<string, unknown> | undefined;
2001
+
2002
+ const supportsToolChoice = params.includes("tool_choice");
2003
+
2004
+ return {
2005
+ ...baseModel,
2006
+ reasoning: params.includes("reasoning"),
2007
+ input: modality.includes("image") ? ["text", "image"] : ["text"],
2008
+ cost: {
2009
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
2010
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
2011
+ cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000,
2012
+ cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000,
2013
+ },
2014
+ contextWindow:
2015
+ typeof entry.context_length === "number" ? entry.context_length : baseModel.contextWindow,
2016
+ maxTokens:
2017
+ typeof topProvider?.max_completion_tokens === "number"
2018
+ ? topProvider.max_completion_tokens
2019
+ : baseModel.maxTokens,
2020
+ ...(!supportsToolChoice && {
2021
+ compat: { ...(baseModel.compat ?? {}), supportsToolChoice: false },
2022
+ }),
2023
+ };
2024
+ },
2025
+ fetch: config?.fetch,
2026
+ }),
2027
+ };
2028
+ }
2029
+
2030
+ const ZENMUX_OPENAI_BASE_URL = "https://zenmux.ai/api/v1";
2031
+ const ZENMUX_ANTHROPIC_BASE_URL = "https://zenmux.ai/api/anthropic";
2032
+
2033
+ function normalizeZenMuxOpenAiBaseUrl(baseUrl?: string): string {
2034
+ const value = baseUrl?.trim();
2035
+ if (!value) {
2036
+ return ZENMUX_OPENAI_BASE_URL;
2037
+ }
2038
+ return value.endsWith("/") ? value.slice(0, -1) : value;
2039
+ }
2040
+
2041
+ function toZenMuxAnthropicBaseUrl(openAiBaseUrl: string): string {
2042
+ try {
2043
+ const parsed = new URL(openAiBaseUrl);
2044
+ const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2045
+ parsed.pathname = trimmedPath.endsWith("/api/v1")
2046
+ ? `${trimmedPath.slice(0, -"/api/v1".length)}/api/anthropic`
2047
+ : "/api/anthropic";
2048
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2049
+ } catch {
2050
+ return ZENMUX_ANTHROPIC_BASE_URL;
2051
+ }
2052
+ }
2053
+
2054
+ function isZenMuxAnthropicModel(entry: OpenAICompatibleModelRecord, modelId: string): boolean {
2055
+ if (typeof entry.owned_by === "string" && entry.owned_by.toLowerCase() === "anthropic") {
2056
+ return true;
2057
+ }
2058
+ return modelId.toLowerCase().startsWith("anthropic/");
2059
+ }
2060
+
2061
+ function getZenMuxPricingValue(pricings: Record<string, unknown> | undefined, key: string): number {
2062
+ const bucket = pricings?.[key];
2063
+ if (!Array.isArray(bucket)) {
2064
+ return 0;
2065
+ }
2066
+ for (const item of bucket) {
2067
+ if (!isRecord(item)) {
2068
+ continue;
2069
+ }
2070
+ const value = toNumber(item.value);
2071
+ if (value !== undefined) {
2072
+ return value;
2073
+ }
2074
+ }
2075
+ return 0;
2076
+ }
2077
+
2078
+ function getZenMuxCacheWritePrice(pricings: Record<string, unknown> | undefined): number {
2079
+ const oneHour = getZenMuxPricingValue(pricings, "input_cache_write_1_h");
2080
+ if (oneHour > 0) {
2081
+ return oneHour;
2082
+ }
2083
+ const fiveMinute = getZenMuxPricingValue(pricings, "input_cache_write_5_min");
2084
+ if (fiveMinute > 0) {
2085
+ return fiveMinute;
2086
+ }
2087
+ return getZenMuxPricingValue(pricings, "input_cache_write");
2088
+ }
2089
+
2090
+ // ---------------------------------------------------------------------------
2091
+ // 10.5 ZenMux
2092
+ // ---------------------------------------------------------------------------
2093
+
2094
+ export interface ZenMuxModelManagerConfig {
2095
+ apiKey?: string;
2096
+ baseUrl?: string;
2097
+ fetch?: FetchImpl;
2098
+ }
2099
+
2100
+ export function zenmuxModelManagerOptions(config?: ZenMuxModelManagerConfig): ModelManagerOptions<Api> {
2101
+ const apiKey = config?.apiKey;
2102
+ const openAiBaseUrl = normalizeZenMuxOpenAiBaseUrl(config?.baseUrl);
2103
+ const anthropicBaseUrl = toZenMuxAnthropicBaseUrl(openAiBaseUrl);
2104
+ return {
2105
+ providerId: "zenmux",
2106
+ ...(apiKey && {
2107
+ fetchDynamicModels: () =>
2108
+ fetchOpenAICompatibleModels<Api>({
2109
+ api: "openai-completions",
2110
+ provider: "zenmux",
2111
+ baseUrl: openAiBaseUrl,
2112
+ apiKey,
2113
+ mapModel: (entry, defaults) => {
2114
+ const pricings = isRecord(entry.pricings) ? entry.pricings : undefined;
2115
+ const capabilities = isRecord(entry.capabilities) ? entry.capabilities : undefined;
2116
+ const isAnthropicModel = isZenMuxAnthropicModel(entry, defaults.id);
2117
+ return {
2118
+ ...defaults,
2119
+ name: toModelName(entry.display_name, defaults.name),
2120
+ api: isAnthropicModel ? "anthropic-messages" : "openai-completions",
2121
+ baseUrl: isAnthropicModel ? anthropicBaseUrl : openAiBaseUrl,
2122
+ reasoning: capabilities?.reasoning === true || defaults.reasoning,
2123
+ input: toInputCapabilities(entry.input_modalities),
2124
+ cost: {
2125
+ input: getZenMuxPricingValue(pricings, "prompt"),
2126
+ output: getZenMuxPricingValue(pricings, "completion"),
2127
+ cacheRead: getZenMuxPricingValue(pricings, "input_cache_read"),
2128
+ cacheWrite: getZenMuxCacheWritePrice(pricings),
2129
+ },
2130
+ contextWindow: toPositiveNumber(entry.context_length, defaults.contextWindow),
2131
+ maxTokens: toPositiveNumber(entry.max_completion_tokens, defaults.maxTokens),
2132
+ };
2133
+ },
2134
+ fetch: config?.fetch,
2135
+ }),
2136
+ }),
2137
+ };
2138
+ }
2139
+
2140
+ // ---------------------------------------------------------------------------
2141
+ // 10.6 Kilo Gateway
2142
+ // ---------------------------------------------------------------------------
2143
+
2144
+ export interface KiloModelManagerConfig {
2145
+ apiKey?: string;
2146
+ baseUrl?: string;
2147
+ fetch?: FetchImpl;
2148
+ }
2149
+
2150
+ export function kiloModelManagerOptions(config?: KiloModelManagerConfig): ModelManagerOptions<"openai-completions"> {
2151
+ const apiKey = config?.apiKey;
2152
+ const baseUrl = config?.baseUrl ?? "https://api.kilo.ai/api/gateway";
2153
+ return {
2154
+ providerId: "kilo",
2155
+ fetchDynamicModels: () =>
2156
+ fetchOpenAICompatibleModels({
2157
+ api: "openai-completions",
2158
+ provider: "kilo",
2159
+ baseUrl,
2160
+ apiKey,
2161
+ fetch: config?.fetch,
2162
+ }),
2163
+ };
2164
+ }
2165
+
2166
+ // ---------------------------------------------------------------------------
2167
+ // Alibaba Coding Plan
2168
+ // ---------------------------------------------------------------------------
2169
+
2170
+ export interface AlibabaCodingPlanModelManagerConfig {
2171
+ apiKey?: string;
2172
+ baseUrl?: string;
2173
+ fetch?: FetchImpl;
2174
+ }
2175
+
2176
+ export function alibabaCodingPlanModelManagerOptions(
2177
+ config?: AlibabaCodingPlanModelManagerConfig,
2178
+ ): ModelManagerOptions<"openai-completions"> {
2179
+ const apiKey = config?.apiKey;
2180
+ const baseUrl = config?.baseUrl ?? "https://coding-intl.dashscope.aliyuncs.com/v1";
2181
+ const references = createBundledReferenceMap<"openai-completions">("alibaba-coding-plan");
2182
+ return {
2183
+ providerId: "alibaba-coding-plan",
2184
+ fetchDynamicModels: () =>
2185
+ fetchOpenAICompatibleModels({
2186
+ api: "openai-completions",
2187
+ provider: "alibaba-coding-plan",
2188
+ baseUrl,
2189
+ apiKey,
2190
+ mapModel: (entry, defaults) => {
2191
+ const reference = references.get(defaults.id);
2192
+ return mapWithBundledReference(entry, defaults, reference);
2193
+ },
2194
+ fetch: config?.fetch,
2195
+ }),
2196
+ };
2197
+ }
2198
+
2199
+ // ---------------------------------------------------------------------------
2200
+ // 11. Vercel AI Gateway
2201
+ // ---------------------------------------------------------------------------
2202
+
2203
+ export interface VercelAiGatewayModelManagerConfig {
2204
+ apiKey?: string;
2205
+ baseUrl?: string;
2206
+ fetch?: FetchImpl;
2207
+ }
2208
+
2209
+ function normalizeVercelAiGatewayBaseUrls(rawBaseUrl: string | undefined): { baseUrl: string; catalogBaseUrl: string } {
2210
+ const baseUrl = (rawBaseUrl === undefined ? "https://ai-gateway.vercel.sh" : rawBaseUrl.trim()).replace(/\/+$/, "");
2211
+ const catalogBaseUrl = baseUrl === "" || baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
2212
+
2213
+ return {
2214
+ baseUrl: baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl,
2215
+ catalogBaseUrl,
2216
+ };
2217
+ }
2218
+
2219
+ export function vercelAiGatewayModelManagerOptions(
2220
+ config?: VercelAiGatewayModelManagerConfig,
2221
+ ): ModelManagerOptions<"anthropic-messages"> {
2222
+ const apiKey = config?.apiKey;
2223
+ const { baseUrl, catalogBaseUrl } = normalizeVercelAiGatewayBaseUrls(config?.baseUrl);
2224
+ return {
2225
+ providerId: "vercel-ai-gateway",
2226
+ fetchDynamicModels: () =>
2227
+ fetchOpenAICompatibleModels({
2228
+ api: "anthropic-messages",
2229
+ provider: "vercel-ai-gateway",
2230
+ baseUrl: catalogBaseUrl,
2231
+ apiKey,
2232
+ filterModel: (entry: OpenAICompatibleModelRecord) => {
2233
+ const tags = entry.tags;
2234
+ return Array.isArray(tags) && tags.includes("tool-use");
2235
+ },
2236
+ mapModel: (
2237
+ entry: OpenAICompatibleModelRecord,
2238
+ defaults: ModelSpec<"anthropic-messages">,
2239
+ _context: OpenAICompatibleModelMapperContext<"anthropic-messages">,
2240
+ ): ModelSpec<"anthropic-messages"> => {
2241
+ const pricing = entry.pricing as Record<string, unknown> | undefined;
2242
+ const tags = Array.isArray(entry.tags) ? (entry.tags as string[]) : [];
2243
+
2244
+ return {
2245
+ ...defaults,
2246
+ baseUrl,
2247
+ reasoning: tags.includes("reasoning"),
2248
+ input: tags.includes("vision") ? ["text", "image"] : ["text"],
2249
+ cost: {
2250
+ input: (toNumber(pricing?.input) ?? 0) * 1_000_000,
2251
+ output: (toNumber(pricing?.output) ?? 0) * 1_000_000,
2252
+ cacheRead: (toNumber(pricing?.input_cache_read) ?? 0) * 1_000_000,
2253
+ cacheWrite: (toNumber(pricing?.input_cache_write) ?? 0) * 1_000_000,
2254
+ },
2255
+ contextWindow:
2256
+ typeof entry.context_window === "number" ? entry.context_window : defaults.contextWindow,
2257
+ maxTokens: typeof entry.max_tokens === "number" ? entry.max_tokens : defaults.maxTokens,
2258
+ };
2259
+ },
2260
+ fetch: config?.fetch,
2261
+ }),
2262
+ };
2263
+ }
2264
+
2265
+ // ---------------------------------------------------------------------------
2266
+ // 12. Kimi Code
2267
+ // ---------------------------------------------------------------------------
2268
+
2269
+ export interface KimiCodeModelManagerConfig {
2270
+ apiKey?: string;
2271
+ baseUrl?: string;
2272
+ fetch?: FetchImpl;
2273
+ }
2274
+
2275
+ export function kimiCodeModelManagerOptions(
2276
+ config?: KimiCodeModelManagerConfig,
2277
+ ): ModelManagerOptions<"openai-completions"> {
2278
+ const apiKey = config?.apiKey;
2279
+ const baseUrl = config?.baseUrl ?? "https://api.kimi.com/coding/v1";
2280
+ return {
2281
+ providerId: "kimi-code",
2282
+ ...(apiKey && {
2283
+ fetchDynamicModels: () =>
2284
+ fetchOpenAICompatibleModels({
2285
+ api: "openai-completions",
2286
+ provider: "kimi-code",
2287
+ baseUrl,
2288
+ apiKey,
2289
+ headers: {
2290
+ "User-Agent": "KimiCLI/1.0",
2291
+ "X-Msh-Platform": "kimi_cli",
2292
+ },
2293
+ mapModel: (
2294
+ entry: OpenAICompatibleModelRecord,
2295
+ defaults: ModelSpec<"openai-completions">,
2296
+ _context: OpenAICompatibleModelMapperContext<"openai-completions">,
2297
+ ): ModelSpec<"openai-completions"> => {
2298
+ const id = defaults.id;
2299
+ return {
2300
+ ...defaults,
2301
+ name: typeof entry.display_name === "string" ? entry.display_name : defaults.name,
2302
+ reasoning: entry.supports_reasoning === true || id.includes("thinking"),
2303
+ input: entry.supports_image_in === true || id.includes("k2.5") ? ["text", "image"] : ["text"],
2304
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : 262144,
2305
+ maxTokens: 32000,
2306
+ compat: {
2307
+ thinkingFormat: "zai",
2308
+ reasoningContentField: "reasoning_content",
2309
+ supportsDeveloperRole: false,
2310
+ },
2311
+ };
2312
+ },
2313
+ fetch: config?.fetch,
2314
+ }),
2315
+ }),
2316
+ };
2317
+ }
2318
+
2319
+ // ---------------------------------------------------------------------------
2320
+ // 12.5. LM Studio
2321
+ // ---------------------------------------------------------------------------
2322
+
2323
+ /** Native LM Studio metadata keyed by model id from `/api/v0/models`. */
2324
+ export interface LmStudioNativeModelMetadata {
2325
+ input: ("text" | "image")[];
2326
+ contextWindow?: number;
2327
+ }
2328
+
2329
+ /** Options for LM Studio's optional native metadata probe. */
2330
+ export interface LmStudioNativeModelMetadataOptions {
2331
+ headers?: Record<string, string>;
2332
+ signal?: AbortSignal;
2333
+ }
2334
+
2335
+ const LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS = 250;
2336
+
2337
+ function toLmStudioNativeBaseUrl(baseUrl: string): string {
2338
+ const trimmed = baseUrl.trim();
2339
+ const normalized = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
2340
+ return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized;
2341
+ }
2342
+
2343
+ function getLmStudioCapabilityNames(value: unknown): string[] {
2344
+ if (!Array.isArray(value)) {
2345
+ return [];
2346
+ }
2347
+ return value.flatMap(item => (typeof item === "string" ? [item.toLowerCase()] : []));
2348
+ }
2349
+
2350
+ function getLmStudioNativeInput(entry: Record<string, unknown>): ("text" | "image")[] {
2351
+ const modelType = typeof entry.type === "string" ? entry.type.toLowerCase() : "";
2352
+ const capabilities = getLmStudioCapabilityNames(entry.capabilities);
2353
+ const supportsImage = modelType === "vlm" || capabilities.includes("vision") || capabilities.includes("image");
2354
+ return supportsImage ? ["text", "image"] : ["text"];
2355
+ }
2356
+
2357
+ function getLmStudioNativeContextWindow(entry: Record<string, unknown>): number | undefined {
2358
+ return (
2359
+ toPositiveNumber(entry.max_context_length, null) ??
2360
+ toPositiveNumber(entry.context_length, null) ??
2361
+ toPositiveNumber(entry.max_model_len, null) ??
2362
+ undefined
2363
+ );
2364
+ }
2365
+
2366
+ /** Fetches LM Studio native model metadata used to mark VLM models as image-capable. */
2367
+ export async function fetchLmStudioNativeModelMetadata(
2368
+ baseUrl: string,
2369
+ fetchImpl: FetchImpl = fetch,
2370
+ options?: LmStudioNativeModelMetadataOptions,
2371
+ ): Promise<Map<string, LmStudioNativeModelMetadata> | null> {
2372
+ const nativeBaseUrl = toLmStudioNativeBaseUrl(baseUrl);
2373
+ const fetchMetadata = async (signal?: AbortSignal): Promise<Map<string, LmStudioNativeModelMetadata> | null> => {
2374
+ try {
2375
+ const response = await fetchImpl(`${nativeBaseUrl}/api/v0/models`, {
2376
+ method: "GET",
2377
+ headers: { Accept: "application/json", ...(options?.headers ?? {}) },
2378
+ signal,
2379
+ });
2380
+ if (!response.ok) {
2381
+ return null;
2382
+ }
2383
+ const payload = await response.json();
2384
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
2385
+ return null;
2386
+ }
2387
+ const metadata = new Map<string, LmStudioNativeModelMetadata>();
2388
+ for (const entry of payload.data) {
2389
+ if (!isRecord(entry) || typeof entry.id !== "string" || entry.id.length === 0) {
2390
+ continue;
2391
+ }
2392
+ const contextWindow = getLmStudioNativeContextWindow(entry);
2393
+ metadata.set(entry.id, {
2394
+ input: getLmStudioNativeInput(entry),
2395
+ ...(contextWindow === undefined ? {} : { contextWindow }),
2396
+ });
2397
+ }
2398
+ return metadata;
2399
+ } catch {
2400
+ return null;
2401
+ }
2402
+ };
2403
+ if (options?.signal !== undefined) {
2404
+ return fetchMetadata(options.signal);
2405
+ }
2406
+ return withCatalogDiscoveryTimeout(LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS, fetchMetadata);
2407
+ }
2408
+
2409
+ export interface LmStudioModelManagerConfig {
2410
+ apiKey?: string;
2411
+ baseUrl?: string;
2412
+ fetch?: FetchImpl;
2413
+ }
2414
+
2415
+ export function lmStudioModelManagerOptions(
2416
+ config?: LmStudioModelManagerConfig,
2417
+ ): ModelManagerOptions<"openai-completions"> {
2418
+ const apiKey = config?.apiKey;
2419
+ const baseUrl = config?.baseUrl ?? Bun.env.LM_STUDIO_BASE_URL ?? "http://127.0.0.1:1234/v1";
2420
+ const references = createBundledReferenceMap<"openai-completions">("lm-studio" as any);
2421
+ return {
2422
+ providerId: "lm-studio",
2423
+ fetchDynamicModels: async () => {
2424
+ const nativeMetadataPromise = fetchLmStudioNativeModelMetadata(baseUrl, config?.fetch, {
2425
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
2426
+ });
2427
+ const models = await fetchOpenAICompatibleModels({
2428
+ api: "openai-completions",
2429
+ provider: "lm-studio",
2430
+ baseUrl,
2431
+ apiKey,
2432
+ mapModel: (entry, defaults) => {
2433
+ const reference = references.get(defaults.id);
2434
+ return mapWithBundledReference(entry, defaults, reference);
2435
+ },
2436
+ fetch: config?.fetch,
2437
+ });
2438
+ if (!models) {
2439
+ return models;
2440
+ }
2441
+ const nativeMetadata = await nativeMetadataPromise;
2442
+ if (!nativeMetadata) {
2443
+ return models;
2444
+ }
2445
+ return models.map(model => {
2446
+ const metadata = nativeMetadata.get(model.id);
2447
+ if (!metadata) {
2448
+ return model;
2449
+ }
2450
+ return {
2451
+ ...model,
2452
+ input: metadata.input,
2453
+ contextWindow: metadata.contextWindow ?? model.contextWindow,
2454
+ };
2455
+ });
2456
+ },
2457
+ };
2458
+ }
2459
+
2460
+ // ---------------------------------------------------------------------------
2461
+ // 13. Synthetic
2462
+ // ---------------------------------------------------------------------------
2463
+
2464
+ export interface SyntheticModelManagerConfig {
2465
+ apiKey?: string;
2466
+ baseUrl?: string;
2467
+ fetch?: FetchImpl;
2468
+ }
2469
+
2470
+ export function syntheticModelManagerOptions(
2471
+ config?: SyntheticModelManagerConfig,
2472
+ ): ModelManagerOptions<"openai-completions"> {
2473
+ const apiKey = config?.apiKey;
2474
+ const baseUrl = config?.baseUrl ?? "https://api.synthetic.new/openai/v1";
2475
+ const references = new Map(
2476
+ (getBundledModels("synthetic") as Model<"openai-completions">[]).map(model => [model.id, toModelSpec(model)]),
2477
+ );
2478
+ return {
2479
+ providerId: "synthetic",
2480
+ dynamicModelsAuthoritative: true,
2481
+ ...(apiKey && {
2482
+ fetchDynamicModels: () =>
2483
+ fetchOpenAICompatibleModels({
2484
+ api: "openai-completions",
2485
+ provider: "synthetic",
2486
+ baseUrl,
2487
+ apiKey,
2488
+ mapModel: (
2489
+ entry: OpenAICompatibleModelRecord,
2490
+ defaults: ModelSpec<"openai-completions">,
2491
+ _context: OpenAICompatibleModelMapperContext<"openai-completions">,
2492
+ ): ModelSpec<"openai-completions"> => {
2493
+ const reference = references.get(defaults.id);
2494
+ const referenceSupportsImage = reference?.input.includes("image") ?? false;
2495
+ return {
2496
+ ...(reference ? { ...reference, id: defaults.id, baseUrl } : defaults),
2497
+ name: toModelName(entry.name, reference?.name ?? defaults.name),
2498
+ reasoning: entry.supports_reasoning === true || (reference?.reasoning ?? false),
2499
+ input: entry.supports_vision === true || referenceSupportsImage ? ["text", "image"] : ["text"],
2500
+ contextWindow: toPositiveNumber(
2501
+ entry.context_length,
2502
+ reference?.contextWindow ?? defaults.contextWindow,
2503
+ ),
2504
+ maxTokens: toPositiveNumber(entry.max_tokens, reference?.maxTokens ?? 8192),
2505
+ };
2506
+ },
2507
+ fetch: config?.fetch,
2508
+ }),
2509
+ }),
2510
+ };
2511
+ }
2512
+
2513
+ // ---------------------------------------------------------------------------
2514
+ // 14. Venice
2515
+ // ---------------------------------------------------------------------------
2516
+
2517
+ export interface VeniceModelManagerConfig {
2518
+ apiKey?: string;
2519
+ baseUrl?: string;
2520
+ fetch?: FetchImpl;
2521
+ }
2522
+
2523
+ export function veniceModelManagerOptions(
2524
+ config?: VeniceModelManagerConfig,
2525
+ ): ModelManagerOptions<"openai-completions"> {
2526
+ const apiKey = config?.apiKey;
2527
+ const baseUrl = config?.baseUrl ?? "https://api.venice.ai/api/v1";
2528
+ const references = createBundledReferenceMap<"openai-completions">("venice");
2529
+ return {
2530
+ providerId: "venice",
2531
+ fetchDynamicModels: () =>
2532
+ fetchOpenAICompatibleModels({
2533
+ api: "openai-completions",
2534
+ provider: "venice",
2535
+ baseUrl,
2536
+ apiKey,
2537
+ mapModel: (entry, defaults) => {
2538
+ const reference = references.get(defaults.id);
2539
+ const model = mapWithBundledReference(entry, defaults, reference);
2540
+ return {
2541
+ ...model,
2542
+ maxTokens: clampKimiK27CodeMaxTokens(defaults.id, model.maxTokens),
2543
+ compat: { ...model.compat, supportsUsageInStreaming: false },
2544
+ };
2545
+ },
2546
+ fetch: config?.fetch,
2547
+ }),
2548
+ };
2549
+ }
2550
+
2551
+ // ---------------------------------------------------------------------------
2552
+ // 15. Together
2553
+ // ---------------------------------------------------------------------------
2554
+
2555
+ export interface TogetherModelManagerConfig {
2556
+ apiKey?: string;
2557
+ baseUrl?: string;
2558
+ fetch?: FetchImpl;
2559
+ }
2560
+
2561
+ export function togetherModelManagerOptions(
2562
+ config?: TogetherModelManagerConfig,
2563
+ ): ModelManagerOptions<"openai-completions"> {
2564
+ return createSimpleOpenAICompletionsOptions("together", "https://api.together.xyz/v1", config);
2565
+ }
2566
+
2567
+ // ---------------------------------------------------------------------------
2568
+ // 15.5 CoreWeave Serverless Inference
2569
+ // ---------------------------------------------------------------------------
2570
+
2571
+ export interface CoreWeaveModelManagerConfig {
2572
+ apiKey?: string;
2573
+ baseUrl?: string;
2574
+ fetch?: FetchImpl;
2575
+ }
2576
+
2577
+ export function coreWeaveModelManagerOptions(
2578
+ config?: CoreWeaveModelManagerConfig,
2579
+ ): ModelManagerOptions<"openai-completions"> {
2580
+ return createSimpleOpenAICompletionsOptions("coreweave", "https://api.inference.wandb.ai/v1", {
2581
+ ...config,
2582
+ headers: () => coreWeaveProjectHeaders(Bun.env),
2583
+ });
2584
+ }
2585
+
2586
+ // ---------------------------------------------------------------------------
2587
+ // 16. Moonshot
2588
+ // ---------------------------------------------------------------------------
2589
+
2590
+ export interface MoonshotModelManagerConfig {
2591
+ apiKey?: string;
2592
+ baseUrl?: string;
2593
+ fetch?: FetchImpl;
2594
+ }
2595
+
2596
+ export function moonshotModelManagerOptions(
2597
+ config?: MoonshotModelManagerConfig,
2598
+ ): ModelManagerOptions<"openai-completions"> {
2599
+ const apiKey = config?.apiKey;
2600
+ // `MOONSHOT_BASE_URL` redirects discovery (and the streaming request that
2601
+ // inherits this baseUrl) at the Kimi China platform `api.moonshot.cn`; an
2602
+ // explicit `config.baseUrl` still wins. Mirrors LITELLM_BASE_URL/LM_STUDIO_BASE_URL. (#2883)
2603
+ const baseUrl = config?.baseUrl ?? Bun.env.MOONSHOT_BASE_URL ?? "https://api.moonshot.ai/v1";
2604
+ const references = createBundledReferenceMap<"openai-completions">("moonshot");
2605
+ return {
2606
+ providerId: "moonshot",
2607
+ ...(apiKey && {
2608
+ fetchDynamicModels: () =>
2609
+ fetchOpenAICompatibleModels({
2610
+ api: "openai-completions",
2611
+ provider: "moonshot",
2612
+ baseUrl,
2613
+ apiKey,
2614
+ mapModel: (entry, defaults) => {
2615
+ const reference = references.get(defaults.id);
2616
+ const model = mapWithBundledReference(entry, defaults, reference);
2617
+ const id = model.id.toLowerCase();
2618
+ // Moonshot's K2.x family (K2.5, K2.6, kimi-k2-thinking, …) is reasoning-capable
2619
+ // and vision-capable on the native API. Without these flags the openai-completions
2620
+ // path skips the z.ai-format `thinking` block, and Moonshot K2.6 stalls on first
2621
+ // turn because its endpoint expects an explicit `thinking: {type}` (#2113). Match
2622
+ // the bundled K2.5 metadata for every K2.x id we discover.
2623
+ const isKimiK2Reasoning = id.includes("thinking") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id);
2624
+ const isVision =
2625
+ id.includes("vision") || id.includes("vl") || /(^|\/)kimi-k2(?:\.\d+)?(?:[-:]|$)/.test(id);
2626
+ return {
2627
+ ...model,
2628
+ reasoning: isKimiK2Reasoning || model.reasoning,
2629
+ input: isVision ? ["text", "image"] : model.input,
2630
+ thinking:
2631
+ model.thinking ??
2632
+ (isKimiK2Reasoning
2633
+ ? { mode: "effort", efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High] }
2634
+ : undefined),
2635
+ };
2636
+ },
2637
+ fetch: config?.fetch,
2638
+ }),
2639
+ }),
2640
+ };
2641
+ }
2642
+
2643
+ // ---------------------------------------------------------------------------
2644
+ // 16.5 Sakana AI
2645
+ // ---------------------------------------------------------------------------
2646
+
2647
+ const SAKANA_DEFAULT_BASE_URL = "https://api.sakana.ai/v1";
2648
+ const SAKANA_FREE_ROUTER_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
2649
+ const SAKANA_FUGU_ULTRA_COST = { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 } as const;
2650
+ const SAKANA_FUGU_ULTRA_CONTEXT_WINDOW = 1_000_000;
2651
+ const SAKANA_FUGU_THINKING: ThinkingConfig = {
2652
+ mode: "effort",
2653
+ efforts: [Effort.High, Effort.XHigh],
2654
+ effortMap: { [Effort.XHigh]: "max" },
2655
+ };
2656
+ const SAKANA_RESPONSES_COMPAT: ModelSpec<"openai-responses">["compat"] = {
2657
+ includeEncryptedReasoning: false,
2658
+ streamIdleTimeoutMs: 0,
2659
+ };
2660
+
2661
+ function normalizeSakanaBaseUrl(baseUrl: string | undefined): string {
2662
+ const value = baseUrl?.trim() || SAKANA_DEFAULT_BASE_URL;
2663
+ const normalized = value.replace(/\/+$/, "");
2664
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
2665
+ }
2666
+
2667
+ function isSakanaFuguModelId(modelId: string): boolean {
2668
+ return /^fugu(?:$|-)/i.test(modelId);
2669
+ }
2670
+
2671
+ function createSakanaFuguStaticModel(
2672
+ id: string,
2673
+ name: string,
2674
+ cost: ModelSpec<"openai-responses">["cost"],
2675
+ contextWindow: number | null,
2676
+ ): ModelSpec<"openai-responses"> {
2677
+ return {
2678
+ id,
2679
+ name,
2680
+ api: "openai-responses",
2681
+ provider: "sakana",
2682
+ baseUrl: SAKANA_DEFAULT_BASE_URL,
2683
+ reasoning: true,
2684
+ input: ["text"],
2685
+ cost: { ...cost },
2686
+ contextWindow,
2687
+ maxTokens: null,
2688
+ thinking: { ...SAKANA_FUGU_THINKING },
2689
+ compat: { ...SAKANA_RESPONSES_COMPAT },
2690
+ };
2691
+ }
2692
+
2693
+ export const SAKANA_FUGU_STATIC_MODELS: readonly ModelSpec<"openai-responses">[] = [
2694
+ createSakanaFuguStaticModel("fugu", "Fugu", SAKANA_FREE_ROUTER_COST, SAKANA_FUGU_ULTRA_CONTEXT_WINDOW),
2695
+ createSakanaFuguStaticModel("fugu-ultra", "Fugu Ultra", SAKANA_FUGU_ULTRA_COST, SAKANA_FUGU_ULTRA_CONTEXT_WINDOW),
2696
+ createSakanaFuguStaticModel(
2697
+ "fugu-ultra-20260615",
2698
+ "Fugu Ultra 20260615",
2699
+ SAKANA_FUGU_ULTRA_COST,
2700
+ SAKANA_FUGU_ULTRA_CONTEXT_WINDOW,
2701
+ ),
2702
+ ];
2703
+
2704
+ const SAKANA_FUGU_STATIC_MODEL_BY_ID = new Map(SAKANA_FUGU_STATIC_MODELS.map(model => [model.id, model] as const));
2705
+ const SAKANA_FUGU_STATIC_MODEL_IDS = SAKANA_FUGU_STATIC_MODELS.map(model => model.id);
2706
+
2707
+ export interface SakanaModelManagerConfig {
2708
+ apiKey?: string;
2709
+ baseUrl?: string;
2710
+ fetch?: FetchImpl;
2711
+ }
2712
+
2713
+ export function sakanaModelManagerOptions(config?: SakanaModelManagerConfig): ModelManagerOptions<"openai-responses"> {
2714
+ const apiKey = config?.apiKey;
2715
+ const baseUrl = normalizeSakanaBaseUrl(config?.baseUrl ?? Bun.env.SAKANA_BASE_URL ?? Bun.env.FUGU_BASE_URL);
2716
+ const references = createBundledReferenceMap<"openai-responses">("sakana");
2717
+ return {
2718
+ providerId: "sakana",
2719
+ dynamicModelsAuthoritative: true,
2720
+ dropCachedModelIdsOnStaticMismatch: SAKANA_FUGU_STATIC_MODEL_IDS,
2721
+ ...(apiKey && {
2722
+ fetchDynamicModels: () =>
2723
+ fetchOpenAICompatibleModels({
2724
+ api: "openai-responses",
2725
+ provider: "sakana",
2726
+ baseUrl,
2727
+ apiKey,
2728
+ mapModel: (entry, defaults) => {
2729
+ const reference = references.get(defaults.id) ?? SAKANA_FUGU_STATIC_MODEL_BY_ID.get(defaults.id);
2730
+ const model = mapWithBundledReference(entry, defaults, reference);
2731
+ if (!reference && isSakanaFuguModelId(model.id)) {
2732
+ return {
2733
+ ...model,
2734
+ reasoning: true,
2735
+ thinking: { ...SAKANA_FUGU_THINKING },
2736
+ compat: { ...SAKANA_RESPONSES_COMPAT },
2737
+ };
2738
+ }
2739
+ return model;
2740
+ },
2741
+ fetch: config?.fetch,
2742
+ }),
2743
+ }),
2744
+ };
2745
+ }
2746
+
2747
+ // ---------------------------------------------------------------------------
2748
+ // 17. Qwen Portal
2749
+ // ---------------------------------------------------------------------------
2750
+
2751
+ export interface QwenPortalModelManagerConfig {
2752
+ apiKey?: string;
2753
+ baseUrl?: string;
2754
+ fetch?: FetchImpl;
2755
+ }
2756
+
2757
+ export function qwenPortalModelManagerOptions(
2758
+ config?: QwenPortalModelManagerConfig,
2759
+ ): ModelManagerOptions<"openai-completions"> {
2760
+ return createSimpleOpenAICompletionsOptions("qwen-portal", "https://portal.qwen.ai/v1", config);
2761
+ }
2762
+
2763
+ // ---------------------------------------------------------------------------
2764
+ // 18. Qianfan
2765
+ // ---------------------------------------------------------------------------
2766
+
2767
+ export interface QianfanModelManagerConfig {
2768
+ apiKey?: string;
2769
+ baseUrl?: string;
2770
+ fetch?: FetchImpl;
2771
+ }
2772
+
2773
+ export function qianfanModelManagerOptions(
2774
+ config?: QianfanModelManagerConfig,
2775
+ ): ModelManagerOptions<"openai-completions"> {
2776
+ return createSimpleOpenAICompletionsOptions("qianfan", "https://qianfan.baidubce.com/v2", config);
2777
+ }
2778
+
2779
+ // ---------------------------------------------------------------------------
2780
+ // 19. Cloudflare AI Gateway
2781
+ // ---------------------------------------------------------------------------
2782
+
2783
+ export interface CloudflareAiGatewayModelManagerConfig {
2784
+ apiKey?: string;
2785
+ baseUrl?: string;
2786
+ fetch?: FetchImpl;
2787
+ }
2788
+
2789
+ export function cloudflareAiGatewayModelManagerOptions(
2790
+ config?: CloudflareAiGatewayModelManagerConfig,
2791
+ ): ModelManagerOptions<"anthropic-messages"> {
2792
+ return createSimpleAnthropicProviderOptions(
2793
+ "cloudflare-ai-gateway",
2794
+ "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic",
2795
+ config,
2796
+ );
2797
+ }
2798
+
2799
+ // ---------------------------------------------------------------------------
2800
+ // 20. Xiaomi
2801
+ // ---------------------------------------------------------------------------
2802
+
2803
+ /** Region codes for Xiaomi Token Plan clusters exposed as separate login providers. */
2804
+ export type XiaomiTokenPlanRegion = "sgp" | "ams" | "cn";
2805
+
2806
+ /** Configures Xiaomi standard or regional Token Plan OpenAI-compatible model discovery. */
2807
+ export interface XiaomiModelManagerConfig {
2808
+ apiKey?: string;
2809
+ baseUrl?: string;
2810
+ fetch?: FetchImpl;
2811
+ providerId?: Provider;
2812
+ tokenPlanRegion?: XiaomiTokenPlanRegion;
2813
+ }
2814
+
2815
+ const XIAOMI_TOKEN_PLAN_BASE_URLS: Record<XiaomiTokenPlanRegion, string> = {
2816
+ sgp: "https://token-plan-sgp.xiaomimimo.com/v1",
2817
+ ams: "https://token-plan-ams.xiaomimimo.com/v1",
2818
+ cn: "https://token-plan-cn.xiaomimimo.com/v1",
2819
+ };
2820
+
2821
+ const XIAOMI_TOKEN_PLAN_FALLBACK_BASE_URLS = [
2822
+ XIAOMI_TOKEN_PLAN_BASE_URLS.sgp,
2823
+ XIAOMI_TOKEN_PLAN_BASE_URLS.ams,
2824
+ XIAOMI_TOKEN_PLAN_BASE_URLS.cn,
2825
+ ];
2826
+
2827
+ /** Builds a Xiaomi model manager, preserving Token Plan region provider ids during discovery. */
2828
+ export function xiaomiModelManagerOptions(
2829
+ config?: XiaomiModelManagerConfig,
2830
+ ): ModelManagerOptions<"openai-completions"> {
2831
+ const apiKey = config?.apiKey;
2832
+ const providerId = config?.providerId ?? "xiaomi";
2833
+ const tokenPlanBaseUrls = config?.tokenPlanRegion
2834
+ ? [XIAOMI_TOKEN_PLAN_BASE_URLS[config.tokenPlanRegion]]
2835
+ : XIAOMI_TOKEN_PLAN_FALLBACK_BASE_URLS;
2836
+ const XIAOMI_STANDARD_BASE_URL = "https://api.xiaomimimo.com/v1";
2837
+ const isTokenPlanProvider = config?.tokenPlanRegion !== undefined || providerId.startsWith("xiaomi-token-plan-");
2838
+ const isTokenPlanKey = isTokenPlanProvider || apiKey?.startsWith("tp-");
2839
+ // Token-plan keys always use a TP cluster; config?.baseUrl (from catalog)
2840
+ // would incorrectly pin to the standard endpoint (api.xiaomimimo.com).
2841
+ const baseUrl = isTokenPlanKey ? tokenPlanBaseUrls[0] : (config?.baseUrl ?? XIAOMI_STANDARD_BASE_URL);
2842
+ const references = createBundledReferenceMap<"openai-completions">("xiaomi");
2843
+ const fetchModels = (url: string) =>
2844
+ fetchOpenAICompatibleModels({
2845
+ api: "openai-completions",
2846
+ provider: providerId,
2847
+ baseUrl: url,
2848
+ apiKey,
2849
+ filterModel: (_entry, model) => !model.id.includes("-tts") && !model.id.includes("-asr"),
2850
+ mapModel: (entry, defaults) => {
2851
+ const reference = references.get(defaults.id);
2852
+ const model = mapWithBundledReference(entry, defaults, reference);
2853
+ return {
2854
+ ...model,
2855
+ api: "openai-completions",
2856
+ provider: providerId,
2857
+ baseUrl: defaults.baseUrl,
2858
+ name: toModelName(entry.display_name, model.name),
2859
+ };
2860
+ },
2861
+ fetch: config?.fetch,
2862
+ });
2863
+ return {
2864
+ providerId,
2865
+ ...(apiKey && {
2866
+ fetchDynamicModels: async () => {
2867
+ if (!isTokenPlanKey) {
2868
+ return fetchModels(baseUrl);
2869
+ }
2870
+ for (const url of tokenPlanBaseUrls) {
2871
+ const result = await fetchModels(url);
2872
+ if (result) return result;
2873
+ }
2874
+ return null;
2875
+ },
2876
+ }),
2877
+ };
2878
+ }
2879
+ // ---------------------------------------------------------------------------
2880
+ // 21. LiteLLM
2881
+ // ---------------------------------------------------------------------------
2882
+
2883
+ export interface LiteLLMModelManagerConfig {
2884
+ apiKey?: string;
2885
+ baseUrl?: string;
2886
+ fetch?: FetchImpl;
2887
+ }
2888
+
2889
+ export interface FetchLiteLLMRichModelsOptions<TApi extends Api> {
2890
+ api: TApi;
2891
+ provider: Provider;
2892
+ baseUrl: string;
2893
+ apiKey?: string;
2894
+ headers?: Record<string, string>;
2895
+ fetch?: FetchImpl;
2896
+ signal?: AbortSignal;
2897
+ timeoutMs?: number;
2898
+ referenceResolver?: (modelId: string) => ModelSpec<TApi> | undefined;
2899
+ }
2900
+
2901
+ type LiteLLMRichModelEntry = Record<string, unknown>;
2902
+
2903
+ const LITELLM_RICH_ENDPOINTS = ["/model_group/info", "/v2/model/info", "/model/info", "/v1/model/info"] as const;
2904
+ export const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128_000;
2905
+ export const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
2906
+ const UNKNOWN_PROXY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
2907
+
2908
+ export function normalizeLiteLLMManagementBaseUrl(baseUrl: string): string {
2909
+ const trimmed = baseUrl.trim().replace(/\/+$/g, "");
2910
+ if (!trimmed) {
2911
+ return "";
2912
+ }
2913
+ try {
2914
+ const parsed = new URL(trimmed);
2915
+ const path = parsed.pathname.replace(/\/+$/g, "");
2916
+ parsed.pathname = path.endsWith("/v1") ? path.slice(0, -3) || "/" : path || "/";
2917
+ const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2918
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
2919
+ } catch {
2920
+ return trimmed.replace(/\/v1$/, "");
2921
+ }
2922
+ }
2923
+
2924
+ function normalizeLiteLLMRuntimeBaseUrl(baseUrl: string): string {
2925
+ const trimmed = baseUrl.trim();
2926
+ return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
2927
+ }
2928
+
2929
+ const LITELLM_RESELLER_USAGE_SUFFIX = /\s+\(\d+(?:\.\d+)?[x×] usage\)$/i;
2930
+
2931
+ function stripLiteLLMResellerUsageSuffix(name: string): string {
2932
+ const cleaned = name.replace(LITELLM_RESELLER_USAGE_SUFFIX, "").trim();
2933
+ return cleaned.length > 0 ? cleaned : name;
2934
+ }
2935
+
2936
+ function toLiteLLMDisplayName(modelName: string | undefined, referenceName: string | undefined, id: string): string {
2937
+ const cleanedModelName = modelName ? stripLiteLLMResellerUsageSuffix(modelName) : undefined;
2938
+ if (cleanedModelName && cleanedModelName !== id) {
2939
+ return cleanedModelName;
2940
+ }
2941
+ return referenceName ? stripLiteLLMResellerUsageSuffix(referenceName) : id;
2942
+ }
2943
+
2944
+ function mapLiteLLMOpenAICompatibleModel<TApi extends Api>(
2945
+ entry: OpenAICompatibleModelRecord,
2946
+ defaults: ModelSpec<TApi>,
2947
+ reference: ModelSpec<TApi> | undefined,
2948
+ ): ModelSpec<TApi> {
2949
+ const model = mapWithBundledReference(entry, defaults, reference);
2950
+ return {
2951
+ ...model,
2952
+ name: stripLiteLLMResellerUsageSuffix(model.name),
2953
+ };
2954
+ }
2955
+
2956
+ function toNonEmptyString(value: unknown): string | undefined {
2957
+ if (typeof value !== "string") {
2958
+ return undefined;
2959
+ }
2960
+ const trimmed = value.trim();
2961
+ return trimmed.length > 0 ? trimmed : undefined;
2962
+ }
2963
+
2964
+ function extractLiteLLMRichEntries(payload: unknown): LiteLLMRichModelEntry[] | null {
2965
+ if (Array.isArray(payload)) {
2966
+ return payload.flatMap(entry => (isRecord(entry) ? [entry] : []));
2967
+ }
2968
+ if (!isRecord(payload)) {
2969
+ return null;
2970
+ }
2971
+ for (const candidate of [payload.data, payload.models, payload.result, payload.items]) {
2972
+ if (candidate === undefined) {
2973
+ continue;
2974
+ }
2975
+ const entries = extractLiteLLMRichEntries(candidate);
2976
+ if (entries !== null) {
2977
+ return entries;
2978
+ }
2979
+ }
2980
+ return null;
2981
+ }
2982
+
2983
+ function getLiteLLMModelInfo(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined {
2984
+ return isRecord(entry.model_info) ? entry.model_info : undefined;
2985
+ }
2986
+
2987
+ function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined {
2988
+ return isRecord(entry.litellm_params) ? entry.litellm_params : undefined;
2989
+ }
2990
+
2991
+ function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown {
2992
+ return entry[key] ?? getLiteLLMModelInfo(entry)?.[key];
2993
+ }
2994
+
2995
+ function getLiteLLMRichModelId(entry: LiteLLMRichModelEntry): string | undefined {
2996
+ return (
2997
+ toNonEmptyString(entry.model_group) ??
2998
+ toNonEmptyString(entry.model_name) ??
2999
+ toNonEmptyString(entry.id) ??
3000
+ toNonEmptyString(getLiteLLMParams(entry)?.model)
3001
+ );
3002
+ }
3003
+
3004
+ function getSupportedOpenAIParams(entry: LiteLLMRichModelEntry): string[] | undefined {
3005
+ const value = getLiteLLMMetadataValue(entry, "supported_openai_params");
3006
+ if (!Array.isArray(value)) {
3007
+ return undefined;
3008
+ }
3009
+ return value.flatMap(item => (typeof item === "string" ? [item] : []));
3010
+ }
3011
+
3012
+ function mapLiteLLMRichEntry<TApi extends Api>(
3013
+ entry: LiteLLMRichModelEntry,
3014
+ options: FetchLiteLLMRichModelsOptions<TApi>,
3015
+ runtimeBaseUrl: string,
3016
+ ): ModelSpec<TApi> | null {
3017
+ const id = getLiteLLMRichModelId(entry);
3018
+ if (!id) {
3019
+ return null;
3020
+ }
3021
+ const reference = options.referenceResolver?.(id);
3022
+ const modelName = toNonEmptyString(entry.model_name);
3023
+ const contextWindow = toPositiveNumber(
3024
+ getLiteLLMMetadataValue(entry, "max_input_tokens"),
3025
+ reference?.contextWindow ?? OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW,
3026
+ );
3027
+ const maxTokens = toPositiveNumber(
3028
+ getLiteLLMMetadataValue(entry, "max_output_tokens"),
3029
+ reference?.maxTokens ?? Math.min(contextWindow, OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS),
3030
+ );
3031
+ const supportsVision = getLiteLLMMetadataValue(entry, "supports_vision");
3032
+ const supportsReasoning = getLiteLLMMetadataValue(entry, "supports_reasoning");
3033
+ const supportedOpenAIParams = getSupportedOpenAIParams(entry);
3034
+ const supportsFunctionCalling = getLiteLLMMetadataValue(entry, "supports_function_calling");
3035
+ const supportsTools =
3036
+ supportsFunctionCalling === true
3037
+ ? true
3038
+ : supportsFunctionCalling === false
3039
+ ? false
3040
+ : supportedOpenAIParams !== undefined
3041
+ ? supportedOpenAIParams.some(param =>
3042
+ ["tools", "tool_choice", "functions", "function_call"].includes(param),
3043
+ )
3044
+ : reference?.supportsTools;
3045
+ const compat: OpenAICompat = {
3046
+ ...(reference?.compat ?? {}),
3047
+ supportsStore: false,
3048
+ supportsDeveloperRole: false,
3049
+ ...(supportedOpenAIParams !== undefined
3050
+ ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") }
3051
+ : {}),
3052
+ };
3053
+ return {
3054
+ id,
3055
+ name: toLiteLLMDisplayName(modelName, reference?.name, id),
3056
+ api: options.api,
3057
+ provider: options.provider,
3058
+ baseUrl: runtimeBaseUrl,
3059
+ contextWindow,
3060
+ maxTokens,
3061
+ input:
3062
+ supportsVision === true
3063
+ ? ["text", "image"]
3064
+ : supportsVision === false
3065
+ ? ["text"]
3066
+ : (reference?.input ?? ["text"]),
3067
+ reasoning: typeof supportsReasoning === "boolean" ? supportsReasoning : (reference?.reasoning ?? false),
3068
+ thinking: reference?.thinking,
3069
+ cost: reference?.cost ?? UNKNOWN_PROXY_COST,
3070
+ ...(supportsTools !== undefined ? { supportsTools } : {}),
3071
+ compat: compat as ModelSpec<TApi>["compat"],
3072
+ };
3073
+ }
3074
+
3075
+ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
3076
+ endpoint: string,
3077
+ options: FetchLiteLLMRichModelsOptions<TApi>,
3078
+ managementBaseUrl: string,
3079
+ runtimeBaseUrl: string,
3080
+ signal?: AbortSignal,
3081
+ ): Promise<ModelSpec<TApi>[] | null> {
3082
+ const fetchImpl = discoveryFetch(options.fetch);
3083
+ const requestHeaders: Record<string, string> = {
3084
+ Accept: "application/json",
3085
+ ...options.headers,
3086
+ };
3087
+ if (options.apiKey) {
3088
+ requestHeaders.Authorization = `Bearer ${options.apiKey}`;
3089
+ }
3090
+ let response: Response;
3091
+ try {
3092
+ response = await fetchImpl(`${managementBaseUrl}${endpoint}`, {
3093
+ method: "GET",
3094
+ headers: requestHeaders,
3095
+ signal,
3096
+ });
3097
+ } catch {
3098
+ return null;
3099
+ }
3100
+ if (!response.ok) {
3101
+ return null;
3102
+ }
3103
+ let payload: unknown;
3104
+ try {
3105
+ payload = await response.json();
3106
+ } catch {
3107
+ return null;
3108
+ }
3109
+ const entries = extractLiteLLMRichEntries(payload);
3110
+ if (!entries || entries.length === 0) {
3111
+ return null;
3112
+ }
3113
+ const deduped = new Map<string, ModelSpec<TApi>>();
3114
+ for (const entry of entries) {
3115
+ const model = mapLiteLLMRichEntry(entry, options, runtimeBaseUrl);
3116
+ if (model) {
3117
+ deduped.set(model.id, model);
3118
+ }
3119
+ }
3120
+ if (deduped.size === 0) {
3121
+ return null;
3122
+ }
3123
+ return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id));
3124
+ }
3125
+
3126
+ export async function fetchLiteLLMRichModels<TApi extends Api>(
3127
+ options: FetchLiteLLMRichModelsOptions<TApi>,
3128
+ ): Promise<ModelSpec<TApi>[] | null> {
3129
+ const managementBaseUrl = normalizeLiteLLMManagementBaseUrl(options.baseUrl);
3130
+ const runtimeBaseUrl = normalizeLiteLLMRuntimeBaseUrl(options.baseUrl);
3131
+ if (!managementBaseUrl || !runtimeBaseUrl) {
3132
+ return null;
3133
+ }
3134
+ const fetchModels = async (signal?: AbortSignal): Promise<ModelSpec<TApi>[] | null> => {
3135
+ for (const endpoint of LITELLM_RICH_ENDPOINTS) {
3136
+ const models = await fetchLiteLLMRichEndpoint(endpoint, options, managementBaseUrl, runtimeBaseUrl, signal);
3137
+ if (models) {
3138
+ return models;
3139
+ }
3140
+ }
3141
+ return null;
3142
+ };
3143
+ if (options.signal !== undefined) {
3144
+ return fetchModels(options.signal);
3145
+ }
3146
+ return options.timeoutMs !== undefined ? withCatalogDiscoveryTimeout(options.timeoutMs, fetchModels) : fetchModels();
3147
+ }
3148
+
3149
+ export function litellmModelManagerOptions(
3150
+ config?: LiteLLMModelManagerConfig,
3151
+ ): ModelManagerOptions<"openai-completions"> {
3152
+ const apiKey = config?.apiKey;
3153
+ const baseUrl = config?.baseUrl ?? Bun.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1";
3154
+ return {
3155
+ providerId: "litellm",
3156
+ // rich-v2 invalidates rows cached before reseller usage-suffix stripping
3157
+ // (stale display names like `MiniMax-M3 (3x usage)`); bump the version
3158
+ // whenever the mappers below change, or warm authoritative caches keep
3159
+ // serving pre-change rows for the full TTL.
3160
+ cacheProviderId: `litellm:rich-v2:${Bun.hash(baseUrl).toString(36)}`,
3161
+ // litellm is a local-only proxy and is never bundled in models.json (that
3162
+ // would leak the machine's localhost catalog). Prefer the proxy's richer
3163
+ // management metadata, then fall back to /v1/models and enrich bare ids
3164
+ // against models.dev like the gateway providers (fireworks et al.) do.
3165
+ fetchDynamicModels: async () => {
3166
+ const modelsDevReferences = await loadModelsDevReferences<"openai-completions">(config?.fetch);
3167
+ const resolveReference = (id: string) => modelsDevReferences.get(id);
3168
+ const richModels = await fetchLiteLLMRichModels({
3169
+ api: "openai-completions",
3170
+ provider: "litellm",
3171
+ baseUrl,
3172
+ apiKey,
3173
+ fetch: config?.fetch,
3174
+ referenceResolver: resolveReference,
3175
+ timeoutMs: 10_000,
3176
+ });
3177
+ if (richModels && richModels.length > 0) {
3178
+ return richModels;
3179
+ }
3180
+ return fetchOpenAICompatibleModels({
3181
+ api: "openai-completions",
3182
+ provider: "litellm",
3183
+ baseUrl,
3184
+ apiKey,
3185
+ mapModel: (entry, defaults) =>
3186
+ mapLiteLLMOpenAICompatibleModel(entry, defaults, resolveReference(defaults.id)),
3187
+ fetch: config?.fetch,
3188
+ });
3189
+ },
3190
+ };
3191
+ }
3192
+
3193
+ // ---------------------------------------------------------------------------
3194
+ // 22. vLLM
3195
+ // ---------------------------------------------------------------------------
3196
+
3197
+ const VLLM_DISCOVERY_TIMEOUT_MS = 10_000;
3198
+
3199
+ export interface VllmModelManagerConfig {
3200
+ apiKey?: string;
3201
+ baseUrl?: string;
3202
+ fetch?: FetchImpl;
3203
+ }
3204
+
3205
+ export function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelManagerOptions<"openai-completions"> {
3206
+ const apiKey = config?.apiKey;
3207
+ const baseUrl = config?.baseUrl ?? "http://127.0.0.1:8000/v1";
3208
+ const references = createBundledReferenceMap<"openai-completions">("vllm" as Parameters<typeof getBundledModels>[0]);
3209
+ return {
3210
+ providerId: "vllm",
3211
+ cacheProviderId: `vllm:${Bun.hash(baseUrl).toString(36)}`,
3212
+ fetchDynamicModels: () =>
3213
+ fetchOpenAICompatibleModels({
3214
+ api: "openai-completions",
3215
+ provider: "vllm",
3216
+ baseUrl,
3217
+ apiKey,
3218
+ mapModel: (entry, defaults) => {
3219
+ const model = mapWithBundledReference(entry, defaults, references.get(defaults.id));
3220
+ return {
3221
+ ...model,
3222
+ contextWindow: toPositiveNumber(entry.max_model_len, model.contextWindow),
3223
+ };
3224
+ },
3225
+ fetch: config?.fetch,
3226
+ timeoutMs: VLLM_DISCOVERY_TIMEOUT_MS,
3227
+ }),
3228
+ };
3229
+ }
3230
+
3231
+ // ---------------------------------------------------------------------------
3232
+ // 23. NanoGPT
3233
+ // ---------------------------------------------------------------------------
3234
+
3235
+ export interface NanoGptModelManagerConfig {
3236
+ apiKey?: string;
3237
+ baseUrl?: string;
3238
+ fetch?: FetchImpl;
3239
+ }
3240
+
3241
+ export function nanoGptModelManagerOptions(
3242
+ config?: NanoGptModelManagerConfig,
3243
+ ): ModelManagerOptions<"openai-completions"> {
3244
+ const apiKey = config?.apiKey;
3245
+ const baseUrl = config?.baseUrl ?? "https://nano-gpt.com/api/v1";
3246
+ const resolveReference = createReferenceResolver(
3247
+ createBundledReferenceMap<"openai-completions">("nanogpt" as Parameters<typeof getBundledModels>[0]),
3248
+ );
3249
+ return {
3250
+ providerId: "nanogpt",
3251
+ ...(apiKey && {
3252
+ fetchDynamicModels: async () => {
3253
+ // Track base IDs that have :thinking variants so we can mark them reasoning-capable.
3254
+ const thinkingBaseIds = new Set<string>();
3255
+ const models = await fetchOpenAICompatibleModels({
3256
+ api: "openai-completions",
3257
+ provider: "nanogpt",
3258
+ baseUrl,
3259
+ apiKey,
3260
+ mapModel: (entry, defaults) => {
3261
+ const reference = resolveReference(defaults.id);
3262
+ const mapped = mapWithBundledReference(entry, defaults, reference);
3263
+ return { ...mapped, api: "openai-completions", provider: "nanogpt" };
3264
+ },
3265
+ filterModel: (_entry, model) => {
3266
+ const match = NANO_GPT_THINKING_SUFFIX_RE.exec(model.id);
3267
+ if (match) {
3268
+ thinkingBaseIds.add(model.id.slice(0, match.index));
3269
+ return false;
3270
+ }
3271
+ return isLikelyNanoGptTextModelId(model.id);
3272
+ },
3273
+ fetch: config?.fetch,
3274
+ });
3275
+ if (!models) return null;
3276
+ // Mark base models as reasoning-capable when a :thinking variant existed.
3277
+ for (const model of models) {
3278
+ if (!model.reasoning && thinkingBaseIds.has(model.id)) {
3279
+ (model as { reasoning: boolean }).reasoning = true;
3280
+ }
3281
+ }
3282
+ return models;
3283
+ },
3284
+ }),
3285
+ };
3286
+ }
3287
+
3288
+ // ---------------------------------------------------------------------------
3289
+ // 24. GitHub Copilot
3290
+ // ---------------------------------------------------------------------------
3291
+
3292
+ export interface GithubCopilotModelManagerConfig {
3293
+ apiKey?: string;
3294
+ baseUrl?: string;
3295
+ fetch?: FetchImpl;
3296
+ }
3297
+
3298
+ const COPILOT_ANTHROPIC_MODEL_PATTERN = /^claude-(haiku|sonnet|opus|fable|mythos)-\d/;
3299
+ const isCopilotResponsesModelId = (modelId: string): boolean =>
3300
+ modelId.startsWith("gpt-5") || modelId.startsWith("oswe");
3301
+
3302
+ function inferCopilotApi(modelId: string): Api {
3303
+ if (COPILOT_ANTHROPIC_MODEL_PATTERN.test(modelId)) {
3304
+ return "anthropic-messages";
3305
+ }
3306
+ if (isCopilotResponsesModelId(modelId)) {
3307
+ return "openai-responses";
3308
+ }
3309
+ return "openai-completions";
3310
+ }
3311
+
3312
+ function extractCopilotLimits(entry: OpenAICompatibleModelRecord): {
3313
+ maxPromptTokens?: number;
3314
+ maxContextWindowTokens?: number;
3315
+ maxOutputTokens?: number;
3316
+ maxNonStreamingOutputTokens?: number;
3317
+ } {
3318
+ if (!isRecord(entry.capabilities)) {
3319
+ return {};
3320
+ }
3321
+ const limitsValue = entry.capabilities.limits;
3322
+ if (!isRecord(limitsValue)) {
3323
+ return {};
3324
+ }
3325
+ return {
3326
+ maxPromptTokens: toNumber(limitsValue.max_prompt_tokens),
3327
+ maxContextWindowTokens: toNumber(limitsValue.max_context_window_tokens),
3328
+ maxOutputTokens: toNumber(limitsValue.max_output_tokens),
3329
+ maxNonStreamingOutputTokens: toNumber(limitsValue.max_non_streaming_output_tokens),
3330
+ };
3331
+ }
3332
+
3333
+ /** Local id/name suffixes for synthesized Copilot long-context variants. */
3334
+ export const COPILOT_LONG_CONTEXT_ID_SUFFIX = "-1m";
3335
+ const COPILOT_LONG_CONTEXT_NAME_SUFFIX = " (1M)";
3336
+
3337
+ /** One tier of Copilot token pricing (`billing.token_prices.{default,long_context}`). Prices are hundredths of a dollar per 1M tokens. */
3338
+ interface CopilotTokenPriceTier {
3339
+ contextMax?: number;
3340
+ inputPrice?: number;
3341
+ outputPrice?: number;
3342
+ cachePrice?: number;
3343
+ }
3344
+
3345
+ function parseCopilotTokenPriceTier(value: unknown): CopilotTokenPriceTier | undefined {
3346
+ if (!isRecord(value)) {
3347
+ return undefined;
3348
+ }
3349
+ return {
3350
+ contextMax: toNumber(value.context_max),
3351
+ inputPrice: toNumber(value.input_price),
3352
+ outputPrice: toNumber(value.output_price),
3353
+ cachePrice: toNumber(value.cache_price),
3354
+ };
3355
+ }
3356
+
3357
+ /**
3358
+ * Tiered context boundaries/prices from `billing.token_prices`. Served only
3359
+ * when discovery requests `X-GitHub-Api-Version` ≥ 2026-06-01; absent on the
3360
+ * legacy response shape (where `capabilities.limits` is already tier-capped).
3361
+ */
3362
+ function extractCopilotTokenPrices(entry: OpenAICompatibleModelRecord): {
3363
+ defaultTier?: CopilotTokenPriceTier;
3364
+ longContext?: CopilotTokenPriceTier;
3365
+ } {
3366
+ if (!isRecord(entry.billing)) {
3367
+ return {};
3368
+ }
3369
+ const tokenPrices = entry.billing.token_prices;
3370
+ if (!isRecord(tokenPrices)) {
3371
+ return {};
3372
+ }
3373
+ return {
3374
+ defaultTier: parseCopilotTokenPriceTier(tokenPrices.default),
3375
+ longContext: parseCopilotTokenPriceTier(tokenPrices.long_context),
3376
+ };
3377
+ }
3378
+
3379
+ function extractCopilotSupportsVision(entry: OpenAICompatibleModelRecord): boolean | undefined {
3380
+ if (!isRecord(entry.capabilities)) {
3381
+ return undefined;
3382
+ }
3383
+ const supports = entry.capabilities.supports;
3384
+ if (!isRecord(supports)) {
3385
+ return undefined;
3386
+ }
3387
+ return toBoolean(supports.vision);
3388
+ }
3389
+
3390
+ /** Copilot's `/models` mixes chat and embedding models; only `type: "chat"` entries are usable here. */
3391
+ function isCopilotChatModel(entry: OpenAICompatibleModelRecord): boolean {
3392
+ if (!isRecord(entry.capabilities)) {
3393
+ return true;
3394
+ }
3395
+ const type = entry.capabilities.type;
3396
+ return typeof type !== "string" || type === "chat";
3397
+ }
3398
+
3399
+ function copilotTierCost(
3400
+ tier: CopilotTokenPriceTier | undefined,
3401
+ ): Omit<ModelSpec<Api>["cost"], "cacheWrite"> | undefined {
3402
+ if (tier?.inputPrice === undefined || tier.outputPrice === undefined) {
3403
+ return undefined;
3404
+ }
3405
+ return {
3406
+ input: tier.inputPrice / 100,
3407
+ output: tier.outputPrice / 100,
3408
+ cacheRead: (tier.cachePrice ?? 0) / 100,
3409
+ };
3410
+ }
3411
+
3412
+ /**
3413
+ * Synthesize the opt-in long-context sibling for a Copilot model that reports
3414
+ * a `billing.token_prices.long_context` tier (e.g. Claude Opus 200k → 1M, as
3415
+ * selectable in copilot-cli). The variant is a local catalog entry: it keeps
3416
+ * the upstream model id on the wire via `requestModelId` — the tier is purely
3417
+ * a client-side context budget with its own pricing, not a served model id.
3418
+ * The base entry stays on the default tier so nobody silently pays
3419
+ * long-context rates.
3420
+ */
3421
+ function createCopilotLongContextVariant(
3422
+ base: ModelSpec<Api>,
3423
+ fullContextWindow: number | null,
3424
+ maxTokens: number | null,
3425
+ longContext: CopilotTokenPriceTier | undefined,
3426
+ ): ModelSpec<Api> | undefined {
3427
+ const longContextMax = longContext?.contextMax;
3428
+ if (longContextMax === undefined || longContextMax <= 0 || fullContextWindow === null || maxTokens === null) {
3429
+ return undefined;
3430
+ }
3431
+ const variantWindow = Math.min(fullContextWindow, longContextMax + maxTokens);
3432
+ if (base.contextWindow === null || variantWindow <= base.contextWindow) {
3433
+ return undefined;
3434
+ }
3435
+ const longCost = copilotTierCost(longContext);
3436
+ return {
3437
+ ...base,
3438
+ id: `${base.id}${COPILOT_LONG_CONTEXT_ID_SUFFIX}`,
3439
+ requestModelId: base.id,
3440
+ name: `${base.name}${COPILOT_LONG_CONTEXT_NAME_SUFFIX}`,
3441
+ contextWindow: variantWindow,
3442
+ // Long-context tier has its own token prices (Gemini/GPT bill ~2x above
3443
+ // the default boundary). cacheWrite is not reported per tier; inherit.
3444
+ ...(longCost && { cost: { ...longCost, cacheWrite: base.cost.cacheWrite } }),
3445
+ contextPromotionTarget: undefined,
3446
+ };
3447
+ }
3448
+
3449
+ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelManagerConfig): ModelManagerOptions<Api> {
3450
+ const rawApiKey = config?.apiKey;
3451
+ const configuredBaseUrl = config?.baseUrl ?? "https://api.githubcopilot.com";
3452
+ const parsedApiKey = rawApiKey ? parseGitHubCopilotApiKey(rawApiKey) : undefined;
3453
+ const apiKey = parsedApiKey?.accessToken;
3454
+ const baseUrl =
3455
+ parsedApiKey?.apiEndpoint && configuredBaseUrl.includes("githubcopilot.com")
3456
+ ? parsedApiKey.apiEndpoint
3457
+ : parsedApiKey?.enterpriseUrl && configuredBaseUrl.includes("githubcopilot.com")
3458
+ ? getGitHubCopilotBaseUrl(parsedApiKey.enterpriseUrl)
3459
+ : configuredBaseUrl;
3460
+ const providerRefs = createBundledReferenceMap<Api>("github-copilot");
3461
+ const resolveReference = createReferenceResolver(providerRefs);
3462
+ return {
3463
+ providerId: "github-copilot",
3464
+ ...(apiKey && {
3465
+ fetchDynamicModels: async () => {
3466
+ const longContextVariants: ModelSpec<Api>[] = [];
3467
+ const models = await fetchOpenAICompatibleModels<Api>({
3468
+ api: "openai-completions",
3469
+ provider: "github-copilot",
3470
+ baseUrl,
3471
+ apiKey,
3472
+ headers: COPILOT_API_HEADERS,
3473
+ mapModel: (
3474
+ entry: OpenAICompatibleModelRecord,
3475
+ defaults: ModelSpec<Api>,
3476
+ _context: OpenAICompatibleModelMapperContext<Api>,
3477
+ ): ModelSpec<Api> | null => {
3478
+ if (!isCopilotChatModel(entry)) {
3479
+ return null;
3480
+ }
3481
+ const reference = resolveReference(defaults.id);
3482
+ const copilotLimits = extractCopilotLimits(entry);
3483
+ // Copilot exposes token limits under capabilities.limits.*.
3484
+ // max_context_window_tokens is the model's total usable window;
3485
+ // max_prompt_tokens is Copilot's prompt/summarization budget and
3486
+ // must only be a fallback when total-window fields are absent.
3487
+ const contextWindow = toPositiveNumber(
3488
+ copilotLimits.maxContextWindowTokens,
3489
+ toPositiveNumber(
3490
+ entry.context_length,
3491
+ toPositiveNumber(
3492
+ copilotLimits.maxPromptTokens,
3493
+ reference?.contextWindow ?? defaults.contextWindow,
3494
+ ),
3495
+ ),
3496
+ );
3497
+ const maxTokens = toPositiveNumber(
3498
+ copilotLimits.maxOutputTokens,
3499
+ toPositiveNumber(
3500
+ entry.max_completion_tokens,
3501
+ toPositiveNumber(
3502
+ copilotLimits.maxNonStreamingOutputTokens,
3503
+ reference?.maxTokens ?? defaults.maxTokens,
3504
+ ),
3505
+ ),
3506
+ );
3507
+ const name =
3508
+ typeof entry.name === "string" && entry.name.trim().length > 0
3509
+ ? entry.name
3510
+ : (reference?.name ?? defaults.name);
3511
+ const api = inferCopilotApi(defaults.id);
3512
+ // `supports.vision` reports the model's intrinsic capability, but
3513
+ // the business/enterprise endpoints respond `400 vision is not
3514
+ // supported` on image inputs. Only honour the flag for the
3515
+ // canonical personal-Copilot host.
3516
+ const supportsVision = extractCopilotSupportsVision(entry);
3517
+ const input: ModelSpec<Api>["input"] = isPersonalGitHubCopilotBaseUrl(baseUrl)
3518
+ ? supportsVision
3519
+ ? ["text", "image"]
3520
+ : (reference?.input ?? defaults.input)
3521
+ : ["text"];
3522
+ // With COPILOT_API_HEADERS the served window is the long-context
3523
+ // ceiling; the default tier ends at token_prices.default.context_max
3524
+ // prompt tokens. Cap the base entry to the default tier — the long
3525
+ // tier is the opt-in `-1m` sibling below.
3526
+ const tokenPrices = extractCopilotTokenPrices(entry);
3527
+ const defaultContextMax = tokenPrices.defaultTier?.contextMax;
3528
+ const defaultTierWindow =
3529
+ defaultContextMax !== undefined &&
3530
+ defaultContextMax > 0 &&
3531
+ contextWindow !== null &&
3532
+ maxTokens !== null
3533
+ ? Math.min(contextWindow, defaultContextMax + maxTokens)
3534
+ : contextWindow;
3535
+ const base: ModelSpec<Api> = reference
3536
+ ? {
3537
+ ...reference,
3538
+ api,
3539
+ provider: "github-copilot",
3540
+ baseUrl,
3541
+ name,
3542
+ input,
3543
+ contextWindow: defaultTierWindow,
3544
+ maxTokens,
3545
+ headers: { ...COPILOT_API_HEADERS, ...(providerRefs.get(defaults.id)?.headers ?? {}) },
3546
+ ...(api === "openai-completions"
3547
+ ? {
3548
+ compat: {
3549
+ supportsStore: false,
3550
+ supportsDeveloperRole: false,
3551
+ supportsReasoningEffort: false,
3552
+ },
3553
+ }
3554
+ : {}),
3555
+ }
3556
+ : {
3557
+ ...defaults,
3558
+ api,
3559
+ baseUrl,
3560
+ name,
3561
+ input,
3562
+ contextWindow: defaultTierWindow,
3563
+ maxTokens,
3564
+ headers: { ...COPILOT_API_HEADERS },
3565
+ ...(api === "openai-completions"
3566
+ ? {
3567
+ compat: {
3568
+ supportsStore: false,
3569
+ supportsDeveloperRole: false,
3570
+ supportsReasoningEffort: false,
3571
+ },
3572
+ }
3573
+ : {}),
3574
+ };
3575
+ const variant = createCopilotLongContextVariant(
3576
+ base,
3577
+ contextWindow,
3578
+ maxTokens,
3579
+ tokenPrices.longContext,
3580
+ );
3581
+ if (variant) {
3582
+ longContextVariants.push(variant);
3583
+ // Overflowing the default tier promotes into the 1M sibling
3584
+ // unless the reference already pins a target.
3585
+ base.contextPromotionTarget ??= `github-copilot/${variant.id}`;
3586
+ }
3587
+ return base;
3588
+ },
3589
+ fetch: config?.fetch,
3590
+ });
3591
+ if (models === null) {
3592
+ return null;
3593
+ }
3594
+ // Append synthesized tiers; a real upstream id always wins over a
3595
+ // local variant with the same id.
3596
+ const takenIds = new Set(models.map(model => model.id));
3597
+ for (const variant of longContextVariants) {
3598
+ if (takenIds.has(variant.id)) {
3599
+ continue;
3600
+ }
3601
+ takenIds.add(variant.id);
3602
+ models.push(variant);
3603
+ }
3604
+ return models.sort((left, right) => left.id.localeCompare(right.id));
3605
+ },
3606
+ }),
3607
+ };
3608
+ }
3609
+
3610
+ // ---------------------------------------------------------------------------
3611
+ // 24. Anthropic
3612
+ // ---------------------------------------------------------------------------
3613
+
3614
+ export interface AnthropicModelManagerConfig {
3615
+ apiKey?: string;
3616
+ baseUrl?: string;
3617
+ fetch?: FetchImpl;
3618
+ }
3619
+
3620
+ export function anthropicModelManagerOptions(
3621
+ config?: AnthropicModelManagerConfig,
3622
+ ): ModelManagerOptions<"anthropic-messages"> {
3623
+ const apiKey = config?.apiKey;
3624
+ const baseUrl = config?.baseUrl ?? ANTHROPIC_BASE_URL;
3625
+ return {
3626
+ providerId: "anthropic",
3627
+ modelsDev: {
3628
+ fetch: () => fetchModelsDevPayload(config?.fetch),
3629
+ map: payload => mapAnthropicModelsDev(payload, baseUrl),
3630
+ },
3631
+ ...(apiKey && {
3632
+ fetchDynamicModels: async () => {
3633
+ const modelsDevModels = await fetchModelsDevPayload(config?.fetch)
3634
+ .then(payload => mapAnthropicModelsDev(payload, baseUrl))
3635
+ .catch(() => []);
3636
+ const references = buildAnthropicReferenceMap(modelsDevModels);
3637
+ return (
3638
+ fetchOpenAICompatibleModels({
3639
+ api: "anthropic-messages",
3640
+ provider: "anthropic",
3641
+ baseUrl,
3642
+ headers: buildAnthropicDiscoveryHeaders(apiKey),
3643
+ mapModel: (
3644
+ entry: OpenAICompatibleModelRecord,
3645
+ defaults: ModelSpec<"anthropic-messages">,
3646
+ _context: OpenAICompatibleModelMapperContext<"anthropic-messages">,
3647
+ ): ModelSpec<"anthropic-messages"> => {
3648
+ const discoveredName = typeof entry.display_name === "string" ? entry.display_name : defaults.name;
3649
+ const reference = references.get(defaults.id);
3650
+ if (!reference) {
3651
+ return {
3652
+ ...defaults,
3653
+ name: discoveredName,
3654
+ };
3655
+ }
3656
+ return {
3657
+ ...reference,
3658
+ id: defaults.id,
3659
+ name: discoveredName,
3660
+ api: "anthropic-messages",
3661
+ provider: "anthropic",
3662
+ baseUrl,
3663
+ };
3664
+ },
3665
+ fetch: config?.fetch,
3666
+ }) ?? null
3667
+ );
3668
+ },
3669
+ }),
3670
+ };
3671
+ }
3672
+
3673
+ // ---------------------------------------------------------------------------
3674
+ // Models.dev provider descriptors for generate-models.ts
3675
+ // ---------------------------------------------------------------------------
3676
+
3677
+ /** Describes how to map models.dev API data for a single provider. */
3678
+ export interface ModelsDevProviderDescriptor {
3679
+ /** Key in the models.dev API response JSON (e.g., "anthropic", "amazon-bedrock") */
3680
+ modelsDevKey: string;
3681
+ /** Provider ID in our system */
3682
+ providerId: string;
3683
+ /** Default API type for this provider's models */
3684
+ api: Api;
3685
+ /** Default base URL */
3686
+ baseUrl: string;
3687
+ /** Default context window fallback (default: UNKNNOWN_CONTEXT_WINDOW) */
3688
+ defaultContextWindow?: number;
3689
+ /** Default max tokens fallback (default: UNKNNOWN_MAX_TOKENS) */
3690
+ defaultMaxTokens?: number;
3691
+ /** Optional compat overrides applied to every model from this provider */
3692
+ compat?: ModelSpec<Api>["compat"];
3693
+ /** Optional static headers applied to every model */
3694
+ headers?: Record<string, string>;
3695
+ /**
3696
+ * Optional filter: return false to skip a model.
3697
+ * Called with (modelId, rawModel). Default: skip if tool_call !== true.
3698
+ */
3699
+ filterModel?: (modelId: string, model: ModelsDevModel) => boolean;
3700
+ /**
3701
+ * Optional transform: modify the mapped model before it's added.
3702
+ * Can return null to skip the model, or an array to emit multiple models.
3703
+ */
3704
+ transformModel?: (
3705
+ model: ModelSpec<Api>,
3706
+ modelId: string,
3707
+ raw: ModelsDevModel,
3708
+ ) => ModelSpec<Api> | ModelSpec<Api>[] | null;
3709
+ /**
3710
+ * Optional: override the API type per-model.
3711
+ * Called with (modelId, raw). Return the API type to use.
3712
+ * If not provided, uses the `api` field.
3713
+ */
3714
+ resolveApi?: (modelId: string, raw: ModelsDevModel) => { api: Api; baseUrl: string } | null;
3715
+ }
3716
+
3717
+ /** Generic mapper that converts models.dev data using provider descriptors. */
3718
+ export function mapModelsDevToModels(
3719
+ data: Record<string, unknown>,
3720
+ descriptors: readonly ModelsDevProviderDescriptor[],
3721
+ ): ModelSpec<Api>[] {
3722
+ const models: ModelSpec<Api>[] = [];
3723
+ for (const desc of descriptors) {
3724
+ const providerData = (data as Record<string, Record<string, unknown>>)[desc.modelsDevKey];
3725
+ if (!isRecord(providerData) || !isRecord(providerData.models)) continue;
3726
+
3727
+ for (const [modelId, rawModel] of Object.entries(providerData.models)) {
3728
+ if (!isRecord(rawModel)) continue;
3729
+ const m = rawModel as ModelsDevModel;
3730
+
3731
+ // Default filter: tool_call must be true
3732
+ if (desc.filterModel) {
3733
+ if (!desc.filterModel(modelId, m)) continue;
3734
+ } else {
3735
+ if (m.tool_call !== true) continue;
3736
+ }
3737
+
3738
+ // Resolve API and baseUrl (may be per-model for providers like OpenCode)
3739
+ const resolved = desc.resolveApi?.(modelId, m) ?? { api: desc.api, baseUrl: desc.baseUrl };
3740
+ if (!resolved) continue;
3741
+
3742
+ const mapped: ModelSpec<Api> = {
3743
+ id: modelId,
3744
+ name: toModelName(m.name, modelId),
3745
+ api: resolved.api,
3746
+ provider: desc.providerId as ModelSpec<Api>["provider"],
3747
+ baseUrl: resolved.baseUrl,
3748
+ reasoning: m.reasoning === true,
3749
+ input: toInputCapabilities(m.modalities?.input),
3750
+ cost: {
3751
+ input: toNumber(m.cost?.input) ?? 0,
3752
+ output: toNumber(m.cost?.output) ?? 0,
3753
+ cacheRead: toNumber(m.cost?.cache_read) ?? 0,
3754
+ cacheWrite: toNumber(m.cost?.cache_write) ?? 0,
3755
+ },
3756
+ contextWindow: toPositiveNumber(m.limit?.context, desc.defaultContextWindow ?? null),
3757
+ maxTokens: toPositiveNumber(m.limit?.output, desc.defaultMaxTokens ?? null),
3758
+ ...(m.tool_call === false ? { supportsTools: false } : {}),
3759
+ ...(desc.compat && { compat: desc.compat }),
3760
+ ...(desc.headers && { headers: { ...desc.headers } }),
3761
+ };
3762
+
3763
+ // Apply per-model transform
3764
+ if (desc.transformModel) {
3765
+ const result = desc.transformModel(mapped, modelId, m);
3766
+ if (result === null) continue;
3767
+ if (Array.isArray(result)) {
3768
+ models.push(...result);
3769
+ } else {
3770
+ models.push(result);
3771
+ }
3772
+ } else {
3773
+ models.push(mapped);
3774
+ }
3775
+ }
3776
+ }
3777
+ return models;
3778
+ }
3779
+
3780
+ // Bedrock cross-region prefix helpers
3781
+ const BEDROCK_GLOBAL_PREFIXES = [
3782
+ "anthropic.claude-fable-5",
3783
+ "anthropic.claude-mythos-5",
3784
+ "anthropic.claude-haiku-4-5",
3785
+ "anthropic.claude-sonnet-4",
3786
+ "anthropic.claude-opus-4-5",
3787
+ "amazon.nova-2-lite",
3788
+ "cohere.embed-v4",
3789
+ "twelvelabs.pegasus-1-2",
3790
+ ];
3791
+
3792
+ const BEDROCK_US_PREFIXES = [
3793
+ "amazon.nova-lite",
3794
+ "amazon.nova-micro",
3795
+ "amazon.nova-premier",
3796
+ "amazon.nova-pro",
3797
+ "anthropic.claude-3-7-sonnet",
3798
+ "anthropic.claude-opus-4-1",
3799
+ "anthropic.claude-opus-4-20250514",
3800
+ "deepseek.r1",
3801
+ "meta.llama3-2",
3802
+ "meta.llama3-3",
3803
+ "meta.llama4",
3804
+ ];
3805
+
3806
+ function bedrockCrossRegionId(id: string): string {
3807
+ if (BEDROCK_GLOBAL_PREFIXES.some(p => id.startsWith(p))) return `global.${id}`;
3808
+ if (BEDROCK_US_PREFIXES.some(p => id.startsWith(p))) return `us.${id}`;
3809
+ return id;
3810
+ }
3811
+
3812
+ interface ApiResolutionRule {
3813
+ matches: (modelId: string, raw: ModelsDevModel) => boolean;
3814
+ resolved: { api: Api; baseUrl: string };
3815
+ }
3816
+
3817
+ function resolveApiByRules(
3818
+ modelId: string,
3819
+ raw: ModelsDevModel,
3820
+ rules: readonly ApiResolutionRule[],
3821
+ fallback: { api: Api; baseUrl: string },
3822
+ ): { api: Api; baseUrl: string } {
3823
+ for (const rule of rules) {
3824
+ if (rule.matches(modelId, raw)) return rule.resolved;
3825
+ }
3826
+ return fallback;
3827
+ }
3828
+
3829
+ function createOpenCodeApiResolution(
3830
+ basePath: string,
3831
+ idOverrides: Readonly<Record<string, Api>> = {},
3832
+ ): {
3833
+ defaultResolution: { api: Api; baseUrl: string };
3834
+ rules: ApiResolutionRule[];
3835
+ } {
3836
+ const completionsBaseUrl = `${basePath}/v1`;
3837
+ // Per-API base URLs on the OpenCode-style endpoint:
3838
+ // - openai-completions / openai-responses / google-generative-ai → /v1
3839
+ // - anthropic-messages → bare basePath (the Anthropic client appends /v1/messages)
3840
+ const baseUrlForApi = (api: Api): string => (api === "anthropic-messages" ? basePath : completionsBaseUrl);
3841
+ const overrideRules: ApiResolutionRule[] = Object.entries(idOverrides).map(([id, api]) => ({
3842
+ matches: modelId => modelId === id,
3843
+ resolved: { api, baseUrl: baseUrlForApi(api) },
3844
+ }));
3845
+ return {
3846
+ defaultResolution: { api: "openai-completions", baseUrl: completionsBaseUrl },
3847
+ rules: [
3848
+ // Per-id overrides take precedence over npm-based heuristics so we can
3849
+ // correct upstream metadata mismatches (see OPENCODE_GO_API_RESOLUTION).
3850
+ ...overrideRules,
3851
+ {
3852
+ matches: (_modelId, raw) => raw.provider?.npm === "@ai-sdk/openai",
3853
+ resolved: { api: "openai-responses", baseUrl: completionsBaseUrl },
3854
+ },
3855
+ {
3856
+ matches: (_modelId, raw) => raw.provider?.npm === "@ai-sdk/anthropic",
3857
+ resolved: { api: "anthropic-messages", baseUrl: basePath },
3858
+ },
3859
+ {
3860
+ matches: (_modelId, raw) => raw.provider?.npm === "@ai-sdk/google",
3861
+ resolved: { api: "google-generative-ai", baseUrl: completionsBaseUrl },
3862
+ },
3863
+ ],
3864
+ };
3865
+ }
3866
+
3867
+ // OpenCode Zen: models.dev declares minimax-m3-free (and forward-compat
3868
+ // minimax-m3) with `provider.npm = "@ai-sdk/anthropic"`, but the Zen gateway
3869
+ // only serves them at https://opencode.ai/zen/v1/chat/completions (verified
3870
+ // against the live /v1/models response — minimax-m3-free is listed there, and
3871
+ // the gateway has no /v1/messages route for it). Without this override the
3872
+ // resolver POSTs anthropic-shaped requests to /v1/messages and the UI surfaces
3873
+ // raw <invoke>/<|minimax|>/<tool_call> markup (#1617).
3874
+ const OPENCODE_ZEN_API_RESOLUTION = createOpenCodeApiResolution("https://opencode.ai/zen", {
3875
+ "minimax-m3": "openai-completions",
3876
+ "minimax-m3-free": "openai-completions",
3877
+ });
3878
+ // OpenCode Go: models.dev declares minimax-m2.7 / qwen3.5-plus / qwen3.6-plus
3879
+ // (and now also minimax-m3) with `provider.npm = "@ai-sdk/anthropic"`, but
3880
+ // the OpenCode Go gateway only serves them at
3881
+ // `https://opencode.ai/zen/go/v1/chat/completions` (verified against
3882
+ // https://opencode.ai/zen/go/v1/models and the upstream endpoint table at
3883
+ // https://opencode.ai/docs/go/#endpoints — minimax-m2.5 works the same way
3884
+ // and lacks an `npm` field on models.dev so it already falls through to the
3885
+ // openai-completions default). Without this override the resolver would POST
3886
+ // anthropic-style requests to /v1/messages and the gateway would return its
3887
+ // `Page Not Found` HTML (issue #887 for the qwen/m2.7 entries; minimax-m3
3888
+ // and minimax-m3-free added under #1617 for the same root cause).
3889
+ const OPENCODE_GO_API_RESOLUTION = createOpenCodeApiResolution("https://opencode.ai/zen/go", {
3890
+ "minimax-m2.7": "openai-completions",
3891
+ "minimax-m3": "openai-completions",
3892
+ "minimax-m3-free": "openai-completions",
3893
+ "qwen3.5-plus": "openai-completions",
3894
+ "qwen3.6-plus": "openai-completions",
3895
+ });
3896
+
3897
+ const COPILOT_BASE_URL = "https://api.githubcopilot.com";
3898
+
3899
+ const COPILOT_DEFAULT_RESOLUTION = {
3900
+ api: "openai-completions",
3901
+ baseUrl: COPILOT_BASE_URL,
3902
+ } as const satisfies { api: Api; baseUrl: string };
3903
+
3904
+ const COPILOT_API_RESOLUTION_RULES: readonly ApiResolutionRule[] = [
3905
+ {
3906
+ matches: modelId => COPILOT_ANTHROPIC_MODEL_PATTERN.test(modelId),
3907
+ resolved: { api: "anthropic-messages", baseUrl: COPILOT_BASE_URL },
3908
+ },
3909
+ {
3910
+ matches: isCopilotResponsesModelId,
3911
+ resolved: { api: "openai-responses", baseUrl: COPILOT_BASE_URL },
3912
+ },
3913
+ ];
3914
+
3915
+ function simpleModelsDevDescriptor(
3916
+ modelsDevKey: string,
3917
+ providerId: string,
3918
+ api: Api,
3919
+ baseUrl: string,
3920
+ options: Omit<ModelsDevProviderDescriptor, "modelsDevKey" | "providerId" | "api" | "baseUrl"> = {},
3921
+ ): ModelsDevProviderDescriptor {
3922
+ return {
3923
+ modelsDevKey,
3924
+ providerId,
3925
+ api,
3926
+ baseUrl,
3927
+ ...options,
3928
+ };
3929
+ }
3930
+
3931
+ function openAiCompletionsDescriptor(
3932
+ modelsDevKey: string,
3933
+ providerId: string,
3934
+ baseUrl: string,
3935
+ options: Omit<ModelsDevProviderDescriptor, "modelsDevKey" | "providerId" | "api" | "baseUrl"> = {},
3936
+ ): ModelsDevProviderDescriptor {
3937
+ return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-completions", baseUrl, options);
3938
+ }
3939
+
3940
+ function anthropicMessagesDescriptor(
3941
+ modelsDevKey: string,
3942
+ providerId: string,
3943
+ baseUrl: string,
3944
+ options: Omit<ModelsDevProviderDescriptor, "modelsDevKey" | "providerId" | "api" | "baseUrl"> = {},
3945
+ ): ModelsDevProviderDescriptor {
3946
+ return simpleModelsDevDescriptor(modelsDevKey, providerId, "anthropic-messages", baseUrl, options);
3947
+ }
3948
+
3949
+ const GOOGLE_VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
3950
+ const GOOGLE_VERTEX_OPENAI_BASE_URL =
3951
+ "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi";
3952
+ const GOOGLE_VERTEX_ANTHROPIC_BASE_URL =
3953
+ "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:streamRawPredict";
3954
+
3955
+ function resolveGoogleVertexApi(modelId: string, raw: ModelsDevModel): { api: Api; baseUrl: string } {
3956
+ if (raw.provider?.npm === "@ai-sdk/google-vertex/anthropic") {
3957
+ return {
3958
+ api: "anthropic-messages",
3959
+ baseUrl: GOOGLE_VERTEX_ANTHROPIC_BASE_URL.replace("{model}", modelId),
3960
+ };
3961
+ }
3962
+ if (modelId.includes("/") || raw.provider?.npm === "@ai-sdk/openai-compatible") {
3963
+ return { api: "openai-completions", baseUrl: GOOGLE_VERTEX_OPENAI_BASE_URL };
3964
+ }
3965
+ return { api: "google-vertex", baseUrl: GOOGLE_VERTEX_BASE_URL };
3966
+ }
3967
+
3968
+ const MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK: readonly ModelsDevProviderDescriptor[] = [
3969
+ // --- Amazon Bedrock ---
3970
+ {
3971
+ modelsDevKey: "amazon-bedrock",
3972
+ providerId: "amazon-bedrock",
3973
+ api: "bedrock-converse-stream",
3974
+ baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
3975
+ filterModel: (id, m) => {
3976
+ if (m.tool_call !== true) return false;
3977
+ if (id.startsWith("ai21.jamba")) return false;
3978
+ if (id.startsWith("amazon.titan-text-express") || id.startsWith("mistral.mistral-7b-instruct-v0"))
3979
+ return false;
3980
+ return true;
3981
+ },
3982
+ transformModel: (model, modelId, m) => {
3983
+ const crossRegionId = bedrockCrossRegionId(modelId);
3984
+ const bedrockModel: ModelSpec<Api> = {
3985
+ ...model,
3986
+ id: crossRegionId,
3987
+ name: toModelName(m.name, crossRegionId),
3988
+ };
3989
+ // Also emit EU variants for Claude models
3990
+ if (modelId.startsWith("anthropic.claude-")) {
3991
+ return [
3992
+ bedrockModel,
3993
+ {
3994
+ ...bedrockModel,
3995
+ id: `eu.${modelId}`,
3996
+ name: `${toModelName(m.name, modelId)} (EU)`,
3997
+ },
3998
+ ];
3999
+ }
4000
+ return bedrockModel;
4001
+ },
4002
+ },
4003
+ ];
4004
+
4005
+ const MODELS_DEV_PROVIDER_DESCRIPTORS_CORE: readonly ModelsDevProviderDescriptor[] = [
4006
+ // --- Anthropic ---
4007
+ anthropicMessagesDescriptor("anthropic", "anthropic", "https://api.anthropic.com", {
4008
+ filterModel: (id, m) => {
4009
+ if (m.tool_call !== true) return false;
4010
+ if (
4011
+ id.startsWith("claude-3-5-haiku") ||
4012
+ id.startsWith("claude-3-7-sonnet") ||
4013
+ id === "claude-3-opus-20240229" ||
4014
+ id === "claude-3-sonnet-20240229"
4015
+ )
4016
+ return false;
4017
+ return true;
4018
+ },
4019
+ }),
4020
+ // --- Google ---
4021
+ simpleModelsDevDescriptor(
4022
+ "google",
4023
+ "google",
4024
+ "google-generative-ai",
4025
+ "https://generativelanguage.googleapis.com/v1beta",
4026
+ ),
4027
+ // --- OpenAI ---
4028
+ simpleModelsDevDescriptor("openai", "openai", "openai-responses", "https://api.openai.com/v1"),
4029
+ // --- Groq ---
4030
+ openAiCompletionsDescriptor("groq", "groq", "https://api.groq.com/openai/v1"),
4031
+ // --- Cerebras ---
4032
+ openAiCompletionsDescriptor("cerebras", "cerebras", "https://api.cerebras.ai/v1"),
4033
+ // --- Together ---
4034
+ openAiCompletionsDescriptor("togetherai", "together", "https://api.together.xyz/v1"),
4035
+ // --- CoreWeave Serverless Inference ---
4036
+ openAiCompletionsDescriptor("wandb", "coreweave", "https://api.inference.wandb.ai/v1", {
4037
+ transformModel: model => {
4038
+ if (!model.id.startsWith("openai/gpt-oss-")) {
4039
+ return model;
4040
+ }
4041
+ return {
4042
+ ...model,
4043
+ reasoning: true,
4044
+ thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High] },
4045
+ };
4046
+ },
4047
+ }),
4048
+ // --- NVIDIA ---
4049
+ openAiCompletionsDescriptor("nvidia", "nvidia", "https://integrate.api.nvidia.com/v1", {
4050
+ defaultContextWindow: 131072,
4051
+ }),
4052
+ // --- xAI ---
4053
+ openAiCompletionsDescriptor("xai", "xai", "https://api.x.ai/v1"),
4054
+ // --- DeepSeek ---
4055
+ openAiCompletionsDescriptor("deepseek", "deepseek", "https://api.deepseek.com", {
4056
+ // Only ship the v4 family as built-ins; older deepseek-chat / deepseek-reasoner
4057
+ // ids are kept off the catalog until the issue thread asks for them.
4058
+ filterModel: (id, m) => m.tool_call === true && id.startsWith("deepseek-v4"),
4059
+ compat: {
4060
+ // DeepSeek V4 effort remapping is derived in model-thinking metadata; this
4061
+ // descriptor keeps only transport-shape compat.
4062
+ supportsDeveloperRole: false,
4063
+ supportsReasoningEffort: true,
4064
+ maxTokensField: "max_tokens",
4065
+ // DeepSeek V4 thinking mode rejects the `tool_choice` control parameter.
4066
+ // Tool calls still work without it; the API defaults to auto when tools exist.
4067
+ supportsToolChoice: false,
4068
+ // DeepSeek V4's OpenAI format docs enable thinking with both the toggle and
4069
+ // reasoning_effort. Keep the toggle explicit for built-in models.
4070
+ extraBody: { thinking: { type: "enabled" } },
4071
+ // DeepSeek emits chain-of-thought via `reasoning_content` and requires it
4072
+ // to round-trip on assistant tool-call messages so the model can resume
4073
+ // from prior thinking (interleaved.field=reasoning_content on models.dev,
4074
+ // matches the kimi/openrouter handling already in detectCompat).
4075
+ reasoningContentField: "reasoning_content",
4076
+ requiresReasoningContentForToolCalls: true,
4077
+ requiresAssistantContentForToolCalls: true,
4078
+ },
4079
+ }),
4080
+ ];
4081
+
4082
+ const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDescriptor[] = [
4083
+ // --- zAI ---
4084
+ anthropicMessagesDescriptor("zai-coding-plan", "zai", "https://api.z.ai/api/anthropic"),
4085
+ // --- Umans AI Coding Plan ---
4086
+ anthropicMessagesDescriptor("umans-ai-coding-plan", "umans", UMANS_BASE_URL),
4087
+ // --- Xiaomi ---
4088
+ openAiCompletionsDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/v1", {
4089
+ defaultContextWindow: 262144,
4090
+ defaultMaxTokens: 8192,
4091
+ compat: {
4092
+ supportsStore: false,
4093
+ thinkingFormat: "zai",
4094
+ reasoningContentField: "reasoning_content",
4095
+ requiresReasoningContentForToolCalls: true,
4096
+ allowsSyntheticReasoningContentForToolCalls: false,
4097
+ },
4098
+ }),
4099
+ // --- MiniMax Coding Plan ---
4100
+ openAiCompletionsDescriptor("minimax-coding-plan", "minimax-code", "https://api.minimax.io/v1", {
4101
+ compat: {
4102
+ supportsStore: false,
4103
+ supportsDeveloperRole: false,
4104
+ supportsReasoningEffort: false,
4105
+ reasoningContentField: "reasoning_content",
4106
+ },
4107
+ }),
4108
+ openAiCompletionsDescriptor("minimax-cn-coding-plan", "minimax-code-cn", "https://api.minimaxi.com/v1", {
4109
+ compat: {
4110
+ supportsStore: false,
4111
+ supportsDeveloperRole: false,
4112
+ supportsReasoningEffort: false,
4113
+ reasoningContentField: "reasoning_content",
4114
+ },
4115
+ }),
4116
+ // --- Alibaba Coding Plan ---
4117
+ openAiCompletionsDescriptor(
4118
+ "alibaba-coding-plan",
4119
+ "alibaba-coding-plan",
4120
+ "https://coding-intl.dashscope.aliyuncs.com/v1",
4121
+ {
4122
+ compat: {
4123
+ supportsDeveloperRole: false,
4124
+ },
4125
+ },
4126
+ ),
4127
+ // --- Zhipu Coding Plan ---
4128
+ openAiCompletionsDescriptor(
4129
+ "zhipuai-coding-plan",
4130
+ "zhipu-coding-plan",
4131
+ "https://open.bigmodel.cn/api/coding/paas/v4",
4132
+ {
4133
+ compat: {
4134
+ thinkingFormat: "zai",
4135
+ reasoningContentField: "reasoning_content",
4136
+ supportsDeveloperRole: false,
4137
+ },
4138
+ },
4139
+ ),
4140
+ ];
4141
+
4142
+ const filterActiveToolCallModels = (_id: string, m: ModelsDevModel): boolean => {
4143
+ if (m.tool_call !== true) return false;
4144
+ if (m.status === "deprecated") return false;
4145
+ return true;
4146
+ };
4147
+
4148
+ const MODELS_DEV_PROVIDER_DESCRIPTORS_GOOGLE_VERTEX: readonly ModelsDevProviderDescriptor[] = [
4149
+ simpleModelsDevDescriptor("google-vertex", "google-vertex", "google-vertex", GOOGLE_VERTEX_BASE_URL, {
4150
+ filterModel: filterActiveToolCallModels,
4151
+ resolveApi: resolveGoogleVertexApi,
4152
+ }),
4153
+ ];
4154
+
4155
+ const MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED: readonly ModelsDevProviderDescriptor[] = [
4156
+ // --- Azure OpenAI ---
4157
+ // OpenAI-family models hosted on Azure, served via the Responses API. baseUrl
4158
+ // is empty: the deployment host is per-resource and resolved at runtime from
4159
+ // AZURE_OPENAI_BASE_URL / AZURE_OPENAI_RESOURCE_NAME (see resolveAzureConfig).
4160
+ simpleModelsDevDescriptor("azure", "azure", "azure-openai-responses", "", {
4161
+ filterModel: (modelId, m) => {
4162
+ if (m.tool_call !== true) return false;
4163
+ // OpenAI-family only (not Foundry/DeepSeek/Claude/Llama/Mistral/Phi, which
4164
+ // Azure serves via non-Responses APIs under a per-model provider override).
4165
+ return /^(gpt-|o1|o3|o4|codex|chatgpt)/.test(modelId);
4166
+ },
4167
+ }),
4168
+ // --- Cloudflare AI Gateway ---
4169
+ anthropicMessagesDescriptor(
4170
+ "cloudflare-ai-gateway",
4171
+ "cloudflare-ai-gateway",
4172
+ "https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic",
4173
+ ),
4174
+ // --- Mistral ---
4175
+ openAiCompletionsDescriptor("mistral", "mistral", "https://api.mistral.ai/v1"),
4176
+ // --- OpenCode Zen ---
4177
+ openAiCompletionsDescriptor("opencode", "opencode-zen", "https://opencode.ai/zen/v1", {
4178
+ filterModel: filterActiveToolCallModels,
4179
+ resolveApi: (modelId, raw) =>
4180
+ resolveApiByRules(
4181
+ modelId,
4182
+ raw,
4183
+ OPENCODE_ZEN_API_RESOLUTION.rules,
4184
+ OPENCODE_ZEN_API_RESOLUTION.defaultResolution,
4185
+ ),
4186
+ }),
4187
+ // --- OpenCode Go ---
4188
+ openAiCompletionsDescriptor("opencode-go", "opencode-go", "https://opencode.ai/zen/go/v1", {
4189
+ filterModel: filterActiveToolCallModels,
4190
+ resolveApi: (modelId, raw) =>
4191
+ resolveApiByRules(
4192
+ modelId,
4193
+ raw,
4194
+ OPENCODE_GO_API_RESOLUTION.rules,
4195
+ OPENCODE_GO_API_RESOLUTION.defaultResolution,
4196
+ ),
4197
+ }),
4198
+ // --- GitHub Copilot ---
4199
+ openAiCompletionsDescriptor("github-copilot", "github-copilot", COPILOT_BASE_URL, {
4200
+ defaultContextWindow: 128000,
4201
+ defaultMaxTokens: 8192,
4202
+ headers: { ...COPILOT_API_HEADERS },
4203
+ filterModel: filterActiveToolCallModels,
4204
+ resolveApi: (modelId, raw) =>
4205
+ resolveApiByRules(modelId, raw, COPILOT_API_RESOLUTION_RULES, COPILOT_DEFAULT_RESOLUTION),
4206
+ transformModel: model => {
4207
+ // compat only applies to openai-completions models
4208
+ if (model.api === "openai-completions") {
4209
+ return {
4210
+ ...model,
4211
+ compat: {
4212
+ supportsStore: false,
4213
+ supportsDeveloperRole: false,
4214
+ supportsReasoningEffort: false,
4215
+ },
4216
+ };
4217
+ }
4218
+ return model;
4219
+ },
4220
+ }),
4221
+ // --- MiniMax (Anthropic) ---
4222
+ anthropicMessagesDescriptor("minimax", "minimax", "https://api.minimax.io/anthropic"),
4223
+ anthropicMessagesDescriptor("minimax-cn", "minimax-cn", "https://api.minimaxi.com/anthropic"),
4224
+ // --- Hugging Face ---
4225
+ openAiCompletionsDescriptor("huggingface", "huggingface", "https://router.huggingface.co/v1"),
4226
+ // --- Kilo Gateway ---
4227
+ openAiCompletionsDescriptor("kilo", "kilo", "https://api.kilo.ai/api/gateway"),
4228
+ // --- Moonshot AI ---
4229
+ openAiCompletionsDescriptor("moonshotai", "moonshot", "https://api.moonshot.ai/v1"),
4230
+ // --- NanoGPT ---
4231
+ openAiCompletionsDescriptor("nano-gpt", "nanogpt", "https://nano-gpt.com/api/v1"),
4232
+ // --- Synthetic ---
4233
+ openAiCompletionsDescriptor("synthetic", "synthetic", "https://api.synthetic.new/openai/v1"),
4234
+ // --- Venice AI ---
4235
+ openAiCompletionsDescriptor("venice", "venice", "https://api.venice.ai/api/v1", {
4236
+ transformModel: model => {
4237
+ const maxTokens = clampKimiK27CodeMaxTokens(model.id, model.maxTokens);
4238
+ return maxTokens === model.maxTokens ? model : { ...model, maxTokens };
4239
+ },
4240
+ }),
4241
+ // --- Ollama Cloud ---
4242
+ simpleModelsDevDescriptor("ollama-cloud", "ollama-cloud", "ollama-chat", "https://ollama.com"),
4243
+ // --- Xiaomi Token Plan ---
4244
+ openAiCompletionsDescriptor(
4245
+ "xiaomi-token-plan-ams",
4246
+ "xiaomi-token-plan-ams",
4247
+ "https://token-plan-ams.xiaomimimo.com/v1",
4248
+ ),
4249
+ openAiCompletionsDescriptor(
4250
+ "xiaomi-token-plan-cn",
4251
+ "xiaomi-token-plan-cn",
4252
+ "https://token-plan-cn.xiaomimimo.com/v1",
4253
+ ),
4254
+ openAiCompletionsDescriptor(
4255
+ "xiaomi-token-plan-sgp",
4256
+ "xiaomi-token-plan-sgp",
4257
+ "https://token-plan-sgp.xiaomimimo.com/v1",
4258
+ ),
4259
+ // --- Qwen Portal ---
4260
+ openAiCompletionsDescriptor("qwen-portal", "qwen-portal", "https://portal.qwen.ai/v1", {
4261
+ defaultContextWindow: 128000,
4262
+ defaultMaxTokens: 8192,
4263
+ }),
4264
+
4265
+ // --- ZenMux ---
4266
+ openAiCompletionsDescriptor("zenmux", "zenmux", ZENMUX_OPENAI_BASE_URL, {
4267
+ filterModel: filterActiveToolCallModels,
4268
+ resolveApi: modelId => {
4269
+ if (modelId.startsWith("anthropic/")) {
4270
+ return { api: "anthropic-messages" as const, baseUrl: ZENMUX_ANTHROPIC_BASE_URL };
4271
+ }
4272
+ return { api: "openai-completions" as const, baseUrl: ZENMUX_OPENAI_BASE_URL };
4273
+ },
4274
+ }),
4275
+ ];
4276
+ /** All provider descriptors for models.dev data mapping in generate-models.ts. */
4277
+ export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [
4278
+ ...MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK,
4279
+ ...MODELS_DEV_PROVIDER_DESCRIPTORS_GOOGLE_VERTEX,
4280
+ ...MODELS_DEV_PROVIDER_DESCRIPTORS_CORE,
4281
+ ...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS,
4282
+ ...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED,
4283
+ ];