From 82ef6804bbfd207522dde4bb4626fad2aaecb9ec Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 8 Jan 2026 15:18:29 -0500 Subject: [PATCH] feat(cliproxy): add thinking budget validator module - validate user-provided thinking values against model capabilities - clamp out-of-range budgets, map invalid levels to closest valid - export constants for budget bounds (MIN=0, MAX=100000) - support off/auto/level names and numeric budgets Refs #307 --- src/cliproxy/index.ts | 13 ++ src/cliproxy/thinking-validator.ts | 320 +++++++++++++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 src/cliproxy/thinking-validator.ts diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 6858f43d..a89c7bc7 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -152,3 +152,16 @@ export { resetAuthToDefaults, getAuthSummary, } from './auth-token-manager'; + +// Thinking validator +export type { ThinkingValidationResult } from './thinking-validator'; +export { + validateThinking, + THINKING_LEVEL_BUDGETS, + VALID_THINKING_LEVELS, + THINKING_OFF_VALUES, + THINKING_AUTO_VALUE, + THINKING_BUDGET_MIN, + THINKING_BUDGET_MAX, + THINKING_BUDGET_DEFAULT_MIN, +} from './thinking-validator'; diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts new file mode 100644 index 00000000..747cd497 --- /dev/null +++ b/src/cliproxy/thinking-validator.ts @@ -0,0 +1,320 @@ +/** + * 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, +}; + +/** + * 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]; + +/** + * Special thinking values + */ +export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const; +export const THINKING_AUTO_VALUE = 'auto'; + +/** + * 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 off/none/disabled values + if (typeof value === 'string') { + const normalizedValue = value.toLowerCase().trim(); + if (THINKING_OFF_VALUES.includes(normalizedValue as (typeof THINKING_OFF_VALUES)[number])) { + 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); + if (isNaN(parsed) || !Number.isFinite(parsed) || value.trim() !== String(parsed)) { + // 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 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 ?? []; + + // 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 { + valid: true, + value: closestLevel, + warning: `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 { valid: true, value: normalizedLevel }; + } + + // Try to find closest match + const closest = findClosestLevel(normalizedLevel, validLevels); + if (closest) { + return { + valid: true, + value: closest, + warning: `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 { + valid: true, + value: mapped, + warning: `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(', ')}).`, + }; +}