chore: commit pending gateway changes

This commit is contained in:
2026-05-10 22:34:15 +08:00
parent 53f8edfb67
commit d59756a27c
71 changed files with 15106 additions and 656 deletions
@@ -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
}
+30 -1
View File
@@ -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
}
+140 -5
View File
@@ -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)
}
@@ -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) != ""
}
@@ -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
@@ -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) != ""
}
+27 -1
View File
@@ -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)
+40 -1
View File
@@ -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
+515
View File
@@ -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
}
+300 -19
View File
@@ -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
}
+31 -4
View File
@@ -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
}
+23 -7
View File
@@ -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"
}
+399
View File
@@ -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, &quotaPolicy, &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
}
+120 -8
View File
@@ -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 {
+7
View File
@@ -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
+150
View File
@@ -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,
&degradePolicy,
&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
}
+3
View File
@@ -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,
@@ -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,
@@ -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://<your-local-ip>: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;
@@ -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');
@@ -0,0 +1,2 @@
ALTER TABLE IF EXISTS integration_platforms
ADD COLUMN IF NOT EXISTS internal_name text;
@@ -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');
+25
View File
@@ -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);
@@ -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);
@@ -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;
@@ -0,0 +1,2 @@
ALTER TABLE gateway_api_keys
ADD COLUMN IF NOT EXISTS key_secret text;
+1 -1
View File
@@ -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": {