diff --git a/package.json b/package.json index 543db489..0fd54e4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.24.2-dev.2", + "version": "7.24.2-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 329678ef..f63ff81b 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -27,6 +27,7 @@ import { CLIPROXY_DEFAULT_PORT, getCliproxyWritablePath, validatePort, + applyThinkingConfig, } from './config-generator'; import { checkRemoteProxy } from './remote-proxy-client'; import { isAuthenticated } from './auth-handler'; @@ -327,6 +328,64 @@ export async function execClaudeWithCLIProxy( setNickname = argsWithoutProxy[nicknameIdx + 1]; } + // Parse --thinking flag for thinking budget control + // Supports: level names (low, medium, high, xhigh, auto), numeric budget, or 'off'/'none' + // Accepts both --thinking=value and --thinking value formats + let thinkingOverride: string | number | undefined; + + // Check for --thinking=value format first + const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking=')); + if (thinkingEqArg) { + const val = thinkingEqArg.substring('--thinking='.length); + // Handle empty value after equals (--thinking=) + if (!val || val.trim() === '') { + console.error(fail('--thinking requires a value')); + console.error(' Examples: --thinking=low, --thinking=8192, --thinking=off'); + console.error(' Levels: minimal, low, medium, high, xhigh, auto'); + process.exit(1); + } + // Parse as number if numeric, otherwise keep as string (level name) + const numVal = parseInt(val, 10); + thinkingOverride = !isNaN(numVal) ? numVal : val; + + // W2: Warn if multiple --thinking flags found (check both formats) + const allThinkingFlags = argsWithoutProxy.filter( + (arg) => arg === '--thinking' || arg.startsWith('--thinking=') + ); + if (allThinkingFlags.length > 1) { + console.warn( + `[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}` + ); + } + } else { + // Fall back to --thinking value format + const thinkingIdx = argsWithoutProxy.indexOf('--thinking'); + if (thinkingIdx !== -1) { + const nextArg = argsWithoutProxy[thinkingIdx + 1]; + // U1: Check if --thinking has a value (not missing or another flag) + if (!nextArg || nextArg.startsWith('-')) { + console.error(fail('--thinking requires a value')); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Levels: minimal, low, medium, high, xhigh, auto'); + process.exit(1); + } + const val = nextArg; + // Parse as number if numeric, otherwise keep as string (level name) + const numVal = parseInt(val, 10); + thinkingOverride = !isNaN(numVal) ? numVal : val; + + // W2: Warn if multiple --thinking flags found (check both formats) + const allThinkingFlags = argsWithoutProxy.filter( + (arg) => arg === '--thinking' || arg.startsWith('--thinking=') + ); + if (allThinkingFlags.length > 1) { + console.warn( + `[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${val}` + ); + } + } + } + // Handle --accounts: list accounts and exit if (showAccounts) { const accounts = getProviderAccounts(provider); @@ -761,6 +820,10 @@ export async function execClaudeWithCLIProxy( ) : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig); + // Apply thinking configuration to model (auto tier-based or manual override) + // This adds thinking suffix like model(high) or model(8192) for CLIProxyAPIPlus + applyThinkingConfig(envVars, provider, thinkingOverride); + // Codex-only: inject OpenAI reasoning effort based on tier model mapping. // Maps by request.model: // - OPUS/default model → xhigh @@ -850,6 +913,7 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + '--thinking', '--incognito', '--no-incognito', '--import', @@ -859,8 +923,14 @@ export async function execClaudeWithCLIProxy( const claudeArgs = argsWithoutProxy.filter((arg, idx) => { // Filter out CCS flags if (ccsFlags.includes(arg)) return false; - // Filter out value after --use or --nickname - if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname') + // Filter out --thinking=value format + if (arg.startsWith('--thinking=')) return false; + // Filter out value after --use, --nickname, or --thinking + if ( + argsWithoutProxy[idx - 1] === '--use' || + argsWithoutProxy[idx - 1] === '--nickname' || + argsWithoutProxy[idx - 1] === '--thinking' + ) return false; return true; }); diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 6931288b..d846cb14 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -1,6 +1,7 @@ import * as http from 'http'; import * as https from 'https'; import { URL } from 'url'; +import { getModelMaxLevel } from './model-catalog'; export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; @@ -51,10 +52,32 @@ const EFFORT_RANK: Record = { xhigh: 3, }; +/** All valid codex effort levels in rank order */ +const EFFORT_BY_RANK: CodexReasoningEffort[] = ['medium', 'high', 'xhigh']; + function minEffort(a: CodexReasoningEffort, b: CodexReasoningEffort): CodexReasoningEffort { return EFFORT_RANK[a] <= EFFORT_RANK[b] ? a : b; } +/** + * Cap effort at model's max level from catalog. + * Returns the capped effort (or original if no cap applies). + */ +function capEffortAtModelMax(model: string, effort: CodexReasoningEffort): CodexReasoningEffort { + const maxLevel = getModelMaxLevel('codex', model); + if (!maxLevel) return effort; + + // Map maxLevel to CodexReasoningEffort (only medium/high/xhigh are valid) + const maxEffort = EFFORT_BY_RANK.find((e) => e === maxLevel); + if (!maxEffort) return effort; + + // Cap if effort exceeds max + if (EFFORT_RANK[effort] > EFFORT_RANK[maxEffort]) { + return maxEffort; + } + return effort; +} + export function buildCodexModelEffortMap( models: CodexReasoningModelMap, defaultEffort: CodexReasoningEffort = 'medium' @@ -85,7 +108,9 @@ export function getEffortForModel( defaultEffort: CodexReasoningEffort ): CodexReasoningEffort { if (!model) return defaultEffort; - return modelEffort.get(model) ?? defaultEffort; + const effort = modelEffort.get(model) ?? defaultEffort; + // Apply model-specific cap from catalog + return capEffortAtModelMax(model, effort); } export function injectReasoningEffortIntoBody( diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index a00c9632..5abc9d44 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -15,14 +15,176 @@ import { expandPath } from '../utils/helpers'; import { warn } from '../utils/ui'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; -import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; +import { + loadOrCreateUnifiedConfig, + getGlobalEnvConfig, + getThinkingConfig, +} from '../config/unified-config-loader'; import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager'; +import { supportsThinking } from './model-catalog'; +import { ThinkingConfig } from '../config/unified-config-types'; +import { validateThinking } from './thinking-validator'; + +/** + * Check if warnings should be shown based on thinking config. + * Defaults to true if show_warnings is not explicitly false. + */ +function shouldShowWarnings(thinkingConfig: ThinkingConfig): boolean { + return thinkingConfig.show_warnings !== false; +} /** Settings file structure for user overrides */ interface ProviderSettings { env: NodeJS.ProcessEnv; } +/** + * Detect current tier from model name. + * Looks at ANTHROPIC_MODEL to determine if using opus/sonnet/haiku tier. + */ +export type ModelTier = 'opus' | 'sonnet' | 'haiku'; + +/** + * Detect tier from model name. + * Returns 'sonnet' as default if unclear. + */ +export function detectTierFromModel(modelName: string): ModelTier { + const lower = modelName.toLowerCase(); + if (lower.includes('opus')) return 'opus'; + if (lower.includes('haiku')) return 'haiku'; + return 'sonnet'; // Default to sonnet (most common) +} + +/** + * Apply thinking suffix to model name. + * CLIProxyAPIPlus parses suffixes like model(level) or model(budget). + * + * @param model - Base model name + * @param thinkingValue - Level name (e.g., 'high') or numeric budget + * @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)" + */ +export function applyThinkingSuffix(model: string, thinkingValue: string | number): string { + // Don't apply if model already ends with a parenthesized suffix (e.g., "model(high)" or "model(8192)") + // Matches: ends with "(...)" where content is non-empty + if (/\([^)]+\)$/.test(model)) { + return model; + } + return `${model}(${thinkingValue})`; +} + +/** + * Get thinking value for tier based on config. + * Respects provider-specific overrides if configured. + */ +export function getThinkingValueForTier( + tier: ModelTier, + provider: CLIProxyProvider, + thinkingConfig: ThinkingConfig +): string { + // Check provider-specific override first + const providerOverride = thinkingConfig.provider_overrides?.[provider]?.[tier]; + if (providerOverride) { + return providerOverride; + } + // Fall back to global tier default (with null guard) + return thinkingConfig.tier_defaults?.[tier] ?? 'medium'; +} + +/** + * Apply thinking configuration to env vars. + * Modifies ANTHROPIC_MODEL and tier models with thinking suffixes. + * + * @param envVars - Environment variables to modify + * @param provider - CLIProxy provider + * @param thinkingOverride - Optional CLI override (takes priority over config) + * @returns Modified env vars with thinking suffixes applied + */ +export function applyThinkingConfig( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + thinkingOverride?: string | number +): NodeJS.ProcessEnv { + const thinkingConfig = getThinkingConfig(); + const result = { ...envVars }; + + // Check if thinking is off + if (thinkingConfig.mode === 'off' && thinkingOverride === undefined) { + return result; + } + + // Get base model to check thinking support + const baseModel = result.ANTHROPIC_MODEL || ''; + if (!supportsThinking(provider, baseModel)) { + // U2: Warn user if they explicitly provided --thinking but model doesn't support it + if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) { + console.warn( + warn( + `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.` + ) + ); + } + return result; + } + + // Determine thinking value to use + let thinkingValue: string | number; + + if (thinkingOverride !== undefined) { + // CLI override takes priority + thinkingValue = thinkingOverride; + } else if (thinkingConfig.mode === 'manual' && thinkingConfig.override !== undefined) { + // Config manual mode with override + thinkingValue = thinkingConfig.override; + } else if (thinkingConfig.mode === 'auto') { + // Auto mode: detect tier and apply default + const tier = detectTierFromModel(baseModel); + thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig); + } else { + return result; // No thinking to apply + } + + // Validate thinking value against model capabilities + const validation = validateThinking(provider, baseModel, thinkingValue); + if (validation.warning && shouldShowWarnings(thinkingConfig)) { + console.warn(warn(validation.warning)); + } + thinkingValue = validation.value; + + // If validation says off, don't apply suffix + if (thinkingValue === 'off') { + return result; + } + + // Apply thinking suffix to main model + if (result.ANTHROPIC_MODEL) { + result.ANTHROPIC_MODEL = applyThinkingSuffix(result.ANTHROPIC_MODEL, thinkingValue); + } + + // Apply to tier models if they support thinking + const tierModels = [ + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ] as const; + + for (const tierVar of tierModels) { + const model = result[tierVar]; + if (model && supportsThinking(provider, model)) { + // Get tier-specific thinking value + const tier = tierVar.includes('OPUS') + ? 'opus' + : tierVar.includes('SONNET') + ? 'sonnet' + : 'haiku'; + const tierThinkingValue = + thinkingOverride ?? getThinkingValueForTier(tier, provider, thinkingConfig); + result[tierVar] = applyThinkingSuffix(model, tierThinkingValue); + } + } + + return result; +} + /** * Validate port is a valid positive integer (1-65535). * Returns default port if invalid. diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 6858f43d..df83c881 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -152,3 +152,17 @@ export { resetAuthToDefaults, getAuthSummary, } from './auth-token-manager'; + +// Thinking validator +export type { ThinkingValidationResult } from './thinking-validator'; +export { + validateThinking, + THINKING_LEVEL_BUDGETS, + VALID_THINKING_LEVELS, + VALID_THINKING_TIERS, + THINKING_OFF_VALUES, + THINKING_AUTO_VALUE, + THINKING_BUDGET_MIN, + THINKING_BUDGET_MAX, + THINKING_BUDGET_DEFAULT_MIN, +} from './thinking-validator'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index ae8e9530..78c86d38 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -7,6 +7,27 @@ import { CLIProxyProvider } from './types'; +/** + * Thinking support configuration for a model. + * Defines how thinking/reasoning budget can be controlled. + */ +export interface ThinkingSupport { + /** Type of thinking control: 'budget' (token count), 'levels' (named levels), 'none' */ + type: 'budget' | 'levels' | 'none'; + /** Minimum budget tokens (for budget type) */ + min?: number; + /** Maximum budget tokens (for budget type) */ + max?: number; + /** Valid level names (for levels type) */ + levels?: string[]; + /** Maximum reasoning effort level (caps effort at this level for levels type) */ + maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + /** Whether zero/disabled thinking is allowed */ + zeroAllowed?: boolean; + /** Whether dynamic/auto thinking is allowed */ + dynamicAllowed?: boolean; +} + /** * Model entry definition */ @@ -27,6 +48,8 @@ export interface ModelEntry { deprecated?: boolean; /** Deprecation reason/message */ deprecationReason?: string; + /** Thinking/reasoning support configuration */ + thinking?: ThinkingSupport; } /** @@ -54,21 +77,37 @@ export const MODEL_CATALOG: Partial> = id: 'gemini-claude-opus-4-5-thinking', name: 'Claude Opus 4.5 Thinking', description: 'Most capable, extended thinking', + thinking: { + type: 'budget', + min: 1024, + max: 100000, + zeroAllowed: false, + dynamicAllowed: true, + }, }, { id: 'gemini-claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking', description: 'Balanced with extended thinking', + thinking: { + type: 'budget', + min: 1024, + max: 100000, + zeroAllowed: false, + dynamicAllowed: true, + }, }, { id: 'gemini-claude-sonnet-4-5', name: 'Claude Sonnet 4.5', description: 'Fast and capable', + thinking: { type: 'none' }, }, { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', description: 'Google latest model via Antigravity', + thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, }, ], }, @@ -82,11 +121,48 @@ export const MODEL_CATALOG: Partial> = name: 'Gemini 3 Pro', tier: 'paid', description: 'Latest model, requires paid Google account', + thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, }, { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable, works with free Google account', + thinking: { + type: 'budget', + min: 128, + max: 32768, + zeroAllowed: false, + dynamicAllowed: true, + }, + }, + ], + }, + codex: { + provider: 'codex', + displayName: 'Copilot Codex', + defaultModel: 'gpt-5.2-codex', + models: [ + { + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + description: 'Full reasoning support (xhigh)', + thinking: { + type: 'levels', + levels: ['medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: 'Capped at high reasoning (no xhigh)', + thinking: { + type: 'levels', + levels: ['medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, }, ], }, @@ -108,11 +184,13 @@ export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog /** * Find model entry by ID + * Note: Model IDs are normalized to lowercase for case-insensitive comparison */ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEntry | undefined { const catalog = MODEL_CATALOG[provider]; - if (!catalog) return undefined; - return catalog.models.find((m) => m.id === modelId); + if (!catalog || !modelId) return undefined; + const normalizedId = modelId.trim().toLowerCase(); + return catalog.models.find((m) => m.id.toLowerCase() === normalizedId); } /** @@ -149,3 +227,34 @@ export function getModelDeprecationReason( const model = findModel(provider, modelId); return model?.deprecationReason; } + +/** + * Get thinking support configuration for a model + */ +export function getModelThinkingSupport( + provider: CLIProxyProvider, + modelId: string +): ThinkingSupport | undefined { + const model = findModel(provider, modelId); + return model?.thinking; +} + +/** + * Get the maximum reasoning effort level for a model. + * Returns undefined if model has no cap or is not in catalog. + */ +export function getModelMaxLevel( + provider: CLIProxyProvider, + modelId: string +): ThinkingSupport['maxLevel'] | undefined { + const thinking = getModelThinkingSupport(provider, modelId); + return thinking?.maxLevel; +} + +/** + * Check if model supports thinking/reasoning + */ +export function supportsThinking(provider: CLIProxyProvider, modelId: string): boolean { + const thinking = getModelThinkingSupport(provider, modelId); + return thinking !== undefined && thinking.type !== 'none'; +} diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts new file mode 100644 index 00000000..29a2a7d3 --- /dev/null +++ b/src/cliproxy/thinking-validator.ts @@ -0,0 +1,403 @@ +/** + * 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'; + +/** + * 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 (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); + 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(', ')}).`, + }; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index effb55a4..51079d1b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -172,6 +172,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs --accounts', 'List all accounts'], ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], + [ + 'ccs --thinking ', + 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', + ], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs kiro --import', 'Import token from Kiro IDE'], @@ -282,6 +286,20 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], ]); + // W3: Thinking Budget explanation + printSubSection('Extended Thinking (--thinking)', [ + ['--thinking off', 'Disable extended thinking'], + ['--thinking auto', 'Let model decide dynamically'], + ['--thinking low', '1K tokens - Quick responses'], + ['--thinking medium', '8K tokens - Standard analysis'], + ['--thinking high', '24K tokens - Deep reasoning'], + ['--thinking xhigh', '32K tokens - Maximum depth'], + ['--thinking ', 'Custom token budget (512-100000)'], + ['', ''], + ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], + ['', 'before responding. Supported: agy, gemini (thinking models).'], + ]); + // CLI Proxy env vars printSubSection('CLI Proxy Environment Variables', [ ['CCS_PROXY_HOST', 'Remote proxy hostname'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 27ae026f..273de189 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -18,8 +18,10 @@ import { DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, + DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, GlobalEnvConfig, + ThinkingConfig, DashboardAuthConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -108,8 +110,23 @@ export function loadUnifiedConfig(): UnifiedConfig | null { return parsed; } catch (err) { - const error = err instanceof Error ? err.message : 'Unknown error'; - console.error(`[X] Failed to load config: ${error}`); + // U3: Provide better context for YAML syntax errors + if (err instanceof yaml.YAMLException) { + const mark = err.mark; + console.error(`[X] YAML syntax error in ${yamlPath}:`); + console.error( + ` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}` + ); + if (mark?.snippet) { + console.error(` ${mark.snippet}`); + } + console.error( + ` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.` + ); + } else { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load config: ${error}`); + } return null; } } @@ -244,6 +261,20 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock, }, }, + // Thinking config - auto/manual/off control for reasoning budget + thinking: { + mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode, + override: partial.thinking?.override, + tier_defaults: { + opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus, + sonnet: + partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet, + haiku: + partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku, + }, + provider_overrides: partial.thinking?.provider_overrides, + show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, + }, // Dashboard auth config - disabled by default dashboard_auth: { enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled, @@ -439,6 +470,25 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Thinking section (extended thinking/reasoning configuration) + if (config.thinking) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Thinking: Extended thinking/reasoning budget configuration'); + lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).'); + lines.push('#'); + lines.push('# Modes: auto (use tier_defaults), off (disable), manual (--thinking flag only)'); + lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto'); + lines.push('# Override: Set global override value (number or level name)'); + lines.push('# Provider overrides: Per-provider tier defaults'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ thinking: config.thinking }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + // Dashboard auth section (only if configured) if (config.dashboard_auth?.enabled) { lines.push('# ----------------------------------------------------------------------------'); @@ -608,6 +658,36 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get thinking configuration. + * Returns defaults if not configured. + */ +export function getThinkingConfig(): ThinkingConfig { + const config = loadOrCreateUnifiedConfig(); + + // W2: Check for invalid thinking config (e.g., thinking: true instead of object) + if (config.thinking !== undefined && typeof config.thinking !== 'object') { + console.warn( + `[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.` + ); + console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`); + return DEFAULT_THINKING_CONFIG; + } + + return { + mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode, + override: config.thinking?.override, + tier_defaults: { + opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus, + sonnet: + config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet, + haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku, + }, + provider_overrides: config.thinking?.provider_overrides, + show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings, + }; +} + /** * Get dashboard_auth configuration with ENV var override. * Priority: ENV vars > config.yaml > defaults diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 17f507e4..2315f67d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -17,8 +17,9 @@ * Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI) * Version 6 = Customizable auth tokens (API key and management secret) * Version 7 = Quota management for hybrid auto+manual account control + * Version 8 = Thinking/reasoning budget configuration */ -export const UNIFIED_CONFIG_VERSION = 7; +export const UNIFIED_CONFIG_VERSION = 8; /** * Account configuration (formerly in profiles.json). @@ -426,6 +427,66 @@ export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = { manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG }, }; +// ============================================================================ +// THINKING CONFIGURATION (v8+) +// ============================================================================ + +/** + * Thinking mode for auto/manual/off control. + * - auto: Apply tier-based defaults (opus→high, sonnet→medium, haiku→low) + * - off: Disable thinking entirely + * - manual: Use explicit override value + */ +export type ThinkingMode = 'auto' | 'off' | 'manual'; + +/** + * Tier-to-thinking level defaults. + * Maps Claude tier names to thinking level names. + */ +export interface ThinkingTierDefaults { + /** Thinking level for opus tier (default: 'high') */ + opus: string; + /** Thinking level for sonnet tier (default: 'medium') */ + sonnet: string; + /** Thinking level for haiku tier (default: 'low') */ + haiku: string; +} + +/** + * Thinking configuration section. + * Controls thinking/reasoning budget injection for CLIProxy providers. + */ +export interface ThinkingConfig { + /** Thinking mode (default: 'auto') */ + mode: ThinkingMode; + /** Manual override value (level name or budget number) */ + override?: string | number; + /** Tier-to-level mapping */ + tier_defaults: ThinkingTierDefaults; + /** Per-provider overrides (e.g., { gemini: { opus: 'high' } }) */ + provider_overrides?: Record>; + /** Show warning when values are clamped (default: true) */ + show_warnings?: boolean; +} + +/** + * Default thinking tier defaults. + */ +export const DEFAULT_THINKING_TIER_DEFAULTS: ThinkingTierDefaults = { + opus: 'high', + sonnet: 'medium', + haiku: 'low', +}; + +/** + * Default thinking configuration. + */ +export const DEFAULT_THINKING_CONFIG: ThinkingConfig = { + mode: 'auto', + tier_defaults: { ...DEFAULT_THINKING_TIER_DEFAULTS }, + show_warnings: true, +}; + /** * Dashboard authentication configuration. * Optional login protection for CCS dashboard. @@ -480,6 +541,8 @@ export interface UnifiedConfig { cliproxy_server?: CliproxyServerConfig; /** Quota management configuration (v7+) */ quota_management?: QuotaManagementConfig; + /** Thinking/reasoning budget configuration (v8+) */ + thinking?: ThinkingConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; } @@ -572,6 +635,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { copilot: { ...DEFAULT_COPILOT_CONFIG }, cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, + thinking: { ...DEFAULT_THINKING_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, }; } diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index f88339b4..98f933fe 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -11,7 +11,17 @@ import { loadOrCreateUnifiedConfig, saveUnifiedConfig, getGlobalEnvConfig, + getThinkingConfig, + getConfigYamlPath, } from '../../config/unified-config-loader'; +import type { ThinkingConfig } from '../../config/unified-config-types'; +import { + THINKING_BUDGET_MIN, + THINKING_BUDGET_MAX, + VALID_THINKING_LEVELS, + VALID_THINKING_TIERS, + THINKING_OFF_VALUES, +} from '../../cliproxy'; import { validateFilePath } from './route-helpers'; const router = Router(); @@ -221,4 +231,196 @@ router.put('/global-env', (req: Request, res: Response): void => { } }); +// ==================== Thinking Configuration ==================== + +/** + * GET /api/thinking - Get thinking budget configuration + * Returns the thinking section from config.yaml with mtime for optimistic locking + */ +router.get('/thinking', (_req: Request, res: Response): void => { + try { + const config = getThinkingConfig(); + // W4: Include mtime for optimistic locking + let lastModified: number | undefined; + try { + const stats = fs.statSync(getConfigYamlPath()); + lastModified = stats.mtimeMs; + } catch { + // File may not exist yet + } + res.json({ config, lastModified }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/thinking - Update thinking budget configuration + * Updates the thinking section in config.yaml + * Supports optimistic locking via lastModified field + */ +router.put('/thinking', (req: Request, res: Response): void => { + try { + const { lastModified, ...updates } = req.body as Partial & { + lastModified?: number; + }; + + // W4: Optimistic locking - check if file was modified since last read + if (lastModified !== undefined) { + try { + const stats = fs.statSync(getConfigYamlPath()); + if (stats.mtimeMs > lastModified) { + res.status(409).json({ + error: 'Config was modified by another process. Please refresh and try again.', + currentMtime: stats.mtimeMs, + }); + return; + } + } catch { + // File may not exist yet, allow creation + } + } + + const config = loadOrCreateUnifiedConfig(); + + // Validate mode if provided + if (updates.mode !== undefined) { + const validModes = ['auto', 'off', 'manual']; + if (!validModes.includes(updates.mode)) { + res.status(400).json({ error: `Invalid mode: must be one of ${validModes.join(', ')}` }); + return; + } + } + + // Validate override if provided (budget or level) + if (updates.override !== undefined) { + // C3: Reject objects/arrays - only number or string allowed + if (typeof updates.override !== 'number' && typeof updates.override !== 'string') { + res.status(400).json({ + error: 'Invalid override: must be a number or string, not object/array', + }); + return; + } + + if (typeof updates.override === 'number') { + if ( + !Number.isFinite(updates.override) || + updates.override < THINKING_BUDGET_MIN || + updates.override > THINKING_BUDGET_MAX + ) { + res.status(400).json({ + error: `Invalid override: must be between ${THINKING_BUDGET_MIN} and ${THINKING_BUDGET_MAX}`, + }); + return; + } + } else if (typeof updates.override === 'string') { + const normalizedValue = updates.override.toLowerCase().trim(); + const validValues = [...VALID_THINKING_LEVELS, ...THINKING_OFF_VALUES] as readonly string[]; + if (!validValues.includes(normalizedValue)) { + res.status(400).json({ + error: `Invalid override: must be a level name (${VALID_THINKING_LEVELS.join(', ')}) or a number`, + }); + return; + } + } + } + + // Validate tier_defaults if provided + if (updates.tier_defaults !== undefined) { + if ( + typeof updates.tier_defaults !== 'object' || + updates.tier_defaults === null || + Array.isArray(updates.tier_defaults) + ) { + res.status(400).json({ error: 'Invalid tier_defaults: must be an object' }); + return; + } + const validLevels = [...VALID_THINKING_LEVELS] as string[]; + for (const [tier, level] of Object.entries(updates.tier_defaults)) { + if (!(VALID_THINKING_TIERS as readonly string[]).includes(tier)) { + res.status(400).json({ error: `Invalid tier: ${tier}` }); + return; + } + if (!validLevels.includes(level)) { + res.status(400).json({ + error: `Invalid level for ${tier}: must be one of ${validLevels.join(', ')}`, + }); + return; + } + } + } + + // C4: Validate provider_overrides if provided (nested structure: Record>) + if (updates.provider_overrides !== undefined) { + if ( + typeof updates.provider_overrides !== 'object' || + updates.provider_overrides === null || + Array.isArray(updates.provider_overrides) + ) { + res.status(400).json({ error: 'Invalid provider_overrides: must be an object' }); + return; + } + const validLevels = [...VALID_THINKING_LEVELS] as string[]; + const validTiers = [...VALID_THINKING_TIERS] as string[]; + for (const [provider, tierOverrides] of Object.entries(updates.provider_overrides)) { + if (typeof provider !== 'string' || provider.trim() === '') { + res + .status(400) + .json({ error: 'Invalid provider_overrides: keys must be non-empty strings' }); + return; + } + // tierOverrides should be Partial + if (typeof tierOverrides !== 'object' || tierOverrides === null) { + res.status(400).json({ + error: `Invalid provider_overrides for ${provider}: must be an object with tier→level mapping`, + }); + return; + } + for (const [tier, level] of Object.entries(tierOverrides)) { + if (!validTiers.includes(tier)) { + res.status(400).json({ + error: `Invalid tier '${tier}' in provider_overrides.${provider}: must be one of ${validTiers.join(', ')}`, + }); + return; + } + if (typeof level !== 'string' || !validLevels.includes(level)) { + res.status(400).json({ + error: `Invalid level for provider_overrides.${provider}.${tier}: must be one of ${validLevels.join(', ')}`, + }); + return; + } + } + } + } + + // Update thinking section + config.thinking = { + mode: updates.mode ?? config.thinking?.mode ?? 'auto', + override: updates.override ?? config.thinking?.override, + tier_defaults: { + opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high', + sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium', + haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low', + }, + provider_overrides: updates.provider_overrides ?? config.thinking?.provider_overrides, + show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true, + }; + + saveUnifiedConfig(config); + + // W4: Return new mtime for subsequent requests + let newMtime: number | undefined; + try { + const stats = fs.statSync(getConfigYamlPath()); + newMtime = stats.mtimeMs; + } catch { + // Ignore + } + + res.json({ success: true, config: config.thinking, lastModified: newMtime }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 703b5fe6..fc8aa340 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -318,8 +318,10 @@ auth-dir: "/test" const userKeys = parseUserApiKeys(configContent); - // Empty strings should be filtered out (only truthy values) - assert.deepStrictEqual(userKeys, ['valid-key']); + // Empty strings should be filtered out - parseUserApiKeys checks: key && key !== CCS_INTERNAL_API_KEY + // Empty string is falsy, so it's excluded along with internal key + assert.strictEqual(userKeys.length, 1, 'Should filter out empty string key'); + assert.deepStrictEqual(userKeys, ['valid-key'], 'Should only include valid non-empty keys'); }); it('handles config with Windows line endings', () => { diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index e583345b..0cd8beed 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -23,9 +23,15 @@ describe('Model Catalog', () => { assert.strictEqual(MODEL_CATALOG.gemini.displayName, 'Gemini'); }); - it('does not contain codex or qwen (not configurable)', () => { + it('contains Codex provider catalog', () => { + const { MODEL_CATALOG } = modelCatalog; + assert(MODEL_CATALOG.codex, 'Should have codex provider'); + assert.strictEqual(MODEL_CATALOG.codex.provider, 'codex'); + assert.strictEqual(MODEL_CATALOG.codex.displayName, 'Copilot Codex'); + }); + + it('does not contain qwen (not configurable)', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.codex, undefined); assert.strictEqual(MODEL_CATALOG.qwen, undefined); }); }); @@ -115,9 +121,9 @@ describe('Model Catalog', () => { assert.strictEqual(supportsModelConfig('gemini'), true); }); - it('returns false for codex', () => { + it('returns true for codex', () => { const { supportsModelConfig } = modelCatalog; - assert.strictEqual(supportsModelConfig('codex'), false); + assert.strictEqual(supportsModelConfig('codex'), true); }); it('returns false for qwen', () => { @@ -142,10 +148,12 @@ describe('Model Catalog', () => { assert.strictEqual(catalog.provider, 'gemini'); }); - it('returns undefined for codex', () => { + it('returns catalog for codex', () => { const { getProviderCatalog } = modelCatalog; const catalog = getProviderCatalog('codex'); - assert.strictEqual(catalog, undefined); + assert(catalog, 'Should return catalog'); + assert.strictEqual(catalog.provider, 'codex'); + assert(Array.isArray(catalog.models)); }); }); diff --git a/tests/unit/cliproxy/model-config.test.js b/tests/unit/cliproxy/model-config.test.js index ec6f3891..66d6aa25 100644 --- a/tests/unit/cliproxy/model-config.test.js +++ b/tests/unit/cliproxy/model-config.test.js @@ -38,12 +38,6 @@ describe('Model Config', () => { }); describe('configureProviderModel', () => { - it('returns false for unsupported provider (codex)', async () => { - const { configureProviderModel } = modelConfig; - const result = await configureProviderModel('codex', true); - assert.strictEqual(result, false); - }); - it('returns false for unsupported provider (qwen)', async () => { const { configureProviderModel } = modelConfig; const result = await configureProviderModel('qwen', true); diff --git a/tests/unit/cliproxy/thinking-validator.test.ts b/tests/unit/cliproxy/thinking-validator.test.ts new file mode 100644 index 00000000..f022313f --- /dev/null +++ b/tests/unit/cliproxy/thinking-validator.test.ts @@ -0,0 +1,171 @@ +/** + * Thinking Validator Unit Tests + * + * Tests for thinking budget validation logic + */ + +import { describe, it, expect } from 'bun:test'; +import { + validateThinking, + THINKING_LEVEL_BUDGETS, + VALID_THINKING_LEVELS, + THINKING_OFF_VALUES, + THINKING_BUDGET_MIN, + THINKING_BUDGET_MAX, + THINKING_BUDGET_DEFAULT_MIN, +} from '../../../src/cliproxy/thinking-validator'; + +describe('Thinking Validator', () => { + describe('Constants', () => { + it('should export valid budget bounds', () => { + expect(THINKING_BUDGET_MIN).toBe(0); + expect(THINKING_BUDGET_MAX).toBe(100000); + expect(THINKING_BUDGET_DEFAULT_MIN).toBe(512); + }); + + it('should export valid thinking levels', () => { + expect(VALID_THINKING_LEVELS).toContain('minimal'); + expect(VALID_THINKING_LEVELS).toContain('low'); + expect(VALID_THINKING_LEVELS).toContain('medium'); + expect(VALID_THINKING_LEVELS).toContain('high'); + expect(VALID_THINKING_LEVELS).toContain('xhigh'); + expect(VALID_THINKING_LEVELS).toContain('auto'); + }); + + it('should export level budget mappings', () => { + expect(THINKING_LEVEL_BUDGETS.minimal).toBe(512); + expect(THINKING_LEVEL_BUDGETS.low).toBe(1024); + expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192); + expect(THINKING_LEVEL_BUDGETS.high).toBe(24576); + expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768); + }); + + it('should export off values', () => { + expect(THINKING_OFF_VALUES).toContain('off'); + expect(THINKING_OFF_VALUES).toContain('none'); + expect(THINKING_OFF_VALUES).toContain('disabled'); + expect(THINKING_OFF_VALUES).toContain('0'); + }); + }); + + describe('Off values', () => { + it('should handle "off" value', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', 'off'); + expect(result.valid).toBe(true); + expect(result.value).toBe('off'); + expect(result.warning).toBeUndefined(); + }); + + it('should handle "none" value', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', 'none'); + expect(result.valid).toBe(true); + expect(result.value).toBe('off'); + }); + + it('should handle "disabled" value', () => { + const result = validateThinking('agy', 'claude-sonnet-4-20250514', 'disabled'); + expect(result.valid).toBe(true); + expect(result.value).toBe('off'); + }); + + it('should handle "0" string as off', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', '0'); + expect(result.valid).toBe(true); + expect(result.value).toBe('off'); + }); + + it('should be case-insensitive for off values', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', 'OFF'); + expect(result.valid).toBe(true); + expect(result.value).toBe('off'); + }); + }); + + describe('Named levels (levels-type models like Gemini)', () => { + it('should accept valid level names', () => { + const levels = ['low', 'medium', 'high']; + for (const level of levels) { + const result = validateThinking('gemini', 'gemini-3-pro-preview', level); + expect(result.valid).toBe(true); + } + }); + + it('should be case-insensitive for level names', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', 'HIGH'); + expect(result.valid).toBe(true); + }); + + it('should map numeric budgets to closest level for level-type models', () => { + // Level-type models convert budget to closest named level + const result = validateThinking('gemini', 'gemini-3-pro-preview', 8192); + expect(result.valid).toBe(true); + expect(typeof result.value).toBe('string'); // Should be a level name + expect(result.warning).toContain('Mapped'); + }); + }); + + describe('Budget-type models (like Claude via agy)', () => { + // Claude models via agy use budget-type thinking + const budgetModel = 'gemini-claude-sonnet-4-5-thinking'; + + it('should accept valid numeric budget', () => { + const result = validateThinking('agy', budgetModel, 8192); + expect(result.valid).toBe(true); + expect(result.value).toBe(8192); + }); + + it('should accept numeric string budget', () => { + const result = validateThinking('agy', budgetModel, '8192'); + expect(result.valid).toBe(true); + expect(result.value).toBe(8192); + }); + + it('should reject negative budgets', () => { + const result = validateThinking('agy', budgetModel, -100); + expect(result.valid).toBe(false); + expect(result.warning).toContain('Negative'); + }); + + it('should clamp excessively high budgets', () => { + const result = validateThinking('agy', budgetModel, 999999999); + expect(result.valid).toBe(true); + expect(result.warning).toContain('Clamped'); + }); + + it('should reject partial numeric parses like "123abc"', () => { + const result = validateThinking('agy', budgetModel, '123abc'); + // Should either fail or find closest level match, not parse as 123 + expect(result.value).not.toBe(123); + }); + }); + + describe('Auto value', () => { + it('should accept auto value', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', 'auto'); + expect(result.valid).toBe(true); + expect(result.value).toBe('auto'); + }); + }); + + describe('Unknown models', () => { + it('should pass through value for unknown models with warning', () => { + const result = validateThinking('gemini', 'unknown-model-xyz', 'high'); + expect(result.valid).toBe(true); + expect(result.warning).toContain('unknown'); + }); + }); + + describe('Edge cases', () => { + it('should handle whitespace in input', () => { + const result = validateThinking('gemini', 'gemini-3-pro-preview', ' high '); + expect(result.valid).toBe(true); + }); + + it('should handle empty string by passing through for unknown', () => { + // Empty string on known model goes to level/budget validation + const result = validateThinking('gemini', 'gemini-3-pro-preview', ''); + // For level-type models, empty string will try to match levels + expect(result.valid).toBeDefined(); // Just check it doesn't crash + }); + }); +}); diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 0bc54a0c..e84b23d7 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -224,9 +224,11 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t return useQuery({ queryKey: ['account-quota', provider, accountId], queryFn: () => fetchAccountQuota(provider, accountId), - enabled: enabled && !!accountId, - staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime) + enabled: enabled && provider === 'agy' && !!accountId, + staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute + refetchOnWindowFocus: false, // Don't refetch on tab switch + refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders) retry: 1, }); } diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 13b25fc0..09c056ba 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -114,12 +114,34 @@ export const MODEL_CATALOGS: Record = { codex: { provider: 'codex', displayName: 'Codex', - defaultModel: 'gpt-5.1-codex-max', + defaultModel: 'gpt-5.2-codex', models: [ + { + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + description: 'Full reasoning support (xhigh)', + presetMapping: { + default: 'gpt-5.2-codex', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5.2-codex', + haiku: 'gpt-5-mini', + }, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: 'Fast, capped at high reasoning (no xhigh)', + presetMapping: { + default: 'gpt-5-mini', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', + }, + }, { id: 'gpt-5.1-codex-max', name: 'Codex Max (5.1)', - description: 'Most capable Codex model', + description: 'Legacy most capable Codex model', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max-high', diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index 5f0eabba..00aecab9 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,7 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server, KeyRound, Archive } from 'lucide-react'; +import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react'; import type { SettingsTab } from '../types'; interface TabNavigationProps { @@ -24,6 +24,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { Global Env + + + Thinking + Proxy diff --git a/ui/src/pages/settings/hooks.ts b/ui/src/pages/settings/hooks.ts index 3b9c20d8..f85f3d25 100644 --- a/ui/src/pages/settings/hooks.ts +++ b/ui/src/pages/settings/hooks.ts @@ -10,4 +10,5 @@ export { useGlobalEnvConfig, useProxyConfig, useRawConfig, + useThinkingConfig, } from './hooks/index'; diff --git a/ui/src/pages/settings/hooks/index.ts b/ui/src/pages/settings/hooks/index.ts index 2797059d..7dbfd881 100644 --- a/ui/src/pages/settings/hooks/index.ts +++ b/ui/src/pages/settings/hooks/index.ts @@ -8,3 +8,4 @@ export { useWebSearchConfig } from './use-websearch-config'; export { useGlobalEnvConfig } from './use-globalenv-config'; export { useProxyConfig } from './use-proxy-config'; export { useRawConfig } from './use-raw-config'; +export { useThinkingConfig } from './use-thinking-config'; diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 8644d45a..8efd3123 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -17,9 +17,11 @@ export function useSettingsTab() { ? 'proxy' : tabParam === 'auth' ? 'auth' - : tabParam === 'backups' - ? 'backups' - : 'websearch'; + : tabParam === 'thinking' + ? 'thinking' + : tabParam === 'backups' + ? 'backups' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/hooks/use-thinking-config.ts b/ui/src/pages/settings/hooks/use-thinking-config.ts new file mode 100644 index 00000000..4decf2f9 --- /dev/null +++ b/ui/src/pages/settings/hooks/use-thinking-config.ts @@ -0,0 +1,171 @@ +/** + * Thinking Config Hook + * Manages thinking budget configuration with direct API calls + * Includes W4 optimistic locking via lastModified timestamps + */ + +import { useCallback, useState, useRef } from 'react'; +import type { ThinkingConfig, ThinkingMode } from '../types'; + +const DEFAULT_THINKING_CONFIG: ThinkingConfig = { + mode: 'auto', + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + show_warnings: true, +}; + +const FETCH_TIMEOUT = 10000; // 10 second timeout + +export function useThinkingConfig() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + // W4: Track lastModified for optimistic locking + const lastModifiedRef = useRef(undefined); + + const fetchConfig = useCallback(async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + + try { + setLoading(true); + setError(null); + const res = await fetch('/api/thinking', { signal: controller.signal }); + clearTimeout(timeoutId); + if (!res.ok) { + // Handle HTML error pages gracefully + const contentType = res.headers.get('content-type'); + if (contentType?.includes('text/html')) { + throw new Error(`Server error (${res.status})`); + } + throw new Error('Failed to load Thinking config'); + } + const data = await res.json(); + setConfig(data.config || DEFAULT_THINKING_CONFIG); + // W4: Store lastModified for optimistic locking + lastModifiedRef.current = data.lastModified; + } catch (err) { + clearTimeout(timeoutId); + if ((err as Error).name === 'AbortError') { + setError('Request timeout - please try again'); + } else { + setError((err as Error).message); + } + setConfig(DEFAULT_THINKING_CONFIG); + } finally { + setLoading(false); + } + + // Return cleanup function for AbortController + return () => controller.abort(); + }, []); + + const saveConfig = useCallback( + async (updates: Partial) => { + const currentConfig = config || DEFAULT_THINKING_CONFIG; + const optimisticConfig = { ...currentConfig, ...updates }; + setConfig(optimisticConfig); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + + try { + setSaving(true); + setError(null); + + // W4: Include lastModified for optimistic locking + const payload = { + ...optimisticConfig, + lastModified: lastModifiedRef.current, + }; + + const res = await fetch('/api/thinking', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!res.ok) { + // Handle HTML error pages gracefully + const contentType = res.headers.get('content-type'); + if (contentType?.includes('text/html')) { + throw new Error(`Server error (${res.status})`); + } + const data = await res.json(); + // W4: Handle conflict (409) with user-friendly message + if (res.status === 409) { + throw new Error('Config changed by another session. Refreshing...'); + } + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + setConfig(data.config); + // W4: Update lastModified after successful save + lastModifiedRef.current = data.lastModified; + setSuccess(true); + setTimeout(() => setSuccess(false), 1500); + } catch (err) { + clearTimeout(timeoutId); + setConfig(currentConfig); + if ((err as Error).name === 'AbortError') { + setError('Request timeout - please try again'); + } else { + setError((err as Error).message); + // W4: On conflict, auto-refresh to get latest + if ((err as Error).message.includes('another session')) { + setTimeout(() => fetchConfig(), 1000); + } + } + } finally { + setSaving(false); + } + }, + [config, fetchConfig] + ); + + const setMode = useCallback( + (mode: ThinkingMode) => { + saveConfig({ mode }); + }, + [saveConfig] + ); + + const setTierDefault = useCallback( + (tier: 'opus' | 'sonnet' | 'haiku', value: string) => { + const currentDefaults = config?.tier_defaults || DEFAULT_THINKING_CONFIG.tier_defaults; + saveConfig({ + tier_defaults: { ...currentDefaults, [tier]: value }, + }); + }, + [config, saveConfig] + ); + + const setShowWarnings = useCallback( + (show: boolean) => { + saveConfig({ show_warnings: show }); + }, + [saveConfig] + ); + + return { + config: config || DEFAULT_THINKING_CONFIG, + loading, + saving, + error, + success, + fetchConfig, + saveConfig, + setMode, + setTierDefault, + setShowWarnings, + }; +} diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index 79806c9c..b7d447af 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -47,6 +47,7 @@ function lazyWithRetry>(importFn: () => Promise // Lazy-loaded sections with retry capability const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); +const ThinkingSection = lazyWithRetry(() => import('./sections/thinking')); const ProxySection = lazyWithRetry(() => import('./sections/proxy')); const AuthSection = lazyWithRetry(() => import('./sections/auth-section')); const BackupsSection = lazyWithRetry(() => import('./sections/backups-section')); @@ -128,6 +129,7 @@ function SettingsPageInner() { }> {activeTab === 'websearch' && } {activeTab === 'globalenv' && } + {activeTab === 'thinking' && } {activeTab === 'proxy' && } {activeTab === 'auth' && } {activeTab === 'backups' && } diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx new file mode 100644 index 00000000..6268e7fd --- /dev/null +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -0,0 +1,238 @@ +/** + * Thinking Section + * Settings section for thinking budget configuration + */ + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info } from 'lucide-react'; +import { useThinkingConfig } from '../../hooks'; +import type { ThinkingMode } from '../../types'; + +const THINKING_LEVELS = [ + { value: 'minimal', label: 'Minimal (512 tokens)' }, + { value: 'low', label: 'Low (1K tokens)' }, + { value: 'medium', label: 'Medium (8K tokens)' }, + { value: 'high', label: 'High (24K tokens)' }, + { value: 'xhigh', label: 'Extra High (32K tokens)' }, + { value: 'auto', label: 'Auto (dynamic)' }, +]; + +export default function ThinkingSection() { + const { + config, + loading, + saving, + error, + success, + fetchConfig, + setMode, + setTierDefault, + setShowWarnings, + } = useThinkingConfig(); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + +
+
+ + {error} +
+ {/* W1: Retry button on error */} + +
+
+ )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+
+ +

+ Configure extended thinking/reasoning for supported models. +

+
+ + {/* U4: Provider support indicator */} +
+ +
+

Supported Providers

+
    +
  • + Thinking budget: agy, gemini (token-based) +
  • +
  • + Reasoning effort: codex (level-based: medium/high/xhigh) +
  • +
+
+
+ + {/* Mode Selection */} +
+

Thinking Mode

+
+ {(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => ( +
setMode(mode)} + > +
+

{mode}

+

+ {mode === 'auto' && 'Automatically set thinking based on model tier'} + {mode === 'off' && 'Disable extended thinking'} + {mode === 'manual' && 'Use --thinking flag to control manually'} +

+
+
+
+ ))} +
+
+ + {/* Tier Defaults (only shown when mode is auto) */} + {config.mode === 'auto' && ( +
+

Tier Defaults

+

+ Default thinking level for each model tier when in auto mode. +

+ +
+ {(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( +
+ + +
+ ))} +
+
+ )} + + {/* Show Warnings Toggle */} +
+
+

Show Warnings

+

+ Display warnings when thinking values are clamped or adjusted +

+
+ +
+ + {/* Info Box */} +
+

CLI Override

+

+ Use --thinking flag to + override settings per session: +

+
+

ccs gemini --thinking high

+

ccs agy --thinking 16384

+

ccs gemini --thinking off

+
+
+
+ + + {/* Footer */} +
+ +
+ + ); +} diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 9e3ba4fc..c6ea66b9 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -49,7 +49,25 @@ export interface GlobalEnvConfig { // === Tab Types === -export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'backups'; +export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'thinking' | 'backups'; + +// === Thinking Types === + +export type ThinkingMode = 'auto' | 'off' | 'manual'; + +export interface ThinkingTierDefaults { + opus: string; + sonnet: string; + haiku: string; +} + +export interface ThinkingConfig { + mode: ThinkingMode; + override?: string | number; + tier_defaults: ThinkingTierDefaults; + provider_overrides?: Record>; + show_warnings?: boolean; +} // === Re-exports from api-client ===