diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d5f0ade6..96550f52 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -2672,6 +2672,76 @@ const docTemplate = `{ } } }, + "/admin/llm/models/{id}/protocols": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "在单个数据库事务中更新平台模型能力类型,并将该模型全部上游绑定替换为指定的完整协议集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员替换模型全部来源的协议集合", + "parameters": [ + { + "type": "integer", + "description": "模型ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "完整协议集合与模型能力类型", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetModelProtocolsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SetModelProtocolsResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, "/admin/llm/models/{id}/sources": { "get": { "security": [ @@ -21348,6 +21418,44 @@ const docTemplate = `{ } } }, + "SetModelProtocolsRequest": { + "type": "object", + "required": [ + "kindsJSON", + "protocols" + ], + "properties": { + "kindsJSON": { + "type": "string", + "maxLength": 1000, + "minLength": 2 + }, + "protocols": { + "type": "array", + "maxItems": 2, + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "SetModelProtocolsResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ModelDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "SetModelsDisplayGroupRequest": { "type": "object", "required": [ @@ -22825,17 +22933,21 @@ const docTemplate = `{ "type": "object", "required": [ "platformModelName", + "protocols", "upstreamModelName" ], "properties": { "cbDurationMin": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "cbFailureThreshold": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "cbWindowMin": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "headersJSON": { "type": "string", @@ -22853,18 +22965,29 @@ const docTemplate = `{ "priority": { "type": "integer" }, - "protocol": { - "type": "string", - "maxLength": 64 + "protocols": { + "description": "Protocols 为空数组时根据模型能力和上游默认配置自动推断完整协议集合。", + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "string" + } }, - "routeID": { - "type": "integer" + "routeIDs": { + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "integer" + } }, "source": { "type": "string", "maxLength": 64 }, "status": { + "description": "路由配置字段省略时保留已有协议各自的配置;新增协议使用服务端默认值或现有绑定模板。", "type": "string", "enum": [ "active", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 15ae070a..6a107e11 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -2665,6 +2665,76 @@ } } }, + "/admin/llm/models/{id}/protocols": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "在单个数据库事务中更新平台模型能力类型,并将该模型全部上游绑定替换为指定的完整协议集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "llm" + ], + "summary": "管理员替换模型全部来源的协议集合", + "parameters": [ + { + "type": "integer", + "description": "模型ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "完整协议集合与模型能力类型", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetModelProtocolsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SetModelProtocolsResponseDoc" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ChannelErrorDoc" + } + } + } + } + }, "/admin/llm/models/{id}/sources": { "get": { "security": [ @@ -21341,6 +21411,44 @@ } } }, + "SetModelProtocolsRequest": { + "type": "object", + "required": [ + "kindsJSON", + "protocols" + ], + "properties": { + "kindsJSON": { + "type": "string", + "maxLength": 1000, + "minLength": 2 + }, + "protocols": { + "type": "array", + "maxItems": 2, + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "SetModelProtocolsResponseDoc": { + "type": "object", + "required": [ + "data", + "errorMsg" + ], + "properties": { + "data": { + "$ref": "#/definitions/ModelDataResponse" + }, + "errorMsg": { + "type": "string" + } + } + }, "SetModelsDisplayGroupRequest": { "type": "object", "required": [ @@ -22818,17 +22926,21 @@ "type": "object", "required": [ "platformModelName", + "protocols", "upstreamModelName" ], "properties": { "cbDurationMin": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "cbFailureThreshold": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "cbWindowMin": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "headersJSON": { "type": "string", @@ -22846,18 +22958,29 @@ "priority": { "type": "integer" }, - "protocol": { - "type": "string", - "maxLength": 64 + "protocols": { + "description": "Protocols 为空数组时根据模型能力和上游默认配置自动推断完整协议集合。", + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "string" + } }, - "routeID": { - "type": "integer" + "routeIDs": { + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { + "type": "integer" + } }, "source": { "type": "string", "maxLength": 64 }, "status": { + "description": "路由配置字段省略时保留已有协议各自的配置;新增协议使用服务端默认值或现有绑定模板。", "type": "string", "enum": [ "active", diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 08878010..55a3139b 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -6442,6 +6442,33 @@ definitions: type: integer type: array type: object + SetModelProtocolsRequest: + properties: + kindsJSON: + maxLength: 1000 + minLength: 2 + type: string + protocols: + items: + type: string + maxItems: 2 + minItems: 1 + type: array + uniqueItems: true + required: + - kindsJSON + - protocols + type: object + SetModelProtocolsResponseDoc: + properties: + data: + $ref: '#/definitions/ModelDataResponse' + errorMsg: + type: string + required: + - data + - errorMsg + type: object SetModelsDisplayGroupRequest: properties: displayGroupID: @@ -7488,10 +7515,13 @@ definitions: UpsertUpstreamModelRequest: properties: cbDurationMin: + minimum: 0 type: integer cbFailureThreshold: + minimum: 0 type: integer cbWindowMin: + minimum: 0 type: integer headersJSON: maxLength: 10000 @@ -7505,15 +7535,24 @@ definitions: type: string priority: type: integer - protocol: - maxLength: 64 - type: string - routeID: - type: integer + protocols: + description: Protocols 为空数组时根据模型能力和上游默认配置自动推断完整协议集合。 + items: + type: string + maxItems: 2 + type: array + uniqueItems: true + routeIDs: + items: + type: integer + maxItems: 2 + type: array + uniqueItems: true source: maxLength: 64 type: string status: + description: 路由配置字段省略时保留已有协议各自的配置;新增协议使用服务端默认值或现有绑定模板。 enum: - active - inactive @@ -7526,6 +7565,7 @@ definitions: type: integer required: - platformModelName + - protocols - upstreamModelName type: object UpsertUpstreamModelResponseDoc: @@ -10276,6 +10316,51 @@ paths: summary: 管理员更新模型 tags: - llm + /admin/llm/models/{id}/protocols: + patch: + consumes: + - application/json + description: 在单个数据库事务中更新平台模型能力类型,并将该模型全部上游绑定替换为指定的完整协议集合 + parameters: + - description: 模型ID + in: path + name: id + required: true + type: integer + - description: 完整协议集合与模型能力类型 + in: body + name: body + required: true + schema: + $ref: '#/definitions/SetModelProtocolsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/SetModelProtocolsResponseDoc' + "400": + description: Bad Request + schema: + $ref: '#/definitions/ChannelErrorDoc' + "404": + description: Not Found + schema: + $ref: '#/definitions/ChannelErrorDoc' + "409": + description: Conflict + schema: + $ref: '#/definitions/ChannelErrorDoc' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/ChannelErrorDoc' + security: + - BearerAuth: [] + summary: 管理员替换模型全部来源的协议集合 + tags: + - llm /admin/llm/models/{id}/sources: get: consumes: diff --git a/backend/internal/application/channel/errs.go b/backend/internal/application/channel/errs.go index 21891c82..64443f21 100644 --- a/backend/internal/application/channel/errs.go +++ b/backend/internal/application/channel/errs.go @@ -67,6 +67,8 @@ var ( ErrUpstreamModelNotFound = repository.ErrUpstreamModelNotFound // ErrUpstreamModelConflict 上游模型路由绑定冲突。 ErrUpstreamModelConflict = repository.ErrUpstreamModelConflict + // ErrUpstreamModelBindingChanged 上游模型绑定已被其他操作修改。 + ErrUpstreamModelBindingChanged = errors.New("upstream model binding changed") // ErrUpstreamSourceUnavailable 上游或上游模型当前不可用。 ErrUpstreamSourceUnavailable = errors.New("upstream source unavailable") // ErrRemoteModelsUnavailable 上游远程模型目录不可用。 diff --git a/backend/internal/application/channel/input.go b/backend/internal/application/channel/input.go index 4ab35f6f..4ebead8e 100644 --- a/backend/internal/application/channel/input.go +++ b/backend/internal/application/channel/input.go @@ -76,6 +76,12 @@ type UpdateModelInput struct { CbWindowMin *int } +// SetModelProtocolsInput 定义平台模型全部来源的目标协议集合。 +type SetModelProtocolsInput struct { + Protocols []string + KindsJSON string +} + // CreateModelVendorInput 定义创建技术厂商入参。 type CreateModelVendorInput struct { Key string @@ -105,19 +111,19 @@ type UpdateModelDisplayGroupInput struct { // UpsertUpstreamModelInput 定义上游真实模型与平台路由保存入参。 type UpsertUpstreamModelInput struct { - RouteID uint + RouteIDs []uint PlatformModelName string UpstreamModelName string - Protocol string + Protocols []string KindsJSON string - Status string - Priority int - Weight int - Source string - CbFailureThreshold int - CbDurationMin int - CbWindowMin int - HeadersJSON string + Status *string + Priority *int + Weight *int + Source *string + CbFailureThreshold *int + CbDurationMin *int + CbWindowMin *int + HeadersJSON *string } // UpdateModelUpstreamSourceInput 定义更新模型来源入参。 diff --git a/backend/internal/application/channel/service_model.go b/backend/internal/application/channel/service_model.go index 7a7f0814..3017f2ad 100644 --- a/backend/internal/application/channel/service_model.go +++ b/backend/internal/application/channel/service_model.go @@ -253,6 +253,10 @@ func filterPricedModelViews(items []ModelView, pricingByPlatformModelName map[st } func (s *Service) normalizeModelAvailability(ctx context.Context, items []ModelView) error { + return s.normalizeModelAvailabilityWithRepo(ctx, s.repo, items) +} + +func (s *Service) normalizeModelAvailabilityWithRepo(ctx context.Context, repo repository.ChannelRepository, items []ModelView) error { for index := range items { if items[index].Status != "active" { items[index].ActiveSourceCount = 0 @@ -261,7 +265,7 @@ func (s *Service) normalizeModelAvailability(ctx context.Context, items []ModelV if s.cache == nil || items[index].SourceCount <= 0 || items[index].ActiveSourceCount <= 0 { continue } - sources, _, err := s.repo.ListModelUpstreamSources(ctx, items[index].PlatformModelName, 0, int(items[index].SourceCount)) + sources, _, err := repo.ListModelUpstreamSources(ctx, items[index].PlatformModelName, 0, int(items[index].SourceCount)) if err != nil { return err } diff --git a/backend/internal/application/channel/service_model_protocols.go b/backend/internal/application/channel/service_model_protocols.go new file mode 100644 index 00000000..a7a01ca6 --- /dev/null +++ b/backend/internal/application/channel/service_model_protocols.go @@ -0,0 +1,164 @@ +package channel + +import ( + "context" + "errors" + "strings" + + domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +// SetModelProtocols 原子替换平台模型全部上游绑定的协议集合。 +func (s *Service) SetModelProtocols(ctx context.Context, modelID uint, input SetModelProtocolsInput) (*ModelView, error) { + if len(input.Protocols) == 0 { + return nil, ErrProtocolRequired + } + if len(input.Protocols) > 2 { + return nil, ErrInvalidRouteProtocolCombination + } + seenProtocols := make(map[string]struct{}, len(input.Protocols)) + for _, raw := range input.Protocols { + protocol := strings.TrimSpace(strings.ToLower(raw)) + if protocol == "" { + return nil, ErrInvalidAdapter + } + if _, exists := seenProtocols[protocol]; exists { + return nil, ErrInvalidRouteProtocolCombination + } + seenProtocols[protocol] = struct{}{} + } + kindsJSON, err := normalizeKindsJSON(input.KindsJSON) + if err != nil { + return nil, err + } + + var view *ModelView + err = s.repo.WithinTransaction(ctx, func(txRepo repository.ChannelRepository) error { + modelItem, txErr := txRepo.GetModelByID(ctx, modelID) + if txErr != nil { + return txErr + } + sources, txErr := txRepo.ListModelUpstreamSourcesForUpdate(ctx, modelItem.PlatformModelName) + if txErr != nil { + return txErr + } + bindings := groupModelSourceBindings(sources) + if len(bindings) == 0 { + return ErrUpstreamModelNotFound + } + + replacements := make([]modelProtocolReplacement, 0, len(bindings)) + for _, binding := range bindings { + protocols, resolveErr := resolveRouteProtocols( + input.Protocols, + binding.template.UpstreamCompatible, + binding.template.UpstreamProtocolDefaultsJSON, + kindsJSON, + ) + if resolveErr != nil { + return resolveErr + } + replacements = append(replacements, modelProtocolReplacement{binding: binding, protocols: protocols}) + } + + if txErr = txRepo.UpdateModel(ctx, modelID, repository.UpdateChannelModelInput{KindsJSON: &kindsJSON}); txErr != nil { + return txErr + } + routeSets := make([]repository.ReplaceChannelPlatformRoutesInput, 0, len(replacements)) + for _, item := range replacements { + desiredRoutes := make([]domainchannel.PlatformModelRoute, 0, len(item.protocols)) + for _, protocol := range item.protocols { + source := item.binding.template + if existing, ok := item.binding.sourcesByProtocol[protocol]; ok { + source = existing + } + desiredRoutes = append(desiredRoutes, modelSourceReplacementRoute(source, protocol)) + } + routeSets = append(routeSets, repository.ReplaceChannelPlatformRoutesInput{ + UpstreamID: item.binding.template.UpstreamID, + ExistingRouteIDs: item.binding.routeIDs, + Routes: desiredRoutes, + }) + } + if _, txErr = txRepo.ReplacePlatformModelRoutes(ctx, routeSets); txErr != nil { + return txErr + } + + row, txErr := txRepo.GetModelListRowByID(ctx, modelID) + if txErr != nil { + return txErr + } + result := toModelView(*row) + views := []ModelView{result} + if txErr = s.normalizeModelAvailabilityWithRepo(ctx, txRepo, views); txErr != nil { + return txErr + } + view = &views[0] + return nil + }) + if err != nil { + switch { + case isDuplicateKeyError(err): + return nil, ErrUpstreamModelConflict + case errors.Is(err, repository.ErrConflict): + return nil, ErrUpstreamModelBindingChanged + default: + return nil, err + } + } + + s.InvalidateModelCatalog() + return view, nil +} + +type modelSourceBinding struct { + template repository.ChannelModelSourceRow + sourcesByProtocol map[string]repository.ChannelModelSourceRow + routeIDs []uint +} + +type modelProtocolReplacement struct { + binding modelSourceBinding + protocols []string +} + +func groupModelSourceBindings(sources []repository.ChannelModelSourceRow) []modelSourceBinding { + type bindingKey struct { + upstreamID uint + upstreamModelID uint + } + bindings := make([]modelSourceBinding, 0) + indexes := make(map[bindingKey]int) + for _, source := range sources { + key := bindingKey{upstreamID: source.UpstreamID, upstreamModelID: source.UpstreamModelID} + index, ok := indexes[key] + if !ok { + index = len(bindings) + indexes[key] = index + bindings = append(bindings, modelSourceBinding{ + template: source, + sourcesByProtocol: make(map[string]repository.ChannelModelSourceRow), + }) + } + bindings[index].sourcesByProtocol[strings.TrimSpace(strings.ToLower(source.Protocol))] = source + bindings[index].routeIDs = append(bindings[index].routeIDs, source.ID) + } + return bindings +} + +func modelSourceReplacementRoute(source repository.ChannelModelSourceRow, protocol string) domainchannel.PlatformModelRoute { + return domainchannel.PlatformModelRoute{ + PlatformModelID: source.PlatformModelID, + UpstreamModelID: source.UpstreamModelID, + Protocol: protocol, + Status: source.Status, + Priority: source.Priority, + Weight: source.Weight, + Source: source.Source, + CbFailureThreshold: source.CbFailureThreshold, + CbDurationMin: source.CbDurationMin, + CbWindowMin: source.CbWindowMin, + HeadersJSON: source.HeadersJSON, + } +} diff --git a/backend/internal/application/channel/service_model_protocols_integration_test.go b/backend/internal/application/channel/service_model_protocols_integration_test.go new file mode 100644 index 00000000..ca9f3f88 --- /dev/null +++ b/backend/internal/application/channel/service_model_protocols_integration_test.go @@ -0,0 +1,330 @@ +package channel_test + +import ( + "context" + "path/filepath" + "testing" + + appchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + channelrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/channel" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestSetModelProtocolsKeepsSharedUpstreamCatalogMetadata(t *testing.T) { + db := openModelProtocolsTestDB(t) + var err error + + upstream := model.LLMUpstream{Name: "shared-upstream", Compatible: "openai", Status: "active"} + if err = db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: "shared-upstream-model", + UpstreamModelName: "shared-upstream-model", + SuggestedProtocol: "openai_chat_completions", + KindsJSON: `["chat"]`, + Status: "active", + } + if err = db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model: %v", err) + } + platformModels := []model.LLMPlatformModel{ + {Name: "platform-a", Vendor: "openai", KindsJSON: `["chat"]`, Status: "active"}, + {Name: "platform-b", Vendor: "openai", KindsJSON: `["chat"]`, Status: "active"}, + } + if err = db.Create(&platformModels).Error; err != nil { + t.Fatalf("create platform models: %v", err) + } + routes := []model.LLMPlatformModelRoute{ + {PlatformModelID: platformModels[0].ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_chat_completions", Status: "active", Priority: 1, Weight: 1}, + {PlatformModelID: platformModels[1].ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_chat_completions", Status: "active", Priority: 1, Weight: 1}, + } + if err = db.Create(&routes).Error; err != nil { + t.Fatalf("create shared routes: %v", err) + } + + repo := channelrepo.NewRepo(db) + service := appchannel.NewService(config.Config{}, repo, repo, nil, nil) + if _, err = service.SetModelProtocols(context.Background(), platformModels[0].ID, appchannel.SetModelProtocolsInput{ + Protocols: []string{"openai_responses"}, + KindsJSON: `["chat"]`, + }); err != nil { + t.Fatalf("SetModelProtocols() error = %v", err) + } + + var storedUpstreamModel model.LLMUpstreamModel + if err = db.First(&storedUpstreamModel, upstreamModel.ID).Error; err != nil { + t.Fatalf("load shared upstream model: %v", err) + } + if storedUpstreamModel.SuggestedProtocol != upstreamModel.SuggestedProtocol || storedUpstreamModel.KindsJSON != upstreamModel.KindsJSON { + t.Fatalf("expected shared upstream metadata to remain unchanged, got protocol=%q kinds=%q", storedUpstreamModel.SuggestedProtocol, storedUpstreamModel.KindsJSON) + } + + var storedRoutes []model.LLMPlatformModelRoute + if err = db.Where("upstream_model_id = ?", upstreamModel.ID).Order("platform_model_id ASC").Find(&storedRoutes).Error; err != nil { + t.Fatalf("load shared routes: %v", err) + } + if len(storedRoutes) != 2 || storedRoutes[0].Protocol != "openai_responses" || storedRoutes[1].Protocol != "openai_chat_completions" { + t.Fatalf("expected only platform A route to change, got %#v", storedRoutes) + } +} + +func TestSetModelProtocolsPreservesRetainedRouteConfiguration(t *testing.T) { + db := openModelProtocolsTestDB(t) + upstream := model.LLMUpstream{Name: "image-upstream", Compatible: "openai", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: "image-upstream-model", + UpstreamModelName: "image-upstream-model", + SuggestedProtocol: "openai_image_generations", + KindsJSON: `["image_gen","image_edit"]`, + Status: "active", + } + if err := db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model: %v", err) + } + platformModel := model.LLMPlatformModel{ + Name: "image-platform-model", Vendor: "openai", KindsJSON: `["image_gen","image_edit"]`, Status: "active", + } + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model: %v", err) + } + routes := []model.LLMPlatformModelRoute{ + { + PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_edits", + Status: "active", Priority: 2, Weight: 3, Source: "sync", CbFailureThreshold: 5, CbDurationMin: 7, CbWindowMin: 11, + HeadersJSON: `{"X-Route":"edit"}`, + }, + { + PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_generations", + Status: "inactive", Priority: 13, Weight: 17, Source: "manual", CbFailureThreshold: 19, CbDurationMin: 23, CbWindowMin: 29, + HeadersJSON: `{"X-Route":"generation"}`, + }, + } + if err := db.Create(&routes).Error; err != nil { + t.Fatalf("create protocol routes: %v", err) + } + + repo := channelrepo.NewRepo(db) + service := appchannel.NewService(config.Config{}, repo, repo, nil, nil) + if _, err := service.SetModelProtocols(context.Background(), platformModel.ID, appchannel.SetModelProtocolsInput{ + Protocols: []string{"openai_image_generations"}, + KindsJSON: `["image_gen"]`, + }); err != nil { + t.Fatalf("SetModelProtocols() error = %v", err) + } + + var storedRoutes []model.LLMPlatformModelRoute + if err := db.Where("platform_model_id = ? AND upstream_model_id = ?", platformModel.ID, upstreamModel.ID).Find(&storedRoutes).Error; err != nil { + t.Fatalf("load retained route: %v", err) + } + if len(storedRoutes) != 1 { + t.Fatalf("expected one retained route, got %#v", storedRoutes) + } + stored := storedRoutes[0] + want := routes[1] + if stored.ID != want.ID || + stored.Protocol != want.Protocol || + stored.Status != want.Status || + stored.Priority != want.Priority || + stored.Weight != want.Weight || + stored.Source != want.Source || + stored.CbFailureThreshold != want.CbFailureThreshold || + stored.CbDurationMin != want.CbDurationMin || + stored.CbWindowMin != want.CbWindowMin || + stored.HeadersJSON != want.HeadersJSON { + t.Fatalf("expected retained route configuration to stay unchanged, got %#v", stored) + } +} + +func TestUpsertUpstreamModelPreservesRouteConfigurationAndSharedCatalog(t *testing.T) { + db := openModelProtocolsTestDB(t) + upstream := model.LLMUpstream{Name: "binding-upstream", Compatible: "openai", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: "binding-upstream-model", + UpstreamModelName: "binding-upstream-model", + SuggestedProtocol: "openai_chat_completions", + KindsJSON: `["chat"]`, + Status: "active", + Source: "sync", + RawJSON: `{"ownedBy":"catalog"}`, + } + if err := db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model: %v", err) + } + platformModels := []model.LLMPlatformModel{ + {Name: "binding-platform-a", Vendor: "openai", KindsJSON: `["image_gen","image_edit"]`, Status: "active"}, + {Name: "binding-platform-b", Vendor: "openai", KindsJSON: `["chat"]`, Status: "active"}, + } + if err := db.Create(&platformModels).Error; err != nil { + t.Fatalf("create platform models: %v", err) + } + routes := []model.LLMPlatformModelRoute{ + { + PlatformModelID: platformModels[0].ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_edits", + Status: "active", Priority: 2, Weight: 3, Source: "sync", CbFailureThreshold: 5, CbDurationMin: 7, CbWindowMin: 11, + HeadersJSON: `{"X-Route":"edit"}`, + }, + { + PlatformModelID: platformModels[0].ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_generations", + Status: "inactive", Priority: 13, Weight: 17, Source: "manual", CbFailureThreshold: 19, CbDurationMin: 23, CbWindowMin: 29, + HeadersJSON: `{"X-Route":"generation"}`, + }, + { + PlatformModelID: platformModels[1].ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_chat_completions", + Status: "active", Priority: 31, Weight: 37, Source: "manual", + }, + } + if err := db.Create(&routes).Error; err != nil { + t.Fatalf("create routes: %v", err) + } + + repo := channelrepo.NewRepo(db) + service := appchannel.NewService(config.Config{}, repo, repo, nil, nil) + if _, err := service.UpsertUpstreamModel(context.Background(), upstream.ID, appchannel.UpsertUpstreamModelInput{ + RouteIDs: []uint{routes[0].ID, routes[1].ID}, + PlatformModelName: platformModels[0].Name, + UpstreamModelName: upstreamModel.UpstreamModelName, + Protocols: []string{"openai_image_generations", "openai_image_edits"}, + KindsJSON: platformModels[0].KindsJSON, + }); err != nil { + t.Fatalf("UpsertUpstreamModel() error = %v", err) + } + + var storedCatalog model.LLMUpstreamModel + if err := db.First(&storedCatalog, upstreamModel.ID).Error; err != nil { + t.Fatalf("load upstream catalog model: %v", err) + } + if storedCatalog.SuggestedProtocol != upstreamModel.SuggestedProtocol || + storedCatalog.KindsJSON != upstreamModel.KindsJSON || + storedCatalog.Status != upstreamModel.Status || + storedCatalog.Source != upstreamModel.Source || + storedCatalog.RawJSON != upstreamModel.RawJSON { + t.Fatalf("expected shared upstream catalog metadata to remain unchanged, got %#v", storedCatalog) + } + + var storedRoutes []model.LLMPlatformModelRoute + if err := db.Where("platform_model_id = ?", platformModels[0].ID).Order("id ASC").Find(&storedRoutes).Error; err != nil { + t.Fatalf("load preserved routes: %v", err) + } + if len(storedRoutes) != 2 { + t.Fatalf("expected two preserved routes, got %#v", storedRoutes) + } + for index, stored := range storedRoutes { + want := routes[index] + if stored.ID != want.ID || + stored.Protocol != want.Protocol || + stored.Status != want.Status || + stored.Priority != want.Priority || + stored.Weight != want.Weight || + stored.Source != want.Source || + stored.CbFailureThreshold != want.CbFailureThreshold || + stored.CbDurationMin != want.CbDurationMin || + stored.CbWindowMin != want.CbWindowMin || + stored.HeadersJSON != want.HeadersJSON { + t.Fatalf("expected route %d configuration to remain unchanged, got %#v", want.ID, stored) + } + } + + var sharedRoute model.LLMPlatformModelRoute + if err := db.First(&sharedRoute, routes[2].ID).Error; err != nil { + t.Fatalf("load unrelated shared route: %v", err) + } + if sharedRoute.Protocol != routes[2].Protocol || sharedRoute.Priority != routes[2].Priority || sharedRoute.Weight != routes[2].Weight { + t.Fatalf("expected unrelated shared route to remain unchanged, got %#v", sharedRoute) + } +} + +func TestUpsertUpstreamModelAppliesOnlyExplicitRouteOverrides(t *testing.T) { + db := openModelProtocolsTestDB(t) + upstream := model.LLMUpstream{Name: "override-upstream", Compatible: "openai", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, BindingCode: "override-model", UpstreamModelName: "override-model", + SuggestedProtocol: "openai_image_generations", KindsJSON: `["image_gen","image_edit"]`, Status: "active", + } + if err := db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model: %v", err) + } + platformModel := model.LLMPlatformModel{ + Name: "override-platform", Vendor: "openai", KindsJSON: `["image_gen","image_edit"]`, Status: "active", + } + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model: %v", err) + } + routes := []model.LLMPlatformModelRoute{ + {PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_generations", Status: "active", Priority: 2, Weight: 3, Source: "sync", HeadersJSON: `{"X-Route":"generation"}`}, + {PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_edits", Status: "inactive", Priority: 5, Weight: 7, Source: "manual", HeadersJSON: `{"X-Route":"edit"}`}, + } + if err := db.Create(&routes).Error; err != nil { + t.Fatalf("create routes: %v", err) + } + + status := "inactive" + repo := channelrepo.NewRepo(db) + service := appchannel.NewService(config.Config{}, repo, repo, nil, nil) + if _, err := service.UpsertUpstreamModel(context.Background(), upstream.ID, appchannel.UpsertUpstreamModelInput{ + RouteIDs: []uint{routes[0].ID, routes[1].ID}, + PlatformModelName: platformModel.Name, + UpstreamModelName: upstreamModel.UpstreamModelName, + Protocols: []string{"openai_image_generations", "openai_image_edits"}, + KindsJSON: platformModel.KindsJSON, + Status: &status, + }); err != nil { + t.Fatalf("UpsertUpstreamModel() error = %v", err) + } + + var storedRoutes []model.LLMPlatformModelRoute + if err := db.Where("platform_model_id = ?", platformModel.ID).Order("id ASC").Find(&storedRoutes).Error; err != nil { + t.Fatalf("load overridden routes: %v", err) + } + if len(storedRoutes) != 2 { + t.Fatalf("expected two routes, got %#v", storedRoutes) + } + for index, stored := range storedRoutes { + want := routes[index] + if stored.Status != status { + t.Fatalf("expected explicit status override, got %#v", stored) + } + if stored.Priority != want.Priority || stored.Weight != want.Weight || stored.Source != want.Source || stored.HeadersJSON != want.HeadersJSON { + t.Fatalf("expected non-overridden configuration to remain unchanged, got %#v", stored) + } + } +} + +func openModelProtocolsTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "channel.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("resolve sqlite database: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err = db.AutoMigrate( + &model.LLMUpstream{}, + &model.LLMUpstreamModel{}, + &model.LLMPlatformModel{}, + &model.LLMPlatformModelRoute{}, + &model.LLMModelVendor{}, + &model.LLMModelDisplayGroup{}, + ); err != nil { + t.Fatalf("migrate channel tables: %v", err) + } + return db +} diff --git a/backend/internal/application/channel/service_model_update_test.go b/backend/internal/application/channel/service_model_update_test.go index 190f11c7..bc24b2a0 100644 --- a/backend/internal/application/channel/service_model_update_test.go +++ b/backend/internal/application/channel/service_model_update_test.go @@ -236,6 +236,161 @@ func TestSetModelsDisplayGroupNormalizesIDsAndMapsRepositoryErrors(t *testing.T) } } +func TestSetModelProtocolsReplacesEveryBindingInOneTransaction(t *testing.T) { + templateRoute := modelProtocolSource(1, 10, 100, "openai_image_edits") + templateRoute.Priority = 2 + templateRoute.Weight = 3 + retainedRoute := modelProtocolSource(2, 10, 100, "openai_image_generations") + retainedRoute.Status = "inactive" + retainedRoute.Priority = 7 + retainedRoute.Weight = 11 + retainedRoute.Source = "manual" + retainedRoute.CbFailureThreshold = 13 + retainedRoute.CbDurationMin = 17 + retainedRoute.CbWindowMin = 19 + retainedRoute.HeadersJSON = `{"X-Route":"generation"}` + repo := &modelUpdateRepo{ + model: domainchannel.PlatformModel{ + ID: 1, + PlatformModelName: "image-model", + KindsJSON: `["image_gen","image_edit"]`, + Status: "active", + }, + sources: []repository.ChannelModelSourceRow{ + templateRoute, + retainedRoute, + modelProtocolSource(3, 20, 200, "openai_image_generations"), + }, + } + service := NewService(config.Config{}, repo, repo, nil, nil) + + view, err := service.SetModelProtocols(t.Context(), 1, SetModelProtocolsInput{ + Protocols: []string{"openai_image_generations"}, + KindsJSON: `["image_gen"]`, + }) + if err != nil { + t.Fatalf("SetModelProtocols() error = %v", err) + } + if !repo.transactionCommitted { + t.Fatal("expected protocol update transaction to commit") + } + if view.KindsJSON != `["image_gen"]` { + t.Fatalf("expected updated kinds, got %q", view.KindsJSON) + } + if len(repo.routeReplacements) != 2 { + t.Fatalf("expected two complete binding replacements, got %d", len(repo.routeReplacements)) + } + if !reflect.DeepEqual(repo.routeReplacements[0].ExistingRouteIDs, []uint{1, 2}) { + t.Fatalf("expected complete first binding route IDs, got %v", repo.routeReplacements[0].ExistingRouteIDs) + } + if !reflect.DeepEqual(repo.routeReplacements[1].ExistingRouteIDs, []uint{3}) { + t.Fatalf("expected complete second binding route IDs, got %v", repo.routeReplacements[1].ExistingRouteIDs) + } + for _, replacement := range repo.routeReplacements { + if len(replacement.Routes) != 1 || replacement.Routes[0].Protocol != "openai_image_generations" { + t.Fatalf("unexpected replacement routes: %#v", replacement.Routes) + } + } + preserved := repo.routeReplacements[0].Routes[0] + if preserved.Status != retainedRoute.Status || + preserved.Priority != retainedRoute.Priority || + preserved.Weight != retainedRoute.Weight || + preserved.Source != retainedRoute.Source || + preserved.CbFailureThreshold != retainedRoute.CbFailureThreshold || + preserved.CbDurationMin != retainedRoute.CbDurationMin || + preserved.CbWindowMin != retainedRoute.CbWindowMin || + preserved.HeadersJSON != retainedRoute.HeadersJSON { + t.Fatalf("expected retained protocol configuration to be preserved, got %#v", preserved) + } +} + +func TestSetModelProtocolsDoesNotLimitSourceCount(t *testing.T) { + const sourceCount = 1001 + sources := make([]repository.ChannelModelSourceRow, 0, sourceCount) + for index := 0; index < sourceCount; index++ { + sources = append(sources, modelProtocolSource(uint(index+1), uint(index+10), uint(index+100), "openai_responses")) + } + repo := &modelUpdateRepo{ + model: domainchannel.PlatformModel{ID: 1, PlatformModelName: "large-model", KindsJSON: `["chat"]`, Status: "active"}, + sources: sources, + } + service := NewService(config.Config{}, repo, repo, nil, nil) + + if _, err := service.SetModelProtocols(t.Context(), 1, SetModelProtocolsInput{ + Protocols: []string{"openai_responses"}, + KindsJSON: `["chat"]`, + }); err != nil { + t.Fatalf("SetModelProtocols() error = %v", err) + } + if len(repo.routeReplacements) != sourceCount { + t.Fatalf("expected all %d bindings to be replaced, got %d", sourceCount, len(repo.routeReplacements)) + } +} + +func TestSetModelProtocolsRollsBackWhenAReplacementConflicts(t *testing.T) { + repo := &modelUpdateRepo{ + model: domainchannel.PlatformModel{ID: 1, PlatformModelName: "conflict-model", KindsJSON: `["chat"]`, Status: "active"}, + sources: []repository.ChannelModelSourceRow{ + modelProtocolSource(1, 10, 100, "openai_chat_completions"), + modelProtocolSource(2, 20, 200, "openai_chat_completions"), + }, + replaceErrAt: 2, + } + service := NewService(config.Config{}, repo, repo, nil, nil) + + _, err := service.SetModelProtocols(t.Context(), 1, SetModelProtocolsInput{ + Protocols: []string{"openai_responses"}, + KindsJSON: `["chat"]`, + }) + if !errors.Is(err, ErrUpstreamModelBindingChanged) { + t.Fatalf("expected binding changed error, got %v", err) + } + if repo.transactionCommitted { + t.Fatal("expected outer transaction to roll back") + } +} + +func TestSetModelProtocolsRejectsMalformedExplicitSets(t *testing.T) { + service := NewService(config.Config{}, &modelUpdateRepo{}, &modelUpdateRepo{}, nil, nil) + tests := []struct { + name string + protocols []string + want error + }{ + {name: "missing", want: ErrProtocolRequired}, + {name: "blank", protocols: []string{" "}, want: ErrInvalidAdapter}, + {name: "normalized duplicate", protocols: []string{"openai_responses", " OPENAI_RESPONSES "}, want: ErrInvalidRouteProtocolCombination}, + {name: "too many", protocols: []string{"openai_responses", "openai_chat_completions", "anthropic_messages"}, want: ErrInvalidRouteProtocolCombination}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := service.SetModelProtocols(t.Context(), 1, SetModelProtocolsInput{ + Protocols: test.protocols, + KindsJSON: `["chat"]`, + }) + if !errors.Is(err, test.want) { + t.Fatalf("expected %v, got %v", test.want, err) + } + }) + } +} + +func modelProtocolSource(routeID uint, upstreamID uint, upstreamModelID uint, protocol string) repository.ChannelModelSourceRow { + return repository.ChannelModelSourceRow{ + PlatformModelRoute: domainchannel.PlatformModelRoute{ + ID: routeID, + PlatformModelID: 1, + UpstreamModelID: upstreamModelID, + Protocol: protocol, + Status: "active", + Priority: 1, + Weight: 1, + }, + UpstreamID: upstreamID, + UpstreamCompatible: "openai", + } +} + type modelUpdateRepo struct { model domainchannel.PlatformModel modelRows []repository.ChannelModelListRow @@ -248,6 +403,15 @@ type modelUpdateRepo struct { lastDisplayGroupModelIDs []uint lastDisplayGroupID uint setDisplayGroupErr error + transactionCommitted bool + routeReplacements []repository.ReplaceChannelPlatformRoutesInput + replaceErrAt int +} + +func (r *modelUpdateRepo) WithinTransaction(ctx context.Context, fn func(repository.ChannelRepository) error) error { + err := fn(r) + r.transactionCommitted = err == nil + return err } func (r *modelUpdateRepo) CreateUpstream(context.Context, *domainchannel.Upstream) error { @@ -356,6 +520,10 @@ func (r *modelUpdateRepo) UpsertUpstreamModel(context.Context, *domainchannel.Up return nil } +func (r *modelUpdateRepo) CreateUpstreamModel(context.Context, *domainchannel.UpstreamModel) error { + return nil +} + func (r *modelUpdateRepo) GetUpstreamModelByID(context.Context, uint, uint) (*domainchannel.UpstreamModel, error) { return nil, repository.ErrNotFound } @@ -364,10 +532,6 @@ func (r *modelUpdateRepo) GetUpstreamModelByUpstreamName(context.Context, uint, return nil, repository.ErrNotFound } -func (r *modelUpdateRepo) UpdateUpstreamModelByID(context.Context, uint, uint, repository.UpdateChannelUpstreamModelInput) error { - return nil -} - func (r *modelUpdateRepo) DeleteUpstreamModel(context.Context, uint, uint) error { return nil } @@ -396,6 +560,18 @@ func (r *modelUpdateRepo) UpsertPlatformModelRoute(context.Context, *domainchann return nil } +func (r *modelUpdateRepo) ReplacePlatformModelRoutes(_ context.Context, inputs []repository.ReplaceChannelPlatformRoutesInput) ([]domainchannel.PlatformModelRoute, error) { + replaced := make([]domainchannel.PlatformModelRoute, 0) + for _, input := range inputs { + r.routeReplacements = append(r.routeReplacements, input) + if r.replaceErrAt > 0 && len(r.routeReplacements) == r.replaceErrAt { + return nil, repository.ErrConflict + } + replaced = append(replaced, input.Routes...) + } + return replaced, nil +} + func (r *modelUpdateRepo) GetModelUpstreamSourceByRouteID(context.Context, string, uint) (*repository.ChannelModelSourceRow, error) { if r.source.ID == 0 { return nil, repository.ErrNotFound @@ -446,6 +622,10 @@ func (r *modelUpdateRepo) ListModelUpstreamSources(context.Context, string, int, return r.sources, int64(len(r.sources)), nil } +func (r *modelUpdateRepo) ListModelUpstreamSourcesForUpdate(context.Context, string) ([]repository.ChannelModelSourceRow, error) { + return r.sources, nil +} + func (r *modelUpdateRepo) ListActiveRoutesByModel(context.Context, string) ([]repository.ChannelUpstreamRouteRow, error) { return nil, nil } diff --git a/backend/internal/application/channel/service_sync.go b/backend/internal/application/channel/service_sync.go index 05cc6744..50515012 100644 --- a/backend/internal/application/channel/service_sync.go +++ b/backend/internal/application/channel/service_sync.go @@ -414,33 +414,32 @@ func (s *Service) importSingleUpstreamModel(ctx context.Context, upstreamItem *d Protocols: protocols, } for _, protocol := range protocols { - createdRoute := !s.routeExists(ctx, upstreamItem.ID, platformModelName, upstreamModelName, protocol) - view, err := s.UpsertUpstreamModel(ctx, upstreamItem.ID, UpsertUpstreamModelInput{ - PlatformModelName: platformModelName, - UpstreamModelName: upstreamModelName, - Protocol: protocol, - KindsJSON: kindsJSON, - Status: input.Status, - Priority: input.Priority, - Weight: 1, - Source: "import", - }) - if err != nil { - return ImportUpstreamModelResultView{}, err - } - if result.BindingCode == "" { - result.BindingCode = view.BindingCode - } - if result.PlatformModelID == 0 { - result.PlatformModelID = view.PlatformModelID - } - if createdRoute { + if !s.routeExists(ctx, upstreamItem.ID, platformModelName, upstreamModelName, protocol) { result.CreatedRoutes++ - result.CreatedRoute = true } else { result.ExistingRoutes++ } } + status := input.Status + priority := input.Priority + weight := 1 + source := "import" + view, err := s.UpsertUpstreamModel(ctx, upstreamItem.ID, UpsertUpstreamModelInput{ + PlatformModelName: platformModelName, + UpstreamModelName: upstreamModelName, + Protocols: protocols, + KindsJSON: kindsJSON, + Status: &status, + Priority: &priority, + Weight: &weight, + Source: &source, + }) + if err != nil { + return ImportUpstreamModelResultView{}, err + } + result.BindingCode = view.BindingCode + result.PlatformModelID = view.PlatformModelID + result.CreatedRoute = result.CreatedRoutes > 0 return result, nil } diff --git a/backend/internal/application/channel/service_upstream_model.go b/backend/internal/application/channel/service_upstream_model.go index cdfc7590..3bf43295 100644 --- a/backend/internal/application/channel/service_upstream_model.go +++ b/backend/internal/application/channel/service_upstream_model.go @@ -3,6 +3,7 @@ package channel import ( "context" "errors" + "sort" "strings" domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" @@ -51,11 +52,6 @@ func (s *Service) ListUpstreamModels(ctx context.Context, upstreamID uint, page // UpsertUpstreamModel 新增或更新平台模型到上游真实模型的路由绑定。 func (s *Service) UpsertUpstreamModel(ctx context.Context, upstreamID uint, input UpsertUpstreamModelInput) (*UpstreamModelView, error) { - upstream, err := s.repo.GetUpstreamByID(ctx, upstreamID) - if err != nil { - return nil, err - } - platformModelName, err := normalizePlatformModelName(input.PlatformModelName) if err != nil { return nil, err @@ -64,8 +60,10 @@ func (s *Service) UpsertUpstreamModel(ctx context.Context, upstreamID uint, inpu if upstreamModelName == "" { return nil, ErrUpstreamModelNotFound } - if err := validateOptionalJSON(strings.TrimSpace(input.HeadersJSON)); err != nil { - return nil, ErrInvalidJSONConfig + if input.HeadersJSON != nil { + if err := validateOptionalJSON(strings.TrimSpace(*input.HeadersJSON)); err != nil { + return nil, ErrInvalidJSONConfig + } } rawKindsJSON := strings.TrimSpace(input.KindsJSON) @@ -78,77 +76,124 @@ func (s *Service) UpsertUpstreamModel(ctx context.Context, upstreamID uint, inpu if err != nil { return nil, err } - protocol, err := resolveRouteProtocol(input.Protocol, upstream.Compatible, upstream.ProtocolDefaultsJSON, kindsJSON) - if err != nil { - return nil, err - } + var view *UpstreamModelView + err = s.repo.WithinTransaction(ctx, func(txRepo repository.ChannelRepository) error { + upstream, txErr := txRepo.GetUpstreamByID(ctx, upstreamID) + if txErr != nil { + return txErr + } + protocols, txErr := resolveRouteProtocols(input.Protocols, upstream.Compatible, upstream.ProtocolDefaultsJSON, kindsJSON) + if txErr != nil { + return txErr + } - platformModel, platformModelCreated, err := s.ensurePlatformModel(ctx, platformModelName, kindsJSON, upstreamModelName) - if err != nil { - return nil, err - } - if !platformModelCreated && kindsExplicit && strings.TrimSpace(platformModel.KindsJSON) != kindsJSON { - if err := s.repo.UpdateModel(ctx, platformModel.ID, repository.UpdateChannelModelInput{KindsJSON: &kindsJSON}); err != nil { - return nil, err + platformModel, platformModelCreated, txErr := ensurePlatformModel(ctx, txRepo, platformModelName, kindsJSON, upstreamModelName) + if txErr != nil { + return txErr + } + if !platformModelCreated && kindsExplicit && strings.TrimSpace(platformModel.KindsJSON) != kindsJSON { + if txErr := txRepo.UpdateModel(ctx, platformModel.ID, repository.UpdateChannelModelInput{KindsJSON: &kindsJSON}); txErr != nil { + return txErr + } + platformModel.KindsJSON = kindsJSON } - platformModel.KindsJSON = kindsJSON - } - upstreamModelVendor := normalizeUpstreamModelVendor("", upstreamModelName, upstream.Name, upstream.BaseURL) - upstreamModelIcon := normalizeModelIcon("", upstreamModelVendor, upstreamModelName) - upstreamModel, err := s.upsertUpstreamCatalogModel(ctx, upstream.ID, upstreamModelName, protocol, kindsJSON, upstreamModelVendor, upstreamModelIcon, "active", normalizeSource(input.Source), "{}") - if err != nil { - return nil, err - } - if err := s.validateRouteProtocolCombination(ctx, upstream.ID, platformModel.ID, upstreamModel.ID, input.RouteID, protocol); err != nil { - return nil, err - } - route := &domainchannel.PlatformModelRoute{ - PlatformModelID: platformModel.ID, - UpstreamModelID: upstreamModel.ID, - Protocol: protocol, - Status: normalizeStatus(input.Status), - Priority: normalizePriority(input.Priority), - Weight: normalizeWeight(input.Weight), - Source: normalizeSource(input.Source), - CbFailureThreshold: input.CbFailureThreshold, - CbDurationMin: input.CbDurationMin, - CbWindowMin: input.CbWindowMin, - HeadersJSON: strings.TrimSpace(input.HeadersJSON), - } + upstreamModelVendor := normalizeUpstreamModelVendor("", upstreamModelName, upstream.Name, upstream.BaseURL) + upstreamModelIcon := normalizeModelIcon("", upstreamModelVendor, upstreamModelName) + upstreamModelSource := "manual" + if input.Source != nil { + upstreamModelSource = normalizeSource(*input.Source) + } + upstreamModel, txErr := ensureUpstreamCatalogModel( + ctx, + txRepo, + upstream.ID, + upstreamModelName, + protocols[0], + kindsJSON, + upstreamModelVendor, + upstreamModelIcon, + upstreamModelSource, + ) + if txErr != nil { + return txErr + } - if input.RouteID > 0 { - if _, err := s.repo.GetPlatformModelRouteByID(ctx, input.RouteID, upstream.ID); err != nil { - return nil, err + existingRoutes, txErr := bindingRoutesForReplacement( + ctx, + txRepo, + upstream.ID, + platformModel.ID, + upstreamModel.ID, + input.RouteIDs, + ) + if txErr != nil { + return txErr } - if err := s.repo.UpdatePlatformModelRouteByID(ctx, input.RouteID, upstream.ID, repository.UpdateChannelPlatformRouteInput{ - PlatformModelID: &route.PlatformModelID, - UpstreamModelID: &route.UpstreamModelID, - Protocol: &route.Protocol, - Status: &route.Status, - Priority: &route.Priority, - Weight: &route.Weight, - Source: &route.Source, - CbFailureThreshold: &route.CbFailureThreshold, - CbDurationMin: &route.CbDurationMin, - CbWindowMin: &route.CbWindowMin, - HeadersJSON: &route.HeadersJSON, - }); err != nil { - if isDuplicateKeyError(err) { - return nil, ErrUpstreamModelConflict + existingRouteIDs := append([]uint(nil), input.RouteIDs...) + if len(existingRouteIDs) == 0 { + existingRouteIDs = make([]uint, 0, len(existingRoutes)) + for _, route := range existingRoutes { + existingRouteIDs = append(existingRouteIDs, route.ID) } - return nil, err } - route.ID = input.RouteID - } else if err := s.repo.UpsertPlatformModelRoute(ctx, route); err != nil { - if isDuplicateKeyError(err) { + + routes := make([]domainchannel.PlatformModelRoute, 0, len(protocols)) + for _, desiredProtocol := range protocols { + route := replacementRouteTemplate(existingRoutes, desiredProtocol) + route.PlatformModelID = platformModel.ID + route.UpstreamModelID = upstreamModel.ID + route.Protocol = desiredProtocol + applyRouteOverrides(&route, input) + routes = append(routes, route) + } + replaced, txErr := txRepo.ReplacePlatformModelRoutes(ctx, []repository.ReplaceChannelPlatformRoutesInput{{ + UpstreamID: upstream.ID, + ExistingRouteIDs: existingRouteIDs, + Routes: routes, + }}) + if txErr != nil { + return txErr + } + view, txErr = findUpstreamModelViewByRoute(ctx, txRepo, upstream.ID, replaced[0].ID, upstreamModel.ID) + return txErr + }) + if err != nil { + switch { + case isDuplicateKeyError(err): return nil, ErrUpstreamModelConflict + case errors.Is(err, repository.ErrConflict): + return nil, ErrUpstreamModelBindingChanged + default: + return nil, err } - return nil, err } s.InvalidateModelCatalog() - return s.findUpstreamModelViewByRoute(ctx, upstream.ID, route.ID, upstreamModel.ID) + return view, nil +} + +func bindingRoutesForReplacement( + ctx context.Context, + repo repository.ChannelRepository, + upstreamID uint, + platformModelID uint, + upstreamModelID uint, + existingRouteIDs []uint, +) ([]domainchannel.PlatformModelRoute, error) { + if len(existingRouteIDs) == 0 { + return repo.ListPlatformModelRoutesByPair(ctx, upstreamID, platformModelID, upstreamModelID) + } + routes := make([]domainchannel.PlatformModelRoute, 0, len(existingRouteIDs)) + for _, routeID := range existingRouteIDs { + route, err := repo.GetPlatformModelRouteByID(ctx, routeID, upstreamID) + if err != nil { + return nil, err + } + routes = append(routes, *route) + } + sort.Slice(routes, func(i int, j int) bool { return routes[i].ID < routes[j].ID }) + return routes, nil } func (s *Service) validateRouteProtocolCombination( @@ -159,17 +204,15 @@ func (s *Service) validateRouteProtocolCombination( routeID uint, protocol string, ) error { - // 同一个平台模型到同一个上游真实模型只允许单协议,或同厂商图片生成/编辑成对组合。 routes, err := s.repo.ListPlatformModelRoutesByPair(ctx, upstreamID, platformModelID, upstreamModelID) if err != nil { return err } protocols := make([]string, 0, len(routes)+1) for _, route := range routes { - if route.ID == routeID { - continue + if route.ID != routeID { + protocols = append(protocols, route.Protocol) } - protocols = append(protocols, route.Protocol) } protocols = append(protocols, protocol) if !isSupportedRouteProtocolCombination(protocols) { @@ -178,8 +221,52 @@ func (s *Service) validateRouteProtocolCombination( return nil } -func (s *Service) ensurePlatformModel(ctx context.Context, platformModelName string, kindsJSON string, candidates ...string) (*domainchannel.PlatformModel, bool, error) { - if item, err := s.repo.GetModelByName(ctx, platformModelName); err == nil { +func replacementRouteTemplate(existingRoutes []domainchannel.PlatformModelRoute, protocol string) domainchannel.PlatformModelRoute { + for _, route := range existingRoutes { + if route.Protocol == protocol { + return route + } + } + if len(existingRoutes) > 0 { + return existingRoutes[0] + } + return domainchannel.PlatformModelRoute{ + Status: "active", + Priority: 1, + Weight: 1, + Source: "manual", + } +} + +func applyRouteOverrides(route *domainchannel.PlatformModelRoute, input UpsertUpstreamModelInput) { + if input.Status != nil { + route.Status = normalizeStatus(*input.Status) + } + if input.Priority != nil { + route.Priority = normalizePriority(*input.Priority) + } + if input.Weight != nil { + route.Weight = normalizeWeight(*input.Weight) + } + if input.Source != nil { + route.Source = normalizeSource(*input.Source) + } + if input.CbFailureThreshold != nil { + route.CbFailureThreshold = normalizeNonNegative(*input.CbFailureThreshold) + } + if input.CbDurationMin != nil { + route.CbDurationMin = normalizeNonNegative(*input.CbDurationMin) + } + if input.CbWindowMin != nil { + route.CbWindowMin = normalizeNonNegative(*input.CbWindowMin) + } + if input.HeadersJSON != nil { + route.HeadersJSON = strings.TrimSpace(*input.HeadersJSON) + } +} + +func ensurePlatformModel(ctx context.Context, repo repository.ChannelRepository, platformModelName string, kindsJSON string, candidates ...string) (*domainchannel.PlatformModel, bool, error) { + if item, err := repo.GetModelByName(ctx, platformModelName); err == nil { return item, false, nil } else if !errors.Is(err, ErrModelNotFound) { return nil, false, err @@ -194,11 +281,11 @@ func (s *Service) ensurePlatformModel(ctx context.Context, platformModelName str Status: "active", Description: "", } - if err := s.repo.CreateModel(ctx, item); err != nil { + if err := repo.CreateModel(ctx, item); err != nil { if !isDuplicateKeyError(err) { return nil, false, err } - item, err = s.repo.GetModelByName(ctx, platformModelName) + item, err = repo.GetModelByName(ctx, platformModelName) if err != nil { return nil, false, err } @@ -207,64 +294,46 @@ func (s *Service) ensurePlatformModel(ctx context.Context, platformModelName str return item, true, nil } -func (s *Service) upsertUpstreamCatalogModel( +func ensureUpstreamCatalogModel( ctx context.Context, + repo repository.ChannelRepository, upstreamID uint, upstreamModelName string, suggestedProtocol string, kindsJSON string, vendor string, icon string, - status string, source string, - rawJSON string, ) (*domainchannel.UpstreamModel, error) { - bindingCode := generateBindingCode() - normalizedSource := normalizeSource(source) - if existing, err := s.repo.GetUpstreamModelByUpstreamName(ctx, upstreamID, upstreamModelName); err == nil { - bindingCode = existing.BindingCode - if normalizedSource != "sync" && strings.TrimSpace(existing.Source) != "" { - normalizedSource = normalizeSource(existing.Source) - } - if strings.TrimSpace(vendor) == "" { - vendor = existing.Vendor - } - if strings.TrimSpace(icon) == "" { - icon = existing.Icon - } - if strings.TrimSpace(rawJSON) == "" || strings.TrimSpace(rawJSON) == "{}" { - rawJSON = existing.RawJSON - } + if existing, err := repo.GetUpstreamModelByUpstreamName(ctx, upstreamID, upstreamModelName); err == nil { + return existing, nil } else if !errors.Is(err, ErrUpstreamModelNotFound) { return nil, err } - if strings.TrimSpace(rawJSON) == "" { - rawJSON = "{}" - } item := &domainchannel.UpstreamModel{ UpstreamID: upstreamID, - BindingCode: bindingCode, + BindingCode: generateBindingCode(), UpstreamModelName: upstreamModelName, SuggestedProtocol: suggestedProtocol, KindsJSON: kindsJSON, - Status: normalizeStatus(status), - Source: normalizedSource, - RawJSON: strings.TrimSpace(rawJSON), + Status: "active", + Source: normalizeSource(source), + RawJSON: "{}", } item.Vendor = normalizeUpstreamModelVendor(vendor, upstreamModelName) item.Icon = normalizeModelIcon(icon, item.Vendor, upstreamModelName) - if err := s.repo.UpsertUpstreamModel(ctx, item); err != nil { + if err := repo.CreateUpstreamModel(ctx, item); err != nil { if isDuplicateKeyError(err) { - return nil, ErrUpstreamModelConflict + return repo.GetUpstreamModelByUpstreamName(ctx, upstreamID, upstreamModelName) } return nil, err } return item, nil } -func (s *Service) findUpstreamModelViewByRoute(ctx context.Context, upstreamID uint, routeID uint, upstreamModelID uint) (*UpstreamModelView, error) { - row, err := s.repo.GetUpstreamModelRouteByID(ctx, upstreamID, routeID) +func findUpstreamModelViewByRoute(ctx context.Context, repo repository.ChannelRepository, upstreamID uint, routeID uint, upstreamModelID uint) (*UpstreamModelView, error) { + row, err := repo.GetUpstreamModelRouteByID(ctx, upstreamID, routeID) if err != nil { return nil, err } diff --git a/backend/internal/infra/persistence/postgres/channel/repository.go b/backend/internal/infra/persistence/postgres/channel/repository.go index 6aa57804..e492e4bf 100644 --- a/backend/internal/infra/persistence/postgres/channel/repository.go +++ b/backend/internal/infra/persistence/postgres/channel/repository.go @@ -7,12 +7,14 @@ import ( "sort" "strconv" "strings" + "time" domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/dberror" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // translateError 将 gorm 底层错误统一映射为仓储语义错误。 @@ -33,7 +35,25 @@ func translateError(err error) error { // Repo 封装上游域数据访问。 type Repo struct { - db *gorm.DB + db *gorm.DB + inTransaction bool +} + +// WithinTransaction 在同一数据库事务中执行渠道仓储操作。 +func (r *Repo) WithinTransaction(ctx context.Context, fn func(repository.ChannelRepository) error) error { + if fn == nil { + return repository.ErrInvalidInput + } + return translateError(r.transact(ctx, func(tx *gorm.DB) error { + return fn(&Repo{db: tx, inTransaction: true}) + })) +} + +func (r *Repo) transact(ctx context.Context, fn func(*gorm.DB) error) error { + if r.inTransaction { + return fn(r.db.WithContext(ctx)) + } + return r.db.WithContext(ctx).Transaction(fn) } // UpstreamRouteRow 是上游路由查询结果。 @@ -192,8 +212,8 @@ func (r *Repo) GetUpstreamListRowByID(ctx context.Context, upstreamID uint) (*Up func upstreamListStatsJoinSQL() string { return `LEFT JOIN ( SELECT um.upstream_id, - COUNT(DISTINCT r.id) AS models_count, - COUNT(DISTINCT CASE WHEN u.status = 'active' AND r.status = 'active' AND um.status = 'active' AND pm.status = 'active' THEN r.id END) AS active_models_count + COUNT(DISTINCT CASE WHEN r.id IS NOT NULL THEN um.id END) AS models_count, + COUNT(DISTINCT CASE WHEN u.status = 'active' AND r.status = 'active' AND um.status = 'active' AND pm.status = 'active' THEN um.id END) AS active_models_count FROM llm_upstream_models um LEFT JOIN llm_upstreams u ON u.id = um.upstream_id LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id @@ -683,6 +703,28 @@ func modelPresentationOrderKey(prefix string) string { // 上游真实模型与平台路由 // --------------------------------------------------------------------------- +// CreateUpstreamModel 新增上游真实模型,不覆盖同名目录项。 +func (r *Repo) CreateUpstreamModel(ctx context.Context, item *domainchannel.UpstreamModel) error { + entity := toUpstreamModelModel(item) + if entity.UpstreamID == 0 || strings.TrimSpace(entity.UpstreamModelName) == "" || strings.TrimSpace(entity.BindingCode) == "" { + return repository.ErrInvalidInput + } + result := r.db.WithContext(ctx). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "upstream_id"}, {Name: "upstream_model_name"}}, + DoNothing: true, + }). + Create(&entity) + if result.Error != nil { + return translateError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrDuplicate + } + *item = toUpstreamModelDomain(entity) + return nil +} + // UpsertUpstreamModel 新增或更新上游真实模型。 func (r *Repo) UpsertUpstreamModel(ctx context.Context, item *domainchannel.UpstreamModel) error { entity := toUpstreamModelModel(item) @@ -763,56 +805,6 @@ func (r *Repo) GetUpstreamModelByUpstreamName(ctx context.Context, upstreamID ui return &result, nil } -// UpdateUpstreamModelByID 更新单条上游真实模型。 -func (r *Repo) UpdateUpstreamModelByID( - ctx context.Context, - sourceID uint, - upstreamID uint, - input repository.UpdateChannelUpstreamModelInput, -) error { - updates := upstreamModelUpdates(input) - if len(updates) == 0 { - return nil - } - result := r.db.WithContext(ctx). - Model(&model.LLMUpstreamModel{}). - Where("id = ? AND upstream_id = ?", sourceID, upstreamID). - Updates(updates) - if result.Error != nil { - return translateError(result.Error) - } - if result.RowsAffected == 0 { - return ErrUpstreamModelNotFound - } - return nil -} - -func upstreamModelUpdates(input repository.UpdateChannelUpstreamModelInput) map[string]interface{} { - updates := make(map[string]interface{}) - if input.UpstreamModelName != nil { - updates["upstream_model_name"] = *input.UpstreamModelName - } - if input.Status != nil { - updates["status"] = *input.Status - } - if input.Source != nil { - updates["source"] = *input.Source - } - if input.SuggestedProtocol != nil { - updates["suggested_protocol"] = *input.SuggestedProtocol - } - if input.KindsJSON != nil { - updates["kinds_json"] = *input.KindsJSON - } - if input.LastSyncedAt != nil { - updates["last_synced_at"] = *input.LastSyncedAt - } - if input.RawJSON != nil { - updates["raw_json"] = *input.RawJSON - } - return updates -} - // DeleteUpstreamModel 硬删除单条上游真实模型及其平台路由。 func (r *Repo) DeleteUpstreamModel(ctx context.Context, sourceID uint, upstreamID uint) error { return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -849,20 +841,41 @@ func (r *Repo) MarkMissingSyncedUpstreamModelsInactive(ctx context.Context, upst // 上游模型列表与查询 // --------------------------------------------------------------------------- -// ListUpstreamModels 查询上游真实模型及其路由绑定。结果为扁平行:每条路由一行,无路由的上游模型单独一行。 +type upstreamModelBindingPageKey struct { + UpstreamModelID uint `gorm:"column:upstream_model_id"` + PlatformModelID uint `gorm:"column:platform_model_id"` +} + +// ListUpstreamModels 查询上游真实模型及其路由绑定。分页单位是平台模型与上游真实模型组成的绑定; +// 返回结果仍为扁平行:每条路由一行,无路由的上游模型单独一行。 func (r *Repo) ListUpstreamModels(ctx context.Context, upstreamID uint, input repository.ListChannelUpstreamModelsInput) ([]UpstreamModelListRow, int64, error) { - items := make([]UpstreamModelListRow, 0) + groupedQuery := r.upstreamModelBindingGroupsQuery(ctx, upstreamID, input) var total int64 - - countQuery := r.db.WithContext(ctx). - Table("llm_upstream_models AS um"). - Joins("LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id"). - Joins("LEFT JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). - Where("um.upstream_id = ?", upstreamID) - countQuery = applyUpstreamModelListFilters(countQuery, input) - if err := countQuery.Count(&total).Error; err != nil { + if err := r.db.WithContext(ctx). + Table("(?) AS binding_groups", groupedQuery). + Count(&total).Error; err != nil { return nil, 0, translateError(err) } + + pageKeys := make([]upstreamModelBindingPageKey, 0) + if input.Limit > 0 { + if err := r.upstreamModelBindingGroupsQuery(ctx, upstreamID, input). + Order(upstreamModelBindingListOrder(input.Sort)). + Offset(input.Offset). + Limit(input.Limit). + Scan(&pageKeys).Error; err != nil { + return nil, 0, translateError(err) + } + } + if len(pageKeys) == 0 { + return []UpstreamModelListRow{}, total, nil + } + + pagedGroups := r.upstreamModelBindingGroupsQuery(ctx, upstreamID, input). + Order(upstreamModelBindingListOrder(input.Sort)). + Offset(input.Offset). + Limit(input.Limit) + items := make([]UpstreamModelListRow, 0) listQuery := r.db.WithContext(ctx). Table("llm_upstream_models AS um"). Select( @@ -872,16 +885,62 @@ func (r *Repo) ListUpstreamModels(ctx context.Context, upstreamID uint, input re ). Joins("LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id"). Joins("LEFT JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Joins( + "JOIN (?) AS page_bindings ON page_bindings.upstream_model_id = um.id AND page_bindings.platform_model_id = COALESCE(r.platform_model_id, 0)", + pagedGroups, + ). Where("um.upstream_id = ?", upstreamID) - listQuery = applyUpstreamModelListFilters(listQuery, input) if err := listQuery. - Order(upstreamModelListOrder(input.Sort)). - Offset(input.Offset). - Limit(input.Limit). + Order("um.id ASC, r.id ASC NULLS LAST"). Scan(&items).Error; err != nil { return nil, 0, translateError(err) } - return items, total, nil + + itemsByKey := make(map[string][]UpstreamModelListRow, len(pageKeys)) + for _, item := range items { + key := upstreamModelBindingKey(item.UpstreamModel.ID, item.PlatformModelID) + itemsByKey[key] = append(itemsByKey[key], item) + } + orderedItems := make([]UpstreamModelListRow, 0, len(items)) + for _, pageKey := range pageKeys { + key := upstreamModelBindingKey(pageKey.UpstreamModelID, pageKey.PlatformModelID) + orderedItems = append(orderedItems, itemsByKey[key]...) + } + return orderedItems, total, nil +} + +func (r *Repo) upstreamModelBindingGroupsQuery(ctx context.Context, upstreamID uint, input repository.ListChannelUpstreamModelsInput) *gorm.DB { + query := r.db.WithContext(ctx). + Table("llm_upstream_models AS um"). + Joins("LEFT JOIN llm_model_routes r ON r.upstream_model_id = um.id"). + Joins("LEFT JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Where("um.upstream_id = ?", upstreamID) + return applyUpstreamModelListFilters(query, input). + Select("um.id AS upstream_model_id, COALESCE(r.platform_model_id, 0) AS platform_model_id"). + Group("um.id, r.platform_model_id, pm.name") +} + +func upstreamModelBindingKey(upstreamModelID uint, platformModelID uint) string { + return strconv.FormatUint(uint64(upstreamModelID), 10) + ":" + strconv.FormatUint(uint64(platformModelID), 10) +} + +func upstreamModelBindingListOrder(sortValue string) string { + switch strings.TrimSpace(sortValue) { + case "upstream_desc": + return "um.upstream_model_name DESC, um.id ASC, COALESCE(r.platform_model_id, 0) ASC" + case "platform_asc": + return "pm.name ASC NULLS LAST, um.upstream_model_name ASC, um.id ASC, COALESCE(r.platform_model_id, 0) ASC" + case "platform_desc": + return "pm.name DESC NULLS LAST, um.upstream_model_name ASC, um.id ASC, COALESCE(r.platform_model_id, 0) ASC" + case "status_asc": + return "CASE WHEN COUNT(r.id) = 0 THEN 2 WHEN MAX(CASE WHEN r.status = 'active' THEN 1 ELSE 0 END) = 1 THEN 0 ELSE 1 END ASC, um.upstream_model_name ASC, um.id ASC, COALESCE(r.platform_model_id, 0) ASC" + case "protocol_asc": + return "MIN(r.protocol) ASC NULLS LAST, um.upstream_model_name ASC, um.id ASC, COALESCE(r.platform_model_id, 0) ASC" + case "upstream_asc": + fallthrough + default: + return "um.upstream_model_name ASC, um.id ASC, COALESCE(r.platform_model_id, 0) ASC" + } } // ListUpstreamModelsByNames 按远端模型名集合查询已有上游模型和绑定快照。 @@ -999,25 +1058,6 @@ func applyUpstreamModelListFilters(query *gorm.DB, input repository.ListChannelU return query } -func upstreamModelListOrder(sort string) string { - switch strings.TrimSpace(sort) { - case "upstream_desc": - return "um.upstream_model_name DESC, r.id ASC NULLS LAST" - case "platform_asc": - return "pm.name ASC NULLS LAST, um.upstream_model_name ASC, r.id ASC NULLS LAST" - case "platform_desc": - return "pm.name DESC NULLS LAST, um.upstream_model_name ASC, r.id ASC NULLS LAST" - case "status_asc": - return "CASE WHEN r.id IS NULL THEN 2 WHEN r.status = 'active' THEN 0 ELSE 1 END ASC, um.upstream_model_name ASC, r.id ASC NULLS LAST" - case "protocol_asc": - return "r.protocol ASC NULLS LAST, um.upstream_model_name ASC, r.id ASC NULLS LAST" - case "upstream_asc": - fallthrough - default: - return "um.upstream_model_name ASC, r.id ASC NULLS LAST" - } -} - // UpsertPlatformModelRoute 新增或更新平台模型到上游真实模型的路由绑定。 func (r *Repo) UpsertPlatformModelRoute(ctx context.Context, item *domainchannel.PlatformModelRoute) error { if item == nil || item.PlatformModelID == 0 || item.UpstreamModelID == 0 { @@ -1065,6 +1105,387 @@ func (r *Repo) UpsertPlatformModelRoute(ctx context.Context, item *domainchannel return nil } +// ReplacePlatformModelRoutes 原子替换一组平台模型与上游真实模型绑定的完整协议集合。 +func (r *Repo) ReplacePlatformModelRoutes( + ctx context.Context, + inputs []repository.ReplaceChannelPlatformRoutesInput, +) ([]domainchannel.PlatformModelRoute, error) { + if len(inputs) == 0 { + return nil, repository.ErrInvalidInput + } + + replaced := make([]domainchannel.PlatformModelRoute, 0, len(inputs)) + err := r.transact(ctx, func(tx *gorm.DB) error { + plans, err := loadPlatformRouteReplacementPlans(tx, inputs) + if err != nil { + return err + } + replaced, err = applyPlatformRouteReplacementPlans(tx, plans) + return err + }) + if err != nil { + return nil, translateError(err) + } + return replaced, nil +} + +type platformRouteBindingKey struct { + platformModelID uint + upstreamModelID uint +} + +type platformRouteReplacementPlan struct { + input repository.ReplaceChannelPlatformRoutesInput + sourceKey platformRouteBindingKey + targetKey platformRouteBindingKey + candidates []model.LLMPlatformModelRoute +} + +func loadPlatformRouteReplacementPlans( + tx *gorm.DB, + inputs []repository.ReplaceChannelPlatformRoutesInput, +) ([]platformRouteReplacementPlan, error) { + normalizedInputs := make([]repository.ReplaceChannelPlatformRoutesInput, len(inputs)) + targetBindings := make(map[platformRouteBindingKey]struct{}, len(inputs)) + selectedRouteSet := make(map[uint]struct{}) + selectedRouteIDs := make([]uint, 0) + + for index, rawInput := range inputs { + input, targetKey, err := normalizePlatformRouteReplacement(rawInput) + if err != nil { + return nil, err + } + if _, exists := targetBindings[targetKey]; exists { + return nil, repository.ErrInvalidInput + } + targetBindings[targetKey] = struct{}{} + for _, routeID := range input.ExistingRouteIDs { + if _, exists := selectedRouteSet[routeID]; exists { + return nil, repository.ErrInvalidInput + } + selectedRouteSet[routeID] = struct{}{} + selectedRouteIDs = append(selectedRouteIDs, routeID) + } + normalizedInputs[index] = input + } + + selectedRows := make([]model.LLMPlatformModelRoute, 0, len(selectedRouteIDs)) + if len(selectedRouteIDs) > 0 { + // 此处仅解析来源归属;先锁父模型、再锁完整路由集合,避免并发写入时形成 route -> model / model -> route 的交叉锁顺序。 + if err := tx.Where("id IN ?", selectedRouteIDs). + Find(&selectedRows).Error; err != nil { + return nil, err + } + if len(selectedRows) != len(selectedRouteIDs) { + return nil, repository.ErrConflict + } + } + selectedByID := make(map[uint]model.LLMPlatformModelRoute, len(selectedRows)) + for _, row := range selectedRows { + selectedByID[row.ID] = row + } + + plans := make([]platformRouteReplacementPlan, len(normalizedInputs)) + platformModelIDs := make(map[uint]struct{}) + upstreamModelIDs := make(map[uint]struct{}) + expectedUpstreamIDs := make(map[uint]uint) + for index, input := range normalizedInputs { + targetKey := platformRouteBindingKey{ + platformModelID: input.Routes[0].PlatformModelID, + upstreamModelID: input.Routes[0].UpstreamModelID, + } + sourceKey := targetKey + if len(input.ExistingRouteIDs) > 0 { + first := selectedByID[input.ExistingRouteIDs[0]] + sourceKey = platformRouteBindingKey{platformModelID: first.PlatformModelID, upstreamModelID: first.UpstreamModelID} + for _, routeID := range input.ExistingRouteIDs[1:] { + row := selectedByID[routeID] + if row.PlatformModelID != sourceKey.platformModelID || row.UpstreamModelID != sourceKey.upstreamModelID { + return nil, repository.ErrConflict + } + } + } + plans[index] = platformRouteReplacementPlan{input: input, sourceKey: sourceKey, targetKey: targetKey} + platformModelIDs[sourceKey.platformModelID] = struct{}{} + platformModelIDs[targetKey.platformModelID] = struct{}{} + upstreamModelIDs[sourceKey.upstreamModelID] = struct{}{} + upstreamModelIDs[targetKey.upstreamModelID] = struct{}{} + if expected, exists := expectedUpstreamIDs[sourceKey.upstreamModelID]; exists && expected != input.UpstreamID { + return nil, repository.ErrInvalidInput + } + expectedUpstreamIDs[sourceKey.upstreamModelID] = input.UpstreamID + if expected, exists := expectedUpstreamIDs[targetKey.upstreamModelID]; exists && expected != input.UpstreamID { + return nil, repository.ErrInvalidInput + } + expectedUpstreamIDs[targetKey.upstreamModelID] = input.UpstreamID + } + + platformIDs := uintSetValues(platformModelIDs) + lockedModels := make([]model.LLMPlatformModel, 0, len(platformIDs)) + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id"). + Where("id IN ?", platformIDs). + Find(&lockedModels).Error; err != nil { + return nil, err + } + if len(lockedModels) != len(platformIDs) { + return nil, ErrModelNotFound + } + + upstreamModelIDList := uintSetValues(upstreamModelIDs) + lockedUpstreamModels := make([]model.LLMUpstreamModel, 0, len(upstreamModelIDList)) + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "upstream_id"). + Where("id IN ?", upstreamModelIDList). + Find(&lockedUpstreamModels).Error; err != nil { + return nil, err + } + if len(lockedUpstreamModels) != len(upstreamModelIDList) { + return nil, ErrUpstreamModelNotFound + } + for _, upstreamModel := range lockedUpstreamModels { + if upstreamModel.UpstreamID != expectedUpstreamIDs[upstreamModel.ID] { + return nil, ErrUpstreamModelNotFound + } + } + + allRows := make([]model.LLMPlatformModelRoute, 0, len(selectedRows)) + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("platform_model_id IN ? AND upstream_model_id IN ?", platformIDs, upstreamModelIDList). + Order("id ASC"). + Find(&allRows).Error; err != nil { + return nil, err + } + rowsByBinding := make(map[platformRouteBindingKey][]model.LLMPlatformModelRoute) + for _, row := range allRows { + key := platformRouteBindingKey{platformModelID: row.PlatformModelID, upstreamModelID: row.UpstreamModelID} + rowsByBinding[key] = append(rowsByBinding[key], row) + } + + bindingOwners := make(map[platformRouteBindingKey]int, len(plans)*2) + for index := range plans { + plan := &plans[index] + if owner, exists := bindingOwners[plan.sourceKey]; exists && owner != index { + return nil, repository.ErrInvalidInput + } + bindingOwners[plan.sourceKey] = index + if owner, exists := bindingOwners[plan.targetKey]; exists && owner != index { + return nil, repository.ErrInvalidInput + } + bindingOwners[plan.targetKey] = index + + sourceRows := rowsByBinding[plan.sourceKey] + if len(plan.input.ExistingRouteIDs) > 0 && !samePlatformRouteIDs(sourceRows, plan.input.ExistingRouteIDs) { + return nil, repository.ErrConflict + } + targetRows := rowsByBinding[plan.targetKey] + if len(plan.input.ExistingRouteIDs) > 0 && plan.sourceKey != plan.targetKey && len(targetRows) > 0 { + return nil, repository.ErrDuplicate + } + + candidateRows := make(map[uint]model.LLMPlatformModelRoute, len(sourceRows)+len(targetRows)) + for _, row := range sourceRows { + candidateRows[row.ID] = row + } + for _, row := range targetRows { + candidateRows[row.ID] = row + } + plan.candidates = make([]model.LLMPlatformModelRoute, 0, len(candidateRows)) + for _, row := range candidateRows { + plan.candidates = append(plan.candidates, row) + } + sort.Slice(plan.candidates, func(i int, j int) bool { return plan.candidates[i].ID < plan.candidates[j].ID }) + } + return plans, nil +} + +func normalizePlatformRouteReplacement( + input repository.ReplaceChannelPlatformRoutesInput, +) (repository.ReplaceChannelPlatformRoutesInput, platformRouteBindingKey, error) { + if input.UpstreamID == 0 || len(input.Routes) == 0 { + return repository.ReplaceChannelPlatformRoutesInput{}, platformRouteBindingKey{}, repository.ErrInvalidInput + } + targetKey := platformRouteBindingKey{ + platformModelID: input.Routes[0].PlatformModelID, + upstreamModelID: input.Routes[0].UpstreamModelID, + } + if targetKey.platformModelID == 0 || targetKey.upstreamModelID == 0 { + return repository.ReplaceChannelPlatformRoutesInput{}, platformRouteBindingKey{}, repository.ErrInvalidInput + } + seenProtocols := make(map[string]struct{}, len(input.Routes)) + for _, route := range input.Routes { + if route.PlatformModelID != targetKey.platformModelID || route.UpstreamModelID != targetKey.upstreamModelID || strings.TrimSpace(route.Protocol) == "" { + return repository.ReplaceChannelPlatformRoutesInput{}, platformRouteBindingKey{}, repository.ErrInvalidInput + } + if _, exists := seenProtocols[route.Protocol]; exists { + return repository.ReplaceChannelPlatformRoutesInput{}, platformRouteBindingKey{}, repository.ErrInvalidInput + } + seenProtocols[route.Protocol] = struct{}{} + } + existingRouteIDs, ok := normalizePlatformRouteIDs(input.ExistingRouteIDs) + if !ok { + return repository.ReplaceChannelPlatformRoutesInput{}, platformRouteBindingKey{}, repository.ErrInvalidInput + } + input.ExistingRouteIDs = existingRouteIDs + return input, targetKey, nil +} + +func applyPlatformRouteReplacementPlans( + tx *gorm.DB, + plans []platformRouteReplacementPlan, +) ([]domainchannel.PlatformModelRoute, error) { + const batchSize = 200 + now := time.Now() + updatedRows := make([]model.LLMPlatformModelRoute, 0) + createdRows := make([]model.LLMPlatformModelRoute, 0) + createdResultIndexes := make([]int, 0) + staleIDs := make([]uint, 0) + replaced := make([]domainchannel.PlatformModelRoute, 0) + + for _, plan := range plans { + usedIDs := make(map[uint]struct{}, len(plan.input.Routes)) + for _, desired := range plan.input.Routes { + candidateIndex := selectPlatformRouteCandidate( + plan.candidates, + usedIDs, + plan.targetKey.platformModelID, + plan.targetKey.upstreamModelID, + desired.Protocol, + ) + if candidateIndex >= 0 { + candidate := plan.candidates[candidateIndex] + entity := toPlatformModelRouteModel(&desired) + entity.ControlPlaneModel = candidate.ControlPlaneModel + entity.UpdatedAt = now + updatedRows = append(updatedRows, entity) + desired.ID = candidate.ID + desired.CreatedAt = candidate.CreatedAt + desired.UpdatedAt = now + usedIDs[candidate.ID] = struct{}{} + } else { + createdRows = append(createdRows, toPlatformModelRouteModel(&desired)) + createdResultIndexes = append(createdResultIndexes, len(replaced)) + } + replaced = append(replaced, desired) + } + for _, candidate := range plan.candidates { + if _, used := usedIDs[candidate.ID]; !used { + staleIDs = append(staleIDs, candidate.ID) + } + } + } + + if len(updatedRows) > 0 { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns(platformRouteMutableColumns), + }).CreateInBatches(&updatedRows, batchSize).Error; err != nil { + return nil, err + } + } + if len(createdRows) > 0 { + if err := tx.CreateInBatches(&createdRows, batchSize).Error; err != nil { + return nil, err + } + for index, row := range createdRows { + replaced[createdResultIndexes[index]] = toPlatformModelRouteDomain(row) + } + } + if len(staleIDs) > 0 { + if err := tx.Unscoped().Where("id IN ?", staleIDs).Delete(&model.LLMPlatformModelRoute{}).Error; err != nil { + return nil, err + } + } + return replaced, nil +} + +var platformRouteMutableColumns = []string{ + "platform_model_id", + "upstream_model_id", + "protocol", + "status", + "priority", + "weight", + "source", + "cb_failure_threshold", + "cb_duration_min", + "cb_window_min", + "headers_json", + "updated_at", +} + +func uintSetValues(values map[uint]struct{}) []uint { + result := make([]uint, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Slice(result, func(i int, j int) bool { return result[i] < result[j] }) + return result +} + +func samePlatformRouteIDs(rows []model.LLMPlatformModelRoute, expected []uint) bool { + if len(rows) != len(expected) { + return false + } + seen := make(map[uint]struct{}, len(rows)) + for _, row := range rows { + seen[row.ID] = struct{}{} + } + for _, routeID := range expected { + if _, exists := seen[routeID]; !exists { + return false + } + } + return true +} + +func normalizePlatformRouteIDs(routeIDs []uint) ([]uint, bool) { + seen := make(map[uint]struct{}, len(routeIDs)) + result := make([]uint, 0, len(routeIDs)) + for _, routeID := range routeIDs { + if routeID == 0 { + return nil, false + } + if _, exists := seen[routeID]; exists { + continue + } + seen[routeID] = struct{}{} + result = append(result, routeID) + } + return result, true +} + +func selectPlatformRouteCandidate( + candidates []model.LLMPlatformModelRoute, + usedIDs map[uint]struct{}, + targetPlatformModelID uint, + targetUpstreamModelID uint, + protocol string, +) int { + for index, candidate := range candidates { + if _, used := usedIDs[candidate.ID]; used { + continue + } + if candidate.PlatformModelID == targetPlatformModelID && candidate.UpstreamModelID == targetUpstreamModelID && candidate.Protocol == protocol { + return index + } + } + for index, candidate := range candidates { + if _, used := usedIDs[candidate.ID]; used { + continue + } + if candidate.Protocol == protocol { + return index + } + } + for index, candidate := range candidates { + if _, used := usedIDs[candidate.ID]; !used { + return index + } + } + return -1 +} + // ListPlatformModelRoutesByPair 查询同一平台模型和同一上游真实模型之间的全部协议绑定。 func (r *Repo) ListPlatformModelRoutesByPair( ctx context.Context, @@ -1192,24 +1613,10 @@ func (r *Repo) ListModelUpstreamSources(ctx context.Context, platformModelName s var total int64 name := strings.TrimSpace(platformModelName) - query := r.db.WithContext(ctx). - Table("llm_model_routes AS r"). - Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). - Where("pm.name = ?", name) - if err := query.Count(&total).Error; err != nil { + if err := r.modelUpstreamSourcesBaseQuery(ctx, name).Count(&total).Error; err != nil { return nil, 0, translateError(err) } - if err := r.db.WithContext(ctx). - Table("llm_model_routes AS r"). - Select( - "r.*, um.upstream_id, u.name AS upstream_name, u.status AS upstream_status, u.base_url AS base_url, "+ - "um.binding_code, um.upstream_model_name, um.vendor AS upstream_model_vendor, um.icon AS upstream_model_icon, "+ - "um.kinds_json AS upstream_model_kinds_json, um.suggested_protocol, um.status AS upstream_model_status", - ). - Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). - Joins("JOIN llm_upstream_models um ON um.id = r.upstream_model_id"). - Joins("JOIN llm_upstreams u ON u.id = um.upstream_id"). - Where("pm.name = ?", name). + if err := r.modelUpstreamSourcesQuery(ctx, name). Order("r.priority ASC, r.id DESC"). Offset(offset). Limit(limit). @@ -1219,20 +1626,42 @@ func (r *Repo) ListModelUpstreamSources(ctx context.Context, platformModelName s return items, total, nil } -// GetModelUpstreamSourceByRouteID 按平台模型名和路由 ID 精确查询模型来源。 -func (r *Repo) GetModelUpstreamSourceByRouteID(ctx context.Context, platformModelName string, routeID uint) (*ModelSourceRow, error) { - var item ModelSourceRow - if err := r.db.WithContext(ctx). +// ListModelUpstreamSourcesForUpdate 锁定并返回平台模型的完整来源集合,用于原子批量更新。 +func (r *Repo) ListModelUpstreamSourcesForUpdate(ctx context.Context, platformModelName string) ([]ModelSourceRow, error) { + items := make([]ModelSourceRow, 0) + if err := r.modelUpstreamSourcesQuery(ctx, strings.TrimSpace(platformModelName)). + Clauses(clause.Locking{Strength: "UPDATE"}). + Order("r.id ASC"). + Scan(&items).Error; err != nil { + return nil, translateError(err) + } + return items, nil +} + +func (r *Repo) modelUpstreamSourcesBaseQuery(ctx context.Context, platformModelName string) *gorm.DB { + return r.db.WithContext(ctx). Table("llm_model_routes AS r"). + Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). + Where("pm.name = ?", platformModelName) +} + +func (r *Repo) modelUpstreamSourcesQuery(ctx context.Context, platformModelName string) *gorm.DB { + return r.modelUpstreamSourcesBaseQuery(ctx, platformModelName). Select( - "r.*, um.upstream_id, u.name AS upstream_name, u.status AS upstream_status, u.base_url AS base_url, "+ - "um.binding_code, um.upstream_model_name, um.vendor AS upstream_model_vendor, um.icon AS upstream_model_icon, "+ + "r.*, um.upstream_id, u.name AS upstream_name, u.status AS upstream_status, " + + "u.compatible AS upstream_compatible, u.protocol_defaults_json AS upstream_protocol_defaults_json, u.base_url AS base_url, " + + "um.binding_code, um.upstream_model_name, um.vendor AS upstream_model_vendor, um.icon AS upstream_model_icon, " + "um.kinds_json AS upstream_model_kinds_json, um.suggested_protocol, um.status AS upstream_model_status", ). - Joins("JOIN llm_platform_models pm ON pm.id = r.platform_model_id"). Joins("JOIN llm_upstream_models um ON um.id = r.upstream_model_id"). - Joins("JOIN llm_upstreams u ON u.id = um.upstream_id"). - Where("pm.name = ? AND r.id = ?", strings.TrimSpace(platformModelName), routeID). + Joins("JOIN llm_upstreams u ON u.id = um.upstream_id") +} + +// GetModelUpstreamSourceByRouteID 按平台模型名和路由 ID 精确查询模型来源。 +func (r *Repo) GetModelUpstreamSourceByRouteID(ctx context.Context, platformModelName string, routeID uint) (*ModelSourceRow, error) { + var item ModelSourceRow + if err := r.modelUpstreamSourcesQuery(ctx, strings.TrimSpace(platformModelName)). + Where("r.id = ?", routeID). Take(&item).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrUpstreamModelNotFound diff --git a/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go b/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go index 0b3f4c42..ee332d6b 100644 --- a/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go +++ b/backend/internal/infra/persistence/postgres/channel/repository_sqlite_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "reflect" "strconv" + "sync/atomic" "testing" "time" @@ -14,13 +16,19 @@ import ( "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/logger" ) func TestListModelsSQLiteUsesPortableRouteStats(t *testing.T) { db := openChannelSQLiteTestDB(t) ctx := context.Background() - activeUpstream := model.LLMUpstream{Name: "active-upstream", Status: "active"} + activeUpstream := model.LLMUpstream{ + Name: "active-upstream", + Status: "active", + Compatible: "openai", + ProtocolDefaultsJSON: `{"chat":"openai_responses"}`, + } inactiveUpstream := model.LLMUpstream{Name: "inactive-upstream", Status: "inactive"} if err := db.Create(&activeUpstream).Error; err != nil { t.Fatalf("create active upstream: %v", err) @@ -95,6 +103,85 @@ func TestListModelsSQLiteUsesPortableRouteStats(t *testing.T) { if !reflect.DeepEqual(codes, []string{"active-a", "active-b"}) { t.Fatalf("expected distinct active binding codes, got %v", codes) } + + sources, err := NewRepo(db).ListModelUpstreamSourcesForUpdate(ctx, platformModel.Name) + if err != nil { + t.Fatalf("ListModelUpstreamSourcesForUpdate() error = %v", err) + } + if len(sources) != len(routes) { + t.Fatalf("expected all %d model sources, got %d", len(routes), len(sources)) + } + if sources[0].UpstreamCompatible != "openai" || sources[0].UpstreamProtocolDefaultsJSON != `{"chat":"openai_responses"}` { + t.Fatalf("expected upstream protocol metadata, got %#v", sources[0]) + } +} + +func TestWithinTransactionSQLiteRollsBackAllChannelWrites(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + repo := NewRepo(db) + + err := repo.WithinTransaction(ctx, func(txRepo repository.ChannelRepository) error { + item := &domainchannel.PlatformModel{ + PlatformModelName: "rollback-model", + Vendor: "openai", + KindsJSON: `["chat"]`, + Status: "active", + } + if err := txRepo.CreateModel(ctx, item); err != nil { + return err + } + return repository.ErrConflict + }) + if !errors.Is(err, repository.ErrConflict) { + t.Fatalf("expected transaction conflict, got %v", err) + } + + var count int64 + if err := db.Model(&model.LLMPlatformModel{}).Where("name = ?", "rollback-model").Count(&count).Error; err != nil { + t.Fatalf("count rolled-back model: %v", err) + } + if count != 0 { + t.Fatalf("expected all channel writes to roll back, got %d model rows", count) + } +} + +func TestCreateUpstreamModelSQLiteDoesNotOverwriteExistingCatalogEntry(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + upstream := model.LLMUpstream{Name: "catalog-create-only", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + repo := NewRepo(db) + original := &domainchannel.UpstreamModel{ + UpstreamID: upstream.ID, BindingCode: "catalog-original", UpstreamModelName: "shared-model", + SuggestedProtocol: "openai_chat_completions", KindsJSON: `["chat"]`, Status: "active", Source: "sync", RawJSON: `{"source":"remote"}`, + } + if err := repo.CreateUpstreamModel(ctx, original); err != nil { + t.Fatalf("create upstream model: %v", err) + } + duplicate := &domainchannel.UpstreamModel{ + UpstreamID: upstream.ID, BindingCode: "catalog-duplicate", UpstreamModelName: original.UpstreamModelName, + SuggestedProtocol: "openai_image_generations", KindsJSON: `["image_gen"]`, Status: "inactive", Source: "manual", RawJSON: `{}`, + } + if err := repo.CreateUpstreamModel(ctx, duplicate); !errors.Is(err, repository.ErrDuplicate) { + t.Fatalf("expected duplicate catalog error, got %v", err) + } + + stored, err := repo.GetUpstreamModelByUpstreamName(ctx, upstream.ID, original.UpstreamModelName) + if err != nil { + t.Fatalf("load original upstream model: %v", err) + } + if stored.ID != original.ID || + stored.BindingCode != original.BindingCode || + stored.SuggestedProtocol != original.SuggestedProtocol || + stored.KindsJSON != original.KindsJSON || + stored.Status != original.Status || + stored.Source != original.Source || + stored.RawJSON != original.RawJSON { + t.Fatalf("expected existing catalog metadata to remain unchanged, got %#v", stored) + } } func TestModelPresentationSQLiteJoinsMetadataAndClearsDeletedGroup(t *testing.T) { @@ -581,6 +668,376 @@ func TestListUpstreamsSQLiteCountsOnlyRouteBindings(t *testing.T) { } } +func TestListUpstreamsSQLiteCountsMultiProtocolBindingOnce(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + + upstream := model.LLMUpstream{Name: "google", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: "imagen", + UpstreamModelName: "imagen", + Status: "active", + } + if err := db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model: %v", err) + } + platformModel := model.LLMPlatformModel{Name: "imagen", Vendor: "google", Status: "active", SortOrder: 1} + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model: %v", err) + } + routes := []model.LLMPlatformModelRoute{ + {PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "google_image_generation", Status: "active"}, + {PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "google_image_edit", Status: "active"}, + } + if err := db.Create(&routes).Error; err != nil { + t.Fatalf("create routes: %v", err) + } + + items, _, err := NewRepo(db).ListUpstreams(ctx, repository.ListChannelUpstreamsInput{Limit: 10}) + if err != nil { + t.Fatalf("ListUpstreams() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 upstream, got %d", len(items)) + } + if items[0].ModelsCount != 1 { + t.Fatalf("expected multi-protocol binding to count as 1 model, got %d", items[0].ModelsCount) + } + if items[0].ActiveModelsCount != 1 { + t.Fatalf("expected multi-protocol binding to count as 1 active model, got %d", items[0].ActiveModelsCount) + } +} + +func TestListUpstreamModelsSQLitePaginatesCompleteBindings(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + + upstream := model.LLMUpstream{Name: "google", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + for index := 0; index < 1000; index++ { + name := fmt.Sprintf("model-%04d", index) + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: name, + UpstreamModelName: name, + Status: "active", + } + if err := db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model %s: %v", name, err) + } + platformModel := model.LLMPlatformModel{Name: name, Vendor: "google", Status: "active", SortOrder: index + 1} + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model %s: %v", name, err) + } + protocols := []string{"gemini_generate_content"} + if index == 0 { + protocols = []string{"google_image_generation", "google_image_edit"} + } + for _, protocol := range protocols { + route := model.LLMPlatformModelRoute{ + PlatformModelID: platformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: protocol, + Status: "active", + } + if err := db.Create(&route).Error; err != nil { + t.Fatalf("create route %s/%s: %v", name, protocol, err) + } + } + } + + repo := NewRepo(db) + firstPage, total, err := repo.ListUpstreamModels(ctx, upstream.ID, repository.ListChannelUpstreamModelsInput{ + Limit: 25, + Sort: "upstream_asc", + }) + if err != nil { + t.Fatalf("ListUpstreamModels() first page error = %v", err) + } + if total != 1000 { + t.Fatalf("expected binding total 1000, got %d", total) + } + if len(firstPage) != 26 { + t.Fatalf("expected 25 bindings represented by 26 route rows, got %d rows", len(firstPage)) + } + firstBindingProtocols := make(map[string]struct{}) + bindingKeys := make(map[string]struct{}) + for _, item := range firstPage { + bindingKeys[upstreamModelBindingKey(item.UpstreamModel.ID, item.PlatformModelID)] = struct{}{} + if item.UpstreamModelName == "model-0000" { + firstBindingProtocols[item.Protocol] = struct{}{} + } + } + if len(bindingKeys) != 25 { + t.Fatalf("expected 25 complete bindings on first page, got %d", len(bindingKeys)) + } + if len(firstBindingProtocols) != 2 { + t.Fatalf("expected both protocols for model-00, got %v", firstBindingProtocols) + } + + secondPage, _, err := repo.ListUpstreamModels(ctx, upstream.ID, repository.ListChannelUpstreamModelsInput{ + Offset: 25, + Limit: 25, + Sort: "upstream_asc", + }) + if err != nil { + t.Fatalf("ListUpstreamModels() second page error = %v", err) + } + if len(secondPage) != 25 || secondPage[0].UpstreamModelName != "model-0025" { + t.Fatalf("expected second page to start with model-0025, got %#v", secondPage) + } + + filtered, filteredTotal, err := repo.ListUpstreamModels(ctx, upstream.ID, repository.ListChannelUpstreamModelsInput{ + Limit: 25, + Protocol: "google_image_generation", + }) + if err != nil { + t.Fatalf("ListUpstreamModels() filtered error = %v", err) + } + if filteredTotal != 1 || len(filtered) != 2 { + t.Fatalf("expected one complete two-route binding after filtering, total=%d rows=%d", filteredTotal, len(filtered)) + } + + fullPage, fullTotal, err := repo.ListUpstreamModels(ctx, upstream.ID, repository.ListChannelUpstreamModelsInput{ + Limit: 1000, + Sort: "upstream_asc", + }) + if err != nil { + t.Fatalf("ListUpstreamModels() 1000-binding page error = %v", err) + } + fullPageKeys := make(map[string]struct{}, 1000) + for _, item := range fullPage { + fullPageKeys[upstreamModelBindingKey(item.UpstreamModel.ID, item.PlatformModelID)] = struct{}{} + } + if fullTotal != 1000 || len(fullPageKeys) != 1000 || len(fullPage) != 1001 { + t.Fatalf("expected 1000 complete bindings represented by 1001 rows, total=%d bindings=%d rows=%d", fullTotal, len(fullPageKeys), len(fullPage)) + } +} + +func TestReplacePlatformModelRoutesSQLiteReplacesCompleteProtocolSet(t *testing.T) { + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + + upstream := model.LLMUpstream{Name: "openai", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + upstreamModel := model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: "image-model", + UpstreamModelName: "image-model", + Status: "active", + } + if err := db.Create(&upstreamModel).Error; err != nil { + t.Fatalf("create upstream model: %v", err) + } + platformModel := model.LLMPlatformModel{Name: "image-model", Vendor: "openai", Status: "active", SortOrder: 1} + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model: %v", err) + } + existing := []model.LLMPlatformModelRoute{ + {PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_generations", Status: "active", Priority: 1, Weight: 1}, + {PlatformModelID: platformModel.ID, UpstreamModelID: upstreamModel.ID, Protocol: "openai_image_edits", Status: "active", Priority: 1, Weight: 1}, + } + if err := db.Create(&existing).Error; err != nil { + t.Fatalf("create existing routes: %v", err) + } + + repo := NewRepo(db) + _, err := repo.ReplacePlatformModelRoutes(ctx, []repository.ReplaceChannelPlatformRoutesInput{{ + UpstreamID: upstream.ID, + ExistingRouteIDs: []uint{existing[0].ID}, + Routes: []domainchannel.PlatformModelRoute{{ + PlatformModelID: platformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: "openai_responses", + Status: "active", + Priority: 2, + Weight: 3, + }}, + }}) + if !errors.Is(err, repository.ErrConflict) { + t.Fatalf("expected incomplete route set to be rejected, got %v", err) + } + var unchangedCount int64 + if err := db.Model(&model.LLMPlatformModelRoute{}). + Where("platform_model_id = ? AND upstream_model_id = ?", platformModel.ID, upstreamModel.ID). + Count(&unchangedCount).Error; err != nil { + t.Fatalf("count unchanged routes: %v", err) + } + if unchangedCount != 2 { + t.Fatalf("expected rejected replacement to keep both routes, got %d", unchangedCount) + } + + replaced, err := repo.ReplacePlatformModelRoutes(ctx, []repository.ReplaceChannelPlatformRoutesInput{{ + UpstreamID: upstream.ID, + ExistingRouteIDs: []uint{existing[0].ID, existing[1].ID}, + Routes: []domainchannel.PlatformModelRoute{{ + PlatformModelID: platformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: "openai_responses", + Status: "active", + Priority: 2, + Weight: 3, + Source: "manual", + }}, + }}) + if err != nil { + t.Fatalf("ReplacePlatformModelRoutes() error = %v", err) + } + if len(replaced) != 1 || replaced[0].Protocol != "openai_responses" { + t.Fatalf("unexpected replacement result: %#v", replaced) + } + if replaced[0].ID != existing[0].ID && replaced[0].ID != existing[1].ID { + t.Fatalf("expected replacement to preserve an existing route ID, got %d", replaced[0].ID) + } + var stored []model.LLMPlatformModelRoute + if err := db.Where("platform_model_id = ? AND upstream_model_id = ?", platformModel.ID, upstreamModel.ID). + Find(&stored).Error; err != nil { + t.Fatalf("load replaced routes: %v", err) + } + if len(stored) != 1 || stored[0].Protocol != "openai_responses" || stored[0].Priority != 2 || stored[0].Weight != 3 { + t.Fatalf("unexpected stored routes: %#v", stored) + } + + targetPlatformModel := model.LLMPlatformModel{Name: "existing-target", Vendor: "openai", Status: "active", SortOrder: 2} + if err := db.Create(&targetPlatformModel).Error; err != nil { + t.Fatalf("create target platform model: %v", err) + } + targetRoute := model.LLMPlatformModelRoute{ + PlatformModelID: targetPlatformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: "openai_responses", + Status: "active", + Priority: 9, + Weight: 9, + } + if err := db.Create(&targetRoute).Error; err != nil { + t.Fatalf("create target route: %v", err) + } + _, err = repo.ReplacePlatformModelRoutes(ctx, []repository.ReplaceChannelPlatformRoutesInput{{ + UpstreamID: upstream.ID, + ExistingRouteIDs: []uint{stored[0].ID}, + Routes: []domainchannel.PlatformModelRoute{{ + PlatformModelID: targetPlatformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: "openai_responses", + Status: "active", + }}, + }}) + if !errors.Is(err, repository.ErrDuplicate) { + t.Fatalf("expected occupied target binding to be rejected, got %v", err) + } + var preserved []model.LLMPlatformModelRoute + if err := db.Where("id IN ?", []uint{stored[0].ID, targetRoute.ID}).Order("id ASC").Find(&preserved).Error; err != nil { + t.Fatalf("load preserved routes: %v", err) + } + if len(preserved) != 2 || preserved[0].PlatformModelID == preserved[1].PlatformModelID { + t.Fatalf("expected source and target bindings to remain unchanged, got %#v", preserved) + } +} + +func TestReplacePlatformModelRoutesSQLiteBatchesLargeModelUpdates(t *testing.T) { + const bindingCount = 1001 + db := openChannelSQLiteTestDB(t) + ctx := context.Background() + + upstream := model.LLMUpstream{Name: "large-upstream", Compatible: "openai", Status: "active"} + if err := db.Create(&upstream).Error; err != nil { + t.Fatalf("create upstream: %v", err) + } + platformModel := model.LLMPlatformModel{Name: "large-platform-model", Vendor: "openai", KindsJSON: `["chat"]`, Status: "active"} + if err := db.Create(&platformModel).Error; err != nil { + t.Fatalf("create platform model: %v", err) + } + + upstreamModels := make([]model.LLMUpstreamModel, 0, bindingCount) + for index := 0; index < bindingCount; index++ { + name := fmt.Sprintf("large-upstream-model-%04d", index) + upstreamModels = append(upstreamModels, model.LLMUpstreamModel{ + UpstreamID: upstream.ID, + BindingCode: name, + UpstreamModelName: name, + SuggestedProtocol: "openai_chat_completions", + KindsJSON: `["chat"]`, + Status: "active", + }) + } + if err := db.CreateInBatches(&upstreamModels, 200).Error; err != nil { + t.Fatalf("create upstream models: %v", err) + } + + routes := make([]model.LLMPlatformModelRoute, 0, bindingCount) + for _, upstreamModel := range upstreamModels { + routes = append(routes, model.LLMPlatformModelRoute{ + PlatformModelID: platformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: "openai_chat_completions", + Status: "active", + Priority: 1, + Weight: 1, + }) + } + if err := db.CreateInBatches(&routes, 200).Error; err != nil { + t.Fatalf("create routes: %v", err) + } + + inputs := make([]repository.ReplaceChannelPlatformRoutesInput, 0, bindingCount) + for index, upstreamModel := range upstreamModels { + inputs = append(inputs, repository.ReplaceChannelPlatformRoutesInput{ + UpstreamID: upstream.ID, + ExistingRouteIDs: []uint{routes[index].ID}, + Routes: []domainchannel.PlatformModelRoute{{ + PlatformModelID: platformModel.ID, + UpstreamModelID: upstreamModel.ID, + Protocol: "openai_responses", + Status: "active", + Priority: 1, + Weight: 1, + }}, + }) + } + + counter := &sqlStatementCounter{Interface: db.Logger} + countedDB := db.Session(&gorm.Session{Logger: counter}) + replaced, err := NewRepo(countedDB).ReplacePlatformModelRoutes(ctx, inputs) + if err != nil { + t.Fatalf("ReplacePlatformModelRoutes() error = %v", err) + } + if len(replaced) != bindingCount { + t.Fatalf("expected %d replaced routes, got %d", bindingCount, len(replaced)) + } + if statements := counter.count.Load(); statements > 20 { + t.Fatalf("expected batched replacement to use at most 20 SQL statements, got %d", statements) + } + + var updatedCount int64 + if err := db.Model(&model.LLMPlatformModelRoute{}). + Where("platform_model_id = ? AND protocol = ?", platformModel.ID, "openai_responses"). + Count(&updatedCount).Error; err != nil { + t.Fatalf("count updated routes: %v", err) + } + if updatedCount != bindingCount { + t.Fatalf("expected %d updated routes, got %d", bindingCount, updatedCount) + } +} + +type sqlStatementCounter struct { + logger.Interface + count atomic.Int64 +} + +func (counter *sqlStatementCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + counter.count.Add(1) + counter.Interface.Trace(ctx, begin, fc, err) +} + func TestPermissionGroupDynamicModelRulesMatchCurrentModels(t *testing.T) { db := openChannelSQLiteTestDB(t) ctx := context.Background() diff --git a/backend/internal/repository/channel.go b/backend/internal/repository/channel.go index 9083135d..b800ad9f 100644 --- a/backend/internal/repository/channel.go +++ b/backend/internal/repository/channel.go @@ -2,7 +2,6 @@ package repository import ( "context" - "time" domainchannel "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/channel" ) @@ -197,17 +196,19 @@ type ChannelUpstreamModelListRow struct { // ChannelModelSourceRow 定义模型来源列表查询结果。 type ChannelModelSourceRow struct { domainchannel.PlatformModelRoute - UpstreamID uint - UpstreamName string - UpstreamStatus string - BaseURL string - BindingCode string - UpstreamModelName string - UpstreamModelVendor string - UpstreamModelIcon string - UpstreamModelKindsJSON string - SuggestedProtocol string - UpstreamModelStatus string + UpstreamID uint + UpstreamName string + UpstreamStatus string + UpstreamCompatible string + UpstreamProtocolDefaultsJSON string + BaseURL string + BindingCode string + UpstreamModelName string + UpstreamModelVendor string + UpstreamModelIcon string + UpstreamModelKindsJSON string + SuggestedProtocol string + UpstreamModelStatus string } // ListChannelUpstreamModelsInput 定义上游模型路由绑定列表查询条件。 @@ -283,17 +284,6 @@ type UpdateChannelUpstreamInput struct { HeadersJSON *string } -// UpdateChannelUpstreamModelInput 定义上游真实模型更新字段。 -type UpdateChannelUpstreamModelInput struct { - UpstreamModelName *string - Status *string - Source *string - SuggestedProtocol *string - KindsJSON *string - LastSyncedAt **time.Time - RawJSON *string -} - // UpdateChannelPlatformRouteInput 定义平台模型路由绑定更新字段。 type UpdateChannelPlatformRouteInput struct { PlatformModelID *uint @@ -309,6 +299,13 @@ type UpdateChannelPlatformRouteInput struct { HeadersJSON *string } +// ReplaceChannelPlatformRoutesInput 定义一个绑定的完整目标路由集合。 +type ReplaceChannelPlatformRoutesInput struct { + UpstreamID uint + ExistingRouteIDs []uint + Routes []domainchannel.PlatformModelRoute +} + // IsZero 判断是否没有任何上游配置更新字段。 func (input UpdateChannelUpstreamInput) IsZero() bool { return input.Name == nil && @@ -328,17 +325,6 @@ func (input UpdateChannelUpstreamInput) IsZero() bool { input.HeadersJSON == nil } -// IsZero 判断是否没有任何路由绑定更新字段。 -func (input UpdateChannelUpstreamModelInput) IsZero() bool { - return input.UpstreamModelName == nil && - input.Status == nil && - input.Source == nil && - input.SuggestedProtocol == nil && - input.KindsJSON == nil && - input.LastSyncedAt == nil && - input.RawJSON == nil -} - func (input UpdateChannelPlatformRouteInput) IsZero() bool { return input.PlatformModelID == nil && input.UpstreamModelID == nil && @@ -387,6 +373,7 @@ type ModelPresentationRepository interface { // ChannelRepository 定义渠道管理依赖的仓储能力。 type ChannelRepository interface { + WithinTransaction(ctx context.Context, fn func(ChannelRepository) error) error CreateUpstream(ctx context.Context, item *domainchannel.Upstream) error UpdateUpstream(ctx context.Context, upstreamID uint, input UpdateChannelUpstreamInput) error GetUpstreamByID(ctx context.Context, upstreamID uint) (*domainchannel.Upstream, error) @@ -400,10 +387,10 @@ type ChannelRepository interface { GetModelByName(ctx context.Context, platformModelName string) (*domainchannel.PlatformModel, error) GetActiveModelByName(ctx context.Context, platformModelName string) (*domainchannel.PlatformModel, error) ListModels(ctx context.Context, input ListChannelModelsInput) ([]ChannelModelListRow, int64, error) + CreateUpstreamModel(ctx context.Context, item *domainchannel.UpstreamModel) error UpsertUpstreamModel(ctx context.Context, item *domainchannel.UpstreamModel) error GetUpstreamModelByID(ctx context.Context, sourceID uint, upstreamID uint) (*domainchannel.UpstreamModel, error) GetUpstreamModelByUpstreamName(ctx context.Context, upstreamID uint, upstreamModelName string) (*domainchannel.UpstreamModel, error) - UpdateUpstreamModelByID(ctx context.Context, sourceID uint, upstreamID uint, input UpdateChannelUpstreamModelInput) error DeleteUpstreamModel(ctx context.Context, sourceID uint, upstreamID uint) error MarkMissingSyncedUpstreamModelsInactive(ctx context.Context, upstreamID uint, activeNames []string) (int64, error) ListUpstreamModels(ctx context.Context, upstreamID uint, input ListChannelUpstreamModelsInput) ([]ChannelUpstreamModelListRow, int64, error) @@ -411,12 +398,14 @@ type ChannelRepository interface { GetUpstreamModelRouteByID(ctx context.Context, upstreamID uint, routeID uint) (*ChannelUpstreamModelListRow, error) GetUpstreamModelRouteByNames(ctx context.Context, upstreamID uint, platformModelName string, upstreamModelName string, protocol string) (*ChannelUpstreamModelListRow, error) UpsertPlatformModelRoute(ctx context.Context, item *domainchannel.PlatformModelRoute) error + ReplacePlatformModelRoutes(ctx context.Context, inputs []ReplaceChannelPlatformRoutesInput) ([]domainchannel.PlatformModelRoute, error) GetModelUpstreamSourceByRouteID(ctx context.Context, platformModelName string, routeID uint) (*ChannelModelSourceRow, error) ListPlatformModelRoutesByPair(ctx context.Context, upstreamID uint, platformModelID uint, upstreamModelID uint) ([]domainchannel.PlatformModelRoute, error) GetPlatformModelRouteByID(ctx context.Context, routeID uint, upstreamID uint) (*domainchannel.PlatformModelRoute, error) UpdatePlatformModelRouteByID(ctx context.Context, routeID uint, upstreamID uint, input UpdateChannelPlatformRouteInput) error DeletePlatformModelRoute(ctx context.Context, routeID uint, upstreamID uint) error ListModelUpstreamSources(ctx context.Context, platformModelName string, offset int, limit int) ([]ChannelModelSourceRow, int64, error) + ListModelUpstreamSourcesForUpdate(ctx context.Context, platformModelName string) ([]ChannelModelSourceRow, error) ListActiveRoutesByModel(ctx context.Context, platformModelName string) ([]ChannelUpstreamRouteRow, error) ListActiveRouteBindingCodesForUpstream(ctx context.Context, upstreamID uint) ([]string, error) GetLLMSetting(ctx context.Context, key string) (*domainchannel.LLMSetting, error) diff --git a/backend/internal/transport/http/channel/dto_request.go b/backend/internal/transport/http/channel/dto_request.go index 0103a4cd..391b80e0 100644 --- a/backend/internal/transport/http/channel/dto_request.go +++ b/backend/internal/transport/http/channel/dto_request.go @@ -81,6 +81,12 @@ type UpdateModelRequest struct { CbWindowMin *int `json:"cbWindowMin,omitempty" binding:"omitempty,gte=0"` } +// SetModelProtocolsRequest 原子替换平台模型全部来源的协议集合。 +type SetModelProtocolsRequest struct { + Protocols []string `json:"protocols" binding:"required,min=1,max=2,unique,dive,min=1,max=64"` + KindsJSON string `json:"kindsJSON" binding:"required,min=2,max=1000"` +} + // CreateModelVendorRequest 创建技术厂商请求。 type CreateModelVendorRequest struct { Key string `json:"key" binding:"required,max=64"` @@ -121,19 +127,21 @@ type ReorderModelsRequest struct { // UpsertUpstreamModelRequest 上游模型路由绑定请求。 type UpsertUpstreamModelRequest struct { - RouteID uint `json:"routeID,omitempty"` - PlatformModelName string `json:"platformModelName" binding:"required,min=2,max=128"` - UpstreamModelName string `json:"upstreamModelName" binding:"required,min=1,max=128"` - Protocol string `json:"protocol,omitempty" binding:"omitempty,max=64"` - KindsJSON string `json:"kindsJSON,omitempty" binding:"omitempty,max=1000"` - Status string `json:"status,omitempty" binding:"omitempty,oneof=active inactive"` - Priority int `json:"priority,omitempty"` - Weight int `json:"weight,omitempty"` - Source string `json:"source,omitempty" binding:"omitempty,max=64"` - CbFailureThreshold int `json:"cbFailureThreshold,omitempty"` - CbDurationMin int `json:"cbDurationMin,omitempty"` - CbWindowMin int `json:"cbWindowMin,omitempty"` - HeadersJSON string `json:"headersJSON,omitempty" binding:"max=10000"` + RouteIDs []uint `json:"routeIDs,omitempty" binding:"omitempty,max=2,unique,dive,gt=0"` + PlatformModelName string `json:"platformModelName" binding:"required,min=2,max=128"` + UpstreamModelName string `json:"upstreamModelName" binding:"required,min=1,max=128"` + // Protocols 为空数组时根据模型能力和上游默认配置自动推断完整协议集合。 + Protocols *[]string `json:"protocols" binding:"required,max=2,unique,dive,min=1,max=64"` + KindsJSON string `json:"kindsJSON,omitempty" binding:"omitempty,max=1000"` + // 路由配置字段省略时保留已有协议各自的配置;新增协议使用服务端默认值或现有绑定模板。 + Status *string `json:"status,omitempty" binding:"omitempty,oneof=active inactive"` + Priority *int `json:"priority,omitempty" binding:"omitempty,gt=0"` + Weight *int `json:"weight,omitempty" binding:"omitempty,gt=0"` + Source *string `json:"source,omitempty" binding:"omitempty,max=64"` + CbFailureThreshold *int `json:"cbFailureThreshold,omitempty" binding:"omitempty,gte=0"` + CbDurationMin *int `json:"cbDurationMin,omitempty" binding:"omitempty,gte=0"` + CbWindowMin *int `json:"cbWindowMin,omitempty" binding:"omitempty,gte=0"` + HeadersJSON *string `json:"headersJSON,omitempty" binding:"omitempty,max=10000"` } // UpdateModelUpstreamSourceRequest 更新模型上游来源请求。 diff --git a/backend/internal/transport/http/channel/dto_request_test.go b/backend/internal/transport/http/channel/dto_request_test.go new file mode 100644 index 00000000..410bc133 --- /dev/null +++ b/backend/internal/transport/http/channel/dto_request_test.go @@ -0,0 +1,70 @@ +package channel + +import ( + "testing" + + "github.com/gin-gonic/gin/binding" +) + +func TestUpsertUpstreamModelRequestRequiresCompleteProtocolSet(t *testing.T) { + emptyProtocols := []string{} + validProtocols := []string{"openai_responses"} + duplicateProtocols := []string{"openai_responses", "openai_responses"} + tooManyProtocols := []string{"openai_responses", "openai_image_generations", "openai_image_edits"} + blankProtocol := []string{""} + + tests := []struct { + name string + protocols *[]string + wantError bool + }{ + {name: "missing", protocols: nil, wantError: true}, + {name: "explicit defaults", protocols: &emptyProtocols}, + {name: "explicit protocol", protocols: &validProtocols}, + {name: "duplicate", protocols: &duplicateProtocols, wantError: true}, + {name: "too many", protocols: &tooManyProtocols, wantError: true}, + {name: "blank", protocols: &blankProtocol, wantError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := UpsertUpstreamModelRequest{ + PlatformModelName: "test-model", + UpstreamModelName: "test-model", + Protocols: test.protocols, + } + err := binding.Validator.ValidateStruct(request) + if test.wantError && err == nil { + t.Fatal("expected validation error") + } + if !test.wantError && err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + }) + } +} + +func TestSetModelProtocolsRequestRequiresExplicitNonEmptyProtocolSet(t *testing.T) { + tests := []struct { + name string + request SetModelProtocolsRequest + wantError bool + }{ + {name: "valid", request: SetModelProtocolsRequest{Protocols: []string{"openai_responses"}, KindsJSON: `["chat"]`}}, + {name: "missing protocols", request: SetModelProtocolsRequest{KindsJSON: `["chat"]`}, wantError: true}, + {name: "duplicate protocols", request: SetModelProtocolsRequest{Protocols: []string{"openai_responses", "openai_responses"}, KindsJSON: `["chat"]`}, wantError: true}, + {name: "missing kinds", request: SetModelProtocolsRequest{Protocols: []string{"openai_responses"}}, wantError: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := binding.Validator.ValidateStruct(test.request) + if test.wantError && err == nil { + t.Fatal("expected validation error") + } + if !test.wantError && err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + }) + } +} diff --git a/backend/internal/transport/http/channel/dto_response.go b/backend/internal/transport/http/channel/dto_response.go index 15f945c2..9c5195b1 100644 --- a/backend/internal/transport/http/channel/dto_response.go +++ b/backend/internal/transport/http/channel/dto_response.go @@ -721,6 +721,12 @@ type UpdateModelResponseDoc struct { Data ModelDataResponse `json:"data"` } +// SetModelProtocolsResponseDoc 平台模型协议集合更新响应文档。 +type SetModelProtocolsResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data ModelDataResponse `json:"data"` +} + // UpsertUpstreamModelResponseDoc 上游模型路由绑定响应文档。 type UpsertUpstreamModelResponseDoc struct { ErrorMsg string `json:"errorMsg"` diff --git a/backend/internal/transport/http/channel/handler.go b/backend/internal/transport/http/channel/handler.go index 3e673cc5..debd1f44 100644 --- a/backend/internal/transport/http/channel/handler.go +++ b/backend/internal/transport/http/channel/handler.go @@ -439,10 +439,10 @@ func (h *Handler) UpsertUpstreamModel(c *gin.Context) { } item, err := h.service.UpsertUpstreamModel(c.Request.Context(), upstreamID, appchannel.UpsertUpstreamModelInput{ - RouteID: req.RouteID, + RouteIDs: req.RouteIDs, PlatformModelName: req.PlatformModelName, UpstreamModelName: req.UpstreamModelName, - Protocol: req.Protocol, + Protocols: *req.Protocols, KindsJSON: req.KindsJSON, Status: req.Status, Priority: req.Priority, @@ -461,6 +461,8 @@ func (h *Handler) UpsertUpstreamModel(c *gin.Context) { response.Error(c, http.StatusNotFound, "model not found") case errors.Is(err, appchannel.ErrUpstreamModelConflict): response.Error(c, http.StatusConflict, "target model already bound on this upstream") + case errors.Is(err, appchannel.ErrUpstreamModelBindingChanged): + response.ErrorWithCode(c, http.StatusConflict, "llm.upstream_model_binding_changed", "upstream model binding changed; reload and retry") case errors.Is(err, appchannel.ErrInvalidJSONConfig): response.Error(c, http.StatusBadRequest, "invalid json config") case errors.Is(err, appchannel.ErrInvalidAdapter): @@ -1081,6 +1083,64 @@ func (h *Handler) UpdateModel(c *gin.Context) { response.Success(c, ModelDataResponse{Model: toModelResponse(*item)}) } +// SetModelProtocols godoc +// @Summary 管理员替换模型全部来源的协议集合 +// @Description 在单个数据库事务中更新平台模型能力类型,并将该模型全部上游绑定替换为指定的完整协议集合 +// @Tags llm +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "模型ID" +// @Param body body SetModelProtocolsRequest true "完整协议集合与模型能力类型" +// @Success 200 {object} SetModelProtocolsResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/llm/models/{id}/protocols [patch] +func (h *Handler) SetModelProtocols(c *gin.Context) { + modelID, err := uintParam(c, "id") + if err != nil { + response.Error(c, http.StatusBadRequest, "invalid model id") + return + } + + var req SetModelProtocolsRequest + if err = c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + + item, err := h.service.SetModelProtocols(c.Request.Context(), modelID, appchannel.SetModelProtocolsInput{ + Protocols: req.Protocols, + KindsJSON: req.KindsJSON, + }) + if err != nil { + switch { + case errors.Is(err, appchannel.ErrModelNotFound): + response.Error(c, http.StatusNotFound, "model not found") + case errors.Is(err, appchannel.ErrUpstreamModelNotFound): + response.Error(c, http.StatusNotFound, "model upstream sources not found") + case errors.Is(err, appchannel.ErrUpstreamModelConflict): + response.ErrorWithCode(c, http.StatusConflict, "llm.upstream_model_conflict", "model upstream source conflict") + case errors.Is(err, appchannel.ErrUpstreamModelBindingChanged): + response.ErrorWithCode(c, http.StatusConflict, "llm.upstream_model_binding_changed", "upstream model binding changed; reload and retry") + case errors.Is(err, appchannel.ErrInvalidAdapter): + response.Error(c, http.StatusBadRequest, "invalid adapter") + case errors.Is(err, appchannel.ErrInvalidRouteProtocolCombination): + response.Error(c, http.StatusBadRequest, "invalid route protocol combination") + case errors.Is(err, appchannel.ErrInvalidKinds): + response.Error(c, http.StatusBadRequest, "invalid kinds") + case errors.Is(err, appchannel.ErrProtocolRequired): + response.Error(c, http.StatusBadRequest, "protocol required") + default: + response.Error(c, http.StatusInternalServerError, "set model protocols failed") + } + return + } + response.Success(c, ModelDataResponse{Model: toModelResponse(*item)}) +} + // ReorderModels godoc // @Summary 管理员调整模型顺序 // @Description 管理员调整平台模型在用户侧模型选择器中的展示顺序 diff --git a/backend/internal/transport/http/channel/router.go b/backend/internal/transport/http/channel/router.go index dec84c64..d16cfebc 100644 --- a/backend/internal/transport/http/channel/router.go +++ b/backend/internal/transport/http/channel/router.go @@ -41,6 +41,7 @@ func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { adminGroup.POST("/llm/models/batch-delete", m.Handler.BatchDeleteModels) adminGroup.PATCH("/llm/models/display-group", m.Handler.SetModelsDisplayGroup) adminGroup.PATCH("/llm/models/:id", m.Handler.UpdateModel) + adminGroup.PATCH("/llm/models/:id/protocols", m.Handler.SetModelProtocols) adminGroup.DELETE("/llm/models/:id", m.Handler.DeleteModel) adminGroup.POST("/llm/models/:id/test", m.Handler.TestModel) adminGroup.POST("/llm/models/:id/test-all", m.Handler.TestModelAll) diff --git a/frontend/components/ui/use-table-viewport-height.ts b/frontend/components/ui/use-table-viewport-height.ts index 240bd781..bef87199 100644 --- a/frontend/components/ui/use-table-viewport-height.ts +++ b/frontend/components/ui/use-table-viewport-height.ts @@ -44,10 +44,15 @@ export function useTableViewportHeight({ const maxHeight = Number.parseFloat(getComputedStyle(viewportElement).maxHeight) const contentHeight = contentElement.scrollHeight + // Reserve non-overlay horizontal scrollbar space to avoid false vertical overflow. + const horizontalScrollbarHeight = viewportElement.scrollWidth > viewportElement.clientWidth + ? Math.max(0, viewportElement.offsetHeight - viewportElement.clientHeight) + : 0 + const requiredHeight = contentHeight + horizontalScrollbarHeight const nextHeight = Math.ceil( Number.isFinite(maxHeight) - ? Math.min(contentHeight, maxHeight) - : contentHeight + ? Math.min(requiredHeight, maxHeight) + : requiredHeight ) setHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight) diff --git a/frontend/features/admin/api/llm.ts b/frontend/features/admin/api/llm.ts index c44b7fd6..13485e15 100644 --- a/frontend/features/admin/api/llm.ts +++ b/frontend/features/admin/api/llm.ts @@ -28,6 +28,7 @@ import type { ImportAdminLLMUpstreamModelsRequest, ListAdminLLMRemoteModelsData, ReorderAdminLLMModelsRequest, + SetAdminLLMModelProtocolsRequest, SetAdminLLMModelsDisplayGroupRequest, ResetAdminLLMCircuitData, UpdateAdminLLMModelRequest, @@ -374,6 +375,18 @@ export async function updateAdminLLMModel( ); } +export async function setAdminLLMModelProtocols( + accessToken: string, + modelID: number, + payload: SetAdminLLMModelProtocolsRequest, +): Promise { + return authedRequest( + `/api/v1/admin/llm/models/${modelID}/protocols`, + { method: "PATCH", accessToken, body: payload }, + true, + ); +} + export async function reorderAdminLLMModels( accessToken: string, payload: ReorderAdminLLMModelsRequest, diff --git a/frontend/features/admin/api/llm.types.ts b/frontend/features/admin/api/llm.types.ts index 29472b8a..1f05df1e 100644 --- a/frontend/features/admin/api/llm.types.ts +++ b/frontend/features/admin/api/llm.types.ts @@ -22,6 +22,7 @@ import type { ModelUpstreamSourceDataResponse, ModelUpstreamSourceResponse, ReorderModelsRequest, + SetModelProtocolsRequest, SetModelsDisplayGroupRequest, UpdateModelDisplayGroupRequest, UpdateModelRequest, @@ -227,9 +228,12 @@ export type UpdateAdminLLMModelVendorRequest = UpdateModelVendorRequest; export type CreateAdminLLMModelDisplayGroupRequest = CreateModelDisplayGroupRequest; export type UpdateAdminLLMModelDisplayGroupRequest = UpdateModelDisplayGroupRequest; export type SetAdminLLMModelsDisplayGroupRequest = SetModelsDisplayGroupRequest; +export type SetAdminLLMModelProtocolsRequest = Omit & { + protocols: AdminLLMAdapter[]; +}; -export type UpsertAdminLLMUpstreamModelRequest = Omit & { - protocol?: AdminLLMAdapter; +export type UpsertAdminLLMUpstreamModelRequest = Omit & { + protocols: AdminLLMAdapter[]; status?: AdminLLMStatus; }; diff --git a/frontend/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx b/frontend/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx index 2bfe9733..b36f96ff 100644 --- a/frontend/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx +++ b/frontend/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx @@ -332,10 +332,12 @@ type ModelRowProps = { isSelected: boolean; upstreamInactive: boolean; onSelect: (draftKey: string, checked: boolean) => void; - onUpdate: (draftKey: string, patch: Partial>) => void; + onUpdate: (draftKey: string, patch: RowDraftPatch) => void; onTest: (row: RowDraft, routeID: number) => void; }; +type RowDraftPatch = Partial>; + const ModelRow = React.memo(function ModelRow({ row, isSelected, upstreamInactive, onSelect, onUpdate, onTest }: ModelRowProps) { const t = useTranslations("adminUpstreams"); const modelT = useTranslations("adminModels"); @@ -847,18 +849,13 @@ function NewBindingDialog({ const payload: UpsertAdminLLMUpstreamModelRequest = { upstreamModelName: form.upstreamModelName.trim(), platformModelName: form.platformModelName.trim(), + protocols: form.protocols, kindsJSON: displayToKindsJson(form.kindsDisplay), status: form.status, priority: 1, weight: 1, }; - const protocols = form.protocols.length > 0 ? form.protocols : [undefined]; - for (const protocol of protocols) { - await upsertAdminLLMUpstreamModel(token, upstreamId, { - ...payload, - protocol, - }); - } + await upsertAdminLLMUpstreamModel(token, upstreamId, payload); toast.success(t("modelsDialog.bindingCreated")); setForm(DEFAULT_NEW_BINDING); onOpenChange(false); @@ -978,7 +975,7 @@ type RouteListParams = { }; type BulkPatchConfirm = { - patch: Partial>; + patch: RowDraftPatch; }; const DEFAULT_ROUTE_LIST_PARAMS: RouteListParams = { @@ -1234,21 +1231,33 @@ export function UpstreamModelsDialog({ const updateRow = React.useCallback(( draftKey: string, - patch: Partial>, + patch: RowDraftPatch, ) => { setRows((prev) => prev.map((r) => - r.draftKey === draftKey ? { ...r, ...patch, isDirty: true } : r, + r.draftKey === draftKey + ? { + ...r, + ...patch, + isDirty: true, + routeStatusOverridden: r.routeStatusOverridden || patch.routeStatus !== undefined, + } + : r, ), ); }, []); - const applyBulkPatch = React.useCallback((patch: Partial>) => { + const applyBulkPatch = React.useCallback((patch: RowDraftPatch) => { if (selected.size === 0) return; setRows((prev) => prev.map((row) => routeIDsForRow(row).length > 0 && selected.has(row.draftKey) - ? { ...row, ...patch, isDirty: true } + ? { + ...row, + ...patch, + isDirty: true, + routeStatusOverridden: row.routeStatusOverridden || patch.routeStatus !== undefined, + } : row, ), ); @@ -1346,58 +1355,21 @@ export function UpstreamModelsDialog({ continue; } - const basePayload: UpsertAdminLLMUpstreamModelRequest = { + const basePayload: Omit = { platformModelName, upstreamModelName: row.upstreamModelName.trim(), kindsJSON: displayToKindsJson(row.kindsDisplay), - status: row.routeStatus || "active", - priority: row.priority || 1, - weight: row.weight || 1, + ...(row.routeStatusOverridden ? { status: row.routeStatus || "active" } : {}), }; const desiredProtocols = selectedProtocolsForSave(row); - if (desiredProtocols.length === 0) { - const keepRouteID = row.routeID || existingRouteIDs[0]; - upsertOperations.push(() => - upsertAdminLLMUpstreamModel(token, upstream.id, { - ...basePayload, - routeID: keepRouteID || undefined, - }), - ); - savedCount += 1; - for (const routeID of existingRouteIDs) { - if (routeID === keepRouteID) continue; - deleteOperations.push(() => deleteAdminLLMUpstreamModel(token, upstream.id, routeID)); - deletedCount += 1; - } - continue; - } - - const desiredSet = new Set(desiredProtocols); - const reusableRouteIDs = Object.entries(row.routeIDsByProtocol) - .filter(([protocol]) => !desiredSet.has(protocol as AdminLLMAdapter)) - .map(([, routeID]) => routeID) - .filter((routeID) => routeID > 0); - const reusedRouteIDs = new Set(); - for (const protocol of desiredProtocols) { - const existingRouteID = row.routeIDsByProtocol[protocol]; - const routeID = existingRouteID || reusableRouteIDs.shift(); - if (routeID) { - reusedRouteIDs.add(routeID); - } - upsertOperations.push(() => - upsertAdminLLMUpstreamModel(token, upstream.id, { - ...basePayload, - routeID, - protocol, - }), - ); - savedCount += 1; - } - for (const [protocol, routeID] of Object.entries(row.routeIDsByProtocol)) { - if (desiredSet.has(protocol as AdminLLMAdapter) || reusedRouteIDs.has(routeID)) continue; - deleteOperations.push(() => deleteAdminLLMUpstreamModel(token, upstream.id, routeID)); - deletedCount += 1; - } + upsertOperations.push(() => + upsertAdminLLMUpstreamModel(token, upstream.id, { + ...basePayload, + routeIDs: existingRouteIDs, + protocols: desiredProtocols, + }), + ); + savedCount += 1; } if (deleteOperations.length === 0 && upsertOperations.length === 0) { @@ -1441,7 +1413,7 @@ export function UpstreamModelsDialog({ <> {t("modelsDialog.manageTitle")} @@ -1587,12 +1559,13 @@ export function UpstreamModelsDialog({ -
+
diff --git a/frontend/features/admin/hooks/use-admin-models.ts b/frontend/features/admin/hooks/use-admin-models.ts index 5126b6e1..80ad2565 100644 --- a/frontend/features/admin/hooks/use-admin-models.ts +++ b/frontend/features/admin/hooks/use-admin-models.ts @@ -4,10 +4,9 @@ import { toast } from "sonner"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; import { - listAdminLLMModelUpstreamSources, listAdminLLMModels, + setAdminLLMModelProtocols, setAdminLLMModelsDisplayGroup, - upsertAdminLLMUpstreamModel, updateAdminLLMModel, } from "@/features/admin/api"; import type { @@ -471,79 +470,22 @@ export function useAdminModels(): UseAdminModelsState { return; } - const token = await resolveAccessToken(); - if (!token) { - toast.error(t("sessionExpired"), { description: t("signInAgain") }); - return; - } - - const rollbackModels = targets.map((item) => items.find((current) => current.id === item.id) ?? item); - const targetIDs = new Set(targets.map((item) => item.id)); const nextProtocolsJSON = JSON.stringify([nextProtocol]); const nextKindsJSON = displayToKindsJson(resolveKindsDisplayForProtocols([nextProtocol])); - setBatchApplying(true); - setItems((current) => - current.map((item) => (targetIDs.has(item.id) ? { ...item, protocolsJSON: nextProtocolsJSON, kindsJSON: nextKindsJSON } : item)), - ); - try { - const results = await runSettledBulkItems({ - items: targets, - title: t("bulkProtocolUpdated", { count: targets.length }), - runItem: async (model) => { - const sources = await listAdminLLMModelUpstreamSources(token, model.id, { page: 1, pageSize: 2000 }); - if (sources.results.length === 0) { - throw new Error("model upstream sources not found"); - } - for (const source of sources.results) { - await upsertAdminLLMUpstreamModel(token, source.upstreamID, { - routeID: source.id, - platformModelName: model.platformModelName, - upstreamModelName: source.upstreamModelName, - protocol: nextProtocol, - kindsJSON: nextKindsJSON, - status: source.status, - priority: source.priority, - weight: source.weight, - }); - } - return { ...model, kindsJSON: nextKindsJSON, protocolsJSON: nextProtocolsJSON }; - }, - }); - const failedModels = results.filter((result) => result.status === "rejected").map((result) => result.item); - const successModels = results.filter((result) => result.status === "fulfilled").map((result) => result.item); - const successResponses = results - .filter((result): result is Extract => result.status === "fulfilled") - .map((result) => result.value); - setItems((current) => - successResponses.reduce((next, model) => replaceByID(next, model.id, (item) => item.id, model), current), - ); - if (failedModels.length > 0) { - const failedIDs = new Set(failedModels.map((item) => item.id)); - setItems((current) => - rollbackModels.reduce( - (next, model) => (failedIDs.has(model.id) ? replaceByID(next, model.id, (item) => item.id, model) : next), - current, - ), - ); - setSelectedModelIDs(new Set(failedModels.map((item) => item.id))); - toast.error(t("bulkProtocolPartialFailed"), { - description: t("bulkPartialDescription", { success: successModels.length, failed: failedModels.length }), - }); - return; - } - - toast.success(t("bulkProtocolUpdated", { count: targets.length })); - setSelectedModelIDs(new Set()); - setBatchProtocol(""); - } catch (error) { - setItems((current) => - rollbackModels.reduce((next, model) => replaceByID(next, model.id, (item) => item.id, model), current), - ); - toast.error(t("bulkProtocolFailed"), { description: resolveAdminErrorMessage(error) }); - } finally { - setBatchApplying(false); - } - }, [batchApplying, batchProtocol, items, selectedModels, t]); + await runBulkModelUpdates({ + targets, + optimisticPatch: (item) => ({ ...item, protocolsJSON: nextProtocolsJSON, kindsJSON: nextKindsJSON }), + successMessage: t("bulkProtocolUpdated", { count: targets.length }), + partialFailureMessage: t("bulkProtocolPartialFailed"), + failureMessage: t("bulkProtocolFailed"), + runItem: (token, item) => + setAdminLLMModelProtocols(token, item.id, { + protocols: [nextProtocol], + kindsJSON: nextKindsJSON, + }), + onSuccess: () => setBatchProtocol(""), + }); + }, [batchApplying, batchProtocol, runBulkModelUpdates, selectedModels, t]); const handleRequestBulkDelete = React.useCallback(() => { if (selectedModels.length === 0) { diff --git a/frontend/features/admin/model/upstreams-models.ts b/frontend/features/admin/model/upstreams-models.ts index dccf048e..a3340df8 100644 --- a/frontend/features/admin/model/upstreams-models.ts +++ b/frontend/features/admin/model/upstreams-models.ts @@ -11,6 +11,7 @@ import { parseKindsJSON, stringifyKinds } from "@/shared/model/llm-schema"; export type RowDraft = AdminLLMUpstreamModelDTO & { draftKey: string; isDirty: boolean; + routeStatusOverridden: boolean; kindsDisplay: string; platformModelNameDraft: string; protocols: AdminLLMAdapter[]; @@ -75,7 +76,8 @@ export function buildRowDrafts(items: AdminLLMUpstreamModelDTO[]): RowDraft[] { draftKey, platformModelNameDraft: platformModelName, isDirty: false, - kindsDisplay: kindsJsonToDisplay(item.upstreamModelKindsJSON || item.modelKindsJSON), + routeStatusOverridden: false, + kindsDisplay: kindsJsonToDisplay(item.modelKindsJSON || item.upstreamModelKindsJSON), protocols: item.protocol ? [item.protocol] : [], routeIDsByProtocol: item.protocol && item.routeID > 0 ? { [item.protocol]: item.routeID } : {}, }); diff --git a/packages/api-contract/src/types.generated.ts b/packages/api-contract/src/types.generated.ts index 210fbda8..8879ac18 100644 --- a/packages/api-contract/src/types.generated.ts +++ b/packages/api-contract/src/types.generated.ts @@ -2845,6 +2845,25 @@ export interface SetModelPermissionGroupsRequest { groupIDs?: number[]; } +export interface SetModelProtocolsRequest { + /** + * @minLength 2 + * @maxLength 1000 + */ + kindsJSON: string; + /** + * @maxItems 2 + * @minItems 1 + * @uniqueItems true + */ + protocols: string[]; +} + +export interface SetModelProtocolsResponseDoc { + data: ModelDataResponse; + errorMsg: string; +} + export interface SetModelsDisplayGroupRequest { displayGroupID: number; /** @@ -3370,8 +3389,11 @@ export interface UpsertModelPricingRequest { } export interface UpsertUpstreamModelRequest { + /** @min 0 */ cbDurationMin?: number; + /** @min 0 */ cbFailureThreshold?: number; + /** @min 0 */ cbWindowMin?: number; /** @maxLength 10000 */ headersJSON?: string; @@ -3383,11 +3405,20 @@ export interface UpsertUpstreamModelRequest { */ platformModelName: string; priority?: number; - /** @maxLength 64 */ - protocol?: string; - routeID?: number; + /** + * Protocols 为空数组时根据模型能力和上游默认配置自动推断完整协议集合。 + * @maxItems 2 + * @uniqueItems true + */ + protocols: string[]; + /** + * @maxItems 2 + * @uniqueItems true + */ + routeIDs?: number[]; /** @maxLength 64 */ source?: string; + /** 路由配置字段省略时保留已有协议各自的配置;新增协议使用服务端默认值或现有绑定模板。 */ status?: "active" | "inactive"; /** * @minLength 1 @@ -4839,6 +4870,25 @@ export namespace Admin { export type ResponseBody = UpdateModelResponseDoc; } + /** + * @description 在单个数据库事务中更新平台模型能力类型,并将该模型全部上游绑定替换为指定的完整协议集合 + * @tags llm + * @name LlmModelsProtocolsPartialUpdate + * @summary 管理员替换模型全部来源的协议集合 + * @request PATCH:/admin/llm/models/{id}/protocols + * @secure + */ + export namespace LlmModelsProtocolsPartialUpdate { + export type RequestParams = { + /** 模型ID */ + id: number; + }; + export type RequestQuery = {}; + export type RequestBody = SetModelProtocolsRequest; + export type RequestHeaders = {}; + export type ResponseBody = SetModelProtocolsResponseDoc; + } + /** * @description 管理员分页查询指定模型在各上游上的路由来源 * @tags llm