From c05189b4b1e2d90ee6bb88a4024feef1f8dbe1b1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 16:36:04 -0400 Subject: [PATCH] fix(cliproxy): preserve explicit Claude long-context intent - centralize Anthropic model-key [1m] handling in shared helpers - keep dashboard preset and editor writes consistent across all Claude tiers - add explicit api create and help guidance for --1m/--no-1m Refs #789 --- .../config/extended-context-config.ts | 66 ++++---------- src/cliproxy/model-id-normalizer.ts | 8 +- src/commands/api-command/create-command.ts | 85 +++++++++++++++++-- src/commands/api-command/help.ts | 15 ++++ src/commands/api-command/shared.ts | 53 +++++++++++- src/commands/help-command.ts | 17 ++-- src/shared/extended-context-utils.ts | 71 ++++++++++++++++ .../cliproxy/extended-context-toggle.tsx | 43 +++++----- .../provider-editor/model-config-section.tsx | 26 +++--- .../provider-editor/use-provider-editor.ts | 59 ++++++------- ui/src/lib/extended-context-utils.ts | 7 ++ ui/src/lib/model-catalogs.ts | 13 +++ 12 files changed, 329 insertions(+), 134 deletions(-) diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 38727e7f..7c63c68c 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -13,9 +13,10 @@ import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { + applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, isNativeGeminiModel, - stripExtendedContextSuffix, + stripModelConfigurationSuffixes, } from '../../shared/extended-context-utils'; // Backward-compatible export retained for tests/importers that reference this module. @@ -72,57 +73,20 @@ export function applyExtendedContextConfig( 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); - - // Tier model env vars to apply/strip extended context suffix - const tierModels = [ - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ] as const; - - if (!shouldApplyExtendedContext(provider, cleanModelId, extendedContextOverride)) { - // Strip [1m] suffix from models that no longer support extended context - // (e.g., user had it enabled before backend dropped support) - if (envVars.ANTHROPIC_MODEL?.toLowerCase().endsWith('[1m]')) { - envVars.ANTHROPIC_MODEL = envVars.ANTHROPIC_MODEL.replace(/\[1m\]$/i, ''); - } - for (const tierVar of tierModels) { - const model = envVars[tierVar]; - if (model?.toLowerCase().endsWith('[1m]')) { - envVars[tierVar] = model.replace(/\[1m\]$/i, ''); - } - } + if (extendedContextOverride === false) { + Object.assign(envVars, applyExtendedContextPreferenceToAnthropicModels(envVars, false)); return; } - // Apply suffix to main model - if (envVars.ANTHROPIC_MODEL) { - envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL); - } - - // Apply to tier models if they support extended context - - for (const tierVar of tierModels) { - const model = envVars[tierVar]; - if (model) { - const tierCleanId = stripModelSuffixes(model); - if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) { - envVars[tierVar] = applyExtendedContextSuffixShared(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 stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); + Object.assign( + envVars, + applyExtendedContextPreferenceToAnthropicModels(envVars, true, { + supportsExtendedContext: (modelId) => + shouldApplyExtendedContext( + provider, + stripModelConfigurationSuffixes(modelId), + extendedContextOverride + ), + }) + ); } diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 99761bfa..d2719d2c 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -6,14 +6,10 @@ */ import type { CLIProxyProvider } from './types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** Env vars that carry model identifiers. */ -export const MODEL_ENV_VAR_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; +export const MODEL_ENV_VAR_KEYS = ANTHROPIC_MODEL_ENV_KEYS; type ProviderLike = CLIProxyProvider | string | null | undefined; diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index dbd798a6..9d9da93f 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -25,7 +25,13 @@ import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; -import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared'; +import { + applyClaudeExtendedContextPreference, + exitOnApiCommandErrors, + hasClaudeModelMapping, + hasExplicitClaudeExtendedContext, + parseApiCommandArgs, +} from './shared'; function resolvePresetOrExit(presetId?: string): ProviderPreset | null { if (!presetId) { @@ -276,6 +282,37 @@ async function resolveDefaultTarget( return useDroidByDefault ? 'droid' : 'claude'; } +async function resolveClaudeLongContextPreference( + models: ModelMapping, + explicitPreference: boolean | undefined, + yes: boolean | undefined +): Promise { + if (explicitPreference !== undefined) { + return explicitPreference; + } + + if (hasExplicitClaudeExtendedContext(models)) { + return true; + } + + if (yes) { + return false; + } + + console.log(''); + console.log(info('Claude long context is explicit in CCS.')); + console.log(dim(' Plain Claude model IDs stay on standard context unless you opt into [1m].')); + console.log( + dim(' Some providers/accounts still require extra usage or PAYG for long-context requests.') + ); + console.log(dim(' If that entitlement is missing, upstream requests can still return 429.')); + console.log(''); + + return InteractivePrompt.confirm('Enable [1m] for compatible Claude mappings?', { + default: false, + }); +} + export async function handleApiCreateCommand(args: string[]): Promise { await initUI(); const parsedArgs = parseApiCommandArgs(args); @@ -403,17 +440,31 @@ export async function handleApiCreateCommand(args: string[]): Promise { } const apiKey = await resolveApiKey(parsedArgs.apiKey, preset); - const { model, models } = await resolveModelConfiguration( + const { models } = await resolveModelConfiguration( baseUrl, preset, parsedArgs.model, parsedArgs.yes ); + const hasClaudeMappings = hasClaudeModelMapping(models); + const shouldEnableClaudeLongContext = hasClaudeMappings + ? await resolveClaudeLongContextPreference(models, parsedArgs.extendedContext, parsedArgs.yes) + : false; + const finalModels = hasClaudeMappings + ? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext) + : models; const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) { + console.log(''); + console.log( + dim('No compatible Claude mappings were detected, so --1m/--no-1m did not change models.') + ); + } + console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl || '', apiKey, models, target); + const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); process.exit(1); @@ -427,25 +478,43 @@ export async function handleApiCreateCommand(args: string[]): Promise { } const hasCustomMapping = - models.opus !== model || models.sonnet !== model || models.haiku !== model; + finalModels.opus !== finalModels.default || + finalModels.sonnet !== finalModels.default || + finalModels.haiku !== finalModels.default; let details = `API: ${name}\n` + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + `Settings: ${result.settingsFile}\n` + `Base URL: ${baseUrl}\n` + - `Model: ${model}\n` + + `Model: ${finalModels.default}\n` + `Target: ${target}`; + if (hasClaudeMappings) { + details += `\nLongCtx: ${ + shouldEnableClaudeLongContext ? 'compatible Claude mappings use [1m]' : 'standard Claude context' + }`; + } + if (hasCustomMapping) { details += `\n\nModel Mapping:\n` + - ` Opus: ${models.opus}\n` + - ` Sonnet: ${models.sonnet}\n` + - ` Haiku: ${models.haiku}`; + ` Opus: ${finalModels.opus}\n` + + ` Sonnet: ${finalModels.sonnet}\n` + + ` Haiku: ${finalModels.haiku}`; } console.log(''); console.log(infoBox(details, 'API Profile Created')); + if (hasClaudeMappings) { + console.log(''); + console.log( + dim( + shouldEnableClaudeLongContext + ? 'CCS saved [1m] on compatible Claude mappings. Provider-side extra-usage requirements can still reject long-context requests.' + : 'Claude mappings were saved with standard context. Use --1m later if you want CCS to write [1m] explicitly.' + ) + ); + } console.log(''); console.log(header('Usage')); if (target === 'droid') { diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index c9b3fb99..b2fb7f94 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -55,6 +55,9 @@ export async function showApiCommandHelp(): Promise { console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` + ); console.log( ` ${color('--target ', 'command')} Default target: claude or droid (create)` ); @@ -84,11 +87,23 @@ export async function showApiCommandHelp(): Promise { console.log(''); console.log(` ${dim('# Quick setup with preset')}`); console.log(` ${color('ccs api create --preset anthropic', 'command')}`); + console.log( + ` ${color('ccs api create --preset anthropic --1m', 'command')} ${dim('# explicit Claude [1m] opt-in')}` + ); console.log(` ${color('ccs api create --preset openrouter', 'command')}`); console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); console.log(` ${color('ccs api create --preset glm', 'command')}`); console.log(''); + console.log(subheader('Claude Long Context')); + console.log(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); + console.log( + ` ${dim('Use --1m during create to append [1m] to compatible Claude mappings, or --no-1m to force plain IDs.')}` + ); + console.log( + ` ${dim('CCS controls only the saved [1m] suffix. Provider pricing/entitlement stay upstream, and some accounts can still return 429 for long-context requests.')}` + ); + console.log(''); console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); console.log( diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index de42e70b..2e725dd0 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -1,4 +1,12 @@ +import type { ModelMapping } from '../../api/services'; import type { TargetType } from '../../targets/target-adapter'; +import { + applyExtendedContextSuffix, + hasExtendedContextSuffix, + isClaudeModelId, + likelySupportsClaudeExtendedContext, + stripExtendedContextSuffix, +} from '../../shared/extended-context-utils'; import { fail } from '../../utils/ui'; import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor'; @@ -11,12 +19,15 @@ export interface ApiCommandArgs { preset?: string; cliproxyProvider?: string; target?: TargetType; + extendedContext?: boolean; force?: boolean; yes?: boolean; errors: string[]; } -export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; +const MODEL_MAPPING_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; + +export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y', '--1m', '--no-1m'] as const; export const API_VALUE_FLAGS = [ '--base-url', '--api-key', @@ -155,6 +166,8 @@ export function parseApiCommandArgs( args: string[], options: ParseApiCommandArgsOptions = {} ): ApiCommandArgs { + const enableExtendedContext = hasAnyFlag(args, ['--1m']); + const disableExtendedContext = hasAnyFlag(args, ['--no-1m']); const result: ApiCommandArgs = { positionals: [], force: hasAnyFlag(args, ['--force']), @@ -162,6 +175,14 @@ export function parseApiCommandArgs( errors: [], }; + if (enableExtendedContext && disableExtendedContext) { + result.errors.push('Cannot combine --1m and --no-1m'); + } else if (enableExtendedContext) { + result.extendedContext = true; + } else if (disableExtendedContext) { + result.extendedContext = false; + } + let remaining = [...args]; remaining = applyRepeatedOption( @@ -251,6 +272,36 @@ export function parseApiCommandArgs( return result; } +export function hasClaudeModelMapping(models: ModelMapping): boolean { + return MODEL_MAPPING_KEYS.some((key) => isClaudeModelId(models[key])); +} + +export function hasExplicitClaudeExtendedContext(models: ModelMapping): boolean { + return MODEL_MAPPING_KEYS.some( + (key) => isClaudeModelId(models[key]) && hasExtendedContextSuffix(models[key]) + ); +} + +export function applyClaudeExtendedContextPreference( + models: ModelMapping, + enabled: boolean +): ModelMapping { + const nextModels = { ...models }; + + for (const key of MODEL_MAPPING_KEYS) { + const value = nextModels[key]; + if (!isClaudeModelId(value)) { + continue; + } + + nextModels[key] = enabled && likelySupportsClaudeExtendedContext(value) + ? applyExtendedContextSuffix(value) + : stripExtendedContextSuffix(value); + } + + return nextModels; +} + export function exitOnApiCommandErrors(errors: string[]): void { if (errors.length === 0) { return; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 756d8022..80e203a8 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -220,8 +220,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', ], ['ccs codex --effort ', 'Set codex reasoning effort (medium/high/xhigh)'], - ['ccs --1m', 'Enable 1M token context window'], - ['ccs --no-1m', 'Disable 1M context (use 200K default)'], + ['ccs --1m', 'Request explicit 1M context when the selected model supports [1m]'], + ['ccs --no-1m', 'Force standard context / clear [1m]'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], @@ -449,14 +449,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Extended Context (1M) printSubSection('Extended Context (--1m)', [ - ['--1m', 'Enable 1M token context window'], - ['--no-1m', 'Disable 1M context (use 200K default)'], + ['--1m', 'Request 1M token context when the selected model supports [1m]'], + ['--no-1m', 'Force standard context (Claude default stays plain)'], ['', ''], - ['Auto behavior:', 'Gemini models: auto-enabled by default'], - ['', 'Claude models: opt-in with --1m flag'], + ['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'], + ['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'], ['', ''], - ['Note:', 'Extended context enables 1M token window via [1m] suffix.'], - ['', 'Premium pricing: 2x input for >200K tokens.'], + ['Note:', 'CCS only controls the saved [1m] suffix.'], + ['', 'Provider pricing and entitlement stay upstream.'], + ['', 'Some accounts/providers can still return 429 extra-usage errors for long-context requests.'], ]); // Image Analysis diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 88b0486a..d8008462 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -4,6 +4,16 @@ /** Extended context suffix recognized by Claude Code. */ export const EXTENDED_CONTEXT_SUFFIX = '[1m]'; +export const ANTHROPIC_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; + +const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { @@ -27,3 +37,64 @@ export function stripExtendedContextSuffix(model: string): string { if (!model) return model; return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model; } + +/** True when key belongs to Anthropic model mapping state. */ +export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey { + return ANTHROPIC_MODEL_ENV_KEY_SET.has(key); +} + +/** Strip transient config suffixes so model IDs can be checked against catalogs. */ +export function stripModelConfigurationSuffixes(modelId: string): string { + return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); +} + +/** Whether any saved Anthropic model mapping explicitly requests [1m]. */ +export function hasAnthropicExtendedContextEnabled( + env: Partial> +): boolean { + return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + const value = env[key]; + return typeof value === 'string' && hasExtendedContextSuffix(value); + }); +} + +/** Apply or strip [1m] across Anthropic model mappings while honoring compatibility. */ +export function applyExtendedContextPreferenceToAnthropicModels< + T extends Record, +>( + env: T, + enabled: boolean, + options: { + supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean; + } = {} +): T { + const nextEnv: Record = { ...env }; + + for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + const value = nextEnv[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + continue; + } + + const modelId = stripModelConfigurationSuffixes(value); + const supported = options.supportsExtendedContext?.(modelId, key) ?? true; + nextEnv[key] = + enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value); + } + + return nextEnv as T; +} + +/** Detect Claude model identifiers, regardless of transient [1m]/(thinking) suffixes. */ +export function isClaudeModelId(modelId: string): boolean { + return stripModelConfigurationSuffixes(modelId).toLowerCase().startsWith('claude-'); +} + +/** + * Conservative Claude long-context support heuristic for generic API profile flows. + * Haiku stays plain; Opus/Sonnet default to opt-in [1m]. + */ +export function likelySupportsClaudeExtendedContext(modelId: string): boolean { + const baseModel = stripModelConfigurationSuffixes(modelId).toLowerCase(); + return baseModel.startsWith('claude-') && !baseModel.startsWith('claude-haiku-'); +} diff --git a/ui/src/components/cliproxy/extended-context-toggle.tsx b/ui/src/components/cliproxy/extended-context-toggle.tsx index a5ec5f22..435d3d90 100644 --- a/ui/src/components/cliproxy/extended-context-toggle.tsx +++ b/ui/src/components/cliproxy/extended-context-toggle.tsx @@ -1,7 +1,6 @@ /** * Extended Context Toggle Component - * Shows toggle for models that support 1M token context window. - * Only visible when selected model has extendedContext: true. + * Shows toggle when any selected mapping supports 1M token context. */ import { Zap, Info } from 'lucide-react'; @@ -12,8 +11,8 @@ import { isNativeGeminiModel } from '@/lib/extended-context-utils'; import type { ModelEntry } from './provider-model-selector'; interface ExtendedContextToggleProps { - /** Currently selected model */ - model: ModelEntry | undefined; + /** Compatible selected models */ + models: ModelEntry[]; /** Provider name for display */ provider: string; /** Whether extended context is enabled */ @@ -27,19 +26,28 @@ interface ExtendedContextToggleProps { } export function ExtendedContextToggle({ - model, - provider, + models, + provider: _provider, enabled, onToggle, disabled, className, }: ExtendedContextToggleProps) { - // Only show if model supports extended context - if (!model?.extendedContext) { + if (models.length === 0) { return null; } - const isAutoEnabled = isNativeGeminiModel(model.id); + const hasGeminiModels = models.some((model) => isNativeGeminiModel(model.id)); + const hasClaudeModels = models.some((model) => !isNativeGeminiModel(model.id)); + + let behaviorHint = 'Compatible mappings stay on standard context unless you turn this on.'; + if (hasGeminiModels && hasClaudeModels) { + behaviorHint = + 'Gemini-compatible mappings can use 1M automatically. Claude mappings stay plain unless you turn this on.'; + } else if (hasGeminiModels) { + behaviorHint = + 'Gemini-compatible mappings can use 1M by default. Turn this off to keep standard context.'; + } return (
-

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 - )} +

Applies the explicit [1m] long-context suffix to compatible saved mappings.

+

{behaviorHint}

+

+ CCS only saves [1m]. Provider pricing and entitlement are separate, and + some accounts can still return 429 extra-usage errors for long-context requests.

- {enabled && ( -

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

- )}
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 d22a9632..d93df27a 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -11,6 +11,7 @@ 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 { findCatalogModel } from '@/lib/model-catalogs'; import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; @@ -48,13 +49,18 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { - // 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]); + const extendedContextModels = useMemo(() => { + if (!catalog) return []; + + const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel] + .filter((modelId): modelId is string => Boolean(modelId)) + .map((modelId) => stripExtendedContextSuffix(modelId)); + + const uniqueIds = [...new Set(selectedModels)]; + return uniqueIds + .map((modelId) => findCatalogModel(catalog.provider, modelId)) + .filter((model): model is NonNullable => Boolean(model?.extendedContext)); + }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); const presetGroups = useMemo(() => { const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); @@ -199,10 +205,10 @@ export function ModelConfigSection({ catalog={catalog} allModels={providerModels} /> - {/* Extended Context Toggle - only shows for models that support it */} - {currentModelEntry?.extendedContext && onExtendedContextToggle && ( + {/* Extended Context Toggle - shows when any saved mapping supports it */} + {extendedContextModels.length > 0 && onExtendedContextToggle && ( { - const env = currentSettings?.env || {}; - return MODEL_ENV_KEYS.some((key) => { - const value = env[key]; - return value && hasExtendedContextSuffix(value); - }); + return hasAnthropicExtendedContextEnabled(currentSettings?.env || {}); }, [currentSettings]); + const applySavedLongContextIntent = useCallback( + (env: Record, enabled: boolean) => + applyExtendedContextPreferenceToAnthropicModels(env, enabled, { + supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId), + }), + [provider] + ); + // Update a single setting value const updateEnvValue = useCallback( (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const newSettings = { ...currentSettings, env: newEnv }; + const envWithIntent = isAnthropicModelEnvKey(key) + ? applySavedLongContextIntent(newEnv, extendedContextEnabled) + : newEnv; + delete envWithIntent['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: envWithIntent }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings, extendedContextEnabled] ); // 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 }; + const newEnv = applySavedLongContextIntent(env, enabled); delete newEnv['CCS_EXTENDED_CONTEXT']; const newSettings = { ...currentSettings, env: newEnv }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings] ); // Batch update multiple env values at once const updateEnvValues = useCallback( (updates: Record) => { const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const newSettings = { ...currentSettings, env: newEnv }; + const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey); + const envWithIntent = touchesAnthropicModel + ? applySavedLongContextIntent(newEnv, extendedContextEnabled) + : newEnv; + delete envWithIntent['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: envWithIntent }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings, extendedContextEnabled] ); // Check if JSON is valid diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 3e09e846..2ce76d69 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -3,9 +3,16 @@ */ export { + ANTHROPIC_MODEL_ENV_KEYS, EXTENDED_CONTEXT_SUFFIX, + applyExtendedContextPreferenceToAnthropicModels, isNativeGeminiModel, + isAnthropicModelEnvKey, + hasAnthropicExtendedContextEnabled, hasExtendedContextSuffix, applyExtendedContextSuffix, + isClaudeModelId, + likelySupportsClaudeExtendedContext, + stripModelConfigurationSuffixes, stripExtendedContextSuffix, } from '../../../src/shared/extended-context-utils'; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 9e711d72..8d9f64a2 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -4,6 +4,7 @@ */ import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ export const MODEL_CATALOGS: Record = { @@ -553,3 +554,15 @@ export const MODEL_CATALOGS: Record = { ], }, }; + +export function findCatalogModel(provider: string, modelId: string) { + const catalog = MODEL_CATALOGS[provider.toLowerCase()]; + if (!catalog) return undefined; + + const normalizedModelId = stripModelConfigurationSuffixes(modelId); + return catalog.models.find((model) => model.id === normalizedModelId); +} + +export function supportsExtendedContext(provider: string, modelId: string): boolean { + return findCatalogModel(provider, modelId)?.extendedContext === true; +}