mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-18 20:17:45 +00:00
Merge pull request #355 from kaitranntt/dev
feat(thinking): add extended thinking mode with budget controls
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(', ')}).`,
|
||||
};
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,4 +10,5 @@ export {
|
||||
useGlobalEnvConfig,
|
||||
useProxyConfig,
|
||||
useRawConfig,
|
||||
useThinkingConfig,
|
||||
} from './hooks/index';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
Reference in New Issue
Block a user