@link-assistant/hive-mind 1.35.9 → 1.35.10

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.
@@ -0,0 +1,871 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Unified models module for hive-mind
5
+ * Single source of truth for all model data, mapping, validation, and info.
6
+ *
7
+ * Consolidates the former:
8
+ * - model-mapping.lib.mjs (model data, maps, tool-model functions)
9
+ * - model-validation.lib.mjs (validation, fuzzy matching, 1M context)
10
+ * - model-info.lib.mjs (display names, models.dev API, PR comment helpers)
11
+ *
12
+ * @see https://github.com/link-assistant/hive-mind/issues/1473
13
+ */
14
+
15
+ // Check if use is already defined (when imported from solve.mjs)
16
+ // If not, fetch it (when running standalone)
17
+ if (typeof globalThis.use === 'undefined') {
18
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
19
+ }
20
+
21
+ import { log } from '../lib.mjs';
22
+
23
+ // ─── MODEL DATA ──────────────────────────────────────────────────────────────
24
+
25
+ // Claude models (Anthropic API)
26
+ // Updated for Opus 4.5/4.6 and Sonnet 4.6 support (Issue #1221, Issue #1238, Issue #1329, Issue #1433)
27
+ export const claudeModels = {
28
+ sonnet: 'claude-sonnet-4-6', // Sonnet 4.6 (default, Issue #1329)
29
+ opus: 'claude-opus-4-6', // Opus 4.6 (Issue #1433)
30
+ haiku: 'claude-haiku-4-5-20251001', // Haiku 4.5
31
+ 'haiku-3-5': 'claude-3-5-haiku-20241022', // Haiku 3.5
32
+ 'haiku-3': 'claude-3-haiku-20240307', // Haiku 3
33
+ // Shorter version aliases (Issue #1221, Issue #1329 - PR comment feedback)
34
+ 'sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 short alias (Issue #1329)
35
+ 'opus-4-6': 'claude-opus-4-6', // Opus 4.6 short alias
36
+ 'opus-4-5': 'claude-opus-4-5-20251101', // Opus 4.5 short alias
37
+ 'sonnet-4-5': 'claude-sonnet-4-5-20250929', // Sonnet 4.5 short alias (backward compatibility)
38
+ 'haiku-4-5': 'claude-haiku-4-5-20251001', // Haiku 4.5 short alias
39
+ // Version aliases for backward compatibility (Issue #1221, Issue #1329)
40
+ 'claude-sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 (Issue #1329)
41
+ 'claude-opus-4-6': 'claude-opus-4-6', // Opus 4.6
42
+ 'claude-opus-4-5': 'claude-opus-4-5-20251101', // Opus 4.5
43
+ 'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929', // Sonnet 4.5 (backward compatibility)
44
+ 'claude-haiku-4-5': 'claude-haiku-4-5-20251001', // Haiku 4.5
45
+ };
46
+
47
+ // Agent models (OpenCode API and Kilo Gateway via agent CLI)
48
+ // Issue #1300: Updated free models to match agent PR #191
49
+ export const agentModels = {
50
+ // OpenCode Zen free models (current)
51
+ grok: 'opencode/grok-code',
52
+ 'grok-code': 'opencode/grok-code',
53
+ 'grok-code-fast-1': 'opencode/grok-code',
54
+ 'big-pickle': 'opencode/big-pickle',
55
+ 'gpt-5-nano': 'opencode/gpt-5-nano',
56
+ 'minimax-m2.5-free': 'opencode/minimax-m2.5-free', // New: upgraded from M2.1 (Issue #1391: now default)
57
+ // Kilo Gateway free models (Issue #1282, updated in #1300)
58
+ // Short names for Kilo-exclusive models (Issue #1300)
59
+ 'glm-5-free': 'kilo/glm-5-free', // Kilo-exclusive
60
+ 'glm-4.5-air-free': 'kilo/glm-4.5-air-free', // Kilo-exclusive: agent-centric model
61
+ 'deepseek-r1-free': 'kilo/deepseek-r1-free', // Kilo-exclusive: reasoning model
62
+ 'giga-potato-free': 'kilo/giga-potato-free', // Kilo-exclusive
63
+ 'trinity-large-preview': 'kilo/trinity-large-preview', // Kilo-exclusive
64
+ // Full names with kilo/ prefix
65
+ 'kilo/glm-5-free': 'kilo/glm-5-free',
66
+ 'kilo/glm-4.5-air-free': 'kilo/glm-4.5-air-free',
67
+ 'kilo/minimax-m2.5-free': 'kilo/minimax-m2.5-free', // Also on OpenCode Zen
68
+ 'kilo/deepseek-r1-free': 'kilo/deepseek-r1-free',
69
+ 'kilo/giga-potato-free': 'kilo/giga-potato-free',
70
+ 'kilo/trinity-large-preview': 'kilo/trinity-large-preview',
71
+ // Deprecated free models (kept for backward compatibility)
72
+ 'kimi-k2.5-free': 'opencode/kimi-k2.5-free', // Deprecated: not supported (Issue #1391)
73
+ 'glm-4.7-free': 'opencode/glm-4.7-free', // Deprecated: no longer free
74
+ 'minimax-m2.1-free': 'opencode/minimax-m2.1-free', // Deprecated: replaced by m2.5
75
+ 'kilo/glm-4.7-free': 'kilo/glm-4.7-free', // Deprecated: replaced by glm-4.5-air-free
76
+ 'kilo/kimi-k2.5-free': 'kilo/kimi-k2.5-free', // Deprecated: not recommended
77
+ 'kilo/minimax-m2.1-free': 'kilo/minimax-m2.1-free', // Deprecated: replaced by m2.5
78
+ // Premium models
79
+ sonnet: 'anthropic/claude-3-5-sonnet',
80
+ haiku: 'anthropic/claude-3-5-haiku',
81
+ opus: 'anthropic/claude-3-opus',
82
+ 'gemini-3-pro': 'google/gemini-3-pro',
83
+ };
84
+
85
+ // OpenCode models (OpenCode API)
86
+ export const opencodeModels = {
87
+ gpt4: 'openai/gpt-4',
88
+ gpt4o: 'openai/gpt-4o',
89
+ claude: 'anthropic/claude-3-5-sonnet',
90
+ sonnet: 'anthropic/claude-3-5-sonnet',
91
+ opus: 'anthropic/claude-3-opus',
92
+ gemini: 'google/gemini-pro',
93
+ grok: 'opencode/grok-code',
94
+ 'grok-code': 'opencode/grok-code',
95
+ 'grok-code-fast-1': 'opencode/grok-code',
96
+ };
97
+
98
+ // Codex models (OpenAI API)
99
+ export const codexModels = {
100
+ gpt5: 'gpt-5',
101
+ 'gpt5-codex': 'gpt-5-codex',
102
+ o3: 'o3',
103
+ 'o3-mini': 'o3-mini',
104
+ gpt4: 'gpt-4',
105
+ gpt4o: 'gpt-4o',
106
+ claude: 'claude-3-5-sonnet',
107
+ sonnet: 'claude-3-5-sonnet',
108
+ opus: 'claude-3-opus',
109
+ };
110
+
111
+ // Default model for each tool (Issue #1473: centralized to avoid scattered hardcoded defaults)
112
+ export const defaultModels = {
113
+ claude: 'sonnet',
114
+ agent: 'minimax-m2.5-free',
115
+ opencode: 'grok-code-fast-1',
116
+ codex: 'gpt-5',
117
+ };
118
+
119
+ // Models that support 1M token context window via [1m] suffix (Issue #1221, Issue #1238, Issue #1329)
120
+ // See: https://code.claude.com/docs/en/model-config
121
+ export const MODELS_SUPPORTING_1M_CONTEXT = [
122
+ 'claude-opus-4-6',
123
+ 'claude-opus-4-5-20251101',
124
+ 'claude-sonnet-4-6', // Sonnet 4.6 (Issue #1329)
125
+ 'claude-sonnet-4-5-20250929',
126
+ 'claude-sonnet-4-5',
127
+ 'sonnet', // Now maps to Sonnet 4.6 (Issue #1329)
128
+ 'sonnet-4-6', // Short alias (Issue #1329)
129
+ 'opus',
130
+ 'opus-4-6', // Short alias (Issue #1221 - PR comment feedback)
131
+ 'opus-4-5', // Short alias (Issue #1238)
132
+ 'sonnet-4-5', // Short alias (Issue #1221 - PR comment feedback)
133
+ ];
134
+
135
+ // Free model to base model mapping for pricing lookup (Issue #1250, Issue #1473)
136
+ // Free models like "kimi-k2.5-free" should use pricing from base model "kimi-k2.5"
137
+ export const freeToBaseModelMap = {
138
+ 'kimi-k2.5-free': 'kimi-k2.5',
139
+ 'glm-4.7-free': 'glm-4.7',
140
+ 'minimax-m2.1-free': 'minimax-m2.1',
141
+ 'minimax-m2.5-free': 'minimax-m2.5',
142
+ 'glm-5-free': 'glm-5',
143
+ 'glm-4.5-air-free': 'glm-4.5-air',
144
+ 'deepseek-r1-free': 'deepseek-r1',
145
+ 'giga-potato-free': 'giga-potato',
146
+ 'trinity-large-preview-free': 'trinity-large-preview',
147
+ };
148
+
149
+ // ─── VALIDATION-EXTENDED MODEL MAPS ──────────────────────────────────────────
150
+ // These extend the base maps with full model ID identity entries for validation
151
+ // (e.g., 'claude-sonnet-4-5-20250929' → 'claude-sonnet-4-5-20250929')
152
+ // so that full model IDs are also accepted as valid inputs
153
+
154
+ export const CLAUDE_MODELS = {
155
+ ...claudeModels,
156
+ 'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-20250929',
157
+ 'claude-opus-4-5-20251101': 'claude-opus-4-5-20251101',
158
+ 'claude-haiku-4-5-20251001': 'claude-haiku-4-5-20251001',
159
+ 'claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022',
160
+ 'claude-3-haiku-20240307': 'claude-3-haiku-20240307',
161
+ };
162
+
163
+ export const OPENCODE_MODELS = {
164
+ ...opencodeModels,
165
+ 'openai/gpt-4': 'openai/gpt-4',
166
+ 'openai/gpt-4o': 'openai/gpt-4o',
167
+ 'anthropic/claude-3-5-sonnet': 'anthropic/claude-3-5-sonnet',
168
+ 'anthropic/claude-3-opus': 'anthropic/claude-3-opus',
169
+ 'google/gemini-pro': 'google/gemini-pro',
170
+ 'opencode/grok-code': 'opencode/grok-code',
171
+ };
172
+
173
+ export const CODEX_MODELS = {
174
+ ...codexModels,
175
+ 'gpt-5': 'gpt-5',
176
+ 'gpt-5-codex': 'gpt-5-codex',
177
+ 'gpt-4': 'gpt-4',
178
+ 'gpt-4o': 'gpt-4o',
179
+ 'claude-3-5-sonnet': 'claude-3-5-sonnet',
180
+ 'claude-3-opus': 'claude-3-opus',
181
+ };
182
+
183
+ export const AGENT_MODELS = {
184
+ ...agentModels,
185
+ 'opencode/grok-code': 'opencode/grok-code',
186
+ 'opencode/big-pickle': 'opencode/big-pickle',
187
+ 'opencode/gpt-5-nano': 'opencode/gpt-5-nano',
188
+ 'opencode/minimax-m2.5-free': 'opencode/minimax-m2.5-free',
189
+ 'opencode/kimi-k2.5-free': 'opencode/kimi-k2.5-free', // Deprecated
190
+ 'opencode/glm-4.7-free': 'opencode/glm-4.7-free', // Deprecated
191
+ 'opencode/minimax-m2.1-free': 'opencode/minimax-m2.1-free', // Deprecated
192
+ 'anthropic/claude-3-5-sonnet': 'anthropic/claude-3-5-sonnet',
193
+ 'anthropic/claude-3-5-haiku': 'anthropic/claude-3-5-haiku',
194
+ 'anthropic/claude-3-opus': 'anthropic/claude-3-opus',
195
+ 'google/gemini-3-pro': 'google/gemini-3-pro',
196
+ };
197
+
198
+ // ─── MODEL MAPPING FUNCTIONS ─────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Get the model map object for a given tool
202
+ * @param {string} tool - The tool name (claude, agent, opencode, codex)
203
+ * @returns {Object} The model mapping for the tool
204
+ */
205
+ export const getModelMapForTool = tool => {
206
+ switch (tool) {
207
+ case 'claude':
208
+ return claudeModels;
209
+ case 'agent':
210
+ return agentModels;
211
+ case 'opencode':
212
+ return opencodeModels;
213
+ case 'codex':
214
+ return codexModels;
215
+ default:
216
+ return claudeModels;
217
+ }
218
+ };
219
+
220
+ /**
221
+ * Get the default model for a given tool
222
+ * @param {string} tool - The tool name (claude, agent, opencode, codex)
223
+ * @returns {string} The default model alias for the tool
224
+ */
225
+ export const getDefaultModelForTool = tool => {
226
+ return defaultModels[tool] || defaultModels.claude;
227
+ };
228
+
229
+ /**
230
+ * Map model name to full model ID for a specific tool
231
+ * @param {string} tool - The tool name (claude, agent, opencode, codex)
232
+ * @param {string} model - The model name or alias
233
+ * @returns {string} The full model ID
234
+ */
235
+ export const mapModelForTool = (tool, model) => {
236
+ switch (tool) {
237
+ case 'claude':
238
+ return claudeModels[model] || model;
239
+ case 'agent':
240
+ return agentModels[model] || model;
241
+ case 'opencode':
242
+ return opencodeModels[model] || model;
243
+ case 'codex':
244
+ return codexModels[model] || model;
245
+ default:
246
+ return model;
247
+ }
248
+ };
249
+
250
+ /**
251
+ * Validate if a model is compatible with a tool
252
+ * @param {string} tool - The tool name (claude, agent, opencode, codex)
253
+ * @param {string} model - The model name or alias
254
+ * @returns {boolean} True if the model is compatible with the tool
255
+ */
256
+ export const isModelCompatibleWithTool = (tool, model) => {
257
+ const mappedModel = mapModelForTool(tool, model);
258
+
259
+ switch (tool) {
260
+ case 'claude':
261
+ return mappedModel.startsWith('claude-');
262
+ case 'agent':
263
+ return mappedModel.includes('/') || Object.keys(agentModels).includes(model);
264
+ case 'opencode':
265
+ return mappedModel.includes('/') || Object.keys(opencodeModels).includes(model);
266
+ case 'codex':
267
+ return Object.keys(codexModels).includes(model) || mappedModel.startsWith('gpt-') || mappedModel.startsWith('o3') || mappedModel.startsWith('claude-');
268
+ default:
269
+ return true;
270
+ }
271
+ };
272
+
273
+ /**
274
+ * Get a list of valid model names for a tool
275
+ * @param {string} tool - The tool name
276
+ * @returns {string[]} Array of valid model names
277
+ */
278
+ export const getValidModelsForTool = tool => {
279
+ switch (tool) {
280
+ case 'claude':
281
+ return Object.keys(claudeModels);
282
+ case 'agent':
283
+ return Object.keys(agentModels);
284
+ case 'opencode':
285
+ return Object.keys(opencodeModels);
286
+ case 'codex':
287
+ return Object.keys(codexModels);
288
+ default:
289
+ return [];
290
+ }
291
+ };
292
+
293
+ // Primary (non-alias, non-deprecated) short names shown in CLI help descriptions
294
+ // These are the recommended model names users should see in --model help text
295
+ export const primaryModelNames = {
296
+ claude: ['opus', 'sonnet', 'haiku'],
297
+ opencode: ['grok', 'gpt4o'],
298
+ codex: ['gpt5', 'gpt5-codex', 'o3'],
299
+ agent: ['minimax-m2.5-free', 'big-pickle', 'gpt-5-nano', 'glm-5-free', 'deepseek-r1-free'],
300
+ };
301
+
302
+ /**
303
+ * Build the --model CLI option description string dynamically from centralized model data.
304
+ * @returns {string} Description like "Model to use (for claude: opus, sonnet, ...; for agent: ...)"
305
+ */
306
+ export const buildModelOptionDescription = () => {
307
+ const parts = Object.entries(primaryModelNames).map(([tool, names]) => `for ${tool}: ${names.join(', ')}`);
308
+ return `Model to use (${parts.join('; ')})`;
309
+ };
310
+
311
+ /**
312
+ * Get the primary choices for Claude model selection (used in review.mjs and task.mjs).
313
+ * Returns short aliases plus key full model IDs for backward compatibility.
314
+ * @returns {string[]}
315
+ */
316
+ export const getClaudeModelChoices = () => {
317
+ return Object.keys(claudeModels);
318
+ };
319
+
320
+ /**
321
+ * Validate tool-model compatibility and throw descriptive error if invalid
322
+ * @param {string} tool - The tool name
323
+ * @param {string} model - The model name
324
+ * @throws {Error} If the model is not compatible with the tool
325
+ */
326
+ export const validateToolModelCompatibility = (tool, model) => {
327
+ if (!isModelCompatibleWithTool(tool, model)) {
328
+ const validModels = getValidModelsForTool(tool);
329
+ const mappedModel = mapModelForTool(tool, model);
330
+
331
+ throw new Error(`Model '${model}' (mapped to '${mappedModel}') is not compatible with --tool ${tool}.\n` + `Valid models for ${tool}: ${validModels.join(', ')}\n` + 'Hint: Different tools use different model APIs and naming conventions.');
332
+ }
333
+ };
334
+
335
+ // ─── MODEL VALIDATION FUNCTIONS ──────────────────────────────────────────────
336
+
337
+ /**
338
+ * Get the model map for a given tool (validation-extended version with full ID entries)
339
+ * @param {string} tool - The tool name ('claude', 'opencode', 'codex', 'agent')
340
+ * @returns {Object} The model mapping for the tool
341
+ */
342
+ const getValidationModelMapForTool = tool => {
343
+ switch (tool) {
344
+ case 'opencode':
345
+ return OPENCODE_MODELS;
346
+ case 'codex':
347
+ return CODEX_MODELS;
348
+ case 'agent':
349
+ return AGENT_MODELS;
350
+ case 'claude':
351
+ default:
352
+ return CLAUDE_MODELS;
353
+ }
354
+ };
355
+
356
+ /**
357
+ * Get the list of available model names for a tool (for display in help/error messages)
358
+ * @param {string} tool - The tool name ('claude', 'opencode', 'codex', 'agent')
359
+ * @returns {string[]} Array of available model short names
360
+ */
361
+ export const getAvailableModelNames = tool => {
362
+ const modelMap = getValidationModelMapForTool(tool);
363
+ // Get unique short names (aliases) - exclude full model IDs that contain '/' or long claude- prefixed IDs
364
+ const aliases = Object.keys(modelMap).filter(key => {
365
+ // Keep short aliases only - exclude:
366
+ // - Full model IDs with slashes (e.g., 'openai/gpt-4')
367
+ // - Long claude-prefixed model IDs (e.g., 'claude-sonnet-4-5-20250929')
368
+ // - Full gpt- prefixed IDs that are ONLY version numbers (e.g., 'gpt-4', 'gpt-4o', 'gpt-5')
369
+ // But keep descriptive aliases like 'gpt-5-nano', 'gpt-5-codex', 'o3', 'o3-mini', 'gpt5', etc.
370
+ // Issue #1185: Updated regex to not filter out gpt-5-nano (a valid short alias)
371
+ if (key.includes('/')) return false;
372
+ if (key.match(/^claude-.*-\d{8}$/)) return false; // Full claude model IDs with date
373
+ if (key.match(/^gpt-\d+[a-z]?$/)) return false; // Full gpt-N or gpt-No model IDs only (e.g., gpt-4, gpt-4o, gpt-5)
374
+ return true;
375
+ });
376
+ return [...new Set(aliases)];
377
+ };
378
+
379
+ /**
380
+ * Calculate Levenshtein distance between two strings (case-insensitive)
381
+ * @param {string} a - First string
382
+ * @param {string} b - Second string
383
+ * @returns {number} The edit distance between the strings
384
+ */
385
+ export const levenshteinDistance = (a, b) => {
386
+ const aLower = a.toLowerCase();
387
+ const bLower = b.toLowerCase();
388
+
389
+ if (aLower === bLower) return 0;
390
+ if (aLower.length === 0) return bLower.length;
391
+ if (bLower.length === 0) return aLower.length;
392
+
393
+ const matrix = [];
394
+
395
+ for (let i = 0; i <= bLower.length; i++) {
396
+ matrix[i] = [i];
397
+ }
398
+
399
+ for (let j = 0; j <= aLower.length; j++) {
400
+ matrix[0][j] = j;
401
+ }
402
+
403
+ for (let i = 1; i <= bLower.length; i++) {
404
+ for (let j = 1; j <= aLower.length; j++) {
405
+ if (bLower.charAt(i - 1) === aLower.charAt(j - 1)) {
406
+ matrix[i][j] = matrix[i - 1][j - 1];
407
+ } else {
408
+ matrix[i][j] = Math.min(
409
+ matrix[i - 1][j - 1] + 1, // substitution
410
+ matrix[i][j - 1] + 1, // insertion
411
+ matrix[i - 1][j] + 1 // deletion
412
+ );
413
+ }
414
+ }
415
+ }
416
+
417
+ return matrix[bLower.length][aLower.length];
418
+ };
419
+
420
+ /**
421
+ * Find the closest matching model names using fuzzy matching
422
+ * @param {string} input - The user-provided model name
423
+ * @param {string[]} validModels - Array of valid model names
424
+ * @param {number} maxSuggestions - Maximum number of suggestions to return
425
+ * @param {number} maxDistance - Maximum Levenshtein distance to consider
426
+ * @returns {string[]} Array of suggested model names
427
+ */
428
+ export const findSimilarModels = (input, validModels, maxSuggestions = 3, maxDistance = 3) => {
429
+ const suggestions = validModels
430
+ .map(model => ({
431
+ model,
432
+ distance: levenshteinDistance(input, model),
433
+ }))
434
+ .filter(({ distance }) => distance <= maxDistance)
435
+ .sort((a, b) => a.distance - b.distance)
436
+ .slice(0, maxSuggestions)
437
+ .map(({ model }) => model);
438
+
439
+ return suggestions;
440
+ };
441
+
442
+ /**
443
+ * Parse model name to extract base model and optional [1m] suffix
444
+ * @param {string} model - The model name (e.g., "opus[1m]", "claude-opus-4-6[1m]")
445
+ * @returns {{ baseModel: string, has1mSuffix: boolean }}
446
+ */
447
+ export const parseModelWith1mSuffix = model => {
448
+ if (!model || typeof model !== 'string') {
449
+ return { baseModel: model, has1mSuffix: false };
450
+ }
451
+
452
+ const match = model.match(/^(.+?)\[1m\]$/i);
453
+ if (match) {
454
+ return { baseModel: match[1], has1mSuffix: true };
455
+ }
456
+
457
+ return { baseModel: model, has1mSuffix: false };
458
+ };
459
+
460
+ /**
461
+ * Check if a model supports the [1m] context window
462
+ * @param {string} model - The base model name (without [1m] suffix)
463
+ * @param {string} tool - The tool name
464
+ * @returns {boolean} True if the model supports 1M context
465
+ */
466
+ export const supports1mContext = (model, tool = 'claude') => {
467
+ if (tool !== 'claude') {
468
+ return false;
469
+ }
470
+
471
+ const normalizedModel = model.toLowerCase();
472
+
473
+ for (const supportedModel of MODELS_SUPPORTING_1M_CONTEXT) {
474
+ if (supportedModel.toLowerCase() === normalizedModel) {
475
+ return true;
476
+ }
477
+ }
478
+
479
+ const modelMap = getValidationModelMapForTool(tool);
480
+ const matchedKey = Object.keys(modelMap).find(key => key.toLowerCase() === normalizedModel);
481
+ if (matchedKey) {
482
+ const mappedModel = modelMap[matchedKey];
483
+ for (const supportedModel of MODELS_SUPPORTING_1M_CONTEXT) {
484
+ if (supportedModel.toLowerCase() === mappedModel.toLowerCase()) {
485
+ return true;
486
+ }
487
+ }
488
+ }
489
+
490
+ return false;
491
+ };
492
+
493
+ /**
494
+ * Validate a model name against the available models for a tool
495
+ * Supports [1m] suffix for 1 million token context (Issue #1221)
496
+ * @param {string} model - The model name to validate (e.g., "opus", "opus[1m]", "claude-opus-4-6[1m]")
497
+ * @param {string} tool - The tool name ('claude', 'opencode', 'codex')
498
+ * @returns {{ valid: boolean, message?: string, suggestions?: string[], mappedModel?: string, has1mSuffix?: boolean }}
499
+ */
500
+ export const validateModelName = (model, tool = 'claude') => {
501
+ if (!model || typeof model !== 'string') {
502
+ return {
503
+ valid: false,
504
+ message: 'Model name is required',
505
+ suggestions: [],
506
+ };
507
+ }
508
+
509
+ const { baseModel, has1mSuffix } = parseModelWith1mSuffix(model);
510
+
511
+ const modelMap = getValidationModelMapForTool(tool);
512
+ const availableNames = Object.keys(modelMap);
513
+
514
+ const normalizedModel = baseModel.toLowerCase();
515
+ const matchedKey = availableNames.find(key => key.toLowerCase() === normalizedModel);
516
+
517
+ if (matchedKey) {
518
+ const mappedModel = modelMap[matchedKey];
519
+
520
+ if (has1mSuffix) {
521
+ if (!supports1mContext(baseModel, tool)) {
522
+ const supportedModels = MODELS_SUPPORTING_1M_CONTEXT.filter(m => !m.includes('-')).join(', ');
523
+ return {
524
+ valid: false,
525
+ message: `Model "${baseModel}" does not support [1m] context window.\n Models supporting 1M context: ${supportedModels}`,
526
+ suggestions: [],
527
+ };
528
+ }
529
+ return {
530
+ valid: true,
531
+ mappedModel: `${mappedModel}[1m]`,
532
+ has1mSuffix: true,
533
+ };
534
+ }
535
+
536
+ return {
537
+ valid: true,
538
+ mappedModel,
539
+ has1mSuffix: false,
540
+ };
541
+ }
542
+
543
+ // Model not found - provide helpful error with suggestions
544
+ const shortNames = getAvailableModelNames(tool);
545
+ const suggestions = findSimilarModels(baseModel, shortNames);
546
+
547
+ let message = `Unrecognized model: "${model}"`;
548
+
549
+ if (suggestions.length > 0) {
550
+ message += `\n Did you mean: ${suggestions.map(s => `"${s}"`).join(', ')}?`;
551
+ }
552
+
553
+ message += `\n Available models for ${tool}: ${shortNames.join(', ')}`;
554
+
555
+ if (tool === 'claude') {
556
+ message += `\n Tip: Use [1m] suffix for 1M context (e.g., opus[1m], sonnet[1m])`;
557
+ }
558
+
559
+ return {
560
+ valid: false,
561
+ message,
562
+ suggestions,
563
+ };
564
+ };
565
+
566
+ /**
567
+ * Validate model name and exit with error if invalid
568
+ * This is the main entry point for model validation in solve.mjs, hive.mjs, etc.
569
+ * @param {string} model - The model name to validate
570
+ * @param {string} tool - The tool name ('claude', 'opencode', 'codex')
571
+ * @param {Function} exitFn - Function to call for exiting (default: process.exit)
572
+ * @returns {Promise<boolean>} True if valid, exits process if invalid
573
+ */
574
+ export const validateAndExitOnInvalidModel = async (model, tool = 'claude', exitFn = null) => {
575
+ const result = validateModelName(model, tool);
576
+
577
+ if (!result.valid) {
578
+ await log(`\u274C ${result.message}`, { level: 'error' });
579
+
580
+ if (exitFn) {
581
+ await exitFn(1, 'Invalid model name');
582
+ } else {
583
+ process.exit(1);
584
+ }
585
+ return false;
586
+ }
587
+
588
+ return true;
589
+ };
590
+
591
+ /**
592
+ * Format the list of available models for help text
593
+ * @param {string} tool - The tool name
594
+ * @returns {string} Formatted list of available models
595
+ */
596
+ export const formatAvailableModelsForHelp = (tool = 'claude') => {
597
+ const names = getAvailableModelNames(tool);
598
+ return names.join(', ');
599
+ };
600
+
601
+ // ─── MODEL INFO FUNCTIONS ────────────────────────────────────────────────────
602
+
603
+ /**
604
+ * Map tool identifier to user-friendly display name.
605
+ * @param {string|null} tool - The tool identifier (claude, codex, opencode, agent)
606
+ * @returns {string} User-friendly display name
607
+ */
608
+ export const getToolDisplayName = tool => {
609
+ const name = (tool || '').toString().toLowerCase();
610
+ switch (name) {
611
+ case 'claude':
612
+ return 'Anthropic Claude Code';
613
+ case 'codex':
614
+ return 'OpenAI Codex';
615
+ case 'opencode':
616
+ return 'OpenCode';
617
+ case 'agent':
618
+ return 'Agent CLI';
619
+ default:
620
+ return 'AI tool';
621
+ }
622
+ };
623
+
624
+ /**
625
+ * Cached models.dev API response to avoid repeated network requests.
626
+ */
627
+ let modelsDevCache = null;
628
+
629
+ /**
630
+ * Fetch the full models.dev API data with caching.
631
+ * @returns {Promise<Object|null>} The full API response or null on failure
632
+ */
633
+ const fetchModelsDevApi = async () => {
634
+ if (modelsDevCache) return modelsDevCache;
635
+ try {
636
+ const https = (await globalThis.use('https')).default;
637
+ return new Promise((resolve, reject) => {
638
+ https
639
+ .get('https://models.dev/api.json', res => {
640
+ let data = '';
641
+ res.on('data', chunk => {
642
+ data += chunk;
643
+ });
644
+ res.on('end', () => {
645
+ try {
646
+ modelsDevCache = JSON.parse(data);
647
+ resolve(modelsDevCache);
648
+ } catch (parseError) {
649
+ reject(parseError);
650
+ }
651
+ });
652
+ })
653
+ .on('error', err => {
654
+ reject(err);
655
+ });
656
+ });
657
+ } catch {
658
+ return null;
659
+ }
660
+ };
661
+
662
+ /**
663
+ * Fetch model metadata from models.dev API.
664
+ * @param {string} modelId - The model ID (e.g., "claude-opus-4-6", "opencode/grok-code")
665
+ * @returns {Promise<Object|null>} Model metadata or null if not found
666
+ */
667
+ export const fetchModelInfoForComment = async modelId => {
668
+ if (!modelId) return null;
669
+ try {
670
+ const apiData = await fetchModelsDevApi();
671
+ if (!apiData) return null;
672
+
673
+ const lookupId = modelId.includes('/') ? modelId.split('/').pop() : modelId;
674
+
675
+ if (apiData.anthropic?.models?.[lookupId]) {
676
+ const modelInfo = { ...apiData.anthropic.models[lookupId] };
677
+ modelInfo.provider = apiData.anthropic.name || 'Anthropic';
678
+ return modelInfo;
679
+ }
680
+
681
+ for (const provider of Object.values(apiData)) {
682
+ if (provider.models && provider.models[lookupId]) {
683
+ const modelInfo = { ...provider.models[lookupId] };
684
+ modelInfo.provider = provider.name || provider.id;
685
+ return modelInfo;
686
+ }
687
+ }
688
+
689
+ if (lookupId !== modelId) {
690
+ for (const provider of Object.values(apiData)) {
691
+ if (provider.models && provider.models[modelId]) {
692
+ const modelInfo = { ...provider.models[modelId] };
693
+ modelInfo.provider = provider.name || provider.id;
694
+ return modelInfo;
695
+ }
696
+ }
697
+ }
698
+
699
+ return null;
700
+ } catch {
701
+ return null;
702
+ }
703
+ };
704
+
705
+ /**
706
+ * Normalize model ID for comparison purposes (strip suffixes, lowercase).
707
+ * @param {string} modelId - A model ID or alias
708
+ * @returns {string} Normalized ID
709
+ */
710
+ const normalizeForComparison = modelId => {
711
+ if (!modelId) return '';
712
+ return modelId
713
+ .toLowerCase()
714
+ .replace(/\[1m\]$/i, '')
715
+ .trim();
716
+ };
717
+
718
+ /**
719
+ * Check if a requested model alias matches an actual model ID.
720
+ * @param {string} requestedModel - The --model flag value (alias or full ID)
721
+ * @param {string} actualModelId - The actual model ID from CLI output
722
+ * @param {string|null} tool - The tool being used
723
+ * @returns {boolean}
724
+ */
725
+ const doesRequestedMatchActual = (requestedModel, actualModelId, tool) => {
726
+ if (!requestedModel || !actualModelId) return false;
727
+ const resolvedRequested = resolveModelId(requestedModel, tool);
728
+ const normResolved = normalizeForComparison(resolvedRequested);
729
+ const normActual = normalizeForComparison(actualModelId);
730
+ if (normResolved === normActual) return true;
731
+ if (normActual.startsWith(normResolved) || normResolved.startsWith(normActual)) return true;
732
+ return false;
733
+ };
734
+
735
+ /**
736
+ * Build model information string for PR/issue comments.
737
+ *
738
+ * @param {Object} options - Model info options
739
+ * @param {string|null} options.requestedModel - The model requested via --model flag
740
+ * @param {string|null} options.tool - The tool used (claude, agent, opencode, codex)
741
+ * @param {Object|null} options.pricingInfo - Pricing info from tool result
742
+ * @param {Object|null} options.modelInfo - Pre-fetched model metadata from models.dev
743
+ * @param {Array<{modelId: string, modelInfo: Object|null}>|null} options.modelsUsed - Actual models used from CLI JSON output
744
+ * @returns {string} Formatted markdown string for model info section
745
+ */
746
+ export const buildModelInfoString = ({ requestedModel = null, tool = null, pricingInfo = null, modelInfo = null, modelsUsed = null } = {}) => {
747
+ const hasRequested = requestedModel !== null && requestedModel !== undefined;
748
+ const hasModelsUsed = Array.isArray(modelsUsed) && modelsUsed.length > 0;
749
+ const hasModelInfo = modelInfo !== null;
750
+ const hasPricingModel = pricingInfo?.modelId || pricingInfo?.modelName;
751
+
752
+ if (!hasRequested && !hasModelsUsed && !hasModelInfo && !hasPricingModel) return '';
753
+
754
+ let info = '\n\n### \uD83E\uDD16 **Models used:**';
755
+
756
+ if (tool) {
757
+ info += `\n- Tool: ${getToolDisplayName(tool)}`;
758
+ }
759
+
760
+ if (hasRequested) {
761
+ info += `\n- Requested: \`${requestedModel}\``;
762
+ }
763
+
764
+ if (hasModelsUsed) {
765
+ const [mainEntry, ...supportingEntries] = modelsUsed;
766
+ const mainModelId = mainEntry.modelId;
767
+ const mainModelMeta = mainEntry.modelInfo;
768
+
769
+ const mainMatches = hasRequested ? doesRequestedMatchActual(requestedModel, mainModelId, tool) : true;
770
+
771
+ const mainModelName = mainModelMeta?.name || mainModelId;
772
+ const modelLabel = supportingEntries.length > 0 ? 'Main model' : 'Model';
773
+
774
+ if (mainMatches) {
775
+ info += `\n- **${modelLabel}: ${mainModelName}** (\`${mainModelId}\`)`;
776
+ } else {
777
+ info += `\n- **${modelLabel}: ${mainModelName}** (\`${mainModelId}\`)`;
778
+ if (hasRequested) {
779
+ info += `\n- \u26A0\uFE0F **Warning**: Main model \`${mainModelId}\` does not match requested model \`${requestedModel}\``;
780
+ }
781
+ }
782
+
783
+ if (supportingEntries.length > 0) {
784
+ info += '\n- **Additional models:**';
785
+ for (const entry of supportingEntries) {
786
+ const name = entry.modelInfo?.name || entry.modelId;
787
+ info += `\n * **${name}** (\`${entry.modelId}\`)`;
788
+ }
789
+ }
790
+ } else if (hasModelInfo) {
791
+ const mainModelName = modelInfo.name || (pricingInfo?.modelId ? pricingInfo.modelId : null) || 'Unknown';
792
+ info += `\n- Model: ${mainModelName}`;
793
+ if (modelInfo.id) info += ` (ID: \`${modelInfo.id}\`)`;
794
+ if (modelInfo.provider) info += `\n- Provider: ${modelInfo.provider}`;
795
+ if (modelInfo.knowledge) info += `\n- Knowledge cutoff: ${modelInfo.knowledge}`;
796
+ } else if (hasPricingModel) {
797
+ const modelId = pricingInfo.modelId || null;
798
+ const modelName = pricingInfo.modelName || modelId || 'Unknown';
799
+ if (modelId && modelId !== modelName) {
800
+ info += `\n- Model: ${modelName} (ID: \`${modelId}\`)`;
801
+ } else {
802
+ info += `\n- Model: ${modelName}`;
803
+ }
804
+ if (pricingInfo.provider) info += `\n- Provider: ${pricingInfo.provider}`;
805
+ }
806
+
807
+ return info;
808
+ };
809
+
810
+ /**
811
+ * Resolve the full model ID from a user-provided alias using the model mapping.
812
+ * @param {string|null} requestedModel - The model alias (e.g., "opus", "sonnet")
813
+ * @param {string|null} tool - The tool being used
814
+ * @returns {string|null} The full model ID or null
815
+ */
816
+ export const resolveModelId = (requestedModel, tool) => {
817
+ if (!requestedModel) return null;
818
+
819
+ try {
820
+ const toolName = (tool || 'claude').toString().toLowerCase();
821
+ const cleanModel = requestedModel.replace(/\[1m\]$/i, '');
822
+ return mapModelForTool(toolName, cleanModel);
823
+ } catch {
824
+ return requestedModel;
825
+ }
826
+ };
827
+
828
+ /**
829
+ * Fetch model info and build the complete model information string for PR comments.
830
+ * Uses actual models from CLI JSON output when available.
831
+ *
832
+ * @param {Object} options
833
+ * @param {string|null} options.requestedModel - The --model flag value
834
+ * @param {string|null} options.tool - The tool used (claude, agent, opencode, codex)
835
+ * @param {Object|null} options.pricingInfo - Pricing info from tool result
836
+ * @param {Array<string>|null} options.actualModelIds - Actual model IDs from CLI JSON output
837
+ * @returns {Promise<string>} Formatted markdown model info section
838
+ */
839
+ export const getModelInfoForComment = async ({ requestedModel = null, tool = null, pricingInfo = null, actualModelIds = null } = {}) => {
840
+ let modelIds = [];
841
+
842
+ if (Array.isArray(actualModelIds) && actualModelIds.length > 0) {
843
+ modelIds = actualModelIds;
844
+ } else if (pricingInfo?.modelId) {
845
+ modelIds = [pricingInfo.modelId];
846
+ } else if (requestedModel) {
847
+ const resolved = resolveModelId(requestedModel, tool);
848
+ if (resolved) modelIds = [resolved];
849
+ }
850
+
851
+ const modelsUsed = [];
852
+ for (const modelId of modelIds) {
853
+ let meta = null;
854
+ try {
855
+ meta = await fetchModelInfoForComment(modelId);
856
+ } catch {
857
+ await log(' \u26A0\uFE0F Could not fetch model info from models.dev', { verbose: true });
858
+ }
859
+ modelsUsed.push({ modelId, modelInfo: meta });
860
+ }
861
+
862
+ const firstModelInfo = modelsUsed.length > 0 ? modelsUsed[0].modelInfo : null;
863
+
864
+ return buildModelInfoString({
865
+ requestedModel,
866
+ tool,
867
+ pricingInfo,
868
+ modelInfo: modelsUsed.length === 0 ? firstModelInfo : null,
869
+ modelsUsed: modelsUsed.length > 0 ? modelsUsed : null,
870
+ });
871
+ };