diff options
Diffstat (limited to 'internal/controlplane')
| -rw-r--r-- | internal/controlplane/access.go | 156 | ||||
| -rw-r--r-- | internal/controlplane/access_test.go | 39 | ||||
| -rw-r--r-- | internal/controlplane/audit.go | 52 | ||||
| -rw-r--r-- | internal/controlplane/limits.go | 81 | ||||
| -rw-r--r-- | internal/controlplane/limits_integration_test.go | 62 | ||||
| -rw-r--r-- | internal/controlplane/manager.go | 20 | ||||
| -rw-r--r-- | internal/controlplane/manager_test.go | 20 | ||||
| -rw-r--r-- | internal/controlplane/mutations.go | 16 | ||||
| -rw-r--r-- | internal/controlplane/queries.go | 89 | ||||
| -rw-r--r-- | internal/controlplane/schema.sql | 179 | ||||
| -rw-r--r-- | internal/controlplane/snapshot.go | 41 | ||||
| -rw-r--r-- | internal/controlplane/types.go | 163 | ||||
| -rw-r--r-- | internal/controlplane/usage.go | 151 |
13 files changed, 1037 insertions, 32 deletions
diff --git a/internal/controlplane/access.go b/internal/controlplane/access.go new file mode 100644 index 0000000..ec2d177 --- /dev/null +++ b/internal/controlplane/access.go @@ -0,0 +1,156 @@ +package controlplane + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/mail" + "strings" + + "github.com/jackc/pgx/v5" +) + +var ErrConsoleUnauthorized = errors.New("invalid console token") + +const ( + RolePlatformAdmin = "platform_admin" + RolePlatformViewer = "platform_viewer" + RoleTenantAdmin = "tenant_admin" + RoleTenantBilling = "tenant_billing" + RoleTenantDeveloper = "tenant_developer" + RoleTenantViewer = "tenant_viewer" +) + +func (a ConsoleActor) IsPlatform() bool { return strings.HasPrefix(a.Role, "platform_") } + +func (a ConsoleActor) Can(permission string) bool { + if a.Role == RolePlatformAdmin { + return true + } + read := strings.HasSuffix(permission, ".read") + switch a.Role { + case RolePlatformViewer: + return read + case RoleTenantAdmin: + switch permission { + case "overview.read", "tenants.read", "projects.read", "projects.write", "keys.read", "keys.write", + "billing.read", "billing.topup", "usage.read", "audit.read", "limits.read", "users.read", "users.write": + return true + } + return false + case RoleTenantBilling: + return permission == "overview.read" || permission == "billing.read" || permission == "billing.topup" || permission == "usage.read" || permission == "audit.read" + case RoleTenantDeveloper: + return permission == "overview.read" || permission == "tenants.read" || permission == "projects.read" || permission == "keys.read" || permission == "keys.write" || permission == "usage.read" || permission == "limits.read" + case RoleTenantViewer: + return permission == "overview.read" || permission == "tenants.read" || permission == "projects.read" || permission == "keys.read" || permission == "billing.read" || permission == "usage.read" || permission == "limits.read" || permission == "audit.read" + default: + return false + } +} + +func (a ConsoleActor) Permissions() []string { + all := []string{"overview.read", "tenants.read", "tenants.write", "projects.read", "projects.write", "keys.read", "keys.write", "platform.read", "platform.write", "billing.read", "billing.topup", "billing.adjust", "usage.read", "limits.read", "limits.write", "users.read", "users.write", "audit.read"} + result := make([]string, 0, len(all)) + for _, permission := range all { + if a.Can(permission) { + result = append(result, permission) + } + } + return result +} + +func (s *Store) AuthenticateConsoleToken(ctx context.Context, raw string) (ConsoleActor, error) { + hash := sha256.Sum256([]byte(raw)) + var actor ConsoleActor + err := s.db.QueryRow(ctx, ` + UPDATE console_users SET last_used_at = now() + WHERE token_hash = $1 AND status = 'active' + RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role`, hash[:], + ).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role) + if errors.Is(err, pgx.ErrNoRows) { + return ConsoleActor{}, ErrConsoleUnauthorized + } + if err != nil { + return ConsoleActor{}, fmt.Errorf("authenticate console token: %w", err) + } + return actor, nil +} + +func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]ConsoleUser, error) { + query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at FROM console_users` + args := []any{} + if tenantID != "" { + query += ` WHERE tenant_id = $1` + args = append(args, tenantID) + } + query += ` ORDER BY created_at DESC` + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query console users: %w", err) + } + defer rows.Close() + result := make([]ConsoleUser, 0) + for rows.Next() { + var item ConsoleUser + if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil { + return nil, fmt.Errorf("scan console user: %w", err) + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) CreateConsoleUser(ctx context.Context, input CreateConsoleUserInput) (CreatedConsoleUser, error) { + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.TenantID = strings.TrimSpace(input.TenantID) + address, addressErr := mail.ParseAddress(input.Email) + if addressErr != nil || address.Address != input.Email || input.DisplayName == "" { + return CreatedConsoleUser{}, errors.New("console user requires a valid email and display_name") + } + platform := input.Role == RolePlatformAdmin || input.Role == RolePlatformViewer + tenant := input.Role == RoleTenantAdmin || input.Role == RoleTenantBilling || input.Role == RoleTenantDeveloper || input.Role == RoleTenantViewer + if (!platform && !tenant) || (platform && input.TenantID != "") || (tenant && input.TenantID == "") { + return CreatedConsoleUser{}, errors.New("console user role and tenant_id are inconsistent") + } + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return CreatedConsoleUser{}, fmt.Errorf("generate console token: %w", err) + } + raw := "cu-aigw-" + base64.RawURLEncoding.EncodeToString(random) + hash := sha256.Sum256([]byte(raw)) + prefix := raw[:min(18, len(raw))] + "..." + var result CreatedConsoleUser + err := s.db.QueryRow(ctx, ` + INSERT INTO console_users (tenant_id, email, display_name, role, token_prefix, token_hash) + VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6) + RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at`, + input.TenantID, input.Email, input.DisplayName, input.Role, prefix, hash[:], + ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix, &result.Status, &result.LastUsedAt, &result.CreatedAt) + if err != nil { + return CreatedConsoleUser{}, fmt.Errorf("create console user: %w", err) + } + result.Token = raw + return result, nil +} + +func (s *Store) RevokeConsoleUser(ctx context.Context, id, tenantID string) error { + query := `UPDATE console_users SET status='revoked', revoked_at=now() WHERE id=$1 AND status='active'` + args := []any{id} + if tenantID != "" { + query += ` AND tenant_id=$2` + args = append(args, tenantID) + } + result, err := s.db.Exec(ctx, query, args...) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/controlplane/access_test.go b/internal/controlplane/access_test.go new file mode 100644 index 0000000..96e3869 --- /dev/null +++ b/internal/controlplane/access_test.go @@ -0,0 +1,39 @@ +package controlplane + +import "testing" + +func TestConsoleRolePermissions(t *testing.T) { + tests := []struct { + role, permission string + want bool + }{ + {RolePlatformAdmin, "platform.write", true}, + {RolePlatformViewer, "platform.read", true}, + {RolePlatformViewer, "platform.write", false}, + {RoleTenantAdmin, "keys.write", true}, + {RoleTenantAdmin, "limits.write", false}, + {RoleTenantBilling, "billing.topup", true}, + {RoleTenantBilling, "keys.read", false}, + {RoleTenantDeveloper, "keys.write", true}, + {RoleTenantDeveloper, "billing.read", false}, + {RoleTenantViewer, "usage.read", true}, + {RoleTenantViewer, "users.read", false}, + } + for _, test := range tests { + t.Run(test.role+"/"+test.permission, func(t *testing.T) { + if got := (ConsoleActor{Role: test.role}).Can(test.permission); got != test.want { + t.Fatalf("Can(%q) = %v, want %v", test.permission, got, test.want) + } + }) + } +} + +func TestTenantRoleNeverGetsPlatformPermissions(t *testing.T) { + roles := []string{RoleTenantAdmin, RoleTenantBilling, RoleTenantDeveloper, RoleTenantViewer} + for _, role := range roles { + actor := ConsoleActor{Role: role} + if actor.Can("platform.read") || actor.Can("platform.write") { + t.Fatalf("role %s received platform access", role) + } + } +} diff --git a/internal/controlplane/audit.go b/internal/controlplane/audit.go new file mode 100644 index 0000000..93a4f58 --- /dev/null +++ b/internal/controlplane/audit.go @@ -0,0 +1,52 @@ +package controlplane + +import ( + "context" + "fmt" + "strings" +) + +func (s *Store) WriteAudit(ctx context.Context, input AuditInput) error { + actorType := "console_user" + if input.Actor.Bootstrap { + actorType = "bootstrap" + } + _, err := s.db.Exec(ctx, `INSERT INTO audit_logs + (actor_id, actor_type, actor_role, tenant_id, request_id, method, path, action, status_code, remote_ip, user_agent) + VALUES (NULLIF($1,'')::uuid,$2,$3,NULLIF($4,'')::uuid,$5,$6,$7,$8,$9,NULLIF($10,'')::inet,$11)`, + input.Actor.ID, actorType, input.Actor.Role, input.Actor.TenantID, input.RequestID, input.Method, + input.Path, input.Action, input.StatusCode, input.RemoteIP, input.UserAgent) + if err != nil { + return fmt.Errorf("write audit log: %w", err) + } + return nil +} + +func (s *Store) ListAudit(ctx context.Context, tenantID string, limit int) ([]AuditLog, error) { + if limit < 1 || limit > 1000 { + limit = 200 + } + query := `SELECT id, COALESCE(actor_id::text,''), actor_type, actor_role, COALESCE(tenant_id::text,''), + request_id, method, path, action, status_code, COALESCE(host(remote_ip),''), user_agent, created_at FROM audit_logs` + args := []any{} + if strings.TrimSpace(tenantID) != "" { + query += ` WHERE tenant_id=$1` + args = append(args, tenantID) + } + args = append(args, limit) + query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d`, len(args)) + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query audit logs: %w", err) + } + defer rows.Close() + result := make([]AuditLog, 0) + for rows.Next() { + var item AuditLog + if err := rows.Scan(&item.ID, &item.ActorID, &item.ActorType, &item.ActorRole, &item.TenantID, &item.RequestID, &item.Method, &item.Path, &item.Action, &item.StatusCode, &item.RemoteIP, &item.UserAgent, &item.CreatedAt); err != nil { + return nil, fmt.Errorf("scan audit log: %w", err) + } + result = append(result, item) + } + return result, rows.Err() +} diff --git a/internal/controlplane/limits.go b/internal/controlplane/limits.go new file mode 100644 index 0000000..5b5825e --- /dev/null +++ b/internal/controlplane/limits.go @@ -0,0 +1,81 @@ +package controlplane + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +func (s *Store) ListProjectLimits(ctx context.Context, tenantID string) ([]ProjectLimit, error) { + query := `SELECT l.tenant_id::text, l.project_id::text, p.name, t.name, + l.requests_per_minute, l.tokens_per_minute, l.concurrent_requests, l.monthly_spend_micros, l.updated_at + FROM project_limits l JOIN projects p ON p.id=l.project_id JOIN tenants t ON t.id=l.tenant_id` + args := []any{} + if strings.TrimSpace(tenantID) != "" { + query += ` WHERE l.tenant_id=$1` + args = append(args, tenantID) + } + query += ` ORDER BY t.name, p.name` + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query project limits: %w", err) + } + defer rows.Close() + result := make([]ProjectLimit, 0) + for rows.Next() { + var item ProjectLimit + if err := rows.Scan(&item.TenantID, &item.ProjectID, &item.ProjectName, &item.TenantName, + &item.RequestsPerMinute, &item.TokensPerMinute, &item.Concurrent, &item.MonthlySpendMicros, &item.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan project limit: %w", err) + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) SetProjectLimit(ctx context.Context, projectID string, input SetProjectLimitInput) (ProjectLimit, int64, error) { + if strings.TrimSpace(projectID) == "" || input.RequestsPerMinute < 0 || input.TokensPerMinute < 0 || input.ConcurrentRequests < 0 || input.MonthlySpendMicros < 0 { + return ProjectLimit{}, 0, errors.New("limit values cannot be negative and project_id is required") + } + tx, err := s.db.Begin(ctx) + if err != nil { + return ProjectLimit{}, 0, err + } + defer tx.Rollback(ctx) + var result ProjectLimit + err = tx.QueryRow(ctx, ` + WITH project AS ( + SELECT p.id, p.tenant_id, p.name AS project_name, t.name AS tenant_name + FROM projects p JOIN tenants t ON t.id=p.tenant_id WHERE p.id=$1 + ), updated AS ( + INSERT INTO project_limits (project_id, tenant_id, requests_per_minute, tokens_per_minute, concurrent_requests, monthly_spend_micros) + SELECT p.id, p.tenant_id, $2, $3, $4, $5 FROM project p + ON CONFLICT (project_id) DO UPDATE SET requests_per_minute=EXCLUDED.requests_per_minute, + tokens_per_minute=EXCLUDED.tokens_per_minute, concurrent_requests=EXCLUDED.concurrent_requests, + monthly_spend_micros=EXCLUDED.monthly_spend_micros, updated_at=now() + RETURNING tenant_id, project_id, requests_per_minute, tokens_per_minute, concurrent_requests, monthly_spend_micros, updated_at + ) + SELECT u.tenant_id::text, u.project_id::text, p.project_name, p.tenant_name, + u.requests_per_minute, u.tokens_per_minute, u.concurrent_requests, u.monthly_spend_micros, u.updated_at + FROM updated u JOIN project p ON p.id=u.project_id`, + projectID, input.RequestsPerMinute, input.TokensPerMinute, input.ConcurrentRequests, input.MonthlySpendMicros, + ).Scan(&result.TenantID, &result.ProjectID, &result.ProjectName, &result.TenantName, + &result.RequestsPerMinute, &result.TokensPerMinute, &result.Concurrent, &result.MonthlySpendMicros, &result.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return ProjectLimit{}, 0, ErrNotFound + } + if err != nil { + return ProjectLimit{}, 0, fmt.Errorf("set project limit: %w", err) + } + generation, err := bumpGeneration(ctx, tx) + if err != nil { + return ProjectLimit{}, 0, err + } + if err := tx.Commit(ctx); err != nil { + return ProjectLimit{}, 0, err + } + return result, generation, nil +} diff --git a/internal/controlplane/limits_integration_test.go b/internal/controlplane/limits_integration_test.go new file mode 100644 index 0000000..8087bdd --- /dev/null +++ b/internal/controlplane/limits_integration_test.go @@ -0,0 +1,62 @@ +package controlplane + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +func TestSetProjectLimitPostgres(t *testing.T) { + databaseURL := os.Getenv("AIGW_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("AIGW_TEST_DATABASE_URL is not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := MigrateDatabase(ctx, databaseURL); err != nil { + t.Fatal(err) + } + store, err := NewStore(ctx, Options{ + DatabaseURL: databaseURL, + CredentialKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Errorf("close control-plane store: %v", err) + } + }) + + suffix := time.Now().UnixNano() + tenant, _, err := store.CreateTenant(ctx, CreateTenantInput{Slug: fmt.Sprintf("limits-%d", suffix), Name: "Limits tenant"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, cleanupErr := store.db.Exec(context.Background(), `DELETE FROM tenants WHERE id=$1`, tenant.ID); cleanupErr != nil { + t.Errorf("cleanup limit integration data: %v", cleanupErr) + } + }) + project, _, err := store.CreateProject(ctx, CreateProjectInput{TenantID: tenant.ID, Slug: "limits-project", Name: "Limits project"}) + if err != nil { + t.Fatal(err) + } + + got, _, err := store.SetProjectLimit(ctx, project.ID, SetProjectLimitInput{ + RequestsPerMinute: 10, TokensPerMinute: 20, ConcurrentRequests: 3, MonthlySpendMicros: 40, + }) + if err != nil { + t.Fatal(err) + } + if got.TenantID != tenant.ID || got.ProjectID != project.ID || got.TenantName != tenant.Name || got.ProjectName != project.Name { + t.Fatalf("unexpected limit identity: %+v", got) + } + if got.RequestsPerMinute != 10 || got.TokensPerMinute != 20 || got.Concurrent != 3 || got.MonthlySpendMicros != 40 { + t.Fatalf("unexpected limit values: %+v", got) + } +} diff --git a/internal/controlplane/manager.go b/internal/controlplane/manager.go index 42f5efe..cdafc36 100644 --- a/internal/controlplane/manager.go +++ b/internal/controlplane/manager.go @@ -10,6 +10,7 @@ import ( "aigw/internal/auth" "aigw/internal/catalog" + "aigw/internal/domain" ) const broadcastQueueSize = 128 @@ -22,6 +23,10 @@ type managerStore interface { RedisEnabled() bool } +type policyReplacer interface { + ReplacePolicies([]domain.LimitPolicy) +} + type Manager struct { store managerStore catalog *catalog.Catalog @@ -32,15 +37,20 @@ type Manager struct { redisConnected atomic.Bool reloadMu sync.Mutex broadcasts chan ChangeEvent + policyTarget policyReplacer } -func NewManager(store managerStore, modelCatalog *catalog.Catalog, authenticator *auth.StaticAuthenticator, logger *slog.Logger, pollInterval time.Duration) *Manager { +func NewManager(store managerStore, modelCatalog *catalog.Catalog, authenticator *auth.StaticAuthenticator, logger *slog.Logger, pollInterval time.Duration, policyTargets ...policyReplacer) *Manager { if pollInterval <= 0 { pollInterval = 30 * time.Second } + var policyTarget policyReplacer + if len(policyTargets) > 0 { + policyTarget = policyTargets[0] + } return &Manager{ store: store, catalog: modelCatalog, authenticator: authenticator, - logger: logger, pollInterval: pollInterval, broadcasts: make(chan ChangeEvent, broadcastQueueSize), + logger: logger, pollInterval: pollInterval, broadcasts: make(chan ChangeEvent, broadcastQueueSize), policyTarget: policyTarget, } } @@ -53,6 +63,9 @@ func (m *Manager) Reload(ctx context.Context) (int64, error) { } m.catalog.Replace(snapshot.Models) m.authenticator.ReplaceHashed(snapshot.APIKeys) + if m.policyTarget != nil { + m.policyTarget.ReplacePolicies(snapshot.Limits) + } m.generation.Store(snapshot.Generation) m.logger.Info("control_plane_reloaded", "generation", snapshot.Generation, "models", len(snapshot.Models), "api_keys", len(snapshot.APIKeys)) return snapshot.Generation, nil @@ -195,7 +208,10 @@ func (m *Manager) runBroadcasts(ctx context.Context) { err := m.store.PublishChange(publishContext, event) cancel() if err != nil { + m.redisConnected.Store(false) m.logger.Warn("control_plane_publish_failed", "generation", event.Generation, "resource", event.Resource, "id", event.ID, "error", err) + } else { + m.redisConnected.Store(true) } } } diff --git a/internal/controlplane/manager_test.go b/internal/controlplane/manager_test.go index 8dd4012..ee78a00 100644 --- a/internal/controlplane/manager_test.go +++ b/internal/controlplane/manager_test.go @@ -13,6 +13,7 @@ import ( "aigw/internal/auth" "aigw/internal/catalog" + "aigw/internal/domain" ) type fakeManagerStore struct { @@ -26,6 +27,12 @@ type fakeManagerStore struct { subscribe func(context.Context, int64) (<-chan ChangeMessage, func() error, error) } +type capturePolicies struct{ values []domain.LimitPolicy } + +func (c *capturePolicies) ReplacePolicies(values []domain.LimitPolicy) { + c.values = append([]domain.LimitPolicy(nil), values...) +} + type safeLogBuffer struct { mu sync.Mutex buf bytes.Buffer @@ -199,6 +206,19 @@ func TestRedisCanBeDisabled(t *testing.T) { } } +func TestReloadReplacesLimitPolicySnapshot(t *testing.T) { + store := newFakeManagerStore(4) + store.snapshot.Store(Snapshot{Generation: 4, Limits: []domain.LimitPolicy{{ProjectID: "project-1", Concurrent: 3}}}) + target := &capturePolicies{} + manager := NewManager(store, catalog.NewModels(nil), auth.NewDynamic(nil, false), slog.Default(), time.Second, target) + if _, err := manager.Reload(context.Background()); err != nil { + t.Fatal(err) + } + if len(target.values) != 1 || target.values[0].Concurrent != 3 { + t.Fatalf("unexpected policies: %+v", target.values) + } +} + func waitUntil(t *testing.T, timeout time.Duration, condition func() bool) { t.Helper() deadline := time.Now().Add(timeout) diff --git a/internal/controlplane/mutations.go b/internal/controlplane/mutations.go index a781994..3cedf70 100644 --- a/internal/controlplane/mutations.go +++ b/internal/controlplane/mutations.go @@ -176,6 +176,9 @@ func (s *Store) CreateModel(ctx context.Context, input CreateModelInput) (Model, if input.PublicID == "" || len(input.Routes) == 0 { return Model{}, 0, errors.New("model requires public_id and at least one route") } + if input.InputPriceMicrosPerMillion < 0 || input.OutputPriceMicrosPerMillion < 0 || input.CacheReadPriceMicrosPerMillion < 0 || input.CacheWritePriceMicrosPerMillion < 0 { + return Model{}, 0, errors.New("model prices cannot be negative") + } for i := range input.Routes { input.Routes[i].ProviderID = strings.TrimSpace(input.Routes[i].ProviderID) input.Routes[i].UpstreamModel = strings.TrimSpace(input.Routes[i].UpstreamModel) @@ -193,9 +196,16 @@ func (s *Store) CreateModel(ctx context.Context, input CreateModelInput) (Model, defer tx.Rollback(ctx) var result Model err = tx.QueryRow(ctx, ` - INSERT INTO models (public_id, owned_by) VALUES ($1, $2) - RETURNING id::text, public_id, owned_by, enabled, created_at`, input.PublicID, input.OwnedBy, - ).Scan(&result.ID, &result.PublicID, &result.OwnedBy, &result.Enabled, &result.CreatedAt) + INSERT INTO models (public_id, owned_by, input_price_micros_per_million, output_price_micros_per_million, + cache_read_price_micros_per_million, cache_write_price_micros_per_million) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id::text, public_id, owned_by, input_price_micros_per_million, output_price_micros_per_million, + cache_read_price_micros_per_million, cache_write_price_micros_per_million, enabled, created_at`, + input.PublicID, input.OwnedBy, input.InputPriceMicrosPerMillion, input.OutputPriceMicrosPerMillion, + input.CacheReadPriceMicrosPerMillion, input.CacheWritePriceMicrosPerMillion, + ).Scan(&result.ID, &result.PublicID, &result.OwnedBy, &result.InputPriceMicrosPerMillion, + &result.OutputPriceMicrosPerMillion, &result.CacheReadPriceMicrosPerMillion, + &result.CacheWritePriceMicrosPerMillion, &result.Enabled, &result.CreatedAt) if err != nil { return Model{}, 0, fmt.Errorf("create model: %w", err) } diff --git a/internal/controlplane/queries.go b/internal/controlplane/queries.go index 732cb46..9b76fad 100644 --- a/internal/controlplane/queries.go +++ b/internal/controlplane/queries.go @@ -24,7 +24,18 @@ func (s *Store) Overview(ctx context.Context) (Overview, error) { } func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) { - rows, err := s.db.Query(ctx, `SELECT id::text, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`) + return s.ListTenantsFor(ctx, "") +} + +func (s *Store) ListTenantsFor(ctx context.Context, tenantID string) ([]Tenant, error) { + query := `SELECT id::text, slug, name, status, created_at FROM tenants` + args := []any{} + if tenantID != "" { + query += ` WHERE id=$1` + args = append(args, tenantID) + } + query += ` ORDER BY created_at DESC` + rows, err := s.db.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query tenants: %w", err) } @@ -41,7 +52,18 @@ func (s *Store) ListTenants(ctx context.Context) ([]Tenant, error) { } func (s *Store) ListProjects(ctx context.Context) ([]Project, error) { - rows, err := s.db.Query(ctx, `SELECT id::text, tenant_id::text, slug, name, status, created_at FROM projects ORDER BY created_at DESC`) + return s.ListProjectsFor(ctx, "") +} + +func (s *Store) ListProjectsFor(ctx context.Context, tenantID string) ([]Project, error) { + query := `SELECT id::text, tenant_id::text, slug, name, status, created_at FROM projects` + args := []any{} + if tenantID != "" { + query += ` WHERE tenant_id=$1` + args = append(args, tenantID) + } + query += ` ORDER BY created_at DESC` + rows, err := s.db.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query projects: %w", err) } @@ -58,9 +80,20 @@ func (s *Store) ListProjects(ctx context.Context) ([]Project, error) { } func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) { - rows, err := s.db.Query(ctx, ` + return s.ListAPIKeysFor(ctx, "") +} + +func (s *Store) ListAPIKeysFor(ctx context.Context, tenantID string) ([]APIKey, error) { + query := ` SELECT id::text, tenant_id::text, project_id::text, name, key_prefix, scopes, status, created_at - FROM api_keys ORDER BY created_at DESC`) + FROM api_keys` + args := []any{} + if tenantID != "" { + query += ` WHERE tenant_id=$1` + args = append(args, tenantID) + } + query += ` ORDER BY created_at DESC` + rows, err := s.db.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query API keys: %w", err) } @@ -80,6 +113,44 @@ func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) { return result, rows.Err() } +func (s *Store) OverviewFor(ctx context.Context, tenantID string) (Overview, error) { + if tenantID == "" { + return s.Overview(ctx) + } + var result Overview + err := s.db.QueryRow(ctx, `SELECT + (SELECT generation FROM control_state WHERE singleton=TRUE), + (SELECT count(*) FROM tenants WHERE id=$1 AND status='active'), + (SELECT count(*) FROM projects WHERE tenant_id=$1 AND status='active'), + (SELECT count(*) FROM api_keys WHERE tenant_id=$1 AND status='active'), + 0, + (SELECT count(*) FROM models WHERE enabled=TRUE)`, tenantID, + ).Scan(&result.Generation, &result.Tenants, &result.Projects, &result.APIKeys, &result.Providers, &result.Models) + if err != nil { + return Overview{}, fmt.Errorf("query tenant overview: %w", err) + } + return result, nil +} + +func (s *Store) ResourceTenantID(ctx context.Context, resource, id string) (string, error) { + var query string + switch resource { + case "project": + query = `SELECT tenant_id::text FROM projects WHERE id=$1` + case "api_key": + query = `SELECT tenant_id::text FROM api_keys WHERE id=$1` + case "console_user": + query = `SELECT COALESCE(tenant_id::text,'') FROM console_users WHERE id=$1` + default: + return "", ErrNotFound + } + var tenantID string + if err := s.db.QueryRow(ctx, query, id).Scan(&tenantID); err != nil { + return "", ErrNotFound + } + return tenantID, nil +} + func (s *Store) ListProviders(ctx context.Context) ([]Provider, error) { rows, err := s.db.Query(ctx, ` SELECT p.id::text, p.name, p.protocol, p.base_url, p.enabled, count(r.id), p.created_at @@ -101,7 +172,11 @@ func (s *Store) ListProviders(ctx context.Context) ([]Provider, error) { } func (s *Store) ListModels(ctx context.Context) ([]Model, error) { - rows, err := s.db.Query(ctx, `SELECT id::text, public_id, owned_by, enabled, created_at FROM models ORDER BY public_id`) + rows, err := s.db.Query(ctx, ` + SELECT id::text, public_id, owned_by, input_price_micros_per_million, + output_price_micros_per_million, cache_read_price_micros_per_million, + cache_write_price_micros_per_million, enabled, created_at + FROM models ORDER BY public_id`) if err != nil { return nil, fmt.Errorf("query models: %w", err) } @@ -109,7 +184,9 @@ func (s *Store) ListModels(ctx context.Context) ([]Model, error) { positions := make(map[string]int) for rows.Next() { var item Model - if err := rows.Scan(&item.ID, &item.PublicID, &item.OwnedBy, &item.Enabled, &item.CreatedAt); err != nil { + if err := rows.Scan(&item.ID, &item.PublicID, &item.OwnedBy, &item.InputPriceMicrosPerMillion, + &item.OutputPriceMicrosPerMillion, &item.CacheReadPriceMicrosPerMillion, + &item.CacheWritePriceMicrosPerMillion, &item.Enabled, &item.CreatedAt); err != nil { rows.Close() return nil, fmt.Errorf("scan model: %w", err) } diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql index 25af7c9..a518b1d 100644 --- a/internal/controlplane/schema.sql +++ b/internal/controlplane/schema.sql @@ -58,11 +58,20 @@ CREATE TABLE IF NOT EXISTS models ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), public_id TEXT NOT NULL UNIQUE, owned_by TEXT NOT NULL DEFAULT '', + input_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (input_price_micros_per_million >= 0), + output_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (output_price_micros_per_million >= 0), + cache_read_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_read_price_micros_per_million >= 0), + cache_write_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_write_price_micros_per_million >= 0), enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE models ADD COLUMN IF NOT EXISTS input_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (input_price_micros_per_million >= 0); +ALTER TABLE models ADD COLUMN IF NOT EXISTS output_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (output_price_micros_per_million >= 0); +ALTER TABLE models ADD COLUMN IF NOT EXISTS cache_read_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_read_price_micros_per_million >= 0); +ALTER TABLE models ADD COLUMN IF NOT EXISTS cache_write_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_write_price_micros_per_million >= 0); + CREATE TABLE IF NOT EXISTS model_routes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, @@ -80,3 +89,173 @@ CREATE INDEX IF NOT EXISTS api_keys_active_hash_idx ON api_keys (key_hash) WHERE CREATE INDEX IF NOT EXISTS projects_tenant_idx ON projects (tenant_id); CREATE INDEX IF NOT EXISTS model_routes_model_idx ON model_routes (model_id) WHERE enabled; CREATE INDEX IF NOT EXISTS model_routes_provider_idx ON model_routes (provider_id) WHERE enabled; + +CREATE TABLE IF NOT EXISTS tenant_wallets ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, + currency TEXT NOT NULL CHECK (currency = lower(currency) AND length(currency) = 3), + balance_micros BIGINT NOT NULL DEFAULT 0 CHECK (balance_micros >= 0), + reserved_micros BIGINT NOT NULL DEFAULT 0 CHECK (reserved_micros >= 0 AND reserved_micros <= balance_micros), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS billing_reservations ( + request_id TEXT PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + project_id UUID NOT NULL, + key_id UUID NOT NULL, + public_model TEXT NOT NULL, + currency TEXT NOT NULL, + reserved_micros BIGINT NOT NULL CHECK (reserved_micros >= 0), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'settled', 'released')), + input_price_micros_per_million BIGINT NOT NULL, + output_price_micros_per_million BIGINT NOT NULL, + cache_read_price_micros_per_million BIGINT NOT NULL, + cache_write_price_micros_per_million BIGINT NOT NULL, + actual_cost_micros BIGINT NOT NULL DEFAULT 0 CHECK (actual_cost_micros >= 0), + charged_micros BIGINT NOT NULL DEFAULT 0 CHECK (charged_micros >= 0), + uncollected_micros BIGINT NOT NULL DEFAULT 0 CHECK (uncollected_micros >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + settled_at TIMESTAMPTZ, + FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS usage_events ( + request_id TEXT PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + project_id UUID NOT NULL, + key_id UUID NOT NULL, + public_model TEXT NOT NULL, + provider_id TEXT, + upstream_model TEXT, + protocol TEXT NOT NULL, + stream BOOLEAN NOT NULL DEFAULT FALSE, + status_code INTEGER NOT NULL, + success BOOLEAN NOT NULL, + error_type TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL, + duration_ms BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + total_tokens BIGINT NOT NULL DEFAULT 0, + cache_creation_input_tokens BIGINT NOT NULL DEFAULT 0, + cache_read_input_tokens BIGINT NOT NULL DEFAULT 0, + cost_micros BIGINT NOT NULL DEFAULT 0, + charged_micros BIGINT NOT NULL DEFAULT 0, + uncollected_micros BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE RESTRICT +); + +-- Usage persistence is independent from billing. Older installations created this +-- foreign key, which prevented recording requests when prepaid billing was disabled. +ALTER TABLE usage_events DROP CONSTRAINT IF EXISTS usage_events_request_id_fkey; + +CREATE TABLE IF NOT EXISTS billing_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + project_id UUID, + currency TEXT NOT NULL, + amount_micros BIGINT NOT NULL, + balance_after_micros BIGINT NOT NULL CHECK (balance_after_micros >= 0), + kind TEXT NOT NULL CHECK (kind IN ('topup', 'usage', 'adjustment', 'refund', 'release')), + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (source_type, source_id) +); + +CREATE TABLE IF NOT EXISTS topup_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + amount_micros BIGINT NOT NULL CHECK (amount_micros > 0), + currency TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'expired')), + stripe_session_id TEXT UNIQUE, + checkout_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + paid_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS console_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ( + 'platform_admin', 'platform_viewer', 'tenant_admin', + 'tenant_billing', 'tenant_developer', 'tenant_viewer' + )), + token_prefix TEXT NOT NULL, + token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + CHECK ((role LIKE 'platform_%' AND tenant_id IS NULL) OR (role LIKE 'tenant_%' AND tenant_id IS NOT NULL)) +); +CREATE UNIQUE INDEX IF NOT EXISTS console_users_email_tenant_idx + ON console_users (lower(email), COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid)); + +CREATE TABLE IF NOT EXISTS project_limits ( + project_id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + requests_per_minute BIGINT NOT NULL DEFAULT 0 CHECK (requests_per_minute >= 0), + tokens_per_minute BIGINT NOT NULL DEFAULT 0 CHECK (tokens_per_minute >= 0), + concurrent_requests BIGINT NOT NULL DEFAULT 0 CHECK (concurrent_requests >= 0), + monthly_spend_micros BIGINT NOT NULL DEFAULT 0 CHECK (monthly_spend_micros >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS usage_monthly_rollups ( + period_start DATE NOT NULL, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + project_id UUID NOT NULL, + request_count BIGINT NOT NULL DEFAULT 0, + successful_requests BIGINT NOT NULL DEFAULT 0, + input_tokens BIGINT NOT NULL DEFAULT 0, + output_tokens BIGINT NOT NULL DEFAULT 0, + total_tokens BIGINT NOT NULL DEFAULT 0, + cost_micros BIGINT NOT NULL DEFAULT 0, + charged_micros BIGINT NOT NULL DEFAULT 0, + uncollected_micros BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, period_start), + FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + actor_id UUID REFERENCES console_users(id) ON DELETE SET NULL, + actor_type TEXT NOT NULL CHECK (actor_type IN ('bootstrap', 'console_user')), + actor_role TEXT NOT NULL, + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + path TEXT NOT NULL, + action TEXT NOT NULL, + status_code INTEGER NOT NULL, + remote_ip INET, + user_agent TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS billing_ledger_tenant_idx ON billing_ledger (tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS usage_events_tenant_idx ON usage_events (tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS usage_events_project_idx ON usage_events (project_id, created_at DESC); +CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events (public_model, created_at DESC); +CREATE INDEX IF NOT EXISTS billing_reservations_pending_idx ON billing_reservations (status, created_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS billing_reservations_project_pending_idx ON billing_reservations (project_id, created_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS console_users_tenant_idx ON console_users (tenant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS audit_logs_tenant_idx ON audit_logs (tenant_id, created_at DESC); diff --git a/internal/controlplane/snapshot.go b/internal/controlplane/snapshot.go index c8931ab..bc04e7c 100644 --- a/internal/controlplane/snapshot.go +++ b/internal/controlplane/snapshot.go @@ -37,12 +37,40 @@ func (s *Store) LoadSnapshot(ctx context.Context) (Snapshot, error) { if err != nil { return Snapshot{}, err } + result.Limits, err = loadLimitPolicies(ctx, tx) + if err != nil { + return Snapshot{}, err + } if err := tx.Commit(ctx); err != nil { return Snapshot{}, fmt.Errorf("commit snapshot transaction: %w", err) } return result, nil } +func loadLimitPolicies(ctx context.Context, tx pgx.Tx) ([]domain.LimitPolicy, error) { + rows, err := tx.Query(ctx, ` + SELECT tenant_id::text, project_id::text, requests_per_minute, tokens_per_minute, + concurrent_requests, monthly_spend_micros + FROM project_limits`) + if err != nil { + return nil, fmt.Errorf("query project limits: %w", err) + } + defer rows.Close() + result := make([]domain.LimitPolicy, 0) + for rows.Next() { + var policy domain.LimitPolicy + if err := rows.Scan(&policy.TenantID, &policy.ProjectID, &policy.RequestsPerMinute, + &policy.TokensPerMinute, &policy.Concurrent, &policy.MonthlySpendMicros); err != nil { + return nil, fmt.Errorf("scan project limit: %w", err) + } + result = append(result, policy) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read project limits: %w", err) + } + return result, nil +} + func (s *Store) loadProviders(ctx context.Context, tx pgx.Tx) (map[string]domain.Provider, error) { rows, err := tx.Query(ctx, ` SELECT id::text, name, protocol, base_url, api_key_ciphertext @@ -74,7 +102,9 @@ func (s *Store) loadProviders(ctx context.Context, tx pgx.Tx) (map[string]domain func loadModels(ctx context.Context, tx pgx.Tx, providers map[string]domain.Provider) ([]domain.Model, error) { rows, err := tx.Query(ctx, ` - SELECT m.public_id, m.owned_by, r.provider_id::text, r.upstream_model, r.priority, r.weight + SELECT m.public_id, m.owned_by, m.input_price_micros_per_million, m.output_price_micros_per_million, + m.cache_read_price_micros_per_million, m.cache_write_price_micros_per_million, + r.provider_id::text, r.upstream_model, r.priority, r.weight FROM models m JOIN model_routes r ON r.model_id = m.id AND r.enabled = TRUE JOIN providers p ON p.id = r.provider_id AND p.enabled = TRUE @@ -88,8 +118,9 @@ func loadModels(ctx context.Context, tx pgx.Tx, providers map[string]domain.Prov index := make(map[string]int) for rows.Next() { var publicID, ownedBy, providerID, upstreamModel string + var inputPrice, outputPrice, cacheReadPrice, cacheWritePrice int64 var priority, weight int - if err := rows.Scan(&publicID, &ownedBy, &providerID, &upstreamModel, &priority, &weight); err != nil { + if err := rows.Scan(&publicID, &ownedBy, &inputPrice, &outputPrice, &cacheReadPrice, &cacheWritePrice, &providerID, &upstreamModel, &priority, &weight); err != nil { return nil, fmt.Errorf("scan model route: %w", err) } provider, ok := providers[providerID] @@ -100,7 +131,11 @@ func loadModels(ctx context.Context, tx pgx.Tx, providers map[string]domain.Prov if !exists { position = len(models) index[publicID] = position - models = append(models, domain.Model{ID: publicID, OwnedBy: ownedBy}) + models = append(models, domain.Model{ + ID: publicID, OwnedBy: ownedBy, + InputPriceMicrosPerMillion: inputPrice, OutputPriceMicrosPerMillion: outputPrice, + CacheReadPriceMicrosPerMillion: cacheReadPrice, CacheWritePriceMicrosPerMillion: cacheWritePrice, + }) } models[position].Routes = append(models[position].Routes, domain.Route{ Provider: provider, UpstreamModel: upstreamModel, Priority: priority, Weight: weight, diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go index 24f2843..7dfb734 100644 --- a/internal/controlplane/types.go +++ b/internal/controlplane/types.go @@ -62,30 +62,38 @@ type Route struct { } type Model struct { - ID string `json:"id"` - PublicID string `json:"public_id"` - OwnedBy string `json:"owned_by"` - Enabled bool `json:"enabled"` - Routes []Route `json:"routes"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + PublicID string `json:"public_id"` + OwnedBy string `json:"owned_by"` + InputPriceMicrosPerMillion int64 `json:"input_price_micros_per_million"` + OutputPriceMicrosPerMillion int64 `json:"output_price_micros_per_million"` + CacheReadPriceMicrosPerMillion int64 `json:"cache_read_price_micros_per_million"` + CacheWritePriceMicrosPerMillion int64 `json:"cache_write_price_micros_per_million"` + Enabled bool `json:"enabled"` + Routes []Route `json:"routes"` + CreatedAt time.Time `json:"created_at"` } type Overview struct { - Generation int64 `json:"generation"` - RuntimeGeneration int64 `json:"runtime_generation"` - RedisConfigured bool `json:"redis_configured"` - RedisConnected bool `json:"redis_connected"` - Tenants int64 `json:"tenants"` - Projects int64 `json:"projects"` - APIKeys int64 `json:"api_keys"` - Providers int64 `json:"providers"` - Models int64 `json:"models"` + Generation int64 `json:"generation"` + RuntimeGeneration int64 `json:"runtime_generation"` + RedisConfigured bool `json:"redis_configured"` + RedisConnected bool `json:"redis_connected"` + Tenants int64 `json:"tenants"` + Projects int64 `json:"projects"` + APIKeys int64 `json:"api_keys"` + Providers int64 `json:"providers"` + Models int64 `json:"models"` + BillingEnabled bool `json:"billing_enabled"` + StripeEnabled bool `json:"stripe_enabled"` + BillingCurrency string `json:"billing_currency,omitempty"` } type Snapshot struct { Generation int64 Models []domain.Model APIKeys []auth.HashedKeyRecord + Limits []domain.LimitPolicy } type ChangeEvent struct { @@ -132,7 +140,126 @@ type RouteInput struct { } type CreateModelInput struct { - PublicID string `json:"public_id"` - OwnedBy string `json:"owned_by"` - Routes []RouteInput `json:"routes"` + PublicID string `json:"public_id"` + OwnedBy string `json:"owned_by"` + InputPriceMicrosPerMillion int64 `json:"input_price_micros_per_million"` + OutputPriceMicrosPerMillion int64 `json:"output_price_micros_per_million"` + CacheReadPriceMicrosPerMillion int64 `json:"cache_read_price_micros_per_million"` + CacheWritePriceMicrosPerMillion int64 `json:"cache_write_price_micros_per_million"` + Routes []RouteInput `json:"routes"` +} + +type ConsoleActor struct { + ID string `json:"id,omitempty"` + TenantID string `json:"tenant_id,omitempty"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Bootstrap bool `json:"bootstrap"` +} + +type ConsoleUser struct { + ID string `json:"id"` + TenantID string `json:"tenant_id,omitempty"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + TokenPrefix string `json:"token_prefix"` + Status string `json:"status"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type CreatedConsoleUser struct { + ConsoleUser + Token string `json:"token"` +} + +type CreateConsoleUserInput struct { + TenantID string `json:"tenant_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` +} + +type ProjectLimit struct { + domain.LimitPolicy + ProjectName string `json:"project_name"` + TenantName string `json:"tenant_name"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SetProjectLimitInput struct { + RequestsPerMinute int64 `json:"requests_per_minute"` + TokensPerMinute int64 `json:"tokens_per_minute"` + ConcurrentRequests int64 `json:"concurrent_requests"` + MonthlySpendMicros int64 `json:"monthly_spend_micros"` +} + +type UsageRecord struct { + RequestID string `json:"request_id"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + KeyID string `json:"key_id"` + PublicModel string `json:"public_model"` + ProviderID string `json:"provider_id,omitempty"` + UpstreamModel string `json:"upstream_model,omitempty"` + Protocol string `json:"protocol"` + Stream bool `json:"stream"` + StatusCode int `json:"status_code"` + Success bool `json:"success"` + ErrorType string `json:"error_type,omitempty"` + Attempts int `json:"attempts"` + StartedAt time.Time `json:"started_at"` + DurationMS int64 `json:"duration_ms"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CostMicros int64 `json:"cost_micros"` + ChargedMicros int64 `json:"charged_micros"` + UncollectedMicros int64 `json:"uncollected_micros"` +} + +type UsageSummary struct { + PeriodStart time.Time `json:"period_start"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + RequestCount int64 `json:"request_count"` + SuccessfulRequests int64 `json:"successful_requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + CostMicros int64 `json:"cost_micros"` + ChargedMicros int64 `json:"charged_micros"` + UncollectedMicros int64 `json:"uncollected_micros"` +} + +type AuditLog struct { + ID int64 `json:"id"` + ActorID string `json:"actor_id,omitempty"` + ActorType string `json:"actor_type"` + ActorRole string `json:"actor_role"` + TenantID string `json:"tenant_id,omitempty"` + RequestID string `json:"request_id"` + Method string `json:"method"` + Path string `json:"path"` + Action string `json:"action"` + StatusCode int `json:"status_code"` + RemoteIP string `json:"remote_ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type AuditInput struct { + Actor ConsoleActor + RequestID string + Method string + Path string + Action string + StatusCode int + RemoteIP string + UserAgent string } diff --git a/internal/controlplane/usage.go b/internal/controlplane/usage.go new file mode 100644 index 0000000..6c69a9f --- /dev/null +++ b/internal/controlplane/usage.go @@ -0,0 +1,151 @@ +package controlplane + +import ( + "context" + "fmt" + "strings" + "time" + + "aigw/internal/domain" + + "github.com/jackc/pgx/v5" +) + +type UsageQuery struct { + TenantID string + ProjectID string + Model string + Limit int +} + +func (s *Store) RecordUsage(ctx context.Context, event domain.UsageEvent) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("begin usage record: %w", err) + } + defer tx.Rollback(ctx) + command, err := tx.Exec(ctx, ` + INSERT INTO usage_events ( + request_id, tenant_id, project_id, key_id, public_model, provider_id, upstream_model, + protocol, stream, status_code, success, error_type, attempts, started_at, duration_ms, + input_tokens, output_tokens, total_tokens, cache_creation_input_tokens, cache_read_input_tokens, + cost_micros, charged_micros, uncollected_micros) + VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,0,0,0) + ON CONFLICT (request_id) DO NOTHING`, event.RequestID, event.TenantID, event.ProjectID, event.KeyID, + event.PublicModel, event.ProviderID, event.UpstreamModel, string(event.Protocol), event.Stream, + event.StatusCode, event.Success, event.ErrorType, event.Attempts, event.StartedAt, event.DurationMS, + event.Usage.InputTokens, event.Usage.OutputTokens, event.Usage.TotalTokens, + event.Usage.CacheCreationInputTokens, event.Usage.CacheReadInputTokens) + if err != nil { + return fmt.Errorf("persist usage event: %w", err) + } + if command.RowsAffected() == 0 { + return tx.Commit(ctx) + } + if err := upsertUsageRollup(ctx, tx, event, 0, 0, 0); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit usage record: %w", err) + } + return nil +} + +func upsertUsageRollup(ctx context.Context, tx pgx.Tx, event domain.UsageEvent, cost, charged, uncollected int64) error { + period := time.Date(event.StartedAt.UTC().Year(), event.StartedAt.UTC().Month(), 1, 0, 0, 0, 0, time.UTC) + _, err := tx.Exec(ctx, ` + INSERT INTO usage_monthly_rollups + (period_start, tenant_id, project_id, request_count, successful_requests, input_tokens, output_tokens, total_tokens, cost_micros, charged_micros, uncollected_micros) + VALUES ($1,$2,$3,1,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (project_id, period_start) DO UPDATE SET request_count=usage_monthly_rollups.request_count+1, + successful_requests=usage_monthly_rollups.successful_requests+EXCLUDED.successful_requests, + input_tokens=usage_monthly_rollups.input_tokens+EXCLUDED.input_tokens, + output_tokens=usage_monthly_rollups.output_tokens+EXCLUDED.output_tokens, + total_tokens=usage_monthly_rollups.total_tokens+EXCLUDED.total_tokens, + cost_micros=usage_monthly_rollups.cost_micros+EXCLUDED.cost_micros, + charged_micros=usage_monthly_rollups.charged_micros+EXCLUDED.charged_micros, + uncollected_micros=usage_monthly_rollups.uncollected_micros+EXCLUDED.uncollected_micros, + updated_at=now()`, period, event.TenantID, event.ProjectID, boolInt(event.Success), + event.Usage.InputTokens, event.Usage.OutputTokens, event.Usage.TotalTokens, cost, charged, uncollected) + if err != nil { + return fmt.Errorf("update usage monthly rollup: %w", err) + } + return nil +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func (s *Store) ListUsage(ctx context.Context, query UsageQuery) ([]UsageRecord, error) { + limit := query.Limit + if limit < 1 || limit > 1000 { + limit = 200 + } + where := []string{"1=1"} + args := make([]any, 0, 5) + index := 1 + for _, item := range []struct{ value, clause string }{{query.TenantID, "tenant_id=$"}, {query.ProjectID, "project_id=$"}, {query.Model, "public_model=$"}} { + if strings.TrimSpace(item.value) != "" { + where = append(where, item.clause+fmt.Sprint(index)) + args = append(args, item.value) + index++ + } + } + args = append(args, limit) + rows, err := s.db.Query(ctx, `SELECT request_id, tenant_id::text, project_id::text, key_id::text, public_model, + COALESCE(provider_id,''), COALESCE(upstream_model,''), protocol, stream, status_code, success, error_type, + attempts, started_at, duration_ms, input_tokens, output_tokens, total_tokens, cache_creation_input_tokens, + cache_read_input_tokens, cost_micros, charged_micros, uncollected_micros FROM usage_events WHERE `+ + strings.Join(where, " AND ")+` ORDER BY created_at DESC LIMIT $`+fmt.Sprint(index), args...) + if err != nil { + return nil, fmt.Errorf("query usage events: %w", err) + } + defer rows.Close() + result := make([]UsageRecord, 0) + for rows.Next() { + var item UsageRecord + if err := rows.Scan(&item.RequestID, &item.TenantID, &item.ProjectID, &item.KeyID, &item.PublicModel, &item.ProviderID, &item.UpstreamModel, + &item.Protocol, &item.Stream, &item.StatusCode, &item.Success, &item.ErrorType, &item.Attempts, &item.StartedAt, &item.DurationMS, + &item.InputTokens, &item.OutputTokens, &item.TotalTokens, &item.CacheCreationInputTokens, &item.CacheReadInputTokens, + &item.CostMicros, &item.ChargedMicros, &item.UncollectedMicros); err != nil { + return nil, fmt.Errorf("scan usage event: %w", err) + } + result = append(result, item) + } + return result, rows.Err() +} + +func (s *Store) UsageSummary(ctx context.Context, tenantID, projectID string) ([]UsageSummary, error) { + where := []string{"1=1"} + args := make([]any, 0, 2) + index := 1 + for _, item := range []struct{ value, clause string }{{tenantID, "r.tenant_id=$"}, {projectID, "r.project_id=$"}} { + if strings.TrimSpace(item.value) != "" { + where = append(where, item.clause+fmt.Sprint(index)) + args = append(args, item.value) + index++ + } + } + rows, err := s.db.Query(ctx, `SELECT r.period_start, r.tenant_id::text, r.project_id::text, p.name, + r.request_count, r.successful_requests, r.input_tokens, r.output_tokens, r.total_tokens, + r.cost_micros, r.charged_micros, r.uncollected_micros FROM usage_monthly_rollups r JOIN projects p ON p.id=r.project_id WHERE `+ + strings.Join(where, " AND ")+` ORDER BY r.period_start DESC, p.name`, args...) + if err != nil { + return nil, fmt.Errorf("query usage summary: %w", err) + } + defer rows.Close() + result := make([]UsageSummary, 0) + for rows.Next() { + var item UsageSummary + if err := rows.Scan(&item.PeriodStart, &item.TenantID, &item.ProjectID, &item.ProjectName, &item.RequestCount, &item.SuccessfulRequests, + &item.InputTokens, &item.OutputTokens, &item.TotalTokens, &item.CostMicros, &item.ChargedMicros, &item.UncollectedMicros); err != nil { + return nil, fmt.Errorf("scan usage summary: %w", err) + } + result = append(result, item) + } + return result, rows.Err() +} |
