diff --git a/config/base-qwen.settings.json b/config/base-qwen.settings.json index 03c510ca..1c689ffc 100644 --- a/config/base-qwen.settings.json +++ b/config/base-qwen.settings.json @@ -3,8 +3,8 @@ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qwen", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", "ANTHROPIC_MODEL": "qwen3-coder-plus", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder-plus", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-max", "ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3-coder-plus", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-vl-plus" + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder-flash" } } diff --git a/package.json b/package.json index 7a770fe8..7892d647 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.37.1", + "version": "7.37.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index 510246e4..78a5d16b 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -144,7 +144,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { id: 'qwen', name: 'Qwen', - description: 'Alibaba Cloud - qwen3-coder-plus (256K context)', + description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)', baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', defaultProfileName: 'qwen', defaultModel: 'qwen3-coder-plus', diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts new file mode 100644 index 00000000..3021419c --- /dev/null +++ b/src/cliproxy/config/extended-context-config.ts @@ -0,0 +1,127 @@ +/** + * Extended Context Configuration + * + * Handles the [1m] suffix for models supporting 1M token context window. + * Claude Code recognizes this suffix to enable extended context. + * + * Behavior: + * - Gemini family (gemini-* but NOT gemini-claude-*): Auto-enabled by default + * - Claude (Anthropic): Opt-in via --1m flag + */ + +import { CLIProxyProvider } from '../types'; +import { supportsExtendedContext, isNativeGeminiModel } from '../model-catalog'; +import { warn } from '../../utils/ui'; + +/** Extended context suffix recognized by Claude Code */ +const EXTENDED_CONTEXT_SUFFIX = '[1m]'; + +/** + * Apply extended context suffix to model name. + * Appends [1m] suffix if not already present. + * + * @param model - Model name (may include thinking suffix like "model(high)") + * @returns Model name with [1m] suffix, e.g., "gemini-2.5-pro[1m]" or "gemini-2.5-pro(high)[1m]" + */ +export function applyExtendedContextSuffix(model: string): string { + if (!model) return model; + // Case-insensitive check to avoid double suffix (handles [1M], [1m], etc.) + if (model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase())) { + return model; + } + return `${model}${EXTENDED_CONTEXT_SUFFIX}`; +} + +/** + * Determine if extended context should be applied to a model. + * + * @param provider - CLIProxy provider + * @param modelId - Base model ID (without suffixes) + * @param extendedContextOverride - CLI override (true = force on, false = force off, undefined = auto) + * @returns Whether to apply extended context suffix + */ +export function shouldApplyExtendedContext( + provider: CLIProxyProvider, + modelId: string, + extendedContextOverride?: boolean +): boolean { + // Explicit override takes priority + if (extendedContextOverride === true) { + // User explicitly requested --1m + const supported = supportsExtendedContext(provider, modelId); + if (!supported) { + console.error(warn(`Model "${modelId}" does not support 1M extended context. Flag ignored.`)); + } + return supported; + } + if (extendedContextOverride === false) { + // User explicitly disabled with --no-1m + return false; + } + + // Auto behavior: enable for native Gemini models only + if (isNativeGeminiModel(modelId)) { + return supportsExtendedContext(provider, modelId); + } + + // For other models (Claude, etc.), default to off - require explicit --1m + return false; +} + +/** + * Apply extended context configuration to env vars. + * Modifies ANTHROPIC_MODEL and tier models with [1m] suffix. + * + * @param envVars - Environment variables to modify (mutated in place) + * @param provider - CLIProxy provider + * @param extendedContextOverride - CLI override (true = force on, false = force off, undefined = auto) + */ +export function applyExtendedContextConfig( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + extendedContextOverride?: boolean +): void { + // Get base model to check support (strip any existing suffixes for lookup) + const baseModel = envVars.ANTHROPIC_MODEL || ''; + const cleanModelId = stripModelSuffixes(baseModel); + + if (!shouldApplyExtendedContext(provider, cleanModelId, extendedContextOverride)) { + return; + } + + // Apply suffix to main model + if (envVars.ANTHROPIC_MODEL) { + envVars.ANTHROPIC_MODEL = applyExtendedContextSuffix(envVars.ANTHROPIC_MODEL); + } + + // Apply to tier models if they support extended context + const tierModels = [ + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ] as const; + + for (const tierVar of tierModels) { + const model = envVars[tierVar]; + if (model) { + const tierCleanId = stripModelSuffixes(model); + if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) { + envVars[tierVar] = applyExtendedContextSuffix(model); + } + } + } +} + +/** + * Strip thinking and extended context suffixes from model ID for catalog lookup. + * Examples: + * "gemini-2.5-pro(high)[1m]" -> "gemini-2.5-pro" + * "gemini-2.5-pro(8192)" -> "gemini-2.5-pro" + * "gemini-2.5-pro" -> "gemini-2.5-pro" + */ +function stripModelSuffixes(modelId: string): string { + return modelId + .trim() + .replace(/\[1m\]$/i, '') // Remove [1m] suffix + .replace(/\([^)]+\)$/, ''); // Remove thinking suffix like (high) or (8192) +} diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 644addfc..f2cd8cb4 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -25,8 +25,25 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v3: Logging disabled by default (user opt-in via ~/.ccs/config.yaml) * v4: Added Kiro (AWS) and GitHub Copilot providers * v5: Added disable-cooling: true for stability + * v6: Added oauth-model-alias with Opus 4.6 support */ -export const CLIPROXY_CONFIG_VERSION = 5; +export const CLIPROXY_CONFIG_VERSION = 6; + +/** + * Default Antigravity oauth-model-alias entries. + * Maps user-facing model names to Antigravity internal model names. + * Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases(). + */ +const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string }> = [ + { name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' }, + { name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' }, + { name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' }, + { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, + { name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5' }, + { name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking' }, + { name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking' }, + { name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking' }, +]; /** Provider display names (static metadata) */ const PROVIDER_DISPLAY_NAMES: Record = { @@ -73,6 +90,39 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { }; } +/** + * Generate oauth-model-alias YAML section. + * Merges default Antigravity aliases with any user-added custom aliases. + */ +function generateOAuthModelAliasSection(existingAliases?: string): string { + // Start with default aliases + const aliasEntries = [...DEFAULT_ANTIGRAVITY_ALIASES]; + + // Parse and merge existing user aliases if provided + if (existingAliases) { + const existingNames = new Set(aliasEntries.map((a) => a.name)); + const lines = existingAliases.split('\n'); + let currentName = ''; + for (const line of lines) { + const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/); + const aliasMatch = line.match(/^\s+alias:\s*(.+)/); + if (nameMatch) { + currentName = nameMatch[1].trim(); + } else if (aliasMatch && currentName && !existingNames.has(currentName)) { + aliasEntries.push({ name: currentName, alias: aliasMatch[1].trim() }); + existingNames.add(currentName); + currentName = ''; + } + } + } + + const entries = aliasEntries + .map((a) => ` - name: ${a.name}\n alias: ${a.alias}`) + .join('\n'); + + return `oauth-model-alias:\n antigravity:\n${entries}`; +} + /** * Generate UNIFIED config.yaml content for ALL providers * This enables concurrent usage of gemini/codex/agy without config conflicts. @@ -80,10 +130,12 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { * * @param port - Server port (default: 8317) * @param userApiKeys - User-added API keys to preserve (default: []) + * @param existingAliases - Existing oauth-model-alias content to merge with defaults */ function generateUnifiedConfigContent( port: number = CLIPROXY_DEFAULT_PORT, - userApiKeys: string[] = [] + userApiKeys: string[] = [], + existingAliases?: string ): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories // Convert Windows backslashes to forward slashes for YAML compatibility @@ -174,6 +226,7 @@ ${apiKeysYaml} # OAuth tokens directory (auto-discovered by CLIProxyAPI) auth-dir: "${authDirNormalized}" +${generateOAuthModelAliasSection(existingAliases)} `; return config; @@ -250,9 +303,36 @@ export function parseUserApiKeys(content: string): string[] { return userKeys; } +/** + * Extract a YAML section from config content by key name. + * Returns the raw lines (including indented children) or empty string. + */ +function extractYamlSection(content: string, sectionKey: string): string { + const lines = content.split('\n'); + const sectionLines: string[] = []; + let inSection = false; + + for (const line of lines) { + if (line.startsWith(`${sectionKey}:`)) { + inSection = true; + continue; // Skip the key line itself + } + if (inSection) { + // Section ends at next top-level key (skip comments and blank lines) + if (line.match(/^\S/) && !line.startsWith('#') && line.trim().length > 0) { + break; + } + sectionLines.push(line); + } + } + + // Strip leading/trailing blank lines but preserve indentation + return sectionLines.join('\n').replace(/^\n+/, '').replace(/\n+$/, ''); +} + /** * Force regenerate config.yaml with latest settings. - * Preserves user-added API keys and port settings. + * Preserves user-added API keys, claude-api-key section, and port settings. * * @param port - Default port to use if not found in existing config * @returns Path to new config file @@ -263,6 +343,8 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { // Preserve user settings from existing config let effectivePort = port; let userApiKeys: string[] = []; + let claudeApiKeySection = ''; + let existingAliases = ''; if (fs.existsSync(configPath)) { try { @@ -276,6 +358,12 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { // Preserve user-added API keys (fix for issue #200) userApiKeys = parseUserApiKeys(content); + + // Preserve claude-api-key section (managed via dashboard/API) + claudeApiKeySection = extractYamlSection(content, 'claude-api-key'); + + // Preserve existing oauth-model-alias user customizations + existingAliases = extractYamlSection(content, 'oauth-model-alias'); } catch { // Use defaults if reading fails } @@ -287,8 +375,14 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); - // Generate fresh config with preserved user API keys - const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys); + // Generate fresh config with preserved user API keys and aliases + let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases); + + // Re-append claude-api-key section if it existed + if (claudeApiKeySection) { + configContent += `claude-api-key:\n${claudeApiKeySection}\n`; + } + fs.writeFileSync(configPath, configContent, { mode: 0o600 }); return configPath; diff --git a/src/cliproxy/config/index.ts b/src/cliproxy/config/index.ts index 2979ad1d..f2c356cc 100644 --- a/src/cliproxy/config/index.ts +++ b/src/cliproxy/config/index.ts @@ -7,4 +7,5 @@ export * from './generator'; export * from './port-manager'; export * from './env-builder'; export * from './thinking-config'; +export * from './extended-context-config'; export * from './path-resolver'; diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 12f4d736..2747a70a 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -10,6 +10,7 @@ */ import { getEffectiveEnvVars, getRemoteEnvVars, applyThinkingConfig } from '../config-generator'; +import { applyExtendedContextConfig } from '../config/extended-context-config'; import { CLIProxyProvider } from '../types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; @@ -37,6 +38,8 @@ export interface ProxyChainConfig { localPort: number; customSettingsPath?: string; thinkingOverride?: string | number; + /** Extended context override: true = force on, false = force off, undefined = auto */ + extendedContextOverride?: boolean; verbose: boolean; } @@ -54,6 +57,7 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record arg.startsWith('--1m=')); + const hasNo1mFlag = + argsWithoutProxy.includes('--no-1m') || + argsWithoutProxy.some((arg) => arg.startsWith('--no-1m=')); + + if (has1mFlag && hasNo1mFlag) { + console.error(fail('Cannot use both --1m and --no-1m flags')); + process.exit(1); + } else if (has1mFlag) { + extendedContextOverride = true; + } else if (hasNo1mFlag) { + extendedContextOverride = false; + } + // undefined = auto behavior (Gemini: on, others: off) + // Handle --accounts if (showAccounts) { const accounts = getProviderAccounts(provider); @@ -593,6 +611,7 @@ export async function execClaudeWithCLIProxy( localPort: cfg.port, customSettingsPath: cfg.customSettingsPath, thinkingOverride, + extendedContextOverride, verbose, }); @@ -681,6 +700,7 @@ export async function execClaudeWithCLIProxy( localPort: cfg.port, customSettingsPath: cfg.customSettingsPath, thinkingOverride, + extendedContextOverride, verbose, }); @@ -700,6 +720,8 @@ export async function execClaudeWithCLIProxy( '--use', '--nickname', '--thinking', + '--1m', + '--no-1m', '--incognito', '--no-incognito', '--import', @@ -709,6 +731,7 @@ export async function execClaudeWithCLIProxy( const claudeArgs = argsWithoutProxy.filter((arg, idx) => { if (ccsFlags.includes(arg)) return false; if (arg.startsWith('--thinking=')) return false; + if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false; if ( argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname' || diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 3ed0c9a0..82b1371e 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -50,6 +50,8 @@ export interface ModelEntry { deprecationReason?: string; /** Thinking/reasoning support configuration */ thinking?: ThinkingSupport; + /** Whether model supports 1M extended context window (appends [1m] suffix) */ + extendedContext?: boolean; } /** @@ -71,12 +73,25 @@ export const MODEL_CATALOG: Partial> = agy: { provider: 'agy', displayName: 'Antigravity', - defaultModel: 'gemini-claude-opus-4-5-thinking', + defaultModel: 'gemini-claude-opus-4-6-thinking', models: [ + { + id: 'gemini-claude-opus-4-6-thinking', + name: 'Claude Opus 4.6 Thinking', + description: 'Latest flagship, 1M context, extended thinking', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + zeroAllowed: true, + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'gemini-claude-opus-4-5-thinking', name: 'Claude Opus 4.5 Thinking', - description: 'Most capable, extended thinking', + description: 'Previous flagship, extended thinking', thinking: { type: 'budget', min: 1024, @@ -108,6 +123,7 @@ export const MODEL_CATALOG: Partial> = name: 'Gemini 3 Pro', description: 'Google latest model via Antigravity', thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + extendedContext: true, }, ], }, @@ -122,6 +138,7 @@ export const MODEL_CATALOG: Partial> = tier: 'pro', description: 'Latest model, requires paid Google account', thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + extendedContext: true, }, { id: 'gemini-2.5-pro', @@ -134,6 +151,7 @@ export const MODEL_CATALOG: Partial> = zeroAllowed: false, dynamicAllowed: true, }, + extendedContext: true, }, ], }, @@ -171,6 +189,19 @@ export const MODEL_CATALOG: Partial> = displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-5-20250929', models: [ + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + description: 'Latest flagship model', + thinking: { + type: 'budget', + min: 1024, + max: 128000, + zeroAllowed: false, + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', @@ -182,6 +213,7 @@ export const MODEL_CATALOG: Partial> = zeroAllowed: false, dynamicAllowed: true, }, + extendedContext: true, }, { id: 'claude-sonnet-4-5-20250929', @@ -194,6 +226,7 @@ export const MODEL_CATALOG: Partial> = zeroAllowed: false, dynamicAllowed: true, }, + extendedContext: true, }, { id: 'claude-sonnet-4-20250514', @@ -206,6 +239,7 @@ export const MODEL_CATALOG: Partial> = zeroAllowed: false, dynamicAllowed: true, }, + extendedContext: true, }, { id: 'claude-haiku-4-5-20251001', @@ -307,3 +341,21 @@ export function supportsThinking(provider: CLIProxyProvider, modelId: string): b const thinking = getModelThinkingSupport(provider, modelId); return thinking !== undefined && thinking.type !== 'none'; } + +/** + * Check if model supports extended context (1M tokens). + * Returns true if model has extendedContext: true in catalog. + */ +export function supportsExtendedContext(provider: CLIProxyProvider, modelId: string): boolean { + const model = findModel(provider, modelId); + return model?.extendedContext === true; +} + +/** + * Check if model is a native Gemini model (not gemini-claude-*). + * Native Gemini models get extended context auto-enabled. + */ +export function isNativeGeminiModel(modelId: string): boolean { + const lower = modelId.toLowerCase(); + return lower.startsWith('gemini-') && !lower.startsWith('gemini-claude-'); +} diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 28e4ce21..f5493f50 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -19,13 +19,13 @@ export const BACKEND_CONFIG = { repo: 'router-for-me/CLIProxyAPI', binaryPrefix: 'CLIProxyAPI', executable: 'cli-proxy-api', - fallbackVersion: '6.7.25', + fallbackVersion: '6.8.2', }, plus: { repo: 'router-for-me/CLIProxyAPIPlus', binaryPrefix: 'CLIProxyAPIPlus', executable: 'cli-proxy-api-plus', - fallbackVersion: '6.7.25-0', + fallbackVersion: '6.8.2-0', }, } as const; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 1dd631dd..2097453f 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -182,6 +182,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'ccs --thinking ', 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', ], + ['ccs --1m', 'Enable 1M token context window'], + ['ccs --no-1m', 'Disable 1M context (use 200K default)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], @@ -309,6 +311,18 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'before responding. Supported: agy, gemini (thinking models).'], ]); + // Extended Context (1M) + printSubSection('Extended Context (--1m)', [ + ['--1m', 'Enable 1M token context window'], + ['--no-1m', 'Disable 1M context (use 200K default)'], + ['', ''], + ['Auto behavior:', 'Gemini models: auto-enabled by default'], + ['', 'Claude models: opt-in with --1m flag'], + ['', ''], + ['Note:', 'Extended context enables 1M token window via [1m] suffix.'], + ['', 'Premium pricing: 2x input for >200K tokens.'], + ]); + // Image Analysis printSubSection('Image Analysis (CLIProxy vision)', [ ['ccs config image-analysis', 'Show current settings'], diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index f9cde90a..02304b46 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { initUI, header, subheader, color, warn } from '../utils/ui'; -import { getConfigPath } from '../utils/config-manager'; +import { getActiveConfigPath } from '../utils/config-manager'; import { getVersion } from '../utils/version'; /** @@ -26,7 +26,7 @@ export async function handleVersionCommand(): Promise { const ccsDir = path.join(os.homedir(), '.ccs'); console.log(` ${color('CCS Directory:'.padEnd(17), 'info')} ${ccsDir}`); - const configPath = getConfigPath(); + const configPath = getActiveConfigPath(); console.log(` ${color('Config:'.padEnd(17), 'info')} ${configPath}`); const profilesJson = path.join(os.homedir(), '.ccs', 'profiles.json'); diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index a3206d6d..e8bb02d9 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -8,6 +8,21 @@ import { spawn, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; +/** + * Strip ANTHROPIC_* env vars from an environment object. + * Used for account/default profiles to prevent stale proxy config from + * interfering with native Claude API routing. + */ +export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of Object.keys(env)) { + if (!key.startsWith('ANTHROPIC_')) { + result[key] = env[key]; + } + } + return result; +} + /** * Escape arguments for shell execution (cross-platform) * @@ -52,10 +67,20 @@ export function execClaude( // Get WebSearch hook config env vars const webSearchEnv = getWebSearchHookEnv(); - // Prepare environment (merge with process.env if envVars provided) + // For account/default profiles, strip ANTHROPIC_* from parent env to prevent + // stale proxy config (e.g., from prior CLIProxy sessions) from interfering + // with native Claude API routing. Settings-based profiles explicitly inject + // their own ANTHROPIC_* values, so they don't need this protection. + const profileType = envVars?.CCS_PROFILE_TYPE; + const baseEnv = + profileType === 'account' || profileType === 'default' + ? stripAnthropicEnv(process.env) + : process.env; + + // Prepare environment (merge with base env if envVars provided) const env = envVars - ? { ...process.env, ...envVars, ...webSearchEnv } - : { ...process.env, ...webSearchEnv }; + ? { ...baseEnv, ...envVars, ...webSearchEnv } + : { ...baseEnv, ...webSearchEnv }; let child: ChildProcess; if (needsShell) { diff --git a/src/utils/ui/init.ts b/src/utils/ui/init.ts index e2076cf8..822347c9 100644 --- a/src/utils/ui/init.ts +++ b/src/utils/ui/init.ts @@ -7,29 +7,46 @@ import { moduleCache, initialized, setInitialized } from './types'; +/** + * Native dynamic import that bypasses TypeScript's require() transformation. + * This preserves ESM import() at runtime, fixing Node 24 ESM/CJS interop issues. + * + * TypeScript compiles `import('x')` to `require('x')` when targeting CommonJS, + * which breaks ESM packages like ora that depend on other ESM-only modules. + */ + +const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string +) => Promise; + /** * Initialize UI dependencies (call once at startup) - * Uses dynamic imports for ESM packages in CommonJS project + * Uses native dynamic imports for ESM packages in CommonJS project */ export async function initUI(): Promise { if (initialized) return; try { - // Dynamic import for ESM-only packages + // Use native dynamic import to avoid TypeScript's require() transformation const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([ - import('chalk'), - import('boxen'), - import('gradient-string'), - import('ora'), - import('listr2'), + dynamicImport('chalk'), + dynamicImport('boxen'), + dynamicImport('gradient-string'), + dynamicImport('ora'), + dynamicImport('listr2'), ]); - // CJS modules: use .default if available (ESM interop), otherwise use module directly - moduleCache.chalk = chalkImport.default || chalkImport; - moduleCache.boxen = boxenImport.default || boxenImport; - moduleCache.gradient = gradientImport.default || gradientImport; - moduleCache.ora = oraImport.default || oraImport; - moduleCache.listr = listrImport.Listr || listrImport.default?.Listr; + // ESM modules: use .default for the actual export + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getDefault = (mod: any) => mod.default || mod; + + moduleCache.chalk = getDefault(chalkImport); + moduleCache.boxen = getDefault(boxenImport); + moduleCache.gradient = getDefault(gradientImport); + moduleCache.ora = getDefault(oraImport); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listr = listrImport as any; + moduleCache.listr = listr.Listr || listr.default?.Listr; setInitialized(true); } catch (_e) { // Fallback: UI works without colors if imports fail diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts new file mode 100644 index 00000000..76d11610 --- /dev/null +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -0,0 +1,154 @@ +/** + * Unit tests for extended context configuration + */ + +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import { + applyExtendedContextSuffix, + shouldApplyExtendedContext, + applyExtendedContextConfig, +} from '../../../src/cliproxy/config/extended-context-config'; + +describe('applyExtendedContextSuffix', () => { + it('appends [1m] to model without suffix', () => { + expect(applyExtendedContextSuffix('gemini-2.5-pro')).toBe('gemini-2.5-pro[1m]'); + }); + + it('does not double-append [1m]', () => { + expect(applyExtendedContextSuffix('gemini-2.5-pro[1m]')).toBe('gemini-2.5-pro[1m]'); + }); + + it('handles model with thinking suffix', () => { + expect(applyExtendedContextSuffix('gemini-2.5-pro(high)')).toBe('gemini-2.5-pro(high)[1m]'); + }); + + it('handles uppercase [1M] suffix', () => { + expect(applyExtendedContextSuffix('gemini-2.5-pro[1M]')).toBe('gemini-2.5-pro[1M]'); + }); + + it('handles mixed case [1m] suffix', () => { + expect(applyExtendedContextSuffix('gemini-2.5-pro[1M]')).toBe('gemini-2.5-pro[1M]'); + }); + + it('returns empty input unchanged', () => { + expect(applyExtendedContextSuffix('')).toBe(''); + }); +}); + +describe('shouldApplyExtendedContext', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('auto behavior (no override)', () => { + it('returns true for native Gemini models with extendedContext in catalog', () => { + // gemini-2.5-pro is in catalog with extendedContext: true + expect(shouldApplyExtendedContext('gemini', 'gemini-2.5-pro', undefined)).toBe(true); + }); + + it('returns true for gemini-3-pro-preview', () => { + expect(shouldApplyExtendedContext('gemini', 'gemini-3-pro-preview', undefined)).toBe(true); + }); + + it('returns false for gemini-claude-* models (not native Gemini)', () => { + expect(shouldApplyExtendedContext('agy', 'gemini-claude-opus-4-5-thinking', undefined)).toBe( + false + ); + }); + + it('returns false for Claude models without explicit flag', () => { + expect(shouldApplyExtendedContext('claude', 'claude-opus-4-5-20251101', undefined)).toBe( + false + ); + }); + + it('handles case-insensitive Gemini prefix', () => { + expect(shouldApplyExtendedContext('gemini', 'GEMINI-2.5-pro', undefined)).toBe(true); + }); + }); + + describe('explicit --1m override', () => { + it('returns true for Claude models with explicit --1m flag', () => { + expect(shouldApplyExtendedContext('claude', 'claude-opus-4-5-20251101', true)).toBe(true); + }); + + it('returns false and warns for model not in catalog', () => { + const result = shouldApplyExtendedContext('qwen', 'qwen-coder-plus', true); + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('returns false and warns for model without extendedContext support', () => { + const result = shouldApplyExtendedContext('claude', 'claude-haiku-4-5-20251001', true); + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalled(); + }); + }); + + describe('explicit --no-1m override', () => { + it('returns false even for native Gemini with explicit --no-1m', () => { + expect(shouldApplyExtendedContext('gemini', 'gemini-2.5-pro', false)).toBe(false); + }); + }); +}); + +describe('applyExtendedContextConfig', () => { + it('applies suffix to ANTHROPIC_MODEL for Gemini', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro', + }; + applyExtendedContextConfig(env, 'gemini', undefined); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro[1m]'); + }); + + it('applies suffix to tier models independently', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-2.5-flash', // Not in catalog + }; + applyExtendedContextConfig(env, 'gemini', undefined); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro[1m]'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro[1m]'); + // Haiku not in catalog, should be unchanged + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-2.5-flash'); + }); + + it('does not apply suffix when --no-1m is set', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro', + }; + applyExtendedContextConfig(env, 'gemini', false); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro'); + }); + + it('applies suffix to Claude with explicit --1m', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-4-5-20251101', + }; + applyExtendedContextConfig(env, 'claude', true); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + }); + + it('strips existing suffixes before catalog lookup', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro(high)', + }; + applyExtendedContextConfig(env, 'gemini', undefined); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]'); + }); + + it('handles empty env vars gracefully', () => { + const env: NodeJS.ProcessEnv = {}; + applyExtendedContextConfig(env, 'gemini', undefined); + expect(env.ANTHROPIC_MODEL).toBeUndefined(); + }); +}); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 8b540bca..e7de1df6 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -39,7 +39,7 @@ describe('Model Catalog', () => { describe('AGY models', () => { it('has correct default model', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-claude-opus-4-5-thinking'); + assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-claude-opus-4-6-thinking'); }); it('includes Claude Opus 4.5 Thinking', () => { @@ -76,9 +76,9 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 4 models total', () => { + it('has 5 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 4); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 5); }); }); diff --git a/tests/unit/utils/shell-executor.test.ts b/tests/unit/utils/shell-executor.test.ts index cca6f0cb..78d7e274 100644 --- a/tests/unit/utils/shell-executor.test.ts +++ b/tests/unit/utils/shell-executor.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { stripAnthropicEnv } from '../../../src/utils/shell-executor'; // We need to mock process.platform for cross-platform testing const originalPlatform = process.platform; @@ -82,3 +83,58 @@ describe('escapeShellArg', () => { }); }); }); + +describe('stripAnthropicEnv', () => { + it('removes all ANTHROPIC_* keys', () => { + const input = { + PATH: '/usr/bin', + ANTHROPIC_BASE_URL: 'http://localhost:3000', + ANTHROPIC_MODEL: 'claude-3', + HOME: '/home/user', + }; + const result = stripAnthropicEnv(input); + expect(result).toEqual({ PATH: '/usr/bin', HOME: '/home/user' }); + }); + + it('preserves non-ANTHROPIC keys', () => { + const input = { FOO: 'bar', ANTHROPIC_KEY: 'secret' }; + const result = stripAnthropicEnv(input); + expect(result).toEqual({ FOO: 'bar' }); + }); + + it('handles empty object', () => { + expect(stripAnthropicEnv({})).toEqual({}); + }); + + it('preserves undefined values', () => { + const input: NodeJS.ProcessEnv = { FOO: 'bar', BAZ: undefined }; + const result = stripAnthropicEnv(input); + expect(result.FOO).toBe('bar'); + expect(result.BAZ).toBeUndefined(); + expect('BAZ' in result).toBe(true); + }); + + it('is case-sensitive (only uppercase ANTHROPIC_)', () => { + const input = { + anthropic_base_url: 'lowercase', + Anthropic_Model: 'mixed', + ANTHROPIC_API_KEY: 'uppercase', + }; + const result = stripAnthropicEnv(input); + expect(result).toEqual({ + anthropic_base_url: 'lowercase', + Anthropic_Model: 'mixed', + }); + }); + + it('strips all ANTHROPIC_ prefixed vars including nested names', () => { + const input = { + ANTHROPIC_: 'empty suffix', + ANTHROPIC_V2_SETTING: 'v2', + ANTHROPIC_INTERNAL_DEBUG: 'internal', + PATH: '/bin', + }; + const result = stripAnthropicEnv(input); + expect(result).toEqual({ PATH: '/bin' }); + }); +}); diff --git a/ui/src/components/cliproxy/extended-context-toggle.tsx b/ui/src/components/cliproxy/extended-context-toggle.tsx new file mode 100644 index 00000000..a5ec5f22 --- /dev/null +++ b/ui/src/components/cliproxy/extended-context-toggle.tsx @@ -0,0 +1,85 @@ +/** + * Extended Context Toggle Component + * Shows toggle for models that support 1M token context window. + * Only visible when selected model has extendedContext: true. + */ + +import { Zap, Info } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { isNativeGeminiModel } from '@/lib/extended-context-utils'; +import type { ModelEntry } from './provider-model-selector'; + +interface ExtendedContextToggleProps { + /** Currently selected model */ + model: ModelEntry | undefined; + /** Provider name for display */ + provider: string; + /** Whether extended context is enabled */ + enabled: boolean; + /** Callback when toggle changes */ + onToggle: (enabled: boolean) => void; + /** Whether the toggle is disabled (saving state) */ + disabled?: boolean; + /** Additional className */ + className?: string; +} + +export function ExtendedContextToggle({ + model, + provider, + enabled, + onToggle, + disabled, + className, +}: ExtendedContextToggleProps) { + // Only show if model supports extended context + if (!model?.extendedContext) { + return null; + } + + const isAutoEnabled = isNativeGeminiModel(model.id); + + return ( +
+ {/* Header with toggle */} +
+
+ + Extended Context + + 1M tokens + +
+ +
+ + {/* Info text */} +
+ +
+

Enables 1M token context window instead of default 200K.

+

+ {isAutoEnabled ? ( + Auto-enabled for {provider} Gemini models + ) : ( + Opt-in for {provider} Claude models via --1m flag + )} +

+ {enabled && ( +

+ Note: 2x input pricing applies for tokens beyond 200K +

+ )} +
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 77672594..59658788 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -91,6 +91,8 @@ export function ProviderEditor({ opusModel, sonnetModel, haikuModel, + extendedContextEnabled, + toggleExtendedContext, handleRawJsonChange, updateEnvValue, updateEnvValues, @@ -197,6 +199,8 @@ export function ProviderEditor({ sonnetModel={sonnetModel} haikuModel={haikuModel} providerModels={providerModels} + extendedContextEnabled={extendedContextEnabled} + onExtendedContextToggle={toggleExtendedContext} onApplyPreset={handleApplyPreset} onUpdateEnvValue={updateEnvValue} onOpenCustomPreset={() => setCustomPresetOpen(true)} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 5645701c..2caff1f7 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -3,10 +3,13 @@ * Presets and model mapping configuration UI */ +import { useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; +import { ExtendedContextToggle } from '../extended-context-toggle'; +import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; import type { ModelConfigSectionProps } from './types'; export function ModelConfigSection({ @@ -17,6 +20,9 @@ export function ModelConfigSection({ sonnetModel, haikuModel, providerModels, + provider, + extendedContextEnabled, + onExtendedContextToggle, onApplyPreset, onUpdateEnvValue, onOpenCustomPreset, @@ -25,6 +31,14 @@ export function ModelConfigSection({ }: ModelConfigSectionProps) { const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0; + // Find current model entry to check for extended context support + // Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix + const currentModelEntry = useMemo(() => { + if (!catalog || !currentModel) return undefined; + const baseModelId = stripExtendedContextSuffix(currentModel); + return catalog.models.find((m) => m.id === baseModelId); + }, [catalog, currentModel]); + return ( <> {/* Quick Presets */} @@ -127,6 +141,15 @@ export function ModelConfigSection({ catalog={catalog} allModels={providerModels} /> + {/* Extended Context Toggle - only shows for models that support it */} + {currentModelEntry?.extendedContext && onExtendedContextToggle && ( + + )} ; + /** Whether extended context (1M tokens) is enabled */ + extendedContextEnabled?: boolean; + /** Callback when extended context toggle changes */ + onExtendedContextToggle?: (enabled: boolean) => void; onApplyPreset: (updates: Record) => void; onUpdateEnvValue: (key: string, value: string) => void; onOpenCustomPreset: () => void; @@ -67,6 +71,8 @@ export function ModelConfigTab({ sonnetModel, haikuModel, providerModels, + extendedContextEnabled, + onExtendedContextToggle, onApplyPreset, onUpdateEnvValue, onOpenCustomPreset, @@ -146,6 +152,9 @@ export function ModelConfigTab({ sonnetModel={sonnetModel} haikuModel={haikuModel} providerModels={providerModels} + provider={provider} + extendedContextEnabled={extendedContextEnabled} + onExtendedContextToggle={onExtendedContextToggle} onApplyPreset={onApplyPreset} onUpdateEnvValue={onUpdateEnvValue} onOpenCustomPreset={onOpenCustomPreset} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index a9023729..51d980ec 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -113,6 +113,12 @@ export interface ModelConfigSectionProps { sonnetModel?: string; haikuModel?: string; providerModels: Array<{ id: string; owned_by: string }>; + /** Provider name for display */ + provider: string; + /** Whether extended context (1M tokens) is enabled */ + extendedContextEnabled?: boolean; + /** Callback when extended context toggle changes */ + onExtendedContextToggle?: (enabled: boolean) => void; onApplyPreset: (updates: Record) => void; onUpdateEnvValue: (key: string, value: string) => void; onOpenCustomPreset: () => void; @@ -133,6 +139,10 @@ export interface UseProviderEditorReturn { opusModel?: string; sonnetModel?: string; haikuModel?: string; + /** Whether extended context (1M tokens) is enabled */ + extendedContextEnabled: boolean; + /** Toggle extended context on/off */ + toggleExtendedContext: (enabled: boolean) => void; handleRawJsonChange: (value: string) => void; updateEnvValue: (key: string, value: string) => void; updateEnvValues: (updates: Record) => void; diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 5476911c..15861210 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -7,6 +7,14 @@ import { useState, useMemo, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { SettingsResponse, UseProviderEditorReturn } from './types'; +import { + applyExtendedContextSuffix, + stripExtendedContextSuffix, + hasExtendedContextSuffix, +} from '@/lib/extended-context-utils'; + +/** Model env keys that should have [1m] suffix applied */ +const MODEL_ENV_KEYS = ['ANTHROPIC_MODEL'] as const; /** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -68,6 +76,15 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL; const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL; + // Extended context is enabled if any model has [1m] suffix + const extendedContextEnabled = useMemo(() => { + const env = currentSettings?.env || {}; + return MODEL_ENV_KEYS.some((key) => { + const value = env[key]; + return value && hasExtendedContextSuffix(value); + }); + }, [currentSettings]); + // Update a single setting value const updateEnvValue = useCallback( (key: string, value: string) => { @@ -78,6 +95,31 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { [currentSettings] ); + // Toggle extended context - applies/strips [1m] suffix to all model env vars + const toggleExtendedContext = useCallback( + (enabled: boolean) => { + const env = currentSettings?.env || {}; + const updates: Record = {}; + + for (const key of MODEL_ENV_KEYS) { + const value = env[key]; + if (value) { + updates[key] = enabled + ? applyExtendedContextSuffix(value) + : stripExtendedContextSuffix(value); + } + } + + // Remove the legacy flag if present + const newEnv = { ...env, ...updates }; + delete newEnv['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: newEnv }; + setRawJsonEdits(JSON.stringify(newSettings, null, 2)); + }, + [currentSettings] + ); + // Batch update multiple env values at once const updateEnvValues = useCallback( (updates: Record) => { @@ -169,6 +211,8 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { opusModel, sonnetModel, haikuModel, + extendedContextEnabled, + toggleExtendedContext, handleRawJsonChange, updateEnvValue, updateEnvValues, diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 96885e28..fcd1ed52 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -29,6 +29,8 @@ export interface ModelEntry { issueUrl?: string; deprecated?: boolean; deprecationReason?: string; + /** Whether model supports 1M extended context window */ + extendedContext?: boolean; /** Optional preset mapping for different tiers (if different from id) */ presetMapping?: { default: string; diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index ab895f8e..45811bfd 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -23,7 +23,8 @@ const badgeVariants = cva( ); interface BadgeProps - extends React.HTMLAttributes, VariantProps {} + extends React.HTMLAttributes, + VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts new file mode 100644 index 00000000..56bbbd00 --- /dev/null +++ b/ui/src/lib/extended-context-utils.ts @@ -0,0 +1,46 @@ +/** + * Extended Context Utilities + * Shared utilities for extended context (1M token window) feature. + */ + +/** The suffix that enables 1M token context in Claude Code */ +export const EXTENDED_CONTEXT_SUFFIX = '[1m]'; + +/** + * Check if model is a native Gemini model (auto-enabled behavior). + * Native Gemini models: gemini-* but NOT gemini-claude-* + * + * NOTE: This function is intentionally duplicated from src/cliproxy/model-catalog.ts + * to avoid bundling backend code in the UI. Keep both in sync. + */ +export function isNativeGeminiModel(modelId: string): boolean { + const lower = modelId.toLowerCase(); + return lower.startsWith('gemini-') && !lower.startsWith('gemini-claude-'); +} + +/** + * Check if model string already has [1m] suffix + */ +export function hasExtendedContextSuffix(model: string): boolean { + return model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase()); +} + +/** + * Apply [1m] suffix to model string if not already present + */ +export function applyExtendedContextSuffix(model: string): string { + if (!model) return model; + if (hasExtendedContextSuffix(model)) return model; + return `${model}${EXTENDED_CONTEXT_SUFFIX}`; +} + +/** + * Strip [1m] suffix from model string + */ +export function stripExtendedContextSuffix(model: string): string { + if (!model) return model; + if (hasExtendedContextSuffix(model)) { + return model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length); + } + return model; +} diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index fe4964ee..bbd70c1d 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -10,12 +10,24 @@ export const MODEL_CATALOGS: Record = { agy: { provider: 'agy', displayName: 'Antigravity', - defaultModel: 'gemini-claude-opus-4-5-thinking', + defaultModel: 'gemini-claude-opus-4-6-thinking', models: [ + { + id: 'gemini-claude-opus-4-6-thinking', + name: 'Claude Opus 4.6 Thinking', + description: 'Latest flagship, 1M context, extended thinking', + extendedContext: true, + presetMapping: { + default: 'gemini-claude-opus-4-6-thinking', + opus: 'gemini-claude-opus-4-6-thinking', + sonnet: 'gemini-claude-sonnet-4-5-thinking', + haiku: 'gemini-claude-sonnet-4-5', + }, + }, { id: 'gemini-claude-opus-4-5-thinking', name: 'Claude Opus 4.5 Thinking', - description: 'Most capable, extended thinking', + description: 'Previous flagship, extended thinking', presetMapping: { default: 'gemini-claude-opus-4-5-thinking', opus: 'gemini-claude-opus-4-5-thinking', @@ -29,7 +41,7 @@ export const MODEL_CATALOGS: Record = { description: 'Balanced with extended thinking', presetMapping: { default: 'gemini-claude-sonnet-4-5-thinking', - opus: 'gemini-claude-opus-4-5-thinking', + opus: 'gemini-claude-opus-4-6-thinking', sonnet: 'gemini-claude-sonnet-4-5-thinking', haiku: 'gemini-claude-sonnet-4-5', }, @@ -40,7 +52,7 @@ export const MODEL_CATALOGS: Record = { description: 'Fast and capable', presetMapping: { default: 'gemini-claude-sonnet-4-5', - opus: 'gemini-claude-opus-4-5-thinking', + opus: 'gemini-claude-opus-4-6-thinking', sonnet: 'gemini-claude-sonnet-4-5', haiku: 'gemini-claude-sonnet-4-5', }, @@ -49,6 +61,7 @@ export const MODEL_CATALOGS: Record = { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', description: 'Google latest model via Antigravity', + extendedContext: true, presetMapping: { default: 'gemini-3-pro-preview', opus: 'gemini-3-pro-preview', @@ -60,6 +73,7 @@ export const MODEL_CATALOGS: Record = { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', description: 'Fast Gemini model via Antigravity', + extendedContext: true, presetMapping: { default: 'gemini-3-flash-preview', opus: 'gemini-3-pro-preview', @@ -79,6 +93,7 @@ export const MODEL_CATALOGS: Record = { name: 'Gemini 3 Pro', tier: 'paid', description: 'Latest model, requires paid Google account', + extendedContext: true, presetMapping: { default: 'gemini-3-pro-preview', opus: 'gemini-3-pro-preview', @@ -91,6 +106,7 @@ export const MODEL_CATALOGS: Record = { name: 'Gemini 3 Flash', tier: 'paid', description: 'Fast Gemini 3 model, requires paid Google account', + extendedContext: true, presetMapping: { default: 'gemini-3-flash-preview', opus: 'gemini-3-pro-preview', @@ -102,6 +118,7 @@ export const MODEL_CATALOGS: Record = { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Stable, works with free Google account', + extendedContext: true, presetMapping: { default: 'gemini-2.5-pro', opus: 'gemini-2.5-pro', @@ -170,17 +187,66 @@ export const MODEL_CATALOGS: Record = { qwen: { provider: 'qwen', displayName: 'Qwen', - defaultModel: 'qwen-coder-plus', + defaultModel: 'qwen3-coder-plus', models: [ { - id: 'qwen-coder-plus', - name: 'Qwen Coder Plus', - description: 'Alibaba code-focused model', + id: 'qwen3-coder-plus', + name: 'Qwen3 Coder Plus', + description: 'Code-focused model (1M context)', + presetMapping: { + default: 'qwen3-coder-plus', + opus: 'qwen3-max', + sonnet: 'qwen3-coder-plus', + haiku: 'qwen3-coder-flash', + }, }, { - id: 'qwen-max', - name: 'Qwen Max', - description: 'Most capable Qwen model', + id: 'qwen3-max', + name: 'Qwen3 Max', + description: 'Flagship model (256K context)', + presetMapping: { + default: 'qwen3-max', + opus: 'qwen3-max', + sonnet: 'qwen3-coder-plus', + haiku: 'qwen3-coder-flash', + }, + }, + { + id: 'qwen3-max-preview', + name: 'Qwen3 Max Preview', + description: 'Preview with thinking support (256K)', + presetMapping: { + default: 'qwen3-max-preview', + opus: 'qwen3-max-preview', + sonnet: 'qwen3-max', + haiku: 'qwen3-coder-flash', + }, + }, + { + id: 'qwen3-235b', + name: 'Qwen3 235B', + description: 'Large 235B A22B model', + presetMapping: { + default: 'qwen3-235b', + opus: 'qwen3-max', + sonnet: 'qwen3-235b', + haiku: 'qwen3-coder-flash', + }, + }, + { + id: 'qwen3-vl-plus', + name: 'Qwen3 VL Plus', + description: 'Vision-language multimodal', + }, + { + id: 'qwen3-coder-flash', + name: 'Qwen3 Coder Flash', + description: 'Fast code generation', + }, + { + id: 'qwen3-32b', + name: 'Qwen3 32B', + description: 'Qwen3 32B model', }, ], }, @@ -316,10 +382,23 @@ export const MODEL_CATALOGS: Record = { displayName: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-5-20250929', models: [ + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + description: 'Latest flagship model', + extendedContext: true, + presetMapping: { + default: 'claude-opus-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-5-20250929', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', description: 'Most capable Claude model', + extendedContext: true, presetMapping: { default: 'claude-opus-4-5-20251101', opus: 'claude-opus-4-5-20251101', @@ -331,6 +410,7 @@ export const MODEL_CATALOGS: Record = { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Balanced performance and speed', + extendedContext: true, presetMapping: { default: 'claude-sonnet-4-5-20250929', opus: 'claude-opus-4-5-20251101', @@ -342,6 +422,7 @@ export const MODEL_CATALOGS: Record = { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', description: 'Previous generation Sonnet', + extendedContext: true, presetMapping: { default: 'claude-sonnet-4-20250514', opus: 'claude-opus-4-5-20251101', diff --git a/ui/src/lib/provider-presets.ts b/ui/src/lib/provider-presets.ts index d6d35486..87446cb1 100644 --- a/ui/src/lib/provider-presets.ts +++ b/ui/src/lib/provider-presets.ts @@ -148,7 +148,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ { id: 'qwen', name: 'Qwen', - description: 'Alibaba Cloud - qwen3-coder-plus (256K context)', + description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)', baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic', defaultProfileName: 'qwen', badge: 'Alibaba',