diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 0558c2ab..57eb7e39 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; @@ -53,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, @@ -147,7 +160,12 @@ export class CodexReasoningProxy { private readonly config: Required< Pick< CodexReasoningProxyConfig, - 'upstreamBaseUrl' | 'verbose' | 'timeoutMs' | 'defaultEffort' | 'traceFilePath' + | 'upstreamBaseUrl' + | 'verbose' + | 'timeoutMs' + | 'defaultEffort' + | 'traceFilePath' + | 'disableEffort' > > & Pick; @@ -170,10 +188,27 @@ 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); } + /** + * 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}`); @@ -330,14 +365,24 @@ 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 = this.parseEffortAlias(normalizedRequestModel); + 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` // // 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 edc701e4..2d76146c 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'; @@ -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,7 +359,12 @@ export async function execClaudeWithCLIProxy( process.exit(1); } - const thinkingOverride = thinkingParse.value; + const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride( + thinkingParse.value, + process.env.CCS_THINKING + ); + const thinkingCfg = getThinkingConfig(); + if (thinkingParse.duplicateDisplays.length > 0) { console.warn( `[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}` @@ -804,10 +814,12 @@ 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 codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride); 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 +887,18 @@ 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 { thinkingLabel, sourceLabel } = buildThinkingStartupStatus( + thinkingCfg, + thinkingOverride, + thinkingSource, + thinkingParse.sourceDisplay + ); + + console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`); + } + // 12. Filter CCS-specific flags before passing to Claude CLI const ccsFlags = [ '--auth', diff --git a/src/cliproxy/executor/thinking-override-resolver.ts b/src/cliproxy/executor/thinking-override-resolver.ts new file mode 100644 index 00000000..593187e1 --- /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 1db499e1..906fac3d 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -68,6 +68,14 @@ 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(' --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)'); console.log(' --dev Development mode with Vite HMR'); @@ -80,6 +88,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 +111,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..36b55b41 --- /dev/null +++ b/src/commands/config-thinking-command.ts @@ -0,0 +1,303 @@ +/** + * 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'; +import { + clearProviderOverride, + parseThinkingCommandArgs, + parseThinkingOverrideInput, +} from './config-thinking-parser'; + +const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const; + +const VALID_TIERS = ['opus', 'sonnet', 'haiku'] as const; +type ThinkingTier = (typeof VALID_TIERS)[number]; +export { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser'; + +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('--clear-provider-override

[t]', 'command')} Remove provider override (provider or tier)` + ); + 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( + ` $ ${color('ccs config thinking --clear-provider-override codex opus', '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, error } = parseThinkingCommandArgs(args); + if (error) { + console.error(fail(error)); + process.exitCode = 1; + return; + } + + 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) { + 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.exitCode = 1; + return; + } + thinkingConfig.mode = normalizedMode as 'auto' | 'off' | 'manual'; + hasChanges = true; + } + + // Validate and apply --override + if (options.override !== undefined) { + 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.exitCode = 1; + return; + } + thinkingConfig.override = parsedOverride.value; + hasChanges = true; + } + + // Apply --clear-override + if (options.clearOverride) { + thinkingConfig.override = undefined; + hasChanges = true; + } + + // Validate and apply --tier + if (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: ${options.tier.tier}`)); + console.error(info(`Valid tiers: ${VALID_TIERS.join(', ')}`)); + process.exitCode = 1; + return; + } + 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.exitCode = 1; + return; + } + thinkingConfig.tier_defaults = { + ...DEFAULT_THINKING_TIER_DEFAULTS, + ...thinkingConfig.tier_defaults, + [tier]: level, + }; + hasChanges = true; + } + + // Validate and apply --provider-override + if (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: ${options.providerOverride.tier}`)); + process.exitCode = 1; + return; + } + 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], + [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 normalizedTier = tier as ThinkingTier | undefined; + const clearResult = clearProviderOverride( + thinkingConfig.provider_overrides, + provider, + normalizedTier + ); + thinkingConfig.provider_overrides = clearResult.nextOverrides; + if (clearResult.changed) { + hasChanges = true; + } else { + console.log( + info(`No provider override found for '${provider}'${tier ? ` tier '${tier}'` : ''}`) + ); + console.log(''); + } + } + + if (hasChanges) { + updateUnifiedConfig({ thinking: thinkingConfig }); + console.log(ok('Configuration updated')); + console.log(''); + } + + // Always show current status + showStatus(); +} diff --git a/src/commands/config-thinking-parser.ts b/src/commands/config-thinking-parser.ts new file mode 100644 index 00000000..15ffd83a --- /dev/null +++ b/src/commands/config-thinking-parser.ts @@ -0,0 +1,158 @@ +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; +} + +type ThinkingTier = 'opus' | 'sonnet' | 'haiku'; +export type ThinkingTierOverrideMap = Partial>; +export type ThinkingProviderOverrides = Record; + +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}`, + }; +} + +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/commands/help-command.ts b/src/commands/help-command.ts index 868d1060..eecffc8b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -282,6 +282,9 @@ 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 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'], @@ -404,6 +407,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/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index 98f933fe..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 ==================== /** @@ -261,8 +275,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 +305,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 +348,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 +358,7 @@ router.put('/thinking', (req: Request, res: Response): void => { }); return; } + normalizedOverride = normalizedValue === '0' ? 'off' : normalizedValue; } } @@ -351,7 +388,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 +399,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 +421,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: 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 1e88c258..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; @@ -141,4 +146,205 @@ 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'); + }); + + 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/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..e98efedb --- /dev/null +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'bun:test'; +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', () => { + 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 }); + }); +}); + +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); + }); +}); diff --git a/ui/src/pages/settings/hooks/use-thinking-config.ts b/ui/src/pages/settings/hooks/use-thinking-config.ts index 4decf2f9..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(); @@ -156,6 +187,47 @@ export function useThinkingConfig() { [saveConfig] ); + const setOverride = useCallback( + (value: string | number | undefined) => { + if (value === undefined) { + saveConfig({ override: null, clear_override: true }); + return; + } + saveConfig({ override: value, clear_override: false }); + }, + [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 : null, + clear_provider_overrides: Object.keys(updatedOverrides).length === 0, + }); + } else { + saveConfig({ + provider_overrides: { + ...currentOverrides, + [provider]: { ...currentProviderOverrides, [tier]: level }, + }, + clear_provider_overrides: false, + }); + } + }, + [config, saveConfig] + ); + return { config: config || DEFAULT_THINKING_CONFIG, loading, @@ -167,5 +239,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..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 } 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, @@ -16,7 +17,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 +30,17 @@ const THINKING_LEVELS = [ { value: 'auto', label: 'Auto (dynamic)' }, ]; +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 DEFAULT_PROVIDER_KEYS = ['agy', 'gemini', 'codex']; +const THINKING_BUDGET_MIN = 0; +const THINKING_BUDGET_MAX = 100000; + export default function ThinkingSection() { const { config, @@ -40,12 +52,74 @@ export default function ThinkingSection() { setMode, setTierDefault, setShowWarnings, + setOverride, + setProviderOverride, } = useThinkingConfig(); + 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 (

@@ -137,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}

@@ -146,7 +224,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. +

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

+ Range: {THINKING_BUDGET_MIN} to {THINKING_BUDGET_MAX} +

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

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

+
+ setCustomProviderInput(event.target.value)} + disabled={saving} + placeholder="Add provider key (e.g. qwen)" + /> + +
+ {providerKeys.map((provider) => ( +
+ +
+ {(['opus', 'sonnet', 'haiku'] as const).map((tier) => { + const currentValue = + config.provider_overrides?.[provider]?.[tier] || '__default__'; + return ( +
+ + +
+ ); + })} +
+
+ ))} +
+ )} +
+ {/* Show Warnings Toggle */}
@@ -212,17 +439,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