diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ce664e76c22a..de1b4c4a4c0e 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2911,7 +2911,7 @@ func (h *AccountHandler) accountTestCatalog(ctx context.Context, account *servic } var models []geminicli.Model - for requestedModel := range mapping { + for _, requestedModel := range sortedModelMappingKeys(mapping) { var found bool for _, dm := range geminicli.DefaultModels { if dm.ID == requestedModel { @@ -2963,14 +2963,8 @@ func (h *AccountHandler) accountTestCatalog(ctx context.Context, account *servic defaultByID[model.ID] = model } - requestedModels := make([]string, 0, len(mapping)) - for requestedModel := range mapping { - requestedModels = append(requestedModels, requestedModel) - } - sort.Strings(requestedModels) - var models []xai.Model - for _, requestedModel := range requestedModels { + for _, requestedModel := range sortedModelMappingKeys(mapping) { if defaultModel, found := defaultByID[requestedModel]; found { models = append(models, defaultModel) continue @@ -3000,7 +2994,7 @@ func (h *AccountHandler) accountTestCatalog(ctx context.Context, account *servic // Return mapped models (keys of the mapping are the available model IDs) var models []claude.Model - for requestedModel := range mapping { + for _, requestedModel := range sortedModelMappingKeys(mapping) { // Try to find display info from default models var found bool for _, dm := range claude.DefaultModels { @@ -3024,6 +3018,18 @@ func (h *AccountHandler) accountTestCatalog(ctx context.Context, account *servic return models, nil } +// sortedModelMappingKeys lists a mapping's requested model IDs in a stable +// order, so test model lists and their automatic default do not depend on map +// iteration. +func sortedModelMappingKeys(mapping map[string]string) []string { + keys := make([]string, 0, len(mapping)) + for key := range mapping { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + // openAIAccountTestModels selects request-side model IDs from this account's // saved configuration. Discovery enumerates unrestricted accounts and wildcard // candidates; it is not an availability gate for explicitly configured models. diff --git a/backend/internal/handler/admin/account_test_plan_test.go b/backend/internal/handler/admin/account_test_plan_test.go index ddacc7502c27..7669fea219d4 100644 --- a/backend/internal/handler/admin/account_test_plan_test.go +++ b/backend/internal/handler/admin/account_test_plan_test.go @@ -181,3 +181,38 @@ func TestAccountTestModelsCarryReasoningOnEveryPlatform(t *testing.T) { require.Equal(t, []openai.Model{{ID: "friendly", Object: "model", Type: "model", DisplayName: "friendly", ReasoningEfforts: levels, DefaultReasoningEffort: defaultLevel}}, openAIRows) } + +func TestAccountTestPlanAnthropicDefaultFollowsTestModelPreference(t *testing.T) { + gin.SetMode(gin.TestMode) + plan := func(mapping map[string]any) accountTestPlanView { + account := &service.Account{ID: 42, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": mapping}} + h := &AccountHandler{adminService: testPlanAdmin{account: account}} + router := gin.New() + router.GET("/accounts/:id/models", h.GetAvailableModels) + out := httptest.NewRecorder() + router.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/accounts/42/models?view=account-test-plan-v1", nil)) + require.Equal(t, http.StatusOK, out.Code, out.Body.String()) + var body struct { + Data accountTestPlanView `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Body.Bytes(), &body)) + return body.Data + } + + // Mapping keys are listed in a stable order, so the fallback default does + // not depend on map iteration. + withoutSonnet55 := plan(map[string]any{ + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5-20250929", "claude-opus-5-5": "claude-opus-5-5", + "claude-fable-5": "claude-fable-5", "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001", + }) + require.Equal(t, []string{"claude-fable-5", "claude-haiku-4-5-20251001", "claude-opus-5-5", "claude-sonnet-4-5-20250929"}, + withoutSonnet55.ModeViews["default"].ModelIDs) + require.Equal(t, "claude-opus-5-5", withoutSonnet55.ModeViews["default"].DefaultModelID) + + withSonnet55 := plan(map[string]any{"claude-opus-5-5": "claude-opus-5-5", "claude-sonnet-5-5": "claude-sonnet-5-5"}) + require.Equal(t, "claude-sonnet-5-5", withSonnet55.ModeViews["default"].DefaultModelID) + + neither := plan(map[string]any{"claude-sonnet-4-6": "claude-sonnet-4-6", "claude-fable-5": "claude-fable-5"}) + require.Equal(t, "claude-fable-5", neither.ModeViews["default"].DefaultModelID) +} diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 6c2aeebba3a2..02e0533e09a8 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -231,8 +231,12 @@ func DefaultModelIDs() []string { return ids } -// DefaultTestModel 测试时使用的默认模型 -const DefaultTestModel = "claude-sonnet-4-5-20250929" +// DefaultTestModel 测试时使用的默认模型:Anthropic 为已弃用的 +// claude-sonnet-4-5-20250929(2026-09-30 弃用,2026-11-30 下线)指定的替代。 +const DefaultTestModel = "claude-sonnet-5-5" + +// DefaultTestModelFallback 是账号不提供 DefaultTestModel 时的默认测试模型。 +const DefaultTestModelFallback = "claude-opus-5-5" // ModelIDOverrides Claude OAuth 请求需要的模型 ID 映射 var ModelIDOverrides = map[string]string{ diff --git a/backend/internal/service/account_test_model.go b/backend/internal/service/account_test_model.go index d30f67604162..03f5fa6c9a64 100644 --- a/backend/internal/service/account_test_model.go +++ b/backend/internal/service/account_test_model.go @@ -11,25 +11,31 @@ import ( ) // PickConnectionTestModel chooses the model of a connection test that does not -// name one: the platform's upstream default test model when the account serves -// it, otherwise the first text-chat model of the account's own model list +// name one: the first of the platform's default test models the account serves, +// otherwise the first text-chat model of the account's own model list // (candidates, in display order) or, without a list, of its model mapping. // "" leaves the choice to the platform tester's own default. func PickConnectionTestModel(account *Account, candidates ...string) string { if account == nil { return "" } - preferred := connectionTestDefaultModel(account) + preferred := connectionTestDefaultModels(account) if len(candidates) == 0 { - if account.IsModelSupported(preferred) { - return preferred + for _, model := range preferred { + if account.IsModelSupported(model) { + return model + } } for model := range account.GetModelMapping() { candidates = append(candidates, model) } sort.Strings(candidates) - } else if slices.Contains(candidates, preferred) { - return preferred + } else { + for _, model := range preferred { + if slices.Contains(candidates, model) { + return model + } + } } for _, model := range candidates { if AccountTestSupportsTextConversation(account, model) { @@ -39,30 +45,34 @@ func PickConnectionTestModel(account *Account, candidates ...string) string { return "" } -// connectionTestDefaultModel mirrors the empty-model default of the platform -// tester that TestAccountConnection dispatches to. -func connectionTestDefaultModel(account *Account) string { +// connectionTestDefaultModels lists, most preferred first, the empty-model +// defaults of the platform tester that TestAccountConnection dispatches to. +func connectionTestDefaultModels(account *Account) []string { switch { case account.IsOpenCodeGo(): - return DefaultOpenCodeGoTestModel + return []string{DefaultOpenCodeGoTestModel} case account.IsCNProvider(): if account.GetAPIProtocol() == APIProtocolAnthropic { - return claude.DefaultTestModel + return claudeConnectionTestModels } - return openai.DefaultTestModel + return []string{openai.DefaultTestModel} case account.IsOpenAI(): - return openai.DefaultTestModel + return []string{openai.DefaultTestModel} case account.IsGemini(): - return geminicli.DefaultTestModel + return []string{geminicli.DefaultTestModel} case account.Platform == PlatformGrok: - return grokDefaultResponsesModel + return []string{grokDefaultResponsesModel} case account.Platform == PlatformAntigravity: - return defaultAntigravityTestModel + return []string{defaultAntigravityTestModel} default: - return claude.DefaultTestModel + return claudeConnectionTestModels } } +// claudeConnectionTestModels prefers Anthropic's replacement for the deprecated +// Sonnet 4.5 test model, then the current Opus. +var claudeConnectionTestModels = []string{claude.DefaultTestModel, claude.DefaultTestModelFallback} + // AccountTestSupportsTextConversation reports whether a model of the account // can answer a text connection test: image, audio, embedding, realtime, speech // and review-only models are excluded, before and after the account mapping. diff --git a/backend/internal/service/account_test_models_test.go b/backend/internal/service/account_test_models_test.go index 23b4bae2ff77..2171e9eecf52 100644 --- a/backend/internal/service/account_test_models_test.go +++ b/backend/internal/service/account_test_models_test.go @@ -295,3 +295,14 @@ func TestFetchOpenAIAccountModelsMappingChangesReuseRawCache(t *testing.T) { require.Equal(t, original, after.Body, "projection must not mutate the shared catalog") require.EqualValues(t, 1, calls.Load(), "both mappings must reuse the same discovery cache") } + +func TestPickConnectionTestModelPrefersClaudeTestModelsInOrder(t *testing.T) { + account := &Account{ID: 7, Platform: PlatformAnthropic, Type: AccountTypeAPIKey} + require.Equal(t, "claude-sonnet-5-5", PickConnectionTestModel(account, "claude-opus-5-5", "claude-sonnet-5-5")) + require.Equal(t, "claude-opus-5-5", PickConnectionTestModel(account, "claude-fable-5", "claude-opus-5-5")) + require.Equal(t, "claude-fable-5", PickConnectionTestModel(account, "claude-fable-5", "claude-sonnet-4-6")) + + // Without a displayed list the account's own mapping decides. + account.Credentials = map[string]any{"model_mapping": map[string]any{"claude-opus-5-5": "claude-opus-5-5", "claude-fable-5": "claude-fable-5"}} + require.Equal(t, "claude-opus-5-5", PickConnectionTestModel(account)) +}