feat(release): v7.38.0 - Extended Context, Qwen Models, Bug Fixes (#480)

* fix(version): show active config path instead of deprecated config.json

The version command was using deprecated getConfigPath() which always
returned config.json path. Now uses getActiveConfigPath() which shows
config.yaml in unified mode or config.json in legacy mode.

* chore(release): 7.37.1-dev.1 [skip ci]

* fix(ui): use native dynamic import to fix Node 24 ESM/CJS interop

TypeScript compiles import() to require() when targeting CommonJS,
which breaks ESM packages like ora on Node 24. Use new Function()
to create native dynamic import at runtime, bypassing TS transform.

Closes #472

* chore(release): 7.37.1-dev.2 [skip ci]

* fix(env): strip ANTHROPIC_* from account/default profiles

Account and default profiles inherit process.env which may contain
stale ANTHROPIC_BASE_URL from prior CLIProxy sessions. This causes
ConnectionRefused errors when Claude tries to hit an unavailable proxy.

Settings-based profiles already handle this by explicitly injecting
their own ANTHROPIC_* values. This fix applies the same protection
to account/default profiles by stripping ANTHROPIC_* before spawn.

Closes #474

* test(env): add unit tests for stripAnthropicEnv

Address code review feedback from PR #475. Tests cover:
- Removing all ANTHROPIC_* keys
- Preserving non-ANTHROPIC keys
- Empty object handling
- Undefined value preservation
- Case sensitivity (only uppercase ANTHROPIC_)
- All ANTHROPIC_ prefixed variants stripped

* chore(release): 7.37.1-dev.3 [skip ci]

* feat(cliproxy): add extended context support for 1M token window

Add --1m and --no-1m flags to enable/disable 1M token context window.
Uses Claude Code's [1m] suffix mechanism.

Behavior:
- Gemini models: auto-enabled by default
- Claude models: opt-in with --1m flag
- New extendedContext field in model catalog

Also adds Claude Opus 4.6 to model catalog with extended context support.

Closes #103

* feat(ui): add extended context toggle in dashboard model config

- Add ExtendedContextToggle component for 1M token context window
- Add Claude Opus 4.6 (claude-opus-4-6-20260203) to model catalogs
- Mark Gemini and Claude models with extendedContext: true
- Toggle only appears when selected model supports extended context
- Auto-enabled info for native Gemini, opt-in info for Claude

Part of extended context feature implementation for issue #103.

* fix: address code review findings and CI failure

- Fix CI error: add missing 'provider' prop to ModelConfigSection
- Fix case sensitivity in applyExtendedContextSuffix
- Fix whitespace handling in stripModelSuffixes
- Handle --1m=value and --no-1m=value CLI patterns
- Add warning when --1m used on unsupported model
- Sync agy catalog: add extendedContext to gemini-3-pro-preview
- Extract isNativeGeminiModel to shared utility (DRY)
- Add 21 unit tests for extended-context-config

* fix(catalog): rename claude-opus-4-6-20260203 to claude-opus-4-6

* fix(ui): wire extended context toggle through component tree

- Add extendedContextEnabled and toggleExtendedContext to useProviderEditor hook
- Store setting as CCS_EXTENDED_CONTEXT env var in provider settings
- Pass props through provider-editor → model-config-tab → model-config-section
- Update UseProviderEditorReturn type with new properties

* fix(ui): apply [1m] suffix directly to model strings in settings

- Toggle now applies/strips [1m] suffix to all ANTHROPIC_*MODEL env vars
- Extended context detected by checking if any model has [1m] suffix
- Remove legacy CCS_EXTENDED_CONTEXT flag approach
- Add suffix utilities: applyExtendedContextSuffix, stripExtendedContextSuffix
- Raw Configuration now shows actual model values with [1m] suffix

* fix(qwen): update model catalog with correct context windows and tier mappings

- Update context window specs from official Alibaba docs:
  - Qwen3 Coder Plus: 1M context (was 32K)
  - Qwen3 Max: 256K context (flagship)
  - Qwen3 Coder Flash: fast code generation
- Fix preset mappings for Claude tier equivalence:
  - Opus → qwen3-max (flagship 256K)
  - Sonnet → qwen3-coder-plus (balanced 1M)
  - Haiku → qwen3-coder-flash (fast)
- Update provider descriptions to reflect 256K-1M context range
- Add all 7 Qwen models from CLIProxyAPI: qwen3-coder-plus, qwen3-max,
  qwen3-max-preview, qwen3-235b, qwen3-vl-plus, qwen3-coder-flash, qwen3-32b

Closes #478

* fix(ui): only apply [1m] suffix to ANTHROPIC_MODEL, fix toggle refresh

- Only ANTHROPIC_MODEL gets [1m] suffix, not tier mappings
- Strip [1m] when looking up model in catalog to prevent toggle disappearing
- Fix odd page refresh when toggling extended context

* chore(release): 7.37.1-dev.4 [skip ci]

* fix(ui): remove duplicate import in use-provider-editor

* chore: address PR review feedback - sync comment and unused import

* chore(release): 7.37.1-dev.5 [skip ci]

* feat(cliproxy): add Opus 4.6 to Antigravity model catalog (#482)

* feat(cliproxy): add Opus 4.6 to Antigravity model catalog

- Add gemini-claude-opus-4-6-thinking as new default agy model
- Update preset mappings to route opus tier to Opus 4.6
- Bump CLIProxy fallback versions to v6.8.2
- Keep Opus 4.5 as previous flagship option

* fix(cliproxy): include oauth-model-alias in config generation

Root cause: CLIProxy config.yaml was missing Opus 4.6 alias because:
1. CLIProxyPlus startup migration is disabled (intentional)
2. CCS config generator never wrote oauth-model-alias section
3. Existing users with outdated aliases got 502 on Opus 4.6

Fix:
- Add DEFAULT_ANTIGRAVITY_ALIASES to config generator
- Generate oauth-model-alias section in config.yaml template
- Preserve claude-api-key and custom aliases during regeneration
- Bump config version to v6 to trigger auto-regeneration
- Update model catalog tests for new model count

* fix(cliproxy): preserve YAML indentation in extractYamlSection

- Replace .trim() with regex to strip only leading/trailing newlines,
  preserving 2-space indent on claude-api-key children
- Skip standalone comments at col 0 in section boundary detection
- Update stale test description (4 → 5 models)

* chore(release): 7.37.1-dev.6 [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-06 20:28:58 -05:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 3e8957af8b
commit b454834175
28 changed files with 923 additions and 47 deletions
+2 -2
View File
@@ -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"
}
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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',
@@ -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)
}
+99 -5
View File
@@ -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<CLIProxyProvider, string> = {
@@ -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;
+1
View File
@@ -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';
+8
View File
@@ -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<string,
localPort,
customSettingsPath,
thinkingOverride,
extendedContextOverride,
codexReasoningPort,
toolSanitizationPort,
} = config;
@@ -104,6 +108,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
// Apply thinking configuration to model (auto tier-based or manual override)
applyThinkingConfig(envVars, provider, thinkingOverride);
// Apply extended context suffix for 1M token context window
// Auto-enabled for Gemini, opt-in for Claude (--1m flag)
applyExtendedContextConfig(envVars, provider, extendedContextOverride);
// Determine the final ANTHROPIC_BASE_URL based on active proxies
// Chain order: Claude CLI → [CodexReasoningProxy] → [ToolSanitizationProxy] → CLIProxy
let finalBaseUrl = envVars.ANTHROPIC_BASE_URL;
+23
View File
@@ -329,6 +329,24 @@ export async function execClaudeWithCLIProxy(
}
}
// Parse --1m / --no-1m flags for extended context (1M token window)
let extendedContextOverride: boolean | undefined;
const has1mFlag =
argsWithoutProxy.includes('--1m') || argsWithoutProxy.some((arg) => 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' ||
+54 -2
View File
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
zeroAllowed: false,
dynamicAllowed: true,
},
extendedContext: true,
},
],
},
@@ -171,6 +189,19 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
zeroAllowed: false,
dynamicAllowed: true,
},
extendedContext: true,
},
{
id: 'claude-sonnet-4-5-20250929',
@@ -194,6 +226,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
zeroAllowed: false,
dynamicAllowed: true,
},
extendedContext: true,
},
{
id: 'claude-sonnet-4-20250514',
@@ -206,6 +239,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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-');
}
+2 -2
View File
@@ -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;
+14
View File
@@ -182,6 +182,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'ccs <provider> --thinking <value>',
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
],
['ccs <provider> --1m', 'Enable 1M token context window'],
['ccs <provider> --no-1m', 'Disable 1M context (use 200K default)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --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'],
+2 -2
View File
@@ -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<void> {
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');
+28 -3
View File
@@ -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) {
+30 -13
View File
@@ -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<unknown>;
/**
* 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<void> {
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
@@ -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<typeof spyOn>;
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();
});
});
+3 -3
View File
@@ -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);
});
});
+56
View File
@@ -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' });
});
});
@@ -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 (
<div
className={cn(
'rounded-lg border p-3 space-y-2',
enabled ? 'border-primary/50 bg-primary/5' : 'border-border bg-muted/30',
className
)}
>
{/* Header with toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className={cn('w-4 h-4', enabled ? 'text-primary' : 'text-muted-foreground')} />
<span className="text-sm font-medium">Extended Context</span>
<Badge variant={enabled ? 'default' : 'secondary'} className="text-[10px] h-5 px-1.5">
1M tokens
</Badge>
</div>
<Switch checked={enabled} onCheckedChange={onToggle} disabled={disabled} />
</div>
{/* Info text */}
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div className="space-y-1">
<p>Enables 1M token context window instead of default 200K.</p>
<p className="text-[10px]">
{isAutoEnabled ? (
<span className="text-primary">Auto-enabled for {provider} Gemini models</span>
) : (
<span>Opt-in for {provider} Claude models via --1m flag</span>
)}
</p>
{enabled && (
<p className="text-amber-600 dark:text-amber-500">
Note: 2x input pricing applies for tokens beyond 200K
</p>
)}
</div>
</div>
</div>
);
}
@@ -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)}
@@ -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 && (
<ExtendedContextToggle
model={currentModelEntry}
provider={provider}
enabled={extendedContextEnabled ?? false}
onToggle={onExtendedContextToggle}
/>
)}
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
@@ -28,6 +28,10 @@ interface ModelConfigTabProps {
sonnetModel?: string;
haikuModel?: string;
providerModels: Array<{ id: string; owned_by: string }>;
/** Whether extended context (1M tokens) is enabled */
extendedContextEnabled?: boolean;
/** Callback when extended context toggle changes */
onExtendedContextToggle?: (enabled: boolean) => void;
onApplyPreset: (updates: Record<string, string>) => 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}
@@ -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<string, string>) => 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<string, string>) => void;
@@ -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<string, string> = {};
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<string, string>) => {
@@ -169,6 +211,8 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn {
opusModel,
sonnetModel,
haikuModel,
extendedContextEnabled,
toggleExtendedContext,
handleRawJsonChange,
updateEnvValue,
updateEnvValues,
@@ -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;
+2 -1
View File
@@ -23,7 +23,8 @@ const badgeVariants = cva(
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
+46
View File
@@ -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;
}
+92 -11
View File
@@ -10,12 +10,24 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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',
+1 -1
View File
@@ -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',