From d662c97302586edfd711a4a2b3a19fe2a95aa1e1 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 10 Jun 2026 08:58:06 +0800 Subject: [PATCH] feat: claude-fable-5 --- backend/internal/domain/constants.go | 3 ++ backend/internal/domain/constants_test.go | 36 ++++++++++------ .../internal/pkg/antigravity/claude_types.go | 1 + .../pkg/antigravity/claude_types_test.go | 1 + .../pkg/antigravity/request_transformer.go | 1 + backend/internal/pkg/claude/constants.go | 6 +++ .../service/antigravity_model_mapping_test.go | 7 ++++ backend/internal/service/bedrock_request.go | 18 ++++++++ .../internal/service/bedrock_request_test.go | 29 +++++++++++++ .../account/AccountStatusIndicator.vue | 1 + .../components/account/AccountUsageCell.vue | 1 + frontend/src/components/keys/UseKeyModal.vue | 16 +++++++ .../keys/__tests__/UseKeyModal.spec.ts | 42 +++++++++++++++++++ .../__tests__/useModelWhitelist.spec.ts | 4 +- frontend/src/composables/useModelWhitelist.ts | 7 +++- 15 files changed, 159 insertions(+), 14 deletions(-) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 7601f35bc9..4ed3f72b3a 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -72,6 +72,7 @@ const ( // 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致 var DefaultAntigravityModelMapping = map[string]string{ // Claude 白名单 + "claude-fable-5": "claude-fable-5", // 官方模型 "claude-opus-4-8": "claude-opus-4-8", // 官方模型 "claude-opus-4-7": "claude-opus-4-7", // 官方模型 "claude-opus-4-6-thinking": "claude-opus-4-6-thinking", // 官方模型 @@ -122,6 +123,8 @@ var DefaultAntigravityModelMapping = map[string]string{ // 注意:此处的 "us." 前缀仅为默认值,ResolveBedrockModelID 会根据账号配置的 // aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等) var DefaultBedrockModelMapping = map[string]string{ + // Claude Fable + "claude-fable-5": "anthropic.claude-fable-5", // Claude Opus "claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1", "claude-opus-4-7": "us.anthropic.claude-opus-4-7-v1", diff --git a/backend/internal/domain/constants_test.go b/backend/internal/domain/constants_test.go index fe6272c5ac..0b24aea915 100644 --- a/backend/internal/domain/constants_test.go +++ b/backend/internal/domain/constants_test.go @@ -25,26 +25,38 @@ func TestDefaultAntigravityModelMapping_ImageCompatibilityAliases(t *testing.T) } } -func TestDefaultAntigravityModelMapping_ContainsOpus48(t *testing.T) { +func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() - got, ok := DefaultAntigravityModelMapping["claude-opus-4-8"] - if !ok { - t.Fatal("expected mapping for claude-opus-4-8 to exist") + cases := map[string]string{ + "claude-fable-5": "claude-fable-5", + "claude-opus-4-8": "claude-opus-4-8", } - if got != "claude-opus-4-8" { - t.Fatalf("unexpected claude-opus-4-8 mapping: got %q", got) + for from, want := range cases { + got, ok := DefaultAntigravityModelMapping[from] + if !ok { + t.Fatalf("expected mapping for %q to exist", from) + } + if got != want { + t.Fatalf("unexpected mapping for %q: got %q want %q", from, got, want) + } } } -func TestDefaultBedrockModelMapping_ContainsOpus48(t *testing.T) { +func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) { t.Parallel() - got, ok := DefaultBedrockModelMapping["claude-opus-4-8"] - if !ok { - t.Fatal("expected Bedrock mapping for claude-opus-4-8 to exist") + cases := map[string]string{ + "claude-fable-5": "anthropic.claude-fable-5", + "claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1", } - if got != "us.anthropic.claude-opus-4-8-v1" { - t.Fatalf("unexpected Bedrock claude-opus-4-8 mapping: got %q", got) + for from, want := range cases { + got, ok := DefaultBedrockModelMapping[from] + if !ok { + t.Fatalf("expected Bedrock mapping for %q to exist", from) + } + if got != want { + t.Fatalf("unexpected Bedrock mapping for %q: got %q want %q", from, got, want) + } } } diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index b651db9408..cd68c02788 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -149,6 +149,7 @@ type modelDef struct { // Antigravity 支持的 Claude 模型 var claudeModels = []modelDef{ + {ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"}, {ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"}, {ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"}, {ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"}, diff --git a/backend/internal/pkg/antigravity/claude_types_test.go b/backend/internal/pkg/antigravity/claude_types_test.go index fdf5c66de1..bb45c2f0c3 100644 --- a/backend/internal/pkg/antigravity/claude_types_test.go +++ b/backend/internal/pkg/antigravity/claude_types_test.go @@ -12,6 +12,7 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) { } requiredIDs := []string{ + "claude-fable-5", "claude-opus-4-8", "claude-opus-4-6-thinking", "gemini-2.5-flash-image", diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 9068ad9730..ac3610d0ab 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -204,6 +204,7 @@ type modelInfo struct { // 只有在此映射表中的模型才会注入身份提示词 // 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。 var modelInfoMap = map[string]modelInfo{ + "claude-fable-5": {DisplayName: "Claude Fable 5", CanonicalID: "claude-fable-5"}, "claude-opus-4-8": {DisplayName: "Claude Opus 4.8", CanonicalID: "claude-opus-4-8"}, "claude-opus-4-7": {DisplayName: "Claude Opus 4.7", CanonicalID: "claude-opus-4-7"}, "claude-opus-4-5": {DisplayName: "Claude Opus 4.5", CanonicalID: "claude-opus-4-5-20250929"}, diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 9529097054..06335aeda9 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -116,6 +116,12 @@ type Model struct { // DefaultModels Claude Code 客户端支持的默认模型列表 var DefaultModels = []Model{ + { + ID: "claude-fable-5", + Type: "model", + DisplayName: "Claude Fable 5", + CreatedAt: "2026-06-09T00:00:00Z", + }, { ID: "claude-opus-4-5-20251101", Type: "model", diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 652b6d6699..62ff2e295d 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -76,6 +76,12 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { }, // 3. 默认映射中的透传(映射到自己) + { + name: "默认映射透传 - claude-fable-5", + requestedModel: "claude-fable-5", + accountMapping: nil, + expected: "claude-fable-5", + }, { name: "默认映射透传 - claude-sonnet-4-6", requestedModel: "claude-sonnet-4-6", @@ -218,6 +224,7 @@ func TestAntigravityGatewayService_IsModelSupported(t *testing.T) { expected bool }{ // 直接支持 + {"直接支持 - claude-fable-5", "claude-fable-5", true}, {"直接支持 - claude-sonnet-4-5", "claude-sonnet-4-5", true}, {"直接支持 - gemini-3-flash", "gemini-3-flash", true}, diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go index f4416ce3a6..69830a6768 100644 --- a/backend/internal/service/bedrock_request.go +++ b/backend/internal/service/bedrock_request.go @@ -348,6 +348,9 @@ var claudeVersionRe = regexp.MustCompile(`claude-(?:haiku|sonnet|opus)-(\d+)[-.] // Claude 4.5+ 支持 cache_control 中的 ttl 字段("5m" 和 "1h") func isBedrockClaude45OrNewer(modelID string) bool { lower := strings.ToLower(modelID) + if isBedrockFable5(lower) { + return true + } matches := claudeVersionRe.FindStringSubmatch(lower) if matches == nil { return false @@ -658,9 +661,14 @@ func isBedrockOpus47OrNewer(modelID string) bool { return major > 4 || (major == 4 && minor >= 7) } +func isBedrockFable5(modelID string) bool { + return strings.Contains(strings.ToLower(modelID), "claude-fable-5") +} + const defaultThinkingBudgetTokens = 10000 // sanitizeBedrockThinking 修复 thinking 字段的 Bedrock 兼容性问题: +// - Fable 5: 仅使用 always-on adaptive thinking,不支持手动 budget_tokens // - Opus 4.7+: 仅支持 "adaptive",将 "enabled" 转换为 "adaptive" 并移除 budget_tokens // - 其他模型: "enabled" 必须带 budget_tokens,缺失时补充默认值 func sanitizeBedrockThinking(body []byte, modelID string) []byte { @@ -674,6 +682,16 @@ func sanitizeBedrockThinking(body []byte, modelID string) []byte { return body } + if isBedrockFable5(modelID) { + if thinkingType == "enabled" { + body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") + } + if thinkingType == "enabled" || thinkingType == "adaptive" { + body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens") + } + return body + } + if isBedrockOpus47OrNewer(modelID) { if thinkingType == "enabled" { body, _ = sjson.SetBytes(body, "thinking.type", "adaptive") diff --git a/backend/internal/service/bedrock_request_test.go b/backend/internal/service/bedrock_request_test.go index c5b71da74f..f209ef0749 100644 --- a/backend/internal/service/bedrock_request_test.go +++ b/backend/internal/service/bedrock_request_test.go @@ -175,6 +175,7 @@ func TestIsBedrockClaude45OrNewer(t *testing.T) { }{ {"us.anthropic.claude-opus-4-6-v1", true}, {"us.anthropic.claude-opus-4-8-v1", true}, + {"anthropic.claude-fable-5", true}, {"us.anthropic.claude-sonnet-4-6", true}, {"us.anthropic.claude-sonnet-4-5-20250929-v1:0", true}, {"us.anthropic.claude-opus-4-5-20251101-v1:0", true}, @@ -526,6 +527,20 @@ func TestResolveBedrockModelID(t *testing.T) { assert.Equal(t, "eu.anthropic.claude-opus-4-8-v1", modelID) }) + t.Run("默认 Fable 5 映射使用官方 Bedrock 模型 ID", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeBedrock, + Credentials: map[string]any{ + "aws_region": "eu-west-1", + }, + } + + modelID, ok := ResolveBedrockModelID(account, "claude-fable-5") + require.True(t, ok) + assert.Equal(t, "anthropic.claude-fable-5", modelID) + }) + t.Run("force global rewrites anthropic regional model id", func(t *testing.T) { account := &Account{ Platform: PlatformAnthropic, @@ -750,6 +765,20 @@ func TestIsBedrockOpus47OrNewer(t *testing.T) { } func TestSanitizeBedrockThinking(t *testing.T) { + t.Run("Fable 5 将 enabled 转换为 adaptive 并移除预算", func(t *testing.T) { + input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}` + result := sanitizeBedrockThinking([]byte(input), "anthropic.claude-fable-5") + assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String()) + assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists()) + }) + + t.Run("Fable 5 adaptive 移除预算", func(t *testing.T) { + input := `{"thinking":{"type":"adaptive","budget_tokens":10000},"messages":[]}` + result := sanitizeBedrockThinking([]byte(input), "claude-fable-5") + assert.Equal(t, "adaptive", gjson.GetBytes(result, "thinking.type").String()) + assert.False(t, gjson.GetBytes(result, "thinking.budget_tokens").Exists()) + }) + t.Run("opus 4.7 converts enabled to adaptive", func(t *testing.T) { input := `{"thinking":{"type":"enabled","budget_tokens":10000},"messages":[]}` result := sanitizeBedrockThinking([]byte(input), "us.anthropic.claude-opus-4-7-v1") diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index a37b5c8e54..8d019bcf7f 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -220,6 +220,7 @@ const activeModelStatuses = computed(() => { const formatScopeName = (scope: string): string => { const aliases: Record = { // Claude 系列 + 'claude-fable-5': 'CFable5', 'claude-opus-4-6': 'COpus46', 'claude-opus-4-6-thinking': 'COpus46T', 'claude-opus-4-7': 'COpus47', diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 887fbf7955..561f90d0e0 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -662,6 +662,7 @@ const antigravity3ImageUsageFromAPI = computed(() => // Claude from API (all Claude model variants) const antigravityClaudeUsageFromAPI = computed(() => getAntigravityUsageFromAPI([ + 'claude-fable-5', 'claude-sonnet-4-5', 'claude-opus-4-5-thinking', 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-opus-4-6-thinking', 'claude-opus-4-7', 'claude-opus-4-8', diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 18440c6011..be12e8fc2f 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -966,6 +966,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin } } const claudeModels = { + 'claude-fable-5': { + name: 'Claude Fable 5', + limit: { + context: 1048576, + output: 128000 + }, + modalities: { + input: ['text', 'image', 'pdf'], + output: ['text'] + }, + options: { + thinking: { + type: 'adaptive' + } + } + }, 'claude-opus-4-6-thinking': { name: 'Claude 4.6 Opus (Thinking)', limit: { diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index b3fdeb937a..8ad9731baa 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -122,4 +122,46 @@ describe('UseKeyModal', () => { expect(codeBlock.text()).toContain('"name": "GPT-5.4 Mini"') expect(codeBlock.text()).not.toContain('"name": "GPT-5.4 Nano"') }) + + it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-test', + baseUrl: 'https://example.com/v1', + platform: 'antigravity' + }, + global: { + stubs: { + BaseDialog: { + template: '
' + }, + Icon: { + template: '' + } + } + } + }) + + const opencodeTab = wrapper.findAll('button').find((button) => + button.text().includes('keys.useKeyModal.cliTabs.opencode') + ) + + expect(opencodeTab).toBeDefined() + await opencodeTab!.trigger('click') + await nextTick() + + const claudeConfig = wrapper.findAll('pre code') + .map((code) => code.text()) + .find((content) => content.includes('"antigravity-claude"')) + + expect(claudeConfig).toBeDefined() + const parsed = JSON.parse(claudeConfig!) + const fable = parsed.provider['antigravity-claude'].models['claude-fable-5'] + + expect(fable.name).toBe('Claude Fable 5') + expect(fable.limit).toEqual({ context: 1048576, output: 128000 }) + expect(fable.options.thinking).toEqual({ type: 'adaptive' }) + expect(fable.options.thinking).not.toHaveProperty('budgetTokens') + }) }) diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index e64250a4c4..7f3157dfff 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -35,7 +35,9 @@ describe('useModelWhitelist', () => { expect(models).toContain('gemini-3-pro-image') }) - it('Claude 模型列表包含 Opus 4.8', () => { + it('Claude 模型列表包含新发布的 Claude 模型', () => { + expect(getModelsByPlatform('claude')).toContain('claude-fable-5') + expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5') expect(getModelsByPlatform('claude')).toContain('claude-opus-4-8') expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8') }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index dc192180f0..c35974e24f 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -30,7 +30,8 @@ export const claudeModels = [ 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', - 'claude-sonnet-4-6' + 'claude-sonnet-4-6', + 'claude-fable-5' ] // Google Gemini @@ -51,6 +52,7 @@ const geminiModels = [ // 基于官方 API 返回的模型列表,只支持 Claude 4.5+ 和 Gemini 2.5+ const antigravityModels = [ // Claude 4.5+ 系列 + 'claude-fable-5', 'claude-opus-4-6', 'claude-opus-4-6-thinking', 'claude-opus-4-7', @@ -234,6 +236,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m })) // ===================== const anthropicPresetMappings = [ + { label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, { label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, { label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, @@ -274,6 +277,7 @@ const geminiPresetMappings = [ const antigravityPresetMappings = [ // Claude 通配符映射 { label: 'Claude→Sonnet', from: 'claude-*', to: 'claude-sonnet-4-5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, + { label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, { label: 'Sonnet→Sonnet', from: 'claude-sonnet-*', to: 'claude-sonnet-4-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'Opus→Opus', from: 'claude-opus-*', to: 'claude-opus-4-6-thinking', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Haiku→Sonnet', from: 'claude-haiku-*', to: 'claude-sonnet-4-5', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, @@ -306,6 +310,7 @@ const antigravityPresetMappings = [ // Bedrock 预设映射(与后端 DefaultBedrockModelMapping 保持一致) const bedrockPresetMappings = [ + { label: 'Fable 5', from: 'claude-fable-5', to: 'anthropic.claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' }, { label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, { label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }, { label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },