Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions backend/internal/handler/admin/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/handler/admin/account_test_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
8 changes: 6 additions & 2 deletions backend/internal/pkg/claude/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
46 changes: 28 additions & 18 deletions backend/internal/service/account_test_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions backend/internal/service/account_test_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading