diff --git a/internal/control/group_create_test.go b/internal/control/group_create_test.go index 3425853d..e1fd3ac9 100644 --- a/internal/control/group_create_test.go +++ b/internal/control/group_create_test.go @@ -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 diff --git a/internal/control/group_models.go b/internal/control/group_models.go index 965a4560..8737e3d1 100644 --- a/internal/control/group_models.go +++ b/internal/control/group_models.go @@ -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, diff --git a/internal/control/group_models_test.go b/internal/control/group_models_test.go index 0fe6a875..500ad406 100644 --- a/internal/control/group_models_test.go +++ b/internal/control/group_models_test.go @@ -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) }) diff --git a/internal/control/group_write.go b/internal/control/group_write.go index 1e788496..cd355a30 100644 --- a/internal/control/group_write.go +++ b/internal/control/group_write.go @@ -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 } diff --git a/internal/control/http_routes.go b/internal/control/http_routes.go index a44dc5e3..de33c666 100644 --- a/internal/control/http_routes.go +++ b/internal/control/http_routes.go @@ -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, diff --git a/internal/control/server.go b/internal/control/server.go index 034ecf9d..4327b8e3 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -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: diff --git a/internal/control/server_test.go b/internal/control/server_test.go index 64f62560..5da1ee6d 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -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) diff --git a/internal/platform/errors/errors.go b/internal/platform/errors/errors.go index 718ed285..f22815ef 100644 --- a/internal/platform/errors/errors.go +++ b/internal/platform/errors/errors.go @@ -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"} diff --git a/internal/platform/i18n/locales/en-US.go b/internal/platform/i18n/locales/en-US.go index 5e151814..cd1b9240 100644 --- a/internal/platform/i18n/locales/en-US.go +++ b/internal/platform/i18n/locales/en-US.go @@ -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", diff --git a/internal/platform/i18n/locales/ja-JP.go b/internal/platform/i18n/locales/ja-JP.go index b455ea16..ac5d215c 100644 --- a/internal/platform/i18n/locales/ja-JP.go +++ b/internal/platform/i18n/locales/ja-JP.go @@ -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": "キーが存在しません", diff --git a/internal/platform/i18n/locales/zh-CN.go b/internal/platform/i18n/locales/zh-CN.go index db727614..641c9d02 100644 --- a/internal/platform/i18n/locales/zh-CN.go +++ b/internal/platform/i18n/locales/zh-CN.go @@ -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": "密钥不存在",