/ utils / model / modelOptions.ts
modelOptions.ts
  1  // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
  2  import { getInitialMainLoopModel } from '../../bootstrap/state.js'
  3  import {
  4    isClaudeAISubscriber,
  5    isMaxSubscriber,
  6    isTeamPremiumSubscriber,
  7  } from '../auth.js'
  8  import { getModelStrings } from './modelStrings.js'
  9  import {
 10    COST_TIER_3_15,
 11    COST_HAIKU_35,
 12    COST_HAIKU_45,
 13    formatModelPricing,
 14  } from '../modelCost.js'
 15  import { getSettings_DEPRECATED } from '../settings/settings.js'
 16  import { checkOpus1mAccess, checkSonnet1mAccess } from './check1mAccess.js'
 17  import { getAPIProvider } from './providers.js'
 18  import { isModelAllowed } from './modelAllowlist.js'
 19  import {
 20    getCanonicalName,
 21    getClaudeAiUserDefaultModelDescription,
 22    getDefaultSonnetModel,
 23    getDefaultOpusModel,
 24    getDefaultHaikuModel,
 25    getDefaultMainLoopModelSetting,
 26    getMarketingNameForModel,
 27    getUserSpecifiedModelSetting,
 28    isOpus1mMergeEnabled,
 29    getOpus46PricingSuffix,
 30    renderDefaultModelSetting,
 31    type ModelSetting,
 32  } from './model.js'
 33  import { has1mContext } from '../context.js'
 34  import { getGlobalConfig } from '../config.js'
 35  
 36  // @[MODEL LAUNCH]: Update all the available and default model option strings below.
 37  
 38  export type ModelOption = {
 39    value: ModelSetting
 40    label: string
 41    description: string
 42    descriptionForModel?: string
 43  }
 44  
 45  export function getDefaultOptionForUser(fastMode = false): ModelOption {
 46    if (process.env.USER_TYPE === 'ant') {
 47      const currentModel = renderDefaultModelSetting(
 48        getDefaultMainLoopModelSetting(),
 49      )
 50      return {
 51        value: null,
 52        label: 'Default (recommended)',
 53        description: `Use the default model for Ants (currently ${currentModel})`,
 54        descriptionForModel: `Default model (currently ${currentModel})`,
 55      }
 56    }
 57  
 58    // Subscribers
 59    if (isClaudeAISubscriber()) {
 60      return {
 61        value: null,
 62        label: 'Default (recommended)',
 63        description: getClaudeAiUserDefaultModelDescription(fastMode),
 64      }
 65    }
 66  
 67    // PAYG
 68    const is3P = getAPIProvider() !== 'firstParty'
 69    return {
 70      value: null,
 71      label: 'Default (recommended)',
 72      description: `Use the default model (currently ${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
 73    }
 74  }
 75  
 76  function getCustomSonnetOption(): ModelOption | undefined {
 77    const is3P = getAPIProvider() !== 'firstParty'
 78    const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
 79    // When a 3P user has a custom sonnet model string, show it directly
 80    if (is3P && customSonnetModel) {
 81      const is1m = has1mContext(customSonnetModel)
 82      return {
 83        value: 'sonnet',
 84        label:
 85          process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME ?? customSonnetModel,
 86        description:
 87          process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION ??
 88          `Custom Sonnet model${is1m ? ' (1M context)' : ''}`,
 89        descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION ?? `Custom Sonnet model${is1m ? ' with 1M context' : ''}`} (${customSonnetModel})`,
 90      }
 91    }
 92  }
 93  
 94  // @[MODEL LAUNCH]: Update or add model option functions (getSonnetXXOption, getOpusXXOption, etc.)
 95  // with the new model's label and description. These appear in the /model picker.
 96  function getSonnet46Option(): ModelOption {
 97    const is3P = getAPIProvider() !== 'firstParty'
 98    return {
 99      value: is3P ? getModelStrings().sonnet46 : 'sonnet',
100      label: 'Sonnet',
101      description: `Sonnet 4.6 · Best for everyday tasks${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
102      descriptionForModel:
103        'Sonnet 4.6 - best for everyday tasks. Generally recommended for most coding tasks',
104    }
105  }
106  
107  function getCustomOpusOption(): ModelOption | undefined {
108    const is3P = getAPIProvider() !== 'firstParty'
109    const customOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
110    // When a 3P user has a custom opus model string, show it directly
111    if (is3P && customOpusModel) {
112      const is1m = has1mContext(customOpusModel)
113      return {
114        value: 'opus',
115        label: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME ?? customOpusModel,
116        description:
117          process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION ??
118          `Custom Opus model${is1m ? ' (1M context)' : ''}`,
119        descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION ?? `Custom Opus model${is1m ? ' with 1M context' : ''}`} (${customOpusModel})`,
120      }
121    }
122  }
123  
124  function getOpus41Option(): ModelOption {
125    return {
126      value: 'opus',
127      label: 'Opus 4.1',
128      description: `Opus 4.1 · Legacy`,
129      descriptionForModel: 'Opus 4.1 - legacy version',
130    }
131  }
132  
133  function getOpus46Option(fastMode = false): ModelOption {
134    const is3P = getAPIProvider() !== 'firstParty'
135    return {
136      value: is3P ? getModelStrings().opus46 : 'opus',
137      label: 'Opus',
138      description: `Opus 4.6 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`,
139      descriptionForModel: 'Opus 4.6 - most capable for complex work',
140    }
141  }
142  
143  export function getSonnet46_1MOption(): ModelOption {
144    const is3P = getAPIProvider() !== 'firstParty'
145    return {
146      value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]',
147      label: 'Sonnet (1M context)',
148      description: `Sonnet 4.6 for long sessions${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
149      descriptionForModel:
150        'Sonnet 4.6 with 1M context window - for long sessions with large codebases',
151    }
152  }
153  
154  export function getOpus46_1MOption(fastMode = false): ModelOption {
155    const is3P = getAPIProvider() !== 'firstParty'
156    return {
157      value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
158      label: 'Opus (1M context)',
159      description: `Opus 4.6 for long sessions${getOpus46PricingSuffix(fastMode)}`,
160      descriptionForModel:
161        'Opus 4.6 with 1M context window - for long sessions with large codebases',
162    }
163  }
164  
165  function getCustomHaikuOption(): ModelOption | undefined {
166    const is3P = getAPIProvider() !== 'firstParty'
167    const customHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
168    // When a 3P user has a custom haiku model string, show it directly
169    if (is3P && customHaikuModel) {
170      return {
171        value: 'haiku',
172        label: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME ?? customHaikuModel,
173        description:
174          process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION ??
175          'Custom Haiku model',
176        descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION ?? 'Custom Haiku model'} (${customHaikuModel})`,
177      }
178    }
179  }
180  
181  function getHaiku45Option(): ModelOption {
182    const is3P = getAPIProvider() !== 'firstParty'
183    return {
184      value: 'haiku',
185      label: 'Haiku',
186      description: `Haiku 4.5 · Fastest for quick answers${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_45)}`}`,
187      descriptionForModel:
188        'Haiku 4.5 - fastest for quick answers. Lower cost but less capable than Sonnet 4.6.',
189    }
190  }
191  
192  function getHaiku35Option(): ModelOption {
193    const is3P = getAPIProvider() !== 'firstParty'
194    return {
195      value: 'haiku',
196      label: 'Haiku',
197      description: `Haiku 3.5 for simple tasks${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_35)}`}`,
198      descriptionForModel:
199        'Haiku 3.5 - faster and lower cost, but less capable than Sonnet. Use for simple tasks.',
200    }
201  }
202  
203  function getHaikuOption(): ModelOption {
204    // Return correct Haiku option based on provider
205    const haikuModel = getDefaultHaikuModel()
206    return haikuModel === getModelStrings().haiku45
207      ? getHaiku45Option()
208      : getHaiku35Option()
209  }
210  
211  function getMaxOpusOption(fastMode = false): ModelOption {
212    return {
213      value: 'opus',
214      label: 'Opus',
215      description: `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`,
216    }
217  }
218  
219  export function getMaxSonnet46_1MOption(): ModelOption {
220    const is3P = getAPIProvider() !== 'firstParty'
221    const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
222    return {
223      value: 'sonnet[1m]',
224      label: 'Sonnet (1M context)',
225      description: `Sonnet 4.6 with 1M context${billingInfo}${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`,
226    }
227  }
228  
229  export function getMaxOpus46_1MOption(fastMode = false): ModelOption {
230    const billingInfo = isClaudeAISubscriber() ? ' · Billed as extra usage' : ''
231    return {
232      value: 'opus[1m]',
233      label: 'Opus (1M context)',
234      description: `Opus 4.6 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`,
235    }
236  }
237  
238  function getMergedOpus1MOption(fastMode = false): ModelOption {
239    const is3P = getAPIProvider() !== 'firstParty'
240    return {
241      value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]',
242      label: 'Opus (1M context)',
243      description: `Opus 4.6 with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`,
244      descriptionForModel:
245        'Opus 4.6 with 1M context - most capable for complex work',
246    }
247  }
248  
249  const MaxSonnet46Option: ModelOption = {
250    value: 'sonnet',
251    label: 'Sonnet',
252    description: 'Sonnet 4.6 · Best for everyday tasks',
253  }
254  
255  const MaxHaiku45Option: ModelOption = {
256    value: 'haiku',
257    label: 'Haiku',
258    description: 'Haiku 4.5 · Fastest for quick answers',
259  }
260  
261  function getOpusPlanOption(): ModelOption {
262    return {
263      value: 'opusplan',
264      label: 'Opus Plan Mode',
265      description: 'Use Opus 4.6 in plan mode, Sonnet 4.6 otherwise',
266    }
267  }
268  
269  // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model.
270  // Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list.
271  function getModelOptionsBase(fastMode = false): ModelOption[] {
272    if (process.env.USER_TYPE === 'ant') {
273      // Build options from antModels config
274      const antModelOptions: ModelOption[] = getAntModels().map(m => ({
275        value: m.alias,
276        label: m.label,
277        description: m.description ?? `[ANT-ONLY] ${m.label} (${m.model})`,
278      }))
279  
280      return [
281        getDefaultOptionForUser(),
282        ...antModelOptions,
283        getMergedOpus1MOption(fastMode),
284        getSonnet46Option(),
285        getSonnet46_1MOption(),
286        getHaiku45Option(),
287      ]
288    }
289  
290    if (isClaudeAISubscriber()) {
291      if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
292        // Max and Team Premium users: Opus is default, show Sonnet as alternative
293        const premiumOptions = [getDefaultOptionForUser(fastMode)]
294        if (!isOpus1mMergeEnabled() && checkOpus1mAccess()) {
295          premiumOptions.push(getMaxOpus46_1MOption(fastMode))
296        }
297  
298        premiumOptions.push(MaxSonnet46Option)
299        if (checkSonnet1mAccess()) {
300          premiumOptions.push(getMaxSonnet46_1MOption())
301        }
302  
303        premiumOptions.push(MaxHaiku45Option)
304        return premiumOptions
305      }
306  
307      // Pro/Team Standard/Enterprise users: Sonnet is default, show Opus as alternative
308      const standardOptions = [getDefaultOptionForUser(fastMode)]
309      if (checkSonnet1mAccess()) {
310        standardOptions.push(getMaxSonnet46_1MOption())
311      }
312  
313      if (isOpus1mMergeEnabled()) {
314        standardOptions.push(getMergedOpus1MOption(fastMode))
315      } else {
316        standardOptions.push(getMaxOpusOption(fastMode))
317        if (checkOpus1mAccess()) {
318          standardOptions.push(getMaxOpus46_1MOption(fastMode))
319        }
320      }
321  
322      standardOptions.push(MaxHaiku45Option)
323      return standardOptions
324    }
325  
326    // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
327    if (getAPIProvider() === 'firstParty') {
328      const payg1POptions = [getDefaultOptionForUser(fastMode)]
329      if (checkSonnet1mAccess()) {
330        payg1POptions.push(getSonnet46_1MOption())
331      }
332      if (isOpus1mMergeEnabled()) {
333        payg1POptions.push(getMergedOpus1MOption(fastMode))
334      } else {
335        payg1POptions.push(getOpus46Option(fastMode))
336        if (checkOpus1mAccess()) {
337          payg1POptions.push(getOpus46_1MOption(fastMode))
338        }
339      }
340      payg1POptions.push(getHaiku45Option())
341      return payg1POptions
342    }
343  
344    // PAYG 3P: Default (Sonnet 4.5) + Sonnet (3P custom) or Sonnet 4.6/1M + Opus (3P custom) or Opus 4.1/Opus 4.6/Opus1M + Haiku + Opus 4.1
345    const payg3pOptions = [getDefaultOptionForUser(fastMode)]
346  
347    const customSonnet = getCustomSonnetOption()
348    if (customSonnet !== undefined) {
349      payg3pOptions.push(customSonnet)
350    } else {
351      // Add Sonnet 4.6 since Sonnet 4.5 is the default
352      payg3pOptions.push(getSonnet46Option())
353      if (checkSonnet1mAccess()) {
354        payg3pOptions.push(getSonnet46_1MOption())
355      }
356    }
357  
358    const customOpus = getCustomOpusOption()
359    if (customOpus !== undefined) {
360      payg3pOptions.push(customOpus)
361    } else {
362      // Add Opus 4.1, Opus 4.6 and Opus 4.6 1M
363      payg3pOptions.push(getOpus41Option()) // This is the default opus
364      payg3pOptions.push(getOpus46Option(fastMode))
365      if (checkOpus1mAccess()) {
366        payg3pOptions.push(getOpus46_1MOption(fastMode))
367      }
368    }
369    const customHaiku = getCustomHaikuOption()
370    if (customHaiku !== undefined) {
371      payg3pOptions.push(customHaiku)
372    } else {
373      payg3pOptions.push(getHaikuOption())
374    }
375    return payg3pOptions
376  }
377  
378  // @[MODEL LAUNCH]: Add the new model ID to the appropriate family pattern below
379  // so the "newer version available" hint works correctly.
380  /**
381   * Map a full model name to its family alias and the marketing name of the
382   * version the alias currently resolves to. Used to detect when a user has
383   * a specific older version pinned and a newer one is available.
384   */
385  function getModelFamilyInfo(
386    model: string,
387  ): { alias: string; currentVersionName: string } | null {
388    const canonical = getCanonicalName(model)
389  
390    // Sonnet family
391    if (
392      canonical.includes('claude-sonnet-4-6') ||
393      canonical.includes('claude-sonnet-4-5') ||
394      canonical.includes('claude-sonnet-4-') ||
395      canonical.includes('claude-3-7-sonnet') ||
396      canonical.includes('claude-3-5-sonnet')
397    ) {
398      const currentName = getMarketingNameForModel(getDefaultSonnetModel())
399      if (currentName) {
400        return { alias: 'Sonnet', currentVersionName: currentName }
401      }
402    }
403  
404    // Opus family
405    if (canonical.includes('claude-opus-4')) {
406      const currentName = getMarketingNameForModel(getDefaultOpusModel())
407      if (currentName) {
408        return { alias: 'Opus', currentVersionName: currentName }
409      }
410    }
411  
412    // Haiku family
413    if (
414      canonical.includes('claude-haiku') ||
415      canonical.includes('claude-3-5-haiku')
416    ) {
417      const currentName = getMarketingNameForModel(getDefaultHaikuModel())
418      if (currentName) {
419        return { alias: 'Haiku', currentVersionName: currentName }
420      }
421    }
422  
423    return null
424  }
425  
426  /**
427   * Returns a ModelOption for a known Anthropic model with a human-readable
428   * label, and an upgrade hint if a newer version is available via the alias.
429   * Returns null if the model is not recognized.
430   */
431  function getKnownModelOption(model: string): ModelOption | null {
432    const marketingName = getMarketingNameForModel(model)
433    if (!marketingName) return null
434  
435    const familyInfo = getModelFamilyInfo(model)
436    if (!familyInfo) {
437      return {
438        value: model,
439        label: marketingName,
440        description: model,
441      }
442    }
443  
444    // Check if the alias currently resolves to a different (newer) version
445    if (marketingName !== familyInfo.currentVersionName) {
446      return {
447        value: model,
448        label: marketingName,
449        description: `Newer version available · select ${familyInfo.alias} for ${familyInfo.currentVersionName}`,
450      }
451    }
452  
453    // Same version as the alias — just show the friendly name
454    return {
455      value: model,
456      label: marketingName,
457      description: model,
458    }
459  }
460  
461  export function getModelOptions(fastMode = false): ModelOption[] {
462    const options = getModelOptionsBase(fastMode)
463  
464    // Add the custom model from the ANTHROPIC_CUSTOM_MODEL_OPTION env var
465    const envCustomModel = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION
466    if (
467      envCustomModel &&
468      !options.some(existing => existing.value === envCustomModel)
469    ) {
470      options.push({
471        value: envCustomModel,
472        label: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME ?? envCustomModel,
473        description:
474          process.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION ??
475          `Custom model (${envCustomModel})`,
476      })
477    }
478  
479    // Append additional model options fetched during bootstrap
480    for (const opt of getGlobalConfig().additionalModelOptionsCache ?? []) {
481      if (!options.some(existing => existing.value === opt.value)) {
482        options.push(opt)
483      }
484    }
485  
486    // Add custom model from either the current model value or the initial one
487    // if it is not already in the options.
488    let customModel: ModelSetting = null
489    const currentMainLoopModel = getUserSpecifiedModelSetting()
490    const initialMainLoopModel = getInitialMainLoopModel()
491    if (currentMainLoopModel !== undefined && currentMainLoopModel !== null) {
492      customModel = currentMainLoopModel
493    } else if (initialMainLoopModel !== null) {
494      customModel = initialMainLoopModel
495    }
496    if (customModel === null || options.some(opt => opt.value === customModel)) {
497      return filterModelOptionsByAllowlist(options)
498    } else if (customModel === 'opusplan') {
499      return filterModelOptionsByAllowlist([...options, getOpusPlanOption()])
500    } else if (customModel === 'opus' && getAPIProvider() === 'firstParty') {
501      return filterModelOptionsByAllowlist([
502        ...options,
503        getMaxOpusOption(fastMode),
504      ])
505    } else if (customModel === 'opus[1m]' && getAPIProvider() === 'firstParty') {
506      return filterModelOptionsByAllowlist([
507        ...options,
508        getMergedOpus1MOption(fastMode),
509      ])
510    } else {
511      // Try to show a human-readable label for known Anthropic models, with an
512      // upgrade hint if the alias now resolves to a newer version.
513      const knownOption = getKnownModelOption(customModel)
514      if (knownOption) {
515        options.push(knownOption)
516      } else {
517        options.push({
518          value: customModel,
519          label: customModel,
520          description: 'Custom model',
521        })
522      }
523      return filterModelOptionsByAllowlist(options)
524    }
525  }
526  
527  /**
528   * Filter model options by the availableModels allowlist.
529   * Always preserves the "Default" option (value: null).
530   */
531  function filterModelOptionsByAllowlist(options: ModelOption[]): ModelOption[] {
532    const settings = getSettings_DEPRECATED() || {}
533    if (!settings.availableModels) {
534      return options // No restrictions
535    }
536    return options.filter(
537      opt =>
538        opt.value === null || (opt.value !== null && isModelAllowed(opt.value)),
539    )
540  }