/ utils / effort.ts
effort.ts
  1  // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
  2  import { isUltrathinkEnabled } from './thinking.js'
  3  import { getInitialSettings } from './settings/settings.js'
  4  import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js'
  5  import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
  6  import { getAPIProvider } from './model/providers.js'
  7  import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
  8  import { isEnvTruthy } from './envUtils.js'
  9  import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js'
 10  
 11  export type { EffortLevel }
 12  
 13  export const EFFORT_LEVELS = [
 14    'low',
 15    'medium',
 16    'high',
 17    'max',
 18  ] as const satisfies readonly EffortLevel[]
 19  
 20  export type EffortValue = EffortLevel | number
 21  
 22  // @[MODEL LAUNCH]: Add the new model to the allowlist if it supports the effort parameter.
 23  export function modelSupportsEffort(model: string): boolean {
 24    const m = model.toLowerCase()
 25    if (isEnvTruthy(process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT)) {
 26      return true
 27    }
 28    const supported3P = get3PModelCapabilityOverride(model, 'effort')
 29    if (supported3P !== undefined) {
 30      return supported3P
 31    }
 32    // Supported by a subset of Claude 4 models
 33    if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) {
 34      return true
 35    }
 36    // Exclude any other known legacy models (haiku, older opus/sonnet variants)
 37    if (m.includes('haiku') || m.includes('sonnet') || m.includes('opus')) {
 38      return false
 39    }
 40  
 41    // IMPORTANT: Do not change the default effort support without notifying
 42    // the model launch DRI and research. This is a sensitive setting that can
 43    // greatly affect model quality and bashing.
 44  
 45    // Default to true for unknown model strings on 1P.
 46    // Do not default to true for 3P as they have different formats for their
 47    // model strings (ex. anthropics/claude-code#30795)
 48    return getAPIProvider() === 'firstParty'
 49  }
 50  
 51  // @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort.
 52  // Per API docs, 'max' is Opus 4.6 only for public models — other models return an error.
 53  export function modelSupportsMaxEffort(model: string): boolean {
 54    const supported3P = get3PModelCapabilityOverride(model, 'max_effort')
 55    if (supported3P !== undefined) {
 56      return supported3P
 57    }
 58    if (model.toLowerCase().includes('opus-4-6')) {
 59      return true
 60    }
 61    if (process.env.USER_TYPE === 'ant' && resolveAntModel(model)) {
 62      return true
 63    }
 64    return false
 65  }
 66  
 67  export function isEffortLevel(value: string): value is EffortLevel {
 68    return (EFFORT_LEVELS as readonly string[]).includes(value)
 69  }
 70  
 71  export function parseEffortValue(value: unknown): EffortValue | undefined {
 72    if (value === undefined || value === null || value === '') {
 73      return undefined
 74    }
 75    if (typeof value === 'number' && isValidNumericEffort(value)) {
 76      return value
 77    }
 78    const str = String(value).toLowerCase()
 79    if (isEffortLevel(str)) {
 80      return str
 81    }
 82    const numericValue = parseInt(str, 10)
 83    if (!isNaN(numericValue) && isValidNumericEffort(numericValue)) {
 84      return numericValue
 85    }
 86    return undefined
 87  }
 88  
 89  /**
 90   * Numeric values are model-default only and not persisted.
 91   * 'max' is session-scoped for external users (ants can persist it).
 92   * Write sites call this before saving to settings so the Zod schema
 93   * (which only accepts string levels) never rejects a write.
 94   */
 95  export function toPersistableEffort(
 96    value: EffortValue | undefined,
 97  ): EffortLevel | undefined {
 98    if (value === 'low' || value === 'medium' || value === 'high') {
 99      return value
100    }
101    if (value === 'max' && process.env.USER_TYPE === 'ant') {
102      return value
103    }
104    return undefined
105  }
106  
107  export function getInitialEffortSetting(): EffortLevel | undefined {
108    // toPersistableEffort filters 'max' for non-ants on read, so a manually
109    // edited settings.json doesn't leak session-scoped max into a fresh session.
110    return toPersistableEffort(getInitialSettings().effortLevel)
111  }
112  
113  /**
114   * Decide what effort level (if any) to persist when the user selects a model
115   * in ModelPicker. Keeps an explicit prior /effort choice sticky even when it
116   * matches the picked model's default, while letting purely-default and
117   * session-ephemeral effort (CLI --effort, EffortCallout default) fall through
118   * to undefined so it follows future model-default changes.
119   *
120   * priorPersisted must come from userSettings on disk
121   * (getSettingsForSource('userSettings')?.effortLevel), NOT merged settings
122   * (project/policy layers would leak into the user's global settings.json)
123   * and NOT AppState.effortValue (includes session-scoped sources that
124   * deliberately do not write to settings.json).
125   */
126  export function resolvePickerEffortPersistence(
127    picked: EffortLevel | undefined,
128    modelDefault: EffortLevel,
129    priorPersisted: EffortLevel | undefined,
130    toggledInPicker: boolean,
131  ): EffortLevel | undefined {
132    const hadExplicit = priorPersisted !== undefined || toggledInPicker
133    return hadExplicit || picked !== modelDefault ? picked : undefined
134  }
135  
136  export function getEffortEnvOverride(): EffortValue | null | undefined {
137    const envOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL
138    return envOverride?.toLowerCase() === 'unset' ||
139      envOverride?.toLowerCase() === 'auto'
140      ? null
141      : parseEffortValue(envOverride)
142  }
143  
144  /**
145   * Resolve the effort value that will actually be sent to the API for a given
146   * model, following the full precedence chain:
147   *   env CLAUDE_CODE_EFFORT_LEVEL → appState.effortValue → model default
148   *
149   * Returns undefined when no effort parameter should be sent (env set to
150   * 'unset', or no default exists for the model).
151   */
152  export function resolveAppliedEffort(
153    model: string,
154    appStateEffortValue: EffortValue | undefined,
155  ): EffortValue | undefined {
156    const envOverride = getEffortEnvOverride()
157    if (envOverride === null) {
158      return undefined
159    }
160    const resolved =
161      envOverride ?? appStateEffortValue ?? getDefaultEffortForModel(model)
162    // API rejects 'max' on non-Opus-4.6 models — downgrade to 'high'.
163    if (resolved === 'max' && !modelSupportsMaxEffort(model)) {
164      return 'high'
165    }
166    return resolved
167  }
168  
169  /**
170   * Resolve the effort level to show the user. Wraps resolveAppliedEffort
171   * with the 'high' fallback (what the API uses when no effort param is sent).
172   * Single source of truth for the status bar and /effort output (CC-1088).
173   */
174  export function getDisplayedEffortLevel(
175    model: string,
176    appStateEffort: EffortValue | undefined,
177  ): EffortLevel {
178    const resolved = resolveAppliedEffort(model, appStateEffort) ?? 'high'
179    return convertEffortValueToLevel(resolved)
180  }
181  
182  /**
183   * Build the ` with {level} effort` suffix shown in Logo/Spinner.
184   * Returns empty string if the user hasn't explicitly set an effort value.
185   * Delegates to resolveAppliedEffort() so the displayed level matches what
186   * the API actually receives (including max→high clamp for non-Opus models).
187   */
188  export function getEffortSuffix(
189    model: string,
190    effortValue: EffortValue | undefined,
191  ): string {
192    if (effortValue === undefined) return ''
193    const resolved = resolveAppliedEffort(model, effortValue)
194    if (resolved === undefined) return ''
195    return ` with ${convertEffortValueToLevel(resolved)} effort`
196  }
197  
198  export function isValidNumericEffort(value: number): boolean {
199    return Number.isInteger(value)
200  }
201  
202  export function convertEffortValueToLevel(value: EffortValue): EffortLevel {
203    if (typeof value === 'string') {
204      // Runtime guard: value may come from remote config (GrowthBook) where
205      // TypeScript types can't help us. Coerce unknown strings to 'high'
206      // rather than passing them through unchecked.
207      return isEffortLevel(value) ? value : 'high'
208    }
209    if (process.env.USER_TYPE === 'ant' && typeof value === 'number') {
210      if (value <= 50) return 'low'
211      if (value <= 85) return 'medium'
212      if (value <= 100) return 'high'
213      return 'max'
214    }
215    return 'high'
216  }
217  
218  /**
219   * Get user-facing description for effort levels
220   *
221   * @param level The effort level to describe
222   * @returns Human-readable description
223   */
224  export function getEffortLevelDescription(level: EffortLevel): string {
225    switch (level) {
226      case 'low':
227        return 'Quick, straightforward implementation with minimal overhead'
228      case 'medium':
229        return 'Balanced approach with standard implementation and testing'
230      case 'high':
231        return 'Comprehensive implementation with extensive testing and documentation'
232      case 'max':
233        return 'Maximum capability with deepest reasoning (Opus 4.6 only)'
234    }
235  }
236  
237  /**
238   * Get user-facing description for effort values (both string and numeric)
239   *
240   * @param value The effort value to describe
241   * @returns Human-readable description
242   */
243  export function getEffortValueDescription(value: EffortValue): string {
244    if (process.env.USER_TYPE === 'ant' && typeof value === 'number') {
245      return `[ANT-ONLY] Numeric effort value of ${value}`
246    }
247  
248    if (typeof value === 'string') {
249      return getEffortLevelDescription(value)
250    }
251    return 'Balanced approach with standard implementation and testing'
252  }
253  
254  export type OpusDefaultEffortConfig = {
255    enabled: boolean
256    dialogTitle: string
257    dialogDescription: string
258  }
259  
260  const OPUS_DEFAULT_EFFORT_CONFIG_DEFAULT: OpusDefaultEffortConfig = {
261    enabled: true,
262    dialogTitle: 'We recommend medium effort for Opus',
263    dialogDescription:
264      'Effort determines how long Claude thinks for when completing your task. We recommend medium effort for most tasks to balance speed and intelligence and maximize rate limits. Use ultrathink to trigger high effort when needed.',
265  }
266  
267  export function getOpusDefaultEffortConfig(): OpusDefaultEffortConfig {
268    const config = getFeatureValue_CACHED_MAY_BE_STALE(
269      'tengu_grey_step2',
270      OPUS_DEFAULT_EFFORT_CONFIG_DEFAULT,
271    )
272    return {
273      ...OPUS_DEFAULT_EFFORT_CONFIG_DEFAULT,
274      ...config,
275    }
276  }
277  
278  // @[MODEL LAUNCH]: Update the default effort levels for new models
279  export function getDefaultEffortForModel(
280    model: string,
281  ): EffortValue | undefined {
282    if (process.env.USER_TYPE === 'ant') {
283      const config = getAntModelOverrideConfig()
284      const isDefaultModel =
285        config?.defaultModel !== undefined &&
286        model.toLowerCase() === config.defaultModel.toLowerCase()
287      if (isDefaultModel && config?.defaultModelEffortLevel) {
288        return config.defaultModelEffortLevel
289      }
290      const antModel = resolveAntModel(model)
291      if (antModel) {
292        if (antModel.defaultEffortLevel) {
293          return antModel.defaultEffortLevel
294        }
295        if (antModel.defaultEffortValue !== undefined) {
296          return antModel.defaultEffortValue
297        }
298      }
299      // Always default ants to undefined/high
300      return undefined
301    }
302  
303    // IMPORTANT: Do not change the default effort level without notifying
304    // the model launch DRI and research. Default effort is a sensitive setting
305    // that can greatly affect model quality and bashing.
306  
307    // Default effort on Opus 4.6 to medium for Pro.
308    // Max/Team also get medium when the tengu_grey_step2 config is enabled.
309    if (model.toLowerCase().includes('opus-4-6')) {
310      if (isProSubscriber()) {
311        return 'medium'
312      }
313      if (
314        getOpusDefaultEffortConfig().enabled &&
315        (isMaxSubscriber() || isTeamSubscriber())
316      ) {
317        return 'medium'
318      }
319    }
320  
321    // When ultrathink feature is on, default effort to medium (ultrathink bumps to high)
322    if (isUltrathinkEnabled() && modelSupportsEffort(model)) {
323      return 'medium'
324    }
325  
326    // Fallback to undefined, which means we don't set an effort level. This
327    // should resolve to high effort level in the API.
328    return undefined
329  }