/ utils / model / model.ts
model.ts
  1  // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
  2  /**
  3   * Ensure that any model codenames introduced here are also added to
  4   * scripts/excluded-strings.txt to avoid leaking them. Wrap any codename string
  5   * literals with process.env.USER_TYPE === 'ant' for Bun to remove the codenames
  6   * during dead code elimination
  7   */
  8  import { getMainLoopModelOverride } from '../../bootstrap/state.js'
  9  import {
 10    getSubscriptionType,
 11    isClaudeAISubscriber,
 12    isMaxSubscriber,
 13    isProSubscriber,
 14    isTeamPremiumSubscriber,
 15  } from '../auth.js'
 16  import {
 17    has1mContext,
 18    is1mContextDisabled,
 19    modelSupports1M,
 20  } from '../context.js'
 21  import { isEnvTruthy } from '../envUtils.js'
 22  import { getModelStrings, resolveOverriddenModel } from './modelStrings.js'
 23  import { formatModelPricing, getOpus46CostTier } from '../modelCost.js'
 24  import { getSettings_DEPRECATED } from '../settings/settings.js'
 25  import type { PermissionMode } from '../permissions/PermissionMode.js'
 26  import { getAPIProvider } from './providers.js'
 27  import { LIGHTNING_BOLT } from '../../constants/figures.js'
 28  import { isModelAllowed } from './modelAllowlist.js'
 29  import { type ModelAlias, isModelAlias } from './aliases.js'
 30  import { capitalize } from '../stringUtils.js'
 31  
 32  export type ModelShortName = string
 33  export type ModelName = string
 34  export type ModelSetting = ModelName | ModelAlias | null
 35  
 36  export function getSmallFastModel(): ModelName {
 37    return process.env.ANTHROPIC_SMALL_FAST_MODEL || getDefaultHaikuModel()
 38  }
 39  
 40  export function isNonCustomOpusModel(model: ModelName): boolean {
 41    return (
 42      model === getModelStrings().opus40 ||
 43      model === getModelStrings().opus41 ||
 44      model === getModelStrings().opus45 ||
 45      model === getModelStrings().opus46
 46    )
 47  }
 48  
 49  /**
 50   * Helper to get the model from /model (including via /config), the --model flag, environment variable,
 51   * or the saved settings. The returned value can be a model alias if that's what the user specified.
 52   * Undefined if the user didn't configure anything, in which case we fall back to
 53   * the default (null).
 54   *
 55   * Priority order within this function:
 56   * 1. Model override during session (from /model command) - highest priority
 57   * 2. Model override at startup (from --model flag)
 58   * 3. ANTHROPIC_MODEL environment variable
 59   * 4. Settings (from user's saved settings)
 60   */
 61  export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
 62    let specifiedModel: ModelSetting | undefined
 63  
 64    const modelOverride = getMainLoopModelOverride()
 65    if (modelOverride !== undefined) {
 66      specifiedModel = modelOverride
 67    } else {
 68      const settings = getSettings_DEPRECATED() || {}
 69      specifiedModel = process.env.ANTHROPIC_MODEL || settings.model || undefined
 70    }
 71  
 72    // Ignore the user-specified model if it's not in the availableModels allowlist.
 73    if (specifiedModel && !isModelAllowed(specifiedModel)) {
 74      return undefined
 75    }
 76  
 77    return specifiedModel
 78  }
 79  
 80  /**
 81   * Get the main loop model to use for the current session.
 82   *
 83   * Model Selection Priority Order:
 84   * 1. Model override during session (from /model command) - highest priority
 85   * 2. Model override at startup (from --model flag)
 86   * 3. ANTHROPIC_MODEL environment variable
 87   * 4. Settings (from user's saved settings)
 88   * 5. Built-in default
 89   *
 90   * @returns The resolved model name to use
 91   */
 92  export function getMainLoopModel(): ModelName {
 93    const model = getUserSpecifiedModelSetting()
 94    if (model !== undefined && model !== null) {
 95      return parseUserSpecifiedModel(model)
 96    }
 97    return getDefaultMainLoopModel()
 98  }
 99  
100  export function getBestModel(): ModelName {
101    return getDefaultOpusModel()
102  }
103  
104  // @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged).
105  export function getDefaultOpusModel(): ModelName {
106    if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) {
107      return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
108    }
109    // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
110    // even when values match, since 3P availability lags firstParty and
111    // these will diverge again at the next model launch.
112    if (getAPIProvider() !== 'firstParty') {
113      return getModelStrings().opus46
114    }
115    return getModelStrings().opus46
116  }
117  
118  // @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged).
119  export function getDefaultSonnetModel(): ModelName {
120    if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) {
121      return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
122    }
123    // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
124    if (getAPIProvider() !== 'firstParty') {
125      return getModelStrings().sonnet45
126    }
127    return getModelStrings().sonnet46
128  }
129  
130  // @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged).
131  export function getDefaultHaikuModel(): ModelName {
132    if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
133      return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
134    }
135  
136    // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
137    return getModelStrings().haiku45
138  }
139  
140  /**
141   * Get the model to use for runtime, depending on the runtime context.
142   * @param params Subset of the runtime context to determine the model to use.
143   * @returns The model to use
144   */
145  export function getRuntimeMainLoopModel(params: {
146    permissionMode: PermissionMode
147    mainLoopModel: string
148    exceeds200kTokens?: boolean
149  }): ModelName {
150    const { permissionMode, mainLoopModel, exceeds200kTokens = false } = params
151  
152    // opusplan uses Opus in plan mode without [1m] suffix.
153    if (
154      getUserSpecifiedModelSetting() === 'opusplan' &&
155      permissionMode === 'plan' &&
156      !exceeds200kTokens
157    ) {
158      return getDefaultOpusModel()
159    }
160  
161    // sonnetplan by default
162    if (getUserSpecifiedModelSetting() === 'haiku' && permissionMode === 'plan') {
163      return getDefaultSonnetModel()
164    }
165  
166    return mainLoopModel
167  }
168  
169  /**
170   * Get the default main loop model setting.
171   *
172   * This handles the built-in default:
173   * - Opus for Max and Team Premium users
174   * - Sonnet 4.6 for all other users (including Team Standard, Pro, Enterprise)
175   *
176   * @returns The default model setting to use
177   */
178  export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
179    // Ants default to defaultModel from flag config, or Opus 1M if not configured
180    if (process.env.USER_TYPE === 'ant') {
181      return (
182        getAntModelOverrideConfig()?.defaultModel ??
183        getDefaultOpusModel() + '[1m]'
184      )
185    }
186  
187    // Max users get Opus as default
188    if (isMaxSubscriber()) {
189      return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '')
190    }
191  
192    // Team Premium gets Opus (same as Max)
193    if (isTeamPremiumSubscriber()) {
194      return getDefaultOpusModel() + (isOpus1mMergeEnabled() ? '[1m]' : '')
195    }
196  
197    // PAYG (1P and 3P), Enterprise, Team Standard, and Pro get Sonnet as default
198    // Note that PAYG (3P) may default to an older Sonnet model
199    return getDefaultSonnetModel()
200  }
201  
202  /**
203   * Synchronous operation to get the default main loop model to use
204   * (bypassing any user-specified values).
205   */
206  export function getDefaultMainLoopModel(): ModelName {
207    return parseUserSpecifiedModel(getDefaultMainLoopModelSetting())
208  }
209  
210  // @[MODEL LAUNCH]: Add a canonical name mapping for the new model below.
211  /**
212   * Pure string-match that strips date/provider suffixes from a first-party model
213   * name. Input must already be a 1P-format ID (e.g. 'claude-3-7-sonnet-20250219',
214   * 'us.anthropic.claude-opus-4-6-v1:0'). Does not touch settings, so safe at
215   * module top-level (see MODEL_COSTS in modelCost.ts).
216   */
217  export function firstPartyNameToCanonical(name: ModelName): ModelShortName {
218    name = name.toLowerCase()
219    // Special cases for Claude 4+ models to differentiate versions
220    // Order matters: check more specific versions first (4-5 before 4)
221    if (name.includes('claude-opus-4-6')) {
222      return 'claude-opus-4-6'
223    }
224    if (name.includes('claude-opus-4-5')) {
225      return 'claude-opus-4-5'
226    }
227    if (name.includes('claude-opus-4-1')) {
228      return 'claude-opus-4-1'
229    }
230    if (name.includes('claude-opus-4')) {
231      return 'claude-opus-4'
232    }
233    if (name.includes('claude-sonnet-4-6')) {
234      return 'claude-sonnet-4-6'
235    }
236    if (name.includes('claude-sonnet-4-5')) {
237      return 'claude-sonnet-4-5'
238    }
239    if (name.includes('claude-sonnet-4')) {
240      return 'claude-sonnet-4'
241    }
242    if (name.includes('claude-haiku-4-5')) {
243      return 'claude-haiku-4-5'
244    }
245    // Claude 3.x models use a different naming scheme (claude-3-{family})
246    if (name.includes('claude-3-7-sonnet')) {
247      return 'claude-3-7-sonnet'
248    }
249    if (name.includes('claude-3-5-sonnet')) {
250      return 'claude-3-5-sonnet'
251    }
252    if (name.includes('claude-3-5-haiku')) {
253      return 'claude-3-5-haiku'
254    }
255    if (name.includes('claude-3-opus')) {
256      return 'claude-3-opus'
257    }
258    if (name.includes('claude-3-sonnet')) {
259      return 'claude-3-sonnet'
260    }
261    if (name.includes('claude-3-haiku')) {
262      return 'claude-3-haiku'
263    }
264    const match = name.match(/(claude-(\d+-\d+-)?\w+)/)
265    if (match && match[1]) {
266      return match[1]
267    }
268    // Fall back to the original name if no pattern matches
269    return name
270  }
271  
272  /**
273   * Maps a full model string to a shorter canonical version that's unified across 1P and 3P providers.
274   * For example, 'claude-3-5-haiku-20241022' and 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
275   * would both be mapped to 'claude-3-5-haiku'.
276   * @param fullModelName The full model name (e.g., 'claude-3-5-haiku-20241022')
277   * @returns The short name (e.g., 'claude-3-5-haiku') if found, or the original name if no mapping exists
278   */
279  export function getCanonicalName(fullModelName: ModelName): ModelShortName {
280    // Resolve overridden model IDs (e.g. Bedrock ARNs) back to canonical names.
281    // resolved is always a 1P-format ID, so firstPartyNameToCanonical can handle it.
282    return firstPartyNameToCanonical(resolveOverriddenModel(fullModelName))
283  }
284  
285  // @[MODEL LAUNCH]: Update the default model description strings shown to users.
286  export function getClaudeAiUserDefaultModelDescription(
287    fastMode = false,
288  ): string {
289    if (isMaxSubscriber() || isTeamPremiumSubscriber()) {
290      if (isOpus1mMergeEnabled()) {
291        return `Opus 4.6 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
292      }
293      return `Opus 4.6 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`
294    }
295    return 'Sonnet 4.6 · Best for everyday tasks'
296  }
297  
298  export function renderDefaultModelSetting(
299    setting: ModelName | ModelAlias,
300  ): string {
301    if (setting === 'opusplan') {
302      return 'Opus 4.6 in plan mode, else Sonnet 4.6'
303    }
304    return renderModelName(parseUserSpecifiedModel(setting))
305  }
306  
307  export function getOpus46PricingSuffix(fastMode: boolean): string {
308    if (getAPIProvider() !== 'firstParty') return ''
309    const pricing = formatModelPricing(getOpus46CostTier(fastMode))
310    const fastModeIndicator = fastMode ? ` (${LIGHTNING_BOLT})` : ''
311    return ` ·${fastModeIndicator} ${pricing}`
312  }
313  
314  export function isOpus1mMergeEnabled(): boolean {
315    if (
316      is1mContextDisabled() ||
317      isProSubscriber() ||
318      getAPIProvider() !== 'firstParty'
319    ) {
320      return false
321    }
322    // Fail closed when a subscriber's subscription type is unknown. The VS Code
323    // config-loading subprocess can have OAuth tokens with valid scopes but no
324    // subscriptionType field (stale or partial refresh). Without this guard,
325    // isProSubscriber() returns false for such users and the merge leaks
326    // opus[1m] into the model dropdown — the API then rejects it with a
327    // misleading "rate limit reached" error.
328    if (isClaudeAISubscriber() && getSubscriptionType() === null) {
329      return false
330    }
331    return true
332  }
333  
334  export function renderModelSetting(setting: ModelName | ModelAlias): string {
335    if (setting === 'opusplan') {
336      return 'Opus Plan'
337    }
338    if (isModelAlias(setting)) {
339      return capitalize(setting)
340    }
341    return renderModelName(setting)
342  }
343  
344  // @[MODEL LAUNCH]: Add display name cases for the new model (base + [1m] variant if applicable).
345  /**
346   * Returns a human-readable display name for known public models, or null
347   * if the model is not recognized as a public model.
348   */
349  export function getPublicModelDisplayName(model: ModelName): string | null {
350    switch (model) {
351      case getModelStrings().opus46:
352        return 'Opus 4.6'
353      case getModelStrings().opus46 + '[1m]':
354        return 'Opus 4.6 (1M context)'
355      case getModelStrings().opus45:
356        return 'Opus 4.5'
357      case getModelStrings().opus41:
358        return 'Opus 4.1'
359      case getModelStrings().opus40:
360        return 'Opus 4'
361      case getModelStrings().sonnet46 + '[1m]':
362        return 'Sonnet 4.6 (1M context)'
363      case getModelStrings().sonnet46:
364        return 'Sonnet 4.6'
365      case getModelStrings().sonnet45 + '[1m]':
366        return 'Sonnet 4.5 (1M context)'
367      case getModelStrings().sonnet45:
368        return 'Sonnet 4.5'
369      case getModelStrings().sonnet40:
370        return 'Sonnet 4'
371      case getModelStrings().sonnet40 + '[1m]':
372        return 'Sonnet 4 (1M context)'
373      case getModelStrings().sonnet37:
374        return 'Sonnet 3.7'
375      case getModelStrings().sonnet35:
376        return 'Sonnet 3.5'
377      case getModelStrings().haiku45:
378        return 'Haiku 4.5'
379      case getModelStrings().haiku35:
380        return 'Haiku 3.5'
381      default:
382        return null
383    }
384  }
385  
386  function maskModelCodename(baseName: string): string {
387    // Mask only the first dash-separated segment (the codename), preserve the rest
388    // e.g. capybara-v2-fast → cap*****-v2-fast
389    const [codename = '', ...rest] = baseName.split('-')
390    const masked =
391      codename.slice(0, 3) + '*'.repeat(Math.max(0, codename.length - 3))
392    return [masked, ...rest].join('-')
393  }
394  
395  export function renderModelName(model: ModelName): string {
396    const publicName = getPublicModelDisplayName(model)
397    if (publicName) {
398      return publicName
399    }
400    if (process.env.USER_TYPE === 'ant') {
401      const resolved = parseUserSpecifiedModel(model)
402      const antModel = resolveAntModel(model)
403      if (antModel) {
404        const baseName = antModel.model.replace(/\[1m\]$/i, '')
405        const masked = maskModelCodename(baseName)
406        const suffix = has1mContext(resolved) ? '[1m]' : ''
407        return masked + suffix
408      }
409      if (resolved !== model) {
410        return `${model} (${resolved})`
411      }
412      return resolved
413    }
414    return model
415  }
416  
417  /**
418   * Returns a safe author name for public display (e.g., in git commit trailers).
419   * Returns "Claude {ModelName}" for publicly known models, or "Claude ({model})"
420   * for unknown/internal models so the exact model name is preserved.
421   *
422   * @param model The full model name
423   * @returns "Claude {ModelName}" for public models, or "Claude ({model})" for non-public models
424   */
425  export function getPublicModelName(model: ModelName): string {
426    const publicName = getPublicModelDisplayName(model)
427    if (publicName) {
428      return `Claude ${publicName}`
429    }
430    return `Claude (${model})`
431  }
432  
433  /**
434   * Returns a full model name for use in this session, possibly after resolving
435   * a model alias.
436   *
437   * This function intentionally does not support version numbers to align with
438   * the model switcher.
439   *
440   * Supports [1m] suffix on any model alias (e.g., haiku[1m], sonnet[1m]) to enable
441   * 1M context window without requiring each variant to be in MODEL_ALIASES.
442   *
443   * @param modelInput The model alias or name provided by the user.
444   */
445  export function parseUserSpecifiedModel(
446    modelInput: ModelName | ModelAlias,
447  ): ModelName {
448    const modelInputTrimmed = modelInput.trim()
449    const normalizedModel = modelInputTrimmed.toLowerCase()
450  
451    const has1mTag = has1mContext(normalizedModel)
452    const modelString = has1mTag
453      ? normalizedModel.replace(/\[1m]$/i, '').trim()
454      : normalizedModel
455  
456    if (isModelAlias(modelString)) {
457      switch (modelString) {
458        case 'opusplan':
459          return getDefaultSonnetModel() + (has1mTag ? '[1m]' : '') // Sonnet is default, Opus in plan mode
460        case 'sonnet':
461          return getDefaultSonnetModel() + (has1mTag ? '[1m]' : '')
462        case 'haiku':
463          return getDefaultHaikuModel() + (has1mTag ? '[1m]' : '')
464        case 'opus':
465          return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
466        case 'best':
467          return getBestModel()
468        default:
469      }
470    }
471  
472    // Opus 4/4.1 are no longer available on the first-party API (same as
473    // Claude.ai) — silently remap to the current Opus default. The 'opus'
474    // alias already resolves to 4.6, so the only users on these explicit
475    // strings pinned them in settings/env/--model/SDK before 4.5 launched.
476    // 3P providers may not yet have 4.6 capacity, so pass through unchanged.
477    if (
478      getAPIProvider() === 'firstParty' &&
479      isLegacyOpusFirstParty(modelString) &&
480      isLegacyModelRemapEnabled()
481    ) {
482      return getDefaultOpusModel() + (has1mTag ? '[1m]' : '')
483    }
484  
485    if (process.env.USER_TYPE === 'ant') {
486      const has1mAntTag = has1mContext(normalizedModel)
487      const baseAntModel = normalizedModel.replace(/\[1m]$/i, '').trim()
488  
489      const antModel = resolveAntModel(baseAntModel)
490      if (antModel) {
491        const suffix = has1mAntTag ? '[1m]' : ''
492        return antModel.model + suffix
493      }
494  
495      // Fall through to the alias string if we cannot load the config. The API calls
496      // will fail with this string, but we should hear about it through feedback and
497      // can tell the user to restart/wait for flag cache refresh to get the latest values.
498    }
499  
500    // Preserve original case for custom model names (e.g., Azure Foundry deployment IDs)
501    // Only strip [1m] suffix if present, maintaining case of the base model
502    if (has1mTag) {
503      return modelInputTrimmed.replace(/\[1m\]$/i, '').trim() + '[1m]'
504    }
505    return modelInputTrimmed
506  }
507  
508  /**
509   * Resolves a skill's `model:` frontmatter against the current model, carrying
510   * the `[1m]` suffix over when the target family supports it.
511   *
512   * A skill author writing `model: opus` means "use opus-class reasoning" — not
513   * "downgrade to 200K". If the user is on opus[1m] at 230K tokens and invokes a
514   * skill with `model: opus`, passing the bare alias through drops the effective
515   * context window from 1M to 200K, which trips autocompact at 23% apparent usage
516   * and surfaces "Context limit reached" even though nothing overflowed.
517   *
518   * We only carry [1m] when the target actually supports it (sonnet/opus). A skill
519   * with `model: haiku` on a 1M session still downgrades — haiku has no 1M variant,
520   * so the autocompact that follows is correct. Skills that already specify [1m]
521   * are left untouched.
522   */
523  export function resolveSkillModelOverride(
524    skillModel: string,
525    currentModel: string,
526  ): string {
527    if (has1mContext(skillModel) || !has1mContext(currentModel)) {
528      return skillModel
529    }
530    // modelSupports1M matches on canonical IDs ('claude-opus-4-6', 'claude-sonnet-4');
531    // a bare 'opus' alias falls through getCanonicalName unmatched. Resolve first.
532    if (modelSupports1M(parseUserSpecifiedModel(skillModel))) {
533      return skillModel + '[1m]'
534    }
535    return skillModel
536  }
537  
538  const LEGACY_OPUS_FIRSTPARTY = [
539    'claude-opus-4-20250514',
540    'claude-opus-4-1-20250805',
541    'claude-opus-4-0',
542    'claude-opus-4-1',
543  ]
544  
545  function isLegacyOpusFirstParty(model: string): boolean {
546    return LEGACY_OPUS_FIRSTPARTY.includes(model)
547  }
548  
549  /**
550   * Opt-out for the legacy Opus 4.0/4.1 → current Opus remap.
551   */
552  export function isLegacyModelRemapEnabled(): boolean {
553    return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP)
554  }
555  
556  export function modelDisplayString(model: ModelSetting): string {
557    if (model === null) {
558      if (process.env.USER_TYPE === 'ant') {
559        return `Default for Ants (${renderDefaultModelSetting(getDefaultMainLoopModelSetting())})`
560      } else if (isClaudeAISubscriber()) {
561        return `Default (${getClaudeAiUserDefaultModelDescription()})`
562      }
563      return `Default (${getDefaultMainLoopModel()})`
564    }
565    const resolvedModel = parseUserSpecifiedModel(model)
566    return model === resolvedModel ? resolvedModel : `${model} (${resolvedModel})`
567  }
568  
569  // @[MODEL LAUNCH]: Add a marketing name mapping for the new model below.
570  export function getMarketingNameForModel(modelId: string): string | undefined {
571    if (getAPIProvider() === 'foundry') {
572      // deployment ID is user-defined in Foundry, so it may have no relation to the actual model
573      return undefined
574    }
575  
576    const has1m = modelId.toLowerCase().includes('[1m]')
577    const canonical = getCanonicalName(modelId)
578  
579    if (canonical.includes('claude-opus-4-6')) {
580      return has1m ? 'Opus 4.6 (with 1M context)' : 'Opus 4.6'
581    }
582    if (canonical.includes('claude-opus-4-5')) {
583      return 'Opus 4.5'
584    }
585    if (canonical.includes('claude-opus-4-1')) {
586      return 'Opus 4.1'
587    }
588    if (canonical.includes('claude-opus-4')) {
589      return 'Opus 4'
590    }
591    if (canonical.includes('claude-sonnet-4-6')) {
592      return has1m ? 'Sonnet 4.6 (with 1M context)' : 'Sonnet 4.6'
593    }
594    if (canonical.includes('claude-sonnet-4-5')) {
595      return has1m ? 'Sonnet 4.5 (with 1M context)' : 'Sonnet 4.5'
596    }
597    if (canonical.includes('claude-sonnet-4')) {
598      return has1m ? 'Sonnet 4 (with 1M context)' : 'Sonnet 4'
599    }
600    if (canonical.includes('claude-3-7-sonnet')) {
601      return 'Claude 3.7 Sonnet'
602    }
603    if (canonical.includes('claude-3-5-sonnet')) {
604      return 'Claude 3.5 Sonnet'
605    }
606    if (canonical.includes('claude-haiku-4-5')) {
607      return 'Haiku 4.5'
608    }
609    if (canonical.includes('claude-3-5-haiku')) {
610      return 'Claude 3.5 Haiku'
611    }
612  
613    return undefined
614  }
615  
616  export function normalizeModelStringForAPI(model: string): string {
617    return model.replace(/\[(1|2)m\]/gi, '')
618  }