/** * Thinking Budget Validator * * Validates user-provided thinking values against model capabilities. * - Clamps out-of-range budgets * - Maps invalid levels to closest valid * - Warns when model doesn't support thinking */ import { getModelThinkingSupport, ThinkingSupport } from './model-catalog'; import { CLIProxyProvider } from './types'; /** * Thinking budget bounds (used for validation across API and CLI) */ export const THINKING_BUDGET_MIN = 0; export const THINKING_BUDGET_MAX = 100000; export const THINKING_BUDGET_DEFAULT_MIN = 512; /** * Result of thinking value validation */ export interface ThinkingValidationResult { /** Whether the value is valid for the model */ valid: boolean; /** The validated value (possibly clamped/mapped) */ value: string | number; /** Warning message for user feedback */ warning?: string; } /** * Named thinking level mappings to budget values (when converting level→budget) */ export const THINKING_LEVEL_BUDGETS: Record = { minimal: 512, low: 1024, medium: 8192, high: 24576, xhigh: 32768, }; /** * Level rank for comparison (higher = more intensive) */ export const THINKING_LEVEL_RANK: Record = { minimal: 1, low: 2, medium: 3, high: 4, xhigh: 5, }; /** * Valid thinking level names */ export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const; export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number]; /** * Valid tier names for tier_defaults configuration */ export const VALID_THINKING_TIERS = ['opus', 'sonnet', 'haiku'] as const; export type ThinkingTier = (typeof VALID_THINKING_TIERS)[number]; /** * Cap a level at the model's maximum supported level. * Returns the capped level and whether capping occurred. */ export function capLevelAtMax( level: string, maxLevel: string | undefined ): { level: string; capped: boolean } { if (!maxLevel) return { level, capped: false }; const levelRank = THINKING_LEVEL_RANK[level] ?? 0; const maxRank = THINKING_LEVEL_RANK[maxLevel] ?? 5; if (levelRank > maxRank) { return { level: maxLevel, capped: true }; } return { level, capped: false }; } /** * Special thinking values */ export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const; export const THINKING_AUTO_VALUE = 'auto'; /** * Check whether a value should disable thinking. * Accepts common string aliases and numeric 0. */ export function isThinkingOffValue(value: unknown): boolean { if (typeof value === 'number') { return value === 0; } if (typeof value === 'string') { const normalized = value.toLowerCase().trim(); return THINKING_OFF_VALUES.includes(normalized as (typeof THINKING_OFF_VALUES)[number]); } return false; } /** * Find closest valid level using simple string matching * Returns undefined if no close match found */ function findClosestLevel(input: string, validLevels: string[]): string | undefined { const normalized = input.toLowerCase().trim(); // Exact match first if (validLevels.includes(normalized)) { return normalized; } // Prefix matching (e.g., 'med' → 'medium', 'hi' → 'high') for (const level of validLevels) { if (level.startsWith(normalized)) { return level; } } // Common aliases const aliases: Record = { min: 'minimal', lo: 'low', med: 'medium', mid: 'medium', hi: 'high', max: 'xhigh', ultra: 'xhigh', extreme: 'xhigh', }; if (aliases[normalized] && validLevels.includes(aliases[normalized])) { return aliases[normalized]; } return undefined; } /** * Validate a thinking value against model capabilities * * @param provider - The CLI proxy provider * @param modelId - The model identifier * @param value - User-provided thinking value (level name or budget number) * @returns Validation result with possibly clamped/mapped value and warnings */ export function validateThinking( provider: CLIProxyProvider, modelId: string, value: string | number ): ThinkingValidationResult { const thinking = getModelThinkingSupport(provider, modelId); // Handle string length limit to prevent performance issues if (typeof value === 'string' && value.length > 100) { return { valid: false, value: 'off', warning: 'Thinking value too long (max 100 chars). Using "off".', }; } // Handle empty string explicitly if (typeof value === 'string' && value.trim() === '') { return { valid: false, value: 'off', warning: 'Empty thinking value not allowed. Using "off".', }; } // Handle off/none/disabled values if (isThinkingOffValue(value)) { return { valid: true, value: 'off' }; } // If model has no thinking support info, pass through if (!thinking) { return { valid: true, value, warning: `Model ${modelId} has unknown thinking support. Value passed through unchanged.`, }; } // Model doesn't support thinking at all if (thinking.type === 'none') { return { valid: false, value: 'off', warning: `Model ${modelId} does not support extended thinking. Thinking disabled.`, }; } // Handle 'auto' value if (typeof value === 'string' && value.toLowerCase().trim() === THINKING_AUTO_VALUE) { if (!thinking.dynamicAllowed) { return { valid: false, value: thinking.type === 'budget' ? (thinking.min ?? 1024) : (thinking.levels?.[0] ?? 'low'), warning: `Model ${modelId} does not support dynamic/auto thinking. Using minimum value.`, }; } return { valid: true, value: 'auto' }; } // Budget-type models if (thinking.type === 'budget') { return validateBudgetThinking(thinking, value, modelId); } // Level-type models if (thinking.type === 'levels') { return validateLevelThinking(thinking, value, modelId); } // Fallback return { valid: true, value }; } /** * Validate budget-type thinking value */ function validateBudgetThinking( thinking: ThinkingSupport, value: string | number, modelId: string ): ThinkingValidationResult { const min = thinking.min ?? THINKING_BUDGET_MIN; const max = thinking.max ?? THINKING_BUDGET_MAX; // Convert level name to budget if needed let budget: number; if (typeof value === 'string') { const normalizedLevel = value.toLowerCase().trim(); if (normalizedLevel in THINKING_LEVEL_BUDGETS) { budget = THINKING_LEVEL_BUDGETS[normalizedLevel]; } else { // Strict number parsing: reject partial parses like "123abc" const parsed = Number(value); const trimmed = value.trim(); // Accept trailing .0 for whole numbers (e.g., "8192.0" → 8192) const isWholeWithDecimal = Number.isInteger(parsed) && trimmed === `${parsed}.0`; if ( isNaN(parsed) || !Number.isFinite(parsed) || (trimmed !== String(parsed) && !isWholeWithDecimal) ) { // Not a valid number, try to find closest level const closest = findClosestLevel(value, Object.keys(THINKING_LEVEL_BUDGETS)); if (closest) { budget = THINKING_LEVEL_BUDGETS[closest]; } else { return { valid: false, value: min || THINKING_BUDGET_DEFAULT_MIN, warning: `Invalid thinking value "${value}" for ${modelId}. Using minimum budget ${min || THINKING_BUDGET_DEFAULT_MIN}.`, }; } } else { budget = parsed; } } } else { budget = value; } // Reject NaN/Infinity budgets if (!Number.isFinite(budget)) { return { valid: false, value: min || THINKING_BUDGET_DEFAULT_MIN, warning: `Budget must be a finite number. Using minimum ${min || THINKING_BUDGET_DEFAULT_MIN}.`, }; } // Reject negative budgets if (budget < THINKING_BUDGET_MIN) { return { valid: false, value: min || THINKING_BUDGET_DEFAULT_MIN, warning: `Negative thinking budget not allowed. Using minimum ${min || THINKING_BUDGET_DEFAULT_MIN}.`, }; } // Check zero budget if (budget === 0 && !thinking.zeroAllowed) { return { valid: false, value: min || THINKING_BUDGET_DEFAULT_MIN, warning: `Model ${modelId} does not support zero thinking budget. Using minimum ${min || THINKING_BUDGET_DEFAULT_MIN}.`, }; } // Clamp to valid range if (budget < min) { return { valid: true, value: min, warning: `Thinking budget ${budget} below minimum for ${modelId}. Clamped to ${min}.`, }; } if (budget > max) { return { valid: true, value: max, warning: `Thinking budget ${budget} exceeds maximum for ${modelId}. Clamped to ${max}.`, }; } return { valid: true, value: budget }; } /** * Validate level-type thinking value */ function validateLevelThinking( thinking: ThinkingSupport, value: string | number, modelId: string ): ThinkingValidationResult { const validLevels = thinking.levels ?? []; const maxLevel = thinking.maxLevel; // Helper to apply maxLevel cap and build result const applyMaxCap = (level: string, baseWarning?: string): ThinkingValidationResult => { const { level: cappedLevel, capped } = capLevelAtMax(level, maxLevel); const warnings: string[] = []; if (baseWarning) warnings.push(baseWarning); if (capped) { warnings.push( `Level "${level}" exceeds max "${maxLevel}" for ${modelId}. Capped to "${cappedLevel}".` ); } return { valid: true, value: cappedLevel, warning: warnings.length > 0 ? warnings.join(' ') : undefined, }; }; // If numeric, try to map to closest level by budget if (typeof value === 'number') { // Find closest level by comparing to level budgets let closestLevel = validLevels[0] ?? 'low'; let closestDiff = Infinity; for (const level of validLevels) { const levelBudget = THINKING_LEVEL_BUDGETS[level] ?? 8192; const diff = Math.abs(levelBudget - value); if (diff < closestDiff) { closestDiff = diff; closestLevel = level; } } return applyMaxCap( closestLevel, `Model ${modelId} uses named levels. Mapped budget ${value} to "${closestLevel}".` ); } // String level const normalizedLevel = value.toLowerCase().trim(); // Check if it's a valid level for this model if (validLevels.includes(normalizedLevel)) { return applyMaxCap(normalizedLevel); } // Try to find closest match const closest = findClosestLevel(normalizedLevel, validLevels); if (closest) { return applyMaxCap( closest, `Level "${value}" not valid for ${modelId}. Mapped to "${closest}".` ); } // Try to map from standard level names to model's levels const standardToModelLevel: Record = {}; const levelOrder = ['minimal', 'low', 'medium', 'high', 'xhigh']; // Build mapping based on position for (let i = 0; i < validLevels.length; i++) { // Map first half of standard levels to lower model levels, second half to higher const standardIdx = Math.floor((i / validLevels.length) * levelOrder.length); for (let j = standardIdx; j < levelOrder.length; j++) { if (!standardToModelLevel[levelOrder[j]]) { standardToModelLevel[levelOrder[j]] = validLevels[Math.min(i, validLevels.length - 1)]; } } } const mapped = standardToModelLevel[normalizedLevel]; if (mapped) { return applyMaxCap( mapped, `Level "${value}" mapped to "${mapped}" for ${modelId} (available: ${validLevels.join(', ')}).` ); } // Default to first level const defaultLevel = validLevels[0] ?? 'low'; return { valid: false, value: defaultLevel, warning: `Invalid level "${value}" for ${modelId}. Using "${defaultLevel}" (available: ${validLevels.join(', ')}).`, }; }