feat(groups): 增加模型详情与别名校验

This commit is contained in:
tbphp committed 2026-08-01 10:54:44 +08:00
1 parent 92ad16068a
commit 2992a7b8f2
11 files changed
+334 -51

No files matched your search

+3 -3
View File
@@ -41,7 +41,6 @@ func TestCreateGroupNormalizesAndPublishesOnce(t *testing.T) {
Set: true,
Values: []GroupModel{
{ID: " gpt-4o ", Alias: " primary "},
{ID: "gpt-4o", Alias: "primary"},
{ID: "claude-3-5", Alias: ""},
},
},
@@ -124,8 +123,9 @@ func TestCreateGroupRejectsDuplicateExternalModelWithoutMutation(t *testing.T) {
},
Keys: "sk-duplicate-external-model",
})
if !errors.Is(err, app_errors.ErrValidation) {
t.Fatalf("CreateGroup() error = %v, want ErrValidation", err)
var apiErr *app_errors.APIError
if !errors.As(err, &apiErr) || apiErr.Code != app_errors.ErrModelNameConflict.Code {
t.Fatalf("CreateGroup() error = %#v, want MODEL_NAME_CONFLICT", err)
}
assertGroupCount(t, fixture.db, 0)
var keyCount int64
+63
View File
@@ -15,6 +15,69 @@ type GroupModelsUpdateRequest struct {
Models optionalGroupModels `json:"models"`
}
type GroupModelResponse struct {
ID string `json:"id"`
Alias string `json:"alias"`
AliasEnabled bool `json:"alias_enabled"`
ClientModel string `json:"client_model"`
PricingStatus string `json:"pricing_status"`
}
type GroupModelsResponse struct {
Items []GroupModelResponse `json:"items"`
Total int `json:"total"`
Unpriced int `json:"unpriced"`
}
type ModelNameConflict struct {
ClientModel string `json:"client_model"`
Indexes []int `json:"indexes"`
}
type ModelNameConflictData struct {
Conflicts []ModelNameConflict `json:"conflicts"`
}
func (s *Service) GetGroupModels(ctx context.Context, groupID uint) (GroupModelsResponse, error) {
if groupID == 0 {
return GroupModelsResponse{}, app_errors.ErrBadRequest
}
s.writeMu.RLock()
defer s.writeMu.RUnlock()
detail, _, err := loadGroupDetail(s.db.WithContext(ctx), groupID)
if err != nil {
return GroupModelsResponse{}, err
}
table := s.priceRuntime.Load()
if table == nil {
return GroupModelsResponse{}, fmt.Errorf("pricing runtime unavailable: %w", app_errors.ErrInternalServer)
}
result := GroupModelsResponse{Items: make([]GroupModelResponse, 0, len(detail.Models))}
for _, model := range detail.Models {
item := GroupModelResponse{
ID: model.ID,
Alias: model.Alias,
AliasEnabled: model.Alias != "",
ClientModel: model.ID,
PricingStatus: "unpriced",
}
if item.AliasEnabled {
item.ClientModel = model.Alias
}
if _, priced := table.Match(model.ID); priced {
item.PricingStatus = "priced"
} else {
result.Unpriced++
}
result.Items = append(result.Items, item)
}
result.Total = len(result.Items)
return result, nil
}
func (s *Service) UpdateGroupModels(
ctx context.Context,
groupID uint,
+135 -17
View File
@@ -15,27 +15,145 @@ import (
"gpt-load/internal/storage/models"
)
func TestGetGroupModelsReturnsClientNamesAndPricingStatus(t *testing.T) {
fixture := newServiceFixture(t)
mustEnsureInitialPrices(t, fixture)
created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{
UpstreamURL: "https://model-read.example.com/v1",
Protocols: []protocol.Protocol{protocol.OpenAICompletions},
Models: optionalGroupModels{Set: true, Values: []GroupModel{
{ID: "gpt-4o", Alias: "default", AliasEnabled: true},
{ID: "missing-price", Alias: ""},
}},
Keys: "sk-model-read",
})
if err != nil {
t.Fatal(err)
}
got, err := fixture.service.GetGroupModels(t.Context(), created.GroupID)
if err != nil {
t.Fatalf("GetGroupModels() error = %v", err)
}
want := GroupModelsResponse{
Items: []GroupModelResponse{
{ID: "gpt-4o", Alias: "default", AliasEnabled: true, ClientModel: "default", PricingStatus: "priced"},
{ID: "missing-price", Alias: "", AliasEnabled: false, ClientModel: "missing-price", PricingStatus: "unpriced"},
},
Total: 2,
Unpriced: 1,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("GetGroupModels() = %#v, want %#v", got, want)
}
}
func TestNormalizeGroupModelsAppliesAliasSwitchAndReportsStableConflicts(t *testing.T) {
tests := []struct {
name string
values []GroupModel
want []GroupModel
wantConflicts []ModelNameConflict
wantError error
}{
{
name: "disabled alias uses upstream ID and conflicts with enabled alias",
values: []GroupModel{
{ID: "a", Alias: ""},
{ID: "b", Alias: "a", AliasEnabled: true},
},
wantConflicts: []ModelNameConflict{{ClientModel: "a", Indexes: []int{0, 1}}},
wantError: app_errors.ErrModelNameConflict,
},
{
name: "enabled aliases conflict",
values: []GroupModel{
{ID: "a", Alias: "x", AliasEnabled: true},
{ID: "b", Alias: "x", AliasEnabled: true},
},
wantConflicts: []ModelNameConflict{{ClientModel: "x", Indexes: []int{0, 1}}},
wantError: app_errors.ErrModelNameConflict,
},
{
name: "model names remain case sensitive",
values: []GroupModel{
{ID: "a", Alias: "X", AliasEnabled: true},
{ID: "b", Alias: "x", AliasEnabled: true},
},
want: []GroupModel{
{ID: "a", Alias: "X"},
{ID: "b", Alias: "x"},
},
},
{
name: "trimmed IDs and aliases conflict",
values: []GroupModel{
{ID: " a ", Alias: ""},
{ID: "b", Alias: " a ", AliasEnabled: true},
},
wantConflicts: []ModelNameConflict{{ClientModel: "a", Indexes: []int{0, 1}}},
wantError: app_errors.ErrModelNameConflict,
},
{
name: "enabled alias cannot be blank after trimming",
values: []GroupModel{{ID: "a", Alias: " ", AliasEnabled: true}},
wantError: app_errors.ErrValidation,
},
{
name: "multiple conflicts use first occurrence order",
values: []GroupModel{
{ID: "a"},
{ID: "b", Alias: "a", AliasEnabled: true},
{ID: "c"},
{ID: "d", Alias: "c", AliasEnabled: true},
},
wantConflicts: []ModelNameConflict{
{ClientModel: "a", Indexes: []int{0, 1}},
{ClientModel: "c", Indexes: []int{2, 3}},
},
wantError: app_errors.ErrModelNameConflict,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := normalizeGroupModels(test.values)
if test.wantError == nil {
if err != nil {
t.Fatalf("normalizeGroupModels() error = %v", err)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("normalizeGroupModels() = %#v, want %#v", got, test.want)
}
return
}
var apiErr *app_errors.APIError
if !errors.As(err, &apiErr) || apiErr.Code != test.wantError.(*app_errors.APIError).Code {
t.Fatalf("normalizeGroupModels() error = %#v, want %q", err, test.wantError)
}
if test.wantConflicts == nil {
return
}
data, ok := apiErr.Data.(ModelNameConflictData)
if !ok || !reflect.DeepEqual(data.Conflicts, test.wantConflicts) {
t.Fatalf("conflict data = %#v, want %#v", apiErr.Data, test.wantConflicts)
}
})
}
}
func TestNormalizeGroupModelsRejectsDuplicateExternalNames(t *testing.T) {
for _, values := range [][]GroupModel{
{{ID: "provider-a", Alias: "public"}, {ID: "provider-b", Alias: "public"}},
{{ID: "public"}, {ID: "provider-b", Alias: "public"}},
} {
if _, err := normalizeGroupModels(values); !errors.Is(err, app_errors.ErrValidation) {
t.Fatalf("normalizeGroupModels(%#v) error = %v", values, err)
var apiErr *app_errors.APIError
if _, err := normalizeGroupModels(values); !errors.As(err, &apiErr) ||
apiErr.Code != app_errors.ErrModelNameConflict.Code {
t.Fatalf("normalizeGroupModels(%#v) error = %#v", values, err)
}
}
got, err := normalizeGroupModels([]GroupModel{
{ID: "provider", Alias: "a"},
{ID: "provider", Alias: "b"},
{ID: " provider ", Alias: " a "},
})
if err != nil {
t.Fatal(err)
}
want := []GroupModel{{ID: "provider", Alias: "a"}, {ID: "provider", Alias: "b"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalized = %#v, want %#v", got, want)
}
}
func TestUpdateGroupModelsRequiresNonNullModelsField(t *testing.T) {
@@ -104,7 +222,6 @@ func TestUpdateGroupModelsReplacesAuthoritativeListAndPublishesOnce(t *testing.T
Values: []GroupModel{
{ID: "provider-b", Alias: "public-b"},
{ID: "provider-a", Alias: "public-a"},
{ID: " provider-a ", Alias: " public-a "},
},
},
})
@@ -280,8 +397,9 @@ func TestUpdateGroupModelsFailuresDoNotPublish(t *testing.T) {
},
},
})
if !errors.Is(err, app_errors.ErrValidation) {
t.Fatalf("UpdateGroupModels() error = %v, want ErrValidation", err)
var apiErr *app_errors.APIError
if !errors.As(err, &apiErr) || apiErr.Code != app_errors.ErrModelNameConflict.Code {
t.Fatalf("UpdateGroupModels() error = %#v, want MODEL_NAME_CONFLICT", err)
}
assertModelsUpdateStateUnchanged(t, fixture, groupID, beforeRevision, beforeRegistry, beforeModels)
})
+69 -24
View File
@@ -21,8 +21,36 @@ import (
const maxUpstreamKeyLines = 1000
type GroupModel struct {
ID string `json:"id"`
Alias string `json:"alias"`
ID string `json:"id"`
Alias string `json:"alias"`
AliasEnabled bool `json:"-"`
aliasEnabledSet bool
}
func (model *GroupModel) UnmarshalJSON(data []byte) error {
var wire struct {
ID string `json:"id"`
Alias string `json:"alias"`
AliasEnabled *bool `json:"alias_enabled"`
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&wire); err != nil {
return fmt.Errorf("decode group model: %w", err)
}
var trailing json.RawMessage
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return fmt.Errorf("decode group model: trailing JSON value")
}
return fmt.Errorf("decode group model trailing value: %w", err)
}
model.ID = wire.ID
model.Alias = wire.Alias
model.AliasEnabled = wire.AliasEnabled != nil && *wire.AliasEnabled
model.aliasEnabledSet = wire.AliasEnabled != nil
return nil
}
type upstreamKeyCandidate struct {
@@ -152,36 +180,53 @@ func normalizeGroupProtocols(values []protocol.Protocol) ([]protocol.Protocol, e
}
func normalizeGroupModels(values []GroupModel) ([]GroupModel, error) {
type pair struct {
id string
alias string
}
result := make([]GroupModel, 0, len(values))
seenPairs := make(map[pair]struct{}, len(values))
seenExternal := make(map[string]struct{}, len(values))
for _, value := range values {
indexesByClientModel := make(map[string][]int, len(values))
clientModelOrder := make([]string, 0, len(values))
for index, value := range values {
normalized := GroupModel{
ID: strings.TrimSpace(value.ID),
Alias: strings.TrimSpace(value.Alias),
ID: strings.TrimSpace(value.ID),
}
if normalized.ID == "" {
return nil, app_errors.ErrValidation
}
identity := pair{id: normalized.ID, alias: normalized.Alias}
if _, duplicate := seenPairs[identity]; duplicate {
alias := strings.TrimSpace(value.Alias)
aliasEnabled := value.AliasEnabled
if !value.aliasEnabledSet && alias != "" {
aliasEnabled = true
}
if aliasEnabled {
if alias == "" {
return nil, app_errors.ErrValidation
}
normalized.Alias = alias
}
clientModel := normalized.ID
if normalized.Alias != "" {
clientModel = normalized.Alias
}
if _, exists := indexesByClientModel[clientModel]; !exists {
clientModelOrder = append(clientModelOrder, clientModel)
}
indexesByClientModel[clientModel] = append(indexesByClientModel[clientModel], index)
result = append(result, normalized)
}
conflicts := make([]ModelNameConflict, 0)
for _, clientModel := range clientModelOrder {
indexes := indexesByClientModel[clientModel]
if len(indexes) < 2 {
continue
}
external := normalized.Alias
if external == "" {
external = normalized.ID
}
if _, duplicate := seenExternal[external]; duplicate {
return nil, app_errors.ErrValidation
}
seenPairs[identity] = struct{}{}
seenExternal[external] = struct{}{}
result = append(result, normalized)
conflicts = append(conflicts, ModelNameConflict{
ClientModel: clientModel,
Indexes: append([]int(nil), indexes...),
})
}
if len(conflicts) > 0 {
return nil, app_errors.NewAPIErrorWithData(
app_errors.ErrModelNameConflict,
ModelNameConflictData{Conflicts: conflicts},
)
}
return result, nil
}
+6
View File
@@ -136,6 +136,12 @@ func (s *Server) HTTPModule() httproute.Module {
)),
s.handleUpdateGroupSettings,
),
controlRoute(
"control.groups.models.get",
http.MethodGet,
"/groups/:group_id/models",
s.handleGetGroupModels,
),
controlRoute(
"control.groups.models.update",
http.MethodPut,
+16 -1
View File
@@ -181,6 +181,19 @@ func (s *Server) handleUpdateGroupModels(c *gin.Context) {
response.SuccessI18n(c, "common.success", result)
}
func (s *Server) handleGetGroupModels(c *gin.Context) {
id, ok := groupID(c, "get_group_models")
if !ok {
return
}
result, err := s.service.GetGroupModels(c.Request.Context(), id)
if err != nil {
writeServiceError(c, "get_group_models", err)
return
}
response.SuccessI18n(c, "common.success", result)
}
func (s *Server) handleDeleteGroup(c *gin.Context) {
id, ok := groupID(c, "delete_group")
if !ok {
@@ -599,7 +612,7 @@ func serviceErrorMessageID(
return "key.not_found"
}
switch operation {
case "list_groups", "get_group", "get_group_settings", "update_group",
case "list_groups", "get_group", "get_group_settings", "get_group_models", "update_group",
"update_group_settings", "delete_group",
"update_group_models", "import_group_keys",
"discover_group_models", "list_group_keys":
@@ -622,6 +635,8 @@ func serviceErrorMessageID(
return "group.in_use"
case app_errors.ErrUpstreamURLConflict.Code:
return "group.upstream_url_conflict"
case app_errors.ErrModelNameConflict.Code:
return "group.model_name_conflict"
case app_errors.ErrBadGateway.Code:
return "bad_gateway"
default:
+38 -6
View File
@@ -92,6 +92,7 @@ func TestGroupCollectionHTTPRoutesDeclareStaticOptionsBeforeDynamicDetail(t *tes
{name: "control.groups.options", path: "/groups/options"},
{name: "control.groups.get", path: "/groups/:group_id"},
{name: "control.groups.settings.get", path: "/groups/:group_id/settings"},
{name: "control.groups.models.get", path: "/groups/:group_id/models"},
{name: "control.group-keys.list", path: "/groups/:group_id/keys"},
}
if !reflect.DeepEqual(got, want) {
@@ -1310,12 +1311,6 @@ func TestUpdateGroupModelsEndpointRejectsStrictInvalidBodiesWithoutMutation(t *t
{name: "unknown top-level field", body: `{"models":[],"unknown":true}`, code: app_errors.ErrInvalidJSON.Code},
{name: "unknown nested field", body: `{"models":[{"id":"provider","unknown":true}]}`, code: app_errors.ErrInvalidJSON.Code},
{name: "blank upstream model ID", body: `{"models":[{"id":" "}]}`, code: app_errors.ErrValidation.Code},
{
name: "duplicate external model",
body: `{"models":[{"id":"provider-a","alias":"public"},` +
`{"id":"provider-b","alias":"public"}]}`,
code: app_errors.ErrValidation.Code,
},
{name: "multiple JSON values", body: `{"models":[]} {"models":[]}`, code: app_errors.ErrInvalidJSON.Code},
{name: "top-level null", body: `null`, code: app_errors.ErrInvalidJSON.Code},
} {
@@ -1349,6 +1344,43 @@ func TestUpdateGroupModelsEndpointRejectsStrictInvalidBodiesWithoutMutation(t *t
}
}
func TestGroupModelsHTTPReturnsStructuredConflictWithoutMutation(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
groupID := createGroupForKeyImport(t, fixture, "sk-model-conflict-http")
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
beforeRevision := fixture.manager.Current().Revision
beforeModels := loadCreatedGroupModels(t, fixture, groupID)
recorder := serveRawGroupModelsUpdateRequest(
t,
engine,
"test-auth-key",
"en-US",
strconv.FormatUint(uint64(groupID), 10),
`{"models":[{"id":"a","alias":"discarded","alias_enabled":false},{"id":"b","alias":"a","alias_enabled":true}]}`,
)
var envelope struct {
Code string `json:"code"`
Data ModelNameConflictData `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
wantConflicts := []ModelNameConflict{{ClientModel: "a", Indexes: []int{0, 1}}}
if recorder.Code != http.StatusConflict || envelope.Code != app_errors.ErrModelNameConflict.Code ||
!reflect.DeepEqual(envelope.Data.Conflicts, wantConflicts) {
t.Fatalf("response = %d %#v, want 409 %#v", recorder.Code, envelope, wantConflicts)
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatal("model conflict published a Snapshot")
}
if got := loadCreatedGroupModels(t, fixture, groupID); !reflect.DeepEqual(got, beforeModels) {
t.Fatalf("model conflict changed persistence: got=%#v want=%#v", got, beforeModels)
}
}
func TestUpdateGroupModelsEndpointIDsAuthNotFoundAndSuccessDTO(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
+1
View File
@@ -35,6 +35,7 @@ var (
ErrUnauthorized = &APIError{HTTPStatus: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "Authentication failed"}
ErrAuthLocked = &APIError{HTTPStatus: http.StatusTooManyRequests, Code: "AUTH_LOCKED", Message: "Authentication is temporarily locked"}
ErrUpstreamURLConflict = &APIError{HTTPStatus: http.StatusConflict, Code: "UPSTREAM_URL_CONFLICT", Message: "Upstream URL conflicts with an existing group"}
ErrModelNameConflict = &APIError{HTTPStatus: http.StatusConflict, Code: "MODEL_NAME_CONFLICT", Message: "Client model names conflict within the group"}
ErrUpstreamURLChangeConfirmationRequired = &APIError{HTTPStatus: http.StatusConflict, Code: "UPSTREAM_URL_CHANGE_CONFIRMATION_REQUIRED", Message: "Upstream URL change requires explicit confirmation"}
ErrNoActiveUpstreamKey = &APIError{HTTPStatus: http.StatusConflict, Code: "NO_ACTIVE_UPSTREAM_KEY", Message: "No active upstream key available for this group"}
ErrBadGateway = &APIError{HTTPStatus: http.StatusBadGateway, Code: "BAD_GATEWAY", Message: "Upstream service error"}
+1
View File
@@ -23,6 +23,7 @@ var MessagesEnUS = map[string]string{
"group.name_exists": "Group name already exists",
"group.in_use": "The group is still referenced by access keys",
"group.upstream_url_conflict": "An existing group already uses this upstream URL",
"group.model_name_conflict": "Client model names must be unique within the group",
"group.upstream_url_change_confirmation_required": "Changing the upstream URL requires explicit confirmation",
"group.no_active_upstream_key": "No active upstream key is available for this group",
"key.not_found": "Key not found",
+1
View File
@@ -23,6 +23,7 @@ var MessagesJaJP = map[string]string{
"group.name_exists": "グループ名が既に存在します",
"group.in_use": "グループはアクセスキーから参照されています",
"group.upstream_url_conflict": "このアップストリームURLは既存のグループで使用されています",
"group.model_name_conflict": "グループ内のクライアントモデル名は一意である必要があります",
"group.upstream_url_change_confirmation_required": "アップストリームURLの変更には明示的な確認が必要です",
"group.no_active_upstream_key": "このグループには利用可能な有効なアップストリームキーがありません",
"key.not_found": "キーが存在しません",
+1
View File
@@ -23,6 +23,7 @@ var MessagesZhCN = map[string]string{
"group.name_exists": "分组名称已存在",
"group.in_use": "分组仍被访问密钥引用",
"group.upstream_url_conflict": "已有分组使用该上游地址",
"group.model_name_conflict": "分组内客户端模型名称必须唯一",
"group.upstream_url_change_confirmation_required": "修改上游地址需要明确确认",
"group.no_active_upstream_key": "该分组没有可用的活跃上游密钥",
"key.not_found": "密钥不存在",