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) => (