mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
fix(thinking): harden codex reasoning controls across cli and dashboard
This commit is contained in:
@@ -55,6 +55,17 @@ function parseModelEffortSuffix(
|
|||||||
return { upstreamModel, effort };
|
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> = {
|
const EFFORT_RANK: Record<CodexReasoningEffort, number> = {
|
||||||
medium: 1,
|
medium: 1,
|
||||||
high: 2,
|
high: 2,
|
||||||
@@ -182,6 +193,22 @@ export class CodexReasoningProxy {
|
|||||||
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
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 {
|
private log(message: string): void {
|
||||||
if (this.config.verbose) {
|
if (this.config.verbose) {
|
||||||
console.error(`[codex-reasoning-proxy] ${message}`);
|
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
|
// When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning
|
||||||
if (this.config.disableEffort) {
|
if (this.config.disableEffort) {
|
||||||
const suffixParsed = normalizedRequestModel
|
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||||
? parseModelEffortSuffix(normalizedRequestModel)
|
|
||||||
: null;
|
|
||||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||||
const forwarded =
|
const forwarded =
|
||||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
||||||
@@ -357,9 +382,7 @@ export class CodexReasoningProxy {
|
|||||||
// - reasoning.effort: `xhigh`
|
// - reasoning.effort: `xhigh`
|
||||||
//
|
//
|
||||||
// This allows tier→effort mapping without inventing upstream model IDs.
|
// This allows tier→effort mapping without inventing upstream model IDs.
|
||||||
const suffixParsed = normalizedRequestModel
|
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||||
? parseModelEffortSuffix(normalizedRequestModel)
|
|
||||||
: null;
|
|
||||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||||
const effort =
|
const effort =
|
||||||
suffixParsed?.effort ??
|
suffixParsed?.effort ??
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { CLIProxyProvider } from '../types';
|
|||||||
import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
|
import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
|
||||||
import { getThinkingConfig } from '../../config/unified-config-loader';
|
import { getThinkingConfig } from '../../config/unified-config-loader';
|
||||||
import { supportsThinking } from '../model-catalog';
|
import { supportsThinking } from '../model-catalog';
|
||||||
import { validateThinking } from '../thinking-validator';
|
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
|
||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
|
|
||||||
/** Model tier types for thinking budget defaults */
|
/** 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.
|
// Explicit "off" (CLI override or manual config override) must disable ALL tier thinking.
|
||||||
const explicitOffOverride =
|
const explicitOffOverride =
|
||||||
thinkingOverride === 'off' ||
|
isThinkingOffValue(thinkingOverride) ||
|
||||||
(thinkingOverride === undefined &&
|
(thinkingOverride === undefined &&
|
||||||
thinkingConfig.mode === 'manual' &&
|
thinkingConfig.mode === 'manual' &&
|
||||||
thinkingConfig.override === 'off');
|
isThinkingOffValue(thinkingConfig.override));
|
||||||
if (explicitOffOverride) {
|
if (explicitOffOverride) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -227,10 +227,10 @@ export function applyThinkingConfig(
|
|||||||
|
|
||||||
// If auto-detection resolves default tier to "off", skip the main model but still allow
|
// If auto-detection resolves default tier to "off", skip the main model but still allow
|
||||||
// explicit per-tier thinking values for other tiers.
|
// explicit per-tier thinking values for other tiers.
|
||||||
if (thinkingValue === 'off') {
|
if (isThinkingOffValue(thinkingValue)) {
|
||||||
const hasPerTierThinking =
|
const hasPerTierThinking =
|
||||||
compositeTierThinking &&
|
compositeTierThinking &&
|
||||||
Object.values(compositeTierThinking).some((v) => v !== undefined && v !== 'off');
|
Object.values(compositeTierThinking).some((v) => v !== undefined && !isThinkingOffValue(v));
|
||||||
if (!hasPerTierThinking) {
|
if (!hasPerTierThinking) {
|
||||||
return result; // No thinking to apply anywhere
|
return result; // No thinking to apply anywhere
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ export function applyThinkingConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If per-tier thinking is 'off', skip this tier
|
// If per-tier thinking is 'off', skip this tier
|
||||||
if (tierThinkingValue === 'off') {
|
if (isThinkingOffValue(tierThinkingValue)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ import {
|
|||||||
restoreAutoPausedAccounts,
|
restoreAutoPausedAccounts,
|
||||||
} from '../account-safety';
|
} from '../account-safety';
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||||
|
import {
|
||||||
|
buildThinkingStartupStatus,
|
||||||
|
resolveRuntimeThinkingOverride,
|
||||||
|
shouldDisableCodexReasoning,
|
||||||
|
} from './thinking-override-resolver';
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||||
@@ -354,19 +359,10 @@ export async function execClaudeWithCLIProxy(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority: CLI flag > CCS_THINKING env var > config.yaml
|
const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride(
|
||||||
let thinkingOverride = thinkingParse.value;
|
thinkingParse.value,
|
||||||
let thinkingSource: 'flag' | 'env' | 'config' | undefined =
|
process.env.CCS_THINKING
|
||||||
thinkingOverride !== undefined ? 'flag' : undefined;
|
);
|
||||||
|
|
||||||
if (thinkingOverride === undefined && process.env.CCS_THINKING) {
|
|
||||||
const envVal = process.env.CCS_THINKING.trim();
|
|
||||||
if (envVal) {
|
|
||||||
// Parse same as CLI: integer string → number, else string
|
|
||||||
thinkingOverride = /^-?\d+$/.test(envVal) ? Number.parseInt(envVal, 10) : envVal;
|
|
||||||
thinkingSource = 'env';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thinkingParse.duplicateDisplays.length > 0) {
|
if (thinkingParse.duplicateDisplays.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -818,12 +814,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
|
process.env.CCS_CODEX_REASONING_TRACE === 'true';
|
||||||
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
||||||
const thinkingCfg = getThinkingConfig();
|
const thinkingCfg = getThinkingConfig();
|
||||||
const codexThinkingOff =
|
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
|
||||||
(thinkingCfg.mode === 'off' && thinkingOverride === undefined) ||
|
|
||||||
thinkingOverride === 'off' ||
|
|
||||||
(thinkingOverride === undefined &&
|
|
||||||
thinkingCfg.mode === 'manual' &&
|
|
||||||
thinkingCfg.override === 'off');
|
|
||||||
codexReasoningProxy = new CodexReasoningProxy({
|
codexReasoningProxy = new CodexReasoningProxy({
|
||||||
upstreamBaseUrl: postSanitizationBaseUrl,
|
upstreamBaseUrl: postSanitizationBaseUrl,
|
||||||
verbose,
|
verbose,
|
||||||
@@ -899,26 +890,12 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
|
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
|
||||||
if (process.stderr.isTTY) {
|
if (process.stderr.isTTY) {
|
||||||
const thinkingCfgStatus = getThinkingConfig();
|
const thinkingCfgStatus = getThinkingConfig();
|
||||||
let thinkingLabel: string;
|
const { thinkingLabel, sourceLabel } = buildThinkingStartupStatus(
|
||||||
let sourceLabel: string;
|
thinkingCfgStatus,
|
||||||
|
thinkingOverride,
|
||||||
if (thinkingOverride === 'off' || thinkingCfgStatus.mode === 'off') {
|
thinkingSource,
|
||||||
thinkingLabel = 'off';
|
thinkingParse.sourceDisplay
|
||||||
sourceLabel =
|
);
|
||||||
thinkingSource === 'flag' ? 'flag' : thinkingSource === 'env' ? 'env' : 'config';
|
|
||||||
} else if (thinkingSource === 'flag') {
|
|
||||||
thinkingLabel = String(thinkingOverride);
|
|
||||||
sourceLabel = `flag: ${thinkingParse.sourceDisplay}`;
|
|
||||||
} else if (thinkingSource === 'env') {
|
|
||||||
thinkingLabel = String(thinkingOverride);
|
|
||||||
sourceLabel = 'env: CCS_THINKING';
|
|
||||||
} else if (thinkingCfgStatus.mode === 'manual' && thinkingCfgStatus.override !== undefined) {
|
|
||||||
thinkingLabel = String(thinkingCfgStatus.override);
|
|
||||||
sourceLabel = 'config: manual';
|
|
||||||
} else {
|
|
||||||
thinkingLabel = thinkingCfgStatus.mode === 'auto' ? 'auto' : 'default';
|
|
||||||
sourceLabel = 'config: auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
|
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -86,6 +86,21 @@ export function capLevelAtMax(
|
|||||||
export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const;
|
export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const;
|
||||||
export const THINKING_AUTO_VALUE = 'auto';
|
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
|
* Find closest valid level using simple string matching
|
||||||
* Returns undefined if no close match found
|
* Returns undefined if no close match found
|
||||||
@@ -158,11 +173,8 @@ export function validateThinking(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle off/none/disabled values
|
// Handle off/none/disabled values
|
||||||
if (typeof value === 'string') {
|
if (isThinkingOffValue(value)) {
|
||||||
const normalizedValue = value.toLowerCase().trim();
|
return { valid: true, value: 'off' };
|
||||||
if (THINKING_OFF_VALUES.includes(normalizedValue as (typeof THINKING_OFF_VALUES)[number])) {
|
|
||||||
return { valid: true, value: 'off' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If model has no thinking support info, pass through
|
// If model has no thinking support info, pass through
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ function showHelp(): void {
|
|||||||
console.log(' thinking Manage thinking/reasoning settings');
|
console.log(' thinking Manage thinking/reasoning settings');
|
||||||
console.log(' --mode <mode> Set mode (auto, off, manual)');
|
console.log(' --mode <mode> Set mode (auto, off, manual)');
|
||||||
console.log(' --override <l> Set persistent override level');
|
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(' --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('');
|
||||||
console.log('Options:');
|
console.log('Options:');
|
||||||
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
||||||
|
|||||||
@@ -13,47 +13,13 @@ import {
|
|||||||
} from '../config/unified-config-loader';
|
} from '../config/unified-config-loader';
|
||||||
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types';
|
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types';
|
||||||
import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator';
|
import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator';
|
||||||
|
import { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser';
|
||||||
|
|
||||||
const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const;
|
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;
|
const VALID_TIERS = ['opus', 'sonnet', 'haiku'] as const;
|
||||||
|
type ThinkingTier = (typeof VALID_TIERS)[number];
|
||||||
function parseArgs(args: string[]): ThinkingCommandOptions {
|
export { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser';
|
||||||
const options: ThinkingCommandOptions = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
const arg = args[i];
|
|
||||||
|
|
||||||
if (arg === '--mode' && args[i + 1]) {
|
|
||||||
options.mode = args[++i];
|
|
||||||
} else if (arg === '--override' && args[i + 1]) {
|
|
||||||
options.override = args[++i];
|
|
||||||
} else if (arg === '--clear-override') {
|
|
||||||
options.clearOverride = true;
|
|
||||||
} else if (arg === '--tier' && args[i + 1] && args[i + 2]) {
|
|
||||||
options.tier = { tier: args[++i], level: args[++i] };
|
|
||||||
} else if (arg === '--provider-override' && args[i + 1] && args[i + 2] && args[i + 3]) {
|
|
||||||
options.providerOverride = {
|
|
||||||
provider: args[++i],
|
|
||||||
tier: args[++i],
|
|
||||||
level: args[++i],
|
|
||||||
};
|
|
||||||
} else if (arg === '--help' || arg === '-h') {
|
|
||||||
options.help = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showHelp(): void {
|
function showHelp(): void {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -82,6 +48,9 @@ function showHelp(): void {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${color('--provider-override <p> <t> <l>', 'command')} Set provider-specific tier override`
|
` ${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(` ${color('--help, -h', 'command')} Show this help`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -107,6 +76,9 @@ function showHelp(): void {
|
|||||||
console.log(
|
console.log(
|
||||||
` $ ${color('ccs config thinking --provider-override codex opus xhigh', 'command')}`
|
` $ ${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('');
|
||||||
|
|
||||||
console.log(subheader('Environment:'));
|
console.log(subheader('Environment:'));
|
||||||
@@ -177,7 +149,12 @@ function showStatus(): void {
|
|||||||
export async function handleConfigThinkingCommand(args: string[]): Promise<void> {
|
export async function handleConfigThinkingCommand(args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
|
|
||||||
const options = parseArgs(args);
|
const { options, error } = parseThinkingCommandArgs(args);
|
||||||
|
if (error) {
|
||||||
|
console.error(fail(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.help) {
|
if (options.help) {
|
||||||
showHelp();
|
showHelp();
|
||||||
@@ -194,29 +171,27 @@ export async function handleConfigThinkingCommand(args: string[]): Promise<void>
|
|||||||
|
|
||||||
// Validate and apply --mode
|
// Validate and apply --mode
|
||||||
if (options.mode !== undefined) {
|
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(fail(`Invalid mode: ${options.mode}`));
|
||||||
console.error(info(`Valid modes: ${VALID_THINKING_MODES.join(', ')}`));
|
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;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and apply --override
|
// Validate and apply --override
|
||||||
if (options.override !== undefined) {
|
if (options.override !== undefined) {
|
||||||
const normalized = options.override.toLowerCase().trim();
|
const parsedOverride = parseThinkingOverrideInput(options.override);
|
||||||
if (
|
if (parsedOverride.error) {
|
||||||
!(VALID_THINKING_LEVELS as readonly string[]).includes(normalized) &&
|
console.error(fail(parsedOverride.error));
|
||||||
!/^\d+$/.test(normalized)
|
|
||||||
) {
|
|
||||||
console.error(fail(`Invalid override: ${options.override}`));
|
|
||||||
console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}, or a number`));
|
console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}, or a number`));
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
thinkingConfig.override = /^\d+$/.test(normalized)
|
thinkingConfig.override = parsedOverride.value;
|
||||||
? Number.parseInt(normalized, 10)
|
|
||||||
: normalized;
|
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,46 +203,97 @@ export async function handleConfigThinkingCommand(args: string[]): Promise<void>
|
|||||||
|
|
||||||
// Validate and apply --tier
|
// Validate and apply --tier
|
||||||
if (options.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)) {
|
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(', ')}`));
|
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())) {
|
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level)) {
|
||||||
console.error(fail(`Invalid level for ${tier}: ${level}`));
|
console.error(fail(`Invalid level for ${tier}: ${options.tier.level}`));
|
||||||
console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}`));
|
console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}`));
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
thinkingConfig.tier_defaults = {
|
thinkingConfig.tier_defaults = {
|
||||||
...DEFAULT_THINKING_TIER_DEFAULTS,
|
...DEFAULT_THINKING_TIER_DEFAULTS,
|
||||||
...thinkingConfig.tier_defaults,
|
...thinkingConfig.tier_defaults,
|
||||||
[tier]: level.toLowerCase(),
|
[tier]: level,
|
||||||
};
|
};
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and apply --provider-override
|
// Validate and apply --provider-override
|
||||||
if (options.providerOverride) {
|
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)) {
|
if (!(VALID_TIERS as readonly string[]).includes(tier)) {
|
||||||
console.error(fail(`Invalid tier: ${tier}`));
|
console.error(fail(`Invalid tier: ${options.providerOverride.tier}`));
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level.toLowerCase())) {
|
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level)) {
|
||||||
console.error(fail(`Invalid level: ${level}`));
|
console.error(fail(`Invalid level: ${options.providerOverride.level}`));
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const normalizedTier = tier as ThinkingTier;
|
||||||
thinkingConfig.provider_overrides = {
|
thinkingConfig.provider_overrides = {
|
||||||
...thinkingConfig.provider_overrides,
|
...thinkingConfig.provider_overrides,
|
||||||
[provider]: {
|
[provider]: {
|
||||||
...thinkingConfig.provider_overrides?.[provider],
|
...thinkingConfig.provider_overrides?.[provider],
|
||||||
[tier]: level.toLowerCase(),
|
[normalizedTier]: level,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
hasChanges = true;
|
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) {
|
if (hasChanges) {
|
||||||
updateUnifiedConfig({ thinking: thinkingConfig });
|
updateUnifiedConfig({ thinking: thinkingConfig });
|
||||||
console.log(ok('Configuration updated'));
|
console.log(ok('Configuration updated'));
|
||||||
|
|||||||
@@ -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: <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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -284,6 +284,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
['ccs config image-analysis --enable', 'Enable image analysis'],
|
['ccs config image-analysis --enable', 'Enable image analysis'],
|
||||||
['ccs config thinking', 'Show thinking/reasoning settings'],
|
['ccs config thinking', 'Show thinking/reasoning settings'],
|
||||||
['ccs config thinking --mode auto', 'Set thinking mode'],
|
['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 config --port 3000', 'Use specific port'],
|
||||||
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
|
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
|
||||||
['ccs persist --list-backups', 'List available settings.json backups'],
|
['ccs persist --list-backups', 'List available settings.json backups'],
|
||||||
|
|||||||
@@ -261,8 +261,17 @@ router.get('/thinking', (_req: Request, res: Response): void => {
|
|||||||
*/
|
*/
|
||||||
router.put('/thinking', (req: Request, res: Response): void => {
|
router.put('/thinking', (req: Request, res: Response): void => {
|
||||||
try {
|
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;
|
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
|
// 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 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
|
// Validate mode if provided
|
||||||
if (updates.mode !== undefined) {
|
if (updates.mode !== undefined) {
|
||||||
const validModes = ['auto', 'off', 'manual'];
|
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(', ')}` });
|
res.status(400).json({ error: `Invalid mode: must be one of ${validModes.join(', ')}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updates.mode = normalizedMode as ThinkingConfig['mode'];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate override if provided (budget or level)
|
// 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
|
// C3: Reject objects/arrays - only number or string allowed
|
||||||
if (typeof updates.override !== 'number' && typeof updates.override !== 'string') {
|
if (typeof updates.override !== 'number' && typeof updates.override !== 'string') {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
@@ -313,6 +334,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
normalizedOverride = updates.override;
|
||||||
} else if (typeof updates.override === 'string') {
|
} else if (typeof updates.override === 'string') {
|
||||||
const normalizedValue = updates.override.toLowerCase().trim();
|
const normalizedValue = updates.override.toLowerCase().trim();
|
||||||
const validValues = [...VALID_THINKING_LEVELS, ...THINKING_OFF_VALUES] as readonly string[];
|
const validValues = [...VALID_THINKING_LEVELS, ...THINKING_OFF_VALUES] as readonly string[];
|
||||||
@@ -322,6 +344,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
|
|||||||
});
|
});
|
||||||
return;
|
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<string, Partial<ThinkingTierDefaults>>)
|
// 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 (
|
if (
|
||||||
typeof updates.provider_overrides !== 'object' ||
|
typeof updates.provider_overrides !== 'object' ||
|
||||||
updates.provider_overrides === null ||
|
updates.provider_overrides === null ||
|
||||||
@@ -362,6 +385,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
const validLevels = [...VALID_THINKING_LEVELS] as string[];
|
const validLevels = [...VALID_THINKING_LEVELS] as string[];
|
||||||
const validTiers = [...VALID_THINKING_TIERS] 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)) {
|
for (const [provider, tierOverrides] of Object.entries(updates.provider_overrides)) {
|
||||||
if (typeof provider !== 'string' || provider.trim() === '') {
|
if (typeof provider !== 'string' || provider.trim() === '') {
|
||||||
res
|
res
|
||||||
@@ -383,26 +407,41 @@ router.put('/thinking', (req: Request, res: Response): void => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof level !== 'string' || !validLevels.includes(level)) {
|
if (typeof level !== 'string' || !validLevels.includes(level.toLowerCase().trim())) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `Invalid level for provider_overrides.${provider}.${tier}: must be one of ${validLevels.join(', ')}`,
|
error: `Invalid level for provider_overrides.${provider}.${tier}: must be one of ${validLevels.join(', ')}`,
|
||||||
});
|
});
|
||||||
return;
|
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
|
// Update thinking section
|
||||||
config.thinking = {
|
config.thinking = {
|
||||||
mode: updates.mode ?? config.thinking?.mode ?? 'auto',
|
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: {
|
tier_defaults: {
|
||||||
opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high',
|
opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high',
|
||||||
sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium',
|
sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium',
|
||||||
haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low',
|
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,
|
show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -141,4 +141,87 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
|||||||
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
expect(capturedBody?.model).toBe('gpt-5.3-codex');
|
||||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -311,6 +311,24 @@ describe('applyThinkingConfig - composite variant integration', () => {
|
|||||||
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
|
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', () => {
|
it('uses per-tier provider capability checks for mixed-provider composites', () => {
|
||||||
const envVars: NodeJS.ProcessEnv = {
|
const envVars: NodeJS.ProcessEnv = {
|
||||||
ANTHROPIC_MODEL: 'gemini-2.5-pro',
|
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,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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,13 @@ const DEFAULT_THINKING_CONFIG: ThinkingConfig = {
|
|||||||
|
|
||||||
const FETCH_TIMEOUT = 10000; // 10 second timeout
|
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() {
|
export function useThinkingConfig() {
|
||||||
const [config, setConfig] = useState<ThinkingConfig | null>(null);
|
const [config, setConfig] = useState<ThinkingConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -27,6 +34,7 @@ export function useThinkingConfig() {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
// W4: Track lastModified for optimistic locking
|
// W4: Track lastModified for optimistic locking
|
||||||
const lastModifiedRef = useRef<number | undefined>(undefined);
|
const lastModifiedRef = useRef<number | undefined>(undefined);
|
||||||
|
const hasLoadedConfigRef = useRef(false);
|
||||||
|
|
||||||
const fetchConfig = useCallback(async () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -49,6 +57,7 @@ export function useThinkingConfig() {
|
|||||||
setConfig(data.config || DEFAULT_THINKING_CONFIG);
|
setConfig(data.config || DEFAULT_THINKING_CONFIG);
|
||||||
// W4: Store lastModified for optimistic locking
|
// W4: Store lastModified for optimistic locking
|
||||||
lastModifiedRef.current = data.lastModified;
|
lastModifiedRef.current = data.lastModified;
|
||||||
|
hasLoadedConfigRef.current = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if ((err as Error).name === 'AbortError') {
|
if ((err as Error).name === 'AbortError') {
|
||||||
@@ -56,7 +65,6 @@ export function useThinkingConfig() {
|
|||||||
} else {
|
} else {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
}
|
}
|
||||||
setConfig(DEFAULT_THINKING_CONFIG);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -66,9 +74,32 @@ export function useThinkingConfig() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveConfig = useCallback(
|
const saveConfig = useCallback(
|
||||||
async (updates: Partial<ThinkingConfig>) => {
|
async (updates: ThinkingUpdatePayload) => {
|
||||||
const currentConfig = config || DEFAULT_THINKING_CONFIG;
|
if (!hasLoadedConfigRef.current || config === null) {
|
||||||
const optimisticConfig = { ...currentConfig, ...updates };
|
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);
|
setConfig(optimisticConfig);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -158,7 +189,11 @@ export function useThinkingConfig() {
|
|||||||
|
|
||||||
const setOverride = useCallback(
|
const setOverride = useCallback(
|
||||||
(value: string | number | undefined) => {
|
(value: string | number | undefined) => {
|
||||||
saveConfig({ override: value });
|
if (value === undefined) {
|
||||||
|
saveConfig({ override: null, clear_override: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveConfig({ override: value, clear_override: false });
|
||||||
},
|
},
|
||||||
[saveConfig]
|
[saveConfig]
|
||||||
);
|
);
|
||||||
@@ -177,8 +212,8 @@ export function useThinkingConfig() {
|
|||||||
? { ...otherProviders, [provider]: updatedProvider }
|
? { ...otherProviders, [provider]: updatedProvider }
|
||||||
: otherProviders;
|
: otherProviders;
|
||||||
saveConfig({
|
saveConfig({
|
||||||
provider_overrides:
|
provider_overrides: Object.keys(updatedOverrides).length > 0 ? updatedOverrides : null,
|
||||||
Object.keys(updatedOverrides).length > 0 ? updatedOverrides : undefined,
|
clear_provider_overrides: Object.keys(updatedOverrides).length === 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
saveConfig({
|
saveConfig({
|
||||||
@@ -186,6 +221,7 @@ export function useThinkingConfig() {
|
|||||||
...currentOverrides,
|
...currentOverrides,
|
||||||
[provider]: { ...currentProviderOverrides, [tier]: level },
|
[provider]: { ...currentProviderOverrides, [tier]: level },
|
||||||
},
|
},
|
||||||
|
clear_provider_overrides: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
* Settings section for thinking budget configuration
|
* Settings section for thinking budget configuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -32,10 +33,13 @@ const THINKING_LEVELS = [
|
|||||||
const OVERRIDE_LEVELS = [
|
const OVERRIDE_LEVELS = [
|
||||||
{ value: '__none__', label: 'None (use CLI flags only)' },
|
{ value: '__none__', label: 'None (use CLI flags only)' },
|
||||||
...THINKING_LEVELS,
|
...THINKING_LEVELS,
|
||||||
|
{ value: '__custom__', label: 'Custom budget (number)' },
|
||||||
{ value: 'off', label: 'Off (disable thinking)' },
|
{ 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() {
|
export default function ThinkingSection() {
|
||||||
const {
|
const {
|
||||||
@@ -51,12 +55,71 @@ export default function ThinkingSection() {
|
|||||||
setOverride,
|
setOverride,
|
||||||
setProviderOverride,
|
setProviderOverride,
|
||||||
} = useThinkingConfig();
|
} = useThinkingConfig();
|
||||||
const [providerOverridesOpen, setProviderOverridesOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
@@ -148,8 +211,12 @@ export default function ThinkingSection() {
|
|||||||
config.mode === mode
|
config.mode === mode
|
||||||
? 'bg-primary/10 border border-primary/30'
|
? 'bg-primary/10 border border-primary/30'
|
||||||
: 'bg-muted/50 hover:bg-muted/80'
|
: 'bg-muted/50 hover:bg-muted/80'
|
||||||
}`}
|
} ${saving ? 'opacity-70 pointer-events-none' : ''}`}
|
||||||
onClick={() => setMode(mode)}
|
onClick={() => {
|
||||||
|
if (!saving) {
|
||||||
|
setMode(mode);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium capitalize">{mode}</p>
|
<p className="font-medium capitalize">{mode}</p>
|
||||||
@@ -214,8 +281,21 @@ export default function ThinkingSection() {
|
|||||||
Applied to all sessions. CLI flags still take priority.
|
Applied to all sessions. CLI flags still take priority.
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={config.override !== undefined ? String(config.override) : '__none__'}
|
value={overrideSelectValue}
|
||||||
onValueChange={(value) => setOverride(value === '__none__' ? undefined : value)}
|
onValueChange={(value) => {
|
||||||
|
if (value === '__none__') {
|
||||||
|
setOverride(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value === '__custom__') {
|
||||||
|
if (!customOverrideBudget) {
|
||||||
|
setCustomOverrideBudgetInput('8192');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomOverrideBudgetInput(null);
|
||||||
|
setOverride(value);
|
||||||
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -229,6 +309,34 @@ export default function ThinkingSection() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -237,19 +345,39 @@ export default function ThinkingSection() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 text-base font-medium w-full text-left"
|
className="flex items-center gap-2 text-base font-medium w-full text-left"
|
||||||
onClick={() => setProviderOverridesOpen(!providerOverridesOpen)}
|
onClick={() =>
|
||||||
|
setProviderOverridesOpenOverride((prev) => !(prev ?? hasProviderOverrides))
|
||||||
|
}
|
||||||
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`}
|
className={`w-4 h-4 transition-transform ${providerOverridesOpen ? 'rotate-0' : '-rotate-90'}`}
|
||||||
/>
|
/>
|
||||||
Provider Overrides
|
Provider Overrides ({Object.keys(config.provider_overrides ?? {}).length})
|
||||||
</button>
|
</button>
|
||||||
{providerOverridesOpen && (
|
{providerOverridesOpen && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Override tier defaults for specific providers.
|
Override tier defaults for specific providers. Add custom provider keys as needed.
|
||||||
</p>
|
</p>
|
||||||
{KNOWN_PROVIDERS.map((provider) => (
|
<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">
|
<div key={provider} className="space-y-2 p-3 rounded-lg bg-muted/30">
|
||||||
<Label className="capitalize font-medium text-sm">{provider}</Label>
|
<Label className="capitalize font-medium text-sm">{provider}</Label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user