From 524f8c5a4e66d8d9aaa793ad97b0cdce3809a9a7 Mon Sep 17 00:00:00 2001 From: zenfun Date: Fri, 19 Dec 2025 21:24:24 +0800 Subject: [PATCH] feat(key): extend key metadata and validation --- internal/api/admin_handler.go | 20 +++- internal/api/master_handler.go | 128 ++++++++++++++++++++----- internal/model/models.go | 32 +++++-- internal/service/master.go | 165 ++++++++++++++++++++++++++++---- internal/service/master_test.go | 14 ++- internal/service/sync.go | 63 ++++++++---- 6 files changed, 351 insertions(+), 71 deletions(-) diff --git a/internal/api/admin_handler.go b/internal/api/admin_handler.go index d062744..772c766 100644 --- a/internal/api/admin_handler.go +++ b/internal/api/admin_handler.go @@ -425,13 +425,31 @@ func (h *AdminHandler) IssueChildKeyForMaster(c *gin.Context) { return } - key, rawChildKey, err := h.masterService.IssueChildKeyAsAdmin(masterID, req.Group, req.Scopes) + modelLimits := strings.TrimSpace(req.ModelLimits) + modelLimitsEnabled := false + if req.ModelLimitsEnabled != nil { + modelLimitsEnabled = *req.ModelLimitsEnabled + } else if modelLimits != "" { + modelLimitsEnabled = true + } + + key, rawChildKey, err := h.masterService.IssueChildKeyAsAdmin(masterID, service.IssueKeyOptions{ + Group: strings.TrimSpace(req.Group), + Scopes: strings.TrimSpace(req.Scopes), + ModelLimits: modelLimits, + ModelLimitsEnabled: modelLimitsEnabled, + ExpiresAt: req.ExpiresAt, + AllowIPs: strings.TrimSpace(req.AllowIPs), + DenyIPs: strings.TrimSpace(req.DenyIPs), + }) if err != nil { switch { case errors.Is(err, service.ErrMasterNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrMasterNotActive): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrModelLimitForbidden): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrChildKeyGroupForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrChildKeyLimitReached): diff --git a/internal/api/master_handler.go b/internal/api/master_handler.go index 1149df5..dd60bf7 100644 --- a/internal/api/master_handler.go +++ b/internal/api/master_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/ez-api/internal/service" @@ -23,8 +24,13 @@ func NewMasterHandler(db *gorm.DB, masterService *service.MasterService, syncSer } type IssueChildKeyRequest struct { - Group string `json:"group"` - Scopes string `json:"scopes"` + Group string `json:"group"` + Scopes string `json:"scopes"` + ModelLimits string `json:"model_limits,omitempty"` + ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AllowIPs string `json:"allow_ips,omitempty"` + DenyIPs string `json:"deny_ips,omitempty"` } // IssueChildKey godoc @@ -68,13 +74,31 @@ func (h *MasterHandler) IssueChildKey(c *gin.Context) { return } - key, rawChildKey, err := h.masterService.IssueChildKey(masterModel.ID, group, req.Scopes) + modelLimits := strings.TrimSpace(req.ModelLimits) + modelLimitsEnabled := false + if req.ModelLimitsEnabled != nil { + modelLimitsEnabled = *req.ModelLimitsEnabled + } else if modelLimits != "" { + modelLimitsEnabled = true + } + + key, rawChildKey, err := h.masterService.IssueChildKey(masterModel.ID, service.IssueKeyOptions{ + Group: group, + Scopes: strings.TrimSpace(req.Scopes), + ModelLimits: modelLimits, + ModelLimitsEnabled: modelLimitsEnabled, + ExpiresAt: req.ExpiresAt, + AllowIPs: strings.TrimSpace(req.AllowIPs), + DenyIPs: strings.TrimSpace(req.DenyIPs), + }) if err != nil { switch { case errors.Is(err, service.ErrMasterNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrMasterNotActive): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrModelLimitForbidden): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrChildKeyGroupForbidden): c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrChildKeyLimitReached): @@ -118,30 +142,54 @@ func (h *MasterHandler) GetSelf(c *gin.Context) { } type TokenView struct { - ID uint `json:"id"` - Group string `json:"group"` - Scopes string `json:"scopes"` - Status string `json:"status"` - IssuedBy string `json:"issued_by"` - IssuedAtEpoch int64 `json:"issued_at_epoch"` - DefaultNamespace string `json:"default_namespace"` - Namespaces string `json:"namespaces"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` + ID uint `json:"id"` + Group string `json:"group"` + Scopes string `json:"scopes"` + Status string `json:"status"` + IssuedBy string `json:"issued_by"` + IssuedAtEpoch int64 `json:"issued_at_epoch"` + DefaultNamespace string `json:"default_namespace"` + Namespaces string `json:"namespaces"` + ModelLimits string `json:"model_limits,omitempty"` + ModelLimitsEnabled bool `json:"model_limits_enabled"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AllowIPs string `json:"allow_ips,omitempty"` + DenyIPs string `json:"deny_ips,omitempty"` + LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` + RequestCount int64 `json:"request_count"` + UsedTokens int64 `json:"used_tokens"` + QuotaLimit int64 `json:"quota_limit"` + QuotaUsed int64 `json:"quota_used"` + QuotaResetAt *time.Time `json:"quota_reset_at,omitempty"` + QuotaResetType string `json:"quota_reset_type,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` } func toTokenView(k model.Key) TokenView { return TokenView{ - ID: k.ID, - Group: k.Group, - Scopes: k.Scopes, - Status: k.Status, - IssuedBy: k.IssuedBy, - IssuedAtEpoch: k.IssuedAtEpoch, - DefaultNamespace: strings.TrimSpace(k.DefaultNamespace), - Namespaces: strings.TrimSpace(k.Namespaces), - CreatedAt: k.CreatedAt.UTC().Unix(), - UpdatedAt: k.UpdatedAt.UTC().Unix(), + ID: k.ID, + Group: k.Group, + Scopes: k.Scopes, + Status: k.Status, + IssuedBy: k.IssuedBy, + IssuedAtEpoch: k.IssuedAtEpoch, + DefaultNamespace: strings.TrimSpace(k.DefaultNamespace), + Namespaces: strings.TrimSpace(k.Namespaces), + ModelLimits: strings.TrimSpace(k.ModelLimits), + ModelLimitsEnabled: k.ModelLimitsEnabled, + ExpiresAt: k.ExpiresAt, + AllowIPs: strings.TrimSpace(k.AllowIPs), + DenyIPs: strings.TrimSpace(k.DenyIPs), + LastAccessedAt: k.LastAccessedAt, + RequestCount: k.RequestCount, + UsedTokens: k.UsedTokens, + QuotaLimit: k.QuotaLimit, + QuotaUsed: k.QuotaUsed, + QuotaResetAt: k.QuotaResetAt, + QuotaResetType: strings.TrimSpace(k.QuotaResetType), + CreatedAt: k.CreatedAt.UTC().Unix(), + UpdatedAt: k.UpdatedAt.UTC().Unix(), } } @@ -212,8 +260,13 @@ func (h *MasterHandler) GetToken(c *gin.Context) { } type UpdateTokenRequest struct { - Scopes *string `json:"scopes,omitempty"` - Status *string `json:"status,omitempty"` // active/suspended + Scopes *string `json:"scopes,omitempty"` + Status *string `json:"status,omitempty"` // active/suspended + ModelLimits *string `json:"model_limits,omitempty"` + ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AllowIPs *string `json:"allow_ips,omitempty"` + DenyIPs *string `json:"deny_ips,omitempty"` } // UpdateToken godoc @@ -256,6 +309,31 @@ func (h *MasterHandler) UpdateToken(c *gin.Context) { if req.Scopes != nil { update["scopes"] = strings.TrimSpace(*req.Scopes) } + if req.ModelLimits != nil { + modelLimits := strings.TrimSpace(*req.ModelLimits) + if modelLimits != "" { + if err := h.masterService.ValidateModelLimits(m, modelLimits); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + update["model_limits"] = modelLimits + if req.ModelLimitsEnabled == nil { + update["model_limits_enabled"] = modelLimits != "" + } + } + if req.ModelLimitsEnabled != nil { + update["model_limits_enabled"] = *req.ModelLimitsEnabled + } + if req.ExpiresAt != nil { + update["expires_at"] = req.ExpiresAt + } + if req.AllowIPs != nil { + update["allow_ips"] = strings.TrimSpace(*req.AllowIPs) + } + if req.DenyIPs != nil { + update["deny_ips"] = strings.TrimSpace(*req.DenyIPs) + } if req.Status != nil { st := strings.ToLower(strings.TrimSpace(*req.Status)) if st != "active" && st != "suspended" { diff --git a/internal/model/models.go b/internal/model/models.go index 31a0ef8..3fb4347 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -26,16 +26,28 @@ type Master struct { // Key represents a child access token issued by a Master. type Key struct { gorm.Model - MasterID uint `gorm:"not null;index" json:"master_id"` - KeySecret string `gorm:"size:255;column:key_secret" json:"-"` // bcrypt hash of child key - TokenHash string `gorm:"size:64;uniqueIndex" json:"token_hash"` // sha256 digest of child key - Group string `gorm:"size:100;default:'default'" json:"group"` // routing group - Scopes string `gorm:"size:1024" json:"scopes"` // Comma-separated scopes - DefaultNamespace string `gorm:"size:100;default:'default'" json:"default_namespace"` - Namespaces string `gorm:"size:1024;default:'default'" json:"namespaces"` // Comma-separated namespaces - IssuedAtEpoch int64 `gorm:"not null" json:"issued_at_epoch"` // copy of master epoch at issuance - Status string `gorm:"size:50;default:'active'" json:"status"` // active, suspended - IssuedBy string `gorm:"size:20;default:'master'" json:"issued_by"` + MasterID uint `gorm:"not null;index" json:"master_id"` + KeySecret string `gorm:"size:255;column:key_secret" json:"-"` // bcrypt hash of child key + TokenHash string `gorm:"size:64;uniqueIndex" json:"token_hash"` // sha256 digest of child key + Group string `gorm:"size:100;default:'default'" json:"group"` // routing group + Scopes string `gorm:"size:1024" json:"scopes"` // Comma-separated scopes + DefaultNamespace string `gorm:"size:100;default:'default'" json:"default_namespace"` + Namespaces string `gorm:"size:1024;default:'default'" json:"namespaces"` // Comma-separated namespaces + IssuedAtEpoch int64 `gorm:"not null" json:"issued_at_epoch"` // copy of master epoch at issuance + Status string `gorm:"size:50;default:'active'" json:"status"` // active, suspended + IssuedBy string `gorm:"size:20;default:'master'" json:"issued_by"` + ModelLimits string `gorm:"size:2048" json:"model_limits"` + ModelLimitsEnabled bool `gorm:"default:false" json:"model_limits_enabled"` + ExpiresAt *time.Time `gorm:"index" json:"expires_at"` + AllowIPs string `gorm:"size:1024" json:"allow_ips"` + DenyIPs string `gorm:"size:1024" json:"deny_ips"` + LastAccessedAt *time.Time `json:"last_accessed_at"` + RequestCount int64 `gorm:"default:0" json:"request_count"` + UsedTokens int64 `gorm:"default:0" json:"used_tokens"` + QuotaLimit int64 `gorm:"default:-1" json:"quota_limit"` + QuotaUsed int64 `gorm:"default:0" json:"quota_used"` + QuotaResetAt *time.Time `json:"quota_reset_at"` + QuotaResetType string `gorm:"size:20" json:"quota_reset_type"` } // Provider remains the same. diff --git a/internal/service/master.go b/internal/service/master.go index d6b0972..17b7226 100644 --- a/internal/service/master.go +++ b/internal/service/master.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/foundation/tokenhash" @@ -18,6 +19,7 @@ var ( ErrMasterNotActive = errors.New("master is not active") ErrChildKeyLimitReached = errors.New("child key limit reached") ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group") + ErrModelLimitForbidden = errors.New("model not in master's accessible models") ) type MasterService struct { @@ -100,15 +102,25 @@ verified: return &master, nil } -func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string) (*model.Key, string, error) { - return s.issueChildKey(masterID, group, scopes, "master") +type IssueKeyOptions struct { + Group string + Scopes string + ModelLimits string + ModelLimitsEnabled bool + ExpiresAt *time.Time + AllowIPs string + DenyIPs string } -func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, group string, scopes string) (*model.Key, string, error) { - return s.issueChildKey(masterID, group, scopes, "admin") +func (s *MasterService) IssueChildKey(masterID uint, opts IssueKeyOptions) (*model.Key, string, error) { + return s.issueChildKey(masterID, opts, "master") } -func (s *MasterService) issueChildKey(masterID uint, group string, scopes string, issuedBy string) (*model.Key, string, error) { +func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, opts IssueKeyOptions) (*model.Key, string, error) { + return s.issueChildKey(masterID, opts, "admin") +} + +func (s *MasterService) issueChildKey(masterID uint, opts IssueKeyOptions, issuedBy string) (*model.Key, string, error) { var master model.Master if err := s.db.First(&master, masterID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -120,7 +132,7 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string return nil, "", fmt.Errorf("%w", ErrMasterNotActive) } - group = strings.TrimSpace(group) + group := strings.TrimSpace(opts.Group) if group == "" { group = master.Group } @@ -146,17 +158,26 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string return nil, "", fmt.Errorf("failed to hash child key: %w", err) } + if err := s.ValidateModelLimits(&master, opts.ModelLimits); err != nil { + return nil, "", err + } + key := &model.Key{ - MasterID: masterID, - KeySecret: string(hashedChildKey), - TokenHash: tokenHash, - Group: group, - Scopes: scopes, - DefaultNamespace: strings.TrimSpace(master.DefaultNamespace), - Namespaces: strings.TrimSpace(master.Namespaces), - IssuedAtEpoch: master.Epoch, - Status: "active", - IssuedBy: strings.TrimSpace(issuedBy), + MasterID: masterID, + KeySecret: string(hashedChildKey), + TokenHash: tokenHash, + Group: group, + Scopes: strings.TrimSpace(opts.Scopes), + DefaultNamespace: strings.TrimSpace(master.DefaultNamespace), + Namespaces: strings.TrimSpace(master.Namespaces), + IssuedAtEpoch: master.Epoch, + Status: "active", + IssuedBy: strings.TrimSpace(issuedBy), + ModelLimits: strings.TrimSpace(opts.ModelLimits), + ModelLimitsEnabled: opts.ModelLimitsEnabled, + ExpiresAt: opts.ExpiresAt, + AllowIPs: strings.TrimSpace(opts.AllowIPs), + DenyIPs: strings.TrimSpace(opts.DenyIPs), } if strings.TrimSpace(key.DefaultNamespace) == "" { key.DefaultNamespace = "default" @@ -175,6 +196,53 @@ func (s *MasterService) issueChildKey(masterID uint, group string, scopes string return key, rawChildKey, nil } +func (s *MasterService) ValidateModelLimits(master *model.Master, limits string) error { + if master == nil { + return fmt.Errorf("master is required") + } + requested := splitList(limits) + if len(requested) == 0 { + return nil + } + namespaces := normalizeNamespaces(master.Namespaces, master.DefaultNamespace) + if len(namespaces) == 0 { + return fmt.Errorf("master has no namespaces configured") + } + + var bindings []model.Binding + if err := s.db.Where("namespace IN ?", namespaces).Find(&bindings).Error; err != nil { + return fmt.Errorf("load bindings: %w", err) + } + allowedBindings := make(map[string]struct{}, len(bindings)) + allowedPublic := make(map[string]struct{}, len(bindings)) + for _, b := range bindings { + if !bindingActive(b.Status) { + continue + } + ns := strings.TrimSpace(b.Namespace) + pm := strings.TrimSpace(b.PublicModel) + if ns == "" || pm == "" { + continue + } + allowedBindings[ns+"."+pm] = struct{}{} + allowedPublic[pm] = struct{}{} + } + + for _, m := range requested { + if strings.Contains(m, ".") { + if _, ok := allowedBindings[m]; !ok { + return fmt.Errorf("%w: %s", ErrModelLimitForbidden, m) + } + continue + } + if _, ok := allowedPublic[m]; !ok { + return fmt.Errorf("%w: %s", ErrModelLimitForbidden, m) + } + } + + return nil +} + func generateRandomKey(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { @@ -182,3 +250,68 @@ func generateRandomKey(length int) (string, error) { } return hex.EncodeToString(bytes), nil } + +func normalizeDefaultNamespace(ns string) string { + ns = strings.TrimSpace(ns) + if ns == "" { + return "default" + } + return ns +} + +func normalizeNamespaces(raw string, defaultNamespace string) []string { + defaultNamespace = normalizeDefaultNamespace(defaultNamespace) + raw = strings.TrimSpace(raw) + var parts []string + if raw != "" { + parts = strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' || r == '\t' || r == '\n' + }) + } + out := make([]string, 0, len(parts)+1) + seen := make(map[string]struct{}, len(parts)+1) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + if _, ok := seen[defaultNamespace]; !ok { + out = append(out, defaultNamespace) + } + return out +} + +func splitList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == ';' || r == '\t' || r == '\n' + }) + out := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + return out +} + +func bindingActive(status string) bool { + status = strings.ToLower(strings.TrimSpace(status)) + return status == "" || status == "active" +} diff --git a/internal/service/master_test.go b/internal/service/master_test.go index b276e2d..bebe9a5 100644 --- a/internal/service/master_test.go +++ b/internal/service/master_test.go @@ -53,7 +53,10 @@ func TestMasterService_IssueChildKey_RespectsLimit(t *testing.T) { t.Fatalf("CreateMaster: %v", err) } - _, raw1, err := svc.IssueChildKey(m.ID, "default", "chat:write") + _, raw1, err := svc.IssueChildKey(m.ID, IssueKeyOptions{ + Group: "default", + Scopes: "chat:write", + }) if err != nil { t.Fatalf("IssueChildKey #1: %v", err) } @@ -61,7 +64,10 @@ func TestMasterService_IssueChildKey_RespectsLimit(t *testing.T) { t.Fatalf("expected raw child key") } - _, _, err = svc.IssueChildKey(m.ID, "default", "chat:write") + _, _, err = svc.IssueChildKey(m.ID, IssueKeyOptions{ + Group: "default", + Scopes: "chat:write", + }) if err == nil { t.Fatalf("expected child key limit error") } @@ -76,7 +82,9 @@ func TestMasterService_IssueChildKeyAsAdmin_SetsIssuedBy(t *testing.T) { t.Fatalf("CreateMaster: %v", err) } - key, raw, err := svc.IssueChildKeyAsAdmin(m.ID, "", "chat:write") + key, raw, err := svc.IssueChildKeyAsAdmin(m.ID, IssueKeyOptions{ + Scopes: "chat:write", + }) if err != nil { t.Fatalf("IssueChildKeyAsAdmin: %v", err) } diff --git a/internal/service/sync.go b/internal/service/sync.go index 6c9d4a9..41b43c1 100644 --- a/internal/service/sync.go +++ b/internal/service/sync.go @@ -36,14 +36,26 @@ func (s *SyncService) SyncKey(key *model.Key) error { } fields := map[string]interface{}{ - "id": key.ID, - "master_id": key.MasterID, - "issued_at_epoch": key.IssuedAtEpoch, - "status": key.Status, - "group": key.Group, - "scopes": key.Scopes, - "default_namespace": key.DefaultNamespace, - "namespaces": key.Namespaces, + "id": key.ID, + "master_id": key.MasterID, + "issued_at_epoch": key.IssuedAtEpoch, + "status": key.Status, + "group": key.Group, + "scopes": key.Scopes, + "default_namespace": key.DefaultNamespace, + "namespaces": key.Namespaces, + "model_limits": strings.TrimSpace(key.ModelLimits), + "model_limits_enabled": key.ModelLimitsEnabled, + "expires_at": unixOrZero(key.ExpiresAt), + "allow_ips": strings.TrimSpace(key.AllowIPs), + "deny_ips": strings.TrimSpace(key.DenyIPs), + "last_accessed_at": unixOrZero(key.LastAccessedAt), + "request_count": key.RequestCount, + "used_tokens": key.UsedTokens, + "quota_limit": key.QuotaLimit, + "quota_used": key.QuotaUsed, + "quota_reset_at": unixOrZero(key.QuotaResetAt), + "quota_reset_type": strings.TrimSpace(key.QuotaResetType), } if err := s.rdb.HSet(ctx, fmt.Sprintf("auth:token:%s", tokenHash), fields).Err(); err != nil { return fmt.Errorf("write auth token: %w", err) @@ -261,14 +273,26 @@ func (s *SyncService) SyncAll(db *gorm.DB) error { return fmt.Errorf("token hash missing for key %d", k.ID) } pipe.HSet(ctx, fmt.Sprintf("auth:token:%s", tokenHash), map[string]interface{}{ - "id": k.ID, - "master_id": k.MasterID, - "issued_at_epoch": k.IssuedAtEpoch, - "status": k.Status, - "group": k.Group, - "scopes": k.Scopes, - "default_namespace": k.DefaultNamespace, - "namespaces": k.Namespaces, + "id": k.ID, + "master_id": k.MasterID, + "issued_at_epoch": k.IssuedAtEpoch, + "status": k.Status, + "group": k.Group, + "scopes": k.Scopes, + "default_namespace": k.DefaultNamespace, + "namespaces": k.Namespaces, + "model_limits": strings.TrimSpace(k.ModelLimits), + "model_limits_enabled": k.ModelLimitsEnabled, + "expires_at": unixOrZero(k.ExpiresAt), + "allow_ips": strings.TrimSpace(k.AllowIPs), + "deny_ips": strings.TrimSpace(k.DenyIPs), + "last_accessed_at": unixOrZero(k.LastAccessedAt), + "request_count": k.RequestCount, + "used_tokens": k.UsedTokens, + "quota_limit": k.QuotaLimit, + "quota_used": k.QuotaUsed, + "quota_reset_at": unixOrZero(k.QuotaResetAt), + "quota_reset_type": strings.TrimSpace(k.QuotaResetType), }) } @@ -464,6 +488,13 @@ func normalizeStatus(status string) string { } } +func unixOrZero(t *time.Time) int64 { + if t == nil { + return 0 + } + return t.UTC().Unix() +} + func (s *SyncService) refreshModelsMetaFromRedis(ctx context.Context, source string) error { raw, err := s.rdb.HGetAll(ctx, "meta:models").Result() if err != nil {