Merge pull request #355 from kaitranntt/dev

feat(thinking): add extended thinking mode with budget controls
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-22 13:52:17 -05:00
committed by GitHub
25 changed files with 1816 additions and 33 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.24.2-dev.2",
"version": "7.24.2-dev.3",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+72 -2
View File
@@ -27,6 +27,7 @@ import {
CLIPROXY_DEFAULT_PORT,
getCliproxyWritablePath,
validatePort,
applyThinkingConfig,
} from './config-generator';
import { checkRemoteProxy } from './remote-proxy-client';
import { isAuthenticated } from './auth-handler';
@@ -327,6 +328,64 @@ export async function execClaudeWithCLIProxy(
setNickname = argsWithoutProxy[nicknameIdx + 1];
}
// Parse --thinking <value> flag for thinking budget control
// Supports: level names (low, medium, high, xhigh, auto), numeric budget, or 'off'/'none'
// Accepts both --thinking=value and --thinking value formats
let thinkingOverride: string | number | undefined;
// Check for --thinking=value format first
const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking='));
if (thinkingEqArg) {
const val = thinkingEqArg.substring('--thinking='.length);
// Handle empty value after equals (--thinking=)
if (!val || val.trim() === '') {
console.error(fail('--thinking requires a value'));
console.error(' Examples: --thinking=low, --thinking=8192, --thinking=off');
console.error(' Levels: minimal, low, medium, high, xhigh, auto');
process.exit(1);
}
// Parse as number if numeric, otherwise keep as string (level name)
const numVal = parseInt(val, 10);
thinkingOverride = !isNaN(numVal) ? numVal : val;
// W2: Warn if multiple --thinking flags found (check both formats)
const allThinkingFlags = argsWithoutProxy.filter(
(arg) => arg === '--thinking' || arg.startsWith('--thinking=')
);
if (allThinkingFlags.length > 1) {
console.warn(
`[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}`
);
}
} else {
// Fall back to --thinking value format
const thinkingIdx = argsWithoutProxy.indexOf('--thinking');
if (thinkingIdx !== -1) {
const nextArg = argsWithoutProxy[thinkingIdx + 1];
// U1: Check if --thinking has a value (not missing or another flag)
if (!nextArg || nextArg.startsWith('-')) {
console.error(fail('--thinking requires a value'));
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, auto');
process.exit(1);
}
const val = nextArg;
// Parse as number if numeric, otherwise keep as string (level name)
const numVal = parseInt(val, 10);
thinkingOverride = !isNaN(numVal) ? numVal : val;
// W2: Warn if multiple --thinking flags found (check both formats)
const allThinkingFlags = argsWithoutProxy.filter(
(arg) => arg === '--thinking' || arg.startsWith('--thinking=')
);
if (allThinkingFlags.length > 1) {
console.warn(
`[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${val}`
);
}
}
}
// Handle --accounts: list accounts and exit
if (showAccounts) {
const accounts = getProviderAccounts(provider);
@@ -761,6 +820,10 @@ export async function execClaudeWithCLIProxy(
)
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
// Apply thinking configuration to model (auto tier-based or manual override)
// This adds thinking suffix like model(high) or model(8192) for CLIProxyAPIPlus
applyThinkingConfig(envVars, provider, thinkingOverride);
// Codex-only: inject OpenAI reasoning effort based on tier model mapping.
// Maps by request.model:
// - OPUS/default model → xhigh
@@ -850,6 +913,7 @@ export async function execClaudeWithCLIProxy(
'--accounts',
'--use',
'--nickname',
'--thinking',
'--incognito',
'--no-incognito',
'--import',
@@ -859,8 +923,14 @@ export async function execClaudeWithCLIProxy(
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
// Filter out CCS flags
if (ccsFlags.includes(arg)) return false;
// Filter out value after --use or --nickname
if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname')
// Filter out --thinking=value format
if (arg.startsWith('--thinking=')) return false;
// Filter out value after --use, --nickname, or --thinking
if (
argsWithoutProxy[idx - 1] === '--use' ||
argsWithoutProxy[idx - 1] === '--nickname' ||
argsWithoutProxy[idx - 1] === '--thinking'
)
return false;
return true;
});
+26 -1
View File
@@ -1,6 +1,7 @@
import * as http from 'http';
import * as https from 'https';
import { URL } from 'url';
import { getModelMaxLevel } from './model-catalog';
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
@@ -51,10 +52,32 @@ const EFFORT_RANK: Record<CodexReasoningEffort, number> = {
xhigh: 3,
};
/** All valid codex effort levels in rank order */
const EFFORT_BY_RANK: CodexReasoningEffort[] = ['medium', 'high', 'xhigh'];
function minEffort(a: CodexReasoningEffort, b: CodexReasoningEffort): CodexReasoningEffort {
return EFFORT_RANK[a] <= EFFORT_RANK[b] ? a : b;
}
/**
* Cap effort at model's max level from catalog.
* Returns the capped effort (or original if no cap applies).
*/
function capEffortAtModelMax(model: string, effort: CodexReasoningEffort): CodexReasoningEffort {
const maxLevel = getModelMaxLevel('codex', model);
if (!maxLevel) return effort;
// Map maxLevel to CodexReasoningEffort (only medium/high/xhigh are valid)
const maxEffort = EFFORT_BY_RANK.find((e) => e === maxLevel);
if (!maxEffort) return effort;
// Cap if effort exceeds max
if (EFFORT_RANK[effort] > EFFORT_RANK[maxEffort]) {
return maxEffort;
}
return effort;
}
export function buildCodexModelEffortMap(
models: CodexReasoningModelMap,
defaultEffort: CodexReasoningEffort = 'medium'
@@ -85,7 +108,9 @@ export function getEffortForModel(
defaultEffort: CodexReasoningEffort
): CodexReasoningEffort {
if (!model) return defaultEffort;
return modelEffort.get(model) ?? defaultEffort;
const effort = modelEffort.get(model) ?? defaultEffort;
// Apply model-specific cap from catalog
return capEffortAtModelMax(model, effort);
}
export function injectReasoningEffortIntoBody(
+163 -1
View File
@@ -15,14 +15,176 @@ import { expandPath } from '../utils/helpers';
import { warn } from '../utils/ui';
import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types';
import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader';
import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader';
import {
loadOrCreateUnifiedConfig,
getGlobalEnvConfig,
getThinkingConfig,
} from '../config/unified-config-loader';
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
import { supportsThinking } from './model-catalog';
import { ThinkingConfig } from '../config/unified-config-types';
import { validateThinking } from './thinking-validator';
/**
* Check if warnings should be shown based on thinking config.
* Defaults to true if show_warnings is not explicitly false.
*/
function shouldShowWarnings(thinkingConfig: ThinkingConfig): boolean {
return thinkingConfig.show_warnings !== false;
}
/** Settings file structure for user overrides */
interface ProviderSettings {
env: NodeJS.ProcessEnv;
}
/**
* Detect current tier from model name.
* Looks at ANTHROPIC_MODEL to determine if using opus/sonnet/haiku tier.
*/
export type ModelTier = 'opus' | 'sonnet' | 'haiku';
/**
* Detect tier from model name.
* Returns 'sonnet' as default if unclear.
*/
export function detectTierFromModel(modelName: string): ModelTier {
const lower = modelName.toLowerCase();
if (lower.includes('opus')) return 'opus';
if (lower.includes('haiku')) return 'haiku';
return 'sonnet'; // Default to sonnet (most common)
}
/**
* Apply thinking suffix to model name.
* CLIProxyAPIPlus parses suffixes like model(level) or model(budget).
*
* @param model - Base model name
* @param thinkingValue - Level name (e.g., 'high') or numeric budget
* @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)"
*/
export function applyThinkingSuffix(model: string, thinkingValue: string | number): string {
// Don't apply if model already ends with a parenthesized suffix (e.g., "model(high)" or "model(8192)")
// Matches: ends with "(...)" where content is non-empty
if (/\([^)]+\)$/.test(model)) {
return model;
}
return `${model}(${thinkingValue})`;
}
/**
* Get thinking value for tier based on config.
* Respects provider-specific overrides if configured.
*/
export function getThinkingValueForTier(
tier: ModelTier,
provider: CLIProxyProvider,
thinkingConfig: ThinkingConfig
): string {
// Check provider-specific override first
const providerOverride = thinkingConfig.provider_overrides?.[provider]?.[tier];
if (providerOverride) {
return providerOverride;
}
// Fall back to global tier default (with null guard)
return thinkingConfig.tier_defaults?.[tier] ?? 'medium';
}
/**
* Apply thinking configuration to env vars.
* Modifies ANTHROPIC_MODEL and tier models with thinking suffixes.
*
* @param envVars - Environment variables to modify
* @param provider - CLIProxy provider
* @param thinkingOverride - Optional CLI override (takes priority over config)
* @returns Modified env vars with thinking suffixes applied
*/
export function applyThinkingConfig(
envVars: NodeJS.ProcessEnv,
provider: CLIProxyProvider,
thinkingOverride?: string | number
): NodeJS.ProcessEnv {
const thinkingConfig = getThinkingConfig();
const result = { ...envVars };
// Check if thinking is off
if (thinkingConfig.mode === 'off' && thinkingOverride === undefined) {
return result;
}
// Get base model to check thinking support
const baseModel = result.ANTHROPIC_MODEL || '';
if (!supportsThinking(provider, baseModel)) {
// U2: Warn user if they explicitly provided --thinking but model doesn't support it
if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) {
console.warn(
warn(
`Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.`
)
);
}
return result;
}
// Determine thinking value to use
let thinkingValue: string | number;
if (thinkingOverride !== undefined) {
// CLI override takes priority
thinkingValue = thinkingOverride;
} else if (thinkingConfig.mode === 'manual' && thinkingConfig.override !== undefined) {
// Config manual mode with override
thinkingValue = thinkingConfig.override;
} else if (thinkingConfig.mode === 'auto') {
// Auto mode: detect tier and apply default
const tier = detectTierFromModel(baseModel);
thinkingValue = getThinkingValueForTier(tier, provider, thinkingConfig);
} else {
return result; // No thinking to apply
}
// Validate thinking value against model capabilities
const validation = validateThinking(provider, baseModel, thinkingValue);
if (validation.warning && shouldShowWarnings(thinkingConfig)) {
console.warn(warn(validation.warning));
}
thinkingValue = validation.value;
// If validation says off, don't apply suffix
if (thinkingValue === 'off') {
return result;
}
// Apply thinking suffix to main model
if (result.ANTHROPIC_MODEL) {
result.ANTHROPIC_MODEL = applyThinkingSuffix(result.ANTHROPIC_MODEL, thinkingValue);
}
// Apply to tier models if they support thinking
const tierModels = [
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
for (const tierVar of tierModels) {
const model = result[tierVar];
if (model && supportsThinking(provider, model)) {
// Get tier-specific thinking value
const tier = tierVar.includes('OPUS')
? 'opus'
: tierVar.includes('SONNET')
? 'sonnet'
: 'haiku';
const tierThinkingValue =
thinkingOverride ?? getThinkingValueForTier(tier, provider, thinkingConfig);
result[tierVar] = applyThinkingSuffix(model, tierThinkingValue);
}
}
return result;
}
/**
* Validate port is a valid positive integer (1-65535).
* Returns default port if invalid.
+14
View File
@@ -152,3 +152,17 @@ export {
resetAuthToDefaults,
getAuthSummary,
} from './auth-token-manager';
// Thinking validator
export type { ThinkingValidationResult } from './thinking-validator';
export {
validateThinking,
THINKING_LEVEL_BUDGETS,
VALID_THINKING_LEVELS,
VALID_THINKING_TIERS,
THINKING_OFF_VALUES,
THINKING_AUTO_VALUE,
THINKING_BUDGET_MIN,
THINKING_BUDGET_MAX,
THINKING_BUDGET_DEFAULT_MIN,
} from './thinking-validator';
+111 -2
View File
@@ -7,6 +7,27 @@
import { CLIProxyProvider } from './types';
/**
* Thinking support configuration for a model.
* Defines how thinking/reasoning budget can be controlled.
*/
export interface ThinkingSupport {
/** Type of thinking control: 'budget' (token count), 'levels' (named levels), 'none' */
type: 'budget' | 'levels' | 'none';
/** Minimum budget tokens (for budget type) */
min?: number;
/** Maximum budget tokens (for budget type) */
max?: number;
/** Valid level names (for levels type) */
levels?: string[];
/** Maximum reasoning effort level (caps effort at this level for levels type) */
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
/** Whether zero/disabled thinking is allowed */
zeroAllowed?: boolean;
/** Whether dynamic/auto thinking is allowed */
dynamicAllowed?: boolean;
}
/**
* Model entry definition
*/
@@ -27,6 +48,8 @@ export interface ModelEntry {
deprecated?: boolean;
/** Deprecation reason/message */
deprecationReason?: string;
/** Thinking/reasoning support configuration */
thinking?: ThinkingSupport;
}
/**
@@ -54,21 +77,37 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
id: 'gemini-claude-opus-4-5-thinking',
name: 'Claude Opus 4.5 Thinking',
description: 'Most capable, extended thinking',
thinking: {
type: 'budget',
min: 1024,
max: 100000,
zeroAllowed: false,
dynamicAllowed: true,
},
},
{
id: 'gemini-claude-sonnet-4-5-thinking',
name: 'Claude Sonnet 4.5 Thinking',
description: 'Balanced with extended thinking',
thinking: {
type: 'budget',
min: 1024,
max: 100000,
zeroAllowed: false,
dynamicAllowed: true,
},
},
{
id: 'gemini-claude-sonnet-4-5',
name: 'Claude Sonnet 4.5',
description: 'Fast and capable',
thinking: { type: 'none' },
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
description: 'Google latest model via Antigravity',
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
},
],
},
@@ -82,11 +121,48 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
name: 'Gemini 3 Pro',
tier: 'paid',
description: 'Latest model, requires paid Google account',
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
},
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
description: 'Stable, works with free Google account',
thinking: {
type: 'budget',
min: 128,
max: 32768,
zeroAllowed: false,
dynamicAllowed: true,
},
},
],
},
codex: {
provider: 'codex',
displayName: 'Copilot Codex',
defaultModel: 'gpt-5.2-codex',
models: [
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Full reasoning support (xhigh)',
thinking: {
type: 'levels',
levels: ['medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Capped at high reasoning (no xhigh)',
thinking: {
type: 'levels',
levels: ['medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
],
},
@@ -108,11 +184,13 @@ export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog
/**
* Find model entry by ID
* Note: Model IDs are normalized to lowercase for case-insensitive comparison
*/
export function findModel(provider: CLIProxyProvider, modelId: string): ModelEntry | undefined {
const catalog = MODEL_CATALOG[provider];
if (!catalog) return undefined;
return catalog.models.find((m) => m.id === modelId);
if (!catalog || !modelId) return undefined;
const normalizedId = modelId.trim().toLowerCase();
return catalog.models.find((m) => m.id.toLowerCase() === normalizedId);
}
/**
@@ -149,3 +227,34 @@ export function getModelDeprecationReason(
const model = findModel(provider, modelId);
return model?.deprecationReason;
}
/**
* Get thinking support configuration for a model
*/
export function getModelThinkingSupport(
provider: CLIProxyProvider,
modelId: string
): ThinkingSupport | undefined {
const model = findModel(provider, modelId);
return model?.thinking;
}
/**
* Get the maximum reasoning effort level for a model.
* Returns undefined if model has no cap or is not in catalog.
*/
export function getModelMaxLevel(
provider: CLIProxyProvider,
modelId: string
): ThinkingSupport['maxLevel'] | undefined {
const thinking = getModelThinkingSupport(provider, modelId);
return thinking?.maxLevel;
}
/**
* Check if model supports thinking/reasoning
*/
export function supportsThinking(provider: CLIProxyProvider, modelId: string): boolean {
const thinking = getModelThinkingSupport(provider, modelId);
return thinking !== undefined && thinking.type !== 'none';
}
+403
View File
@@ -0,0 +1,403 @@
/**
* Thinking Budget Validator
*
* Validates user-provided thinking values against model capabilities.
* - Clamps out-of-range budgets
* - Maps invalid levels to closest valid
* - Warns when model doesn't support thinking
*/
import { getModelThinkingSupport, ThinkingSupport } from './model-catalog';
import { CLIProxyProvider } from './types';
/**
* Thinking budget bounds (used for validation across API and CLI)
*/
export const THINKING_BUDGET_MIN = 0;
export const THINKING_BUDGET_MAX = 100000;
export const THINKING_BUDGET_DEFAULT_MIN = 512;
/**
* Result of thinking value validation
*/
export interface ThinkingValidationResult {
/** Whether the value is valid for the model */
valid: boolean;
/** The validated value (possibly clamped/mapped) */
value: string | number;
/** Warning message for user feedback */
warning?: string;
}
/**
* Named thinking level mappings to budget values (when converting level→budget)
*/
export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
minimal: 512,
low: 1024,
medium: 8192,
high: 24576,
xhigh: 32768,
};
/**
* Level rank for comparison (higher = more intensive)
*/
export const THINKING_LEVEL_RANK: Record<string, number> = {
minimal: 1,
low: 2,
medium: 3,
high: 4,
xhigh: 5,
};
/**
* Valid thinking level names
*/
export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const;
export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
/**
* Valid tier names for tier_defaults configuration
*/
export const VALID_THINKING_TIERS = ['opus', 'sonnet', 'haiku'] as const;
export type ThinkingTier = (typeof VALID_THINKING_TIERS)[number];
/**
* Cap a level at the model's maximum supported level.
* Returns the capped level and whether capping occurred.
*/
export function capLevelAtMax(
level: string,
maxLevel: string | undefined
): { level: string; capped: boolean } {
if (!maxLevel) return { level, capped: false };
const levelRank = THINKING_LEVEL_RANK[level] ?? 0;
const maxRank = THINKING_LEVEL_RANK[maxLevel] ?? 5;
if (levelRank > maxRank) {
return { level: maxLevel, capped: true };
}
return { level, capped: false };
}
/**
* Special thinking values
*/
export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const;
export const THINKING_AUTO_VALUE = 'auto';
/**
* Find closest valid level using simple string matching
* Returns undefined if no close match found
*/
function findClosestLevel(input: string, validLevels: string[]): string | undefined {
const normalized = input.toLowerCase().trim();
// Exact match first
if (validLevels.includes(normalized)) {
return normalized;
}
// Prefix matching (e.g., 'med' → 'medium', 'hi' → 'high')
for (const level of validLevels) {
if (level.startsWith(normalized)) {
return level;
}
}
// Common aliases
const aliases: Record<string, string> = {
min: 'minimal',
lo: 'low',
med: 'medium',
mid: 'medium',
hi: 'high',
max: 'xhigh',
ultra: 'xhigh',
extreme: 'xhigh',
};
if (aliases[normalized] && validLevels.includes(aliases[normalized])) {
return aliases[normalized];
}
return undefined;
}
/**
* Validate a thinking value against model capabilities
*
* @param provider - The CLI proxy provider
* @param modelId - The model identifier
* @param value - User-provided thinking value (level name or budget number)
* @returns Validation result with possibly clamped/mapped value and warnings
*/
export function validateThinking(
provider: CLIProxyProvider,
modelId: string,
value: string | number
): ThinkingValidationResult {
const thinking = getModelThinkingSupport(provider, modelId);
// Handle string length limit to prevent performance issues
if (typeof value === 'string' && value.length > 100) {
return {
valid: false,
value: 'off',
warning: 'Thinking value too long (max 100 chars). Using "off".',
};
}
// Handle empty string explicitly
if (typeof value === 'string' && value.trim() === '') {
return {
valid: false,
value: 'off',
warning: 'Empty thinking value not allowed. Using "off".',
};
}
// 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 model has no thinking support info, pass through
if (!thinking) {
return {
valid: true,
value,
warning: `Model ${modelId} has unknown thinking support. Value passed through unchanged.`,
};
}
// Model doesn't support thinking at all
if (thinking.type === 'none') {
return {
valid: false,
value: 'off',
warning: `Model ${modelId} does not support extended thinking. Thinking disabled.`,
};
}
// Handle 'auto' value
if (typeof value === 'string' && value.toLowerCase().trim() === THINKING_AUTO_VALUE) {
if (!thinking.dynamicAllowed) {
return {
valid: false,
value:
thinking.type === 'budget' ? (thinking.min ?? 1024) : (thinking.levels?.[0] ?? 'low'),
warning: `Model ${modelId} does not support dynamic/auto thinking. Using minimum value.`,
};
}
return { valid: true, value: 'auto' };
}
// Budget-type models
if (thinking.type === 'budget') {
return validateBudgetThinking(thinking, value, modelId);
}
// Level-type models
if (thinking.type === 'levels') {
return validateLevelThinking(thinking, value, modelId);
}
// Fallback
return { valid: true, value };
}
/**
* Validate budget-type thinking value
*/
function validateBudgetThinking(
thinking: ThinkingSupport,
value: string | number,
modelId: string
): ThinkingValidationResult {
const min = thinking.min ?? THINKING_BUDGET_MIN;
const max = thinking.max ?? THINKING_BUDGET_MAX;
// Convert level name to budget if needed
let budget: number;
if (typeof value === 'string') {
const normalizedLevel = value.toLowerCase().trim();
if (normalizedLevel in THINKING_LEVEL_BUDGETS) {
budget = THINKING_LEVEL_BUDGETS[normalizedLevel];
} else {
// Strict number parsing: reject partial parses like "123abc"
const parsed = Number(value);
const trimmed = value.trim();
// Accept trailing .0 for whole numbers (e.g., "8192.0" → 8192)
const isWholeWithDecimal = Number.isInteger(parsed) && trimmed === `${parsed}.0`;
if (
isNaN(parsed) ||
!Number.isFinite(parsed) ||
(trimmed !== String(parsed) && !isWholeWithDecimal)
) {
// Not a valid number, try to find closest level
const closest = findClosestLevel(value, Object.keys(THINKING_LEVEL_BUDGETS));
if (closest) {
budget = THINKING_LEVEL_BUDGETS[closest];
} else {
return {
valid: false,
value: min || THINKING_BUDGET_DEFAULT_MIN,
warning: `Invalid thinking value "${value}" for ${modelId}. Using minimum budget ${min || THINKING_BUDGET_DEFAULT_MIN}.`,
};
}
} else {
budget = parsed;
}
}
} else {
budget = value;
}
// Reject NaN/Infinity budgets
if (!Number.isFinite(budget)) {
return {
valid: false,
value: min || THINKING_BUDGET_DEFAULT_MIN,
warning: `Budget must be a finite number. Using minimum ${min || THINKING_BUDGET_DEFAULT_MIN}.`,
};
}
// Reject negative budgets
if (budget < THINKING_BUDGET_MIN) {
return {
valid: false,
value: min || THINKING_BUDGET_DEFAULT_MIN,
warning: `Negative thinking budget not allowed. Using minimum ${min || THINKING_BUDGET_DEFAULT_MIN}.`,
};
}
// Check zero budget
if (budget === 0 && !thinking.zeroAllowed) {
return {
valid: false,
value: min || THINKING_BUDGET_DEFAULT_MIN,
warning: `Model ${modelId} does not support zero thinking budget. Using minimum ${min || THINKING_BUDGET_DEFAULT_MIN}.`,
};
}
// Clamp to valid range
if (budget < min) {
return {
valid: true,
value: min,
warning: `Thinking budget ${budget} below minimum for ${modelId}. Clamped to ${min}.`,
};
}
if (budget > max) {
return {
valid: true,
value: max,
warning: `Thinking budget ${budget} exceeds maximum for ${modelId}. Clamped to ${max}.`,
};
}
return { valid: true, value: budget };
}
/**
* Validate level-type thinking value
*/
function validateLevelThinking(
thinking: ThinkingSupport,
value: string | number,
modelId: string
): ThinkingValidationResult {
const validLevels = thinking.levels ?? [];
const maxLevel = thinking.maxLevel;
// Helper to apply maxLevel cap and build result
const applyMaxCap = (level: string, baseWarning?: string): ThinkingValidationResult => {
const { level: cappedLevel, capped } = capLevelAtMax(level, maxLevel);
const warnings: string[] = [];
if (baseWarning) warnings.push(baseWarning);
if (capped) {
warnings.push(
`Level "${level}" exceeds max "${maxLevel}" for ${modelId}. Capped to "${cappedLevel}".`
);
}
return {
valid: true,
value: cappedLevel,
warning: warnings.length > 0 ? warnings.join(' ') : undefined,
};
};
// If numeric, try to map to closest level by budget
if (typeof value === 'number') {
// Find closest level by comparing to level budgets
let closestLevel = validLevels[0] ?? 'low';
let closestDiff = Infinity;
for (const level of validLevels) {
const levelBudget = THINKING_LEVEL_BUDGETS[level] ?? 8192;
const diff = Math.abs(levelBudget - value);
if (diff < closestDiff) {
closestDiff = diff;
closestLevel = level;
}
}
return applyMaxCap(
closestLevel,
`Model ${modelId} uses named levels. Mapped budget ${value} to "${closestLevel}".`
);
}
// String level
const normalizedLevel = value.toLowerCase().trim();
// Check if it's a valid level for this model
if (validLevels.includes(normalizedLevel)) {
return applyMaxCap(normalizedLevel);
}
// Try to find closest match
const closest = findClosestLevel(normalizedLevel, validLevels);
if (closest) {
return applyMaxCap(
closest,
`Level "${value}" not valid for ${modelId}. Mapped to "${closest}".`
);
}
// Try to map from standard level names to model's levels
const standardToModelLevel: Record<string, string> = {};
const levelOrder = ['minimal', 'low', 'medium', 'high', 'xhigh'];
// Build mapping based on position
for (let i = 0; i < validLevels.length; i++) {
// Map first half of standard levels to lower model levels, second half to higher
const standardIdx = Math.floor((i / validLevels.length) * levelOrder.length);
for (let j = standardIdx; j < levelOrder.length; j++) {
if (!standardToModelLevel[levelOrder[j]]) {
standardToModelLevel[levelOrder[j]] = validLevels[Math.min(i, validLevels.length - 1)];
}
}
}
const mapped = standardToModelLevel[normalizedLevel];
if (mapped) {
return applyMaxCap(
mapped,
`Level "${value}" mapped to "${mapped}" for ${modelId} (available: ${validLevels.join(', ')}).`
);
}
// Default to first level
const defaultLevel = validLevels[0] ?? 'low';
return {
valid: false,
value: defaultLevel,
warning: `Invalid level "${value}" for ${modelId}. Using "${defaultLevel}" (available: ${validLevels.join(', ')}).`,
};
}
+18
View File
@@ -172,6 +172,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
[
'ccs <provider> --thinking <value>',
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs kiro --import', 'Import token from Kiro IDE'],
@@ -282,6 +286,20 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'],
]);
// W3: Thinking Budget explanation
printSubSection('Extended Thinking (--thinking)', [
['--thinking off', 'Disable extended thinking'],
['--thinking auto', 'Let model decide dynamically'],
['--thinking low', '1K tokens - Quick responses'],
['--thinking medium', '8K tokens - Standard analysis'],
['--thinking high', '24K tokens - Deep reasoning'],
['--thinking xhigh', '32K tokens - Maximum depth'],
['--thinking <number>', 'Custom token budget (512-100000)'],
['', ''],
['Note:', 'Extended thinking allocates compute for step-by-step reasoning'],
['', 'before responding. Supported: agy, gemini (thinking models).'],
]);
// CLI Proxy env vars
printSubSection('CLI Proxy Environment Variables', [
['CCS_PROXY_HOST', 'Remote proxy hostname'],
+82 -2
View File
@@ -18,8 +18,10 @@ import {
DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
GlobalEnvConfig,
ThinkingConfig,
DashboardAuthConfig,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
@@ -108,8 +110,23 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
return parsed;
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
console.error(`[X] Failed to load config: ${error}`);
// U3: Provide better context for YAML syntax errors
if (err instanceof yaml.YAMLException) {
const mark = err.mark;
console.error(`[X] YAML syntax error in ${yamlPath}:`);
console.error(
` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}`
);
if (mark?.snippet) {
console.error(` ${mark.snippet}`);
}
console.error(
` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.`
);
} else {
const error = err instanceof Error ? err.message : 'Unknown error';
console.error(`[X] Failed to load config: ${error}`);
}
return null;
}
}
@@ -244,6 +261,20 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
},
},
// Thinking config - auto/manual/off control for reasoning budget
thinking: {
mode: partial.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: partial.thinking?.override,
tier_defaults: {
opus: partial.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
partial.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku:
partial.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: partial.thinking?.provider_overrides,
show_warnings: partial.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
},
// Dashboard auth config - disabled by default
dashboard_auth: {
enabled: partial.dashboard_auth?.enabled ?? DEFAULT_DASHBOARD_AUTH_CONFIG.enabled,
@@ -439,6 +470,25 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Thinking section (extended thinking/reasoning configuration)
if (config.thinking) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Thinking: Extended thinking/reasoning budget configuration');
lines.push('# Controls reasoning depth for supported providers (agy, gemini, codex).');
lines.push('#');
lines.push('# Modes: auto (use tier_defaults), off (disable), manual (--thinking flag only)');
lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto');
lines.push('# Override: Set global override value (number or level name)');
lines.push('# Provider overrides: Per-provider tier defaults');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump({ thinking: config.thinking }, { indent: 2, lineWidth: -1, quotingType: '"' })
.trim()
);
lines.push('');
}
// Dashboard auth section (only if configured)
if (config.dashboard_auth?.enabled) {
lines.push('# ----------------------------------------------------------------------------');
@@ -608,6 +658,36 @@ export function getGlobalEnvConfig(): GlobalEnvConfig {
};
}
/**
* Get thinking configuration.
* Returns defaults if not configured.
*/
export function getThinkingConfig(): ThinkingConfig {
const config = loadOrCreateUnifiedConfig();
// W2: Check for invalid thinking config (e.g., thinking: true instead of object)
if (config.thinking !== undefined && typeof config.thinking !== 'object') {
console.warn(
`[!] Invalid thinking config: expected object, got ${typeof config.thinking}. Using defaults.`
);
console.warn(` Tip: Use 'thinking: { mode: auto }' instead of 'thinking: true'`);
return DEFAULT_THINKING_CONFIG;
}
return {
mode: config.thinking?.mode ?? DEFAULT_THINKING_CONFIG.mode,
override: config.thinking?.override,
tier_defaults: {
opus: config.thinking?.tier_defaults?.opus ?? DEFAULT_THINKING_CONFIG.tier_defaults.opus,
sonnet:
config.thinking?.tier_defaults?.sonnet ?? DEFAULT_THINKING_CONFIG.tier_defaults.sonnet,
haiku: config.thinking?.tier_defaults?.haiku ?? DEFAULT_THINKING_CONFIG.tier_defaults.haiku,
},
provider_overrides: config.thinking?.provider_overrides,
show_warnings: config.thinking?.show_warnings ?? DEFAULT_THINKING_CONFIG.show_warnings,
};
}
/**
* Get dashboard_auth configuration with ENV var override.
* Priority: ENV vars > config.yaml > defaults
+65 -1
View File
@@ -17,8 +17,9 @@
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
* Version 6 = Customizable auth tokens (API key and management secret)
* Version 7 = Quota management for hybrid auto+manual account control
* Version 8 = Thinking/reasoning budget configuration
*/
export const UNIFIED_CONFIG_VERSION = 7;
export const UNIFIED_CONFIG_VERSION = 8;
/**
* Account configuration (formerly in profiles.json).
@@ -426,6 +427,66 @@ export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
};
// ============================================================================
// THINKING CONFIGURATION (v8+)
// ============================================================================
/**
* Thinking mode for auto/manual/off control.
* - auto: Apply tier-based defaults (opus→high, sonnet→medium, haiku→low)
* - off: Disable thinking entirely
* - manual: Use explicit override value
*/
export type ThinkingMode = 'auto' | 'off' | 'manual';
/**
* Tier-to-thinking level defaults.
* Maps Claude tier names to thinking level names.
*/
export interface ThinkingTierDefaults {
/** Thinking level for opus tier (default: 'high') */
opus: string;
/** Thinking level for sonnet tier (default: 'medium') */
sonnet: string;
/** Thinking level for haiku tier (default: 'low') */
haiku: string;
}
/**
* Thinking configuration section.
* Controls thinking/reasoning budget injection for CLIProxy providers.
*/
export interface ThinkingConfig {
/** Thinking mode (default: 'auto') */
mode: ThinkingMode;
/** Manual override value (level name or budget number) */
override?: string | number;
/** Tier-to-level mapping */
tier_defaults: ThinkingTierDefaults;
/** Per-provider overrides (e.g., { gemini: { opus: 'high' } }) */
provider_overrides?: Record<string, Partial<ThinkingTierDefaults>>;
/** Show warning when values are clamped (default: true) */
show_warnings?: boolean;
}
/**
* Default thinking tier defaults.
*/
export const DEFAULT_THINKING_TIER_DEFAULTS: ThinkingTierDefaults = {
opus: 'high',
sonnet: 'medium',
haiku: 'low',
};
/**
* Default thinking configuration.
*/
export const DEFAULT_THINKING_CONFIG: ThinkingConfig = {
mode: 'auto',
tier_defaults: { ...DEFAULT_THINKING_TIER_DEFAULTS },
show_warnings: true,
};
/**
* Dashboard authentication configuration.
* Optional login protection for CCS dashboard.
@@ -480,6 +541,8 @@ export interface UnifiedConfig {
cliproxy_server?: CliproxyServerConfig;
/** Quota management configuration (v7+) */
quota_management?: QuotaManagementConfig;
/** Thinking/reasoning budget configuration (v8+) */
thinking?: ThinkingConfig;
/** Dashboard authentication configuration (optional) */
dashboard_auth?: DashboardAuthConfig;
}
@@ -572,6 +635,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
copilot: { ...DEFAULT_COPILOT_CONFIG },
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
thinking: { ...DEFAULT_THINKING_CONFIG },
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
};
}
+202
View File
@@ -11,7 +11,17 @@ import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
getGlobalEnvConfig,
getThinkingConfig,
getConfigYamlPath,
} from '../../config/unified-config-loader';
import type { ThinkingConfig } from '../../config/unified-config-types';
import {
THINKING_BUDGET_MIN,
THINKING_BUDGET_MAX,
VALID_THINKING_LEVELS,
VALID_THINKING_TIERS,
THINKING_OFF_VALUES,
} from '../../cliproxy';
import { validateFilePath } from './route-helpers';
const router = Router();
@@ -221,4 +231,196 @@ router.put('/global-env', (req: Request, res: Response): void => {
}
});
// ==================== Thinking Configuration ====================
/**
* GET /api/thinking - Get thinking budget configuration
* Returns the thinking section from config.yaml with mtime for optimistic locking
*/
router.get('/thinking', (_req: Request, res: Response): void => {
try {
const config = getThinkingConfig();
// W4: Include mtime for optimistic locking
let lastModified: number | undefined;
try {
const stats = fs.statSync(getConfigYamlPath());
lastModified = stats.mtimeMs;
} catch {
// File may not exist yet
}
res.json({ config, lastModified });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/thinking - Update thinking budget configuration
* Updates the thinking section in config.yaml
* Supports optimistic locking via lastModified field
*/
router.put('/thinking', (req: Request, res: Response): void => {
try {
const { lastModified, ...updates } = req.body as Partial<ThinkingConfig> & {
lastModified?: number;
};
// W4: Optimistic locking - check if file was modified since last read
if (lastModified !== undefined) {
try {
const stats = fs.statSync(getConfigYamlPath());
if (stats.mtimeMs > lastModified) {
res.status(409).json({
error: 'Config was modified by another process. Please refresh and try again.',
currentMtime: stats.mtimeMs,
});
return;
}
} catch {
// File may not exist yet, allow creation
}
}
const config = loadOrCreateUnifiedConfig();
// Validate mode if provided
if (updates.mode !== undefined) {
const validModes = ['auto', 'off', 'manual'];
if (!validModes.includes(updates.mode)) {
res.status(400).json({ error: `Invalid mode: must be one of ${validModes.join(', ')}` });
return;
}
}
// Validate override if provided (budget or level)
if (updates.override !== undefined) {
// C3: Reject objects/arrays - only number or string allowed
if (typeof updates.override !== 'number' && typeof updates.override !== 'string') {
res.status(400).json({
error: 'Invalid override: must be a number or string, not object/array',
});
return;
}
if (typeof updates.override === 'number') {
if (
!Number.isFinite(updates.override) ||
updates.override < THINKING_BUDGET_MIN ||
updates.override > THINKING_BUDGET_MAX
) {
res.status(400).json({
error: `Invalid override: must be between ${THINKING_BUDGET_MIN} and ${THINKING_BUDGET_MAX}`,
});
return;
}
} else if (typeof updates.override === 'string') {
const normalizedValue = updates.override.toLowerCase().trim();
const validValues = [...VALID_THINKING_LEVELS, ...THINKING_OFF_VALUES] as readonly string[];
if (!validValues.includes(normalizedValue)) {
res.status(400).json({
error: `Invalid override: must be a level name (${VALID_THINKING_LEVELS.join(', ')}) or a number`,
});
return;
}
}
}
// Validate tier_defaults if provided
if (updates.tier_defaults !== undefined) {
if (
typeof updates.tier_defaults !== 'object' ||
updates.tier_defaults === null ||
Array.isArray(updates.tier_defaults)
) {
res.status(400).json({ error: 'Invalid tier_defaults: must be an object' });
return;
}
const validLevels = [...VALID_THINKING_LEVELS] as string[];
for (const [tier, level] of Object.entries(updates.tier_defaults)) {
if (!(VALID_THINKING_TIERS as readonly string[]).includes(tier)) {
res.status(400).json({ error: `Invalid tier: ${tier}` });
return;
}
if (!validLevels.includes(level)) {
res.status(400).json({
error: `Invalid level for ${tier}: must be one of ${validLevels.join(', ')}`,
});
return;
}
}
}
// C4: Validate provider_overrides if provided (nested structure: Record<string, Partial<ThinkingTierDefaults>>)
if (updates.provider_overrides !== undefined) {
if (
typeof updates.provider_overrides !== 'object' ||
updates.provider_overrides === null ||
Array.isArray(updates.provider_overrides)
) {
res.status(400).json({ error: 'Invalid provider_overrides: must be an object' });
return;
}
const validLevels = [...VALID_THINKING_LEVELS] as string[];
const validTiers = [...VALID_THINKING_TIERS] as string[];
for (const [provider, tierOverrides] of Object.entries(updates.provider_overrides)) {
if (typeof provider !== 'string' || provider.trim() === '') {
res
.status(400)
.json({ error: 'Invalid provider_overrides: keys must be non-empty strings' });
return;
}
// tierOverrides should be Partial<ThinkingTierDefaults>
if (typeof tierOverrides !== 'object' || tierOverrides === null) {
res.status(400).json({
error: `Invalid provider_overrides for ${provider}: must be an object with tier→level mapping`,
});
return;
}
for (const [tier, level] of Object.entries(tierOverrides)) {
if (!validTiers.includes(tier)) {
res.status(400).json({
error: `Invalid tier '${tier}' in provider_overrides.${provider}: must be one of ${validTiers.join(', ')}`,
});
return;
}
if (typeof level !== 'string' || !validLevels.includes(level)) {
res.status(400).json({
error: `Invalid level for provider_overrides.${provider}.${tier}: must be one of ${validLevels.join(', ')}`,
});
return;
}
}
}
}
// Update thinking section
config.thinking = {
mode: updates.mode ?? config.thinking?.mode ?? 'auto',
override: updates.override ?? 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,
show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true,
};
saveUnifiedConfig(config);
// W4: Return new mtime for subsequent requests
let newMtime: number | undefined;
try {
const stats = fs.statSync(getConfigYamlPath());
newMtime = stats.mtimeMs;
} catch {
// Ignore
}
res.json({ success: true, config: config.thinking, lastModified: newMtime });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+4 -2
View File
@@ -318,8 +318,10 @@ auth-dir: "/test"
const userKeys = parseUserApiKeys(configContent);
// Empty strings should be filtered out (only truthy values)
assert.deepStrictEqual(userKeys, ['valid-key']);
// Empty strings should be filtered out - parseUserApiKeys checks: key && key !== CCS_INTERNAL_API_KEY
// Empty string is falsy, so it's excluded along with internal key
assert.strictEqual(userKeys.length, 1, 'Should filter out empty string key');
assert.deepStrictEqual(userKeys, ['valid-key'], 'Should only include valid non-empty keys');
});
it('handles config with Windows line endings', () => {
+14 -6
View File
@@ -23,9 +23,15 @@ describe('Model Catalog', () => {
assert.strictEqual(MODEL_CATALOG.gemini.displayName, 'Gemini');
});
it('does not contain codex or qwen (not configurable)', () => {
it('contains Codex provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.codex, 'Should have codex provider');
assert.strictEqual(MODEL_CATALOG.codex.provider, 'codex');
assert.strictEqual(MODEL_CATALOG.codex.displayName, 'Copilot Codex');
});
it('does not contain qwen (not configurable)', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.codex, undefined);
assert.strictEqual(MODEL_CATALOG.qwen, undefined);
});
});
@@ -115,9 +121,9 @@ describe('Model Catalog', () => {
assert.strictEqual(supportsModelConfig('gemini'), true);
});
it('returns false for codex', () => {
it('returns true for codex', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('codex'), false);
assert.strictEqual(supportsModelConfig('codex'), true);
});
it('returns false for qwen', () => {
@@ -142,10 +148,12 @@ describe('Model Catalog', () => {
assert.strictEqual(catalog.provider, 'gemini');
});
it('returns undefined for codex', () => {
it('returns catalog for codex', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('codex');
assert.strictEqual(catalog, undefined);
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'codex');
assert(Array.isArray(catalog.models));
});
});
-6
View File
@@ -38,12 +38,6 @@ describe('Model Config', () => {
});
describe('configureProviderModel', () => {
it('returns false for unsupported provider (codex)', async () => {
const { configureProviderModel } = modelConfig;
const result = await configureProviderModel('codex', true);
assert.strictEqual(result, false);
});
it('returns false for unsupported provider (qwen)', async () => {
const { configureProviderModel } = modelConfig;
const result = await configureProviderModel('qwen', true);
@@ -0,0 +1,171 @@
/**
* Thinking Validator Unit Tests
*
* Tests for thinking budget validation logic
*/
import { describe, it, expect } from 'bun:test';
import {
validateThinking,
THINKING_LEVEL_BUDGETS,
VALID_THINKING_LEVELS,
THINKING_OFF_VALUES,
THINKING_BUDGET_MIN,
THINKING_BUDGET_MAX,
THINKING_BUDGET_DEFAULT_MIN,
} from '../../../src/cliproxy/thinking-validator';
describe('Thinking Validator', () => {
describe('Constants', () => {
it('should export valid budget bounds', () => {
expect(THINKING_BUDGET_MIN).toBe(0);
expect(THINKING_BUDGET_MAX).toBe(100000);
expect(THINKING_BUDGET_DEFAULT_MIN).toBe(512);
});
it('should export valid thinking levels', () => {
expect(VALID_THINKING_LEVELS).toContain('minimal');
expect(VALID_THINKING_LEVELS).toContain('low');
expect(VALID_THINKING_LEVELS).toContain('medium');
expect(VALID_THINKING_LEVELS).toContain('high');
expect(VALID_THINKING_LEVELS).toContain('xhigh');
expect(VALID_THINKING_LEVELS).toContain('auto');
});
it('should export level budget mappings', () => {
expect(THINKING_LEVEL_BUDGETS.minimal).toBe(512);
expect(THINKING_LEVEL_BUDGETS.low).toBe(1024);
expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192);
expect(THINKING_LEVEL_BUDGETS.high).toBe(24576);
expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768);
});
it('should export off values', () => {
expect(THINKING_OFF_VALUES).toContain('off');
expect(THINKING_OFF_VALUES).toContain('none');
expect(THINKING_OFF_VALUES).toContain('disabled');
expect(THINKING_OFF_VALUES).toContain('0');
});
});
describe('Off values', () => {
it('should handle "off" value', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'off');
expect(result.valid).toBe(true);
expect(result.value).toBe('off');
expect(result.warning).toBeUndefined();
});
it('should handle "none" value', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'none');
expect(result.valid).toBe(true);
expect(result.value).toBe('off');
});
it('should handle "disabled" value', () => {
const result = validateThinking('agy', 'claude-sonnet-4-20250514', 'disabled');
expect(result.valid).toBe(true);
expect(result.value).toBe('off');
});
it('should handle "0" string as off', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', '0');
expect(result.valid).toBe(true);
expect(result.value).toBe('off');
});
it('should be case-insensitive for off values', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'OFF');
expect(result.valid).toBe(true);
expect(result.value).toBe('off');
});
});
describe('Named levels (levels-type models like Gemini)', () => {
it('should accept valid level names', () => {
const levels = ['low', 'medium', 'high'];
for (const level of levels) {
const result = validateThinking('gemini', 'gemini-3-pro-preview', level);
expect(result.valid).toBe(true);
}
});
it('should be case-insensitive for level names', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'HIGH');
expect(result.valid).toBe(true);
});
it('should map numeric budgets to closest level for level-type models', () => {
// Level-type models convert budget to closest named level
const result = validateThinking('gemini', 'gemini-3-pro-preview', 8192);
expect(result.valid).toBe(true);
expect(typeof result.value).toBe('string'); // Should be a level name
expect(result.warning).toContain('Mapped');
});
});
describe('Budget-type models (like Claude via agy)', () => {
// Claude models via agy use budget-type thinking
const budgetModel = 'gemini-claude-sonnet-4-5-thinking';
it('should accept valid numeric budget', () => {
const result = validateThinking('agy', budgetModel, 8192);
expect(result.valid).toBe(true);
expect(result.value).toBe(8192);
});
it('should accept numeric string budget', () => {
const result = validateThinking('agy', budgetModel, '8192');
expect(result.valid).toBe(true);
expect(result.value).toBe(8192);
});
it('should reject negative budgets', () => {
const result = validateThinking('agy', budgetModel, -100);
expect(result.valid).toBe(false);
expect(result.warning).toContain('Negative');
});
it('should clamp excessively high budgets', () => {
const result = validateThinking('agy', budgetModel, 999999999);
expect(result.valid).toBe(true);
expect(result.warning).toContain('Clamped');
});
it('should reject partial numeric parses like "123abc"', () => {
const result = validateThinking('agy', budgetModel, '123abc');
// Should either fail or find closest level match, not parse as 123
expect(result.value).not.toBe(123);
});
});
describe('Auto value', () => {
it('should accept auto value', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', 'auto');
expect(result.valid).toBe(true);
expect(result.value).toBe('auto');
});
});
describe('Unknown models', () => {
it('should pass through value for unknown models with warning', () => {
const result = validateThinking('gemini', 'unknown-model-xyz', 'high');
expect(result.valid).toBe(true);
expect(result.warning).toContain('unknown');
});
});
describe('Edge cases', () => {
it('should handle whitespace in input', () => {
const result = validateThinking('gemini', 'gemini-3-pro-preview', ' high ');
expect(result.valid).toBe(true);
});
it('should handle empty string by passing through for unknown', () => {
// Empty string on known model goes to level/budget validation
const result = validateThinking('gemini', 'gemini-3-pro-preview', '');
// For level-type models, empty string will try to match levels
expect(result.valid).toBeDefined(); // Just check it doesn't crash
});
});
});
+4 -2
View File
@@ -224,9 +224,11 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t
return useQuery({
queryKey: ['account-quota', provider, accountId],
queryFn: () => fetchAccountQuota(provider, accountId),
enabled: enabled && !!accountId,
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
enabled: enabled && provider === 'agy' && !!accountId,
staleTime: 60000, // Match refetchInterval to prevent early refetching
refetchInterval: 60000, // Refresh every 1 minute
refetchOnWindowFocus: false, // Don't refetch on tab switch
refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders)
retry: 1,
});
}
+24 -2
View File
@@ -114,12 +114,34 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
codex: {
provider: 'codex',
displayName: 'Codex',
defaultModel: 'gpt-5.1-codex-max',
defaultModel: 'gpt-5.2-codex',
models: [
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Full reasoning support (xhigh)',
presetMapping: {
default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex',
sonnet: 'gpt-5.2-codex',
haiku: 'gpt-5-mini',
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Fast, capped at high reasoning (no xhigh)',
presetMapping: {
default: 'gpt-5-mini',
opus: 'gpt-5.2-codex',
sonnet: 'gpt-5-mini',
haiku: 'gpt-5-mini',
},
},
{
id: 'gpt-5.1-codex-max',
name: 'Codex Max (5.1)',
description: 'Most capable Codex model',
description: 'Legacy most capable Codex model',
presetMapping: {
default: 'gpt-5.1-codex-max',
opus: 'gpt-5.1-codex-max-high',
@@ -4,7 +4,7 @@
*/
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Globe, Settings2, Server, KeyRound, Archive } from 'lucide-react';
import { Globe, Settings2, Server, KeyRound, Brain, Archive } from 'lucide-react';
import type { SettingsTab } from '../types';
interface TabNavigationProps {
@@ -24,6 +24,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
<Settings2 className="w-4 h-4" />
Global Env
</TabsTrigger>
<TabsTrigger value="thinking" className="flex-1 gap-2">
<Brain className="w-4 h-4" />
Thinking
</TabsTrigger>
<TabsTrigger value="proxy" className="flex-1 gap-2">
<Server className="w-4 h-4" />
Proxy
+1
View File
@@ -10,4 +10,5 @@ export {
useGlobalEnvConfig,
useProxyConfig,
useRawConfig,
useThinkingConfig,
} from './hooks/index';
+1
View File
@@ -8,3 +8,4 @@ export { useWebSearchConfig } from './use-websearch-config';
export { useGlobalEnvConfig } from './use-globalenv-config';
export { useProxyConfig } from './use-proxy-config';
export { useRawConfig } from './use-raw-config';
export { useThinkingConfig } from './use-thinking-config';
@@ -17,9 +17,11 @@ export function useSettingsTab() {
? 'proxy'
: tabParam === 'auth'
? 'auth'
: tabParam === 'backups'
? 'backups'
: 'websearch';
: tabParam === 'thinking'
? 'thinking'
: tabParam === 'backups'
? 'backups'
: 'websearch';
const setActiveTab = useCallback(
(tab: SettingsTab) => {
@@ -0,0 +1,171 @@
/**
* Thinking Config Hook
* Manages thinking budget configuration with direct API calls
* Includes W4 optimistic locking via lastModified timestamps
*/
import { useCallback, useState, useRef } from 'react';
import type { ThinkingConfig, ThinkingMode } from '../types';
const DEFAULT_THINKING_CONFIG: ThinkingConfig = {
mode: 'auto',
tier_defaults: {
opus: 'high',
sonnet: 'medium',
haiku: 'low',
},
show_warnings: true,
};
const FETCH_TIMEOUT = 10000; // 10 second timeout
export function useThinkingConfig() {
const [config, setConfig] = useState<ThinkingConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// W4: Track lastModified for optimistic locking
const lastModifiedRef = useRef<number | undefined>(undefined);
const fetchConfig = useCallback(async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
setLoading(true);
setError(null);
const res = await fetch('/api/thinking', { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) {
// Handle HTML error pages gracefully
const contentType = res.headers.get('content-type');
if (contentType?.includes('text/html')) {
throw new Error(`Server error (${res.status})`);
}
throw new Error('Failed to load Thinking config');
}
const data = await res.json();
setConfig(data.config || DEFAULT_THINKING_CONFIG);
// W4: Store lastModified for optimistic locking
lastModifiedRef.current = data.lastModified;
} catch (err) {
clearTimeout(timeoutId);
if ((err as Error).name === 'AbortError') {
setError('Request timeout - please try again');
} else {
setError((err as Error).message);
}
setConfig(DEFAULT_THINKING_CONFIG);
} finally {
setLoading(false);
}
// Return cleanup function for AbortController
return () => controller.abort();
}, []);
const saveConfig = useCallback(
async (updates: Partial<ThinkingConfig>) => {
const currentConfig = config || DEFAULT_THINKING_CONFIG;
const optimisticConfig = { ...currentConfig, ...updates };
setConfig(optimisticConfig);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
try {
setSaving(true);
setError(null);
// W4: Include lastModified for optimistic locking
const payload = {
...optimisticConfig,
lastModified: lastModifiedRef.current,
};
const res = await fetch('/api/thinking', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
// Handle HTML error pages gracefully
const contentType = res.headers.get('content-type');
if (contentType?.includes('text/html')) {
throw new Error(`Server error (${res.status})`);
}
const data = await res.json();
// W4: Handle conflict (409) with user-friendly message
if (res.status === 409) {
throw new Error('Config changed by another session. Refreshing...');
}
throw new Error(data.error || 'Failed to save');
}
const data = await res.json();
setConfig(data.config);
// W4: Update lastModified after successful save
lastModifiedRef.current = data.lastModified;
setSuccess(true);
setTimeout(() => setSuccess(false), 1500);
} catch (err) {
clearTimeout(timeoutId);
setConfig(currentConfig);
if ((err as Error).name === 'AbortError') {
setError('Request timeout - please try again');
} else {
setError((err as Error).message);
// W4: On conflict, auto-refresh to get latest
if ((err as Error).message.includes('another session')) {
setTimeout(() => fetchConfig(), 1000);
}
}
} finally {
setSaving(false);
}
},
[config, fetchConfig]
);
const setMode = useCallback(
(mode: ThinkingMode) => {
saveConfig({ mode });
},
[saveConfig]
);
const setTierDefault = useCallback(
(tier: 'opus' | 'sonnet' | 'haiku', value: string) => {
const currentDefaults = config?.tier_defaults || DEFAULT_THINKING_CONFIG.tier_defaults;
saveConfig({
tier_defaults: { ...currentDefaults, [tier]: value },
});
},
[config, saveConfig]
);
const setShowWarnings = useCallback(
(show: boolean) => {
saveConfig({ show_warnings: show });
},
[saveConfig]
);
return {
config: config || DEFAULT_THINKING_CONFIG,
loading,
saving,
error,
success,
fetchConfig,
saveConfig,
setMode,
setTierDefault,
setShowWarnings,
};
}
+2
View File
@@ -47,6 +47,7 @@ function lazyWithRetry<T extends ComponentType<unknown>>(importFn: () => Promise
// Lazy-loaded sections with retry capability
const WebSearchSection = lazyWithRetry(() => import('./sections/websearch'));
const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section'));
const ThinkingSection = lazyWithRetry(() => import('./sections/thinking'));
const ProxySection = lazyWithRetry(() => import('./sections/proxy'));
const AuthSection = lazyWithRetry(() => import('./sections/auth-section'));
const BackupsSection = lazyWithRetry(() => import('./sections/backups-section'));
@@ -128,6 +129,7 @@ function SettingsPageInner() {
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
{activeTab === 'proxy' && <ProxySection />}
{activeTab === 'auth' && <AuthSection />}
{activeTab === 'backups' && <BackupsSection />}
@@ -0,0 +1,238 @@
/**
* Thinking Section
* Settings section for thinking budget configuration
*/
import { useEffect } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RefreshCw, CheckCircle2, AlertCircle, Brain, Info } from 'lucide-react';
import { useThinkingConfig } from '../../hooks';
import type { ThinkingMode } from '../../types';
const THINKING_LEVELS = [
{ value: 'minimal', label: 'Minimal (512 tokens)' },
{ value: 'low', label: 'Low (1K tokens)' },
{ value: 'medium', label: 'Medium (8K tokens)' },
{ value: 'high', label: 'High (24K tokens)' },
{ value: 'xhigh', label: 'Extra High (32K tokens)' },
{ value: 'auto', label: 'Auto (dynamic)' },
];
export default function ThinkingSection() {
const {
config,
loading,
saving,
error,
success,
fetchConfig,
setMode,
setTierDefault,
setShowWarnings,
} = useThinkingConfig();
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading...</span>
</div>
</div>
);
}
return (
<>
{/* Toast-style alerts */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
}`}
>
{error && (
<Alert variant="destructive" className="py-2 shadow-lg">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</div>
{/* W1: Retry button on error */}
<Button
variant="outline"
size="sm"
onClick={fetchConfig}
className="h-7 px-2 text-xs border-destructive/50 hover:bg-destructive/10"
>
<RefreshCw className="w-3 h-3 mr-1" />
Retry
</Button>
</div>
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">Saved</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<div className="flex items-center gap-3">
<Brain className="w-5 h-5 text-primary" />
<p className="text-sm text-muted-foreground">
Configure extended thinking/reasoning for supported models.
</p>
</div>
{/* U4: Provider support indicator */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium">Supported Providers</p>
<ul className="mt-1 space-y-0.5 text-blue-600 dark:text-blue-400">
<li>
Thinking budget: <strong>agy</strong>, <strong>gemini</strong> (token-based)
</li>
<li>
Reasoning effort: <strong>codex</strong> (level-based: medium/high/xhigh)
</li>
</ul>
</div>
</div>
{/* Mode Selection */}
<div className="space-y-3">
<h3 className="text-base font-medium">Thinking Mode</h3>
<div className="space-y-2">
{(['auto', 'off', 'manual'] as ThinkingMode[]).map((mode) => (
<div
key={mode}
className={`flex items-center justify-between p-4 rounded-lg cursor-pointer transition-colors ${
config.mode === mode
? 'bg-primary/10 border border-primary/30'
: 'bg-muted/50 hover:bg-muted/80'
}`}
onClick={() => setMode(mode)}
>
<div>
<p className="font-medium capitalize">{mode}</p>
<p className="text-sm text-muted-foreground">
{mode === 'auto' && 'Automatically set thinking based on model tier'}
{mode === 'off' && 'Disable extended thinking'}
{mode === 'manual' && 'Use --thinking flag to control manually'}
</p>
</div>
<div
className={`w-4 h-4 rounded-full border-2 ${
config.mode === mode
? 'bg-primary border-primary'
: 'border-muted-foreground/50'
}`}
/>
</div>
))}
</div>
</div>
{/* Tier Defaults (only shown when mode is auto) */}
{config.mode === 'auto' && (
<div className="space-y-3">
<h3 className="text-base font-medium">Tier Defaults</h3>
<p className="text-sm text-muted-foreground">
Default thinking level for each model tier when in auto mode.
</p>
<div className="space-y-3">
{(['opus', 'sonnet', 'haiku'] as const).map((tier) => (
<div key={tier} className="flex items-center gap-4 p-3 rounded-lg bg-muted/30">
<Label className="w-20 capitalize font-medium">{tier}</Label>
<Select
value={config.tier_defaults[tier]}
onValueChange={(value) => setTierDefault(tier, value)}
disabled={saving}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
{level.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
)}
{/* Show Warnings Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">Show Warnings</p>
<p className="text-sm text-muted-foreground">
Display warnings when thinking values are clamped or adjusted
</p>
</div>
<Switch
checked={config.show_warnings ?? true}
onCheckedChange={setShowWarnings}
disabled={saving}
/>
</div>
{/* Info Box */}
<div className="p-4 rounded-lg border bg-muted/30">
<h4 className="text-sm font-medium mb-2">CLI Override</h4>
<p className="text-sm text-muted-foreground mb-2">
Use <code className="px-1.5 py-0.5 rounded bg-muted">--thinking</code> flag to
override settings per session:
</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 gemini --thinking off</p>
</div>
</div>
</div>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<Button
variant="outline"
size="sm"
onClick={fetchConfig}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</>
);
}
+19 -1
View File
@@ -49,7 +49,25 @@ export interface GlobalEnvConfig {
// === Tab Types ===
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'backups';
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'thinking' | 'backups';
// === Thinking Types ===
export type ThinkingMode = 'auto' | 'off' | 'manual';
export interface ThinkingTierDefaults {
opus: string;
sonnet: string;
haiku: string;
}
export interface ThinkingConfig {
mode: ThinkingMode;
override?: string | number;
tier_defaults: ThinkingTierDefaults;
provider_overrides?: Record<string, Partial<ThinkingTierDefaults>>;
show_warnings?: boolean;
}
// === Re-exports from api-client ===