Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
197 lines
7.1 KiB
Go
197 lines
7.1 KiB
Go
package store
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/shopspring/decimal"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// User is a registered account.
|
|
type User struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
Username string `gorm:"uniqueIndex;size:64"`
|
|
Email string `gorm:"uniqueIndex;size:255"`
|
|
PasswordHash string `gorm:"size:255"`
|
|
Role string `gorm:"size:16;default:user"` // admin | user
|
|
Balance decimal.Decimal `gorm:"type:numeric(20,8);default:0"`
|
|
Status string `gorm:"size:16;default:active"` // active | disabled
|
|
InviteCode string `gorm:"size:64"`
|
|
LastLoginAt *time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ApiKey is an API key issued to a user.
|
|
type ApiKey struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
UserID int64 `gorm:"index"`
|
|
Name string `gorm:"size:128"`
|
|
KeyHash string `gorm:"uniqueIndex;size:128"`
|
|
KeyPrefix string `gorm:"size:16"`
|
|
QuotaTokensPerDay *int64
|
|
QuotaRequestsPerDay *int
|
|
AllowedModels []string `gorm:"serializer:json"`
|
|
ExpiresAt *time.Time
|
|
Status string `gorm:"size:16;default:active"` // active | revoked
|
|
LastUsedAt *time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Channel is an upstream provider.
|
|
type Channel struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
Name string `gorm:"size:128"`
|
|
Provider string `gorm:"size:32"` // openai | anthropic | compatible
|
|
BaseURL string `gorm:"size:512"`
|
|
APIKeyEnc string `gorm:"size:2048"` // AES-GCM ciphertext
|
|
Weight int `gorm:"default:1"`
|
|
Priority int `gorm:"default:0"` // lower = preferred
|
|
TimeoutMs int `gorm:"default:300000"`
|
|
MaxConcurrency int `gorm:"default:100"`
|
|
HealthStatus string `gorm:"size:16;default:healthy"` // healthy | degraded | cooldown
|
|
HealthFailures int
|
|
Enabled bool `gorm:"default:true"`
|
|
Formats []string `gorm:"serializer:json"` // client API formats served natively; empty = derive from Provider
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Client API format keys a channel can serve natively. They match the proxy
|
|
// route protocol values so SupportsFormat can compare them directly.
|
|
const (
|
|
FormatOpenAIChat = "openai-chat"
|
|
FormatOpenAIResponses = "openai-responses"
|
|
FormatAnthropic = "anthropic"
|
|
)
|
|
|
|
// FormatsResolved returns the API formats the channel serves directly. When
|
|
// Formats is empty it falls back to the provider's native formats so legacy
|
|
// rows behave exactly as before.
|
|
func (c *Channel) FormatsResolved() []string {
|
|
if len(c.Formats) > 0 {
|
|
out := make([]string, len(c.Formats))
|
|
copy(out, c.Formats)
|
|
return out
|
|
}
|
|
if c.Provider == "anthropic" {
|
|
return []string{FormatAnthropic}
|
|
}
|
|
return []string{FormatOpenAIChat, FormatOpenAIResponses}
|
|
}
|
|
|
|
// SupportsFormat reports whether the channel serves a client protocol
|
|
// natively (passthrough) without conversion.
|
|
func (c *Channel) SupportsFormat(f string) bool {
|
|
for _, x := range c.FormatsResolved() {
|
|
if x == f {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Model is the global model registry with pricing.
|
|
type Model struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
Name string `gorm:"uniqueIndex;size:128"`
|
|
DisplayName string `gorm:"size:255"`
|
|
InputPrice decimal.Decimal `gorm:"type:numeric(20,8);default:0"` // per 1M tokens
|
|
OutputPrice decimal.Decimal `gorm:"type:numeric(20,8);default:0"`
|
|
CacheReadPrice decimal.Decimal `gorm:"type:numeric(20,8);default:0"`
|
|
Enabled bool `gorm:"default:true"`
|
|
Sort int `gorm:"default:0"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// ChannelModelBinding binds a global model to a channel with an upstream name.
|
|
type ChannelModelBinding struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
ChannelID int64 `gorm:"index;uniqueIndex:idx_channel_model"`
|
|
ModelID int64 `gorm:"index;uniqueIndex:idx_channel_model"`
|
|
UpstreamModel string `gorm:"size:255"`
|
|
Weight int `gorm:"default:1"`
|
|
Channel Channel `gorm:"foreignKey:ChannelID"`
|
|
Model Model `gorm:"foreignKey:ModelID"`
|
|
}
|
|
|
|
// UsageLog is one proxied request's billing record.
|
|
type UsageLog struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
RequestID string `gorm:"size:128"`
|
|
UserID int64 `gorm:"index:idx_user_created,priority:1"`
|
|
KeyID int64
|
|
ChannelID int64
|
|
ModelID int64
|
|
ModelName string `gorm:"size:128"`
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
CacheReadTokens int64
|
|
CacheCreationTokens int64
|
|
InputPrice decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
OutputPrice decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
CacheReadPrice decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
Cost decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
LatencyMs int
|
|
Status string `gorm:"size:16"` // success | error | canceled
|
|
ErrorCode string `gorm:"size:64"`
|
|
CreatedAt time.Time `gorm:"index:idx_user_created,priority:2"`
|
|
}
|
|
|
|
// UsageDaily is the pre-aggregated per-user per-model daily rollup.
|
|
type UsageDaily struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
UserID int64 `gorm:"index:idx_user_date,priority:1"`
|
|
ModelID int64
|
|
Date string `gorm:"size:10;index:idx_user_date,priority:2"` // YYYY-MM-DD
|
|
Requests int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
CacheReadTokens int64
|
|
CacheCreationTokens int64
|
|
Cost decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
}
|
|
|
|
// RechargeOrder is reserved for the (paused) recharge feature.
|
|
type RechargeOrder struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
UserID int64 `gorm:"index"`
|
|
Amount decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
Status string `gorm:"size:16;default:pending"` // pending | credited | rejected
|
|
Method string `gorm:"size:16;default:manual"` // manual | online
|
|
TransactionID string `gorm:"size:128"`
|
|
ReviewedBy *int64
|
|
ReviewedAt *time.Time
|
|
Remark string `gorm:"size:512"`
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
User User `gorm:"foreignKey:UserID"`
|
|
}
|
|
|
|
// BalanceLog is a user balance ledger entry.
|
|
type BalanceLog struct {
|
|
ID int64 `gorm:"primaryKey"`
|
|
UserID int64 `gorm:"index:idx_balance_user_created,priority:1"`
|
|
Change decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
BalanceAfter decimal.Decimal `gorm:"type:numeric(20,8)"`
|
|
Type string `gorm:"size:32"` // recharge | usage | refund | admin_adjust
|
|
RefID string `gorm:"size:128"`
|
|
CreatedAt time.Time `gorm:"index:idx_balance_user_created,priority:2"`
|
|
}
|
|
|
|
// SystemConfig is a key/value store for runtime settings.
|
|
type SystemConfig struct {
|
|
Key string `gorm:"primaryKey;size:128"`
|
|
Value []byte `gorm:"serializer:json"`
|
|
}
|
|
|
|
// Migrate creates/upgrades the schema.
|
|
func Migrate(db *gorm.DB) error {
|
|
return db.AutoMigrate(
|
|
&User{}, &ApiKey{}, &Channel{}, &Model{}, &ChannelModelBinding{},
|
|
&UsageLog{}, &UsageDaily{}, &RechargeOrder{}, &BalanceLog{}, &SystemConfig{},
|
|
)
|
|
}
|