Merge branch 'main' into fix/session-stats-display

This commit is contained in:
Joseph Mearman
2026-01-23 07:01:45 +00:00
committed by GitHub
39 changed files with 2032 additions and 41 deletions
+62
View File
@@ -1,3 +1,65 @@
## [7.26.0](https://github.com/kaitranntt/ccs/compare/v7.25.0...v7.26.0) (2026-01-23)
### Features
* add Ollama provider support ([bd3be23](https://github.com/kaitranntt/ccs/commit/bd3be23355f48269d5ce74dbf2b5aaf0eda8cf22))
* **ui:** add Ollama logo and make API key optional ([2cb77f2](https://github.com/kaitranntt/ccs/commit/2cb77f2dfd8376905e76cdd951830d24f2d29bbf))
* **ui:** add Ollama provider presets to dashboard ([2b7d18c](https://github.com/kaitranntt/ccs/commit/2b7d18c4c6631cb949c82a3eecc83beb0c885319))
* **ui:** add provider logos for alternative API presets ([5074122](https://github.com/kaitranntt/ccs/commit/5074122d4af705933c60527d34f5ec9bc168990b))
### Bug Fixes
* **api:** skip API key prompt for local Ollama preset ([ef2c8bb](https://github.com/kaitranntt/ccs/commit/ef2c8bba12e9ab164fa5b4be4c8fbb60617a20a1))
* **api:** skip API key prompt for local Ollama using noApiKey flag ([dc6977d](https://github.com/kaitranntt/ccs/commit/dc6977d32e5511b24f6a403f609507e9cd19af19))
* **ollama:** align property naming and descriptions ([3ce698c](https://github.com/kaitranntt/ccs/commit/3ce698c5fe3bc013371b51f6c13d01f32611f9a3))
* **presets:** make requiresApiKey required boolean, add sentinel docs ([8e29c48](https://github.com/kaitranntt/ccs/commit/8e29c48c6dd689794adaf740634823914e609c9d))
### Documentation
* add Ollama to Built-in Providers table and usage examples ([c9604be](https://github.com/kaitranntt/ccs/commit/c9604be5e1089c38d10047f65ab531a25ea14fc5))
## [7.25.0](https://github.com/kaitranntt/ccs/compare/v7.24.2...v7.25.0) (2026-01-22)
### Features
* **api:** add /api/thinking endpoints for budget config ([9a2598f](https://github.com/kaitranntt/ccs/commit/9a2598fb61904e1124f5142a179f0407a1f1c13a)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* **cli:** add --thinking flag for runtime budget override ([4d361b2](https://github.com/kaitranntt/ccs/commit/4d361b2ecf9032271eb4fa292b82a2205139b81b)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* **cliproxy:** add model-specific reasoning effort caps ([eec44d5](https://github.com/kaitranntt/ccs/commit/eec44d54e2ba8d2f4e5f0bc48a7e9a03f25de2d9)), closes [#344](https://github.com/kaitranntt/ccs/issues/344)
* **cliproxy:** add thinking budget validator module ([82ef680](https://github.com/kaitranntt/ccs/commit/82ef6804bbfd207522dde4bb4626fad2aaecb9ec)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* **cliproxy:** add ThinkingSupport to model catalog ([ebf7e04](https://github.com/kaitranntt/ccs/commit/ebf7e04b725d09d2fae10e36b9a45b57f8272069)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* **cliproxy:** inject thinking suffix into model config ([014b5e6](https://github.com/kaitranntt/ccs/commit/014b5e68b8d9486ed697509e6e6fc506671af36a)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* **config:** add ThinkingConfig to unified config ([0c2fd9c](https://github.com/kaitranntt/ccs/commit/0c2fd9cf5f4142a5a096cfa030b489ba9b6260bc)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* **thinking:** improve config validation and codex support ([19b7a49](https://github.com/kaitranntt/ccs/commit/19b7a49eee3a3487e8026a165c0961d60fe4cb43))
* **ui:** add Thinking settings tab to dashboard ([0a95f36](https://github.com/kaitranntt/ccs/commit/0a95f361a25415fb06bda06a16b0419ce2651119)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
### Bug Fixes
* **api:** add optimistic locking for thinking config ([ba19e1f](https://github.com/kaitranntt/ccs/commit/ba19e1fcda0b360f0ca4e02d24f8fad47f249b48))
* **api:** add override type and provider_overrides validation ([31b9520](https://github.com/kaitranntt/ccs/commit/31b9520d54d5fda607bace8f87a7a0989bbb3d23))
* **api:** add type guard for tier_defaults and extract tiers constant ([299d96c](https://github.com/kaitranntt/ccs/commit/299d96c01186fd065e5454edb7cb9aee6ab12bb0)), closes [#351](https://github.com/kaitranntt/ccs/issues/351)
* **cli:** add --thinking=value format and improve flag handling ([3060373](https://github.com/kaitranntt/ccs/commit/3060373797871ce2dc1394a5176d3a4693905921))
* **cliproxy:** add case-insensitive model lookup ([36bcc04](https://github.com/kaitranntt/ccs/commit/36bcc04133f6a0b0775d5edf897bc915b8a3efc5))
* **cliproxy:** add NaN/Infinity and empty string validation ([5f8d23c](https://github.com/kaitranntt/ccs/commit/5f8d23c60bae72cde1f281a24312813211c39140))
* **cliproxy:** handle edge cases in thinking validation ([ca490a9](https://github.com/kaitranntt/ccs/commit/ca490a9f4e96dd2da7e6c76b466328ca4aa4dc6c))
* **cliproxy:** improve thinking flag validation and warnings ([d5652de](https://github.com/kaitranntt/ccs/commit/d5652de63423ebad7afe8f8a428c271d29edb427))
* **config:** add null guard and document nested paren limitation ([19e5239](https://github.com/kaitranntt/ccs/commit/19e52399fe4a9707dbf2878117d2db09cfa5d467))
* **config:** improve YAML error messages and thinking validation ([f7cc9f4](https://github.com/kaitranntt/ccs/commit/f7cc9f465312ec5005edb2671f235286f31718d6))
* **ui:** add fetch timeout and abort controller cleanup ([b634f36](https://github.com/kaitranntt/ccs/commit/b634f365f3c80199516fb798a5a4aba6cb36512d))
* **ui:** add missing useThinkingConfig export to barrel file ([b996153](https://github.com/kaitranntt/ccs/commit/b996153e7fe92e786ae0c1335472cbb470a03327))
* **ui:** add provider indicator, retry button, and optimistic locking ([35f28a6](https://github.com/kaitranntt/ccs/commit/35f28a6e7733675813b34a3e6e2bda5907cdc393))
* **ui:** add spacing between Port label and input field ([1eeb8f9](https://github.com/kaitranntt/ccs/commit/1eeb8f922ddb25cb8caebe01a9239eb4529efc5e))
* **ui:** add thinking tab to URL sync conditional ([3ea549a](https://github.com/kaitranntt/ccs/commit/3ea549addddeba2c8c100d3fc7b892205904da44))
* **ui:** reduce excessive AGY quota API requests ([c8c1894](https://github.com/kaitranntt/ccs/commit/c8c189427221707832fa5257ece259321ee3bb52))
### Documentation
* **cli:** add extended thinking section to help ([7c5f365](https://github.com/kaitranntt/ccs/commit/7c5f36580ac357e7f63d70ed084e99c2fa24c6c4))
### Tests
* **cliproxy:** add unit tests for thinking validator ([3bd3e37](https://github.com/kaitranntt/ccs/commit/3bd3e379fe9573929bf24e1c3a925daac8578eaf)), closes [#307](https://github.com/kaitranntt/ccs/issues/307)
* update tests for codex catalog inclusion ([fbb71a2](https://github.com/kaitranntt/ccs/commit/fbb71a228ed232035f1e14cf858b590492720b1c))
## [7.24.2](https://github.com/kaitranntt/ccs/compare/v7.24.1...v7.24.2) (2026-01-18)
### Bug Fixes
+9 -1
View File
@@ -95,6 +95,8 @@ The dashboard provides visual management for all account types:
| **Kiro** | OAuth | `ccs kiro` | AWS CodeWhisperer (Claude-powered) |
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API |
| **Ollama** | Local | `ccs ollama` | Local open-source models, privacy |
| **Ollama Cloud** | API Key | `ccs ollama-cloud` | Cloud-hosted open-source models |
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
| **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure |
@@ -104,6 +106,8 @@ The dashboard provides visual management for all account types:
**OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard.
**Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`.
**Azure Foundry**: Use `ccs api create --preset foundry` to set up Claude via Microsoft Azure AI Foundry. Requires Azure resource and API key from [ai.azure.com](https://ai.azure.com).
![OpenRouter API Profiles](assets/screenshots/api-profiles-openrouter.webp)
@@ -131,6 +135,7 @@ ccs codex # OpenAI Codex (OAuth)
ccs kiro # Kiro/AWS CodeWhisperer (OAuth)
ccs ghcp # GitHub Copilot (OAuth device flow)
ccs agy # Antigravity (OAuth)
ccs ollama # Local Ollama (no API key needed)
ccs glm # GLM (API key)
```
@@ -145,7 +150,10 @@ ccs work "design the authentication system"
# Terminal 2: Execution (GLM - cost optimized)
ccs glm "implement the user service from the plan"
# Terminal 3: Review (Gemini)
# Terminal 3: Local testing (Ollama - offline, privacy)
ccs ollama "run tests and generate coverage report"
# Terminal 4: Review (Gemini)
ccs gemini "review the implementation for security issues"
```
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "https://ollama.com",
"ANTHROPIC_AUTH_TOKEN": "YOUR_OLLAMA_CLOUD_API_KEY_HERE",
"ANTHROPIC_MODEL": "glm-4.7:cloud",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder:480b",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.7:cloud",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "minimax-m2.1:cloud"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:11434",
"ANTHROPIC_AUTH_TOKEN": "ollama",
"ANTHROPIC_MODEL": "qwen3-coder",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-oss:120b",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-oss:20b",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.24.2-dev.2",
"version": "7.26.0",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+34
View File
@@ -17,6 +17,8 @@ export interface ProviderPreset {
apiKeyPlaceholder: string;
apiKeyHint: string;
category: PresetCategory;
/** Whether API key is required (default: true, set false for local providers) */
requiresApiKey: boolean;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode */
@@ -42,6 +44,19 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
requiresApiKey: true,
},
{
id: 'ollama',
name: 'Ollama (Local)',
description: 'Local open-source models via Ollama (32K+ context)',
baseUrl: 'http://localhost:11434',
defaultProfileName: 'ollama',
defaultModel: 'qwen3-coder',
apiKeyPlaceholder: 'ollama',
apiKeyHint: 'Install Ollama from ollama.com - no API key needed for local',
category: 'recommended',
requiresApiKey: false,
},
// Alternative providers
{
@@ -54,6 +69,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'glmt',
@@ -65,6 +81,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
requiresApiKey: true,
extraEnv: {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
@@ -85,6 +102,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
requiresApiKey: true,
alwaysThinkingEnabled: true,
},
{
@@ -97,6 +115,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'YOUR_AZURE_API_KEY',
apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'mm',
@@ -108,6 +127,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE',
apiKeyHint: 'Get your API key at platform.minimax.io',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'deepseek',
@@ -119,6 +139,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key at platform.deepseek.com',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'qwen',
@@ -130,6 +151,19 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'ollama-cloud',
name: 'Ollama Cloud',
description: 'Ollama cloud models via direct API (glm-4.7:cloud, minimax-m2.1:cloud)',
baseUrl: 'https://ollama.com',
defaultProfileName: 'ollama-cloud',
defaultModel: 'glm-4.7:cloud',
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
apiKeyHint: 'Get your API key at ollama.com',
category: 'alternative',
requiresApiKey: true,
},
];
+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 levelbudget)
*/
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(', ')}).`,
};
}
+21 -3
View File
@@ -181,9 +181,21 @@ async function handleCreate(args: string[]): Promise<void> {
console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.'));
}
// Step 3: API Key
// Step 3: API Key (skip if preset has requiresApiKey: false)
let apiKey = parsedArgs.apiKey;
if (!apiKey) {
if (preset?.requiresApiKey === false) {
// Preset doesn't require API key (e.g., local Ollama)
if (parsedArgs.apiKey) {
console.log(dim('Note: Using provided API key for local Ollama (optional)'));
apiKey = parsedArgs.apiKey;
} else {
console.log(info('No API key required for local Ollama'));
// Sentinel value 'ollama' matches config/base-ollama.settings.json template
// This is not a valid API key, just a placeholder for local-only providers
apiKey = 'ollama';
}
} else if (!apiKey) {
const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key';
apiKey = await InteractivePrompt.password(keyPrompt);
if (!apiKey) {
@@ -426,7 +438,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, glm, glmt, kimi, foundry, mm, deepseek, qwen)`
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, kimi, foundry, mm, deepseek, qwen)`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
@@ -438,6 +450,12 @@ async function showHelp(): Promise<void> {
console.log(
` ${color('openrouter', 'command')} OpenRouter - 349+ models (Claude, GPT, Gemini, Llama)`
);
console.log(
` ${color('ollama', 'command')} Ollama - Local open-source models (no API key)`
);
console.log(
` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-4.7:cloud, qwen3-coder:480b`
);
console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI`);
console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`);
console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`);
+20
View File
@@ -127,6 +127,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs glm', 'GLM 4.6 (API key required)'],
['ccs glmt', 'GLM with thinking mode'],
['ccs kimi', 'Kimi for Coding (API key)'],
['ccs ollama', 'Local Ollama (http://localhost:11434)'],
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
['', ''], // Spacer
['ccs api create', 'Create custom API profile'],
['ccs api remove', 'Remove an API profile'],
@@ -172,6 +174,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 +288,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 (opushigh, sonnetmedium, haikulow)
* - 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
});
});
});
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AzureAI</title><path clip-rule="evenodd" d="M16.233 0c.713 0 1.345.551 1.572 1.329.227.778 1.555 5.59 1.555 5.59v9.562h-4.813L14.645 0h1.588z" fill="url(#lobe-icons-azure-ai-fill-0)" fill-rule="evenodd"></path><path d="M23.298 7.47c0-.34-.275-.6-.6-.6h-2.835a3.617 3.617 0 00-3.614 3.615v5.996h3.436a3.617 3.617 0 003.613-3.614V7.47z" fill="url(#lobe-icons-azure-ai-fill-1)"></path><path clip-rule="evenodd" d="M16.233 0a.982.982 0 00-.989.989l-.097 18.198A4.814 4.814 0 0110.334 24H1.6a.597.597 0 01-.567-.794l7-19.981A4.819 4.819 0 0112.57 0h3.679-.016z" fill="url(#lobe-icons-azure-ai-fill-2)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-ai-fill-0" x1="18.242" x2="14.191" y1="16.837" y2=".616"><stop stop-color="#712575"></stop><stop offset=".09" stop-color="#9A2884"></stop><stop offset=".18" stop-color="#BF2C92"></stop><stop offset=".27" stop-color="#DA2E9C"></stop><stop offset=".34" stop-color="#EB30A2"></stop><stop offset=".4" stop-color="#F131A5"></stop><stop offset=".5" stop-color="#EC30A3"></stop><stop offset=".61" stop-color="#DF2F9E"></stop><stop offset=".72" stop-color="#C92D96"></stop><stop offset=".83" stop-color="#AA2A8A"></stop><stop offset=".95" stop-color="#83267C"></stop><stop offset="1" stop-color="#712575"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-ai-fill-1" x1="19.782" x2="19.782" y1=".34" y2="23.222"><stop stop-color="#DA7ED0"></stop><stop offset=".08" stop-color="#B17BD5"></stop><stop offset=".19" stop-color="#8778DB"></stop><stop offset=".3" stop-color="#6276E1"></stop><stop offset=".41" stop-color="#4574E5"></stop><stop offset=".54" stop-color="#2E72E8"></stop><stop offset=".67" stop-color="#1D71EB"></stop><stop offset=".81" stop-color="#1471EC"></stop><stop offset="1" stop-color="#1171ED"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-ai-fill-2" x1="18.404" x2="3.236" y1=".859" y2="25.183"><stop stop-color="#DA7ED0"></stop><stop offset=".05" stop-color="#B77BD4"></stop><stop offset=".11" stop-color="#9079DA"></stop><stop offset=".18" stop-color="#6E77DF"></stop><stop offset=".25" stop-color="#5175E3"></stop><stop offset=".33" stop-color="#3973E7"></stop><stop offset=".42" stop-color="#2772E9"></stop><stop offset=".54" stop-color="#1A71EB"></stop><stop offset=".68" stop-color="#1371EC"></stop><stop offset="1" stop-color="#1171ED"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>

After

Width:  |  Height:  |  Size: 711 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -47,7 +47,7 @@ const schema = z.object({
.min(1, 'Name is required')
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'),
baseUrl: z.string().url('Invalid URL format'),
apiKey: z.string().min(1, 'API key is required'),
apiKey: z.string(), // Validation handled conditionally in onSubmit
model: z.string().optional(),
opusModel: z.string().optional(),
sonnetModel: z.string().optional(),
@@ -207,9 +207,16 @@ export function ProfileCreateDialog({
}, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => {
// Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error('API key is required');
return;
}
// Use user-provided baseUrl (allows customization of preset URLs)
const finalData = {
...data,
// Use provided API key, or empty string if not provided (for optional auth providers)
apiKey: data.apiKey || '',
};
try {
await createMutation.mutateAsync(finalData);
@@ -367,17 +374,27 @@ export function ProfileCreateDialog({
)}
</div>
{/* API Key */}
{/* API Key - optional for presets that don't require it */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
API Key{' '}
{currentPreset?.requiresApiKey !== false && (
<span className="text-destructive">*</span>
)}
{currentPreset?.requiresApiKey === false && (
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
)}
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={currentPreset?.apiKeyPlaceholder ?? 'sk-...'}
placeholder={
currentPreset?.requiresApiKey === false
? 'Optional - only if auth is enabled'
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
}
className="pr-10"
/>
<Button
@@ -393,6 +410,10 @@ export function ProfileCreateDialog({
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : currentPreset?.requiresApiKey === false ? (
<p className="text-xs text-muted-foreground">
Only needed if you have configured Ollama authentication
</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
+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',
+37
View File
@@ -44,6 +44,22 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
},
// Recommended - Ollama (Local)
{
id: 'ollama',
name: 'Ollama (Local)',
description: 'Local open-source models via Ollama (32K+ context)',
baseUrl: 'http://localhost:11434',
defaultProfileName: 'ollama',
badge: 'Local',
featured: true,
icon: '/icons/ollama.svg',
defaultModel: 'qwen3-coder',
requiresApiKey: false,
apiKeyPlaceholder: '',
apiKeyHint: 'No API key required for local Ollama',
category: 'recommended',
},
// Alternative providers - GLM/GLMT/Kimi
{
id: 'glm',
@@ -52,6 +68,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
badge: 'Z.AI',
icon: '/icons/zai.svg',
defaultModel: 'glm-4.7',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
@@ -65,6 +82,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
badge: 'Thinking',
icon: '/icons/zai.svg',
defaultModel: 'glm-4.7',
requiresApiKey: true,
apiKeyPlaceholder: 'ghp_...',
@@ -78,6 +96,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'kimi',
badge: 'Reasoning',
icon: '/icons/kimi.svg',
defaultModel: 'kimi-k2-thinking-turbo',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
@@ -91,6 +110,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://<your-resource>.services.ai.azure.com/api/anthropic',
defaultProfileName: 'foundry',
badge: 'Azure',
icon: '/icons/azure.svg',
defaultModel: 'claude-sonnet-4-5',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_AZURE_API_KEY',
@@ -104,6 +124,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://api.minimax.io/anthropic',
defaultProfileName: 'mm',
badge: '1M context',
icon: '/icons/minimax.svg',
defaultModel: 'MiniMax-M2.1',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE',
@@ -117,6 +138,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://api.deepseek.com/anthropic',
defaultProfileName: 'deepseek',
badge: 'Reasoning',
icon: '/icons/deepseek.svg',
defaultModel: 'deepseek-chat',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
@@ -130,12 +152,27 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic',
defaultProfileName: 'qwen',
badge: 'Alibaba',
icon: '/assets/providers/qwen-color.svg',
defaultModel: 'qwen3-coder-plus',
requiresApiKey: true,
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
},
{
id: 'ollama-cloud',
name: 'Ollama Cloud',
description: 'Ollama cloud models via direct API (glm-4.7:cloud, minimax-m2.1:cloud)',
baseUrl: 'https://ollama.com',
defaultProfileName: 'ollama-cloud',
badge: 'Cloud',
icon: '/icons/ollama.svg',
defaultModel: 'glm-4.7:cloud',
requiresApiKey: true,
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
apiKeyHint: 'Get your API key at ollama.com',
category: 'alternative',
},
];
/** Get presets by category */
@@ -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 ===