From d59756a27c6337463759653709100d9ffdb817fd Mon Sep 17 00:00:00 2001 From: wangbo Date: Sun, 10 May 2026 22:34:15 +0800 Subject: [PATCH] chore: commit pending gateway changes --- .../internal/httpapi/access_rule_handlers.go | 139 + apps/api/internal/httpapi/catalog_handlers.go | 31 +- apps/api/internal/httpapi/handlers.go | 145 +- .../httpapi/identity_admin_handlers.go | 223 + apps/api/internal/httpapi/pricing_handlers.go | 5 + .../httpapi/runtime_policy_handlers.go | 91 + apps/api/internal/httpapi/server.go | 28 +- apps/api/internal/httpapi/streaming.go | 41 +- apps/api/internal/store/access_rules.go | 515 ++ apps/api/internal/store/base_models.go | 319 +- apps/api/internal/store/candidates.go | 35 +- apps/api/internal/store/catalog_providers.go | 30 +- apps/api/internal/store/identity_admin.go | 399 ++ apps/api/internal/store/platform_models.go | 128 +- apps/api/internal/store/pricing_rules.go | 7 + apps/api/internal/store/runtime_policies.go | 150 + apps/api/migrations/0001_init.sql | 3 + .../0002_invitation_relationship_only.sql | 1 + .../0009_provider_default_connection.sql | 50 + .../0010_base_model_pricing_rule_binding.sql | 28 + .../0011_platform_internal_name.sql | 2 + .../migrations/0012_runtime_policy_sets.sql | 51 + apps/api/migrations/0013_access_rules.sql | 25 + .../0014_base_model_catalog_type.sql | 73 + .../0015_base_model_model_type_array.sql | 41 + .../0016_api_key_recoverable_secret.sql | 2 + apps/api/project.json | 2 +- apps/web/package.json | 13 + apps/web/src/App.tsx | 580 +- apps/web/src/api.ts | 309 +- apps/web/src/app-state.ts | 4 + apps/web/src/components/Dashboard.tsx | 5 +- apps/web/src/components/ui/button.tsx | 8 +- apps/web/src/components/ui/checkbox.tsx | 24 + apps/web/src/components/ui/confirm-dialog.tsx | 62 + apps/web/src/components/ui/form-item.tsx | 17 + apps/web/src/components/ui/index.ts | 4 + apps/web/src/components/ui/input.tsx | 24 +- apps/web/src/components/ui/message.tsx | 47 + apps/web/src/components/ui/select.tsx | 24 +- apps/web/src/components/ui/textarea.tsx | 24 +- apps/web/src/hooks/useCatalogOperations.ts | 37 + .../hooks/useRuntimePolicySetOperations.ts | 47 + apps/web/src/main.tsx | 2 + apps/web/src/navigation.ts | 6 + apps/web/src/pages/AdminPage.tsx | 245 +- apps/web/src/pages/HomePage.tsx | 12 +- apps/web/src/pages/ModelsPage.tsx | 7 +- apps/web/src/pages/admin/AccessRulesPanel.tsx | 433 ++ .../pages/admin/BaseModelCapabilityEditor.tsx | 843 +++ .../src/pages/admin/BaseModelCatalogPanel.tsx | 411 +- .../pages/admin/IdentityManagementPanels.tsx | 775 +++ apps/web/src/pages/admin/ModelCatalogCard.tsx | 36 + .../pages/admin/PlatformManagementPanel.tsx | 1028 ++++ .../pages/admin/PricingRuleVisualEditor.tsx | 176 +- .../web/src/pages/admin/PricingRulesPanel.tsx | 35 +- .../pages/admin/ProviderManagementPanel.tsx | 40 +- .../src/pages/admin/RuntimePoliciesPanel.tsx | 385 ++ .../pages/admin/base-model-capabilities.ts | 497 ++ apps/web/src/pages/admin/platform-form.ts | 350 ++ apps/web/src/routing.ts | 25 +- apps/web/src/styles/admin-capabilities.css | 336 ++ apps/web/src/styles/api-docs.css | 10 +- apps/web/src/styles/landing.css | 12 +- apps/web/src/styles/pages.css | 952 +++- apps/web/src/styles/pricing.css | 156 +- apps/web/src/styles/ui.css | 404 +- apps/web/src/types.ts | 46 +- apps/web/vite.config.ts | 3 +- pnpm-lock.yaml | 4726 ++++++++++++++++- scripts/go-watch.mjs | 18 +- 71 files changed, 15106 insertions(+), 656 deletions(-) create mode 100644 apps/api/internal/httpapi/access_rule_handlers.go create mode 100644 apps/api/internal/httpapi/identity_admin_handlers.go create mode 100644 apps/api/internal/httpapi/runtime_policy_handlers.go create mode 100644 apps/api/internal/store/access_rules.go create mode 100644 apps/api/internal/store/identity_admin.go create mode 100644 apps/api/internal/store/runtime_policies.go create mode 100644 apps/api/migrations/0009_provider_default_connection.sql create mode 100644 apps/api/migrations/0010_base_model_pricing_rule_binding.sql create mode 100644 apps/api/migrations/0011_platform_internal_name.sql create mode 100644 apps/api/migrations/0012_runtime_policy_sets.sql create mode 100644 apps/api/migrations/0013_access_rules.sql create mode 100644 apps/api/migrations/0014_base_model_catalog_type.sql create mode 100644 apps/api/migrations/0015_base_model_model_type_array.sql create mode 100644 apps/api/migrations/0016_api_key_recoverable_secret.sql create mode 100644 apps/web/src/components/ui/checkbox.tsx create mode 100644 apps/web/src/components/ui/confirm-dialog.tsx create mode 100644 apps/web/src/components/ui/form-item.tsx create mode 100644 apps/web/src/components/ui/message.tsx create mode 100644 apps/web/src/hooks/useRuntimePolicySetOperations.ts create mode 100644 apps/web/src/pages/admin/AccessRulesPanel.tsx create mode 100644 apps/web/src/pages/admin/BaseModelCapabilityEditor.tsx create mode 100644 apps/web/src/pages/admin/IdentityManagementPanels.tsx create mode 100644 apps/web/src/pages/admin/ModelCatalogCard.tsx create mode 100644 apps/web/src/pages/admin/PlatformManagementPanel.tsx create mode 100644 apps/web/src/pages/admin/RuntimePoliciesPanel.tsx create mode 100644 apps/web/src/pages/admin/base-model-capabilities.ts create mode 100644 apps/web/src/pages/admin/platform-form.ts create mode 100644 apps/web/src/styles/admin-capabilities.css diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go new file mode 100644 index 0000000..4b9c38f --- /dev/null +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ListAccessRules(r.Context()) + if err != nil { + s.logger.Error("list access rules failed", "error", err) + writeError(w, http.StatusInternalServerError, "list access rules failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) createAccessRule(w http.ResponseWriter, r *http.Request) { + var input store.AccessRuleInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validAccessRuleInput(input) { + writeError(w, http.StatusBadRequest, "subject, resource and effect are required") + return + } + item, err := s.store.CreateAccessRule(r.Context(), input) + if err != nil { + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "access rule already exists") + return + } + s.logger.Error("create access rule failed", "error", err) + writeError(w, http.StatusInternalServerError, "create access rule failed") + return + } + writeJSON(w, http.StatusCreated, item) +} + +func (s *Server) batchAccessRules(w http.ResponseWriter, r *http.Request) { + var input store.AccessRuleBatchInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validAccessRuleBatchInput(input) { + writeError(w, http.StatusBadRequest, "subject, effect and resources are required") + return + } + items, err := s.store.BatchAccessRules(r.Context(), input) + if err != nil { + s.logger.Error("batch access rules failed", "error", err) + writeError(w, http.StatusInternalServerError, "batch access rules failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) updateAccessRule(w http.ResponseWriter, r *http.Request) { + var input store.AccessRuleInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validAccessRuleInput(input) { + writeError(w, http.StatusBadRequest, "subject, resource and effect are required") + return + } + item, err := s.store.UpdateAccessRule(r.Context(), r.PathValue("ruleID"), input) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "access rule not found") + return + } + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "access rule already exists") + return + } + s.logger.Error("update access rule failed", "error", err) + writeError(w, http.StatusInternalServerError, "update access rule failed") + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) deleteAccessRule(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteAccessRule(r.Context(), r.PathValue("ruleID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "access rule not found") + return + } + s.logger.Error("delete access rule failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete access rule failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func validAccessRuleInput(input store.AccessRuleInput) bool { + return validOneOf(input.SubjectType, "user_group", "tenant", "user", "api_key") && + strings.TrimSpace(input.SubjectID) != "" && + validOneOf(input.ResourceType, "platform", "platform_model", "base_model") && + strings.TrimSpace(input.ResourceID) != "" && + validOneOf(input.Effect, "allow", "deny") && + (input.Status == "" || validOneOf(input.Status, "active", "disabled")) +} + +func validAccessRuleBatchInput(input store.AccessRuleBatchInput) bool { + if !validOneOf(input.SubjectType, "user_group", "tenant", "user", "api_key") || + strings.TrimSpace(input.SubjectID) == "" || + !validOneOf(input.Effect, "allow", "deny") { + return false + } + if len(input.UpsertResources) == 0 && len(input.DeleteResources) == 0 { + return false + } + for _, resource := range append(input.UpsertResources, input.DeleteResources...) { + if !validOneOf(resource.ResourceType, "platform", "platform_model", "base_model") || + strings.TrimSpace(resource.ResourceID) == "" || + (resource.Status != "" && !validOneOf(resource.Status, "active", "disabled")) { + return false + } + } + return true +} + +func validOneOf(value string, allowed ...string) bool { + value = strings.TrimSpace(value) + for _, item := range allowed { + if value == item { + return true + } + } + return false +} diff --git a/apps/api/internal/httpapi/catalog_handlers.go b/apps/api/internal/httpapi/catalog_handlers.go index 533194f..db88c12 100644 --- a/apps/api/internal/httpapi/catalog_handlers.go +++ b/apps/api/internal/httpapi/catalog_handlers.go @@ -2,6 +2,7 @@ package httpapi import ( "encoding/json" + "errors" "net/http" "strings" @@ -141,6 +142,34 @@ func (s *Server) updateBaseModel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, item) } +func (s *Server) resetBaseModel(w http.ResponseWriter, r *http.Request) { + item, err := s.store.ResetBaseModelToDefault(r.Context(), r.PathValue("baseModelID")) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "base model not found") + return + } + if errors.Is(err, store.ErrProtectedDefault) { + writeError(w, http.StatusConflict, "base model has no system default snapshot") + return + } + s.logger.Error("reset base model failed", "error", err) + writeError(w, http.StatusInternalServerError, "reset base model failed") + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) resetAllBaseModels(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ResetAllBaseModelsToDefault(r.Context()) + if err != nil { + s.logger.Error("reset all base models failed", "error", err) + writeError(w, http.StatusInternalServerError, "reset all base models failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) { if err := s.store.DeleteBaseModel(r.Context(), r.PathValue("baseModelID")); err != nil { if store.IsNotFound(err) { @@ -157,5 +186,5 @@ func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) { func validBaseModelInput(input store.BaseModelInput) bool { return strings.TrimSpace(input.ProviderKey) != "" && strings.TrimSpace(input.ProviderModelName) != "" && - strings.TrimSpace(input.ModelType) != "" + len(input.ModelType) > 0 } diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index e6e5e4c..3b418ed 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -148,6 +148,9 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid json body") return } + input.Provider = strings.TrimSpace(input.Provider) + input.Name = strings.TrimSpace(input.Name) + input.InternalName = strings.TrimSpace(input.InternalName) if input.Provider == "" || input.Name == "" { writeError(w, http.StatusBadRequest, "provider and name are required") return @@ -164,6 +167,52 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, platform) } +func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) { + var input store.CreatePlatformInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + input.Provider = strings.TrimSpace(input.Provider) + input.Name = strings.TrimSpace(input.Name) + input.InternalName = strings.TrimSpace(input.InternalName) + if input.Provider == "" || input.Name == "" { + writeError(w, http.StatusBadRequest, "provider and name are required") + return + } + if input.AuthType == "" { + input.AuthType = "bearer" + } + platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "platform not found") + return + } + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "platform key already exists") + return + } + s.logger.Error("update platform failed", "error", err) + writeError(w, http.StatusInternalServerError, "update platform failed") + return + } + writeJSON(w, http.StatusOK, platform) +} + +func (s *Server) deletePlatform(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeletePlatform(r.Context(), r.PathValue("platformID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "platform not found") + return + } + s.logger.Error("delete platform failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete platform failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) { var input store.CreatePlatformModelInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { @@ -190,6 +239,47 @@ func (s *Server) createPlatformModel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, model) } +func (s *Server) replacePlatformModels(w http.ResponseWriter, r *http.Request) { + platformID := r.PathValue("platformID") + if platformID == "" { + writeError(w, http.StatusBadRequest, "platformId is required") + return + } + + var input struct { + Models []store.CreatePlatformModelInput `json:"models"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + + models, err := s.store.ReplacePlatformModels(r.Context(), platformID, input.Models) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "base model not found") + return + } + s.logger.Error("replace platform models failed", "error", err) + writeError(w, http.StatusInternalServerError, "replace platform models failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": models}) +} + +func (s *Server) deletePlatformModel(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeletePlatformModel(r.Context(), r.PathValue("modelID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "platform model not found") + return + } + s.logger.Error("delete platform model failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete platform model failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) listModels(w http.ResponseWriter, r *http.Request) { models, err := s.store.ListModels(r.Context()) if err != nil { @@ -200,6 +290,17 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": models}) } +func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + models, err := s.store.ListAccessiblePlatformModels(r.Context(), user) + if err != nil { + s.logger.Error("list playable models failed", "error", err) + writeError(w, http.StatusInternalServerError, "list playable models failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": models}) +} + func (s *Server) listPricingRules(w http.ResponseWriter, r *http.Request) { items, err := s.store.ListPricingRules(r.Context()) if err != nil { @@ -251,6 +352,21 @@ func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (s *Server) listPlayableAPIKeys(w http.ResponseWriter, r *http.Request) { + user, _ := auth.UserFromContext(r.Context()) + items, err := s.store.ListPlayableAPIKeys(r.Context(), user) + if err != nil { + if errors.Is(err, store.ErrLocalUserRequired) { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.logger.Error("list playable api keys failed", "error", err) + writeError(w, http.StatusInternalServerError, "list playable api keys failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) { user, _ := auth.UserFromContext(r.Context()) var input store.CreateAPIKeyInput @@ -359,19 +475,38 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler { writeError(w, http.StatusInternalServerError, "create task failed") return } - result, runErr := s.runner.Execute(r.Context(), task, user) if compatible { - if runErr != nil { - writeError(w, statusFromRunError(runErr), runErr.Error()) + if boolValue(body, "stream") { + flusher := prepareCompatibleStream(w) + result, runErr := s.runner.ExecuteStream(r.Context(), task, user, func(delta string) error { + writeCompatibleDelta(w, kind, model, delta) + if flusher != nil { + flusher.Flush() + } + return nil + }) + if runErr != nil { + sendSSE(w, "error", map[string]any{"error": map[string]any{"message": runErr.Error(), "status": statusFromRunError(runErr)}}) + if flusher != nil { + flusher.Flush() + } + return + } + writeCompatibleDone(w, kind, model, result.Output) + if flusher != nil { + flusher.Flush() + } return } - if boolValue(body, "stream") { - writeCompatibleStream(w, kind, model, result.Output) + result, runErr := s.runner.Execute(r.Context(), task, user) + if runErr != nil { + writeError(w, statusFromRunError(runErr), runErr.Error()) return } writeJSON(w, http.StatusOK, result.Output) return } + result, runErr := s.runner.Execute(r.Context(), task, user) if runErr != nil { s.logger.Warn("task completed with failure", "kind", kind, "taskId", task.ID, "error", runErr) } diff --git a/apps/api/internal/httpapi/identity_admin_handlers.go b/apps/api/internal/httpapi/identity_admin_handlers.go new file mode 100644 index 0000000..1c06d4a --- /dev/null +++ b/apps/api/internal/httpapi/identity_admin_handlers.go @@ -0,0 +1,223 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) { + var input store.GatewayTenantInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validTenantInput(input) { + writeError(w, http.StatusBadRequest, "tenantKey and name are required") + return + } + item, err := s.store.CreateTenant(r.Context(), input) + if err != nil { + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "tenant key or external tenant id already exists") + return + } + s.logger.Error("create tenant failed", "error", err) + writeError(w, http.StatusInternalServerError, "create tenant failed") + return + } + writeJSON(w, http.StatusCreated, item) +} + +func (s *Server) updateTenant(w http.ResponseWriter, r *http.Request) { + var input store.GatewayTenantInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validTenantInput(input) { + writeError(w, http.StatusBadRequest, "tenantKey and name are required") + return + } + item, err := s.store.UpdateTenant(r.Context(), r.PathValue("tenantID"), input) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "tenant not found") + return + } + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "tenant key or external tenant id already exists") + return + } + s.logger.Error("update tenant failed", "error", err) + writeError(w, http.StatusInternalServerError, "update tenant failed") + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) deleteTenant(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteTenant(r.Context(), r.PathValue("tenantID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "tenant not found") + return + } + s.logger.Error("delete tenant failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete tenant failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) createGatewayUser(w http.ResponseWriter, r *http.Request) { + var input store.GatewayUserInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validGatewayUserInput(input) { + writeError(w, http.StatusBadRequest, "username is required") + return + } + if !validOptionalPassword(input.Password) { + writeError(w, http.StatusBadRequest, store.ErrWeakPassword.Error()) + return + } + item, err := s.store.CreateGatewayUser(r.Context(), input) + if err != nil { + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "user key, email or external user id already exists") + return + } + s.logger.Error("create gateway user failed", "error", err) + writeError(w, http.StatusInternalServerError, "create gateway user failed") + return + } + writeJSON(w, http.StatusCreated, item) +} + +func (s *Server) updateGatewayUser(w http.ResponseWriter, r *http.Request) { + var input store.GatewayUserInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validGatewayUserInput(input) { + writeError(w, http.StatusBadRequest, "username is required") + return + } + if !validOptionalPassword(input.Password) { + writeError(w, http.StatusBadRequest, store.ErrWeakPassword.Error()) + return + } + item, err := s.store.UpdateGatewayUser(r.Context(), r.PathValue("userID"), input) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "user not found") + return + } + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "user key, email or external user id already exists") + return + } + s.logger.Error("update gateway user failed", "error", err) + writeError(w, http.StatusInternalServerError, "update gateway user failed") + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) deleteGatewayUser(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteGatewayUser(r.Context(), r.PathValue("userID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "user not found") + return + } + s.logger.Error("delete gateway user failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete gateway user failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) createUserGroup(w http.ResponseWriter, r *http.Request) { + var input store.UserGroupInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validUserGroupInput(input) { + writeError(w, http.StatusBadRequest, "groupKey and name are required") + return + } + item, err := s.store.CreateUserGroup(r.Context(), input) + if err != nil { + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "user group key already exists") + return + } + s.logger.Error("create user group failed", "error", err) + writeError(w, http.StatusInternalServerError, "create user group failed") + return + } + writeJSON(w, http.StatusCreated, item) +} + +func (s *Server) updateUserGroup(w http.ResponseWriter, r *http.Request) { + var input store.UserGroupInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validUserGroupInput(input) { + writeError(w, http.StatusBadRequest, "groupKey and name are required") + return + } + item, err := s.store.UpdateUserGroup(r.Context(), r.PathValue("groupID"), input) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "user group not found") + return + } + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "user group key already exists") + return + } + s.logger.Error("update user group failed", "error", err) + writeError(w, http.StatusInternalServerError, "update user group failed") + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) deleteUserGroup(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteUserGroup(r.Context(), r.PathValue("groupID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "user group not found") + return + } + s.logger.Error("delete user group failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete user group failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func validTenantInput(input store.GatewayTenantInput) bool { + return strings.TrimSpace(input.TenantKey) != "" && strings.TrimSpace(input.Name) != "" +} + +func validGatewayUserInput(input store.GatewayUserInput) bool { + return strings.TrimSpace(input.Username) != "" +} + +func validOptionalPassword(password string) bool { + password = strings.TrimSpace(password) + return password == "" || len(password) >= 8 +} + +func validUserGroupInput(input store.UserGroupInput) bool { + return strings.TrimSpace(input.GroupKey) != "" && strings.TrimSpace(input.Name) != "" +} diff --git a/apps/api/internal/httpapi/pricing_handlers.go b/apps/api/internal/httpapi/pricing_handlers.go index eae4ce0..8b6d298 100644 --- a/apps/api/internal/httpapi/pricing_handlers.go +++ b/apps/api/internal/httpapi/pricing_handlers.go @@ -2,6 +2,7 @@ package httpapi import ( "encoding/json" + "errors" "net/http" "strings" @@ -74,6 +75,10 @@ func (s *Server) deletePricingRuleSet(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "pricing rule set not found") return } + if errors.Is(err, store.ErrProtectedDefault) { + writeError(w, http.StatusForbidden, "default pricing rule set cannot be deleted") + return + } s.logger.Error("delete pricing rule set failed", "error", err) writeError(w, http.StatusInternalServerError, "delete pricing rule set failed") return diff --git a/apps/api/internal/httpapi/runtime_policy_handlers.go b/apps/api/internal/httpapi/runtime_policy_handlers.go new file mode 100644 index 0000000..716fd24 --- /dev/null +++ b/apps/api/internal/httpapi/runtime_policy_handlers.go @@ -0,0 +1,91 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func (s *Server) listRuntimePolicySets(w http.ResponseWriter, r *http.Request) { + items, err := s.store.ListRuntimePolicySets(r.Context()) + if err != nil { + s.logger.Error("list runtime policy sets failed", "error", err) + writeError(w, http.StatusInternalServerError, "list runtime policy sets failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) createRuntimePolicySet(w http.ResponseWriter, r *http.Request) { + var input store.RuntimePolicySetInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validRuntimePolicyInput(input) { + writeError(w, http.StatusBadRequest, "policyKey and name are required") + return + } + item, err := s.store.CreateRuntimePolicySet(r.Context(), input) + if err != nil { + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "runtime policy key already exists") + return + } + s.logger.Error("create runtime policy set failed", "error", err) + writeError(w, http.StatusInternalServerError, "create runtime policy set failed") + return + } + writeJSON(w, http.StatusCreated, item) +} + +func (s *Server) updateRuntimePolicySet(w http.ResponseWriter, r *http.Request) { + var input store.RuntimePolicySetInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + if !validRuntimePolicyInput(input) { + writeError(w, http.StatusBadRequest, "policyKey and name are required") + return + } + item, err := s.store.UpdateRuntimePolicySet(r.Context(), r.PathValue("policySetID"), input) + if err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "runtime policy set not found") + return + } + if store.IsUniqueViolation(err) { + writeError(w, http.StatusConflict, "runtime policy key already exists") + return + } + s.logger.Error("update runtime policy set failed", "error", err) + writeError(w, http.StatusInternalServerError, "update runtime policy set failed") + return + } + writeJSON(w, http.StatusOK, item) +} + +func (s *Server) deleteRuntimePolicySet(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteRuntimePolicySet(r.Context(), r.PathValue("policySetID")); err != nil { + if store.IsNotFound(err) { + writeError(w, http.StatusNotFound, "runtime policy set not found") + return + } + if errors.Is(err, store.ErrProtectedDefault) { + writeError(w, http.StatusForbidden, "default runtime policy set cannot be deleted") + return + } + s.logger.Error("delete runtime policy set failed", "error", err) + writeError(w, http.StatusInternalServerError, "delete runtime policy set failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func validRuntimePolicyInput(input store.RuntimePolicySetInput) bool { + return strings.TrimSpace(input.PolicyKey) != "" && strings.TrimSpace(input.Name) != "" +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 8bf6a88..60f3a2c 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -44,25 +44,51 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux.Handle("DELETE /api/v1/catalog/providers/{providerID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteCatalogProvider))) mux.Handle("GET /api/v1/catalog/base-models", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listBaseModels))) mux.Handle("POST /api/v1/catalog/base-models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createBaseModel))) + mux.Handle("POST /api/v1/catalog/base-models/reset-all", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.resetAllBaseModels))) mux.Handle("PATCH /api/v1/catalog/base-models/{baseModelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateBaseModel))) + mux.Handle("POST /api/v1/catalog/base-models/{baseModelID}/reset", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.resetBaseModel))) mux.Handle("DELETE /api/v1/catalog/base-models/{baseModelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteBaseModel))) mux.Handle("GET /api/v1/tenants", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listTenants))) + mux.Handle("POST /api/v1/tenants", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createTenant))) + mux.Handle("PATCH /api/v1/tenants/{tenantID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateTenant))) + mux.Handle("DELETE /api/v1/tenants/{tenantID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteTenant))) mux.Handle("GET /api/v1/users", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUsers))) + mux.Handle("POST /api/v1/users", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createGatewayUser))) + mux.Handle("PATCH /api/v1/users/{userID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateGatewayUser))) + mux.Handle("DELETE /api/v1/users/{userID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteGatewayUser))) mux.Handle("GET /api/v1/user-groups", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listUserGroups))) + mux.Handle("POST /api/v1/user-groups", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createUserGroup))) + mux.Handle("PATCH /api/v1/user-groups/{groupID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateUserGroup))) + mux.Handle("DELETE /api/v1/user-groups/{groupID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteUserGroup))) + mux.Handle("GET /api/v1/access-rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listAccessRules))) + mux.Handle("POST /api/v1/access-rules", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createAccessRule))) + mux.Handle("POST /api/v1/access-rules/batch", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules))) + mux.Handle("PATCH /api/v1/access-rules/{ruleID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule))) + mux.Handle("DELETE /api/v1/access-rules/{ruleID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule))) mux.Handle("GET /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys))) mux.Handle("POST /api/v1/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey))) mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey))) + mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys))) mux.Handle("GET /api/v1/pricing/rules", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRules))) mux.Handle("GET /api/v1/pricing/rule-sets", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets))) mux.Handle("POST /api/v1/pricing/rule-sets", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet))) mux.Handle("PATCH /api/v1/pricing/rule-sets/{ruleSetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updatePricingRuleSet))) mux.Handle("DELETE /api/v1/pricing/rule-sets/{ruleSetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePricingRuleSet))) mux.Handle("POST /api/v1/pricing/estimate", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.estimatePricing))) + mux.Handle("GET /api/v1/runtime/policy-sets", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRuntimePolicySets))) + mux.Handle("POST /api/v1/runtime/policy-sets", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet))) + mux.Handle("PATCH /api/v1/runtime/policy-sets/{policySetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet))) + mux.Handle("DELETE /api/v1/runtime/policy-sets/{policySetID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet))) mux.Handle("GET /api/v1/platforms", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listPlatforms))) mux.Handle("POST /api/v1/platforms", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatform))) + mux.Handle("PATCH /api/v1/platforms/{platformID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.updatePlatform))) + mux.Handle("DELETE /api/v1/platforms/{platformID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePlatform))) + mux.Handle("PUT /api/v1/platforms/{platformID}/models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.replacePlatformModels))) mux.Handle("POST /api/v1/platforms/{platformID}/models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) mux.Handle("POST /api/v1/platform-models", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.createPlatformModel))) + mux.Handle("DELETE /api/v1/platform-models/{modelID}", server.auth.Require(auth.PermissionManager, http.HandlerFunc(server.deletePlatformModel))) mux.Handle("GET /api/v1/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listModels))) + mux.Handle("GET /api/v1/playground/models", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels))) mux.Handle("GET /api/v1/runtime/rate-limit-windows", server.auth.Require(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows))) mux.Handle("POST /api/v1/chat/completions", server.auth.Require(auth.PermissionBasic, server.createTask("chat.completions", false))) mux.Handle("POST /api/v1/responses", server.auth.Require(auth.PermissionBasic, server.createTask("responses", false))) @@ -91,7 +117,7 @@ func (s *Server) cors(next http.Handler) http.Handler { w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/apps/api/internal/httpapi/streaming.go b/apps/api/internal/httpapi/streaming.go index 053bfda..310221e 100644 --- a/apps/api/internal/httpapi/streaming.go +++ b/apps/api/internal/httpapi/streaming.go @@ -2,10 +2,42 @@ package httpapi import "net/http" -func writeCompatibleStream(w http.ResponseWriter, kind string, model string, output map[string]any) { +func prepareCompatibleStream(w http.ResponseWriter) http.Flusher { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + flusher, _ := w.(http.Flusher) + return flusher +} + +func writeCompatibleDelta(w http.ResponseWriter, kind string, model string, content string) { + if kind == "responses" { + sendSSE(w, "response.output_text.delta", map[string]any{"type": "response.output_text.delta", "delta": content}) + return + } + sendSSE(w, "message", map[string]any{ + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "model": model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": content}, "finish_reason": nil}}, + }) +} + +func writeCompatibleDone(w http.ResponseWriter, kind string, model string, output map[string]any) { + if kind == "responses" { + sendSSE(w, "response.completed", map[string]any{"type": "response.completed", "response": output}) + return + } + sendSSE(w, "message", map[string]any{ + "id": firstString(output["id"], "chatcmpl-stream"), + "object": "chat.completion.chunk", + "model": model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}}, + }) +} + +func writeCompatibleStream(w http.ResponseWriter, kind string, model string, output map[string]any) { + prepareCompatibleStream(w) content := extractOutputText(output) if content == "" { content = "done" @@ -29,6 +61,13 @@ func writeCompatibleStream(w http.ResponseWriter, kind string, model string, out }) } +func firstString(value any, fallback string) string { + if text, ok := value.(string); ok && text != "" { + return text + } + return fallback +} + func extractOutputText(output map[string]any) string { if text, ok := output["output_text"].(string); ok { return text diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go new file mode 100644 index 0000000..d85eacc --- /dev/null +++ b/apps/api/internal/store/access_rules.go @@ -0,0 +1,515 @@ +package store + +import ( + "context" + "encoding/json" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/jackc/pgx/v5" +) + +type AccessRuleInput struct { + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + Effect string `json:"effect"` + Priority int `json:"priority"` + MinPermissionLevel int `json:"minPermissionLevel"` + Conditions map[string]any `json:"conditions"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` +} + +type AccessRuleResourceInput struct { + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + Priority int `json:"priority"` + MinPermissionLevel int `json:"minPermissionLevel"` + Conditions map[string]any `json:"conditions"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` +} + +type AccessRuleBatchInput struct { + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` + Effect string `json:"effect"` + UpsertResources []AccessRuleResourceInput `json:"upsertResources"` + DeleteResources []AccessRuleResourceInput `json:"deleteResources"` +} + +type accessRuleResource struct { + Type string + ID string +} + +func (s *Store) ListAccessRules(ctx context.Context) ([]AccessRule, error) { + rows, err := s.pool.Query(ctx, ` +SELECT `+accessRuleColumns+` +FROM gateway_access_rules +ORDER BY resource_type ASC, priority ASC, subject_type ASC, created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]AccessRule, 0) + for rows.Next() { + item, err := scanAccessRule(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) CreateAccessRule(ctx context.Context, input AccessRuleInput) (AccessRule, error) { + input = normalizeAccessRuleInput(input) + conditions, _ := json.Marshal(emptyObjectIfNil(input.Conditions)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanAccessRule(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_access_rules ( + subject_type, subject_id, resource_type, resource_id, effect, priority, + min_permission_level, conditions, metadata, status +) +VALUES ($1, $2::uuid, $3, $4::uuid, $5, $6, $7, $8::jsonb, $9::jsonb, $10) +RETURNING `+accessRuleColumns, + input.SubjectType, input.SubjectID, input.ResourceType, input.ResourceID, input.Effect, + input.Priority, input.MinPermissionLevel, string(conditions), string(metadata), input.Status, + )) +} + +func (s *Store) UpdateAccessRule(ctx context.Context, id string, input AccessRuleInput) (AccessRule, error) { + input = normalizeAccessRuleInput(input) + conditions, _ := json.Marshal(emptyObjectIfNil(input.Conditions)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanAccessRule(s.pool.QueryRow(ctx, ` +UPDATE gateway_access_rules +SET subject_type = $2, + subject_id = $3::uuid, + resource_type = $4, + resource_id = $5::uuid, + effect = $6, + priority = $7, + min_permission_level = $8, + conditions = $9::jsonb, + metadata = $10::jsonb, + status = $11, + updated_at = now() +WHERE id = $1::uuid +RETURNING `+accessRuleColumns, + id, input.SubjectType, input.SubjectID, input.ResourceType, input.ResourceID, input.Effect, + input.Priority, input.MinPermissionLevel, string(conditions), string(metadata), input.Status, + )) +} + +func (s *Store) DeleteAccessRule(ctx context.Context, id string) error { + result, err := s.pool.Exec(ctx, `DELETE FROM gateway_access_rules WHERE id = $1::uuid`, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func (s *Store) BatchAccessRules(ctx context.Context, input AccessRuleBatchInput) ([]AccessRule, error) { + input = normalizeAccessRuleBatchInput(input) + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + for _, resource := range dedupeAccessRuleResources(input.DeleteResources) { + resource = normalizeAccessRuleResource(resource, input.Effect) + if resource.ResourceType == "" || resource.ResourceID == "" { + continue + } + if _, err := tx.Exec(ctx, ` +DELETE FROM gateway_access_rules +WHERE subject_type = $1 + AND subject_id = $2::uuid + AND effect = $3 + AND resource_type = $4 + AND resource_id = $5::uuid`, + input.SubjectType, input.SubjectID, input.Effect, resource.ResourceType, resource.ResourceID, + ); err != nil { + return nil, err + } + } + + for _, resource := range dedupeAccessRuleResources(input.UpsertResources) { + resource = normalizeAccessRuleResource(resource, input.Effect) + if resource.ResourceType == "" || resource.ResourceID == "" { + continue + } + oppositeEffect := "deny" + if input.Effect == "deny" { + oppositeEffect = "allow" + } + if _, err := tx.Exec(ctx, ` +DELETE FROM gateway_access_rules +WHERE subject_type = $1 + AND subject_id = $2::uuid + AND effect = $3 + AND resource_type = $4 + AND resource_id = $5::uuid`, + input.SubjectType, input.SubjectID, oppositeEffect, resource.ResourceType, resource.ResourceID, + ); err != nil { + return nil, err + } + conditions, _ := json.Marshal(emptyObjectIfNil(resource.Conditions)) + metadata, _ := json.Marshal(emptyObjectIfNil(resource.Metadata)) + if _, err := tx.Exec(ctx, ` +INSERT INTO gateway_access_rules ( + subject_type, subject_id, resource_type, resource_id, effect, priority, + min_permission_level, conditions, metadata, status +) +VALUES ($1, $2::uuid, $3, $4::uuid, $5, $6, $7, $8::jsonb, $9::jsonb, $10) +ON CONFLICT (subject_type, subject_id, resource_type, resource_id, effect) +DO UPDATE SET priority = EXCLUDED.priority, + min_permission_level = EXCLUDED.min_permission_level, + conditions = EXCLUDED.conditions, + metadata = EXCLUDED.metadata, + status = EXCLUDED.status, + updated_at = now()`, + input.SubjectType, input.SubjectID, resource.ResourceType, resource.ResourceID, input.Effect, + resource.Priority, resource.MinPermissionLevel, string(conditions), string(metadata), resource.Status, + ); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return s.ListAccessRules(ctx) +} + +func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) { + if len(candidates) == 0 { + return candidates, nil + } + resources := candidateAccessResources(candidates) + if len(resources) == 0 { + return candidates, nil + } + rules, err := s.listActiveAccessRulesForResources(ctx, resources) + if err != nil { + return nil, err + } + if len(rules) == 0 { + return candidates, nil + } + subjects := accessRuleSubjects(user) + level := 0 + if user != nil { + level = auth.PermissionLevel(user.Roles) + } + filtered := candidates[:0] + for _, candidate := range candidates { + if candidateAllowedByAccessRules(candidate, rules, subjects, level) { + filtered = append(filtered, candidate) + } + } + return filtered, nil +} + +func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) { + models, err := s.ListModels(ctx) + if err != nil { + return nil, err + } + platforms, err := s.ListPlatforms(ctx) + if err != nil { + return nil, err + } + enabledPlatforms := map[string]bool{} + for _, platform := range platforms { + if platform.Status == "enabled" { + enabledPlatforms[platform.ID] = true + } + } + enabled := make([]PlatformModel, 0, len(models)) + for _, model := range models { + if model.Enabled && enabledPlatforms[model.PlatformID] { + enabled = append(enabled, model) + } + } + return s.filterPlatformModelsByAccessRules(ctx, user, enabled) +} + +func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { + if len(models) == 0 { + return models, nil + } + resources := platformModelAccessResources(models) + if len(resources) == 0 { + return models, nil + } + rules, err := s.listActiveAccessRulesForResources(ctx, resources) + if err != nil { + return nil, err + } + if len(rules) == 0 { + return models, nil + } + subjects := accessRuleSubjects(user) + level := 0 + if user != nil { + level = auth.PermissionLevel(user.Roles) + } + filtered := models[:0] + for _, model := range models { + if platformModelAllowedByAccessRules(model, rules, subjects, level) { + filtered = append(filtered, model) + } + } + return filtered, nil +} + +func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) { + values := make([]string, 0, len(resources)) + for _, resource := range resources { + if resource.Type == "" || resource.ID == "" { + continue + } + values = append(values, resource.Type+":"+resource.ID) + } + if len(values) == 0 { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` +SELECT `+accessRuleColumns+` +FROM gateway_access_rules +WHERE status = 'active' + AND (resource_type || ':' || resource_id::text) = ANY($1) +ORDER BY priority ASC, created_at ASC`, values) + if err != nil { + return nil, err + } + defer rows.Close() + rules := make([]AccessRule, 0) + for rows.Next() { + item, err := scanAccessRule(rows) + if err != nil { + return nil, err + } + rules = append(rules, item) + } + return rules, rows.Err() +} + +func candidateAccessResources(candidates []RuntimeModelCandidate) []accessRuleResource { + seen := map[string]bool{} + out := make([]accessRuleResource, 0, len(candidates)*3) + add := func(resourceType string, resourceID string) { + key := resourceType + ":" + resourceID + if resourceID == "" || seen[key] { + return + } + seen[key] = true + out = append(out, accessRuleResource{Type: resourceType, ID: resourceID}) + } + for _, candidate := range candidates { + add("platform", candidate.PlatformID) + add("platform_model", candidate.PlatformModelID) + add("base_model", candidate.BaseModelID) + } + return out +} + +func platformModelAccessResources(models []PlatformModel) []accessRuleResource { + seen := map[string]bool{} + out := make([]accessRuleResource, 0, len(models)*3) + add := func(resourceType string, resourceID string) { + key := resourceType + ":" + resourceID + if resourceID == "" || seen[key] { + return + } + seen[key] = true + out = append(out, accessRuleResource{Type: resourceType, ID: resourceID}) + } + for _, model := range models { + add("platform", model.PlatformID) + add("platform_model", model.ID) + add("base_model", model.BaseModelID) + } + return out +} + +func candidateAllowedByAccessRules(candidate RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool { + resourceKeys := map[string]bool{ + "platform:" + candidate.PlatformID: true, + "platform_model:" + candidate.PlatformModelID: true, + "base_model:" + candidate.BaseModelID: candidate.BaseModelID != "", + } + allowByResource := map[string]bool{} + matchedAllowByResource := map[string]bool{} + for _, rule := range rules { + resourceKey := rule.ResourceType + ":" + rule.ResourceID + if !resourceKeys[resourceKey] { + continue + } + subjectKey := rule.SubjectType + ":" + rule.SubjectID + if rule.Effect == "deny" && subjects[subjectKey] { + return false + } + if rule.Effect == "allow" { + allowByResource[resourceKey] = true + if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel { + matchedAllowByResource[resourceKey] = true + } + } + } + for resourceKey := range allowByResource { + if !matchedAllowByResource[resourceKey] { + return false + } + } + return true +} + +func platformModelAllowedByAccessRules(model PlatformModel, rules []AccessRule, subjects map[string]bool, permissionLevel int) bool { + resourceKeys := map[string]bool{ + "platform:" + model.PlatformID: true, + "platform_model:" + model.ID: true, + "base_model:" + model.BaseModelID: model.BaseModelID != "", + } + allowByResource := map[string]bool{} + matchedAllowByResource := map[string]bool{} + for _, rule := range rules { + resourceKey := rule.ResourceType + ":" + rule.ResourceID + if !resourceKeys[resourceKey] { + continue + } + subjectKey := rule.SubjectType + ":" + rule.SubjectID + if rule.Effect == "deny" && subjects[subjectKey] { + return false + } + if rule.Effect == "allow" { + allowByResource[resourceKey] = true + if subjects[subjectKey] && permissionLevel >= rule.MinPermissionLevel { + matchedAllowByResource[resourceKey] = true + } + } + } + for resourceKey := range allowByResource { + if !matchedAllowByResource[resourceKey] { + return false + } + } + return true +} + +func accessRuleSubjects(user *auth.User) map[string]bool { + subjects := map[string]bool{} + if user == nil { + return subjects + } + add := func(subjectType string, id string) { + id = strings.TrimSpace(id) + if id != "" { + subjects[subjectType+":"+id] = true + } + } + add("user", firstNonEmpty(user.GatewayUserID, user.ID)) + add("tenant", firstNonEmpty(user.GatewayTenantID, user.TenantID)) + add("api_key", user.APIKeyID) + add("user_group", user.UserGroupID) + for _, groupKey := range user.UserGroupKeys { + add("user_group", groupKey) + } + return subjects +} + +const accessRuleColumns = ` +id::text, subject_type, subject_id::text, resource_type, resource_id::text, effect, +priority, min_permission_level, conditions, metadata, status, created_at, updated_at` + +func scanAccessRule(row scanner) (AccessRule, error) { + var item AccessRule + var conditions []byte + var metadata []byte + if err := row.Scan( + &item.ID, + &item.SubjectType, + &item.SubjectID, + &item.ResourceType, + &item.ResourceID, + &item.Effect, + &item.Priority, + &item.MinPermissionLevel, + &conditions, + &metadata, + &item.Status, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return AccessRule{}, err + } + item.Conditions = decodeObject(conditions) + item.Metadata = decodeObject(metadata) + return item, nil +} + +func normalizeAccessRuleInput(input AccessRuleInput) AccessRuleInput { + input.SubjectType = strings.TrimSpace(input.SubjectType) + input.SubjectID = strings.TrimSpace(input.SubjectID) + input.ResourceType = strings.TrimSpace(input.ResourceType) + input.ResourceID = strings.TrimSpace(input.ResourceID) + input.Effect = firstNonEmpty(strings.TrimSpace(input.Effect), "allow") + input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") + if input.Priority == 0 { + input.Priority = 100 + } + if input.MinPermissionLevel < 0 { + input.MinPermissionLevel = 0 + } + return input +} + +func normalizeAccessRuleBatchInput(input AccessRuleBatchInput) AccessRuleBatchInput { + input.SubjectType = strings.TrimSpace(input.SubjectType) + input.SubjectID = strings.TrimSpace(input.SubjectID) + input.Effect = firstNonEmpty(strings.TrimSpace(input.Effect), "allow") + return input +} + +func normalizeAccessRuleResource(input AccessRuleResourceInput, effect string) AccessRuleResourceInput { + input.ResourceType = strings.TrimSpace(input.ResourceType) + input.ResourceID = strings.TrimSpace(input.ResourceID) + input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") + if input.Priority == 0 { + if effect == "deny" { + input.Priority = 10 + } else { + input.Priority = 100 + } + } + if input.MinPermissionLevel < 0 { + input.MinPermissionLevel = 0 + } + return input +} + +func dedupeAccessRuleResources(resources []AccessRuleResourceInput) []AccessRuleResourceInput { + seen := map[string]bool{} + out := make([]AccessRuleResourceInput, 0, len(resources)) + for _, resource := range resources { + resource.ResourceType = strings.TrimSpace(resource.ResourceType) + resource.ResourceID = strings.TrimSpace(resource.ResourceID) + key := resource.ResourceType + ":" + resource.ResourceID + if resource.ResourceType == "" || resource.ResourceID == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, resource) + } + return out +} diff --git a/apps/api/internal/store/base_models.go b/apps/api/internal/store/base_models.go index d328d64..3254c51 100644 --- a/apps/api/internal/store/base_models.go +++ b/apps/api/internal/store/base_models.go @@ -10,19 +10,27 @@ import ( const baseModelColumns = ` id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name, -capabilities, base_billing_config, default_rate_limit_policy, metadata, pricing_version, -status, created_at, updated_at` +capabilities, base_billing_config, default_rate_limit_policy, COALESCE(pricing_rule_set_id::text, ''), +COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override, metadata, +catalog_type, COALESCE(default_snapshot, '{}'::jsonb), COALESCE(customized_at::text, ''), +pricing_version, status, created_at, updated_at` type BaseModelInput struct { ProviderKey string `json:"providerKey"` CanonicalModelKey string `json:"canonicalModelKey"` ProviderModelName string `json:"providerModelName"` - ModelType string `json:"modelType"` + ModelType StringList `json:"modelType"` + ModelAlias string `json:"modelAlias"` DisplayName string `json:"displayName"` Capabilities map[string]any `json:"capabilities"` BaseBillingConfig map[string]any `json:"baseBillingConfig"` DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy"` + PricingRuleSetID string `json:"pricingRuleSetId"` + RuntimePolicySetID string `json:"runtimePolicySetId"` + RuntimePolicyOverride map[string]any `json:"runtimePolicyOverride"` Metadata map[string]any `json:"metadata"` + CatalogType string `json:"catalogType"` + DefaultSnapshot map[string]any `json:"defaultSnapshot"` PricingVersion int `json:"pricingVersion"` Status string `json:"status"` } @@ -31,6 +39,22 @@ type baseModelScanner interface { Scan(dest ...any) error } +type StringList []string + +func (list *StringList) UnmarshalJSON(data []byte) error { + var values []string + if err := json.Unmarshal(data, &values); err == nil { + *list = uniqueStringList(values) + return nil + } + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *list = uniqueStringList([]string{value}) + return nil +} + func (s *Store) ListBaseModels(ctx context.Context) ([]BaseModel, error) { rows, err := s.pool.Query(ctx, ` SELECT `+baseModelColumns+` @@ -57,27 +81,39 @@ func (s *Store) CreateBaseModel(ctx context.Context, input BaseModelInput) (Base capabilities, _ := json.Marshal(emptyObjectIfNil(input.Capabilities)) billingConfig, _ := json.Marshal(emptyObjectIfNil(input.BaseBillingConfig)) rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.DefaultRateLimitPolicy)) + runtimePolicyOverride, _ := json.Marshal(emptyObjectIfNil(input.RuntimePolicyOverride)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + defaultSnapshot, _ := json.Marshal(emptyObjectIfNil(input.DefaultSnapshot)) + modelType := primaryString(input.ModelType, "text_generate") return scanBaseModel(s.pool.QueryRow(ctx, ` INSERT INTO base_model_catalog ( provider_id, provider_key, canonical_model_key, provider_model_name, model_type, display_name, - capabilities, base_billing_config, default_rate_limit_policy, metadata, pricing_version, status + capabilities, base_billing_config, default_rate_limit_policy, pricing_rule_set_id, runtime_policy_set_id, runtime_policy_override, + metadata, catalog_type, default_snapshot, pricing_version, status ) VALUES ( (SELECT id FROM model_catalog_providers WHERE provider_key = $1 OR provider_code = $1 LIMIT 1), - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + $1, $2, $3, $4, $5, $6, $7, $8, + COALESCE(NULLIF($9, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)), + COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)), + $11, $12, NULLIF($13, ''), NULLIF($14::jsonb, '{}'::jsonb), $15, $16 ) RETURNING `+baseModelColumns, input.ProviderKey, input.CanonicalModelKey, input.ProviderModelName, - input.ModelType, - input.DisplayName, + modelType, + input.ModelAlias, capabilities, billingConfig, rateLimitPolicy, + input.PricingRuleSetID, + input.RuntimePolicySetID, + runtimePolicyOverride, metadata, + input.CatalogType, + string(defaultSnapshot), input.PricingVersion, input.Status, )) @@ -88,7 +124,10 @@ func (s *Store) UpdateBaseModel(ctx context.Context, id string, input BaseModelI capabilities, _ := json.Marshal(emptyObjectIfNil(input.Capabilities)) billingConfig, _ := json.Marshal(emptyObjectIfNil(input.BaseBillingConfig)) rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.DefaultRateLimitPolicy)) + runtimePolicyOverride, _ := json.Marshal(emptyObjectIfNil(input.RuntimePolicyOverride)) metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + defaultSnapshot, _ := json.Marshal(emptyObjectIfNil(input.DefaultSnapshot)) + modelType := primaryString(input.ModelType, "text_generate") return scanBaseModel(s.pool.QueryRow(ctx, ` UPDATE base_model_catalog @@ -101,9 +140,15 @@ SET provider_id = (SELECT id FROM model_catalog_providers WHERE provider_key = $ capabilities = $7, base_billing_config = $8, default_rate_limit_policy = $9, - metadata = $10, - pricing_version = $11, - status = $12, + pricing_rule_set_id = COALESCE(NULLIF($10, '')::uuid, (SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1)), + runtime_policy_set_id = COALESCE(NULLIF($11, '')::uuid, (SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1)), + runtime_policy_override = $12, + metadata = $13, + catalog_type = NULLIF($14, ''), + default_snapshot = COALESCE(NULLIF($15::jsonb, '{}'::jsonb), default_snapshot), + customized_at = CASE WHEN NULLIF($14, '') = 'system' THEN now() ELSE NULL END, + pricing_version = $16, + status = $17, updated_at = now() WHERE id = $1::uuid RETURNING `+baseModelColumns, @@ -111,17 +156,117 @@ RETURNING `+baseModelColumns, input.ProviderKey, input.CanonicalModelKey, input.ProviderModelName, - input.ModelType, - input.DisplayName, + modelType, + input.ModelAlias, capabilities, billingConfig, rateLimitPolicy, + input.PricingRuleSetID, + input.RuntimePolicySetID, + runtimePolicyOverride, metadata, + input.CatalogType, + string(defaultSnapshot), input.PricingVersion, input.Status, )) } +func (s *Store) ResetBaseModelToDefault(ctx context.Context, id string) (BaseModel, error) { + var catalogType string + var snapshotBytes []byte + if err := s.pool.QueryRow(ctx, ` +SELECT catalog_type, COALESCE(default_snapshot, '{}'::jsonb) +FROM base_model_catalog +WHERE id = $1::uuid`, id).Scan(&catalogType, &snapshotBytes); err != nil { + return BaseModel{}, err + } + if catalogType != "system" { + return BaseModel{}, ErrProtectedDefault + } + snapshot := decodeObject(snapshotBytes) + if len(snapshot) == 0 { + return BaseModel{}, ErrProtectedDefault + } + + return scanBaseModel(s.pool.QueryRow(ctx, ` +UPDATE base_model_catalog +SET provider_id = (SELECT id FROM model_catalog_providers WHERE provider_key = COALESCE($2::text, provider_key) OR provider_code = COALESCE($2::text, provider_key) LIMIT 1), + provider_key = COALESCE($2::text, provider_key), + canonical_model_key = COALESCE($3::text, canonical_model_key), + provider_model_name = COALESCE($4::text, provider_model_name), + model_type = COALESCE($5::text, model_type), + display_name = COALESCE($6::text, display_name), + capabilities = COALESCE($7::jsonb, capabilities), + base_billing_config = COALESCE($8::jsonb, base_billing_config), + default_rate_limit_policy = COALESCE($9::jsonb, default_rate_limit_policy), + pricing_rule_set_id = COALESCE(NULLIF($10::text, '')::uuid, pricing_rule_set_id), + runtime_policy_set_id = COALESCE(NULLIF($11::text, '')::uuid, runtime_policy_set_id), + runtime_policy_override = COALESCE($12::jsonb, runtime_policy_override), + metadata = COALESCE($13::jsonb, metadata), + pricing_version = COALESCE($14::integer, pricing_version), + status = COALESCE($15::text, status), + customized_at = NULL, + updated_at = now() +WHERE id = $1::uuid +RETURNING `+baseModelColumns, + id, + stringFromSnapshot(snapshot, "providerKey"), + stringFromSnapshot(snapshot, "canonicalModelKey"), + stringFromSnapshot(snapshot, "providerModelName"), + stringFromSnapshot(snapshot, "modelType"), + stringFromSnapshot(snapshot, "modelAlias", "displayName"), + jsonFromSnapshot(snapshot, "capabilities"), + jsonFromSnapshot(snapshot, "baseBillingConfig"), + jsonFromSnapshot(snapshot, "defaultRateLimitPolicy"), + stringFromSnapshot(snapshot, "pricingRuleSetId"), + stringFromSnapshot(snapshot, "runtimePolicySetId"), + jsonFromSnapshot(snapshot, "runtimePolicyOverride"), + jsonFromSnapshot(snapshot, "metadata"), + intFromSnapshot(snapshot, "pricingVersion"), + stringFromSnapshot(snapshot, "status"), + )) +} + +func (s *Store) ResetAllBaseModelsToDefault(ctx context.Context) ([]BaseModel, error) { + rows, err := s.pool.Query(ctx, ` +UPDATE base_model_catalog +SET provider_id = ( + SELECT id + FROM model_catalog_providers + WHERE provider_key = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key) + OR provider_code = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key) + LIMIT 1 + ), + provider_key = COALESCE(NULLIF(default_snapshot->>'providerKey', ''), provider_key), + canonical_model_key = COALESCE(NULLIF(default_snapshot->>'canonicalModelKey', ''), canonical_model_key), + provider_model_name = COALESCE(NULLIF(default_snapshot->>'providerModelName', ''), provider_model_name), + model_type = COALESCE(NULLIF(CASE + WHEN jsonb_typeof(default_snapshot->'modelType') = 'array' THEN default_snapshot->'modelType'->>0 + ELSE default_snapshot->>'modelType' + END, ''), model_type), + display_name = COALESCE(NULLIF(COALESCE(default_snapshot->>'modelAlias', default_snapshot->>'displayName'), ''), display_name), + capabilities = COALESCE(default_snapshot->'capabilities', capabilities), + base_billing_config = COALESCE(default_snapshot->'baseBillingConfig', base_billing_config), + default_rate_limit_policy = COALESCE(default_snapshot->'defaultRateLimitPolicy', default_rate_limit_policy), + pricing_rule_set_id = COALESCE(NULLIF(default_snapshot->>'pricingRuleSetId', '')::uuid, pricing_rule_set_id), + runtime_policy_set_id = COALESCE(NULLIF(default_snapshot->>'runtimePolicySetId', '')::uuid, runtime_policy_set_id), + runtime_policy_override = COALESCE(default_snapshot->'runtimePolicyOverride', runtime_policy_override), + metadata = COALESCE(default_snapshot->'metadata', metadata), + pricing_version = COALESCE(NULLIF(default_snapshot->>'pricingVersion', '')::integer, pricing_version), + status = COALESCE(NULLIF(default_snapshot->>'status', ''), status), + customized_at = NULL, + updated_at = now() +WHERE catalog_type = 'system' + AND COALESCE(default_snapshot, '{}'::jsonb) <> '{}'::jsonb +RETURNING `+baseModelColumns) + if err != nil { + return nil, err + } + defer rows.Close() + return scanBaseModelRows(rows) +} + func (s *Store) DeleteBaseModel(ctx context.Context, id string) error { result, err := s.pool.Exec(ctx, `DELETE FROM base_model_catalog WHERE id = $1::uuid`, id) if err != nil { @@ -133,23 +278,45 @@ func (s *Store) DeleteBaseModel(ctx context.Context, id string) error { return nil } +func scanBaseModelRows(rows pgx.Rows) ([]BaseModel, error) { + items := make([]BaseModel, 0) + for rows.Next() { + item, err := scanBaseModel(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + func scanBaseModel(scanner baseModelScanner) (BaseModel, error) { var item BaseModel + var modelType string + var modelAlias string var capabilities []byte var billingConfig []byte var rateLimitPolicy []byte + var runtimePolicyOverride []byte var metadata []byte + var defaultSnapshot []byte if err := scanner.Scan( &item.ID, &item.ProviderKey, &item.CanonicalModelKey, &item.ProviderModelName, - &item.ModelType, - &item.DisplayName, + &modelType, + &modelAlias, &capabilities, &billingConfig, &rateLimitPolicy, + &item.PricingRuleSetID, + &item.RuntimePolicySetID, + &runtimePolicyOverride, &metadata, + &item.CatalogType, + &defaultSnapshot, + &item.CustomizedAt, &item.PricingVersion, &item.Status, &item.CreatedAt, @@ -160,7 +327,12 @@ func scanBaseModel(scanner baseModelScanner) (BaseModel, error) { item.Capabilities = decodeObject(capabilities) item.BaseBillingConfig = decodeObject(billingConfig) item.DefaultRateLimitPolicy = decodeObject(rateLimitPolicy) + item.RuntimePolicyOverride = decodeObject(runtimePolicyOverride) item.Metadata = decodeObject(metadata) + item.DefaultSnapshot = decodeObject(defaultSnapshot) + item.ModelType = baseModelTypes(item.Capabilities, item.Metadata, modelType) + item.ModelAlias = modelAlias + item.DisplayName = modelAlias return item, nil } @@ -168,17 +340,27 @@ func normalizeBaseModelInput(input BaseModelInput) BaseModelInput { input.ProviderKey = strings.TrimSpace(input.ProviderKey) input.CanonicalModelKey = strings.TrimSpace(input.CanonicalModelKey) input.ProviderModelName = strings.TrimSpace(input.ProviderModelName) - input.ModelType = strings.TrimSpace(input.ModelType) + input.ModelType = uniqueStringList(input.ModelType) + input.ModelAlias = strings.TrimSpace(input.ModelAlias) input.DisplayName = strings.TrimSpace(input.DisplayName) + input.PricingRuleSetID = strings.TrimSpace(input.PricingRuleSetID) + input.RuntimePolicySetID = strings.TrimSpace(input.RuntimePolicySetID) + input.CatalogType = strings.TrimSpace(input.CatalogType) input.Status = strings.TrimSpace(input.Status) if input.CanonicalModelKey == "" && input.ProviderKey != "" && input.ProviderModelName != "" { input.CanonicalModelKey = input.ProviderKey + ":" + input.ProviderModelName } - if input.DisplayName == "" { - input.DisplayName = input.ProviderModelName + if input.ModelAlias == "" { + input.ModelAlias = input.DisplayName } - if input.ModelType == "" { - input.ModelType = "text_generate" + if input.ModelAlias == "" { + input.ModelAlias = input.ProviderModelName + } + if len(input.ModelType) == 0 { + input.ModelType = StringList{"text_generate"} + } + if input.CatalogType == "" { + input.CatalogType = "custom" } if input.PricingVersion <= 0 { input.PricingVersion = 1 @@ -188,3 +370,102 @@ func normalizeBaseModelInput(input BaseModelInput) BaseModelInput { } return input } + +func stringFromSnapshot(snapshot map[string]any, keys ...string) any { + for _, key := range keys { + value, ok := snapshot[key] + if !ok { + continue + } + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) != "" { + return typed + } + case []any: + for _, item := range typed { + if text, ok := item.(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + case []string: + if primary := primaryString(typed, ""); primary != "" { + return primary + } + } + } + return nil +} + +func intFromSnapshot(snapshot map[string]any, key string) any { + switch value := snapshot[key].(type) { + case float64: + return int(value) + case int: + return value + default: + return nil + } +} + +func jsonFromSnapshot(snapshot map[string]any, key string) any { + value, ok := snapshot[key] + if !ok || value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + return string(raw) +} + +func baseModelTypes(capabilities map[string]any, metadata map[string]any, fallback string) StringList { + values := make([]string, 0) + values = append(values, stringListFromAny(capabilities["originalTypes"])...) + values = append(values, stringListFromAny(metadata["originalTypes"])...) + if fallback != "" { + values = append(values, fallback) + } + return uniqueStringList(values) +} + +func stringListFromAny(value any) []string { + switch typed := value.(type) { + case []string: + return typed + case []any: + values := make([]string, 0, len(typed)) + for _, item := range typed { + if text, ok := item.(string); ok { + values = append(values, text) + } + } + return values + default: + return nil + } +} + +func uniqueStringList(values []string) StringList { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func primaryString(values []string, fallback string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return fallback +} diff --git a/apps/api/internal/store/candidates.go b/apps/api/internal/store/candidates.go index 3f2a239..50b081c 100644 --- a/apps/api/internal/store/candidates.go +++ b/apps/api/internal/store/candidates.go @@ -3,11 +3,15 @@ package store import ( "context" "fmt" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" ) -func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType string) ([]RuntimeModelCandidate, error) { +func (s *Store) ListModelCandidates(ctx context.Context, model string, modelType string, user *auth.User) ([]RuntimeModelCandidate, error) { rows, err := s.pool.Query(ctx, ` -SELECT p.id::text, p.platform_key, p.name, p.provider, COALESCE(p.base_url, ''), +SELECT p.id::text, p.platform_key, p.name, p.provider, + COALESCE(NULLIF(p.config->>'specType', ''), NULLIF(cp.provider_type, ''), NULLIF(p.config->>'sourceSpecType', ''), p.provider) AS spec_type, + COALESCE(p.base_url, ''), p.auth_type, p.credentials, p.config, p.default_pricing_mode, p.default_discount_factor::float8, COALESCE(p.pricing_rule_set_id::text, ''), p.retry_policy, p.rate_limit_policy, @@ -17,16 +21,24 @@ SELECT p.id::text, p.platform_key, p.name, p.provider, COALESCE(p.base_url, ''), m.model_type, m.display_name, m.capabilities, m.capability_override, COALESCE(b.base_billing_config, '{}'::jsonb), m.billing_config, m.billing_config_override, m.pricing_mode, COALESCE(m.discount_factor, 0)::float8, COALESCE(m.pricing_rule_set_id::text, ''), - m.retry_policy, m.rate_limit_policy + m.permission_config, m.retry_policy, m.rate_limit_policy, COALESCE(m.runtime_policy_set_id::text, COALESCE(b.runtime_policy_set_id::text, '')), + COALESCE(NULLIF(m.runtime_policy_override, '{}'::jsonb), b.runtime_policy_override, '{}'::jsonb) FROM platform_models m JOIN integration_platforms p ON p.id = m.platform_id +LEFT JOIN model_catalog_providers cp ON cp.provider_key = p.provider OR cp.provider_code = p.provider LEFT JOIN base_model_catalog b ON b.id = m.base_model_id LEFT JOIN runtime_client_states s ON s.client_id = p.platform_key || ':' || m.model_type || ':' || m.model_name WHERE p.status = 'enabled' AND p.deleted_at IS NULL AND m.enabled = true - AND m.model_type = $2 + AND ( + m.model_type = $2 + OR ($2 = 'text_generate' AND m.model_type IN ('chat', 'responses', 'text')) + OR ($2 = 'image_generate' AND m.model_type IN ('image', 'images.generations')) + OR ($2 = 'image_edit' AND m.model_type IN ('images.edits')) + OR ($2 = 'video_generate' AND m.model_type IN ('video', 'videos.generations')) + ) AND (p.cooldown_until IS NULL OR p.cooldown_until <= now()) AND ( m.model_name = $1 @@ -57,13 +69,16 @@ ORDER BY effective_priority ASC, var baseBilling []byte var billing []byte var billingOverride []byte + var permissionConfig []byte var modelRetryPolicy []byte var modelRateLimitPolicy []byte + var runtimePolicyOverride []byte if err := rows.Scan( &item.PlatformID, &item.PlatformKey, &item.PlatformName, &item.Provider, + &item.SpecType, &item.BaseURL, &item.AuthType, &credentials, @@ -90,8 +105,11 @@ ORDER BY effective_priority ASC, &item.PricingMode, &item.DiscountFactor, &item.ModelPricingRuleSetID, + &permissionConfig, &modelRetryPolicy, &modelRateLimitPolicy, + &item.RuntimePolicySetID, + &runtimePolicyOverride, ); err != nil { return nil, err } @@ -104,8 +122,10 @@ ORDER BY effective_priority ASC, item.BaseBillingConfig = decodeObject(baseBilling) item.BillingConfig = decodeObject(billing) item.BillingConfigOverride = decodeObject(billingOverride) + item.PermissionConfig = decodeObject(permissionConfig) item.ModelRetryPolicy = decodeObject(modelRetryPolicy) item.ModelRateLimitPolicy = decodeObject(modelRateLimitPolicy) + item.RuntimePolicyOverride = decodeObject(runtimePolicyOverride) item.ClientID = fmt.Sprintf("%s:%s:%s", item.PlatformKey, item.ModelType, item.ModelName) item.QueueKey = item.ClientID items = append(items, item) @@ -116,5 +136,12 @@ ORDER BY effective_priority ASC, if len(items) == 0 { return nil, ErrNoModelCandidate } + items, err = s.filterCandidatesByAccessRules(ctx, user, items) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, ErrNoModelCandidate + } return items, nil } diff --git a/apps/api/internal/store/catalog_providers.go b/apps/api/internal/store/catalog_providers.go index 4fdcfec..1753603 100644 --- a/apps/api/internal/store/catalog_providers.go +++ b/apps/api/internal/store/catalog_providers.go @@ -11,6 +11,7 @@ import ( const catalogProviderColumns = ` id::text, provider_key, COALESCE(NULLIF(provider_code, ''), provider_key) AS provider_code, display_name, provider_type, COALESCE(icon_path, '') AS icon_path, +COALESCE(default_base_url, '') AS default_base_url, COALESCE(default_auth_type, '') AS default_auth_type, COALESCE(source, '') AS source, capability_schema, default_rate_limit_policy, metadata, status, created_at, updated_at` @@ -20,6 +21,8 @@ type CatalogProviderInput struct { DisplayName string `json:"displayName"` ProviderType string `json:"providerType"` IconPath string `json:"iconPath"` + DefaultBaseURL string `json:"defaultBaseUrl"` + DefaultAuthType string `json:"defaultAuthType"` Source string `json:"source"` CapabilitySchema map[string]any `json:"capabilitySchema"` DefaultRateLimitPolicy map[string]any `json:"defaultRateLimitPolicy"` @@ -39,16 +42,18 @@ func (s *Store) CreateCatalogProvider(ctx context.Context, input CatalogProvider return scanCatalogProvider(s.pool.QueryRow(ctx, ` INSERT INTO model_catalog_providers ( - provider_key, provider_code, display_name, provider_type, icon_path, source, + provider_key, provider_code, display_name, provider_type, icon_path, default_base_url, default_auth_type, source, capability_schema, default_rate_limit_policy, metadata, status ) -VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, $7, $8, $9, $10) +VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, $9, $10, $11, $12) RETURNING `+catalogProviderColumns, input.ProviderKey, input.Code, input.DisplayName, input.ProviderType, input.IconPath, + input.DefaultBaseURL, + input.DefaultAuthType, input.Source, capabilitySchema, rateLimitPolicy, @@ -70,11 +75,13 @@ SET provider_key = $2, display_name = $4, provider_type = $5, icon_path = NULLIF($6, ''), - source = $7, - capability_schema = $8, - default_rate_limit_policy = $9, - metadata = $10, - status = $11, + default_base_url = NULLIF($7, ''), + default_auth_type = $8, + source = $9, + capability_schema = $10, + default_rate_limit_policy = $11, + metadata = $12, + status = $13, updated_at = now() WHERE id = $1::uuid RETURNING `+catalogProviderColumns, @@ -84,6 +91,8 @@ RETURNING `+catalogProviderColumns, input.DisplayName, input.ProviderType, input.IconPath, + input.DefaultBaseURL, + input.DefaultAuthType, input.Source, capabilitySchema, rateLimitPolicy, @@ -115,6 +124,8 @@ func scanCatalogProvider(scanner catalogProviderScanner) (CatalogProvider, error &item.DisplayName, &item.ProviderType, &item.IconPath, + &item.DefaultBaseURL, + &item.DefaultAuthType, &item.Source, &capabilitySchema, &rateLimitPolicy, @@ -137,6 +148,8 @@ func normalizeCatalogProviderInput(input CatalogProviderInput) CatalogProviderIn input.DisplayName = strings.TrimSpace(input.DisplayName) input.ProviderType = strings.TrimSpace(input.ProviderType) input.IconPath = strings.TrimSpace(input.IconPath) + input.DefaultBaseURL = strings.TrimSpace(input.DefaultBaseURL) + input.DefaultAuthType = strings.TrimSpace(input.DefaultAuthType) input.Source = strings.TrimSpace(input.Source) input.Status = strings.TrimSpace(input.Status) if input.Code == "" { @@ -145,6 +158,9 @@ func normalizeCatalogProviderInput(input CatalogProviderInput) CatalogProviderIn if input.ProviderType == "" { input.ProviderType = "openai" } + if input.DefaultAuthType == "" { + input.DefaultAuthType = "APIKey" + } if input.Source == "" { input.Source = "gateway" } diff --git a/apps/api/internal/store/identity_admin.go b/apps/api/internal/store/identity_admin.go new file mode 100644 index 0000000..ec7d659 --- /dev/null +++ b/apps/api/internal/store/identity_admin.go @@ -0,0 +1,399 @@ +package store + +import ( + "context" + "encoding/json" + "strings" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" +) + +type GatewayTenantInput struct { + TenantKey string `json:"tenantKey"` + Source string `json:"source"` + ExternalTenantID string `json:"externalTenantId"` + Name string `json:"name"` + Description string `json:"description"` + DefaultUserGroupID string `json:"defaultUserGroupId"` + PlanKey string `json:"planKey"` + BillingProfile map[string]any `json:"billingProfile"` + RateLimitPolicy map[string]any `json:"rateLimitPolicy"` + AuthPolicy map[string]any `json:"authPolicy"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` +} + +type GatewayUserInput struct { + UserKey string `json:"userKey"` + Source string `json:"source"` + ExternalUserID string `json:"externalUserId"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Phone string `json:"phone"` + AvatarURL string `json:"avatarUrl"` + Password string `json:"password"` + GatewayTenantID string `json:"gatewayTenantId"` + TenantID string `json:"tenantId"` + TenantKey string `json:"tenantKey"` + DefaultUserGroupID string `json:"defaultUserGroupId"` + Roles []string `json:"roles"` + AuthProfile map[string]any `json:"authProfile"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` +} + +type UserGroupInput struct { + GroupKey string `json:"groupKey"` + Name string `json:"name"` + Description string `json:"description"` + Source string `json:"source"` + Priority int `json:"priority"` + RechargeDiscountPolicy map[string]any `json:"rechargeDiscountPolicy"` + BillingDiscountPolicy map[string]any `json:"billingDiscountPolicy"` + RateLimitPolicy map[string]any `json:"rateLimitPolicy"` + QuotaPolicy map[string]any `json:"quotaPolicy"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` +} + +func (s *Store) CreateTenant(ctx context.Context, input GatewayTenantInput) (GatewayTenant, error) { + input = normalizeTenantInput(input) + billingProfile, _ := json.Marshal(emptyObjectIfNil(input.BillingProfile)) + rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) + authPolicy, _ := json.Marshal(emptyObjectIfNil(input.AuthPolicy)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanTenant(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_tenants ( + tenant_key, source, external_tenant_id, name, description, default_user_group_id, plan_key, + billing_profile, rate_limit_policy, auth_policy, metadata, status +) +VALUES ($1, $2, NULLIF($3, ''), $4, NULLIF($5, ''), NULLIF($6, '')::uuid, NULLIF($7, ''), $8, $9, $10, $11, $12) +RETURNING `+tenantColumns, + input.TenantKey, input.Source, input.ExternalTenantID, input.Name, input.Description, input.DefaultUserGroupID, input.PlanKey, + billingProfile, rateLimitPolicy, authPolicy, metadata, input.Status, + )) +} + +func (s *Store) UpdateTenant(ctx context.Context, id string, input GatewayTenantInput) (GatewayTenant, error) { + input = normalizeTenantInput(input) + billingProfile, _ := json.Marshal(emptyObjectIfNil(input.BillingProfile)) + rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) + authPolicy, _ := json.Marshal(emptyObjectIfNil(input.AuthPolicy)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanTenant(s.pool.QueryRow(ctx, ` +UPDATE gateway_tenants +SET tenant_key = $2, + source = $3, + external_tenant_id = NULLIF($4, ''), + name = $5, + description = NULLIF($6, ''), + default_user_group_id = NULLIF($7, '')::uuid, + plan_key = NULLIF($8, ''), + billing_profile = $9, + rate_limit_policy = $10, + auth_policy = $11, + metadata = $12, + status = $13, + updated_at = now() +WHERE id = $1::uuid AND deleted_at IS NULL +RETURNING `+tenantColumns, + id, input.TenantKey, input.Source, input.ExternalTenantID, input.Name, input.Description, input.DefaultUserGroupID, + input.PlanKey, billingProfile, rateLimitPolicy, authPolicy, metadata, input.Status, + )) +} + +func (s *Store) DeleteTenant(ctx context.Context, id string) error { + result, err := s.pool.Exec(ctx, ` +UPDATE gateway_tenants +SET deleted_at = now(), + status = 'deleted', + tenant_key = tenant_key || ':deleted:' || left(id::text, 8), + external_tenant_id = NULL, + updated_at = now() +WHERE id = $1::uuid AND deleted_at IS NULL`, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func (s *Store) CreateGatewayUser(ctx context.Context, input GatewayUserInput) (GatewayUser, error) { + input = normalizeUserInput(input) + passwordHash := "" + if strings.TrimSpace(input.Password) != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return GatewayUser{}, err + } + passwordHash = string(hash) + } + roles, _ := json.Marshal(input.Roles) + authProfile, _ := json.Marshal(emptyObjectIfNil(input.AuthProfile)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanUser(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_users ( + user_key, source, external_user_id, username, display_name, email, phone, avatar_url, password_hash, + gateway_tenant_id, tenant_id, tenant_key, default_user_group_id, roles, auth_profile, metadata, status +) +VALUES ( + $1, $2, NULLIF($3, ''), $4, NULLIF($5, ''), NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), + NULLIF($10, '')::uuid, NULLIF($11, ''), NULLIF($12, ''), NULLIF($13, '')::uuid, $14, $15, $16, $17 +) +RETURNING `+userColumns, + input.UserKey, input.Source, input.ExternalUserID, input.Username, input.DisplayName, input.Email, input.Phone, input.AvatarURL, passwordHash, + input.GatewayTenantID, input.TenantID, input.TenantKey, input.DefaultUserGroupID, roles, authProfile, metadata, input.Status, + )) +} + +func (s *Store) UpdateGatewayUser(ctx context.Context, id string, input GatewayUserInput) (GatewayUser, error) { + input = normalizeUserInput(input) + passwordHash := "" + if strings.TrimSpace(input.Password) != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return GatewayUser{}, err + } + passwordHash = string(hash) + } + roles, _ := json.Marshal(input.Roles) + authProfile, _ := json.Marshal(emptyObjectIfNil(input.AuthProfile)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanUser(s.pool.QueryRow(ctx, ` +UPDATE gateway_users +SET user_key = $2, + source = $3, + external_user_id = NULLIF($4, ''), + username = $5, + display_name = NULLIF($6, ''), + email = NULLIF($7, ''), + phone = NULLIF($8, ''), + avatar_url = NULLIF($9, ''), + password_hash = COALESCE(NULLIF($10, ''), password_hash), + gateway_tenant_id = NULLIF($11, '')::uuid, + tenant_id = NULLIF($12, ''), + tenant_key = NULLIF($13, ''), + default_user_group_id = NULLIF($14, '')::uuid, + roles = $15, + auth_profile = $16, + metadata = $17, + status = $18, + updated_at = now() +WHERE id = $1::uuid AND deleted_at IS NULL +RETURNING `+userColumns, + id, input.UserKey, input.Source, input.ExternalUserID, input.Username, input.DisplayName, input.Email, input.Phone, + input.AvatarURL, passwordHash, input.GatewayTenantID, input.TenantID, input.TenantKey, input.DefaultUserGroupID, + roles, authProfile, metadata, input.Status, + )) +} + +func (s *Store) DeleteGatewayUser(ctx context.Context, id string) error { + result, err := s.pool.Exec(ctx, ` +UPDATE gateway_users +SET deleted_at = now(), + status = 'deleted', + user_key = user_key || ':deleted:' || left(id::text, 8), + external_user_id = NULL, + email = NULL, + updated_at = now() +WHERE id = $1::uuid AND deleted_at IS NULL`, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func (s *Store) CreateUserGroup(ctx context.Context, input UserGroupInput) (UserGroup, error) { + input = normalizeUserGroupInput(input) + rechargeDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.RechargeDiscountPolicy)) + billingDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.BillingDiscountPolicy)) + rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) + quotaPolicy, _ := json.Marshal(emptyObjectIfNil(input.QuotaPolicy)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanUserGroup(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_user_groups ( + group_key, name, description, source, priority, recharge_discount_policy, billing_discount_policy, + rate_limit_policy, quota_policy, metadata, status +) +VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9, $10, $11) +RETURNING `+userGroupColumns, + input.GroupKey, input.Name, input.Description, input.Source, input.Priority, rechargeDiscountPolicy, billingDiscountPolicy, + rateLimitPolicy, quotaPolicy, metadata, input.Status, + )) +} + +func (s *Store) UpdateUserGroup(ctx context.Context, id string, input UserGroupInput) (UserGroup, error) { + input = normalizeUserGroupInput(input) + rechargeDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.RechargeDiscountPolicy)) + billingDiscountPolicy, _ := json.Marshal(emptyObjectIfNil(input.BillingDiscountPolicy)) + rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) + quotaPolicy, _ := json.Marshal(emptyObjectIfNil(input.QuotaPolicy)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanUserGroup(s.pool.QueryRow(ctx, ` +UPDATE gateway_user_groups +SET group_key = $2, + name = $3, + description = NULLIF($4, ''), + source = $5, + priority = $6, + recharge_discount_policy = $7, + billing_discount_policy = $8, + rate_limit_policy = $9, + quota_policy = $10, + metadata = $11, + status = $12, + updated_at = now() +WHERE id = $1::uuid +RETURNING `+userGroupColumns, + id, input.GroupKey, input.Name, input.Description, input.Source, input.Priority, + rechargeDiscountPolicy, billingDiscountPolicy, rateLimitPolicy, quotaPolicy, metadata, input.Status, + )) +} + +func (s *Store) DeleteUserGroup(ctx context.Context, id string) error { + result, err := s.pool.Exec(ctx, `DELETE FROM gateway_user_groups WHERE id = $1::uuid`, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +const tenantColumns = ` +id::text, tenant_key, source, COALESCE(external_tenant_id, ''), name, COALESCE(description, ''), +COALESCE(default_user_group_id::text, ''), COALESCE(plan_key, ''), billing_profile, rate_limit_policy, +auth_policy, metadata, status, COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), +created_at, updated_at` + +const userColumns = ` +id::text, user_key, source, COALESCE(external_user_id, ''), username, +COALESCE(display_name, ''), COALESCE(email, ''), COALESCE(phone, ''), COALESCE(avatar_url, ''), +COALESCE(gateway_tenant_id::text, ''), COALESCE(tenant_id, ''), COALESCE(tenant_key, ''), +COALESCE(default_user_group_id::text, ''), roles, auth_profile, metadata, +status, COALESCE(last_login_at::text, ''), COALESCE(synced_at::text, ''), COALESCE(source_updated_at::text, ''), +created_at, updated_at` + +const userGroupColumns = ` +id::text, group_key, name, COALESCE(description, ''), source, priority, +recharge_discount_policy, billing_discount_policy, rate_limit_policy, quota_policy, metadata, +status, created_at, updated_at` + +type scanner interface { + Scan(dest ...any) error +} + +func scanTenant(row scanner) (GatewayTenant, error) { + var item GatewayTenant + var billingProfile []byte + var rateLimitPolicy []byte + var authPolicy []byte + var metadata []byte + if err := row.Scan( + &item.ID, &item.TenantKey, &item.Source, &item.ExternalTenantID, &item.Name, &item.Description, + &item.DefaultUserGroupID, &item.PlanKey, &billingProfile, &rateLimitPolicy, &authPolicy, &metadata, + &item.Status, &item.SyncedAt, &item.SourceUpdatedAt, &item.CreatedAt, &item.UpdatedAt, + ); err != nil { + return GatewayTenant{}, err + } + item.BillingProfile = decodeObject(billingProfile) + item.RateLimitPolicy = decodeObject(rateLimitPolicy) + item.AuthPolicy = decodeObject(authPolicy) + item.Metadata = decodeObject(metadata) + return item, nil +} + +func scanUser(row scanner) (GatewayUser, error) { + var item GatewayUser + var roles []byte + var authProfile []byte + var metadata []byte + if err := row.Scan( + &item.ID, &item.UserKey, &item.Source, &item.ExternalUserID, &item.Username, &item.DisplayName, + &item.Email, &item.Phone, &item.AvatarURL, &item.GatewayTenantID, &item.TenantID, &item.TenantKey, + &item.DefaultUserGroupID, &roles, &authProfile, &metadata, &item.Status, &item.LastLoginAt, + &item.SyncedAt, &item.SourceUpdatedAt, &item.CreatedAt, &item.UpdatedAt, + ); err != nil { + return GatewayUser{}, err + } + item.Roles = decodeStringArray(roles) + item.AuthProfile = decodeObject(authProfile) + item.Metadata = decodeObject(metadata) + return item, nil +} + +func scanUserGroup(row scanner) (UserGroup, error) { + var item UserGroup + var rechargeDiscountPolicy []byte + var billingDiscountPolicy []byte + var rateLimitPolicy []byte + var quotaPolicy []byte + var metadata []byte + if err := row.Scan( + &item.ID, &item.GroupKey, &item.Name, &item.Description, &item.Source, &item.Priority, + &rechargeDiscountPolicy, &billingDiscountPolicy, &rateLimitPolicy, "aPolicy, &metadata, + &item.Status, &item.CreatedAt, &item.UpdatedAt, + ); err != nil { + return UserGroup{}, err + } + item.RechargeDiscountPolicy = decodeObject(rechargeDiscountPolicy) + item.BillingDiscountPolicy = decodeObject(billingDiscountPolicy) + item.RateLimitPolicy = decodeObject(rateLimitPolicy) + item.QuotaPolicy = decodeObject(quotaPolicy) + item.Metadata = decodeObject(metadata) + return item, nil +} + +func normalizeTenantInput(input GatewayTenantInput) GatewayTenantInput { + input.TenantKey = strings.TrimSpace(input.TenantKey) + input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway") + input.Name = strings.TrimSpace(input.Name) + input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") + return input +} + +func normalizeUserInput(input GatewayUserInput) GatewayUserInput { + input.Username = strings.TrimSpace(input.Username) + input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway") + input.UserKey = firstNonEmpty(strings.TrimSpace(input.UserKey), input.Source+":"+input.Username) + input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") + input.Roles = normalizeGatewayRoles(input.Roles) + return input +} + +func normalizeGatewayRoles(roles []string) []string { + for _, role := range roles { + switch strings.TrimSpace(role) { + case "manager": + return []string{"manager"} + case "admin": + return []string{"admin"} + case "operator": + return []string{"operator"} + case "creator": + return []string{"creator"} + case "user": + return []string{"user"} + } + } + return []string{"user"} +} + +func normalizeUserGroupInput(input UserGroupInput) UserGroupInput { + input.GroupKey = strings.TrimSpace(input.GroupKey) + input.Name = strings.TrimSpace(input.Name) + input.Source = firstNonEmpty(strings.TrimSpace(input.Source), "gateway") + input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "active") + if input.Priority == 0 { + input.Priority = 100 + } + return input +} diff --git a/apps/api/internal/store/platform_models.go b/apps/api/internal/store/platform_models.go index 24acffa..1acb1fe 100644 --- a/apps/api/internal/store/platform_models.go +++ b/apps/api/internal/store/platform_models.go @@ -8,6 +8,10 @@ import ( "github.com/jackc/pgx/v5" ) +type platformModelQuerier interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + type modelCatalogSnapshot struct { ID string ProviderKey string @@ -18,10 +22,50 @@ type modelCatalogSnapshot struct { Capabilities map[string]any BaseBillingConfig map[string]any DefaultRateLimitPolicy map[string]any + RuntimePolicySetID string + RuntimePolicyOverride map[string]any } func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformModelInput) (PlatformModel, error) { - base, err := s.lookupBaseModel(ctx, input.BaseModelID, input.CanonicalModelKey, input.ModelName) + return s.createPlatformModel(ctx, s.pool, input) +} + +func (s *Store) ReplacePlatformModels(ctx context.Context, platformID string, inputs []CreatePlatformModelInput) ([]PlatformModel, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + keptIDs := make([]string, 0, len(inputs)) + for _, input := range inputs { + input.PlatformID = platformID + model, err := s.createPlatformModel(ctx, tx, input) + if err != nil { + return nil, err + } + keptIDs = append(keptIDs, model.ID) + } + + if len(keptIDs) == 0 { + if _, err := tx.Exec(ctx, `DELETE FROM platform_models WHERE platform_id = $1::uuid`, platformID); err != nil { + return nil, err + } + } else if _, err := tx.Exec(ctx, ` +DELETE FROM platform_models +WHERE platform_id = $1::uuid + AND NOT (id::text = ANY($2::text[]))`, platformID, keptIDs); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return s.ListPlatformModels(ctx, platformID) +} + +func (s *Store) createPlatformModel(ctx context.Context, q platformModelQuerier, input CreatePlatformModelInput) (PlatformModel, error) { + base, err := s.lookupBaseModel(ctx, q, input.BaseModelID, input.CanonicalModelKey, input.ModelName) if err != nil && !IsNotFound(err) { return PlatformModel{}, err } @@ -34,6 +78,7 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod if input.DisplayName == "" { input.DisplayName = firstNonEmpty(base.DisplayName, input.ModelName) } + input.ModelAlias = normalizePlatformModelAlias(input.ModelAlias, base) if input.PricingMode == "" { input.PricingMode = "inherit_discount" } @@ -49,6 +94,14 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod if len(rateLimitPolicy) == 0 { rateLimitPolicy = base.DefaultRateLimitPolicy } + runtimePolicySetID := strings.TrimSpace(input.RuntimePolicySetID) + if runtimePolicySetID == "" { + runtimePolicySetID = base.RuntimePolicySetID + } + runtimePolicyOverride := input.RuntimePolicyOverride + if len(runtimePolicyOverride) == 0 { + runtimePolicyOverride = base.RuntimePolicyOverride + } capabilityOverrideJSON, _ := json.Marshal(emptyObjectIfNil(input.CapabilityOverride)) capabilitiesJSON, _ := json.Marshal(emptyObjectIfNil(capabilities)) @@ -57,6 +110,7 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod permissionJSON, _ := json.Marshal(emptyObjectIfNil(input.PermissionConfig)) retryJSON, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy)) rateLimitJSON, _ := json.Marshal(emptyObjectIfNil(rateLimitPolicy)) + runtimePolicyOverrideJSON, _ := json.Marshal(emptyObjectIfNil(runtimePolicyOverride)) discount := any(nil) if input.DiscountFactor > 0 { @@ -72,16 +126,22 @@ func (s *Store) CreatePlatformModel(ctx context.Context, input CreatePlatformMod var capabilitiesBytes []byte var billingOverrideBytes []byte var billingBytes []byte - err = s.pool.QueryRow(ctx, ` + var permissionBytes []byte + var retryPolicyBytes []byte + var rateLimitPolicyBytes []byte + var runtimePolicyOverrideBytes []byte + err = q.QueryRow(ctx, ` INSERT INTO platform_models ( platform_id, base_model_id, model_name, model_alias, model_type, display_name, capability_override, capabilities, pricing_mode, discount_factor, - pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy, enabled + pricing_rule_set_id, billing_config_override, billing_config, permission_config, retry_policy, rate_limit_policy, + runtime_policy_set_id, runtime_policy_override, enabled ) VALUES ( $1::uuid, $2::uuid, $3, NULLIF($4, ''), $5, $6, $7::jsonb, $8::jsonb, $9, $10::numeric, - NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, true + NULLIF($11, '')::uuid, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, + NULLIF($17, '')::uuid, $18::jsonb, true ) ON CONFLICT (platform_id, model_name, model_type) DO UPDATE SET base_model_id = EXCLUDED.base_model_id, @@ -97,12 +157,16 @@ SET base_model_id = EXCLUDED.base_model_id, permission_config = EXCLUDED.permission_config, retry_policy = EXCLUDED.retry_policy, rate_limit_policy = EXCLUDED.rate_limit_policy, + runtime_policy_set_id = EXCLUDED.runtime_policy_set_id, + runtime_policy_override = EXCLUDED.runtime_policy_override, enabled = true, updated_at = now() RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_name, COALESCE(model_alias, ''), model_type, display_name, capability_override, capabilities, pricing_mode, COALESCE(discount_factor, 0)::float8, - COALESCE(pricing_rule_set_id::text, ''), billing_config_override, billing_config, enabled, created_at, updated_at`, + COALESCE(pricing_rule_set_id::text, ''), billing_config_override, billing_config, + permission_config, retry_policy, rate_limit_policy, COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override, + enabled, created_at, updated_at`, input.PlatformID, baseID, input.ModelName, @@ -119,6 +183,8 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ string(permissionJSON), string(retryJSON), string(rateLimitJSON), + runtimePolicySetID, + string(runtimePolicyOverrideJSON), ).Scan( &model.ID, &model.PlatformID, @@ -134,6 +200,11 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ &model.PricingRuleSetID, &billingOverrideBytes, &billingBytes, + &permissionBytes, + &retryPolicyBytes, + &rateLimitPolicyBytes, + &model.RuntimePolicySetID, + &runtimePolicyOverrideBytes, &model.Enabled, &model.CreatedAt, &model.UpdatedAt, @@ -145,17 +216,34 @@ RETURNING id::text, platform_id::text, COALESCE(base_model_id::text, ''), model_ model.Capabilities = decodeObject(capabilitiesBytes) model.BillingConfigOverride = decodeObject(billingOverrideBytes) model.BillingConfig = decodeObject(billingBytes) + model.PermissionConfig = decodeObject(permissionBytes) + model.RetryPolicy = decodeObject(retryPolicyBytes) + model.RateLimitPolicy = decodeObject(rateLimitPolicyBytes) + model.RuntimePolicyOverride = decodeObject(runtimePolicyOverrideBytes) return model, nil } -func (s *Store) lookupBaseModel(ctx context.Context, id string, canonicalKey string, modelName string) (modelCatalogSnapshot, error) { +func (s *Store) DeletePlatformModel(ctx context.Context, id string) error { + result, err := s.pool.Exec(ctx, `DELETE FROM platform_models WHERE id = $1::uuid`, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func (s *Store) lookupBaseModel(ctx context.Context, q platformModelQuerier, id string, canonicalKey string, modelName string) (modelCatalogSnapshot, error) { var item modelCatalogSnapshot var capabilities []byte var billingConfig []byte var rateLimitPolicy []byte - err := s.pool.QueryRow(ctx, ` + var runtimePolicyOverride []byte + err := q.QueryRow(ctx, ` SELECT id::text, provider_key, canonical_model_key, provider_model_name, model_type, display_name, - capabilities, base_billing_config, default_rate_limit_policy + capabilities, base_billing_config, default_rate_limit_policy, + COALESCE(runtime_policy_set_id::text, ''), runtime_policy_override FROM base_model_catalog WHERE ($1 <> '' AND id = NULLIF($1, '')::uuid) OR ($2 <> '' AND canonical_model_key = $2) @@ -171,6 +259,8 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp &capabilities, &billingConfig, &rateLimitPolicy, + &item.RuntimePolicySetID, + &runtimePolicyOverride, ) if err != nil { if err == pgx.ErrNoRows { @@ -181,9 +271,31 @@ LIMIT 1`, strings.TrimSpace(id), strings.TrimSpace(canonicalKey), strings.TrimSp item.Capabilities = decodeObject(capabilities) item.BaseBillingConfig = decodeObject(billingConfig) item.DefaultRateLimitPolicy = decodeObject(rateLimitPolicy) + item.RuntimePolicyOverride = decodeObject(runtimePolicyOverride) return item, nil } +func normalizePlatformModelAlias(alias string, base modelCatalogSnapshot) string { + alias = strings.TrimSpace(alias) + if alias == "" { + alias = firstNonEmpty(base.ProviderModelName, base.DisplayName, base.CanonicalModelKey) + } + if base.ProviderKey != "" { + alias = strings.TrimPrefix(alias, base.ProviderKey+":") + } + if alias == base.CanonicalModelKey { + alias = stripAliasPrefix(alias) + } + return strings.TrimSpace(alias) +} + +func stripAliasPrefix(alias string) string { + if before, after, ok := strings.Cut(alias, ":"); ok && before != "" && after != "" { + return after + } + return alias +} + func mergeObjects(base map[string]any, override map[string]any) map[string]any { out := map[string]any{} for key, value := range base { diff --git a/apps/api/internal/store/pricing_rules.go b/apps/api/internal/store/pricing_rules.go index cd1071c..8cf2ea8 100644 --- a/apps/api/internal/store/pricing_rules.go +++ b/apps/api/internal/store/pricing_rules.go @@ -159,6 +159,13 @@ RETURNING `+pricingRuleSetColumns, } func (s *Store) DeletePricingRuleSet(ctx context.Context, id string) error { + var ruleSetKey string + if err := s.pool.QueryRow(ctx, `SELECT rule_set_key FROM model_pricing_rule_sets WHERE id = $1::uuid`, id).Scan(&ruleSetKey); err != nil { + return err + } + if ruleSetKey == "default-multimodal-v1" { + return ErrProtectedDefault + } result, err := s.pool.Exec(ctx, `DELETE FROM model_pricing_rule_sets WHERE id = $1::uuid`, id) if err != nil { return err diff --git a/apps/api/internal/store/runtime_policies.go b/apps/api/internal/store/runtime_policies.go new file mode 100644 index 0000000..309c4ca --- /dev/null +++ b/apps/api/internal/store/runtime_policies.go @@ -0,0 +1,150 @@ +package store + +import ( + "context" + "encoding/json" + "strings" + + "github.com/jackc/pgx/v5" +) + +const runtimePolicyColumns = ` +id::text, policy_key, name, COALESCE(description, ''), rate_limit_policy, retry_policy, +auto_disable_policy, degrade_policy, metadata, status, created_at, updated_at` + +type RuntimePolicySetInput struct { + PolicyKey string `json:"policyKey"` + Name string `json:"name"` + Description string `json:"description"` + RateLimitPolicy map[string]any `json:"rateLimitPolicy"` + RetryPolicy map[string]any `json:"retryPolicy"` + AutoDisablePolicy map[string]any `json:"autoDisablePolicy"` + DegradePolicy map[string]any `json:"degradePolicy"` + Metadata map[string]any `json:"metadata"` + Status string `json:"status"` +} + +type runtimePolicyScanner interface { + Scan(dest ...any) error +} + +func (s *Store) ListRuntimePolicySets(ctx context.Context) ([]RuntimePolicySet, error) { + rows, err := s.pool.Query(ctx, `SELECT `+runtimePolicyColumns+` FROM model_runtime_policy_sets ORDER BY policy_key ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]RuntimePolicySet, 0) + for rows.Next() { + item, err := scanRuntimePolicySet(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) CreateRuntimePolicySet(ctx context.Context, input RuntimePolicySetInput) (RuntimePolicySet, error) { + input = normalizeRuntimePolicyInput(input) + rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) + retryPolicy, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy)) + autoDisablePolicy, _ := json.Marshal(emptyObjectIfNil(input.AutoDisablePolicy)) + degradePolicy, _ := json.Marshal(emptyObjectIfNil(input.DegradePolicy)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanRuntimePolicySet(s.pool.QueryRow(ctx, ` +INSERT INTO model_runtime_policy_sets ( + policy_key, name, description, rate_limit_policy, retry_policy, auto_disable_policy, degrade_policy, metadata, status +) +VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8, $9) +RETURNING `+runtimePolicyColumns, + input.PolicyKey, input.Name, input.Description, rateLimitPolicy, retryPolicy, + autoDisablePolicy, degradePolicy, metadata, input.Status, + )) +} + +func (s *Store) UpdateRuntimePolicySet(ctx context.Context, id string, input RuntimePolicySetInput) (RuntimePolicySet, error) { + input = normalizeRuntimePolicyInput(input) + rateLimitPolicy, _ := json.Marshal(emptyObjectIfNil(input.RateLimitPolicy)) + retryPolicy, _ := json.Marshal(emptyObjectIfNil(input.RetryPolicy)) + autoDisablePolicy, _ := json.Marshal(emptyObjectIfNil(input.AutoDisablePolicy)) + degradePolicy, _ := json.Marshal(emptyObjectIfNil(input.DegradePolicy)) + metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) + return scanRuntimePolicySet(s.pool.QueryRow(ctx, ` +UPDATE model_runtime_policy_sets +SET policy_key = $2, + name = $3, + description = NULLIF($4, ''), + rate_limit_policy = $5, + retry_policy = $6, + auto_disable_policy = $7, + degrade_policy = $8, + metadata = $9, + status = $10, + updated_at = now() +WHERE id = $1::uuid +RETURNING `+runtimePolicyColumns, + id, input.PolicyKey, input.Name, input.Description, rateLimitPolicy, retryPolicy, + autoDisablePolicy, degradePolicy, metadata, input.Status, + )) +} + +func (s *Store) DeleteRuntimePolicySet(ctx context.Context, id string) error { + var policyKey string + if err := s.pool.QueryRow(ctx, `SELECT policy_key FROM model_runtime_policy_sets WHERE id = $1::uuid`, id).Scan(&policyKey); err != nil { + return err + } + if policyKey == "default-runtime-v1" { + return ErrProtectedDefault + } + result, err := s.pool.Exec(ctx, `DELETE FROM model_runtime_policy_sets WHERE id = $1::uuid`, id) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +func scanRuntimePolicySet(scanner runtimePolicyScanner) (RuntimePolicySet, error) { + var item RuntimePolicySet + var rateLimitPolicy []byte + var retryPolicy []byte + var autoDisablePolicy []byte + var degradePolicy []byte + var metadata []byte + if err := scanner.Scan( + &item.ID, + &item.PolicyKey, + &item.Name, + &item.Description, + &rateLimitPolicy, + &retryPolicy, + &autoDisablePolicy, + °radePolicy, + &metadata, + &item.Status, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + return RuntimePolicySet{}, err + } + item.RateLimitPolicy = decodeObject(rateLimitPolicy) + item.RetryPolicy = decodeObject(retryPolicy) + item.AutoDisablePolicy = decodeObject(autoDisablePolicy) + item.DegradePolicy = decodeObject(degradePolicy) + item.Metadata = decodeObject(metadata) + return item, nil +} + +func normalizeRuntimePolicyInput(input RuntimePolicySetInput) RuntimePolicySetInput { + input.PolicyKey = strings.TrimSpace(input.PolicyKey) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.Status = strings.TrimSpace(input.Status) + if input.Status == "" { + input.Status = "active" + } + return input +} diff --git a/apps/api/migrations/0001_init.sql b/apps/api/migrations/0001_init.sql index 2c8b819..b993663 100644 --- a/apps/api/migrations/0001_init.sql +++ b/apps/api/migrations/0001_init.sql @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS model_catalog_providers ( display_name text NOT NULL, provider_type text NOT NULL DEFAULT 'openai', icon_path text, + default_base_url text, + default_auth_type text NOT NULL DEFAULT 'APIKey', source text NOT NULL DEFAULT 'gateway', capability_schema jsonb NOT NULL DEFAULT '{}'::jsonb, default_rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb, @@ -272,6 +274,7 @@ CREATE TABLE IF NOT EXISTS gateway_api_keys ( tenant_key text, user_id text, key_prefix text NOT NULL, + key_secret text, key_hash text NOT NULL UNIQUE, name text NOT NULL, scopes jsonb NOT NULL DEFAULT '[]'::jsonb, diff --git a/apps/api/migrations/0002_invitation_relationship_only.sql b/apps/api/migrations/0002_invitation_relationship_only.sql index 28aa96d..65dad25 100644 --- a/apps/api/migrations/0002_invitation_relationship_only.sql +++ b/apps/api/migrations/0002_invitation_relationship_only.sql @@ -252,6 +252,7 @@ CREATE TABLE IF NOT EXISTS gateway_api_keys ( tenant_key text, user_id text, key_prefix text NOT NULL, + key_secret text, key_hash text NOT NULL UNIQUE, name text NOT NULL, scopes jsonb NOT NULL DEFAULT '[]'::jsonb, diff --git a/apps/api/migrations/0009_provider_default_connection.sql b/apps/api/migrations/0009_provider_default_connection.sql new file mode 100644 index 0000000..b316263 --- /dev/null +++ b/apps/api/migrations/0009_provider_default_connection.sql @@ -0,0 +1,50 @@ +ALTER TABLE model_catalog_providers + ADD COLUMN IF NOT EXISTS default_base_url text, + ADD COLUMN IF NOT EXISTS default_auth_type text NOT NULL DEFAULT 'APIKey'; + +WITH source_defaults(provider_key, default_base_url, default_auth_type) AS ( + VALUES + ('easyai', 'https://51easyai.com/api/v1', 'APIKey'), + ('runninghub', 'https://www.runninghub.ai', 'APIKey'), + ('LiblibAI', 'https://openapi.liblibai.cloud', 'AccessKey-SecretKey'), + ('keling', 'https://api-beijing.klingai.com/v1', 'AccessKey-SecretKey'), + ('gemini', 'https://generativelanguage.googleapis.com/v1beta', 'APIKey'), + ('openai', 'https://api.openai.com/v1', 'APIKey'), + ('aliyun-bailian-openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'APIKey'), + ('gemini-openai', 'https://generativelanguage.googleapis.com/v1beta/openai', 'APIKey'), + ('volces-openai', 'https://ark.cn-beijing.volces.com/api/v3', 'APIKey'), + ('zhipu-openai', 'https://open.bigmodel.cn/api/paas/v4', 'APIKey'), + ('minimax-openai', 'https://api.minimaxi.com/v1', 'APIKey'), + ('openrouter-openai', 'https://openrouter.ai/api/v1', 'APIKey'), + ('aliyun-bailian', 'https://dashscope.aliyuncs.com/api/v1', 'APIKey'), + ('ollama', 'http://:11434/v1', 'APIKey'), + ('blackforest', 'https://api.bfl.ai/v1', 'APIKey'), + ('dify', 'http://localhost/v1', 'APIKey'), + ('volces', 'https://ark.cn-beijing.volces.com/api/v3', 'AccessKey-SecretKey'), + ('jimeng', '', 'APIKey'), + ('silicon-flow-openai', 'https://api.siliconflow.cn/v1', 'APIKey'), + ('tripo3d', 'https://api.tripo3d.ai/v2/openapi', 'APIKey'), + ('tencent-hunyuan-image', 'https://aiart.tencentcloudapi.com', 'AccessKey-SecretKey'), + ('tencent-hunyuan-video', 'https://vclm.tencentcloudapi.com', 'AccessKey-SecretKey'), + ('tencent-hunyuan', 'https://ai3d.tencentcloudapi.com', 'AccessKey-SecretKey'), + ('suno', 'https://api.cqtai.com/api/cqt', 'APIKey'), + ('minimax', 'https://api.minimaxi.com/v1', 'APIKey'), + ('midjourney', 'https://api.legnext.ai/api/v1', 'APIKey'), + ('tencent-lke', 'https://wss.lke.cloud.tencent.com/v1', 'AccessKey-SecretKey'), + ('universal', 'https://example.com/v1/image/edits', 'APIKey'), + ('newapi', 'https://api.newapi.com/v1', 'APIKey'), + ('vidu', 'https://api.vidu.cn/ent/v2', 'APIKey'), + ('n8n', 'http://127.0.0.1:5678', 'APIKey'), + ('mock-test', 'http://mock.test', 'APIKey') +) +UPDATE model_catalog_providers p +SET default_base_url = NULLIF(source_defaults.default_base_url, ''), + default_auth_type = source_defaults.default_auth_type, + metadata = p.metadata || jsonb_build_object( + 'defaultConnectionSource', 'server-main.integration-platform', + 'defaultBaseUrlSynced', NULLIF(source_defaults.default_base_url, ''), + 'defaultAuthTypeSynced', source_defaults.default_auth_type + ), + updated_at = now() +FROM source_defaults +WHERE p.provider_key = source_defaults.provider_key; diff --git a/apps/api/migrations/0010_base_model_pricing_rule_binding.sql b/apps/api/migrations/0010_base_model_pricing_rule_binding.sql new file mode 100644 index 0000000..e106b16 --- /dev/null +++ b/apps/api/migrations/0010_base_model_pricing_rule_binding.sql @@ -0,0 +1,28 @@ +ALTER TABLE IF EXISTS base_model_catalog + ADD COLUMN IF NOT EXISTS pricing_rule_set_id uuid REFERENCES model_pricing_rule_sets(id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint c + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) + WHERE c.contype = 'f' + AND c.conrelid = 'base_model_catalog'::regclass + AND a.attname = 'pricing_rule_set_id' + ) THEN + ALTER TABLE base_model_catalog + ADD CONSTRAINT fk_base_model_catalog_pricing_rule_set + FOREIGN KEY (pricing_rule_set_id) REFERENCES model_pricing_rule_sets(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_base_model_catalog_pricing_rule_set + ON base_model_catalog(pricing_rule_set_id); + +UPDATE base_model_catalog +SET pricing_rule_set_id = ( + SELECT id FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1' LIMIT 1 +) +WHERE pricing_rule_set_id IS NULL + AND EXISTS (SELECT 1 FROM model_pricing_rule_sets WHERE rule_set_key = 'default-multimodal-v1'); diff --git a/apps/api/migrations/0011_platform_internal_name.sql b/apps/api/migrations/0011_platform_internal_name.sql new file mode 100644 index 0000000..1e6a0cc --- /dev/null +++ b/apps/api/migrations/0011_platform_internal_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS integration_platforms + ADD COLUMN IF NOT EXISTS internal_name text; diff --git a/apps/api/migrations/0012_runtime_policy_sets.sql b/apps/api/migrations/0012_runtime_policy_sets.sql new file mode 100644 index 0000000..9d01664 --- /dev/null +++ b/apps/api/migrations/0012_runtime_policy_sets.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS model_runtime_policy_sets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + policy_key text NOT NULL UNIQUE, + name text NOT NULL, + description text, + rate_limit_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + retry_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + auto_disable_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + degrade_policy jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO model_runtime_policy_sets ( + policy_key, name, description, rate_limit_policy, retry_policy, auto_disable_policy, degrade_policy, metadata, status +) +VALUES ( + 'default-runtime-v1', + '默认运行策略', + '默认包含 TPM/RPM/并发、失败重试、自动禁用和优先级降级关键词。', + '{"rules":[{"metric":"rpm","limit":120,"windowSeconds":60},{"metric":"tpm_total","limit":240000,"windowSeconds":60},{"metric":"concurrent","limit":6,"leaseTtlSeconds":120}]}'::jsonb, + '{"enabled":true,"maxAttempts":2,"allowKeywords":["rate_limit","timeout","server_error","network","429","5xx"],"denyKeywords":["invalid_api_key","insufficient_quota","billing_not_active","permission_denied"]}'::jsonb, + '{"enabled":false,"threshold":3,"windowSeconds":300,"keywords":["invalid_api_key","account_deactivated","permission_denied","billing_not_active"]}'::jsonb, + '{"enabled":true,"cooldownSeconds":300,"keywords":["rate_limit","quota","timeout","temporarily_unavailable","overloaded"]}'::jsonb, + '{"seed":"0012_runtime_policy_sets"}'::jsonb, + 'active' +) +ON CONFLICT (policy_key) DO NOTHING; + +ALTER TABLE IF EXISTS base_model_catalog + ADD COLUMN IF NOT EXISTS runtime_policy_set_id uuid REFERENCES model_runtime_policy_sets(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS runtime_policy_override jsonb NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE IF EXISTS platform_models + ADD COLUMN IF NOT EXISTS runtime_policy_set_id uuid REFERENCES model_runtime_policy_sets(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS runtime_policy_override jsonb NOT NULL DEFAULT '{}'::jsonb; + +CREATE INDEX IF NOT EXISTS idx_base_model_catalog_runtime_policy + ON base_model_catalog(runtime_policy_set_id); + +CREATE INDEX IF NOT EXISTS idx_platform_models_runtime_policy + ON platform_models(runtime_policy_set_id); + +UPDATE base_model_catalog +SET runtime_policy_set_id = ( + SELECT id FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1' LIMIT 1 +) +WHERE runtime_policy_set_id IS NULL + AND EXISTS (SELECT 1 FROM model_runtime_policy_sets WHERE policy_key = 'default-runtime-v1'); diff --git a/apps/api/migrations/0013_access_rules.sql b/apps/api/migrations/0013_access_rules.sql new file mode 100644 index 0000000..2d174d6 --- /dev/null +++ b/apps/api/migrations/0013_access_rules.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS gateway_access_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + subject_type text NOT NULL CHECK (subject_type IN ('user_group', 'tenant', 'user', 'api_key')), + subject_id uuid NOT NULL, + resource_type text NOT NULL CHECK (resource_type IN ('platform', 'platform_model', 'base_model')), + resource_id uuid NOT NULL, + effect text NOT NULL CHECK (effect IN ('allow', 'deny')), + priority integer NOT NULL DEFAULT 100, + min_permission_level integer NOT NULL DEFAULT 0, + conditions jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (subject_type, subject_id, resource_type, resource_id, effect) +); + +CREATE INDEX IF NOT EXISTS idx_gateway_access_rules_subject + ON gateway_access_rules(subject_type, subject_id, status); + +CREATE INDEX IF NOT EXISTS idx_gateway_access_rules_resource + ON gateway_access_rules(resource_type, resource_id, status); + +CREATE INDEX IF NOT EXISTS idx_gateway_access_rules_effect + ON gateway_access_rules(effect, status, priority); diff --git a/apps/api/migrations/0014_base_model_catalog_type.sql b/apps/api/migrations/0014_base_model_catalog_type.sql new file mode 100644 index 0000000..2e71fcd --- /dev/null +++ b/apps/api/migrations/0014_base_model_catalog_type.sql @@ -0,0 +1,73 @@ +ALTER TABLE IF EXISTS base_model_catalog + ADD COLUMN IF NOT EXISTS catalog_type text NOT NULL DEFAULT 'system', + ADD COLUMN IF NOT EXISTS default_snapshot jsonb, + ADD COLUMN IF NOT EXISTS customized_at timestamptz; + +UPDATE base_model_catalog +SET catalog_type = CASE + WHEN metadata ->> 'source' = 'server-main.integration-platform' + OR metadata ? 'sourceProviderCode' + OR metadata ? 'rawModel' + THEN 'system' + ELSE 'custom' +END; + +UPDATE base_model_catalog +SET default_snapshot = jsonb_build_object( + 'providerKey', provider_key, + 'canonicalModelKey', canonical_model_key, + 'providerModelName', provider_model_name, + 'modelType', CASE + WHEN jsonb_typeof(capabilities -> 'originalTypes') = 'array' THEN capabilities -> 'originalTypes' + ELSE jsonb_build_array(model_type) + END, + 'modelAlias', display_name, + 'capabilities', capabilities, + 'baseBillingConfig', base_billing_config, + 'defaultRateLimitPolicy', default_rate_limit_policy, + 'pricingRuleSetId', COALESCE(pricing_rule_set_id::text, ''), + 'runtimePolicySetId', COALESCE(runtime_policy_set_id::text, ''), + 'runtimePolicyOverride', runtime_policy_override, + 'metadata', metadata, + 'pricingVersion', pricing_version, + 'status', status +) +WHERE catalog_type = 'system' + AND COALESCE(default_snapshot, '{}'::jsonb) = '{}'::jsonb; + +CREATE OR REPLACE FUNCTION fill_system_base_model_default_snapshot() +RETURNS trigger AS $$ +BEGIN + IF NEW.catalog_type = 'system' AND NEW.default_snapshot IS NULL THEN + NEW.default_snapshot = jsonb_build_object( + 'providerKey', NEW.provider_key, + 'canonicalModelKey', NEW.canonical_model_key, + 'providerModelName', NEW.provider_model_name, + 'modelType', CASE + WHEN jsonb_typeof(NEW.capabilities -> 'originalTypes') = 'array' THEN NEW.capabilities -> 'originalTypes' + ELSE jsonb_build_array(NEW.model_type) + END, + 'modelAlias', NEW.display_name, + 'capabilities', NEW.capabilities, + 'baseBillingConfig', NEW.base_billing_config, + 'defaultRateLimitPolicy', NEW.default_rate_limit_policy, + 'pricingRuleSetId', COALESCE(NEW.pricing_rule_set_id::text, ''), + 'runtimePolicySetId', COALESCE(NEW.runtime_policy_set_id::text, ''), + 'runtimePolicyOverride', NEW.runtime_policy_override, + 'metadata', NEW.metadata, + 'pricingVersion', NEW.pricing_version, + 'status', NEW.status + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_fill_system_base_model_default_snapshot ON base_model_catalog; +CREATE TRIGGER trg_fill_system_base_model_default_snapshot +BEFORE INSERT ON base_model_catalog +FOR EACH ROW +EXECUTE FUNCTION fill_system_base_model_default_snapshot(); + +CREATE INDEX IF NOT EXISTS idx_base_model_catalog_type + ON base_model_catalog(catalog_type, status); diff --git a/apps/api/migrations/0015_base_model_model_type_array.sql b/apps/api/migrations/0015_base_model_model_type_array.sql new file mode 100644 index 0000000..0c3168f --- /dev/null +++ b/apps/api/migrations/0015_base_model_model_type_array.sql @@ -0,0 +1,41 @@ +UPDATE base_model_catalog +SET default_snapshot = default_snapshot + || jsonb_build_object( + 'modelType', CASE + WHEN jsonb_typeof(default_snapshot -> 'modelType') = 'array' THEN default_snapshot -> 'modelType' + WHEN COALESCE(default_snapshot ->> 'modelType', '') <> '' THEN jsonb_build_array(default_snapshot ->> 'modelType') + WHEN jsonb_typeof(capabilities -> 'originalTypes') = 'array' THEN capabilities -> 'originalTypes' + ELSE jsonb_build_array(model_type) + END, + 'modelAlias', COALESCE(default_snapshot ->> 'modelAlias', default_snapshot ->> 'displayName', display_name) + ) +WHERE catalog_type = 'system' + AND COALESCE(default_snapshot, '{}'::jsonb) <> '{}'::jsonb; + +CREATE OR REPLACE FUNCTION fill_system_base_model_default_snapshot() +RETURNS trigger AS $$ +BEGIN + IF NEW.catalog_type = 'system' AND NEW.default_snapshot IS NULL THEN + NEW.default_snapshot = jsonb_build_object( + 'providerKey', NEW.provider_key, + 'canonicalModelKey', NEW.canonical_model_key, + 'providerModelName', NEW.provider_model_name, + 'modelType', CASE + WHEN jsonb_typeof(NEW.capabilities -> 'originalTypes') = 'array' THEN NEW.capabilities -> 'originalTypes' + ELSE jsonb_build_array(NEW.model_type) + END, + 'modelAlias', NEW.display_name, + 'capabilities', NEW.capabilities, + 'baseBillingConfig', NEW.base_billing_config, + 'defaultRateLimitPolicy', NEW.default_rate_limit_policy, + 'pricingRuleSetId', COALESCE(NEW.pricing_rule_set_id::text, ''), + 'runtimePolicySetId', COALESCE(NEW.runtime_policy_set_id::text, ''), + 'runtimePolicyOverride', NEW.runtime_policy_override, + 'metadata', NEW.metadata, + 'pricingVersion', NEW.pricing_version, + 'status', NEW.status + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/apps/api/migrations/0016_api_key_recoverable_secret.sql b/apps/api/migrations/0016_api_key_recoverable_secret.sql new file mode 100644 index 0000000..d4ec70a --- /dev/null +++ b/apps/api/migrations/0016_api_key_recoverable_secret.sql @@ -0,0 +1,2 @@ +ALTER TABLE gateway_api_keys + ADD COLUMN IF NOT EXISTS key_secret text; diff --git a/apps/api/project.json b/apps/api/project.json index 21ed72e..b1f2d30 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -9,7 +9,7 @@ "executor": "nx:run-commands", "options": { "cwd": "apps/api", - "command": "node ../../scripts/go-watch.mjs -- go run ./cmd/gateway" + "command": "GO_WATCH_PRESTART='go run ./cmd/migrate' node ../../scripts/go-watch.mjs -- go run ./cmd/gateway" } }, "migrate": { diff --git a/apps/web/package.json b/apps/web/package.json index d7f6ebc..3e755e5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,20 +10,33 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@assistant-ui/react": "^0.14.0", + "@assistant-ui/react-streamdown": "^0.3.0", "@easyai-ai-gateway/contracts": "workspace:*", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@streamdown/cjk": "^1.0.3", + "@streamdown/code": "^1.1.1", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", "@vitejs/plugin-react": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "katex": "^0.16.45", "lucide-react": "^1.14.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "vite": "^7.0.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.3.0", "typescript": "^5.8.0" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index faa33cc..a8d7834 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,14 @@ -import { useEffect, useMemo, useState, type FormEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import type { BaseModelCatalogItem, CatalogProvider, + GatewayAccessRuleBatchRequest, + GatewayAccessRule, + GatewayAccessRuleUpsertRequest, GatewayApiKey, + GatewayTenantUpsertRequest, GatewayTask, + GatewayUserUpsertRequest, GatewayTenant, GatewayUser, IntegrationPlatform, @@ -11,21 +16,36 @@ import type { PricingRule, PricingRuleSet, RateLimitWindow, + RuntimePolicySet, + UserGroupUpsertRequest, UserGroup, } from '@easyai-ai-gateway/contracts'; import { + batchAccessRules, + createAccessRule, createApiKey, + createGatewayUser, createPlatform, - createPlatformModel, + createTenant, + createUserGroup, + deleteAccessRule, + deleteGatewayUser, + deletePlatform, + deleteTenant, + deleteUserGroup, getHealth, getTask, + listAccessRules, listApiKeys, listBaseModels, listCatalogProviders, listModels, + listPlayableApiKeys, + listPlayableModels, listPlatforms, listPricingRules, listPricingRuleSets, + listRuntimePolicySets, listPublicBaseModels, listPublicCatalogProviders, listRateLimitWindows, @@ -34,25 +54,34 @@ import { listUsers, loginLocalAccount, registerLocalAccount, + replacePlatformModels, type HealthResponse, + updateAccessRule, + updateGatewayUser, + updatePlatform, + updateTenant, + updateUserGroup, } from './api'; import type { ConsoleData, StatItem } from './app-state'; import { AppShell } from './components/layout/AppShell'; import { LoginRequiredPanel } from './components/LoginRequiredPanel'; import { useCatalogOperations } from './hooks/useCatalogOperations'; import { usePricingRuleSetOperations } from './hooks/usePricingRuleSetOperations'; +import { useRuntimePolicySetOperations } from './hooks/useRuntimePolicySetOperations'; import { persistAccessToken, readStoredAccessToken } from './lib/auth-storage'; import { runTask } from './lib/run-task'; import { AdminPage } from './pages/AdminPage'; import { ApiDocsPage } from './pages/ApiDocsPage'; import { HomePage } from './pages/HomePage'; import { ModelsPage } from './pages/ModelsPage'; +import { PlaygroundPage } from './pages/PlaygroundPage'; import { WorkspacePage } from './pages/WorkspacePage'; import { parseAppRoute, pathForAdminSection, pathForApiDocSection, pathForPage, + pathForPlaygroundMode, pathForWorkspaceSection, type AppRouteState, } from './routing'; @@ -64,19 +93,41 @@ import type { LoadState, LoginForm, PageKey, - PlatformForm, - PlatformModelForm, + PlaygroundMode, + PlatformCreateInput, + PlatformModelBindingInput, + PlatformWithModelsInput, RegisterForm, TaskForm, WorkspaceSection, } from './types'; +type DataKey = + | 'health' + | 'publicCatalog' + | 'playgroundApiKeys' + | 'playgroundModels' + | 'platforms' + | 'models' + | 'providers' + | 'baseModels' + | 'pricingRules' + | 'pricingRuleSets' + | 'runtimePolicySets' + | 'rateLimitWindows' + | 'tenants' + | 'users' + | 'userGroups' + | 'accessRules' + | 'apiKeys'; + export function App() { const initialRoute = parseAppRoute(); const [activePage, setActivePage] = useState(initialRoute.activePage); const [adminSection, setAdminSection] = useState(initialRoute.adminSection); const [workspaceSection, setWorkspaceSection] = useState(initialRoute.workspaceSection); const [apiDocSection, setApiDocSection] = useState(initialRoute.apiDocSection); + const [playgroundMode, setPlaygroundMode] = useState(initialRoute.playgroundMode); const [token, setToken] = useState(readStoredAccessToken); const [externalToken, setExternalToken] = useState(''); const [authMode, setAuthMode] = useState('login'); @@ -85,10 +136,13 @@ export function App() { const [health, setHealth] = useState(null); const [platforms, setPlatforms] = useState([]); const [models, setModels] = useState([]); + const [playgroundModels, setPlaygroundModels] = useState([]); const [providers, setProviders] = useState([]); const [baseModels, setBaseModels] = useState([]); const [pricingRules, setPricingRules] = useState([]); const [pricingRuleSets, setPricingRuleSets] = useState([]); + const [runtimePolicySets, setRuntimePolicySets] = useState([]); + const [accessRules, setAccessRules] = useState([]); const [rateLimitWindows, setRateLimitWindows] = useState([]); const [tenants, setTenants] = useState([]); const [users, setUsers] = useState([]); @@ -96,15 +150,17 @@ export function App() { const [apiKeys, setApiKeys] = useState([]); const [apiKeyForm, setApiKeyForm] = useState({ name: 'Local smoke key' }); const [apiKeySecret, setApiKeySecret] = useState(''); - const [platformForm, setPlatformForm] = useState({ provider: 'openai', platformKey: 'openai-simulation', name: 'OpenAI Simulation', baseUrl: 'https://api.openai.com/v1', pricingRuleSetId: '', defaultDiscountFactor: '1' }); - const [platformModelForm, setPlatformModelForm] = useState({ platformId: '', canonicalModelKey: '', modelName: 'gpt-4o-mini', modelAlias: 'gpt-4o-mini', modelType: 'chat', pricingRuleSetId: '', discountFactor: '' }); + const [apiKeySecretsById, setApiKeySecretsById] = useState>({}); + const [selectedPlaygroundApiKeyId, setSelectedPlaygroundApiKeyId] = useState(''); const [taskForm, setTaskForm] = useState({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' }); const [taskResult, setTaskResult] = useState(null); const [coreState, setCoreState] = useState('idle'); const [coreMessage, setCoreMessage] = useState(''); const [state, setState] = useState('idle'); const [error, setError] = useState(''); - const { removeBaseModel, removeProvider, saveBaseModel, saveProvider } = useCatalogOperations({ + const loadedDataKeysRef = useRef(new Set()); + const loadingDataKeysRef = useRef(new Set()); + const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({ setBaseModels, setCoreMessage, setCoreState, @@ -117,15 +173,19 @@ export function App() { setPricingRuleSets, token, }); + const { removeRuntimePolicySet, saveRuntimePolicySet } = useRuntimePolicySetOperations({ + setCoreMessage, + setCoreState, + setRuntimePolicySets, + token, + }); useEffect(() => { - void loadPublicCatalog(); + void ensureData(['health']); }, []); useEffect(() => { - if (token) { - void refresh(token); - } - }, []); + void ensureRouteData(token); + }, [activePage, adminSection, workspaceSection, token]); useEffect(() => { function handlePopState() { applyRoute(parseAppRoute()); @@ -147,11 +207,14 @@ export function App() { { label: '平台模型', value: enabledModels, tone: 'violet' }, { label: 'Provider', value: activeProviders || providers.length, tone: 'amber' }, { label: '定价规则', value: pricingRules.length, tone: 'cyan' }, + { label: '运行策略', value: runtimePolicySets.length, tone: 'slate' }, + { label: '访问规则', value: accessRules.length, tone: 'amber' }, { label: '限流窗口', value: activeRateWindows, tone: 'rose' }, ]; - }, [models, platforms, pricingRules.length, providers, rateLimitWindows, tenants.length, userGroups.length, users.length]); + }, [accessRules.length, models, platforms, pricingRules.length, providers, rateLimitWindows, runtimePolicySets.length, tenants.length, userGroups.length, users.length]); const data = useMemo(() => ({ + accessRules, apiKeys, baseModels, models, @@ -160,58 +223,108 @@ export function App() { pricingRuleSets, providers, rateLimitWindows, + runtimePolicySets, taskResult, tenants, userGroups, users, - }), [apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, taskResult, tenants, userGroups, users]); + }), [accessRules, apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tenants, userGroups, users]); async function refresh(nextToken = token) { + await ensureRouteData(nextToken, true); + } + + async function ensureRouteData(nextToken = token, force = false) { + await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force); + } + + async function ensureData(keys: DataKey[], nextToken = token, force = false) { + const uniqueKeys = Array.from(new Set(keys)); + const requestKeys = uniqueKeys.filter((key) => { + if (!force && loadedDataKeysRef.current.has(key)) return false; + if (loadingDataKeysRef.current.has(key)) return false; + return key === 'health' || key === 'publicCatalog' || Boolean(nextToken); + }); + if (requestKeys.length === 0) return; + + requestKeys.forEach((key) => loadingDataKeysRef.current.add(key)); setState('loading'); setError(''); try { - const responses = await Promise.all([ - listPlatforms(nextToken), - listModels(nextToken), - listCatalogProviders(nextToken), - listBaseModels(nextToken), - listPricingRules(nextToken), - listPricingRuleSets(nextToken), - listRateLimitWindows(nextToken), - listTenants(nextToken), - listUsers(nextToken), - listUserGroups(nextToken), - listApiKeys(nextToken), - ]); - setPlatforms(responses[0].items); - setModels(responses[1].items); - setProviders(responses[2].items); - setBaseModels(responses[3].items); - setPricingRules(responses[4].items); - setPricingRuleSets(responses[5].items); - setRateLimitWindows(responses[6].items); - setTenants(responses[7].items); - setUsers(responses[8].items); - setUserGroups(responses[9].items); - setApiKeys(responses[10].items); + await Promise.all(requestKeys.map((key) => loadDataKey(key, nextToken))); + requestKeys.forEach((key) => loadedDataKeysRef.current.add(key)); setState('ready'); } catch (err) { setState('error'); setError(err instanceof Error ? err.message : '加载失败'); + } finally { + requestKeys.forEach((key) => loadingDataKeysRef.current.delete(key)); } } - async function loadPublicCatalog() { - try { - const [healthResult, providersResult, baseModelsResult] = await Promise.all([ - getHealth(), - listPublicCatalogProviders(), - listPublicBaseModels(), - ]); - setHealth(healthResult); - setProviders(providersResult.items); - setBaseModels(baseModelsResult.items); - } catch (err) { - setError(err instanceof Error ? err.message : '公共模型目录加载失败'); + + async function loadDataKey(key: DataKey, nextToken: string) { + switch (key) { + case 'health': { + setHealth(await getHealth()); + return; + } + case 'publicCatalog': { + const [providersResult, baseModelsResult] = await Promise.all([ + listPublicCatalogProviders(), + listPublicBaseModels(), + ]); + setProviders(providersResult.items); + setBaseModels(baseModelsResult.items); + return; + } + case 'platforms': + setPlatforms((await listPlatforms(nextToken)).items); + return; + case 'models': + setModels((await listModels(nextToken)).items); + return; + case 'playgroundModels': + setPlaygroundModels((await listPlayableModels(nextToken)).items); + return; + case 'playgroundApiKeys': { + const response = await listPlayableApiKeys(nextToken); + setApiKeys(response.items); + setApiKeySecretsById(Object.fromEntries(response.items.map((item) => [item.id, item.secret]))); + setSelectedPlaygroundApiKeyId((current) => current && response.items.some((item) => item.id === current) ? current : response.items[0]?.id ?? ''); + return; + } + case 'providers': + setProviders((await listCatalogProviders(nextToken)).items); + return; + case 'baseModels': + setBaseModels((await listBaseModels(nextToken)).items); + return; + case 'pricingRules': + setPricingRules((await listPricingRules(nextToken)).items); + return; + case 'pricingRuleSets': + setPricingRuleSets((await listPricingRuleSets(nextToken)).items); + return; + case 'runtimePolicySets': + setRuntimePolicySets((await listRuntimePolicySets(nextToken)).items); + return; + case 'rateLimitWindows': + setRateLimitWindows((await listRateLimitWindows(nextToken)).items); + return; + case 'tenants': + setTenants((await listTenants(nextToken)).items); + return; + case 'users': + setUsers((await listUsers(nextToken)).items); + return; + case 'userGroups': + setUserGroups((await listUserGroups(nextToken)).items); + return; + case 'accessRules': + setAccessRules((await listAccessRules(nextToken)).items); + return; + case 'apiKeys': + setApiKeys((await listApiKeys(nextToken)).items); } } @@ -234,7 +347,7 @@ export function App() { } persistAccessToken(nextToken); setToken(nextToken); - await refresh(nextToken); + await ensureRouteData(nextToken, true); } async function authenticate(request: () => Promise<{ accessToken: string }>, fallback: string) { @@ -244,7 +357,7 @@ export function App() { const response = await request(); persistAccessToken(response.accessToken); setToken(response.accessToken); - await refresh(response.accessToken); + await ensureRouteData(response.accessToken, true); } catch (err) { setState('error'); setError(err instanceof Error ? err.message : fallback); @@ -258,6 +371,8 @@ export function App() { try { const response = await createApiKey(token, { name: apiKeyForm.name, scopes: ['chat', 'image', 'video'] }); setApiKeySecret(response.secret); + setApiKeySecretsById((current) => ({ ...current, [response.apiKey.id]: response.secret })); + setSelectedPlaygroundApiKeyId(response.apiKey.id); setApiKeys((current) => [response.apiKey, ...current.filter((item) => item.id !== response.apiKey.id)]); setCoreState('ready'); setCoreMessage('API Key 已创建,secret 仅展示一次。'); @@ -267,54 +382,183 @@ export function App() { } } - async function submitPlatform(event: FormEvent) { - event.preventDefault(); + async function savePlatformWithModels(input: PlatformWithModelsInput) { setCoreState('loading'); setCoreMessage(''); try { - const platform = await createPlatform(token, { - ...platformForm, - authType: 'bearer', - config: { testMode: true }, - credentials: { mode: 'simulation' }, - defaultDiscountFactor: Number(platformForm.defaultDiscountFactor) || 1, - pricingRuleSetId: platformForm.pricingRuleSetId || undefined, - }); - setPlatforms((current) => [platform, ...current.filter((item) => item.id !== platform.id)]); - setPlatformModelForm((current) => ({ ...current, platformId: current.platformId || platform.id })); + const platform = input.platformId + ? await updatePlatform(token, input.platformId, input.platform) + : await createPlatform(token, input.platform); + const platformForState = withCredentialPreviewFallback( + platform, + input.platform, + input.platformId ? platforms.find((item) => item.id === input.platformId) : undefined, + ); + const modelBindings = input.models.map((modelInput) => mergeExistingPlatformModelInput(modelInput, models, platform.id)); + const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings); + setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]); + setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]); setCoreState('ready'); - setCoreMessage('平台已创建,当前阶段平台凭证仅全局管理员可配置。'); + setCoreMessage(input.platformId + ? `平台已更新,当前绑定 ${input.models.length} 个模型。` + : `平台已创建,已绑定 ${input.models.length} 个模型。`); } catch (err) { setCoreState('error'); - setCoreMessage(err instanceof Error ? err.message : '创建平台失败'); + setCoreMessage(err instanceof Error ? err.message : input.platformId ? '更新平台失败' : '创建平台失败'); + throw err; } } - async function submitPlatformModel(event: FormEvent) { - event.preventDefault(); - if (!platformModelForm.platformId || !platformModelForm.modelName || !platformModelForm.modelType) { - setCoreState('error'); - setCoreMessage('请选择平台并填写模型名。'); - return; - } + async function removePlatform(platformId: string) { setCoreState('loading'); setCoreMessage(''); try { - const model = await createPlatformModel(token, platformModelForm.platformId, { - canonicalModelKey: platformModelForm.canonicalModelKey || undefined, - modelAlias: platformModelForm.modelAlias || platformModelForm.modelName, - modelName: platformModelForm.modelName, - modelType: platformModelForm.modelType, - discountFactor: Number(platformModelForm.discountFactor) || undefined, - pricingRuleSetId: platformModelForm.pricingRuleSetId || undefined, - retryPolicy: { enabled: true, maxAttempts: 2 }, - }); - setModels((current) => [model, ...current.filter((item) => item.id !== model.id)]); + await deletePlatform(token, platformId); + setPlatforms((current) => current.filter((item) => item.id !== platformId)); + setModels((current) => current.filter((item) => item.platformId !== platformId)); setCoreState('ready'); - setCoreMessage('平台模型已绑定,可参与 simulation 任务路由。'); + setCoreMessage('平台已删除。'); } catch (err) { setCoreState('error'); - setCoreMessage(err instanceof Error ? err.message : '绑定平台模型失败'); + setCoreMessage(err instanceof Error ? err.message : '删除平台失败'); + throw err; + } + } + + async function saveTenant(input: GatewayTenantUpsertRequest, tenantId?: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input); + setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]); + setCoreState('ready'); + setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : tenantId ? '更新租户失败' : '创建租户失败'); + throw err; + } + } + + async function removeTenant(tenantId: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + await deleteTenant(token, tenantId); + setTenants((current) => current.filter((tenant) => tenant.id !== tenantId)); + setCoreState('ready'); + setCoreMessage('租户已删除。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '删除租户失败'); + throw err; + } + } + + async function saveUser(input: GatewayUserUpsertRequest, userId?: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input); + setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]); + setCoreState('ready'); + setCoreMessage(userId ? '用户已更新。' : '用户已创建。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : userId ? '更新用户失败' : '创建用户失败'); + throw err; + } + } + + async function removeUser(userId: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + await deleteGatewayUser(token, userId); + setUsers((current) => current.filter((user) => user.id !== userId)); + setCoreState('ready'); + setCoreMessage('用户已删除。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '删除用户失败'); + throw err; + } + } + + async function saveUserGroup(input: UserGroupUpsertRequest, groupId?: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input); + setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]); + setCoreState('ready'); + setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : groupId ? '更新用户组失败' : '创建用户组失败'); + throw err; + } + } + + async function removeUserGroup(groupId: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + await deleteUserGroup(token, groupId); + setUserGroups((current) => current.filter((group) => group.id !== groupId)); + setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant)); + setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user)); + setCoreState('ready'); + setCoreMessage('用户组已删除。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '删除用户组失败'); + throw err; + } + } + + async function saveAccessRule(input: GatewayAccessRuleUpsertRequest, ruleId?: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); + setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); + setCoreState('ready'); + setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : ruleId ? '更新访问权限失败' : '创建访问权限失败'); + throw err; + } + } + + async function removeAccessRule(ruleId: string) { + setCoreState('loading'); + setCoreMessage(''); + try { + await deleteAccessRule(token, ruleId); + setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); + setCoreState('ready'); + setCoreMessage('访问权限规则已删除。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '删除访问权限失败'); + throw err; + } + } + + async function batchSaveAccessRules(input: GatewayAccessRuleBatchRequest) { + setCoreState('loading'); + setCoreMessage(''); + try { + const response = await batchAccessRules(token, input); + setAccessRules(response.items); + setCoreState('ready'); + setCoreMessage('访问权限已更新。'); + } catch (err) { + setCoreState('error'); + setCoreMessage(err instanceof Error ? err.message : '批量更新访问权限失败'); + throw err; } } @@ -338,19 +582,26 @@ export function App() { function signOut() { persistAccessToken(''); setToken(''); + loadedDataKeysRef.current = new Set(health ? ['health'] : []); + loadingDataKeysRef.current.clear(); setState('idle'); setPlatforms([]); setModels([]); + setPlaygroundModels([]); setProviders([]); setBaseModels([]); setPricingRules([]); setPricingRuleSets([]); + setRuntimePolicySets([]); + setAccessRules([]); setRateLimitWindows([]); setTenants([]); setUsers([]); setUserGroups([]); setApiKeys([]); setApiKeySecret(''); + setApiKeySecretsById({}); + setSelectedPlaygroundApiKeyId(''); setTaskResult(null); setCoreMessage(''); navigatePath('/'); @@ -362,13 +613,14 @@ export function App() { } function currentRouteState(): AppRouteState { - return { activePage, adminSection, apiDocSection, workspaceSection }; + return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection }; } function applyRoute(route: AppRouteState) { setActivePage(route.activePage); setAdminSection(route.adminSection); setApiDocSection(route.apiDocSection); + setPlaygroundMode(route.playgroundMode); setWorkspaceSection(route.workspaceSection); } @@ -395,6 +647,19 @@ export function App() { navigatePath(pathForApiDocSection(section)); } + function navigatePlaygroundMode(mode: PlaygroundMode) { + navigatePath(pathForPlaygroundMode(mode)); + } + + function openApiKeyCreation() { + navigatePath(pathForWorkspaceSection('apiKeys')); + } + + function useApiKeyForPlayground(apiKeyId?: string) { + if (apiKeyId) setSelectedPlaygroundApiKeyId(apiKeyId); + navigatePath(pathForPlaygroundMode('chat')); + } + const isAuthenticated = Boolean(token); return ( @@ -409,7 +674,21 @@ export function App() { onSignOut={signOut} > {error &&
{error}
} - {activePage === 'home' && } + {activePage === 'home' && } + {activePage === 'playground' && ( + + )} {activePage === 'models' && } {activePage === 'workspace' && ( isAuthenticated ? ( @@ -422,6 +701,7 @@ export function App() { onApiKeyFormChange={setApiKeyForm} onSectionChange={navigateWorkspaceSection} onSubmitApiKey={submitAPIKey} + onUseApiKeyForPlayground={useApiKeyForPlayground} /> ) : ( ) : ( ); } + +function platformModelIsSelected(model: PlatformModel, selectedModels: PlatformModelBindingInput[]) { + return selectedModels.some((selected) => { + if (selected.baseModelId && model.baseModelId) return selected.baseModelId === model.baseModelId; + return selected.modelName === model.modelName && selected.modelType === model.modelType; + }); +} + +function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, currentModels: PlatformModel[], platformId: string): PlatformModelBindingInput { + const existing = currentModels.find((model) => model.platformId === platformId && platformModelIsSelected(model, [input])); + if (!existing) return input; + return { + ...input, + discountFactor: (input.discountFactor ?? existing.discountFactor) || undefined, + pricingRuleSetId: input.pricingRuleSetId ?? existing.pricingRuleSetId, + rateLimitPolicy: input.rateLimitPolicy ?? existing.rateLimitPolicy, + retryPolicy: input.retryPolicy ?? existing.retryPolicy, + runtimePolicyOverride: input.runtimePolicyOverride ?? (existing.runtimePolicyOverride as Record | undefined), + runtimePolicySetId: input.runtimePolicySetId ?? existing.runtimePolicySetId, + }; +} + +function withCredentialPreviewFallback( + platform: IntegrationPlatform, + input: PlatformCreateInput, + existing?: IntegrationPlatform, +): IntegrationPlatform { + const responsePreview = nonEmptyRecord(platform.credentialsPreview); + if (responsePreview) return platform; + if (input.credentials !== undefined) { + const inputPreview = maskCredentialsPreview(input.credentials); + return { ...platform, credentialsPreview: inputPreview ?? {} }; + } + const existingPreview = nonEmptyRecord(existing?.credentialsPreview); + return existingPreview ? { ...platform, credentialsPreview: existingPreview } : platform; +} + +function maskCredentialsPreview(credentials: Record | undefined) { + if (!credentials || Object.keys(credentials).length === 0) return undefined; + return Object.fromEntries(Object.entries(credentials).map(([key, value]) => [key, maskCredentialValue(value)])); +} + +function maskCredentialValue(value: unknown): unknown { + if (typeof value === 'string') return maskSecret(value); + if (Array.isArray(value)) return value.map(maskCredentialValue); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).map(([key, nested]) => [key, maskCredentialValue(nested)])); + } + return value; +} + +function maskSecret(value: string) { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (trimmed.length <= 6) return '*'.repeat(trimmed.length); + return `${trimmed.slice(0, 3)}${'*'.repeat(trimmed.length - 6)}${trimmed.slice(-3)}`; +} + +function nonEmptyRecord(value: Record | undefined) { + return value && Object.keys(value).length > 0 ? value : undefined; +} + +function dataKeysForRoute( + activePage: PageKey, + adminSection: AdminSection, + workspaceSection: WorkspaceSection, + isAuthenticated: boolean, +): DataKey[] { + if (activePage === 'playground') return isAuthenticated ? ['playgroundModels', 'playgroundApiKeys'] : []; + if (activePage === 'models') return ['publicCatalog']; + if (activePage === 'home' || activePage === 'docs') return []; + if (!isAuthenticated) return []; + + if (activePage === 'workspace') { + if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys']; + if (workspaceSection === 'apiKeys') return ['apiKeys']; + return []; + } + + if (activePage !== 'admin') return []; + switch (adminSection) { + case 'overview': + return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'tenants', 'users', 'userGroups', 'accessRules']; + case 'globalModels': + return ['providers']; + case 'pricing': + return ['pricingRuleSets']; + case 'runtime': + return ['runtimePolicySets']; + case 'baseModels': + return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets']; + case 'platforms': + return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets']; + case 'tenants': + return ['tenants', 'userGroups']; + case 'users': + return ['users', 'tenants', 'userGroups']; + case 'userGroups': + return ['userGroups']; + case 'accessRules': + return ['accessRules', 'userGroups', 'platforms', 'models']; + default: + return []; + } +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 39e8bc2..f55839a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -5,19 +5,29 @@ import type { CatalogProvider, CatalogProviderUpsertRequest, CreatedGatewayApiKey, + GatewayAccessRuleBatchRequest, + GatewayAccessRule, + GatewayAccessRuleUpsertRequest, GatewayApiKey, GatewayTenant, + GatewayTenantUpsertRequest, GatewayTask, GatewayUser, + GatewayUserUpsertRequest, IntegrationPlatform, ListResponse, PlatformModel, + PlayableGatewayApiKey, PricingRule, PricingRuleSet, PricingRuleSetUpsertRequest, RateLimitWindow, + RuntimePolicySet, + RuntimePolicySetUpsertRequest, UserGroup, + UserGroupUpsertRequest, } from '@easyai-ai-gateway/contracts'; +import type { PlatformCreateInput, PlatformModelBindingInput } from './types'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; @@ -62,6 +72,10 @@ export async function listModels(token: string): Promise>('/api/v1/models', { token }); } +export async function listPlayableModels(token: string): Promise> { + return request>('/api/v1/playground/models', { token }); +} + export async function listPublicCatalogProviders(): Promise> { return request>('/api/v1/public/catalog/providers', { auth: false }); } @@ -128,6 +142,20 @@ export async function updateBaseModel( }); } +export async function resetBaseModel(token: string, baseModelId: string): Promise { + return request(`/api/v1/catalog/base-models/${baseModelId}/reset`, { + method: 'POST', + token, + }); +} + +export async function resetAllBaseModels(token: string): Promise> { + return request>('/api/v1/catalog/base-models/reset-all', { + method: 'POST', + token, + }); +} + export async function deleteBaseModel(token: string, baseModelId: string): Promise { await request(`/api/v1/catalog/base-models/${baseModelId}`, { method: 'DELETE', @@ -173,22 +201,168 @@ export async function deletePricingRuleSet(token: string, ruleSetId: string): Pr }); } +export async function listRuntimePolicySets(token: string): Promise> { + return request>('/api/v1/runtime/policy-sets', { token }); +} + +export async function createRuntimePolicySet( + token: string, + input: RuntimePolicySetUpsertRequest, +): Promise { + return request('/api/v1/runtime/policy-sets', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateRuntimePolicySet( + token: string, + policySetId: string, + input: RuntimePolicySetUpsertRequest, +): Promise { + return request(`/api/v1/runtime/policy-sets/${policySetId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteRuntimePolicySet(token: string, policySetId: string): Promise { + await request(`/api/v1/runtime/policy-sets/${policySetId}`, { + method: 'DELETE', + token, + }); +} + export async function listTenants(token: string): Promise> { return request>('/api/v1/tenants', { token }); } +export async function createTenant(token: string, input: GatewayTenantUpsertRequest): Promise { + return request('/api/v1/tenants', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateTenant(token: string, tenantId: string, input: GatewayTenantUpsertRequest): Promise { + return request(`/api/v1/tenants/${tenantId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteTenant(token: string, tenantId: string): Promise { + await request(`/api/v1/tenants/${tenantId}`, { + method: 'DELETE', + token, + }); +} + export async function listUsers(token: string): Promise> { return request>('/api/v1/users', { token }); } +export async function createGatewayUser(token: string, input: GatewayUserUpsertRequest): Promise { + return request('/api/v1/users', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateGatewayUser(token: string, userId: string, input: GatewayUserUpsertRequest): Promise { + return request(`/api/v1/users/${userId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteGatewayUser(token: string, userId: string): Promise { + await request(`/api/v1/users/${userId}`, { + method: 'DELETE', + token, + }); +} + export async function listUserGroups(token: string): Promise> { return request>('/api/v1/user-groups', { token }); } +export async function createUserGroup(token: string, input: UserGroupUpsertRequest): Promise { + return request('/api/v1/user-groups', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateUserGroup(token: string, groupId: string, input: UserGroupUpsertRequest): Promise { + return request(`/api/v1/user-groups/${groupId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteUserGroup(token: string, groupId: string): Promise { + await request(`/api/v1/user-groups/${groupId}`, { + method: 'DELETE', + token, + }); +} + +export async function listAccessRules(token: string): Promise> { + return request>('/api/v1/access-rules', { token }); +} + +export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise { + return request('/api/v1/access-rules', { + body: input, + method: 'POST', + token, + }); +} + +export async function batchAccessRules(token: string, input: GatewayAccessRuleBatchRequest): Promise> { + return request>('/api/v1/access-rules/batch', { + body: input, + method: 'POST', + token, + }); +} + +export async function updateAccessRule( + token: string, + ruleId: string, + input: GatewayAccessRuleUpsertRequest, +): Promise { + return request(`/api/v1/access-rules/${ruleId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deleteAccessRule(token: string, ruleId: string): Promise { + await request(`/api/v1/access-rules/${ruleId}`, { + method: 'DELETE', + token, + }); +} + export async function listApiKeys(token: string): Promise> { return request>('/api/v1/api-keys', { token }); } +export async function listPlayableApiKeys(token: string): Promise> { + return request>('/api/playground/api-keys', { token }); +} + export async function createApiKey( token: string, input: { name: string; scopes?: string[] }, @@ -200,22 +374,7 @@ export async function createApiKey( }); } -export async function createPlatform( - token: string, - input: { - provider: string; - platformKey?: string; - name: string; - baseUrl?: string; - authType?: string; - credentials?: Record; - config?: Record; - defaultPricingMode?: string; - defaultDiscountFactor?: number; - pricingRuleSetId?: string; - priority?: number; - }, -): Promise { +export async function createPlatform(token: string, input: PlatformCreateInput): Promise { return request('/api/v1/platforms', { body: input, method: 'POST', @@ -223,21 +382,25 @@ export async function createPlatform( }); } +export async function updatePlatform(token: string, platformId: string, input: PlatformCreateInput): Promise { + return request(`/api/v1/platforms/${platformId}`, { + body: input, + method: 'PATCH', + token, + }); +} + +export async function deletePlatform(token: string, platformId: string): Promise { + await request(`/api/v1/platforms/${platformId}`, { + method: 'DELETE', + token, + }); +} + export async function createPlatformModel( token: string, platformId: string, - input: { - canonicalModelKey?: string; - baseModelId?: string; - modelName: string; - modelAlias?: string; - modelType: string; - displayName?: string; - retryPolicy?: Record; - rateLimitPolicy?: Record; - pricingRuleSetId?: string; - discountFactor?: number; - }, + input: PlatformModelBindingInput, ): Promise { return request(`/api/v1/platforms/${platformId}/models`, { body: input, @@ -246,6 +409,25 @@ export async function createPlatformModel( }); } +export async function replacePlatformModels( + token: string, + platformId: string, + models: PlatformModelBindingInput[], +): Promise> { + return request>(`/api/v1/platforms/${platformId}/models`, { + body: { models }, + method: 'PUT', + token, + }); +} + +export async function deletePlatformModel(token: string, modelId: string): Promise { + await request(`/api/v1/platform-models/${modelId}`, { + method: 'DELETE', + token, + }); +} + export async function createChatTask( token: string, input: { model: string; messages: Array>; runMode?: string; simulation?: boolean }, @@ -257,6 +439,57 @@ export async function createChatTask( }); } +export async function streamChatCompletions( + token: string, + input: { model: string; messages: Array>; simulation?: boolean }, + onDelta: (delta: string) => void, +): Promise { + for await (const delta of streamChatCompletionText(token, input)) { + onDelta(delta); + } +} + +export async function* streamChatCompletionText( + token: string, + input: { model: string; messages: Array>; simulation?: boolean }, + signal?: AbortSignal, +): AsyncGenerator { + const response = await fetch(`${API_BASE}/v1/chat/completions`, { + body: JSON.stringify({ ...input, stream: true }), + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + signal, + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`); + } + if (!response.body) { + return; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\n\n/); + buffer = events.pop() ?? ''; + for (const eventBlock of events) { + const delta = parseSSEBlockDelta(eventBlock); + if (delta) yield delta; + } + } + if (buffer.trim()) { + const delta = parseSSEBlockDelta(buffer); + if (delta) yield delta; + } +} + export async function createImageGenerationTask( token: string, input: { model: string; prompt: string; size?: string; quality?: string; runMode?: string; simulation?: boolean }, @@ -335,3 +568,23 @@ function parseErrorMessage(body: string) { return body; } } + +function parseSSEBlockDelta(block: string) { + const data = block + .split(/\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.replace(/^data:\s?/, '')) + .join('\n') + .trim(); + if (!data || data === '[DONE]') return ''; + try { + const parsed = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string }; message?: { content?: string } }>; + delta?: string; + output_text?: string; + }; + return parsed.choices?.[0]?.delta?.content ?? parsed.delta ?? parsed.output_text ?? ''; + } catch { + return data; + } +} diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts index a8de5bb..eb95b9b 100644 --- a/apps/web/src/app-state.ts +++ b/apps/web/src/app-state.ts @@ -1,6 +1,7 @@ import type { BaseModelCatalogItem, CatalogProvider, + GatewayAccessRule, GatewayApiKey, GatewayTask, GatewayTenant, @@ -10,10 +11,12 @@ import type { PricingRule, PricingRuleSet, RateLimitWindow, + RuntimePolicySet, UserGroup, } from '@easyai-ai-gateway/contracts'; export interface ConsoleData { + accessRules: GatewayAccessRule[]; apiKeys: GatewayApiKey[]; baseModels: BaseModelCatalogItem[]; models: PlatformModel[]; @@ -22,6 +25,7 @@ export interface ConsoleData { pricingRuleSets: PricingRuleSet[]; providers: CatalogProvider[]; rateLimitWindows: RateLimitWindow[]; + runtimePolicySets: RuntimePolicySet[]; taskResult: GatewayTask | null; tenants: GatewayTenant[]; userGroups: UserGroup[]; diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx index a3fc411..34af16c 100644 --- a/apps/web/src/components/Dashboard.tsx +++ b/apps/web/src/components/Dashboard.tsx @@ -2,6 +2,7 @@ import type { BaseModelCatalogItem, IntegrationPlatform, PlatformModel, RateLimi import { adminPages, apiDocPages, primaryModules, workspacePages } from '../navigation'; import { DataPanel } from './DataPanel'; import { ModuleList } from './ModuleList'; +import { baseModelTypeText } from '../pages/admin/platform-form'; export function Dashboard(props: { baseModels: BaseModelCatalogItem[]; @@ -66,7 +67,7 @@ export function Dashboard(props: { [item.provider, item.name, item.status, String(item.priority)])} + rows={props.platforms.map((item) => [item.provider, item.internalName || item.name, item.status, String(item.priority)])} title="平台" /> [item.providerKey, item.canonicalModelKey, item.modelType, String(item.pricingVersion)])} + rows={props.baseModels.map((item) => [item.providerKey, item.canonicalModelKey, baseModelTypeText(item), String(item.pricingVersion)])} title="基准模型库" /> , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); + +Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/confirm-dialog.tsx b/apps/web/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..b295e11 --- /dev/null +++ b/apps/web/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { Button } from './button'; + +export interface ConfirmDialogProps { + cancelLabel?: string; + children?: React.ReactNode; + className?: string; + confirmLabel?: string; + confirmVariant?: 'default' | 'destructive'; + description?: string; + loading?: boolean; + open: boolean; + title: string; + onCancel: () => void; + onConfirm: () => void | Promise; +} + +export function ConfirmDialog(props: ConfirmDialogProps) { + const { onCancel, open } = props; + + React.useEffect(() => { + if (!open) return undefined; + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onCancel(); + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onCancel, open]); + + if (!open) return null; + + return ( +
+
+
+ +
+
+ {props.title} + {props.description &&

{props.description}

} + {props.children} +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/form-item.tsx b/apps/web/src/components/ui/form-item.tsx new file mode 100644 index 0000000..0061aa0 --- /dev/null +++ b/apps/web/src/components/ui/form-item.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; +import { cn } from '../../lib/utils'; + +export function FormItem(props: { + children: ReactNode; + className?: string; + description?: ReactNode; + label: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 641d422..0c3ec0b 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -1,9 +1,13 @@ export * from './badge'; export * from './button'; export * from './card'; +export * from './checkbox'; +export * from './confirm-dialog'; export * from './dialog'; +export * from './form-item'; export * from './input'; export * from './label'; +export * from './message'; export * from './select'; export * from './separator'; export * from './table'; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 899059d..3ea6649 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,8 +1,28 @@ import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../lib/utils'; -export const Input = React.forwardRef>( - ({ className, ...props }, ref) => , +const inputVariants = cva('shInput', { + variants: { + size: { + xs: 'shControlXs', + sm: 'shControlSm', + md: 'shControlMd', + lg: 'shControlLg', + xl: 'shControlXl', + }, + }, + defaultVariants: { + size: 'md', + }, +}); + +export interface InputProps + extends Omit, 'size'>, + VariantProps {} + +export const Input = React.forwardRef( + ({ className, size, ...props }, ref) => , ); Input.displayName = 'Input'; diff --git a/apps/web/src/components/ui/message.tsx b/apps/web/src/components/ui/message.tsx new file mode 100644 index 0000000..86ade31 --- /dev/null +++ b/apps/web/src/components/ui/message.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { Button } from './button'; + +export type ScreenMessageVariant = 'info' | 'success' | 'error'; + +export interface ScreenMessageProps { + className?: string; + duration?: number; + message: string; + open?: boolean; + variant?: ScreenMessageVariant; + onClose?: () => void; +} + +const iconMap: Record = { + error: , + info: , + success: , +}; + +export function ScreenMessage(props: ScreenMessageProps) { + const { duration = 3600, message, onClose, open = Boolean(message), variant = 'info' } = props; + + React.useEffect(() => { + if (!open || !message || duration <= 0 || !onClose) return undefined; + const timer = window.setTimeout(onClose, duration); + return () => window.clearTimeout(timer); + }, [duration, message, onClose, open]); + + if (!open || !message) return null; + + return ( +
+
+ {iconMap[variant]} + {message} + {onClose && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index a5b69ac..d8bde14 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -1,8 +1,28 @@ import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../lib/utils'; -export const Select = React.forwardRef>( - ({ className, ...props }, ref) => , ); Select.displayName = 'Select'; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index 0053aaa..1074487 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -1,8 +1,28 @@ import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../lib/utils'; -export const Textarea = React.forwardRef>( - ({ className, ...props }, ref) =>