mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-07 22:21:09 +00:00
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
This commit is contained in:
@@ -152,3 +152,16 @@ export {
|
|||||||
resetAuthToDefaults,
|
resetAuthToDefaults,
|
||||||
getAuthSummary,
|
getAuthSummary,
|
||||||
} from './auth-token-manager';
|
} 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';
|
||||||
|
|||||||
@@ -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<string, number> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {};
|
||||||
|
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(', ')}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user