From c48ed2ea7fe7fcf0725805274233bd5760f284d8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Feb 2026 12:45:23 +0700 Subject: [PATCH 1/3] feat(thinking): complete thinking UX/DX for all providers - Fix codex off-mode bug: add disableEffort to CodexReasoningProxy so mode=off actually skips reasoning injection - Add CCS_THINKING env var (priority: flag > env > config) - Add startup feedback: [i] Thinking: () - Add ccs config thinking CLI subcommand with --mode, --override, --tier, --provider-override, --clear-override - Dashboard: add manual override selector when mode=manual - Dashboard: add provider overrides collapsible section - Dashboard: update info box with CCS_THINKING env var docs - Update help-command.ts with CCS_THINKING and config thinking Closes #583 --- src/cliproxy/codex-reasoning-proxy.ts | 24 +- src/cliproxy/executor/index.ts | 52 +++- src/commands/config-command.ts | 14 + src/commands/config-thinking-command.ts | 279 ++++++++++++++++++ src/commands/help-command.ts | 3 + .../settings/hooks/use-thinking-config.ts | 38 +++ .../settings/sections/thinking/index.tsx | 117 +++++++- 7 files changed, 515 insertions(+), 12 deletions(-) create mode 100644 src/commands/config-thinking-command.ts diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 0558c2ab..0ab9b976 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -25,6 +25,8 @@ export interface CodexReasoningProxyConfig { * Example: '/api/provider/codex' will transform '/api/provider/codex/v1/messages' to '/v1/messages' */ stripPathPrefix?: string; + /** When true, skip reasoning effort injection entirely (thinking mode: off) */ + disableEffort?: boolean; } const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; @@ -147,7 +149,12 @@ export class CodexReasoningProxy { private readonly config: Required< Pick< CodexReasoningProxyConfig, - 'upstreamBaseUrl' | 'verbose' | 'timeoutMs' | 'defaultEffort' | 'traceFilePath' + | 'upstreamBaseUrl' + | 'verbose' + | 'timeoutMs' + | 'defaultEffort' + | 'traceFilePath' + | 'disableEffort' > > & Pick; @@ -170,6 +177,7 @@ export class CodexReasoningProxy { defaultEffort: config.defaultEffort ?? 'medium', traceFilePath: config.traceFilePath ?? '', stripPathPrefix: config.stripPathPrefix, + disableEffort: config.disableEffort ?? false, }; this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } @@ -330,6 +338,20 @@ export class CodexReasoningProxy { ? stripExtendedContextSuffix(originalModel) : null; + // When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning + if (this.config.disableEffort) { + const suffixParsed = normalizedRequestModel + ? parseModelEffortSuffix(normalizedRequestModel) + : null; + const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; + const forwarded = + upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; + + this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); + await this.forwardJson(req, res, fullUpstreamUrl, forwarded); + return; + } + // Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to: // - upstream model: `gpt-5.2-codex` // - reasoning.effort: `xhigh` diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index edc701e4..d3c87a64 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -49,7 +49,7 @@ import { installWebSearchHook, displayWebSearchStatus, } from '../../utils/websearch-manager'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; import { installImageAnalyzerHook } from '../../utils/hooks'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types'; @@ -354,7 +354,20 @@ export async function execClaudeWithCLIProxy( process.exit(1); } - const thinkingOverride = thinkingParse.value; + // Priority: CLI flag > CCS_THINKING env var > config.yaml + let thinkingOverride = thinkingParse.value; + let thinkingSource: 'flag' | 'env' | 'config' | undefined = + thinkingOverride !== undefined ? 'flag' : undefined; + + if (thinkingOverride === undefined && process.env.CCS_THINKING) { + const envVal = process.env.CCS_THINKING.trim(); + if (envVal) { + // Parse same as CLI: integer string → number, else string + thinkingOverride = /^-?\d+$/.test(envVal) ? Number.parseInt(envVal, 10) : envVal; + thinkingSource = 'env'; + } + } + if (thinkingParse.duplicateDisplays.length > 0) { console.warn( `[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}` @@ -804,10 +817,18 @@ export async function execClaudeWithCLIProxy( process.env.CCS_CODEX_REASONING_TRACE === '1' || process.env.CCS_CODEX_REASONING_TRACE === 'true'; const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; + const thinkingCfg = getThinkingConfig(); + const codexThinkingOff = + (thinkingCfg.mode === 'off' && thinkingOverride === undefined) || + thinkingOverride === 'off' || + (thinkingOverride === undefined && + thinkingCfg.mode === 'manual' && + thinkingCfg.override === 'off'); codexReasoningProxy = new CodexReasoningProxy({ upstreamBaseUrl: postSanitizationBaseUrl, verbose, defaultEffort: 'medium', + disableEffort: codexThinkingOff, traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '', modelMap: { defaultModel: initialEnvVars.ANTHROPIC_MODEL, @@ -875,6 +896,33 @@ export async function execClaudeWithCLIProxy( const webSearchEnv = getWebSearchHookEnv(); logEnvironment(env, webSearchEnv, verbose); + // 11b. Print thinking status feedback (TTY only, non-piped sessions) + if (process.stderr.isTTY) { + const thinkingCfgStatus = getThinkingConfig(); + let thinkingLabel: string; + let sourceLabel: string; + + if (thinkingOverride === 'off' || thinkingCfgStatus.mode === 'off') { + thinkingLabel = 'off'; + sourceLabel = + thinkingSource === 'flag' ? 'flag' : thinkingSource === 'env' ? 'env' : 'config'; + } else if (thinkingSource === 'flag') { + thinkingLabel = String(thinkingOverride); + sourceLabel = `flag: ${thinkingParse.sourceDisplay}`; + } else if (thinkingSource === 'env') { + thinkingLabel = String(thinkingOverride); + sourceLabel = 'env: CCS_THINKING'; + } else if (thinkingCfgStatus.mode === 'manual' && thinkingCfgStatus.override !== undefined) { + thinkingLabel = String(thinkingCfgStatus.override); + sourceLabel = 'config: manual'; + } else { + thinkingLabel = thinkingCfgStatus.mode === 'auto' ? 'auto' : 'default'; + sourceLabel = 'config: auto'; + } + + console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`); + } + // 12. Filter CCS-specific flags before passing to Claude CLI const ccsFlags = [ '--auth', diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 1db499e1..faffcaf7 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -68,6 +68,11 @@ function showHelp(): void { console.log(' --timeout Set analysis timeout (seconds)'); console.log(' --set-model

Set model for provider'); console.log(''); + console.log(' thinking Manage thinking/reasoning settings'); + console.log(' --mode Set mode (auto, off, manual)'); + console.log(' --override Set persistent override level'); + console.log(' --tier Set tier default level'); + console.log(''); console.log('Options:'); console.log(' --port, -p PORT Specify server port (default: auto-detect)'); console.log(' --dev Development mode with Vite HMR'); @@ -80,6 +85,8 @@ function showHelp(): void { console.log(' ccs config auth setup Configure dashboard login'); console.log(' ccs config image-analysis Show image settings'); console.log(' ccs config image-analysis --enable Enable feature'); + console.log(' ccs config thinking Show thinking settings'); + console.log(' ccs config thinking --mode auto Set auto mode'); console.log(''); } @@ -101,6 +108,13 @@ export async function handleConfigCommand(args: string[]): Promise { return; } + // Route thinking subcommand + if (args[0] === 'thinking') { + const { handleConfigThinkingCommand } = await import('./config-thinking-command'); + await handleConfigThinkingCommand(args.slice(1)); + return; + } + await initUI(); const options = parseArgs(args); diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts new file mode 100644 index 00000000..7b35e6a4 --- /dev/null +++ b/src/commands/config-thinking-command.ts @@ -0,0 +1,279 @@ +/** + * Config Thinking Command Handler + * + * Manages thinking section of config.yaml via CLI. + * Usage: ccs config thinking [options] + */ + +import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; +import { + getThinkingConfig, + updateUnifiedConfig, + loadOrCreateUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types'; +import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator'; + +const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const; + +interface ThinkingCommandOptions { + mode?: string; + override?: string; + clearOverride?: boolean; + tier?: { tier: string; level: string }; + providerOverride?: { provider: string; tier: string; level: string }; + help?: boolean; +} + +const VALID_TIERS = ['opus', 'sonnet', 'haiku'] as const; + +function parseArgs(args: string[]): ThinkingCommandOptions { + const options: ThinkingCommandOptions = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--mode' && args[i + 1]) { + options.mode = args[++i]; + } else if (arg === '--override' && args[i + 1]) { + options.override = args[++i]; + } else if (arg === '--clear-override') { + options.clearOverride = true; + } else if (arg === '--tier' && args[i + 1] && args[i + 2]) { + options.tier = { tier: args[++i], level: args[++i] }; + } else if (arg === '--provider-override' && args[i + 1] && args[i + 2] && args[i + 3]) { + options.providerOverride = { + provider: args[++i], + tier: args[++i], + level: args[++i], + }; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } + } + + return options; +} + +function showHelp(): void { + console.log(''); + console.log(header('ccs config thinking')); + console.log(''); + console.log(' Configure extended thinking/reasoning for CLIProxy providers.'); + console.log(''); + + console.log(subheader('Usage:')); + console.log(` ${color('ccs config thinking', 'command')} [options]`); + console.log(''); + + console.log(subheader('Options:')); + console.log( + ` ${color('--mode ', 'command')} Set mode (auto, off, manual)` + ); + console.log( + ` ${color('--override ', 'command')} Set persistent override (manual mode)` + ); + console.log( + ` ${color('--clear-override', 'command')} Remove persistent override` + ); + console.log( + ` ${color('--tier ', 'command')} Set tier default (opus/sonnet/haiku)` + ); + console.log( + ` ${color('--provider-override

', 'command')} Set provider-specific tier override` + ); + console.log(` ${color('--help, -h', 'command')} Show this help`); + console.log(''); + + console.log(subheader('Levels:')); + console.log( + ` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto, off')}` + ); + console.log(''); + + console.log(subheader('Examples:')); + console.log( + ` $ ${color('ccs config thinking', 'command')} ${dim('# Show status')}` + ); + console.log( + ` $ ${color('ccs config thinking --mode auto', 'command')} ${dim('# Auto mode')}` + ); + console.log( + ` $ ${color('ccs config thinking --mode manual --override high', 'command')} ${dim('# Persistent high')}` + ); + console.log( + ` $ ${color('ccs config thinking --tier opus xhigh', 'command')} ${dim('# Opus -> xhigh')}` + ); + console.log( + ` $ ${color('ccs config thinking --provider-override codex opus xhigh', 'command')}` + ); + console.log(''); + + console.log(subheader('Environment:')); + console.log( + ` ${color('CCS_THINKING', 'command')} Override per-session via env var (priority: flag > env > config)` + ); + console.log(` ${dim('Example: CCS_THINKING=high ccs codex "debug this"')}`); + console.log(''); +} + +function showStatus(): void { + const config = getThinkingConfig(); + + console.log(''); + console.log(header('Thinking Configuration')); + console.log(''); + + // Mode + const modeText = + config.mode === 'auto' ? ok('Auto') : config.mode === 'off' ? warn('Off') : info('Manual'); + console.log(` Mode: ${modeText}`); + + // Override + if (config.override !== undefined) { + console.log(` Override: ${color(String(config.override), 'command')}`); + } + + // Warnings + console.log(` Warnings: ${config.show_warnings !== false ? 'on' : 'off'}`); + console.log(''); + + // Tier defaults + console.log(subheader('Tier Defaults:')); + for (const tier of VALID_TIERS) { + const level = config.tier_defaults?.[tier] ?? DEFAULT_THINKING_TIER_DEFAULTS[tier]; + const isDefault = level === DEFAULT_THINKING_TIER_DEFAULTS[tier]; + const suffix = isDefault ? dim(' (default)') : ''; + console.log(` ${color(tier.padEnd(10), 'command')} ${level}${suffix}`); + } + console.log(''); + + // Provider overrides + const overrides = config.provider_overrides; + if (overrides && Object.keys(overrides).length > 0) { + console.log(subheader('Provider Overrides:')); + for (const [provider, tierOverrides] of Object.entries(overrides)) { + const parts = Object.entries(tierOverrides) + .map(([t, l]) => `${t}=${l}`) + .join(', '); + console.log(` ${color(provider.padEnd(10), 'command')} ${parts}`); + } + console.log(''); + } + + // Config location + console.log(subheader('Configuration:')); + console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`); + console.log(` Section: ${dim('thinking')}`); + console.log(''); + + // Env var hint + if (process.env.CCS_THINKING) { + console.log(info(`CCS_THINKING env var active: ${process.env.CCS_THINKING}`)); + console.log(''); + } +} + +export async function handleConfigThinkingCommand(args: string[]): Promise { + await initUI(); + + const options = parseArgs(args); + + if (options.help) { + showHelp(); + return; + } + + let hasChanges = false; + const config = loadOrCreateUnifiedConfig(); + const thinkingConfig = config.thinking ?? { + mode: 'auto' as const, + tier_defaults: { ...DEFAULT_THINKING_TIER_DEFAULTS }, + show_warnings: true, + }; + + // Validate and apply --mode + if (options.mode !== undefined) { + if (!(VALID_THINKING_MODES as readonly string[]).includes(options.mode)) { + console.error(fail(`Invalid mode: ${options.mode}`)); + console.error(info(`Valid modes: ${VALID_THINKING_MODES.join(', ')}`)); + process.exit(1); + } + thinkingConfig.mode = options.mode as 'auto' | 'off' | 'manual'; + hasChanges = true; + } + + // Validate and apply --override + if (options.override !== undefined) { + const normalized = options.override.toLowerCase().trim(); + if ( + !(VALID_THINKING_LEVELS as readonly string[]).includes(normalized) && + !/^\d+$/.test(normalized) + ) { + console.error(fail(`Invalid override: ${options.override}`)); + console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}, or a number`)); + process.exit(1); + } + thinkingConfig.override = /^\d+$/.test(normalized) + ? Number.parseInt(normalized, 10) + : normalized; + hasChanges = true; + } + + // Apply --clear-override + if (options.clearOverride) { + thinkingConfig.override = undefined; + hasChanges = true; + } + + // Validate and apply --tier + if (options.tier) { + const { tier, level } = options.tier; + if (!(VALID_TIERS as readonly string[]).includes(tier)) { + console.error(fail(`Invalid tier: ${tier}`)); + console.error(info(`Valid tiers: ${VALID_TIERS.join(', ')}`)); + process.exit(1); + } + if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level.toLowerCase())) { + console.error(fail(`Invalid level for ${tier}: ${level}`)); + console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}`)); + process.exit(1); + } + thinkingConfig.tier_defaults = { + ...DEFAULT_THINKING_TIER_DEFAULTS, + ...thinkingConfig.tier_defaults, + [tier]: level.toLowerCase(), + }; + hasChanges = true; + } + + // Validate and apply --provider-override + if (options.providerOverride) { + const { provider, tier, level } = options.providerOverride; + if (!(VALID_TIERS as readonly string[]).includes(tier)) { + console.error(fail(`Invalid tier: ${tier}`)); + process.exit(1); + } + if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level.toLowerCase())) { + console.error(fail(`Invalid level: ${level}`)); + process.exit(1); + } + thinkingConfig.provider_overrides = { + ...thinkingConfig.provider_overrides, + [provider]: { + ...thinkingConfig.provider_overrides?.[provider], + [tier]: level.toLowerCase(), + }, + }; + hasChanges = true; + } + + if (hasChanges) { + updateUnifiedConfig({ thinking: thinkingConfig }); + console.log(ok('Configuration updated')); + console.log(''); + } + + // Always show current status + showStatus(); +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 868d1060..2ee3fcb8 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -282,6 +282,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config auth show', 'Show dashboard auth status'], ['ccs config image-analysis', 'Show image analysis settings'], ['ccs config image-analysis --enable', 'Enable image analysis'], + ['ccs config thinking', 'Show thinking/reasoning settings'], + ['ccs config thinking --mode auto', 'Set thinking mode'], ['ccs config --port 3000', 'Use specific port'], ['ccs persist ', 'Write profile env to ~/.claude/settings.json'], ['ccs persist --list-backups', 'List available settings.json backups'], @@ -404,6 +406,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'], ['CCS_HOME', 'Override home directory (legacy, appends .ccs)'], ['CCS_DEBUG', 'Enable debug logging'], + ['CCS_THINKING', 'Override thinking level (flag > env > config)'], ]); // CLI Proxy env vars diff --git a/ui/src/pages/settings/hooks/use-thinking-config.ts b/ui/src/pages/settings/hooks/use-thinking-config.ts index 4decf2f9..d73ef659 100644 --- a/ui/src/pages/settings/hooks/use-thinking-config.ts +++ b/ui/src/pages/settings/hooks/use-thinking-config.ts @@ -156,6 +156,42 @@ export function useThinkingConfig() { [saveConfig] ); + const setOverride = useCallback( + (value: string | number | undefined) => { + saveConfig({ override: value }); + }, + [saveConfig] + ); + + const setProviderOverride = useCallback( + (provider: string, tier: 'opus' | 'sonnet' | 'haiku', level: string | undefined) => { + const currentOverrides = config?.provider_overrides || {}; + const currentProviderOverrides = currentOverrides[provider] || {}; + + if (level === undefined) { + // Remove the tier override + const { [tier]: _, ...rest } = currentProviderOverrides; + const updatedProvider = Object.keys(rest).length > 0 ? rest : undefined; + const { [provider]: __, ...otherProviders } = currentOverrides; + const updatedOverrides = updatedProvider + ? { ...otherProviders, [provider]: updatedProvider } + : otherProviders; + saveConfig({ + provider_overrides: + Object.keys(updatedOverrides).length > 0 ? updatedOverrides : undefined, + }); + } else { + saveConfig({ + provider_overrides: { + ...currentOverrides, + [provider]: { ...currentProviderOverrides, [tier]: level }, + }, + }); + } + }, + [config, saveConfig] + ); + return { config: config || DEFAULT_THINKING_CONFIG, loading, @@ -167,5 +203,7 @@ export function useThinkingConfig() { setMode, setTierDefault, setShowWarnings, + setOverride, + setProviderOverride, }; } diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index 91368a4b..181db25e 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -3,7 +3,7 @@ * Settings section for thinking budget configuration */ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -16,7 +16,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info } from 'lucide-react'; +import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info, ChevronDown } from 'lucide-react'; import { useThinkingConfig } from '../../hooks'; import type { ThinkingMode } from '../../types'; @@ -29,6 +29,14 @@ const THINKING_LEVELS = [ { value: 'auto', label: 'Auto (dynamic)' }, ]; +const OVERRIDE_LEVELS = [ + { value: '__none__', label: 'None (use CLI flags only)' }, + ...THINKING_LEVELS, + { value: 'off', label: 'Off (disable thinking)' }, +]; + +const KNOWN_PROVIDERS = ['agy', 'gemini', 'codex'] as const; + export default function ThinkingSection() { const { config, @@ -40,7 +48,10 @@ export default function ThinkingSection() { setMode, setTierDefault, setShowWarnings, + setOverride, + setProviderOverride, } = useThinkingConfig(); + const [providerOverridesOpen, setProviderOverridesOpen] = useState(false); useEffect(() => { fetchConfig(); @@ -146,7 +157,7 @@ export default function ThinkingSection() { {mode === 'auto' && 'Automatically set thinking based on model tier'} {mode === 'off' && 'Disable extended thinking'} {mode === 'manual' && - 'Use --thinking (agy/gemini) or --effort (codex) per run'} + 'Set a persistent override level or use CLI flags per run'}

)} + {/* Manual Override (shown when mode is manual) */} + {config.mode === 'manual' && ( +
+

Persistent Override

+

+ Applied to all sessions. CLI flags still take priority. +

+ +
+ )} + + {/* Provider Overrides (collapsible) */} +
+ + {providerOverridesOpen && ( +
+

+ Override tier defaults for specific providers. +

+ {KNOWN_PROVIDERS.map((provider) => ( +
+ +
+ {(['opus', 'sonnet', 'haiku'] as const).map((tier) => { + const currentValue = + config.provider_overrides?.[provider]?.[tier] || '__default__'; + return ( +
+ + +
+ ); + })} +
+
+ ))} +
+ )} +
+ {/* Show Warnings Toggle */}
@@ -212,17 +311,17 @@ export default function ThinkingSection() { {/* Info Box */}
-

CLI Override

+

CLI & Env Override

- Override per session with{' '} - --thinking (agy/gemini) or{' '} - --effort (codex): + Override per session with flags or{' '} + CCS_THINKING env var. + Priority: flag > env > config.

ccs gemini --thinking high

-

ccs agy --thinking 16384

ccs codex --effort xhigh

-

ccs codex -p "Refactor this module" --effort xhigh

+

CCS_THINKING=high ccs codex "debug this"

+

ccs config thinking --mode auto

From 92e2ec111d08aecb55e4461fccebc93c544439eb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Feb 2026 14:13:41 +0700 Subject: [PATCH 2/3] fix(thinking): harden codex reasoning controls across cli and dashboard --- src/cliproxy/codex-reasoning-proxy.ts | 35 +++- src/cliproxy/config/thinking-config.ts | 12 +- src/cliproxy/executor/index.ts | 55 ++---- .../executor/thinking-override-resolver.ts | 129 +++++++++++++++ src/cliproxy/thinking-validator.ts | 22 ++- src/commands/config-command.ts | 3 + src/commands/config-thinking-command.ts | 156 ++++++++++-------- src/commands/config-thinking-parser.ts | 109 ++++++++++++ src/commands/help-command.ts | 1 + src/web-server/routes/misc-routes.ts | 53 +++++- ...x-reasoning-proxy-extended-context.test.ts | 83 ++++++++++ .../unit/cliproxy/composite-thinking.test.ts | 18 ++ .../thinking-override-resolver.test.ts | 87 ++++++++++ .../commands/config-thinking-command.test.ts | 43 +++++ .../settings/hooks/use-thinking-config.ts | 50 +++++- .../settings/sections/thinking/index.tsx | 150 +++++++++++++++-- 16 files changed, 860 insertions(+), 146 deletions(-) create mode 100644 src/cliproxy/executor/thinking-override-resolver.ts create mode 100644 src/commands/config-thinking-parser.ts create mode 100644 tests/unit/cliproxy/thinking-override-resolver.test.ts create mode 100644 tests/unit/commands/config-thinking-command.test.ts diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 0ab9b976..57eb7e39 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -55,6 +55,17 @@ function parseModelEffortSuffix( return { upstreamModel, effort }; } +function isKnownCodexModelId( + model: string, + modelEffort: Map +): boolean { + if (modelEffort.has(model)) return true; + if (EFFORT_BY_RANK.some((effort) => modelEffort.has(`${model}-${effort}`))) { + return true; + } + return getModelMaxLevel('codex', model) !== undefined; +} + const EFFORT_RANK: Record = { medium: 1, high: 2, @@ -182,6 +193,22 @@ export class CodexReasoningProxy { this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } + /** + * Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models. + * Prevents stripping legitimate upstream model IDs that happen to end with those tokens. + */ + private parseEffortAlias( + model: string | null + ): { upstreamModel: string; effort: CodexReasoningEffort } | null { + if (!model) return null; + const parsed = parseModelEffortSuffix(model); + if (!parsed) return null; + if (!isKnownCodexModelId(parsed.upstreamModel, this.modelEffort)) { + return null; + } + return parsed; + } + private log(message: string): void { if (this.config.verbose) { console.error(`[codex-reasoning-proxy] ${message}`); @@ -340,9 +367,7 @@ export class CodexReasoningProxy { // When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning if (this.config.disableEffort) { - const suffixParsed = normalizedRequestModel - ? parseModelEffortSuffix(normalizedRequestModel) - : null; + const suffixParsed = this.parseEffortAlias(normalizedRequestModel); const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; const forwarded = upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; @@ -357,9 +382,7 @@ export class CodexReasoningProxy { // - reasoning.effort: `xhigh` // // This allows tier→effort mapping without inventing upstream model IDs. - const suffixParsed = normalizedRequestModel - ? parseModelEffortSuffix(normalizedRequestModel) - : null; + const suffixParsed = this.parseEffortAlias(normalizedRequestModel); const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; const effort = suffixParsed?.effort ?? diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 940c157e..840b87e3 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -7,7 +7,7 @@ import { CLIProxyProvider } from '../types'; import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; import { getThinkingConfig } from '../../config/unified-config-loader'; import { supportsThinking } from '../model-catalog'; -import { validateThinking } from '../thinking-validator'; +import { isThinkingOffValue, validateThinking } from '../thinking-validator'; import { warn } from '../../utils/ui'; /** Model tier types for thinking budget defaults */ @@ -169,10 +169,10 @@ export function applyThinkingConfig( // Explicit "off" (CLI override or manual config override) must disable ALL tier thinking. const explicitOffOverride = - thinkingOverride === 'off' || + isThinkingOffValue(thinkingOverride) || (thinkingOverride === undefined && thinkingConfig.mode === 'manual' && - thinkingConfig.override === 'off'); + isThinkingOffValue(thinkingConfig.override)); if (explicitOffOverride) { return result; } @@ -227,10 +227,10 @@ export function applyThinkingConfig( // If auto-detection resolves default tier to "off", skip the main model but still allow // explicit per-tier thinking values for other tiers. - if (thinkingValue === 'off') { + if (isThinkingOffValue(thinkingValue)) { const hasPerTierThinking = compositeTierThinking && - Object.values(compositeTierThinking).some((v) => v !== undefined && v !== 'off'); + Object.values(compositeTierThinking).some((v) => v !== undefined && !isThinkingOffValue(v)); if (!hasPerTierThinking) { return result; // No thinking to apply anywhere } @@ -288,7 +288,7 @@ export function applyThinkingConfig( } // If per-tier thinking is 'off', skip this tier - if (tierThinkingValue === 'off') { + if (isThinkingOffValue(tierThinkingValue)) { continue; } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d3c87a64..c61add33 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -73,6 +73,11 @@ import { restoreAutoPausedAccounts, } from '../account-safety'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; +import { + buildThinkingStartupStatus, + resolveRuntimeThinkingOverride, + shouldDisableCodexReasoning, +} from './thinking-override-resolver'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -354,19 +359,10 @@ export async function execClaudeWithCLIProxy( process.exit(1); } - // Priority: CLI flag > CCS_THINKING env var > config.yaml - let thinkingOverride = thinkingParse.value; - let thinkingSource: 'flag' | 'env' | 'config' | undefined = - thinkingOverride !== undefined ? 'flag' : undefined; - - if (thinkingOverride === undefined && process.env.CCS_THINKING) { - const envVal = process.env.CCS_THINKING.trim(); - if (envVal) { - // Parse same as CLI: integer string → number, else string - thinkingOverride = /^-?\d+$/.test(envVal) ? Number.parseInt(envVal, 10) : envVal; - thinkingSource = 'env'; - } - } + const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride( + thinkingParse.value, + process.env.CCS_THINKING + ); if (thinkingParse.duplicateDisplays.length > 0) { console.warn( @@ -818,12 +814,7 @@ export async function execClaudeWithCLIProxy( process.env.CCS_CODEX_REASONING_TRACE === 'true'; const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; const thinkingCfg = getThinkingConfig(); - const codexThinkingOff = - (thinkingCfg.mode === 'off' && thinkingOverride === undefined) || - thinkingOverride === 'off' || - (thinkingOverride === undefined && - thinkingCfg.mode === 'manual' && - thinkingCfg.override === 'off'); + const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); codexReasoningProxy = new CodexReasoningProxy({ upstreamBaseUrl: postSanitizationBaseUrl, verbose, @@ -899,26 +890,12 @@ export async function execClaudeWithCLIProxy( // 11b. Print thinking status feedback (TTY only, non-piped sessions) if (process.stderr.isTTY) { const thinkingCfgStatus = getThinkingConfig(); - let thinkingLabel: string; - let sourceLabel: string; - - if (thinkingOverride === 'off' || thinkingCfgStatus.mode === 'off') { - thinkingLabel = 'off'; - sourceLabel = - thinkingSource === 'flag' ? 'flag' : thinkingSource === 'env' ? 'env' : 'config'; - } else if (thinkingSource === 'flag') { - thinkingLabel = String(thinkingOverride); - sourceLabel = `flag: ${thinkingParse.sourceDisplay}`; - } else if (thinkingSource === 'env') { - thinkingLabel = String(thinkingOverride); - sourceLabel = 'env: CCS_THINKING'; - } else if (thinkingCfgStatus.mode === 'manual' && thinkingCfgStatus.override !== undefined) { - thinkingLabel = String(thinkingCfgStatus.override); - sourceLabel = 'config: manual'; - } else { - thinkingLabel = thinkingCfgStatus.mode === 'auto' ? 'auto' : 'default'; - sourceLabel = 'config: auto'; - } + const { thinkingLabel, sourceLabel } = buildThinkingStartupStatus( + thinkingCfgStatus, + thinkingOverride, + thinkingSource, + thinkingParse.sourceDisplay + ); console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`); } diff --git a/src/cliproxy/executor/thinking-override-resolver.ts b/src/cliproxy/executor/thinking-override-resolver.ts new file mode 100644 index 00000000..e0957710 --- /dev/null +++ b/src/cliproxy/executor/thinking-override-resolver.ts @@ -0,0 +1,129 @@ +import type { ThinkingConfig } from '../../config/unified-config-types'; +import { + isThinkingOffValue, + THINKING_BUDGET_MAX, + THINKING_BUDGET_MIN, + VALID_THINKING_LEVELS, +} from '../thinking-validator'; + +export type RuntimeThinkingSource = 'flag' | 'env' | 'config' | undefined; + +/** + * Parse CCS_THINKING env value using same rules as CLI parsing: + * integer string => number, known level/off aliases => normalized string. + * Unknown/invalid values are ignored to preserve config fallback behavior. + */ +export function parseEnvThinkingOverride(raw: string | undefined): string | number | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + if (/^-?\d+$/.test(trimmed)) { + const parsed = Number.parseInt(trimmed, 10); + if (parsed < THINKING_BUDGET_MIN || parsed > THINKING_BUDGET_MAX) { + return undefined; + } + return parsed; + } + + const normalized = trimmed.toLowerCase(); + if (isThinkingOffValue(normalized)) { + return 'off'; + } + if ((VALID_THINKING_LEVELS as readonly string[]).includes(normalized)) { + return normalized; + } + return undefined; +} + +/** + * Runtime precedence: CLI flag > CCS_THINKING env var. + * Config is handled later during model/env resolution. + */ +export function resolveRuntimeThinkingOverride( + flagOverride: string | number | undefined, + envValue: string | undefined +): { thinkingOverride: string | number | undefined; thinkingSource: RuntimeThinkingSource } { + if (flagOverride !== undefined) { + return { thinkingOverride: flagOverride, thinkingSource: 'flag' }; + } + const envOverride = parseEnvThinkingOverride(envValue); + if (envOverride !== undefined) { + return { thinkingOverride: envOverride, thinkingSource: 'env' }; + } + return { thinkingOverride: undefined, thinkingSource: undefined }; +} + +/** + * Effective off logic for codex reasoning proxy wiring. + */ +export function shouldDisableCodexReasoning( + thinkingConfig: ThinkingConfig, + thinkingOverride: string | number | undefined +): boolean { + return ( + (thinkingConfig.mode === 'off' && thinkingOverride === undefined) || + isThinkingOffValue(thinkingOverride) || + (thinkingOverride === undefined && + thinkingConfig.mode === 'manual' && + isThinkingOffValue(thinkingConfig.override)) + ); +} + +/** + * Build user-facing startup feedback label/source based on effective precedence. + */ +export function buildThinkingStartupStatus( + thinkingConfig: ThinkingConfig, + thinkingOverride: string | number | undefined, + thinkingSource: RuntimeThinkingSource, + sourceDisplay?: string +): { thinkingLabel: string; sourceLabel: string } { + const overrideDisablesThinking = isThinkingOffValue(thinkingOverride); + const configDisablesThinking = + thinkingOverride === undefined && + (thinkingConfig.mode === 'off' || + (thinkingConfig.mode === 'manual' && isThinkingOffValue(thinkingConfig.override))); + + if (overrideDisablesThinking || configDisablesThinking) { + if (thinkingSource === 'flag') { + return { + thinkingLabel: 'off', + sourceLabel: `flag: ${sourceDisplay ?? '--thinking off'}`, + }; + } + if (thinkingSource === 'env') { + return { + thinkingLabel: 'off', + sourceLabel: 'env: CCS_THINKING', + }; + } + return { + thinkingLabel: 'off', + sourceLabel: thinkingConfig.mode === 'manual' ? 'config: manual' : 'config: off', + }; + } + + if (thinkingSource === 'flag') { + return { + thinkingLabel: String(thinkingOverride), + sourceLabel: `flag: ${sourceDisplay ?? '--thinking'}`, + }; + } + if (thinkingSource === 'env') { + return { + thinkingLabel: String(thinkingOverride), + sourceLabel: 'env: CCS_THINKING', + }; + } + if (thinkingConfig.mode === 'manual' && thinkingConfig.override !== undefined) { + return { + thinkingLabel: String(thinkingConfig.override), + sourceLabel: 'config: manual', + }; + } + return { + thinkingLabel: thinkingConfig.mode === 'auto' ? 'auto' : 'default', + sourceLabel: 'config: auto', + }; +} diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index 29a2a7d3..8b14ed95 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -86,6 +86,21 @@ export function capLevelAtMax( export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const; export const THINKING_AUTO_VALUE = 'auto'; +/** + * Check whether a value should disable thinking. + * Accepts common string aliases and numeric 0. + */ +export function isThinkingOffValue(value: unknown): boolean { + if (typeof value === 'number') { + return value === 0; + } + if (typeof value === 'string') { + const normalized = value.toLowerCase().trim(); + return THINKING_OFF_VALUES.includes(normalized as (typeof THINKING_OFF_VALUES)[number]); + } + return false; +} + /** * Find closest valid level using simple string matching * Returns undefined if no close match found @@ -158,11 +173,8 @@ export function validateThinking( } // 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 (isThinkingOffValue(value)) { + return { valid: true, value: 'off' }; } // If model has no thinking support info, pass through diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index faffcaf7..906fac3d 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -71,7 +71,10 @@ function showHelp(): void { console.log(' thinking Manage thinking/reasoning settings'); console.log(' --mode Set mode (auto, off, manual)'); console.log(' --override Set persistent override level'); + console.log(' --clear-override Remove persistent override'); console.log(' --tier Set tier default level'); + console.log(' --provider-override

Set provider tier override'); + console.log(' --clear-provider-override

[t] Remove provider override'); console.log(''); console.log('Options:'); console.log(' --port, -p PORT Specify server port (default: auto-detect)'); diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts index 7b35e6a4..c1b27b4d 100644 --- a/src/commands/config-thinking-command.ts +++ b/src/commands/config-thinking-command.ts @@ -13,47 +13,13 @@ import { } from '../config/unified-config-loader'; import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types'; import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator'; +import { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser'; const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const; -interface ThinkingCommandOptions { - mode?: string; - override?: string; - clearOverride?: boolean; - tier?: { tier: string; level: string }; - providerOverride?: { provider: string; tier: string; level: string }; - help?: boolean; -} - const VALID_TIERS = ['opus', 'sonnet', 'haiku'] as const; - -function parseArgs(args: string[]): ThinkingCommandOptions { - const options: ThinkingCommandOptions = {}; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === '--mode' && args[i + 1]) { - options.mode = args[++i]; - } else if (arg === '--override' && args[i + 1]) { - options.override = args[++i]; - } else if (arg === '--clear-override') { - options.clearOverride = true; - } else if (arg === '--tier' && args[i + 1] && args[i + 2]) { - options.tier = { tier: args[++i], level: args[++i] }; - } else if (arg === '--provider-override' && args[i + 1] && args[i + 2] && args[i + 3]) { - options.providerOverride = { - provider: args[++i], - tier: args[++i], - level: args[++i], - }; - } else if (arg === '--help' || arg === '-h') { - options.help = true; - } - } - - return options; -} +type ThinkingTier = (typeof VALID_TIERS)[number]; +export { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser'; function showHelp(): void { console.log(''); @@ -82,6 +48,9 @@ function showHelp(): void { console.log( ` ${color('--provider-override

', 'command')} Set provider-specific tier override` ); + console.log( + ` ${color('--clear-provider-override

[t]', 'command')} Remove provider override (provider or tier)` + ); console.log(` ${color('--help, -h', 'command')} Show this help`); console.log(''); @@ -107,6 +76,9 @@ function showHelp(): void { console.log( ` $ ${color('ccs config thinking --provider-override codex opus xhigh', 'command')}` ); + console.log( + ` $ ${color('ccs config thinking --clear-provider-override codex opus', 'command')}` + ); console.log(''); console.log(subheader('Environment:')); @@ -177,7 +149,12 @@ function showStatus(): void { export async function handleConfigThinkingCommand(args: string[]): Promise { await initUI(); - const options = parseArgs(args); + const { options, error } = parseThinkingCommandArgs(args); + if (error) { + console.error(fail(error)); + process.exitCode = 1; + return; + } if (options.help) { showHelp(); @@ -194,29 +171,27 @@ export async function handleConfigThinkingCommand(args: string[]): Promise // Validate and apply --mode if (options.mode !== undefined) { - if (!(VALID_THINKING_MODES as readonly string[]).includes(options.mode)) { + const normalizedMode = options.mode.trim().toLowerCase(); + if (!(VALID_THINKING_MODES as readonly string[]).includes(normalizedMode)) { console.error(fail(`Invalid mode: ${options.mode}`)); console.error(info(`Valid modes: ${VALID_THINKING_MODES.join(', ')}`)); - process.exit(1); + process.exitCode = 1; + return; } - thinkingConfig.mode = options.mode as 'auto' | 'off' | 'manual'; + thinkingConfig.mode = normalizedMode as 'auto' | 'off' | 'manual'; hasChanges = true; } // Validate and apply --override if (options.override !== undefined) { - const normalized = options.override.toLowerCase().trim(); - if ( - !(VALID_THINKING_LEVELS as readonly string[]).includes(normalized) && - !/^\d+$/.test(normalized) - ) { - console.error(fail(`Invalid override: ${options.override}`)); + const parsedOverride = parseThinkingOverrideInput(options.override); + if (parsedOverride.error) { + console.error(fail(parsedOverride.error)); console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}, or a number`)); - process.exit(1); + process.exitCode = 1; + return; } - thinkingConfig.override = /^\d+$/.test(normalized) - ? Number.parseInt(normalized, 10) - : normalized; + thinkingConfig.override = parsedOverride.value; hasChanges = true; } @@ -228,46 +203,97 @@ export async function handleConfigThinkingCommand(args: string[]): Promise // Validate and apply --tier if (options.tier) { - const { tier, level } = options.tier; + const tier = options.tier.tier.toLowerCase().trim(); + const level = options.tier.level.toLowerCase().trim(); if (!(VALID_TIERS as readonly string[]).includes(tier)) { - console.error(fail(`Invalid tier: ${tier}`)); + console.error(fail(`Invalid tier: ${options.tier.tier}`)); console.error(info(`Valid tiers: ${VALID_TIERS.join(', ')}`)); - process.exit(1); + process.exitCode = 1; + return; } - if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level.toLowerCase())) { - console.error(fail(`Invalid level for ${tier}: ${level}`)); + if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level)) { + console.error(fail(`Invalid level for ${tier}: ${options.tier.level}`)); console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}`)); - process.exit(1); + process.exitCode = 1; + return; } thinkingConfig.tier_defaults = { ...DEFAULT_THINKING_TIER_DEFAULTS, ...thinkingConfig.tier_defaults, - [tier]: level.toLowerCase(), + [tier]: level, }; hasChanges = true; } // Validate and apply --provider-override if (options.providerOverride) { - const { provider, tier, level } = options.providerOverride; + const provider = options.providerOverride.provider.trim().toLowerCase(); + const tier = options.providerOverride.tier.trim().toLowerCase(); + const level = options.providerOverride.level.trim().toLowerCase(); + if (!provider) { + console.error(fail('Provider name cannot be empty')); + process.exitCode = 1; + return; + } if (!(VALID_TIERS as readonly string[]).includes(tier)) { - console.error(fail(`Invalid tier: ${tier}`)); - process.exit(1); + console.error(fail(`Invalid tier: ${options.providerOverride.tier}`)); + process.exitCode = 1; + return; } - if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level.toLowerCase())) { - console.error(fail(`Invalid level: ${level}`)); - process.exit(1); + if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level)) { + console.error(fail(`Invalid level: ${options.providerOverride.level}`)); + process.exitCode = 1; + return; } + const normalizedTier = tier as ThinkingTier; thinkingConfig.provider_overrides = { ...thinkingConfig.provider_overrides, [provider]: { ...thinkingConfig.provider_overrides?.[provider], - [tier]: level.toLowerCase(), + [normalizedTier]: level, }, }; hasChanges = true; } + // Validate and apply --clear-provider-override + if (options.clearProviderOverride) { + const provider = options.clearProviderOverride.provider.trim().toLowerCase(); + const tier = options.clearProviderOverride.tier?.trim().toLowerCase(); + if (!provider) { + console.error(fail('Provider name cannot be empty')); + process.exitCode = 1; + return; + } + if (tier && !(VALID_TIERS as readonly string[]).includes(tier)) { + console.error(fail(`Invalid tier: ${options.clearProviderOverride.tier}`)); + console.error(info(`Valid tiers: ${VALID_TIERS.join(', ')}`)); + process.exitCode = 1; + return; + } + const currentOverrides = thinkingConfig.provider_overrides ?? {}; + const nextOverrides = { ...currentOverrides }; + if (!nextOverrides[provider]) { + // no-op, but still considered change request to keep command deterministic + hasChanges = true; + } else if (!tier) { + delete nextOverrides[provider]; + hasChanges = true; + } else { + const normalizedTier = tier as ThinkingTier; + const providerEntry = { ...nextOverrides[provider] }; + delete providerEntry[normalizedTier]; + if (Object.keys(providerEntry).length === 0) { + delete nextOverrides[provider]; + } else { + nextOverrides[provider] = providerEntry; + } + hasChanges = true; + } + thinkingConfig.provider_overrides = + Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; + } + if (hasChanges) { updateUnifiedConfig({ thinking: thinkingConfig }); console.log(ok('Configuration updated')); diff --git a/src/commands/config-thinking-parser.ts b/src/commands/config-thinking-parser.ts new file mode 100644 index 00000000..2d82c253 --- /dev/null +++ b/src/commands/config-thinking-parser.ts @@ -0,0 +1,109 @@ +import { + isThinkingOffValue, + THINKING_BUDGET_MAX, + THINKING_BUDGET_MIN, + VALID_THINKING_LEVELS, +} from '../cliproxy/thinking-validator'; + +interface ThinkingCommandOptions { + mode?: string; + override?: string; + clearOverride?: boolean; + tier?: { tier: string; level: string }; + providerOverride?: { provider: string; tier: string; level: string }; + clearProviderOverride?: { provider: string; tier?: string }; + help?: boolean; +} + +export interface ParseResult { + options: ThinkingCommandOptions; + error?: string; +} + +export function parseThinkingCommandArgs(args: string[]): ParseResult { + const options: ThinkingCommandOptions = {}; + const requireValue = (index: number): string | undefined => { + const value = args[index]; + if (!value || value.startsWith('-')) { + return undefined; + } + return value; + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--mode') { + const value = requireValue(i + 1); + if (!value) return { options, error: `${arg} requires a value` }; + options.mode = value; + i += 1; + } else if (arg === '--override') { + const value = requireValue(i + 1); + if (!value) return { options, error: `${arg} requires a value` }; + options.override = value; + i += 1; + } else if (arg === '--clear-override') { + options.clearOverride = true; + } else if (arg === '--tier') { + const tier = requireValue(i + 1); + const level = requireValue(i + 2); + if (!tier || !level) return { options, error: `${arg} requires 2 values: ` }; + options.tier = { tier, level }; + i += 2; + } else if (arg === '--provider-override') { + const provider = requireValue(i + 1); + const tier = requireValue(i + 2); + const level = requireValue(i + 3); + if (!provider || !tier || !level) { + return { options, error: `${arg} requires 3 values: ` }; + } + options.providerOverride = { + provider, + tier, + level, + }; + i += 3; + } else if (arg === '--clear-provider-override') { + const provider = requireValue(i + 1); + if (!provider) + return { options, error: `${arg} requires at least 1 value: [tier]` }; + const tier = requireValue(i + 2); + options.clearProviderOverride = { provider, tier }; + i += tier ? 2 : 1; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg.startsWith('-')) { + return { options, error: `Unknown option: ${arg}` }; + } else { + return { options, error: `Unexpected argument: ${arg}` }; + } + } + + return { options }; +} + +export function parseThinkingOverrideInput(rawOverride: string): { + value?: string | number; + error?: string; +} { + const normalized = rawOverride.toLowerCase().trim(); + if (isThinkingOffValue(normalized)) { + return { value: 'off' }; + } + if ((VALID_THINKING_LEVELS as readonly string[]).includes(normalized)) { + return { value: normalized }; + } + if (/^\d+$/.test(normalized)) { + const budget = Number.parseInt(normalized, 10); + if (budget < THINKING_BUDGET_MIN || budget > THINKING_BUDGET_MAX) { + return { + error: `Invalid override: numeric budget must be between ${THINKING_BUDGET_MIN} and ${THINKING_BUDGET_MAX}`, + }; + } + return { value: budget }; + } + return { + error: `Invalid override: ${rawOverride}`, + }; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 2ee3fcb8..eecffc8b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -284,6 +284,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config image-analysis --enable', 'Enable image analysis'], ['ccs config thinking', 'Show thinking/reasoning settings'], ['ccs config thinking --mode auto', 'Set thinking mode'], + ['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'], ['ccs config --port 3000', 'Use specific port'], ['ccs persist ', 'Write profile env to ~/.claude/settings.json'], ['ccs persist --list-backups', 'List available settings.json backups'], diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index 98f933fe..8d680051 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -261,8 +261,17 @@ router.get('/thinking', (_req: Request, res: Response): void => { */ router.put('/thinking', (req: Request, res: Response): void => { try { - const { lastModified, ...updates } = req.body as Partial & { + const { + lastModified, + clear_override: clearOverrideFlag, + clear_provider_overrides: clearProviderOverridesFlag, + ...updates + } = req.body as Omit, 'override' | 'provider_overrides'> & { lastModified?: number; + override?: string | number | null; + provider_overrides?: Record> | null; + clear_override?: boolean; + clear_provider_overrides?: boolean; }; // W4: Optimistic locking - check if file was modified since last read @@ -282,18 +291,30 @@ router.put('/thinking', (req: Request, res: Response): void => { } const config = loadOrCreateUnifiedConfig(); + const shouldClearOverride = clearOverrideFlag === true || updates.override === null; + const shouldClearProviderOverrides = + clearProviderOverridesFlag === true || updates.provider_overrides === null; + let normalizedOverride: string | number | undefined = config.thinking?.override as + | string + | number + | undefined; + let normalizedProviderOverrides: + | Record> + | undefined; // Validate mode if provided if (updates.mode !== undefined) { const validModes = ['auto', 'off', 'manual']; - if (!validModes.includes(updates.mode)) { + const normalizedMode = updates.mode.toLowerCase().trim(); + if (!validModes.includes(normalizedMode)) { res.status(400).json({ error: `Invalid mode: must be one of ${validModes.join(', ')}` }); return; } + updates.mode = normalizedMode as ThinkingConfig['mode']; } // Validate override if provided (budget or level) - if (updates.override !== undefined) { + if (updates.override !== undefined && updates.override !== null) { // C3: Reject objects/arrays - only number or string allowed if (typeof updates.override !== 'number' && typeof updates.override !== 'string') { res.status(400).json({ @@ -313,6 +334,7 @@ router.put('/thinking', (req: Request, res: Response): void => { }); return; } + normalizedOverride = updates.override; } else if (typeof updates.override === 'string') { const normalizedValue = updates.override.toLowerCase().trim(); const validValues = [...VALID_THINKING_LEVELS, ...THINKING_OFF_VALUES] as readonly string[]; @@ -322,6 +344,7 @@ router.put('/thinking', (req: Request, res: Response): void => { }); return; } + normalizedOverride = normalizedValue === '0' ? 'off' : normalizedValue; } } @@ -351,7 +374,7 @@ router.put('/thinking', (req: Request, res: Response): void => { } // C4: Validate provider_overrides if provided (nested structure: Record>) - if (updates.provider_overrides !== undefined) { + if (updates.provider_overrides !== undefined && updates.provider_overrides !== null) { if ( typeof updates.provider_overrides !== 'object' || updates.provider_overrides === null || @@ -362,6 +385,7 @@ router.put('/thinking', (req: Request, res: Response): void => { } const validLevels = [...VALID_THINKING_LEVELS] as string[]; const validTiers = [...VALID_THINKING_TIERS] as string[]; + const sanitizedOverrides: Record> = {}; for (const [provider, tierOverrides] of Object.entries(updates.provider_overrides)) { if (typeof provider !== 'string' || provider.trim() === '') { res @@ -383,26 +407,41 @@ router.put('/thinking', (req: Request, res: Response): void => { }); return; } - if (typeof level !== 'string' || !validLevels.includes(level)) { + if (typeof level !== 'string' || !validLevels.includes(level.toLowerCase().trim())) { res.status(400).json({ error: `Invalid level for provider_overrides.${provider}.${tier}: must be one of ${validLevels.join(', ')}`, }); return; } + const normalizedProvider = provider.trim().toLowerCase(); + const normalizedTier = tier.trim().toLowerCase() as keyof ThinkingConfig['tier_defaults']; + const normalizedLevel = level.toLowerCase().trim(); + sanitizedOverrides[normalizedProvider] = sanitizedOverrides[normalizedProvider] ?? {}; + sanitizedOverrides[normalizedProvider][normalizedTier] = normalizedLevel; } } + normalizedProviderOverrides = + Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined; } // Update thinking section config.thinking = { mode: updates.mode ?? config.thinking?.mode ?? 'auto', - override: updates.override ?? config.thinking?.override, + override: shouldClearOverride + ? undefined + : updates.override !== undefined + ? normalizedOverride + : 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, + provider_overrides: shouldClearProviderOverrides + ? undefined + : updates.provider_overrides !== undefined + ? normalizedProviderOverrides + : config.thinking?.provider_overrides, show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true, }; diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 1e88c258..09eb8c53 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -141,4 +141,87 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(capturedBody?.model).toBe('gpt-5.3-codex'); expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high'); }); + + it('skips reasoning injection when disableEffort is enabled', async () => { + let capturedBody: JsonRecord | null = null; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + sonnetModel: 'gpt-5.3-codex-high', + }, + disableEffort: true, + }); + + const proxyPort = await proxy.start(); + const response = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.3-codex-high', + messages: [], + } + ); + + proxy.stop(); + + expect(response.statusCode).toBe(200); + expect(capturedBody?.model).toBe('gpt-5.3-codex'); + expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined(); + }); + + it('does not strip unknown model ids that merely end with "-high"', async () => { + let capturedBody: JsonRecord | null = null; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + defaultModel: 'gpt-5.1-codex-mini', + }, + defaultEffort: 'medium', + }); + + const proxyPort = await proxy.start(); + const response = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'enterprise-internal-high', + messages: [], + } + ); + + proxy.stop(); + + expect(response.statusCode).toBe(200); + expect(capturedBody?.model).toBe('enterprise-internal-high'); + }); }); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index f217de64..4f66dad4 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -311,6 +311,24 @@ describe('applyThinkingConfig - composite variant integration', () => { expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); }); + it('treats off override aliases case-insensitively', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'OFF', { + opus: 'xhigh', + sonnet: 'high', + }); + + expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking'); + }); + it('uses per-tier provider capability checks for mixed-provider composites', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro', diff --git a/tests/unit/cliproxy/thinking-override-resolver.test.ts b/tests/unit/cliproxy/thinking-override-resolver.test.ts new file mode 100644 index 00000000..7a86c1d8 --- /dev/null +++ b/tests/unit/cliproxy/thinking-override-resolver.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'bun:test'; +import type { ThinkingConfig } from '../../../src/config/unified-config-types'; +import { + buildThinkingStartupStatus, + parseEnvThinkingOverride, + resolveRuntimeThinkingOverride, + shouldDisableCodexReasoning, +} from '../../../src/cliproxy/executor/thinking-override-resolver'; + +const baseConfig: ThinkingConfig = { + mode: 'auto', + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + show_warnings: true, +}; + +describe('thinking-override-resolver', () => { + it('parses env thinking values with CLI-compatible integer handling', () => { + expect(parseEnvThinkingOverride(undefined)).toBeUndefined(); + expect(parseEnvThinkingOverride(' ')).toBeUndefined(); + expect(parseEnvThinkingOverride('8192')).toBe(8192); + expect(parseEnvThinkingOverride(' OFF ')).toBe('off'); + expect(parseEnvThinkingOverride('bogus')).toBeUndefined(); + expect(parseEnvThinkingOverride('-1')).toBeUndefined(); + expect(parseEnvThinkingOverride('100001')).toBeUndefined(); + }); + + it('resolves runtime priority as flag > env', () => { + expect(resolveRuntimeThinkingOverride('high', 'low')).toEqual({ + thinkingOverride: 'high', + thinkingSource: 'flag', + }); + expect(resolveRuntimeThinkingOverride(undefined, 'xhigh')).toEqual({ + thinkingOverride: 'xhigh', + thinkingSource: 'env', + }); + expect(resolveRuntimeThinkingOverride(undefined, 'invalid')).toEqual({ + thinkingOverride: undefined, + thinkingSource: undefined, + }); + }); + + it('disables codex reasoning for off aliases regardless of case', () => { + expect(shouldDisableCodexReasoning(baseConfig, 'OFF')).toBe(true); + expect( + shouldDisableCodexReasoning( + { + ...baseConfig, + mode: 'off', + }, + 'high' + ) + ).toBe(false); + expect( + shouldDisableCodexReasoning( + { + ...baseConfig, + mode: 'manual', + override: 'off', + }, + undefined + ) + ).toBe(true); + }); + + it('builds startup status from effective precedence instead of raw config mode', () => { + const offConfig: ThinkingConfig = { ...baseConfig, mode: 'off' }; + + expect(buildThinkingStartupStatus(offConfig, 'high', 'env')).toEqual({ + thinkingLabel: 'high', + sourceLabel: 'env: CCS_THINKING', + }); + + expect(buildThinkingStartupStatus(offConfig, undefined, undefined)).toEqual({ + thinkingLabel: 'off', + sourceLabel: 'config: off', + }); + + expect(buildThinkingStartupStatus(baseConfig, 'off', 'flag', '--effort off')).toEqual({ + thinkingLabel: 'off', + sourceLabel: 'flag: --effort off', + }); + }); +}); diff --git a/tests/unit/commands/config-thinking-command.test.ts b/tests/unit/commands/config-thinking-command.test.ts new file mode 100644 index 00000000..a549c99b --- /dev/null +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test'; +import { + parseThinkingCommandArgs, + parseThinkingOverrideInput, +} from '../../../src/commands/config-thinking-command'; + +describe('config thinking command parser', () => { + it('rejects missing required option values', () => { + const result = parseThinkingCommandArgs(['--mode']); + expect(result.error).toBe('--mode requires a value'); + }); + + it('rejects unknown options', () => { + const result = parseThinkingCommandArgs(['--unknown-flag']); + expect(result.error).toBe('Unknown option: --unknown-flag'); + }); + + it('parses clear-provider-override with optional tier', () => { + const withTier = parseThinkingCommandArgs(['--clear-provider-override', 'codex', 'opus']); + expect(withTier.error).toBeUndefined(); + expect(withTier.options.clearProviderOverride).toEqual({ provider: 'codex', tier: 'opus' }); + + const withoutTier = parseThinkingCommandArgs(['--clear-provider-override', 'codex']); + expect(withoutTier.error).toBeUndefined(); + expect(withoutTier.options.clearProviderOverride).toEqual({ provider: 'codex', tier: undefined }); + }); +}); + +describe('config thinking override normalization', () => { + it('normalizes off aliases and case', () => { + expect(parseThinkingOverrideInput('OFF')).toEqual({ value: 'off' }); + expect(parseThinkingOverrideInput('0')).toEqual({ value: 'off' }); + }); + + it('accepts valid levels', () => { + expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' }); + }); + + it('validates numeric bounds', () => { + expect(parseThinkingOverrideInput('100001').error).toContain('between 0 and 100000'); + expect(parseThinkingOverrideInput('8192')).toEqual({ value: 8192 }); + }); +}); diff --git a/ui/src/pages/settings/hooks/use-thinking-config.ts b/ui/src/pages/settings/hooks/use-thinking-config.ts index d73ef659..692ee677 100644 --- a/ui/src/pages/settings/hooks/use-thinking-config.ts +++ b/ui/src/pages/settings/hooks/use-thinking-config.ts @@ -19,6 +19,13 @@ const DEFAULT_THINKING_CONFIG: ThinkingConfig = { const FETCH_TIMEOUT = 10000; // 10 second timeout +type ThinkingUpdatePayload = Omit, 'override' | 'provider_overrides'> & { + override?: string | number | null; + provider_overrides?: Record> | null; + clear_override?: boolean; + clear_provider_overrides?: boolean; +}; + export function useThinkingConfig() { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); @@ -27,6 +34,7 @@ export function useThinkingConfig() { const [success, setSuccess] = useState(false); // W4: Track lastModified for optimistic locking const lastModifiedRef = useRef(undefined); + const hasLoadedConfigRef = useRef(false); const fetchConfig = useCallback(async () => { const controller = new AbortController(); @@ -49,6 +57,7 @@ export function useThinkingConfig() { setConfig(data.config || DEFAULT_THINKING_CONFIG); // W4: Store lastModified for optimistic locking lastModifiedRef.current = data.lastModified; + hasLoadedConfigRef.current = true; } catch (err) { clearTimeout(timeoutId); if ((err as Error).name === 'AbortError') { @@ -56,7 +65,6 @@ export function useThinkingConfig() { } else { setError((err as Error).message); } - setConfig(DEFAULT_THINKING_CONFIG); } finally { setLoading(false); } @@ -66,9 +74,32 @@ export function useThinkingConfig() { }, []); const saveConfig = useCallback( - async (updates: Partial) => { - const currentConfig = config || DEFAULT_THINKING_CONFIG; - const optimisticConfig = { ...currentConfig, ...updates }; + async (updates: ThinkingUpdatePayload) => { + if (!hasLoadedConfigRef.current || config === null) { + setError('Cannot save settings before they load. Click Refresh and try again.'); + return; + } + + const currentConfig = config; + const optimisticConfig: ThinkingConfig = { + ...currentConfig, + ...(updates.mode !== undefined ? { mode: updates.mode } : {}), + ...(updates.tier_defaults !== undefined ? { tier_defaults: updates.tier_defaults } : {}), + ...(updates.show_warnings !== undefined ? { show_warnings: updates.show_warnings } : {}), + }; + + if (updates.clear_override || updates.override === null) { + delete optimisticConfig.override; + } else if (updates.override !== undefined) { + optimisticConfig.override = updates.override; + } + + if (updates.clear_provider_overrides || updates.provider_overrides === null) { + delete optimisticConfig.provider_overrides; + } else if (updates.provider_overrides !== undefined) { + optimisticConfig.provider_overrides = updates.provider_overrides; + } + setConfig(optimisticConfig); const controller = new AbortController(); @@ -158,7 +189,11 @@ export function useThinkingConfig() { const setOverride = useCallback( (value: string | number | undefined) => { - saveConfig({ override: value }); + if (value === undefined) { + saveConfig({ override: null, clear_override: true }); + return; + } + saveConfig({ override: value, clear_override: false }); }, [saveConfig] ); @@ -177,8 +212,8 @@ export function useThinkingConfig() { ? { ...otherProviders, [provider]: updatedProvider } : otherProviders; saveConfig({ - provider_overrides: - Object.keys(updatedOverrides).length > 0 ? updatedOverrides : undefined, + provider_overrides: Object.keys(updatedOverrides).length > 0 ? updatedOverrides : null, + clear_provider_overrides: Object.keys(updatedOverrides).length === 0, }); } else { saveConfig({ @@ -186,6 +221,7 @@ export function useThinkingConfig() { ...currentOverrides, [provider]: { ...currentProviderOverrides, [tier]: level }, }, + clear_provider_overrides: false, }); } }, diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index 181db25e..a4b09ea2 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -3,12 +3,13 @@ * Settings section for thinking budget configuration */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } 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 { Input } from '@/components/ui/input'; import { Select, SelectContent, @@ -32,10 +33,13 @@ const THINKING_LEVELS = [ const OVERRIDE_LEVELS = [ { value: '__none__', label: 'None (use CLI flags only)' }, ...THINKING_LEVELS, + { value: '__custom__', label: 'Custom budget (number)' }, { value: 'off', label: 'Off (disable thinking)' }, ]; -const KNOWN_PROVIDERS = ['agy', 'gemini', 'codex'] as const; +const DEFAULT_PROVIDER_KEYS = ['agy', 'gemini', 'codex']; +const THINKING_BUDGET_MIN = 0; +const THINKING_BUDGET_MAX = 100000; export default function ThinkingSection() { const { @@ -51,12 +55,71 @@ export default function ThinkingSection() { setOverride, setProviderOverride, } = useThinkingConfig(); - const [providerOverridesOpen, setProviderOverridesOpen] = useState(false); + const [providerOverridesOpenOverride, setProviderOverridesOpenOverride] = useState< + boolean | null + >(null); + const [customProviderInput, setCustomProviderInput] = useState(''); + const [addedProviders, setAddedProviders] = useState([]); + const [customOverrideBudgetInput, setCustomOverrideBudgetInput] = useState(null); + + const providerKeys = useMemo( + () => + Array.from( + new Set([ + ...DEFAULT_PROVIDER_KEYS, + ...Object.keys(config.provider_overrides ?? {}), + ...addedProviders, + ]) + ), + [addedProviders, config.provider_overrides] + ); + + const overrideSelectValue = + config.override === undefined + ? '__none__' + : typeof config.override === 'number' || /^\d+$/.test(String(config.override)) + ? '__custom__' + : String(config.override); + const persistedCustomOverrideBudget = + typeof config.override === 'number' || /^\d+$/.test(String(config.override ?? '')) + ? String(config.override) + : ''; + const customOverrideBudget = customOverrideBudgetInput ?? persistedCustomOverrideBudget; + const hasProviderOverrides = Object.keys(config.provider_overrides ?? {}).length > 0; + const providerOverridesOpen = providerOverridesOpenOverride ?? hasProviderOverrides; useEffect(() => { fetchConfig(); }, [fetchConfig]); + const handleAddProvider = () => { + const normalized = customProviderInput.trim().toLowerCase(); + if (!normalized) return; + if (!providerKeys.includes(normalized)) { + setAddedProviders((prev) => [...prev, normalized]); + } + setCustomProviderInput(''); + setProviderOverridesOpenOverride(true); + }; + + const handleApplyCustomBudget = () => { + const trimmed = customOverrideBudget.trim(); + if (!trimmed) { + setOverride(undefined); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if ( + Number.isNaN(parsed) || + parsed < THINKING_BUDGET_MIN || + parsed > THINKING_BUDGET_MAX || + !/^\d+$/.test(trimmed) + ) { + return; + } + setOverride(parsed); + }; + if (loading) { return (

@@ -148,8 +211,12 @@ export default function ThinkingSection() { config.mode === mode ? 'bg-primary/10 border border-primary/30' : 'bg-muted/50 hover:bg-muted/80' - }`} - onClick={() => setMode(mode)} + } ${saving ? 'opacity-70 pointer-events-none' : ''}`} + onClick={() => { + if (!saving) { + setMode(mode); + } + }} >

{mode}

@@ -214,8 +281,21 @@ export default function ThinkingSection() { Applied to all sessions. CLI flags still take priority.

+ {overrideSelectValue === '__custom__' && ( +
+
+ setCustomOverrideBudgetInput(event.target.value)} + onBlur={handleApplyCustomBudget} + disabled={saving} + placeholder="Enter custom budget" + /> + +
+

+ Range: {THINKING_BUDGET_MIN} to {THINKING_BUDGET_MAX} +

+
+ )}
)} @@ -237,19 +345,39 @@ export default function ThinkingSection() { {providerOverridesOpen && (

- Override tier defaults for specific providers. + Override tier defaults for specific providers. Add custom provider keys as needed.

- {KNOWN_PROVIDERS.map((provider) => ( +
+ setCustomProviderInput(event.target.value)} + disabled={saving} + placeholder="Add provider key (e.g. qwen)" + /> + +
+ {providerKeys.map((provider) => (
From 954baecfe429aad1c6fc4f60601b578af04ff37f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 19 Feb 2026 16:26:56 +0700 Subject: [PATCH 3/3] fix(thinking): handle clear no-op and tighten override coverage --- src/cliproxy/executor/index.ts | 5 +- .../executor/thinking-override-resolver.ts | 2 +- src/commands/config-thinking-command.ts | 36 +++-- src/commands/config-thinking-parser.ts | 49 +++++++ src/web-server/routes/misc-routes.ts | 24 +++- ...x-reasoning-proxy-extended-context.test.ts | 123 ++++++++++++++++++ .../commands/config-thinking-command.test.ts | 45 +++++++ .../web-server/thinking-routes-logic.test.ts | 42 ++++++ 8 files changed, 298 insertions(+), 28 deletions(-) create mode 100644 tests/unit/web-server/thinking-routes-logic.test.ts diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index c61add33..2d76146c 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -363,6 +363,7 @@ export async function execClaudeWithCLIProxy( thinkingParse.value, process.env.CCS_THINKING ); + const thinkingCfg = getThinkingConfig(); if (thinkingParse.duplicateDisplays.length > 0) { console.warn( @@ -813,7 +814,6 @@ export async function execClaudeWithCLIProxy( process.env.CCS_CODEX_REASONING_TRACE === '1' || process.env.CCS_CODEX_REASONING_TRACE === 'true'; const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; - const thinkingCfg = getThinkingConfig(); const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); codexReasoningProxy = new CodexReasoningProxy({ upstreamBaseUrl: postSanitizationBaseUrl, @@ -889,9 +889,8 @@ export async function execClaudeWithCLIProxy( // 11b. Print thinking status feedback (TTY only, non-piped sessions) if (process.stderr.isTTY) { - const thinkingCfgStatus = getThinkingConfig(); const { thinkingLabel, sourceLabel } = buildThinkingStartupStatus( - thinkingCfgStatus, + thinkingCfg, thinkingOverride, thinkingSource, thinkingParse.sourceDisplay diff --git a/src/cliproxy/executor/thinking-override-resolver.ts b/src/cliproxy/executor/thinking-override-resolver.ts index e0957710..593187e1 100644 --- a/src/cliproxy/executor/thinking-override-resolver.ts +++ b/src/cliproxy/executor/thinking-override-resolver.ts @@ -18,7 +18,7 @@ export function parseEnvThinkingOverride(raw: string | undefined): string | numb const trimmed = raw.trim(); if (!trimmed) return undefined; - if (/^-?\d+$/.test(trimmed)) { + if (/^\d+$/.test(trimmed)) { const parsed = Number.parseInt(trimmed, 10); if (parsed < THINKING_BUDGET_MIN || parsed > THINKING_BUDGET_MAX) { return undefined; diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts index c1b27b4d..36b55b41 100644 --- a/src/commands/config-thinking-command.ts +++ b/src/commands/config-thinking-command.ts @@ -13,7 +13,11 @@ import { } from '../config/unified-config-loader'; import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types'; import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator'; -import { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser'; +import { + clearProviderOverride, + parseThinkingCommandArgs, + parseThinkingOverrideInput, +} from './config-thinking-parser'; const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const; @@ -271,27 +275,21 @@ export async function handleConfigThinkingCommand(args: string[]): Promise process.exitCode = 1; return; } - const currentOverrides = thinkingConfig.provider_overrides ?? {}; - const nextOverrides = { ...currentOverrides }; - if (!nextOverrides[provider]) { - // no-op, but still considered change request to keep command deterministic - hasChanges = true; - } else if (!tier) { - delete nextOverrides[provider]; + const normalizedTier = tier as ThinkingTier | undefined; + const clearResult = clearProviderOverride( + thinkingConfig.provider_overrides, + provider, + normalizedTier + ); + thinkingConfig.provider_overrides = clearResult.nextOverrides; + if (clearResult.changed) { hasChanges = true; } else { - const normalizedTier = tier as ThinkingTier; - const providerEntry = { ...nextOverrides[provider] }; - delete providerEntry[normalizedTier]; - if (Object.keys(providerEntry).length === 0) { - delete nextOverrides[provider]; - } else { - nextOverrides[provider] = providerEntry; - } - hasChanges = true; + console.log( + info(`No provider override found for '${provider}'${tier ? ` tier '${tier}'` : ''}`) + ); + console.log(''); } - thinkingConfig.provider_overrides = - Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } if (hasChanges) { diff --git a/src/commands/config-thinking-parser.ts b/src/commands/config-thinking-parser.ts index 2d82c253..15ffd83a 100644 --- a/src/commands/config-thinking-parser.ts +++ b/src/commands/config-thinking-parser.ts @@ -15,6 +15,10 @@ interface ThinkingCommandOptions { help?: boolean; } +type ThinkingTier = 'opus' | 'sonnet' | 'haiku'; +export type ThinkingTierOverrideMap = Partial>; +export type ThinkingProviderOverrides = Record; + export interface ParseResult { options: ThinkingCommandOptions; error?: string; @@ -107,3 +111,48 @@ export function parseThinkingOverrideInput(rawOverride: string): { error: `Invalid override: ${rawOverride}`, }; } + +export function clearProviderOverride( + currentOverrides: ThinkingProviderOverrides | undefined, + provider: string, + tier?: ThinkingTier +): { nextOverrides: ThinkingProviderOverrides | undefined; changed: boolean } { + const current = currentOverrides ?? {}; + const nextOverrides: ThinkingProviderOverrides = { ...current }; + + const providerEntry = nextOverrides[provider]; + if (!providerEntry) { + return { + nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined, + changed: false, + }; + } + + if (!tier) { + delete nextOverrides[provider]; + return { + nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined, + changed: true, + }; + } + + if (providerEntry[tier] === undefined) { + return { + nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined, + changed: false, + }; + } + + const nextProviderEntry = { ...providerEntry }; + delete nextProviderEntry[tier]; + if (Object.keys(nextProviderEntry).length === 0) { + delete nextOverrides[provider]; + } else { + nextOverrides[provider] = nextProviderEntry; + } + + return { + nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined, + changed: true, + }; +} diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index 8d680051..fb3037c9 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -26,6 +26,20 @@ import { validateFilePath } from './route-helpers'; const router = Router(); +export function resolveThinkingProviderOverridesForSave( + currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined, + updatesProviderOverrides: Record> | undefined, + shouldClearProviderOverrides: boolean +): ThinkingConfig['provider_overrides'] | undefined { + if (shouldClearProviderOverrides) { + return undefined; + } + if (updatesProviderOverrides !== undefined) { + return updatesProviderOverrides; + } + return currentProviderOverrides; +} + // ==================== Generic File API ==================== /** @@ -437,11 +451,11 @@ router.put('/thinking', (req: Request, res: Response): void => { sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium', haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low', }, - provider_overrides: shouldClearProviderOverrides - ? undefined - : updates.provider_overrides !== undefined - ? normalizedProviderOverrides - : config.thinking?.provider_overrides, + provider_overrides: resolveThinkingProviderOverridesForSave( + config.thinking?.provider_overrides, + updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined, + shouldClearProviderOverrides + ), show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true, }; diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 09eb8c53..1e6f0cd3 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -5,6 +5,11 @@ import { CodexReasoningProxy, getEffortForModel, } from '../../../src/cliproxy/codex-reasoning-proxy'; +import { + parseEnvThinkingOverride, + resolveRuntimeThinkingOverride, + shouldDisableCodexReasoning, +} from '../../../src/cliproxy/executor/thinking-override-resolver'; type JsonRecord = Record; @@ -224,4 +229,122 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(response.statusCode).toBe(200); expect(capturedBody?.model).toBe('enterprise-internal-high'); }); + + it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => { + let capturedBody: JsonRecord | null = null; + + expect(parseEnvThinkingOverride('high')).toBe('high'); + const { thinkingOverride } = resolveRuntimeThinkingOverride(undefined, 'high'); + const disableEffort = shouldDisableCodexReasoning( + { + mode: 'off', + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + show_warnings: true, + }, + thinkingOverride + ); + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + disableEffort, + defaultEffort: 'medium', + modelMap: { + defaultModel: 'gpt-5.3-codex', + }, + }); + + const proxyPort = await proxy.start(); + const response = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.3-codex-high', + messages: [], + } + ); + + proxy.stop(); + + expect(response.statusCode).toBe(200); + expect(disableEffort).toBe(false); + expect(capturedBody?.model).toBe('gpt-5.3-codex'); + expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high'); + }); + + it('disables reasoning when CCS_THINKING=off is provided', async () => { + let capturedBody: JsonRecord | null = null; + + expect(parseEnvThinkingOverride('off')).toBe('off'); + const { thinkingOverride } = resolveRuntimeThinkingOverride(undefined, 'off'); + const disableEffort = shouldDisableCodexReasoning( + { + mode: 'auto', + tier_defaults: { + opus: 'high', + sonnet: 'medium', + haiku: 'low', + }, + show_warnings: true, + }, + thinkingOverride + ); + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + disableEffort, + defaultEffort: 'medium', + modelMap: { + defaultModel: 'gpt-5.3-codex', + }, + }); + + const proxyPort = await proxy.start(); + const response = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.3-codex-high', + messages: [], + } + ); + + proxy.stop(); + + expect(response.statusCode).toBe(200); + expect(disableEffort).toBe(true); + expect(capturedBody?.model).toBe('gpt-5.3-codex'); + expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined(); + }); }); diff --git a/tests/unit/commands/config-thinking-command.test.ts b/tests/unit/commands/config-thinking-command.test.ts index a549c99b..e98efedb 100644 --- a/tests/unit/commands/config-thinking-command.test.ts +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -3,6 +3,7 @@ import { parseThinkingCommandArgs, parseThinkingOverrideInput, } from '../../../src/commands/config-thinking-command'; +import { clearProviderOverride } from '../../../src/commands/config-thinking-parser'; describe('config thinking command parser', () => { it('rejects missing required option values', () => { @@ -41,3 +42,47 @@ describe('config thinking override normalization', () => { expect(parseThinkingOverrideInput('8192')).toEqual({ value: 8192 }); }); }); + +describe('config thinking provider override clearing', () => { + it('is a no-op when provider override does not exist', () => { + const result = clearProviderOverride( + { + codex: { opus: 'high' }, + }, + 'gemini' + ); + + expect(result.changed).toBe(false); + expect(result.nextOverrides).toEqual({ + codex: { opus: 'high' }, + }); + }); + + it('is a no-op when provider exists but tier override does not', () => { + const result = clearProviderOverride( + { + codex: { opus: 'high' }, + }, + 'codex', + 'haiku' + ); + + expect(result.changed).toBe(false); + expect(result.nextOverrides).toEqual({ + codex: { opus: 'high' }, + }); + }); + + it('removes provider entry when last tier is cleared', () => { + const result = clearProviderOverride( + { + codex: { opus: 'high' }, + }, + 'codex', + 'opus' + ); + + expect(result.changed).toBe(true); + expect(result.nextOverrides).toBeUndefined(); + }); +}); diff --git a/tests/unit/web-server/thinking-routes-logic.test.ts b/tests/unit/web-server/thinking-routes-logic.test.ts new file mode 100644 index 00000000..e98b734c --- /dev/null +++ b/tests/unit/web-server/thinking-routes-logic.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveThinkingProviderOverridesForSave } from '../../../src/web-server/routes/misc-routes'; + +describe('thinking routes logic', () => { + it('clears provider overrides when clear flag is set', () => { + const result = resolveThinkingProviderOverridesForSave( + { + codex: { opus: 'high' }, + }, + { + gemini: { sonnet: 'medium' }, + }, + true + ); + + expect(result).toBeUndefined(); + }); + + it('applies normalized updates when provided and clear flag is false', () => { + const updates = { + gemini: { sonnet: 'medium' }, + }; + const result = resolveThinkingProviderOverridesForSave( + { + codex: { opus: 'high' }, + }, + updates, + false + ); + + expect(result).toEqual(updates); + }); + + it('preserves current overrides when no updates are provided', () => { + const current = { + codex: { opus: 'high' }, + }; + const result = resolveThinkingProviderOverridesForSave(current, undefined, false); + + expect(result).toEqual(current); + }); +});