diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 0d271e106ec..0646a2d3ce9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2203,6 +2203,7 @@ export async function loadCliConfig( memoryAgentTimeoutMinutes: settings.memory?.agentTimeoutMinutes, fastModel: settings.fastModel || undefined, visionModel: settings.visionModel || undefined, + compactionModel: settings.compactionModel || undefined, visionBridgeTimeoutMs: settings.visionBridgeTimeoutMs, modelFallbacks: resolveModelFallbacks( argv.fallbackModel, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2a73f4e9951..6d9bae046de 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1318,6 +1318,17 @@ const SETTINGS_SCHEMA = { showInDialog: false, }, + compactionModel: { + type: 'string', + label: 'Compaction Model', + category: 'Model', + requiresRestart: false, + default: '', + description: + 'Model used for chat compression (auto-compaction). Set with /model --compaction. Leave empty to fall back to fastModel or the main model. A smaller/faster model reduces compression latency and cost.', + showInDialog: false, + }, + model: { type: 'object', label: 'Model', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ad9b54ecd28..abda01a35b4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1292,6 +1292,7 @@ export const AppContainer = (props: AppContainerProps) => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + isCompactionModelMode, modelDialogPersistScope, openModelDialog, closeModelDialog, @@ -3980,6 +3981,7 @@ export const AppContainer = (props: AppContainerProps) => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + isCompactionModelMode, modelDialogPersistScope, isTrustDialogOpen, activeArenaDialog, @@ -4123,6 +4125,7 @@ export const AppContainer = (props: AppContainerProps) => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + isCompactionModelMode, modelDialogPersistScope, isTrustDialogOpen, activeArenaDialog, diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 93ae32fabe2..4077b6f05e5 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -46,7 +46,7 @@ describe('modelCommand', () => { it('should have the correct name and description', () => { expect(modelCommand.name).toBe('model'); expect(modelCommand.description).toBe( - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --compaction for chat compression model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', ); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 29d9f94da34..56c17ae280a 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -38,6 +38,9 @@ const MAIN_MODEL_CONFIGURATION_HINT = const FAST_MODEL_CONFIGURATION_HINT = 'Configure models in settings.modelProviders and ensure the required environment variables are set. In interactive mode, run /auth to configure or switch providers, or run /model --fast without a model to choose from configured models.'; +const COMPACTION_MODEL_CONFIGURATION_HINT = + 'Configure models in settings.modelProviders and ensure the required environment variables are set. In interactive mode, run /auth to configure or switch providers, or run /model --compaction without a model to choose from configured models.'; + const VISION_MODEL_CONFIGURATION_HINT = 'Configure an image-capable model in settings.modelProviders and ensure the required environment variables are set. Run /model --vision to set it, or leave it unset to auto-pick a same-provider vision model.'; @@ -143,7 +146,7 @@ async function switchMainModel( } function formatUnavailableModelMessage( - kind: 'Model' | 'Fast model' | 'Vision model', + kind: 'Model' | 'Fast model' | 'Vision model' | 'Compaction model', modelName: string, authType: AuthType, availableModels: AvailableModel[], @@ -161,7 +164,9 @@ function formatUnavailableModelMessage( ? FAST_MODEL_CONFIGURATION_HINT : kind === 'Vision model' ? VISION_MODEL_CONFIGURATION_HINT - : MAIN_MODEL_CONFIGURATION_HINT; + : kind === 'Compaction model' + ? COMPACTION_MODEL_CONFIGURATION_HINT + : MAIN_MODEL_CONFIGURATION_HINT; return ( `${kind} '${modelName}' is not available for auth type '${authType}'.\n` + @@ -170,10 +175,10 @@ function formatUnavailableModelMessage( ); } -// Fast and vision share the same "not configured for any auth type" message +// Fast, vision, and compaction share the same "not configured for any auth type" message // shape, differing only in the label and the configuration hint. function formatUnavailableAuxModelMessage( - label: 'Fast model' | 'Vision model', + label: 'Fast model' | 'Vision model' | 'Compaction model', modelName: string, availableModels: AvailableModel[], hint: string, @@ -217,6 +222,18 @@ function formatUnavailableVisionModelMessage( ); } +function formatUnavailableCompactionModelMessage( + modelName: string, + availableModels: AvailableModel[], +): string { + return formatUnavailableAuxModelMessage( + 'Compaction model', + modelName, + availableModels, + COMPACTION_MODEL_CONFIGURATION_HINT, + ); +} + function formatAmbiguousVisionModelMessage( modelName: string, matchingModels: AvailableModel[], @@ -294,7 +311,7 @@ function formatUnavailableVoiceModelMessage( // Get an array of the available model IDs as strings, filtered by mode function getAvailableModelIds( context: CommandContext, - mode: 'main' | 'fast' | 'voice' | 'vision' = 'main', + mode: 'main' | 'fast' | 'voice' | 'vision' | 'compaction' = 'main', ) { const { services } = context; const { config } = services; @@ -302,7 +319,7 @@ function getAvailableModelIds( return []; } const availableModels = config.getAvailableModels().filter((m) => { - if (mode === 'fast') return !m.voiceOnly; + if (mode === 'fast' || mode === 'compaction') return !m.voiceOnly; if (mode === 'voice') return !m.fastOnly; // 'vision' and 'main' both exclude fast/voice-only models. return !m.fastOnly && !m.voiceOnly; @@ -315,11 +332,11 @@ export const modelCommand: SlashCommand = { completionPriority: 100, get description() { return t( - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --compaction for chat compression model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', ); }, argumentHint: - '[--fast|--voice|--vision] [--project|--global] [] | ', + '[--fast|--voice|--vision|--compaction] [--project|--global] [] | ', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, completion: async (context, partialArg) => { @@ -341,6 +358,12 @@ export const modelCommand: SlashCommand = { 'Set the image-capable model used to transcribe images for a text-only main model', ), }, + { + value: '--compaction', + description: t( + 'Set the model used for chat compression (auto-compaction)', + ), + }, { value: '--project', description: t( @@ -359,18 +382,20 @@ export const modelCommand: SlashCommand = { } const trimmed = partialArg.trim(); if (trimmed) { - let mode: 'main' | 'fast' | 'voice' | 'vision' = 'main'; + let mode: 'main' | 'fast' | 'voice' | 'vision' | 'compaction' = 'main'; // Strip all known flags to isolate the model prefix for completion const modelPrefix = trimmed .replace(/(?:^|\s)--fast(?:\s|$)/, ' ') .replace(/(?:^|\s)--voice(?:\s|$)/, ' ') .replace(/(?:^|\s)--vision(?:\s|$)/, ' ') + .replace(/(?:^|\s)--compaction(?:\s|$)/, ' ') .replace(/(?:^|\s)--project(?:\s|$)/, ' ') .replace(/(?:^|\s)--global(?:\s|$)/, ' ') .trim(); if (/(?:^|\s)--fast(?:\s|$)/.test(trimmed)) mode = 'fast'; else if (/(?:^|\s)--voice(?:\s|$)/.test(trimmed)) mode = 'voice'; else if (/(?:^|\s)--vision(?:\s|$)/.test(trimmed)) mode = 'vision'; + else if (/(?:^|\s)--compaction(?:\s|$)/.test(trimmed)) mode = 'compaction'; return getAvailableModelIds(context, mode).filter((id) => id.startsWith(modelPrefix), ); @@ -711,6 +736,85 @@ export const modelCommand: SlashCommand = { }; } + const isCompactionModelCommand = + args === '--compaction' || args.startsWith('--compaction '); + if (isCompactionModelCommand) { + const modelName = args.replace('--compaction', '').trim(); + if (!modelName) { + if (context.executionMode !== 'interactive') { + const compactionModel = + context.services.settings?.merged?.compactionModel?.trim() || + t('not set (falls back to fast model, then main model)'); + return { + type: 'message', + messageType: 'info', + content: t( + 'Current compaction model: {{compactionModel}}\nUse "/model --compaction " to set compaction model, or "/model --compaction " to clear the override.', + { compactionModel }, + ), + }; + } + return { + type: 'dialog', + dialog: 'compaction-model', + }; + } + if (!settings) { + return { + type: 'message', + messageType: 'error', + content: t('Settings service not available.'), + }; + } + + const selector = (() => { + try { + return resolveModelId(modelName); + } catch { + return undefined; + } + })(); + if (!selector) { + return { + type: 'message', + messageType: 'error', + content: formatUnavailableCompactionModelMessage(modelName, []), + }; + } + + const availableModels = ( + selector.authType + ? config.getAvailableModelsForAuthType(selector.authType) + : config.getAllConfiguredModels() + ).filter((m) => !m.voiceOnly); + if (!availableModels.some((model) => model.id === selector.modelId)) { + return { + type: 'message', + messageType: 'error', + content: selector.authType + ? formatUnavailableModelMessage( + 'Compaction model', + selector.modelId, + selector.authType, + availableModels, + ) + : formatUnavailableCompactionModelMessage( + modelName, + availableModels, + ), + }; + } + + persistSetting(settings, 'compactionModel', modelName); + // Sync runtime Config so the compression service picks it up immediately. + config.setCompactionModel(modelName); + return { + type: 'message', + messageType: 'info', + content: t('Compaction Model') + ': ' + modelName, + }; + } + const contentGeneratorConfig = config.getContentGeneratorConfig(); if (!contentGeneratorConfig) { return { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 19714c33743..fd97ea2b4f7 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -189,6 +189,7 @@ export interface OpenDialogActionReturn { | 'fast-model' | 'voice-model' | 'vision-model' + | 'compaction-model' | 'subagent_create' | 'subagent_list' | 'skills_manage' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index a611cae428b..1c1540ebc89 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -272,6 +272,7 @@ export const DialogManager = ({ isFastModelMode={uiState.isFastModelMode} isVoiceModelMode={uiState.isVoiceModelMode} isVisionModelMode={uiState.isVisionModelMode} + isCompactionModelMode={uiState.isCompactionModelMode} persistScope={uiState.modelDialogPersistScope} availableTerminalHeight={listDialogHeight} /> diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index d8158e72cc8..2728ce690f1 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -116,6 +116,7 @@ interface ModelDialogProps { isFastModelMode?: boolean; isVoiceModelMode?: boolean; isVisionModelMode?: boolean; + isCompactionModelMode?: boolean; /** Override which settings scope to persist the selection to. */ persistScope?: 'workspace' | 'user'; availableTerminalHeight?: number; @@ -285,6 +286,7 @@ export function ModelDialog({ isFastModelMode, isVoiceModelMode, isVisionModelMode, + isCompactionModelMode, persistScope, availableTerminalHeight, }: ModelDialogProps): React.JSX.Element { @@ -309,7 +311,9 @@ export function ModelDialog({ (m.authType !== AuthType.QWEN_OAUTH || authType === AuthType.QWEN_OAUTH) && (isFastModelMode || !m.fastOnly) && - (isVoiceModelMode || !m.voiceOnly), + (isVoiceModelMode || !m.voiceOnly) && + (isVisionModelMode || !m.visionOnly) && + (isCompactionModelMode || !m.visionOnly), ); // Group registry models by authType @@ -363,7 +367,14 @@ export function ModelDialog({ } return result; - }, [authType, config, isFastModelMode, isVoiceModelMode]); + }, [ + authType, + config, + isFastModelMode, + isVoiceModelMode, + isVisionModelMode, + isCompactionModelMode, + ]); const MODEL_OPTIONS = useMemo( () => @@ -488,8 +499,11 @@ export function ModelDialog({ // Check if current model is a runtime model // Runtime snapshot ID is already in $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = - isFastModelMode || isVoiceModelMode || isVisionModelMode - ? undefined // fast/voice/vision models are never runtime model selections + isFastModelMode || + isVoiceModelMode || + isVisionModelMode || + isCompactionModelMode + ? undefined // fast/voice/vision/compaction models are never runtime model selections : config?.getActiveRuntimeModelSnapshot?.(); const currentBaseUrl = config ?.getModelsConfig() diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c5274c0ec73..3d590247c94 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -57,6 +57,7 @@ export interface UIActions { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + compactionModelMode?: boolean; }) => void; openArenaDialog: (type: Exclude) => void; closeArenaDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 2cd2c4ac828..4d3dd815f70 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -72,6 +72,7 @@ export interface UIState { isFastModelMode: boolean; isVoiceModelMode: boolean; isVisionModelMode: boolean; + isCompactionModelMode: boolean; modelDialogPersistScope: 'workspace' | 'user' | undefined; isTrustDialogOpen: boolean; activeArenaDialog: ArenaDialogType; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index cfa59d4506c..58b4c3195d0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -134,6 +134,7 @@ export interface SlashCommandProcessorActions { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + compactionModelMode?: boolean; persistScope?: 'workspace' | 'user'; }) => void; openTrustDialog: () => void; @@ -1119,6 +1120,9 @@ export const useSlashCommandProcessor = ( persistScope: result.persistScope, }); return { type: 'handled' }; + case 'compaction-model': + actions.openModelDialog({ compactionModelMode: true }); + return { type: 'handled' }; case 'trust': actions.openTrustDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useModelCommand.ts b/packages/cli/src/ui/hooks/useModelCommand.ts index 377fe5bd7c4..f3aab82c2fe 100644 --- a/packages/cli/src/ui/hooks/useModelCommand.ts +++ b/packages/cli/src/ui/hooks/useModelCommand.ts @@ -13,11 +13,13 @@ interface UseModelCommandReturn { isFastModelMode: boolean; isVoiceModelMode: boolean; isVisionModelMode: boolean; + isCompactionModelMode: boolean; modelDialogPersistScope: ModelDialogPersistScope | undefined; openModelDialog: (options?: { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + compactionModelMode?: boolean; persistScope?: ModelDialogPersistScope; }) => void; closeModelDialog: () => void; @@ -28,6 +30,7 @@ export const useModelCommand = (): UseModelCommandReturn => { const [isFastModelMode, setIsFastModelMode] = useState(false); const [isVoiceModelMode, setIsVoiceModelMode] = useState(false); const [isVisionModelMode, setIsVisionModelMode] = useState(false); + const [isCompactionModelMode, setIsCompactionModelMode] = useState(false); const [modelDialogPersistScope, setModelDialogPersistScope] = useState< ModelDialogPersistScope | undefined >(undefined); @@ -37,20 +40,25 @@ export const useModelCommand = (): UseModelCommandReturn => { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + compactionModelMode?: boolean; persistScope?: ModelDialogPersistScope; }) => { const voiceModelMode = options?.voiceModelMode ?? false; const visionModelMode = options?.visionModelMode ?? false; + const compactionModelMode = options?.compactionModelMode ?? false; // Modes are mutually exclusive; a specialized mode suppresses fast mode. setIsFastModelMode( - voiceModelMode || visionModelMode + voiceModelMode || visionModelMode || compactionModelMode ? false : (options?.fastModelMode ?? false), ); // Vision wins over voice when both are passed, so the dialog can't end up // in two specialized modes at once (mismatched title vs. highlighted row). - setIsVoiceModelMode(visionModelMode ? false : voiceModelMode); - setIsVisionModelMode(visionModelMode); + setIsVoiceModelMode( + visionModelMode || compactionModelMode ? false : voiceModelMode, + ); + setIsVisionModelMode(compactionModelMode ? false : visionModelMode); + setIsCompactionModelMode(compactionModelMode); setModelDialogPersistScope(options?.persistScope); setIsModelDialogOpen(true); }, @@ -62,6 +70,7 @@ export const useModelCommand = (): UseModelCommandReturn => { setIsFastModelMode(false); setIsVoiceModelMode(false); setIsVisionModelMode(false); + setIsCompactionModelMode(false); setModelDialogPersistScope(undefined); }, []); @@ -70,6 +79,7 @@ export const useModelCommand = (): UseModelCommandReturn => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + isCompactionModelMode, modelDialogPersistScope, openModelDialog, closeModelDialog, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1e073904209..bd1c7ad9fca 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1156,6 +1156,12 @@ export interface ConfigParameters { * (configurable via `/model --vision`). */ visionModel?: string; + /** + * Dedicated model for chat compression (auto-compaction). Falls back to + * fastModel, then the main model. Corresponds to the `compactionModel` setting + * (configurable via `/model --compaction`). + */ + compactionModel?: string; /** * Per-attempt timeout in milliseconds for the vision bridge transcription * call. Unset → built-in 30s. Corresponds to the `visionBridgeTimeoutMs` @@ -1786,6 +1792,7 @@ export class Config { private readonly memoryAgentTimeoutMinutes: number | undefined; private fastModel?: string; private visionModel?: string; + private compactionModel?: string; private readonly visionBridgeTimeoutMs: number | undefined; private readonly modelFallbacks: string[]; private readonly disableAllHooks: boolean; @@ -2116,6 +2123,7 @@ export class Config { : undefined; this.fastModel = params.fastModel || undefined; this.visionModel = params.visionModel || undefined; + this.compactionModel = params.compactionModel || undefined; // Guard: nothing validates settings.json on the load path, so this is the // only real gate. `AbortSignal.timeout()` requires an integer in // [0, 2^31-1] — a fractional or out-of-range value (which the number-typed @@ -3358,6 +3366,62 @@ export class Config { this.visionModel = model || undefined; } + /** + * Resolve the compaction model for chat compression (auto-compaction). + * Priority: compactionModel (if set) → fastModel (if set) → main model. + */ + getCompactionModel(): string | undefined { + const selector = this.resolveCompactionModelSelector(); + if (selector) { + const available = selector.authType + ? this.getAllConfiguredModels([selector.authType]) + : this.getAllConfiguredModels(); + if (!available.some((m) => m.id === selector.modelId)) { + return undefined; + } + const rawSelector = resolveModelId(this.compactionModel); + return rawSelector?.authType + ? `${rawSelector.authType}:${selector.modelId}` + : selector.modelId; + } + // Fallback: fastModel → main model + return this.getFastModel() ?? this.getModel(); + } + + private resolveCompactionModelSelector() { + if (!this.compactionModel) return undefined; + try { + const rawSelector = resolveModelId(this.compactionModel); + if (!rawSelector) return undefined; + if (rawSelector.authType) return rawSelector; + + const currentAuthType = this.getContentGeneratorConfig()?.authType; + if (!currentAuthType) { + this.debugLogger.debug( + 'No active auth type; skipping bare compaction model resolution', + ); + return undefined; + } + + return resolveModelId(this.compactionModel, { + currentAuthType, + getAvailableModels: () => + this.getAllConfiguredModels([currentAuthType]), + }); + } catch { + return undefined; + } + } + + /** + * Update the compaction model at runtime (e.g. `/model --compaction `). + * Pass undefined or an empty string to clear the override and fall back to + * fastModel, then the main model. + */ + setCompactionModel(model: string | undefined): void { + this.compactionModel = model || undefined; + } + /** * Return the ordered list of fallback model IDs configured for this session. * The list is already normalized (deduplicated, capped at 3, blanks removed). diff --git a/packages/core/src/models/types.ts b/packages/core/src/models/types.ts index cb2a3f0ed6c..f352f4ce5f8 100644 --- a/packages/core/src/models/types.ts +++ b/packages/core/src/models/types.ts @@ -127,6 +127,8 @@ export interface AvailableModel { fastOnly?: boolean; /** When true, this model only appears in the voice model selector */ voiceOnly?: boolean; + /** When true, this model only appears in the vision model selector */ + visionOnly?: boolean; /** Whether this is a runtime model (not from modelProviders) */ isRuntimeModel?: boolean; diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index b8c1cf09a0f..d64d8d520a4 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -53,6 +53,7 @@ describe('ChatCompressionService', () => { getContentGeneratorConfig: vi.fn().mockReturnValue({}), getHookSystem: mockGetHookSystem, getModel: () => 'test-model', + getFastModel: vi.fn(), getApprovalMode: () => 'default', getDebugLogger: () => ({ warn: vi.fn(), @@ -838,7 +839,10 @@ describe('ChatCompressionService', () => { expect(serialized).toContain('[image: image/png]'); }); - it('forwards model, maxAttempts, and thinkingConfig to runSideQuery', async () => { + it('passes getFastModel to runSideQuery for compression', async () => { + // Compression passes config.getFastModel?.() to runSideQuery so it uses + // the fast model (e.g., Qwen/Qwen3-Alpha) instead of the expensive main + // model, reducing cost. See https://github.com/QwenLM/qwen-code/issues/5956 const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, { role: 'model', parts: [{ text: 'msg2' }] }, @@ -848,6 +852,7 @@ describe('ChatCompressionService', () => { vi.mocked(mockChat.getHistory).mockReturnValue(history); vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100); vi.mocked(tokenLimit).mockReturnValue(1000); + vi.mocked(mockConfig.getFastModel).mockReturnValue('fast-model-v1'); const mockGenerateText = vi.fn().mockResolvedValue({ text: 'Summary', @@ -874,9 +879,10 @@ describe('ChatCompressionService', () => { // inconsistent) and the output is hard-capped by COMPACT_MAX_OUTPUT_TOKENS // so subsequent threshold math has a predictable reserve. maxAttempts=1 // keeps the call best-effort (next turn re-triggers on failure). + // Model is set to getFastModel?.() to use the fast model for cost efficiency. expect(mockGenerateText).toHaveBeenCalledWith( expect.objectContaining({ - model: mockModel, + model: 'fast-model-v1', maxAttempts: 1, config: expect.objectContaining({ thinkingConfig: { includeThoughts: false }, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 65ca1592c7b..6d14119d1cb 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -318,7 +318,6 @@ export class ChatCompressionService { const { promptId, force, - model, config, consecutiveFailures, originalTokenCount, @@ -520,7 +519,9 @@ export class ChatCompressionService { const summaryResult = await runSideQuery(config, { purpose: 'chat-compression', skipOutputLanguagePreference: true, - model, + model: config.getFastModel?.(), + // Compression uses the fast model (config.getFastModel?.()) to reduce cost. + // See https://github.com/QwenLM/qwen-code/issues/5956 // Stream so a slow compression inference keeps the HTTP connection alive. // Non-streaming returns no bytes until the whole summary is generated, so // behind a BFF gateway with a short `proxy_read_timeout` a long inference