From 4b4e112a2c2fecd7c15e32ca9f517131253ca1c9 Mon Sep 17 00:00:00 2001 From: hanger Date: Fri, 29 May 2026 09:45:40 +0800 Subject: [PATCH] fix claude model option merging (#726) --- .../AssistantChat/modelOptions.test.ts | 37 +++++++++++++++ .../components/AssistantChat/modelOptions.ts | 46 ++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index 512d8472..e5fadbcb 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -16,6 +16,35 @@ describe('getModelOptionsForFlavor', () => { expect(options.some((o) => o.value === 'opus')).toBe(true) }) + it('keeps Claude presets when explicit options only include Sonnet models', () => { + const options = getModelOptionsForFlavor('claude', null, [ + { value: null, label: 'Default' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' } + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' }, + { value: 'opus', label: 'Opus' }, + { value: 'opus[1m]', label: 'Opus 1M' } + ]) + }) + + it('adds non-preset Claude options without hiding Opus presets', () => { + const options = getModelOptionsForFlavor('claude', null, [ + { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' } + ]) + expect(options).toEqual([ + { value: null, label: 'Default' }, + { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' }, + { value: 'opus', label: 'Opus' }, + { value: 'opus[1m]', label: 'Opus 1M' } + ]) + }) + it('includes custom Gemini model from env/config in options', () => { const options = getModelOptionsForFlavor('gemini', 'gemini-custom-experiment') expect(options.some((o) => o.value === 'gemini-custom-experiment')).toBe(true) @@ -94,6 +123,14 @@ describe('getNextModelForFlavor', () => { expect(next).not.toBeNull() }) + it('cycles through Claude presets when explicit options only include Sonnet models', () => { + const next = getNextModelForFlavor('claude', 'sonnet[1m]', [ + { value: 'sonnet', label: 'Sonnet' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M' } + ]) + expect(next).toBe('opus') + }) + it('cycles explicit model options', () => { const next = getNextModelForFlavor('codex', 'gpt-5.5', [ { value: 'gpt-5.5', label: 'GPT-5.5' }, diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 30ee7690..cab7c3a8 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -28,6 +28,39 @@ function withCurrentModelOption(options: ModelOption[], currentModel?: string | return nextOptions } +function getClaudeModelOptions(currentModel?: string | null, customOptions?: ModelOption[]): ModelOption[] { + if (!customOptions || customOptions.length === 0) { + return getClaudeComposerModelOptions(currentModel) + } + + const options = getClaudeComposerModelOptions(currentModel) + const nextOptions = [...options] + let insertIndex = Math.max(1, nextOptions.findIndex((option) => option.value !== null)) + + for (const option of customOptions) { + const normalizedValue = normalizeCurrentModel(option.value) + if (!normalizedValue) { + continue + } + + const existingIndex = nextOptions.findIndex((nextOption) => nextOption.value === normalizedValue) + if (existingIndex >= 0) { + if (nextOptions[existingIndex]?.label === normalizedValue) { + nextOptions[existingIndex] = option + } + continue + } + + nextOptions.splice(insertIndex, 0, { + value: normalizedValue, + label: option.label + }) + insertIndex += 1 + } + + return nextOptions +} + function getGeminiModelOptions(currentModel?: string | null): ModelOption[] { const options = MODEL_OPTIONS.gemini.map((m) => ({ value: m.value === 'auto' ? null : m.value, @@ -50,6 +83,9 @@ export function getModelOptionsForFlavor( currentModel?: string | null, customOptions?: ModelOption[] ): ModelOption[] { + if (flavor === 'claude') { + return getClaudeModelOptions(currentModel, customOptions) + } if (customOptions && customOptions.length > 0) { return withCurrentModelOption(customOptions, currentModel) } @@ -69,7 +105,7 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } - return getClaudeComposerModelOptions(currentModel) + return getClaudeModelOptions(currentModel) } export function getNextModelForFlavor( @@ -77,6 +113,14 @@ export function getNextModelForFlavor( currentModel?: string | null, customOptions?: ModelOption[] ): string | null { + if (flavor === 'claude') { + const options = getClaudeModelOptions(currentModel, customOptions) + const currentIndex = options.findIndex((option) => option.value === (normalizeCurrentModel(currentModel) ?? null)) + if (currentIndex === -1) { + return options[0]?.value ?? null + } + return options[(currentIndex + 1) % options.length]?.value ?? null + } if (customOptions && customOptions.length > 0) { const options = getModelOptionsForFlavor(flavor, currentModel, customOptions) const currentIndex = options.findIndex((option) => option.value === (normalizeCurrentModel(currentModel) ?? null))