feat: migrate backend from rust to go
This commit replaces the Axum/Rust backend with a Gin/Go implementation. The original Rust code has been archived in the 'rust' branch.
This commit is contained in:
174
internal/config/config.go
Normal file
174
internal/config/config.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Providers ProviderConfig `mapstructure:"providers"`
|
||||
EncryptionKey string `mapstructure:"encryption_key"`
|
||||
KeyBytes []byte
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Host string `mapstructure:"host"`
|
||||
AuthTokens []string `mapstructure:"auth_tokens"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `mapstructure:"path"`
|
||||
MaxConnections int `mapstructure:"max_connections"`
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||
DeepSeek DeepSeekConfig `mapstructure:"deepseek"`
|
||||
Grok GrokConfig `mapstructure:"grok"`
|
||||
Ollama OllamaConfig `mapstructure:"ollama"`
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
DefaultModel string `mapstructure:"default_model"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type GeminiConfig struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
DefaultModel string `mapstructure:"default_model"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type DeepSeekConfig struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
DefaultModel string `mapstructure:"default_model"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type GrokConfig struct {
|
||||
APIKeyEnv string `mapstructure:"api_key_env"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
DefaultModel string `mapstructure:"default_model"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type OllamaConfig struct {
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultModel string `mapstructure:"default_model"`
|
||||
Models []string `mapstructure:"models"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Defaults
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("server.host", "0.0.0.0")
|
||||
v.SetDefault("server.auth_tokens", []string{})
|
||||
v.SetDefault("database.path", "./data/llm_proxy.db")
|
||||
v.SetDefault("database.max_connections", 10)
|
||||
|
||||
v.SetDefault("providers.openai.api_key_env", "OPENAI_API_KEY")
|
||||
v.SetDefault("providers.openai.base_url", "https://api.openai.com/v1")
|
||||
v.SetDefault("providers.openai.default_model", "gpt-4o")
|
||||
v.SetDefault("providers.openai.enabled", true)
|
||||
|
||||
v.SetDefault("providers.gemini.api_key_env", "GEMINI_API_KEY")
|
||||
v.SetDefault("providers.gemini.base_url", "https://generativelanguage.googleapis.com/v1")
|
||||
v.SetDefault("providers.gemini.default_model", "gemini-2.0-flash")
|
||||
v.SetDefault("providers.gemini.enabled", true)
|
||||
|
||||
v.SetDefault("providers.deepseek.api_key_env", "DEEPSEEK_API_KEY")
|
||||
v.SetDefault("providers.deepseek.base_url", "https://api.deepseek.com")
|
||||
v.SetDefault("providers.deepseek.default_model", "deepseek-reasoner")
|
||||
v.SetDefault("providers.deepseek.enabled", true)
|
||||
|
||||
v.SetDefault("providers.grok.api_key_env", "GROK_API_KEY")
|
||||
v.SetDefault("providers.grok.base_url", "https://api.x.ai/v1")
|
||||
v.SetDefault("providers.grok.default_model", "grok-beta")
|
||||
v.SetDefault("providers.grok.enabled", true)
|
||||
|
||||
v.SetDefault("providers.ollama.base_url", "http://localhost:11434/v1")
|
||||
v.SetDefault("providers.ollama.enabled", false)
|
||||
v.SetDefault("providers.ollama.models", []string{})
|
||||
|
||||
// Environment variables
|
||||
v.SetEnvPrefix("LLM_PROXY")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "__"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Config file
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("toml")
|
||||
v.AddConfigPath(".")
|
||||
if envPath := os.Getenv("LLM_PROXY__CONFIG_PATH"); envPath != "" {
|
||||
v.SetConfigFile(envPath)
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Validate encryption key
|
||||
if cfg.EncryptionKey == "" {
|
||||
return nil, fmt.Errorf("encryption key is required (LLM_PROXY__ENCRYPTION_KEY)")
|
||||
}
|
||||
|
||||
keyBytes, err := hex.DecodeString(cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
keyBytes, err = base64.StdEncoding.DecodeString(cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption key must be hex or base64 encoded")
|
||||
}
|
||||
}
|
||||
|
||||
if len(keyBytes) != 32 {
|
||||
return nil, fmt.Errorf("encryption key must be 32 bytes, got %d", len(keyBytes))
|
||||
}
|
||||
cfg.KeyBytes = keyBytes
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) GetAPIKey(provider string) (string, error) {
|
||||
var envVar string
|
||||
switch provider {
|
||||
case "openai":
|
||||
envVar = c.Providers.OpenAI.APIKeyEnv
|
||||
case "gemini":
|
||||
envVar = c.Providers.Gemini.APIKeyEnv
|
||||
case "deepseek":
|
||||
envVar = c.Providers.DeepSeek.APIKeyEnv
|
||||
case "grok":
|
||||
envVar = c.Providers.Grok.APIKeyEnv
|
||||
default:
|
||||
return "", fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
|
||||
val := os.Getenv(envVar)
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("environment variable %s not set for %s", envVar, provider)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
264
internal/db/db.go
Normal file
264
internal/db/db.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "modernc.org/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
func Init(path string) (*DB, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "/" {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to SQLite
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path)
|
||||
db, err := sqlx.Connect("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
instance := &DB{db}
|
||||
|
||||
// Run migrations
|
||||
if err := instance.RunMigrations(); err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (db *DB) RunMigrations() error {
|
||||
// Tables creation
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||
total_requests INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER DEFAULT 0,
|
||||
total_cost REAL DEFAULT 0.0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS llm_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
client_id TEXT,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER,
|
||||
cost REAL,
|
||||
has_images BOOLEAN DEFAULT FALSE,
|
||||
status TEXT DEFAULT 'success',
|
||||
error_message TEXT,
|
||||
duration_ms INTEGER,
|
||||
request_body TEXT,
|
||||
response_body TEXT,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE SET NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS provider_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
base_url TEXT,
|
||||
api_key TEXT,
|
||||
credit_balance REAL DEFAULT 0.0,
|
||||
low_credit_threshold REAL DEFAULT 5.0,
|
||||
billing_mode TEXT,
|
||||
api_key_encrypted BOOLEAN DEFAULT FALSE,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS model_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
prompt_cost_per_m REAL,
|
||||
completion_cost_per_m REAL,
|
||||
cache_read_cost_per_m REAL,
|
||||
cache_write_cost_per_m REAL,
|
||||
mapping TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (provider_id) REFERENCES provider_configs(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT DEFAULT 'admin',
|
||||
must_change_password BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS client_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
name TEXT DEFAULT 'default',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME,
|
||||
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
return fmt.Errorf("migration failed for query [%s]: %w", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add indices
|
||||
indices := []string{
|
||||
"CREATE INDEX IF NOT EXISTS idx_clients_client_id ON clients(client_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_clients_created_at ON clients(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_timestamp ON llm_requests(timestamp)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_client_id ON llm_requests(client_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_provider ON llm_requests(provider)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_status ON llm_requests(status)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_client_tokens_token ON client_tokens(token)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_client_tokens_client_id ON client_tokens(client_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_client_timestamp ON llm_requests(client_id, timestamp)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_provider_timestamp ON llm_requests(provider, timestamp)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_model_configs_provider_id ON model_configs(provider_id)",
|
||||
}
|
||||
|
||||
for _, idx := range indices {
|
||||
if _, err := db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("failed to create index [%s]: %w", idx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default admin user
|
||||
var count int
|
||||
if err := db.Get(&count, "SELECT COUNT(*) FROM users"); err != nil {
|
||||
return fmt.Errorf("failed to count users: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), 12)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash default password: %w", err)
|
||||
}
|
||||
_, err = db.Exec("INSERT INTO users (username, password_hash, role, must_change_password) VALUES ('admin', ?, 'admin', 1)", string(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert default admin: %w", err)
|
||||
}
|
||||
log.Println("Created default admin user with password 'admin' (must change on first login)")
|
||||
}
|
||||
|
||||
// Default client
|
||||
_, err := db.Exec(`INSERT OR IGNORE INTO clients (client_id, name, description)
|
||||
VALUES ('default', 'Default Client', 'Default client for anonymous requests')`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert default client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Data models for DB tables
|
||||
|
||||
type Client struct {
|
||||
ID int `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
Name *string `db:"name"`
|
||||
Description *string `db:"description"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
IsActive bool `db:"is_active"`
|
||||
RateLimitPerMinute int `db:"rate_limit_per_minute"`
|
||||
TotalRequests int `db:"total_requests"`
|
||||
TotalTokens int `db:"total_tokens"`
|
||||
TotalCost float64 `db:"total_cost"`
|
||||
}
|
||||
|
||||
type LLMRequest struct {
|
||||
ID int `db:"id"`
|
||||
Timestamp time.Time `db:"timestamp"`
|
||||
ClientID *string `db:"client_id"`
|
||||
Provider *string `db:"provider"`
|
||||
Model *string `db:"model"`
|
||||
PromptTokens *int `db:"prompt_tokens"`
|
||||
CompletionTokens *int `db:"completion_tokens"`
|
||||
ReasoningTokens int `db:"reasoning_tokens"`
|
||||
TotalTokens *int `db:"total_tokens"`
|
||||
Cost *float64 `db:"cost"`
|
||||
HasImages bool `db:"has_images"`
|
||||
Status string `db:"status"`
|
||||
ErrorMessage *string `db:"error_message"`
|
||||
DurationMS *int `db:"duration_ms"`
|
||||
RequestBody *string `db:"request_body"`
|
||||
ResponseBody *string `db:"response_body"`
|
||||
CacheReadTokens int `db:"cache_read_tokens"`
|
||||
CacheWriteTokens int `db:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
ID string `db:"id"`
|
||||
DisplayName string `db:"display_name"`
|
||||
Enabled bool `db:"enabled"`
|
||||
BaseURL *string `db:"base_url"`
|
||||
APIKey *string `db:"api_key"`
|
||||
CreditBalance float64 `db:"credit_balance"`
|
||||
LowCreditThreshold float64 `db:"low_credit_threshold"`
|
||||
BillingMode *string `db:"billing_mode"`
|
||||
APIKeyEncrypted bool `db:"api_key_encrypted"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type ModelConfig struct {
|
||||
ID string `db:"id"`
|
||||
ProviderID string `db:"provider_id"`
|
||||
DisplayName *string `db:"display_name"`
|
||||
Enabled bool `db:"enabled"`
|
||||
PromptCostPerM *float64 `db:"prompt_cost_per_m"`
|
||||
CompletionCostPerM *float64 `db:"completion_cost_per_m"`
|
||||
CacheReadCostPerM *float64 `db:"cache_read_cost_per_m"`
|
||||
CacheWriteCostPerM *float64 `db:"cache_write_cost_per_m"`
|
||||
Mapping *string `db:"mapping"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id"`
|
||||
Username string `db:"username"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
DisplayName *string `db:"display_name"`
|
||||
Role string `db:"role"`
|
||||
MustChangePassword bool `db:"must_change_password"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
type ClientToken struct {
|
||||
ID int `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
Token string `db:"token"`
|
||||
Name string `db:"name"`
|
||||
IsActive bool `db:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
LastUsedAt *time.Time `db:"last_used_at"`
|
||||
}
|
||||
52
internal/middleware/auth.go
Normal file
52
internal/middleware/auth.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"llm-proxy/internal/db"
|
||||
"llm-proxy/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware(database *db.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == authHeader { // No "Bearer " prefix
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Try to resolve client from database
|
||||
var clientID string
|
||||
err := database.Get(&clientID, "UPDATE client_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token = ? AND is_active = 1 RETURNING client_id", token)
|
||||
|
||||
if err == nil {
|
||||
c.Set("auth", models.AuthInfo{
|
||||
Token: token,
|
||||
ClientID: clientID,
|
||||
})
|
||||
} else {
|
||||
// Fallback to token-prefix derivation (matches Rust behavior)
|
||||
prefixLen := len(token)
|
||||
if prefixLen > 8 {
|
||||
prefixLen = 8
|
||||
}
|
||||
clientID = "client_" + token[:prefixLen]
|
||||
c.Set("auth", models.AuthInfo{
|
||||
Token: token,
|
||||
ClientID: clientID,
|
||||
})
|
||||
log.Printf("Token not found in DB, using fallback client ID: %s", clientID)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
216
internal/models/models.go
Normal file
216
internal/models/models.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// OpenAI-compatible Request/Response Structs
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
TopK *uint32 `json:"top_k,omitempty"`
|
||||
N *uint32 `json:"n,omitempty"`
|
||||
Stop json.RawMessage `json:"stop,omitempty"` // Can be string or array of strings
|
||||
MaxTokens *uint32 `json:"max_tokens,omitempty"`
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"` // "system", "user", "assistant", "tool"
|
||||
Content interface{} `json:"content"`
|
||||
ReasoningContent *string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
ToolCallID *string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageUrl *ImageUrl `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
type ImageUrl struct {
|
||||
URL string `json:"url"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Tool-Calling Types
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function FunctionDef `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionDef struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type ToolCallDelta struct {
|
||||
Index uint32 `json:"index"`
|
||||
ID *string `json:"id,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Function *FunctionCallDelta `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionCallDelta struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Arguments *string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAI-compatible Response Structs
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ChatChoice struct {
|
||||
Index uint32 `json:"index"`
|
||||
Message ChatMessage `json:"message"`
|
||||
FinishReason *string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
PromptTokens uint32 `json:"prompt_tokens"`
|
||||
CompletionTokens uint32 `json:"completion_tokens"`
|
||||
TotalTokens uint32 `json:"total_tokens"`
|
||||
ReasoningTokens *uint32 `json:"reasoning_tokens,omitempty"`
|
||||
CacheReadTokens *uint32 `json:"cache_read_tokens,omitempty"`
|
||||
CacheWriteTokens *uint32 `json:"cache_write_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// Streaming Response Structs
|
||||
|
||||
type ChatCompletionStreamResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatStreamChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ChatStreamChoice struct {
|
||||
Index uint32 `json:"index"`
|
||||
Delta ChatStreamDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
type ChatStreamDelta struct {
|
||||
Role *string `json:"role,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
ReasoningContent *string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type StreamUsage struct {
|
||||
PromptTokens uint32 `json:"prompt_tokens"`
|
||||
CompletionTokens uint32 `json:"completion_tokens"`
|
||||
TotalTokens uint32 `json:"total_tokens"`
|
||||
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
||||
CacheReadTokens uint32 `json:"cache_read_tokens"`
|
||||
CacheWriteTokens uint32 `json:"cache_write_tokens"`
|
||||
}
|
||||
|
||||
// Unified Request Format (for internal use)
|
||||
|
||||
type UnifiedRequest struct {
|
||||
ClientID string
|
||||
Model string
|
||||
Messages []UnifiedMessage
|
||||
Temperature *float64
|
||||
TopP *float64
|
||||
TopK *uint32
|
||||
N *uint32
|
||||
Stop []string
|
||||
MaxTokens *uint32
|
||||
PresencePenalty *float64
|
||||
FrequencyPenalty *float64
|
||||
Stream bool
|
||||
HasImages bool
|
||||
Tools []Tool
|
||||
ToolChoice json.RawMessage
|
||||
}
|
||||
|
||||
type UnifiedMessage struct {
|
||||
Role string
|
||||
Content []UnifiedContentPart
|
||||
ReasoningContent *string
|
||||
ToolCalls []ToolCall
|
||||
Name *string
|
||||
ToolCallID *string
|
||||
}
|
||||
|
||||
type UnifiedContentPart struct {
|
||||
Type string
|
||||
Text string
|
||||
Image *ImageInput
|
||||
}
|
||||
|
||||
type ImageInput struct {
|
||||
Base64 string `json:"base64,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
}
|
||||
|
||||
func (i *ImageInput) ToBase64() (string, string, error) {
|
||||
if i.Base64 != "" {
|
||||
return i.Base64, i.MimeType, nil
|
||||
}
|
||||
|
||||
if i.URL != "" {
|
||||
client := resty.New()
|
||||
resp, err := client.R().Get(i.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to fetch image: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return "", "", fmt.Errorf("failed to fetch image: HTTP %d", resp.StatusCode())
|
||||
}
|
||||
|
||||
mimeType := resp.Header().Get("Content-Type")
|
||||
if mimeType == "" {
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(resp.Body())
|
||||
return encoded, mimeType, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("empty image input")
|
||||
}
|
||||
|
||||
// AuthInfo for context
|
||||
type AuthInfo struct {
|
||||
Token string
|
||||
ClientID string
|
||||
}
|
||||
143
internal/providers/deepseek.go
Normal file
143
internal/providers/deepseek.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type DeepSeekProvider struct {
|
||||
client *resty.Client
|
||||
config config.DeepSeekConfig
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
|
||||
return &DeepSeekProvider{
|
||||
client: resty.New(),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DeepSeekProvider) Name() string {
|
||||
return "deepseek"
|
||||
}
|
||||
|
||||
func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
}
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, false)
|
||||
|
||||
// Sanitize for deepseek-reasoner
|
||||
if req.Model == "deepseek-reasoner" {
|
||||
delete(body, "temperature")
|
||||
delete(body, "top_p")
|
||||
delete(body, "presence_penalty")
|
||||
delete(body, "frequency_penalty")
|
||||
|
||||
// Ensure assistant messages have content and reasoning_content
|
||||
if msgs, ok := body["messages"].([]interface{}); ok {
|
||||
for _, m := range msgs {
|
||||
if msg, ok := m.(map[string]interface{}); ok {
|
||||
if msg["role"] == "assistant" {
|
||||
if msg["reasoning_content"] == nil {
|
||||
msg["reasoning_content"] = " "
|
||||
}
|
||||
if msg["content"] == nil || msg["content"] == "" {
|
||||
msg["content"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
var respJSON map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Body(), &respJSON); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return ParseOpenAIResponse(respJSON, req.Model)
|
||||
}
|
||||
|
||||
func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
}
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, true)
|
||||
|
||||
// Sanitize for deepseek-reasoner
|
||||
if req.Model == "deepseek-reasoner" {
|
||||
delete(body, "temperature")
|
||||
delete(body, "top_p")
|
||||
delete(body, "presence_penalty")
|
||||
delete(body, "frequency_penalty")
|
||||
|
||||
// Ensure assistant messages have content and reasoning_content
|
||||
if msgs, ok := body["messages"].([]interface{}); ok {
|
||||
for _, m := range msgs {
|
||||
if msg, ok := m.(map[string]interface{}); ok {
|
||||
if msg["role"] == "assistant" {
|
||||
if msg["reasoning_content"] == nil {
|
||||
msg["reasoning_content"] = " "
|
||||
}
|
||||
if msg["content"] == nil || msg["content"] == "" {
|
||||
msg["content"] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
SetDoNotParseResponse(true).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
ch := make(chan *models.ChatCompletionStreamResponse)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamOpenAI(resp.RawBody(), ch)
|
||||
if err != nil {
|
||||
fmt.Printf("DeepSeek Stream error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
254
internal/providers/gemini.go
Normal file
254
internal/providers/gemini.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type GeminiProvider struct {
|
||||
client *resty.Client
|
||||
config config.GeminiConfig
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewGeminiProvider(cfg config.GeminiConfig, apiKey string) *GeminiProvider {
|
||||
return &GeminiProvider{
|
||||
client: resty.New(),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Name() string {
|
||||
return "gemini"
|
||||
}
|
||||
|
||||
type GeminiRequest struct {
|
||||
Contents []GeminiContent `json:"contents"`
|
||||
}
|
||||
|
||||
type GeminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []GeminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type GeminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
|
||||
FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"`
|
||||
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
|
||||
}
|
||||
|
||||
type GeminiInlineData struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type GeminiFunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
type GeminiFunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||
// Gemini mapping
|
||||
var contents []GeminiContent
|
||||
for _, msg := range req.Messages {
|
||||
role := "user"
|
||||
if msg.Role == "assistant" {
|
||||
role = "model"
|
||||
} else if msg.Role == "tool" {
|
||||
role = "user" // Tool results are user-side in Gemini
|
||||
}
|
||||
|
||||
var parts []GeminiPart
|
||||
|
||||
// Handle tool responses
|
||||
if msg.Role == "tool" {
|
||||
text := ""
|
||||
if len(msg.Content) > 0 {
|
||||
text = msg.Content[0].Text
|
||||
}
|
||||
|
||||
// Gemini expects functionResponse to be an object
|
||||
name := "unknown_function"
|
||||
if msg.Name != nil {
|
||||
name = *msg.Name
|
||||
}
|
||||
|
||||
parts = append(parts, GeminiPart{
|
||||
FunctionResponse: &GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: json.RawMessage(text),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
for _, cp := range msg.Content {
|
||||
if cp.Type == "text" {
|
||||
parts = append(parts, GeminiPart{Text: cp.Text})
|
||||
} else if cp.Image != nil {
|
||||
base64Data, mimeType, _ := cp.Image.ToBase64()
|
||||
parts = append(parts, GeminiPart{
|
||||
InlineData: &GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle assistant tool calls
|
||||
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||
for _, tc := range msg.ToolCalls {
|
||||
parts = append(parts, GeminiPart{
|
||||
FunctionCall: &GeminiFunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Args: json.RawMessage(tc.Function.Arguments),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contents = append(contents, GeminiContent{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
|
||||
body := GeminiRequest{
|
||||
Contents: contents,
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", p.config.BaseURL, req.Model, p.apiKey)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
Post(url)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
// Parse Gemini response and convert to OpenAI format
|
||||
var geminiResp struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount uint32 `json:"promptTokenCount"`
|
||||
CandidatesTokenCount uint32 `json:"candidatesTokenCount"`
|
||||
TotalTokenCount uint32 `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(resp.Body(), &geminiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(geminiResp.Candidates) == 0 {
|
||||
return nil, fmt.Errorf("no candidates in Gemini response")
|
||||
}
|
||||
|
||||
content := ""
|
||||
for _, p := range geminiResp.Candidates[0].Content.Parts {
|
||||
content += p.Text
|
||||
}
|
||||
|
||||
openAIResp := &models.ChatCompletionResponse{
|
||||
ID: "gemini-" + req.Model,
|
||||
Object: "chat.completion",
|
||||
Created: 0, // Should be current timestamp
|
||||
Model: req.Model,
|
||||
Choices: []models.ChatChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: models.ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
},
|
||||
FinishReason: &geminiResp.Candidates[0].FinishReason,
|
||||
},
|
||||
},
|
||||
Usage: &models.Usage{
|
||||
PromptTokens: geminiResp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiResp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: geminiResp.UsageMetadata.TotalTokenCount,
|
||||
},
|
||||
}
|
||||
|
||||
return openAIResp, nil
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
// Simplified Gemini mapping
|
||||
var contents []GeminiContent
|
||||
for _, msg := range req.Messages {
|
||||
role := "user"
|
||||
if msg.Role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
|
||||
var parts []GeminiPart
|
||||
for _, p := range msg.Content {
|
||||
parts = append(parts, GeminiPart{Text: p.Text})
|
||||
}
|
||||
|
||||
contents = append(contents, GeminiContent{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
|
||||
body := GeminiRequest{
|
||||
Contents: contents,
|
||||
}
|
||||
|
||||
// Use streamGenerateContent for streaming
|
||||
url := fmt.Sprintf("%s/models/%s:streamGenerateContent?key=%s", p.config.BaseURL, req.Model, p.apiKey)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetBody(body).
|
||||
SetDoNotParseResponse(true).
|
||||
Post(url)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("Gemini API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
ch := make(chan *models.ChatCompletionStreamResponse)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamGemini(resp.RawBody(), ch, req.Model)
|
||||
if err != nil {
|
||||
fmt.Printf("Gemini Stream error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
95
internal/providers/grok.go
Normal file
95
internal/providers/grok.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type GrokProvider struct {
|
||||
client *resty.Client
|
||||
config config.GrokConfig
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewGrokProvider(cfg config.GrokConfig, apiKey string) *GrokProvider {
|
||||
return &GrokProvider{
|
||||
client: resty.New(),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GrokProvider) Name() string {
|
||||
return "grok"
|
||||
}
|
||||
|
||||
func (p *GrokProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
}
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, false)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("Grok API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
var respJSON map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Body(), &respJSON); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return ParseOpenAIResponse(respJSON, req.Model)
|
||||
}
|
||||
|
||||
func (p *GrokProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
}
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, true)
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
SetDoNotParseResponse(true).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("Grok API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
ch := make(chan *models.ChatCompletionStreamResponse)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamOpenAI(resp.RawBody(), ch)
|
||||
if err != nil {
|
||||
fmt.Printf("Grok Stream error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
259
internal/providers/helpers.go
Normal file
259
internal/providers/helpers.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"llm-proxy/internal/models"
|
||||
)
|
||||
|
||||
// MessagesToOpenAIJSON converts unified messages to OpenAI-compatible JSON, including tools and images.
|
||||
func MessagesToOpenAIJSON(messages []models.UnifiedMessage) ([]interface{}, error) {
|
||||
var result []interface{}
|
||||
for _, m := range messages {
|
||||
if m.Role == "tool" {
|
||||
text := ""
|
||||
if len(m.Content) > 0 {
|
||||
text = m.Content[0].Text
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"role": "tool",
|
||||
"content": text,
|
||||
}
|
||||
if m.ToolCallID != nil {
|
||||
id := *m.ToolCallID
|
||||
if len(id) > 40 {
|
||||
id = id[:40]
|
||||
}
|
||||
msg["tool_call_id"] = id
|
||||
}
|
||||
if m.Name != nil {
|
||||
msg["name"] = *m.Name
|
||||
}
|
||||
result = append(result, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
var parts []interface{}
|
||||
for _, p := range m.Content {
|
||||
if p.Type == "text" {
|
||||
parts = append(parts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": p.Text,
|
||||
})
|
||||
} else if p.Image != nil {
|
||||
base64Data, mimeType, err := p.Image.ToBase64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert image to base64: %w", err)
|
||||
}
|
||||
parts = append(parts, map[string]interface{}{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]interface{}{
|
||||
"url": fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"role": m.Role,
|
||||
"content": parts,
|
||||
}
|
||||
|
||||
if m.ReasoningContent != nil {
|
||||
msg["reasoning_content"] = *m.ReasoningContent
|
||||
}
|
||||
|
||||
if len(m.ToolCalls) > 0 {
|
||||
sanitizedCalls := make([]models.ToolCall, len(m.ToolCalls))
|
||||
copy(sanitizedCalls, m.ToolCalls)
|
||||
for i := range sanitizedCalls {
|
||||
if len(sanitizedCalls[i].ID) > 40 {
|
||||
sanitizedCalls[i].ID = sanitizedCalls[i].ID[:40]
|
||||
}
|
||||
}
|
||||
msg["tool_calls"] = sanitizedCalls
|
||||
if len(parts) == 0 {
|
||||
msg["content"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
if m.Name != nil {
|
||||
msg["name"] = *m.Name
|
||||
}
|
||||
|
||||
result = append(result, msg)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildOpenAIBody(request *models.UnifiedRequest, messagesJSON []interface{}, stream bool) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"model": request.Model,
|
||||
"messages": messagesJSON,
|
||||
"stream": stream,
|
||||
}
|
||||
|
||||
if stream {
|
||||
body["stream_options"] = map[string]interface{}{
|
||||
"include_usage": true,
|
||||
}
|
||||
}
|
||||
|
||||
if request.Temperature != nil {
|
||||
body["temperature"] = *request.Temperature
|
||||
}
|
||||
if request.MaxTokens != nil {
|
||||
body["max_tokens"] = *request.MaxTokens
|
||||
}
|
||||
if len(request.Tools) > 0 {
|
||||
body["tools"] = request.Tools
|
||||
}
|
||||
if request.ToolChoice != nil {
|
||||
var toolChoice interface{}
|
||||
if err := json.Unmarshal(request.ToolChoice, &toolChoice); err == nil {
|
||||
body["tool_choice"] = toolChoice
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func ParseOpenAIResponse(respJSON map[string]interface{}, model string) (*models.ChatCompletionResponse, error) {
|
||||
data, err := json.Marshal(respJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp models.ChatCompletionResponse
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Streaming support
|
||||
|
||||
func ParseOpenAIStreamChunk(line string) (*models.ChatCompletionStreamResponse, bool, error) {
|
||||
if line == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
var chunk models.ChatCompletionStreamResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, false, fmt.Errorf("failed to unmarshal stream chunk: %w", err)
|
||||
}
|
||||
|
||||
return &chunk, false, nil
|
||||
}
|
||||
|
||||
func StreamOpenAI(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse) error {
|
||||
defer ctx.Close()
|
||||
scanner := bufio.NewScanner(ctx)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
chunk, done, err := ParseOpenAIStreamChunk(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
if chunk != nil {
|
||||
ch <- chunk
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func StreamGemini(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse, model string) error {
|
||||
defer ctx.Close()
|
||||
|
||||
dec := json.NewDecoder(ctx)
|
||||
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delim, ok := t.(json.Delim); ok && delim == '[' {
|
||||
for dec.More() {
|
||||
var geminiChunk struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Thought string `json:"thought,omitempty"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount uint32 `json:"promptTokenCount"`
|
||||
CandidatesTokenCount uint32 `json:"candidatesTokenCount"`
|
||||
TotalTokenCount uint32 `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
if err := dec.Decode(&geminiChunk); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(geminiChunk.Candidates) > 0 {
|
||||
content := ""
|
||||
var reasoning *string
|
||||
for _, p := range geminiChunk.Candidates[0].Content.Parts {
|
||||
if p.Text != "" {
|
||||
content += p.Text
|
||||
}
|
||||
if p.Thought != "" {
|
||||
if reasoning == nil {
|
||||
reasoning = new(string)
|
||||
}
|
||||
*reasoning += p.Thought
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := strings.ToLower(geminiChunk.Candidates[0].FinishReason)
|
||||
if finishReason == "stop" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
ch <- &models.ChatCompletionStreamResponse{
|
||||
ID: "gemini-stream",
|
||||
Object: "chat.completion.chunk",
|
||||
Created: 0,
|
||||
Model: model,
|
||||
Choices: []models.ChatStreamChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: models.ChatStreamDelta{
|
||||
Content: &content,
|
||||
ReasoningContent: reasoning,
|
||||
},
|
||||
FinishReason: &finishReason,
|
||||
},
|
||||
},
|
||||
Usage: &models.Usage{
|
||||
PromptTokens: geminiChunk.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiChunk.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: geminiChunk.UsageMetadata.TotalTokenCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
113
internal/providers/openai.go
Normal file
113
internal/providers/openai.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/models"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type OpenAIProvider struct {
|
||||
client *resty.Client
|
||||
config config.OpenAIConfig
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewOpenAIProvider(cfg config.OpenAIConfig, apiKey string) *OpenAIProvider {
|
||||
return &OpenAIProvider{
|
||||
client: resty.New(),
|
||||
config: cfg,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string {
|
||||
return "openai"
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
}
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, false)
|
||||
|
||||
// Transition: Newer models require max_completion_tokens
|
||||
if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") {
|
||||
if maxTokens, ok := body["max_tokens"]; ok {
|
||||
delete(body, "max_tokens")
|
||||
body["max_completion_tokens"] = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("OpenAI API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
var respJSON map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Body(), &respJSON); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return ParseOpenAIResponse(respJSON, req.Model)
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
||||
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
||||
}
|
||||
|
||||
body := BuildOpenAIBody(req, messagesJSON, true)
|
||||
|
||||
// Transition: Newer models require max_completion_tokens
|
||||
if strings.HasPrefix(req.Model, "o1-") || strings.HasPrefix(req.Model, "o3-") || strings.Contains(req.Model, "gpt-5") {
|
||||
if maxTokens, ok := body["max_tokens"]; ok {
|
||||
delete(body, "max_tokens")
|
||||
body["max_completion_tokens"] = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := p.client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("Authorization", "Bearer "+p.apiKey).
|
||||
SetBody(body).
|
||||
SetDoNotParseResponse(true).
|
||||
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.IsSuccess() {
|
||||
return nil, fmt.Errorf("OpenAI API error (%d): %s", resp.StatusCode(), resp.String())
|
||||
}
|
||||
|
||||
ch := make(chan *models.ChatCompletionStreamResponse)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
err := StreamOpenAI(resp.RawBody(), ch)
|
||||
if err != nil {
|
||||
// In a real app, you might want to send an error chunk or log it
|
||||
fmt.Printf("Stream error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
13
internal/providers/provider.go
Normal file
13
internal/providers/provider.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"llm-proxy/internal/models"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error)
|
||||
ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error)
|
||||
}
|
||||
675
internal/server/dashboard.go
Normal file
675
internal/server/dashboard.go
Normal file
@@ -0,0 +1,675 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type ApiResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func SuccessResponse(data interface{}) ApiResponse {
|
||||
return ApiResponse{Success: true, Data: data}
|
||||
}
|
||||
|
||||
func ErrorResponse(err string) ApiResponse {
|
||||
return ApiResponse{Success: false, Error: err}
|
||||
}
|
||||
|
||||
func (s *Server) adminAuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
|
||||
return
|
||||
}
|
||||
|
||||
session, _, err := s.sessions.ValidateSession(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse("Session expired or invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
if session.Role != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, ErrorResponse("Admin access required"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("session", session)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
var user db.User
|
||||
err := s.database.Get(&user, "SELECT username, password_hash, display_name, role, must_change_password FROM users WHERE username = ?", req.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse("Invalid username or password"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse("Invalid username or password"))
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.sessions.CreateSession(user.Username, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to create session"))
|
||||
return
|
||||
}
|
||||
|
||||
displayName := user.Username
|
||||
if user.DisplayName != nil {
|
||||
displayName = *user.DisplayName
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"token": token,
|
||||
"must_change_password": user.MustChangePassword,
|
||||
"user": gin.H{
|
||||
"username": user.Username,
|
||||
"name": displayName,
|
||||
"role": user.Role,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
session, _, err := s.sessions.ValidateSession(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse("Not authenticated"))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"authenticated": true,
|
||||
"user": gin.H{
|
||||
"username": session.Username,
|
||||
"role": session.Role,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
s.sessions.RevokeSession(token)
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Logged out"}))
|
||||
}
|
||||
|
||||
type UsagePeriodFilter struct {
|
||||
Period string `form:"period"`
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
}
|
||||
|
||||
func (f *UsagePeriodFilter) ToSQL() (string, []interface{}) {
|
||||
period := f.Period
|
||||
if period == "" {
|
||||
period = "all"
|
||||
}
|
||||
|
||||
if period == "custom" {
|
||||
var clauses []string
|
||||
var binds []interface{}
|
||||
if f.From != "" {
|
||||
clauses = append(clauses, "timestamp >= ?")
|
||||
binds = append(binds, f.From)
|
||||
}
|
||||
if f.To != "" {
|
||||
clauses = append(clauses, "timestamp <= ?")
|
||||
binds = append(binds, f.To)
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
return " AND " + strings.Join(clauses, " AND "), binds
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var cutoff time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
cutoff = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
case "24h":
|
||||
cutoff = now.Add(-24 * time.Hour)
|
||||
case "7d":
|
||||
cutoff = now.Add(-7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
cutoff = now.Add(-30 * 24 * time.Hour)
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return " AND timestamp >= ?", []interface{}{cutoff.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
func (s *Server) handleUsageSummary(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(cost), 0.0) as total_cost,
|
||||
COUNT(DISTINCT client_id) as active_clients
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
`, clause)
|
||||
|
||||
var stats struct {
|
||||
TotalRequests int `db:"total_requests"`
|
||||
TotalTokens int `db:"total_tokens"`
|
||||
TotalCost float64 `db:"total_cost"`
|
||||
ActiveClients int `db:"active_clients"`
|
||||
}
|
||||
|
||||
err := s.database.Get(&stats, query, binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(stats))
|
||||
}
|
||||
|
||||
func (s *Server) handleTimeSeries(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
if clause == "" {
|
||||
cutoff := time.Now().UTC().Add(-30 * 24 * time.Hour)
|
||||
clause = " AND timestamp >= ?"
|
||||
binds = []interface{}{cutoff.Format(time.RFC3339)}
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
strftime('%%Y-%%m-%%d', timestamp) as bucket,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||
COALESCE(SUM(cost), 0.0) as cost
|
||||
FROM llm_requests
|
||||
WHERE 1=1 %s
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`, clause)
|
||||
|
||||
var rows []struct {
|
||||
Bucket string `db:"bucket"`
|
||||
Requests int `db:"requests"`
|
||||
Tokens int `db:"tokens"`
|
||||
Cost float64 `db:"cost"`
|
||||
}
|
||||
|
||||
err := s.database.Select(&rows, query, binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
series := make([]gin.H, len(rows))
|
||||
for i, r := range rows {
|
||||
series[i] = gin.H{
|
||||
"time": r.Bucket,
|
||||
"requests": r.Requests,
|
||||
"tokens": r.Tokens,
|
||||
"cost": r.Cost,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"series": series,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleAnalyticsBreakdown(c *gin.Context) {
|
||||
var filter UsagePeriodFilter
|
||||
if err := c.ShouldBindQuery(&filter); err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clause, binds := filter.ToSQL()
|
||||
|
||||
var models []struct {
|
||||
Label string `db:"label"`
|
||||
Value int `db:"value"`
|
||||
}
|
||||
err := s.database.Select(&models, fmt.Sprintf("SELECT model as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY model ORDER BY value DESC", clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var clients []struct {
|
||||
Label string `db:"label"`
|
||||
Value int `db:"value"`
|
||||
}
|
||||
err = s.database.Select(&clients, fmt.Sprintf("SELECT client_id as label, COUNT(*) as value FROM llm_requests WHERE 1=1 %s GROUP BY client_id ORDER BY value DESC", clause), binds...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"models": models,
|
||||
"clients": clients,
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClients(c *gin.Context) {
|
||||
var clients []db.Client
|
||||
err := s.database.Select(&clients, "SELECT * FROM clients ORDER BY created_at DESC")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(clients))
|
||||
}
|
||||
|
||||
type CreateClientRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ClientID *string `json:"client_id"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClient(c *gin.Context) {
|
||||
var req CreateClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
clientID := ""
|
||||
if req.ClientID != nil {
|
||||
clientID = *req.ClientID
|
||||
} else {
|
||||
clientID = "client-" + uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("INSERT INTO clients (client_id, name, is_active) VALUES (?, ?, 1)", clientID, req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
token := "sk-" + uuid.New().String() + uuid.New().String()
|
||||
token = token[:51]
|
||||
|
||||
_, err = s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')", clientID, token)
|
||||
if err != nil {
|
||||
// Log error
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"id": clientID,
|
||||
"name": req.Name,
|
||||
"status": "active",
|
||||
"token": token,
|
||||
"created_at": time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteClient(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "default" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete default client"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM clients WHERE client_id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client deleted"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClientTokens(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var tokens []db.ClientToken
|
||||
err := s.database.Select(&tokens, "SELECT * FROM client_tokens WHERE client_id = ? ORDER BY created_at DESC", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
type MaskedToken struct {
|
||||
ID int `json:"id"`
|
||||
TokenMasked string `json:"token_masked"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
masked := make([]MaskedToken, len(tokens))
|
||||
for i, t := range tokens {
|
||||
maskedToken := "••••"
|
||||
if len(t.Token) > 8 {
|
||||
maskedToken = t.Token[:3] + "••••" + t.Token[len(t.Token)-8:]
|
||||
}
|
||||
masked[i] = MaskedToken{
|
||||
ID: t.ID,
|
||||
TokenMasked: maskedToken,
|
||||
Name: t.Name,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt,
|
||||
LastUsedAt: t.LastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(masked))
|
||||
}
|
||||
|
||||
type CreateTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateClientToken(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
var req CreateTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// optional name
|
||||
}
|
||||
|
||||
name := "default"
|
||||
if req.Name != "" {
|
||||
name = req.Name
|
||||
}
|
||||
|
||||
token := "sk-" + uuid.New().String() + uuid.New().String()
|
||||
token = token[:51]
|
||||
|
||||
_, err := s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?)", clientID, token, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"token": token,
|
||||
"name": name,
|
||||
"created_at": time.Now(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteClientToken(c *gin.Context) {
|
||||
tokenID := c.Param("token_id")
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM client_tokens WHERE id = ?", tokenID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Token revoked"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProviders(c *gin.Context) {
|
||||
var dbConfigs []db.ProviderConfig
|
||||
err := s.database.Select(&dbConfigs, "SELECT id, enabled, base_url, credit_balance, low_credit_threshold, billing_mode FROM provider_configs")
|
||||
if err != nil {
|
||||
// Log error
|
||||
}
|
||||
|
||||
dbMap := make(map[string]db.ProviderConfig)
|
||||
for _, cfg := range dbConfigs {
|
||||
dbMap[cfg.ID] = cfg
|
||||
}
|
||||
|
||||
providerIDs := []string{"openai", "gemini", "deepseek", "grok", "ollama"}
|
||||
var result []gin.H
|
||||
|
||||
for _, id := range providerIDs {
|
||||
var name string
|
||||
var enabled bool
|
||||
var baseURL string
|
||||
|
||||
switch id {
|
||||
case "openai":
|
||||
name = "OpenAI"
|
||||
enabled = s.cfg.Providers.OpenAI.Enabled
|
||||
baseURL = s.cfg.Providers.OpenAI.BaseURL
|
||||
case "gemini":
|
||||
name = "Google Gemini"
|
||||
enabled = s.cfg.Providers.Gemini.Enabled
|
||||
baseURL = s.cfg.Providers.Gemini.BaseURL
|
||||
case "deepseek":
|
||||
name = "DeepSeek"
|
||||
enabled = s.cfg.Providers.DeepSeek.Enabled
|
||||
baseURL = s.cfg.Providers.DeepSeek.BaseURL
|
||||
case "grok":
|
||||
name = "xAI Grok"
|
||||
enabled = s.cfg.Providers.Grok.Enabled
|
||||
baseURL = s.cfg.Providers.Grok.BaseURL
|
||||
case "ollama":
|
||||
name = "Ollama"
|
||||
enabled = s.cfg.Providers.Ollama.Enabled
|
||||
baseURL = s.cfg.Providers.Ollama.BaseURL
|
||||
}
|
||||
|
||||
var balance float64
|
||||
var threshold float64 = 5.0
|
||||
var billingMode string
|
||||
|
||||
if dbCfg, ok := dbMap[id]; ok {
|
||||
enabled = dbCfg.Enabled
|
||||
if dbCfg.BaseURL != nil {
|
||||
baseURL = *dbCfg.BaseURL
|
||||
}
|
||||
balance = dbCfg.CreditBalance
|
||||
threshold = dbCfg.LowCreditThreshold
|
||||
if dbCfg.BillingMode != nil {
|
||||
billingMode = *dbCfg.BillingMode
|
||||
}
|
||||
}
|
||||
|
||||
status := "disabled"
|
||||
if enabled {
|
||||
if _, ok := s.providers[id]; ok {
|
||||
status = "online"
|
||||
} else {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"status": status,
|
||||
"base_url": baseURL,
|
||||
"credit_balance": balance,
|
||||
"low_credit_threshold": threshold,
|
||||
"billing_mode": billingMode,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(result))
|
||||
}
|
||||
|
||||
type UpdateProviderRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
CreditBalance *float64 `json:"credit_balance"`
|
||||
LowCreditThreshold *float64 `json:"low_credit_threshold"`
|
||||
BillingMode *string `json:"billing_mode"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProvider(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
var req UpdateProviderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := s.database.Exec(`
|
||||
INSERT INTO provider_configs (id, display_name, enabled, base_url, api_key, credit_balance, low_credit_threshold, billing_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
base_url = COALESCE(excluded.base_url, provider_configs.base_url),
|
||||
api_key = COALESCE(excluded.api_key, provider_configs.api_key),
|
||||
credit_balance = COALESCE(excluded.credit_balance, provider_configs.credit_balance),
|
||||
low_credit_threshold = COALESCE(excluded.low_credit_threshold, provider_configs.low_credit_threshold),
|
||||
billing_mode = COALESCE(excluded.billing_mode, provider_configs.billing_mode),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, name, strings.ToUpper(name), req.Enabled, req.BaseURL, req.APIKey, req.CreditBalance, req.LowCreditThreshold, req.BillingMode)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Provider updated"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetModels(c *gin.Context) {
|
||||
var models []db.ModelConfig
|
||||
err := s.database.Select(&models, "SELECT * FROM model_configs")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(models))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetUsers(c *gin.Context) {
|
||||
var users []db.User
|
||||
err := s.database.Select(&users, "SELECT id, username, display_name, role, must_change_password, created_at FROM users")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, SuccessResponse(users))
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(c *gin.Context) {
|
||||
var req CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse("Failed to hash password"))
|
||||
return
|
||||
}
|
||||
|
||||
role := "viewer"
|
||||
if req.Role != nil {
|
||||
role = *req.Role
|
||||
}
|
||||
|
||||
_, err = s.database.Exec("INSERT INTO users (username, password_hash, display_name, role, must_change_password) VALUES (?, ?, ?, ?, 1)",
|
||||
req.Username, string(hash), req.DisplayName, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User created"}))
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role *string `json:"role"`
|
||||
Password *string `json:"password"`
|
||||
MustChangePassword *bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.DisplayName != nil {
|
||||
s.database.Exec("UPDATE users SET display_name = ? WHERE id = ?", req.DisplayName, id)
|
||||
}
|
||||
if req.Role != nil {
|
||||
s.database.Exec("UPDATE users SET role = ? WHERE id = ?", req.Role, id)
|
||||
}
|
||||
if req.MustChangePassword != nil {
|
||||
s.database.Exec("UPDATE users SET must_change_password = ? WHERE id = ?", req.MustChangePassword, id)
|
||||
}
|
||||
if req.Password != nil {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), 12)
|
||||
s.database.Exec("UPDATE users SET password_hash = ? WHERE id = ?", string(hash), id)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User updated"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
session, _ := c.Get("session")
|
||||
if sess, ok := session.(*Session); ok {
|
||||
var username string
|
||||
s.database.Get(&username, "SELECT username FROM users WHERE id = ?", id)
|
||||
if username == sess.Username {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete your own account"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.database.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "User deleted"}))
|
||||
}
|
||||
|
||||
func (s *Server) handleSystemHealth(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, SuccessResponse(gin.H{
|
||||
"status": "ok",
|
||||
"db": "connected",
|
||||
}))
|
||||
}
|
||||
113
internal/server/logging.go
Normal file
113
internal/server/logging.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/db"
|
||||
)
|
||||
|
||||
type RequestLog struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ClientID string `json:"client_id"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
PromptTokens uint32 `json:"prompt_tokens"`
|
||||
CompletionTokens uint32 `json:"completion_tokens"`
|
||||
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
||||
TotalTokens uint32 `json:"total_tokens"`
|
||||
CacheReadTokens uint32 `json:"cache_read_tokens"`
|
||||
CacheWriteTokens uint32 `json:"cache_write_tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
HasImages bool `json:"has_images"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type RequestLogger struct {
|
||||
database *db.DB
|
||||
hub *Hub
|
||||
logChan chan RequestLog
|
||||
}
|
||||
|
||||
func NewRequestLogger(database *db.DB, hub *Hub) *RequestLogger {
|
||||
return &RequestLogger{
|
||||
database: database,
|
||||
hub: hub,
|
||||
logChan: make(chan RequestLog, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RequestLogger) Start() {
|
||||
go func() {
|
||||
for entry := range l.logChan {
|
||||
l.processLog(entry)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (l *RequestLogger) LogRequest(entry RequestLog) {
|
||||
select {
|
||||
case l.logChan <- entry:
|
||||
default:
|
||||
log.Println("Request log channel full, dropping log entry")
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RequestLogger) processLog(entry RequestLog) {
|
||||
// Broadcast to dashboard
|
||||
l.hub.broadcast <- map[string]interface{}{
|
||||
"type": "request",
|
||||
"channel": "requests",
|
||||
"payload": entry,
|
||||
}
|
||||
|
||||
// Insert into DB
|
||||
tx, err := l.database.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction for logging: %v", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Ensure client exists
|
||||
_, _ = tx.Exec("INSERT OR IGNORE INTO clients (client_id, name, description) VALUES (?, ?, 'Auto-created from request')",
|
||||
entry.ClientID, entry.ClientID)
|
||||
|
||||
// Insert log
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO llm_requests
|
||||
(timestamp, client_id, provider, model, prompt_tokens, completion_tokens, reasoning_tokens, total_tokens, cache_read_tokens, cache_write_tokens, cost, has_images, status, error_message, duration_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, entry.Timestamp, entry.ClientID, entry.Provider, entry.Model,
|
||||
entry.PromptTokens, entry.CompletionTokens, entry.ReasoningTokens, entry.TotalTokens,
|
||||
entry.CacheReadTokens, entry.CacheWriteTokens, entry.Cost, entry.HasImages,
|
||||
entry.Status, entry.ErrorMessage, entry.DurationMS)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert request log: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update client stats
|
||||
_, _ = tx.Exec(`
|
||||
UPDATE clients SET
|
||||
total_requests = total_requests + 1,
|
||||
total_tokens = total_tokens + ?,
|
||||
total_cost = total_cost + ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE client_id = ?
|
||||
`, entry.TotalTokens, entry.Cost, entry.ClientID)
|
||||
|
||||
// Update provider balance
|
||||
if entry.Cost > 0 {
|
||||
_, _ = tx.Exec("UPDATE provider_configs SET credit_balance = credit_balance - ? WHERE id = ? AND (billing_mode IS NULL OR billing_mode != 'postpaid')",
|
||||
entry.Cost, entry.Provider)
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
log.Printf("Failed to commit logging transaction: %v", err)
|
||||
}
|
||||
}
|
||||
326
internal/server/server.go
Normal file
326
internal/server/server.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"llm-proxy/internal/config"
|
||||
"llm-proxy/internal/db"
|
||||
"llm-proxy/internal/middleware"
|
||||
"llm-proxy/internal/models"
|
||||
"llm-proxy/internal/providers"
|
||||
"llm-proxy/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
cfg *config.Config
|
||||
database *db.DB
|
||||
providers map[string]providers.Provider
|
||||
sessions *SessionManager
|
||||
hub *Hub
|
||||
logger *RequestLogger
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, database *db.DB) *Server {
|
||||
router := gin.Default()
|
||||
hub := NewHub()
|
||||
|
||||
s := &Server{
|
||||
router: router,
|
||||
cfg: cfg,
|
||||
database: database,
|
||||
providers: make(map[string]providers.Provider),
|
||||
sessions: NewSessionManager(cfg.KeyBytes, 24*time.Hour),
|
||||
hub: hub,
|
||||
logger: NewRequestLogger(database, hub),
|
||||
}
|
||||
|
||||
// Initialize providers
|
||||
if cfg.Providers.OpenAI.Enabled {
|
||||
apiKey, _ := cfg.GetAPIKey("openai")
|
||||
s.providers["openai"] = providers.NewOpenAIProvider(cfg.Providers.OpenAI, apiKey)
|
||||
}
|
||||
if cfg.Providers.Gemini.Enabled {
|
||||
apiKey, _ := cfg.GetAPIKey("gemini")
|
||||
s.providers["gemini"] = providers.NewGeminiProvider(cfg.Providers.Gemini, apiKey)
|
||||
}
|
||||
if cfg.Providers.DeepSeek.Enabled {
|
||||
apiKey, _ := cfg.GetAPIKey("deepseek")
|
||||
s.providers["deepseek"] = providers.NewDeepSeekProvider(cfg.Providers.DeepSeek, apiKey)
|
||||
}
|
||||
if cfg.Providers.Grok.Enabled {
|
||||
apiKey, _ := cfg.GetAPIKey("grok")
|
||||
s.providers["grok"] = providers.NewGrokProvider(cfg.Providers.Grok, apiKey)
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router.Use(middleware.AuthMiddleware(s.database))
|
||||
|
||||
// Static files
|
||||
s.router.Static("/static", "./static")
|
||||
s.router.StaticFile("/", "./static/index.html")
|
||||
s.router.StaticFile("/favicon.ico", "./static/favicon.ico")
|
||||
|
||||
// WebSocket
|
||||
s.router.GET("/ws", s.handleWebSocket)
|
||||
|
||||
v1 := s.router.Group("/v1")
|
||||
{
|
||||
v1.POST("/chat/completions", s.handleChatCompletions)
|
||||
}
|
||||
|
||||
// Dashboard API Group
|
||||
api := s.router.Group("/api")
|
||||
{
|
||||
api.POST("/auth/login", s.handleLogin)
|
||||
api.GET("/auth/status", s.handleAuthStatus)
|
||||
api.POST("/auth/logout", s.handleLogout)
|
||||
|
||||
// Protected dashboard routes (need admin session)
|
||||
admin := api.Group("/")
|
||||
admin.Use(s.adminAuthMiddleware())
|
||||
{
|
||||
admin.GET("/usage/summary", s.handleUsageSummary)
|
||||
admin.GET("/usage/time-series", s.handleTimeSeries)
|
||||
admin.GET("/analytics/breakdown", s.handleAnalyticsBreakdown)
|
||||
|
||||
admin.GET("/clients", s.handleGetClients)
|
||||
admin.POST("/clients", s.handleCreateClient)
|
||||
admin.DELETE("/clients/:id", s.handleDeleteClient)
|
||||
|
||||
admin.GET("/clients/:id/tokens", s.handleGetClientTokens)
|
||||
admin.POST("/clients/:id/tokens", s.handleCreateClientToken)
|
||||
admin.DELETE("/clients/:id/tokens/:token_id", s.handleDeleteClientToken)
|
||||
|
||||
admin.GET("/providers", s.handleGetProviders)
|
||||
admin.PUT("/providers/:name", s.handleUpdateProvider)
|
||||
admin.GET("/models", s.handleGetModels)
|
||||
|
||||
admin.GET("/users", s.handleGetUsers)
|
||||
admin.POST("/users", s.handleCreateUser)
|
||||
admin.PUT("/users/:id", s.handleUpdateUser)
|
||||
admin.DELETE("/users/:id", s.handleDeleteUser)
|
||||
|
||||
admin.GET("/system/health", s.handleSystemHealth)
|
||||
}
|
||||
}
|
||||
|
||||
s.router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleChatCompletions(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
var req models.ChatCompletionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Select provider based on model name
|
||||
providerName := "openai" // default
|
||||
if strings.Contains(req.Model, "gemini") {
|
||||
providerName = "gemini"
|
||||
} else if strings.Contains(req.Model, "deepseek") {
|
||||
providerName = "deepseek"
|
||||
} else if strings.Contains(req.Model, "grok") {
|
||||
providerName = "grok"
|
||||
}
|
||||
|
||||
provider, ok := s.providers[providerName]
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Provider %s not enabled or supported", providerName)})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert ChatCompletionRequest to UnifiedRequest
|
||||
unifiedReq := &models.UnifiedRequest{
|
||||
Model: req.Model,
|
||||
Messages: []models.UnifiedMessage{},
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
TopK: req.TopK,
|
||||
N: req.N,
|
||||
MaxTokens: req.MaxTokens,
|
||||
PresencePenalty: req.PresencePenalty,
|
||||
FrequencyPenalty: req.FrequencyPenalty,
|
||||
Stream: req.Stream != nil && *req.Stream,
|
||||
Tools: req.Tools,
|
||||
ToolChoice: req.ToolChoice,
|
||||
}
|
||||
|
||||
// Handle Stop sequences
|
||||
if req.Stop != nil {
|
||||
var stop []string
|
||||
if err := json.Unmarshal(req.Stop, &stop); err == nil {
|
||||
unifiedReq.Stop = stop
|
||||
} else {
|
||||
var singleStop string
|
||||
if err := json.Unmarshal(req.Stop, &singleStop); err == nil {
|
||||
unifiedReq.Stop = []string{singleStop}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
for _, msg := range req.Messages {
|
||||
unifiedMsg := models.UnifiedMessage{
|
||||
Role: msg.Role,
|
||||
Content: []models.UnifiedContentPart{},
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
Name: msg.Name,
|
||||
ToolCallID: msg.ToolCallID,
|
||||
}
|
||||
|
||||
// Handle multimodal content
|
||||
if strContent, ok := msg.Content.(string); ok {
|
||||
unifiedMsg.Content = append(unifiedMsg.Content, models.UnifiedContentPart{
|
||||
Type: "text",
|
||||
Text: strContent,
|
||||
})
|
||||
} else if parts, ok := msg.Content.([]interface{}); ok {
|
||||
for _, part := range parts {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
partType, _ := partMap["type"].(string)
|
||||
if partType == "text" {
|
||||
text, _ := partMap["text"].(string)
|
||||
unifiedMsg.Content = append(unifiedMsg.Content, models.UnifiedContentPart{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
})
|
||||
} else if partType == "image_url" {
|
||||
if imgURLMap, ok := partMap["image_url"].(map[string]interface{}); ok {
|
||||
url, _ := imgURLMap["url"].(string)
|
||||
imageInput := &models.ImageInput{}
|
||||
if strings.HasPrefix(url, "data:") {
|
||||
mime, data, err := utils.ParseDataURL(url)
|
||||
if err == nil {
|
||||
imageInput.Base64 = data
|
||||
imageInput.MimeType = mime
|
||||
}
|
||||
} else {
|
||||
imageInput.URL = url
|
||||
}
|
||||
unifiedMsg.Content = append(unifiedMsg.Content, models.UnifiedContentPart{
|
||||
Type: "image",
|
||||
Image: imageInput,
|
||||
})
|
||||
unifiedReq.HasImages = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unifiedReq.Messages = append(unifiedReq.Messages, unifiedMsg)
|
||||
}
|
||||
|
||||
clientID := "default"
|
||||
if auth, ok := c.Get("auth"); ok {
|
||||
if authInfo, ok := auth.(models.AuthInfo); ok {
|
||||
unifiedReq.ClientID = authInfo.ClientID
|
||||
clientID = authInfo.ClientID
|
||||
}
|
||||
} else {
|
||||
unifiedReq.ClientID = clientID
|
||||
}
|
||||
|
||||
if unifiedReq.Stream {
|
||||
ch, err := provider.ChatCompletionStream(c.Request.Context(), unifiedReq)
|
||||
if err != nil {
|
||||
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, unifiedReq.HasImages)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
var lastUsage *models.Usage
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
chunk, ok := <-ch
|
||||
if !ok {
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
s.logRequest(startTime, clientID, providerName, req.Model, lastUsage, nil, unifiedReq.HasImages)
|
||||
return false
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
lastUsage = chunk.Usage
|
||||
}
|
||||
data, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), unifiedReq)
|
||||
if err != nil {
|
||||
s.logRequest(startTime, clientID, providerName, req.Model, nil, err, unifiedReq.HasImages)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
s.logRequest(startTime, clientID, providerName, req.Model, resp.Usage, nil, unifiedReq.HasImages)
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) logRequest(start time.Time, clientID, provider, model string, usage *models.Usage, err error, hasImages bool) {
|
||||
entry := RequestLog{
|
||||
Timestamp: start,
|
||||
ClientID: clientID,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Status: "success",
|
||||
DurationMS: time.Since(start).Milliseconds(),
|
||||
HasImages: hasImages,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
entry.Status = "error"
|
||||
entry.ErrorMessage = err.Error()
|
||||
}
|
||||
|
||||
if usage != nil {
|
||||
entry.PromptTokens = usage.PromptTokens
|
||||
entry.CompletionTokens = usage.CompletionTokens
|
||||
entry.TotalTokens = usage.TotalTokens
|
||||
if usage.ReasoningTokens != nil {
|
||||
entry.ReasoningTokens = *usage.ReasoningTokens
|
||||
}
|
||||
if usage.CacheReadTokens != nil {
|
||||
entry.CacheReadTokens = *usage.CacheReadTokens
|
||||
}
|
||||
if usage.CacheWriteTokens != nil {
|
||||
entry.CacheWriteTokens = *usage.CacheWriteTokens
|
||||
}
|
||||
// TODO: Calculate cost properly based on pricing
|
||||
entry.Cost = 0.0
|
||||
}
|
||||
|
||||
s.logger.LogRequest(entry)
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
go s.hub.Run()
|
||||
s.logger.Start()
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||
return s.router.Run(addr)
|
||||
}
|
||||
151
internal/server/sessions.go
Normal file
151
internal/server/sessions.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
sessions map[string]Session
|
||||
mu sync.RWMutex
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type sessionPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func NewSessionManager(secret []byte, ttl time.Duration) *SessionManager {
|
||||
return &SessionManager{
|
||||
sessions: make(map[string]Session),
|
||||
secret: secret,
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) CreateSession(username, role string) (string, error) {
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(m.ttl)
|
||||
|
||||
m.mu.Lock()
|
||||
m.sessions[sessionID] = Session{
|
||||
Username: username,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
SessionID: sessionID,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.createSignedToken(sessionID, username, role, expiresAt.Unix())
|
||||
}
|
||||
|
||||
func (m *SessionManager) createSignedToken(sessionID, username, role string, exp int64) (string, error) {
|
||||
payload := sessionPayload{
|
||||
SessionID: sessionID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
Exp: exp,
|
||||
}
|
||||
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
h := hmac.New(sha256.New, m.secret)
|
||||
h.Write(payloadJSON)
|
||||
signature := h.Sum(nil)
|
||||
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
|
||||
|
||||
return fmt.Sprintf("%s.%s", payloadB64, signatureB64), nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) ValidateSession(token string) (*Session, string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, "", fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
payloadB64 := parts[0]
|
||||
signatureB64 := parts[1]
|
||||
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadB64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
signature, err := base64.RawURLEncoding.DecodeString(signatureB64)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, m.secret)
|
||||
h.Write(payloadJSON)
|
||||
if !hmac.Equal(signature, h.Sum(nil)) {
|
||||
return nil, "", fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
var payload sessionPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if time.Now().Unix() > payload.Exp {
|
||||
return nil, "", fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
session, ok := m.sessions[payload.SessionID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("session not found")
|
||||
}
|
||||
|
||||
return &session, "", nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) RevokeSession(token string) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var payload sessionPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, payload.SessionID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
98
internal/server/websocket.go
Normal file
98
internal/server/websocket.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // In production, refine this
|
||||
},
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
clients map[*websocket.Conn]bool
|
||||
broadcast chan interface{}
|
||||
register chan *websocket.Conn
|
||||
unregister chan *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
broadcast: make(chan interface{}),
|
||||
register: make(chan *websocket.Conn),
|
||||
unregister: make(chan *websocket.Conn),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
h.mu.Unlock()
|
||||
log.Println("WebSocket client registered")
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
client.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Println("WebSocket client unregistered")
|
||||
case message := <-h.broadcast:
|
||||
h.mu.Lock()
|
||||
for client := range h.clients {
|
||||
err := client.WriteJSON(message)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket error: %v", err)
|
||||
client.Close()
|
||||
delete(h.clients, client)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set websocket upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.hub.register <- conn
|
||||
|
||||
defer func() {
|
||||
s.hub.unregister <- conn
|
||||
}()
|
||||
|
||||
// Initial message
|
||||
conn.WriteJSON(gin.H{
|
||||
"type": "connected",
|
||||
"message": "Connected to LLM Proxy Dashboard",
|
||||
})
|
||||
|
||||
for {
|
||||
var msg map[string]interface{}
|
||||
err := conn.ReadJSON(&msg)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if msg["type"] == "ping" {
|
||||
conn.WriteJSON(gin.H{"type": "pong", "payload": gin.H{}})
|
||||
}
|
||||
}
|
||||
}
|
||||
19
internal/utils/utils.go
Normal file
19
internal/utils/utils.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseDataURL(dataURL string) (string, string, error) {
|
||||
if !strings.HasPrefix(dataURL, "data:") {
|
||||
return "", "", fmt.Errorf("not a data URL")
|
||||
}
|
||||
|
||||
parts := strings.Split(dataURL[5:], ";base64,")
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("invalid data URL format")
|
||||
}
|
||||
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
Reference in New Issue
Block a user