mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-08 22:24:09 +00:00
Merge branch 'main' into fix/session-stats-display
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||

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