Merge pull request #587 from kaitranntt/kai/feat/583-thinking-ux-dx

feat(thinking): complete thinking UX/DX for all providers
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-19 16:32:05 +07:00
commit e63292c793
17 files changed
+1526 -39

No files matched your search

+49 -4
View File
@@ -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<string, CodexReasoningEffort>
): 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<CodexReasoningEffort, number> = {
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<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
@@ -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 ??
+6 -6
View File
@@ -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;
}
+26 -2
View File
@@ -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',
@@ -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',
};
}
+17 -5
View File
@@ -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
+17
View File
@@ -68,6 +68,14 @@ function showHelp(): void {
console.log(' --timeout <s> Set analysis timeout (seconds)');
console.log(' --set-model <p> <m> Set model for provider');
console.log('');
console.log(' thinking Manage thinking/reasoning settings');
console.log(' --mode <mode> Set mode (auto, off, manual)');
console.log(' --override <l> Set persistent override level');
console.log(' --clear-override Remove persistent override');
console.log(' --tier <t> <l> Set tier default level');
console.log(' --provider-override <p> <t> <l> Set provider tier override');
console.log(' --clear-provider-override <p> [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<void> {
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);
+303
View File
@@ -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 <mode>', 'command')} Set mode (auto, off, manual)`
);
console.log(
` ${color('--override <level>', 'command')} Set persistent override (manual mode)`
);
console.log(
` ${color('--clear-override', 'command')} Remove persistent override`
);
console.log(
` ${color('--tier <tier> <level>', 'command')} Set tier default (opus/sonnet/haiku)`
);
console.log(
` ${color('--provider-override <p> <t> <l>', 'command')} Set provider-specific tier override`
);
console.log(
` ${color('--clear-provider-override <p> [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<void> {
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();
}
+158
View File
@@ -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<Record<ThinkingTier, string>>;
export type ThinkingProviderOverrides = Record<string, ThinkingTierOverrideMap>;
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: <tier> <level>` };
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: <provider> <tier> <level>` };
}
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: <provider> [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,
};
}
+4
View File
@@ -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 <profile>', '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
+60 -7
View File
@@ -26,6 +26,20 @@ import { validateFilePath } from './route-helpers';
const router = Router();
export function resolveThinkingProviderOverridesForSave(
currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined,
updatesProviderOverrides: Record<string, Partial<ThinkingConfig['tier_defaults']>> | 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<ThinkingConfig> & {
const {
lastModified,
clear_override: clearOverrideFlag,
clear_provider_overrides: clearProviderOverridesFlag,
...updates
} = req.body as Omit<Partial<ThinkingConfig>, 'override' | 'provider_overrides'> & {
lastModified?: number;
override?: string | number | null;
provider_overrides?: Record<string, Partial<ThinkingConfig['tier_defaults']>> | 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<string, Partial<ThinkingConfig['tier_defaults']>>
| 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<string, Partial<ThinkingTierDefaults>>)
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<string, Partial<ThinkingConfig['tier_defaults']>> = {};
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,
};
@@ -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<string, unknown>;
@@ -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();
});
});
@@ -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',
@@ -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',
});
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -19,6 +19,13 @@ const DEFAULT_THINKING_CONFIG: ThinkingConfig = {
const FETCH_TIMEOUT = 10000; // 10 second timeout
type ThinkingUpdatePayload = Omit<Partial<ThinkingConfig>, 'override' | 'provider_overrides'> & {
override?: string | number | null;
provider_overrides?: Record<string, Partial<ThinkingConfig['tier_defaults']>> | null;
clear_override?: boolean;
clear_provider_overrides?: boolean;
};
export function useThinkingConfig() {
const [config, setConfig] = useState<ThinkingConfig | null>(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<number | undefined>(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<ThinkingConfig>) => {
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,
};
}
+238 -11
View File
@@ -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<string[]>([]);
const [customOverrideBudgetInput, setCustomOverrideBudgetInput] = useState<string | null>(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 (
<div className="flex-1 flex items-center justify-center">
@@ -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);
}
}}
>
<div>
<p className="font-medium capitalize">{mode}</p>
@@ -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'}
</p>
</div>
<div
@@ -195,6 +273,155 @@ export default function ThinkingSection() {
</div>
)}
{/* Manual Override (shown when mode is manual) */}
{config.mode === 'manual' && (
<div className="space-y-3">
<h3 className="text-base font-medium">Persistent Override</h3>
<p className="text-sm text-muted-foreground">
Applied to all sessions. CLI flags still take priority.
</p>
<Select
value={overrideSelectValue}
onValueChange={(value) => {
if (value === '__none__') {
setOverride(undefined);
return;
}
if (value === '__custom__') {
if (!customOverrideBudget) {
setCustomOverrideBudgetInput('8192');
}
return;
}
setCustomOverrideBudgetInput(null);
setOverride(value);
}}
disabled={saving}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OVERRIDE_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
{overrideSelectValue === '__custom__' && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input
type="number"
min={THINKING_BUDGET_MIN}
max={THINKING_BUDGET_MAX}
value={customOverrideBudget}
onChange={(event) => setCustomOverrideBudgetInput(event.target.value)}
onBlur={handleApplyCustomBudget}
disabled={saving}
placeholder="Enter custom budget"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleApplyCustomBudget}
disabled={saving}
>
Apply
</Button>
</div>
<p className="text-xs text-muted-foreground">
Range: {THINKING_BUDGET_MIN} to {THINKING_BUDGET_MAX}
</p>
</div>
)}
</div>
)}
{/* Provider Overrides (collapsible) */}
<div className="space-y-3">
<button
type="button"
className="flex items-center gap-2 text-base font-medium w-full text-left"
onClick={() =>
setProviderOverridesOpenOverride((prev) => !(prev ?? hasProviderOverrides))
}
disabled={saving}
>
<ChevronDown
className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`}
/>
Provider Overrides ({Object.keys(config.provider_overrides ?? {}).length})
</button>
{providerOverridesOpen && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Override tier defaults for specific providers. Add custom provider keys as needed.
</p>
<div className="flex items-center gap-2">
<Input
value={customProviderInput}
onChange={(event) => setCustomProviderInput(event.target.value)}
disabled={saving}
placeholder="Add provider key (e.g. qwen)"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleAddProvider}
disabled={saving}
>
Add
</Button>
</div>
{providerKeys.map((provider) => (
<div key={provider} className="space-y-2 p-3 rounded-lg bg-muted/30">
<Label className="capitalize font-medium text-sm">{provider}</Label>
<div className="grid grid-cols-3 gap-2">
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => {
const currentValue =
config.provider_overrides?.[provider]?.[tier] || '__default__';
return (
<div key={tier} className="space-y-1">
<Label className="text-xs text-muted-foreground capitalize">
{tier}
</Label>
<Select
value={currentValue}
onValueChange={(value) =>
setProviderOverride(
provider,
tier,
value === '__default__' ? undefined : value
)
}
disabled={saving}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default</SelectItem>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
{/* Show Warnings Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
@@ -212,17 +439,17 @@ export default function ThinkingSection() {
{/* Info Box */}
<div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-2">CLI Override</h4>
<h4 className="text-sm font-medium mb-2">CLI &amp; Env Override</h4>
<p className="text-sm text-muted-foreground mb-2">
Override per session with{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">--thinking</code> (agy/gemini) or{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">--effort</code> (codex):
Override per session with flags or{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">CCS_THINKING</code> env var.
Priority: flag &gt; env &gt; config.
</p>
<div className="space-y-1 text-sm font-mono text-muted-foreground">
<p>ccs gemini --thinking high</p>
<p>ccs agy --thinking 16384</p>
<p>ccs codex --effort xhigh</p>
<p>ccs codex -p "Refactor this module" --effort xhigh</p>
<p>CCS_THINKING=high ccs codex &quot;debug this&quot;</p>
<p>ccs config thinking --mode auto</p>
</div>
</div>
</div>