Changeset 0.10.0 (#56)
This commit is contained in:
@@ -20,12 +20,13 @@ import (
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
uekCache *crypto.UEKCache
|
||||
}
|
||||
|
||||
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver) *AdminHandler {
|
||||
return &AdminHandler{stores: s, vault: vault}
|
||||
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler {
|
||||
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
|
||||
}
|
||||
|
||||
// ── User Management ─────────────────────────
|
||||
@@ -145,10 +146,50 @@ func (h *AdminHandler) ResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy vault — old UEK is unrecoverable with new password.
|
||||
// A fresh vault will be initialized on the user's next login.
|
||||
h.destroyVault(c, id)
|
||||
|
||||
h.auditLog(c, "user.password_reset", "user", id, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||
}
|
||||
|
||||
// destroyVault nullifies a user's encrypted UEK and deletes their personal
|
||||
// provider keys. Called when an admin resets a user's password — the old
|
||||
// UEK cannot be recovered without the old password.
|
||||
func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
|
||||
if h.uekCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Null out vault columns — user gets a fresh vault on next login
|
||||
_, err := database.DB.Exec(`
|
||||
UPDATE users
|
||||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
// Evict from session cache (if user is currently logged in)
|
||||
h.uekCache.Evict(userID)
|
||||
|
||||
// Delete personal provider configs — their keys are undecryptable now
|
||||
result, err := database.DB.Exec(`
|
||||
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
|
||||
} else if rows, _ := result.RowsAffected(); rows > 0 {
|
||||
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
|
||||
}
|
||||
|
||||
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
|
||||
"reason": "admin_password_reset",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
@@ -34,13 +35,14 @@ type completionRequest struct {
|
||||
}
|
||||
|
||||
// CompletionHandler proxies LLM requests through the backend.
|
||||
type CompletionHandler struct{
|
||||
vault *crypto.KeyResolver
|
||||
type CompletionHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewCompletionHandler creates a new handler.
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault}
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores}
|
||||
}
|
||||
|
||||
// ── Chat Completion ─────────────────────────
|
||||
@@ -108,7 +110,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Resolve provider config
|
||||
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
|
||||
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -179,9 +181,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
if stream {
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
|
||||
} else {
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,16 +213,21 @@ func (h *CompletionHandler) streamCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
channelID, userID, model, configID, providerScope string,
|
||||
) {
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
|
||||
|
||||
// Persist assistant response
|
||||
if result.Content != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, 0, 0, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
result.InputTokens, result.OutputTokens,
|
||||
result.CacheCreationTokens, result.CacheReadTokens)
|
||||
}
|
||||
|
||||
// ── Non-Streaming Completion with Tool Loop ──
|
||||
@@ -230,9 +237,10 @@ func (h *CompletionHandler) syncCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model string,
|
||||
channelID, userID, model, configID, providerScope string,
|
||||
) {
|
||||
var totalInput, totalOutput int
|
||||
var totalCacheCreation, totalCacheRead int
|
||||
var finalContent string
|
||||
var allToolActivity []map[string]interface{}
|
||||
|
||||
@@ -245,6 +253,8 @@ func (h *CompletionHandler) syncCompletion(
|
||||
|
||||
totalInput += resp.InputTokens
|
||||
totalOutput += resp.OutputTokens
|
||||
totalCacheCreation += resp.CacheCreationTokens
|
||||
totalCacheRead += resp.CacheReadTokens
|
||||
|
||||
// No tool calls — normal response
|
||||
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
|
||||
@@ -255,6 +265,10 @@ func (h *CompletionHandler) syncCompletion(
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
@@ -353,7 +367,7 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
|
||||
// ── Config Resolution ───────────────────────
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
|
||||
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) {
|
||||
var configID string
|
||||
|
||||
// 1. Explicit config from request
|
||||
@@ -384,32 +398,33 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
LIMIT 1
|
||||
`, userID).Scan(&configID)
|
||||
if err != nil {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
|
||||
}
|
||||
}
|
||||
|
||||
// Load the config — allow personal, global, OR team configs the user belongs to
|
||||
var providerID, endpoint string
|
||||
var providerScope string
|
||||
var modelDefault *string
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var customHeadersJSON, providerSettingsJSON []byte
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT provider, endpoint, api_key_enc, key_nonce, key_scope,
|
||||
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
|
||||
model_default, headers, settings
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
|
||||
`, configID, userID).Scan(&providerID, &endpoint, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
`, configID, userID).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
|
||||
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
}
|
||||
if err != nil {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to load API config: %w", err)
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to load API config: %w", err)
|
||||
}
|
||||
|
||||
// Resolve model: request > config default
|
||||
@@ -418,7 +433,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
model = *modelDefault
|
||||
}
|
||||
if model == "" {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config")
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no model specified and no default model in config")
|
||||
}
|
||||
|
||||
// Decrypt API key using the appropriate tier
|
||||
@@ -429,9 +444,9 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("personal vault is locked — please log in again")
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("personal vault is locked — please log in again")
|
||||
}
|
||||
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||
@@ -456,7 +471,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
}, providerID, model, configID, nil
|
||||
}, providerID, model, configID, providerScope, nil
|
||||
}
|
||||
|
||||
// ── Conversation Loader ─────────────────────
|
||||
@@ -578,3 +593,51 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
// ── Usage Logging ─────────────────────────
|
||||
|
||||
// logUsage records token usage and cost in the usage_log table.
|
||||
// Always logs even if tokens are zero — the request happened.
|
||||
func (h *CompletionHandler) logUsage(
|
||||
c *gin.Context,
|
||||
channelID, userID, configID, providerScope, modelID string,
|
||||
inputTokens, outputTokens, cacheCreation, cacheRead int,
|
||||
) {
|
||||
if h.stores.Usage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: &channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: &configID,
|
||||
ProviderScope: providerScope,
|
||||
ModelID: modelID,
|
||||
PromptTokens: inputTokens,
|
||||
CompletionTokens: outputTokens,
|
||||
CacheCreationTokens: cacheCreation,
|
||||
CacheReadTokens: cacheRead,
|
||||
}
|
||||
|
||||
// Look up pricing for cost calculation
|
||||
if h.stores.Pricing != nil {
|
||||
pricing, err := h.stores.Pricing.GetForModel(c.Request.Context(), configID, modelID)
|
||||
if err == nil && pricing != nil {
|
||||
entry.CostInput = calcCost(inputTokens, pricing.InputPerM)
|
||||
entry.CostOutput = calcCost(outputTokens, pricing.OutputPerM)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Usage.Log(c.Request.Context(), entry); err != nil {
|
||||
log.Printf("⚠ Failed to log usage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// calcCost computes the cost for a given token count and price-per-million.
|
||||
func calcCost(tokens int, pricePerM *float64) *float64 {
|
||||
if pricePerM == nil || tokens == 0 {
|
||||
return nil
|
||||
}
|
||||
cost := float64(tokens) * *pricePerM / 1_000_000.0
|
||||
return &cost
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,9 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
|
||||
// Roles resolver (nil vault — test-fire won't work, but CRUD will)
|
||||
roleResolver := roles.NewResolver(stores, nil)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
@@ -52,7 +56,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
authGroup.POST("/login", auth.Login)
|
||||
|
||||
// Public settings
|
||||
adm := NewAdminHandler(stores, nil)
|
||||
adm := NewAdminHandler(stores, nil, nil)
|
||||
api.GET("/settings/public", adm.PublicSettings)
|
||||
|
||||
// Protected routes
|
||||
@@ -91,10 +95,24 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||
|
||||
// Team usage
|
||||
teamUsage := NewUsageHandler(stores)
|
||||
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
||||
|
||||
// Team roles
|
||||
teamRoles := NewRolesHandler(stores, roleResolver)
|
||||
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||
}
|
||||
|
||||
// User usage
|
||||
usage := NewUsageHandler(stores)
|
||||
protected.GET("/usage", usage.PersonalUsage)
|
||||
|
||||
// Profile / Settings
|
||||
settings := NewSettingsHandler()
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
|
||||
// Presets
|
||||
@@ -116,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler(nil)
|
||||
completions := NewCompletionHandler(nil, stores)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Admin routes
|
||||
@@ -143,6 +161,21 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/presets", presets.ListAdminPersonas)
|
||||
admin.POST("/presets", presets.CreateAdminPersona)
|
||||
|
||||
// Admin roles
|
||||
rolesH := NewRolesHandler(stores, roleResolver)
|
||||
admin.GET("/roles", rolesH.ListRoles)
|
||||
admin.GET("/roles/:role", rolesH.GetRole)
|
||||
admin.PUT("/roles/:role", rolesH.UpdateRole)
|
||||
|
||||
// Admin usage + pricing
|
||||
usageH := NewUsageHandler(stores)
|
||||
admin.GET("/usage", usageH.AdminUsage)
|
||||
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
|
||||
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
|
||||
admin.GET("/pricing", usageH.ListPricing)
|
||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
||||
|
||||
return &testHarness{router: r, t: t}
|
||||
}
|
||||
|
||||
@@ -1561,3 +1594,551 @@ func TestUserJourney_FullMatrix(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ROLES TESTS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/roles", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list roles: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
// Migration 004 seeds utility, embedding, generation
|
||||
for _, role := range []string{"utility", "embedding", "generation"} {
|
||||
if _, ok := resp[role]; !ok {
|
||||
t.Errorf("expected role %q in response, got: %v", role, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Roles_UpdateAndGet(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create a global provider to reference
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "TestProvider", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
|
||||
// Update utility role
|
||||
w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
|
||||
"primary": map[string]string{
|
||||
"provider_config_id": cfgID,
|
||||
"model_id": "gpt-4o-mini",
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("update role: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify via list
|
||||
w = h.request("GET", "/api/v1/admin/roles", adminToken, nil)
|
||||
var allRoles map[string]interface{}
|
||||
decode(w, &allRoles)
|
||||
utilRaw, ok := allRoles["utility"]
|
||||
if !ok {
|
||||
t.Fatal("utility role missing after update")
|
||||
}
|
||||
utilMap, ok := utilRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("utility role unexpected type: %T", utilRaw)
|
||||
}
|
||||
primary, _ := utilMap["primary"].(map[string]interface{})
|
||||
if primary == nil {
|
||||
t.Fatal("utility primary should be set")
|
||||
}
|
||||
if primary["model_id"] != "gpt-4o-mini" {
|
||||
t.Errorf("utility primary model: want gpt-4o-mini, got %v", primary["model_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Roles_InvalidRole400(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/roles/nonexistent", adminToken, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid role: want 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Roles_NonAdmin403(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("user1", "user1@test.com", "password123")
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/roles", userToken, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin roles: want 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team Roles ────────────────────────────
|
||||
|
||||
func TestIntegration_TeamRoles_CRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create team admin user
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "Eng", "description": "Engineering",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
// Add team admin
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
|
||||
// Create a provider to reference
|
||||
w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Provider1", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
|
||||
})
|
||||
var pcfg map[string]interface{}
|
||||
decode(w, &pcfg)
|
||||
cfgID := pcfg["id"].(string)
|
||||
|
||||
// List team roles — initially empty
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var roleList map[string]interface{}
|
||||
decode(w, &roleList)
|
||||
if len(roleList) != 0 {
|
||||
t.Fatalf("team roles should be empty initially, got %d", len(roleList))
|
||||
}
|
||||
|
||||
// Set team role override
|
||||
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken,
|
||||
map[string]interface{}{
|
||||
"primary": map[string]string{
|
||||
"provider_config_id": cfgID,
|
||||
"model_id": "gpt-4o",
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("set team role: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List should now have override
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
decode(w, &roleList)
|
||||
if _, ok := roleList["utility"]; !ok {
|
||||
t.Fatal("team roles should have utility after update")
|
||||
}
|
||||
|
||||
// Delete override
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete team role: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List should be empty again
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
|
||||
roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps
|
||||
decode(w, &roleList)
|
||||
if _, ok := roleList["utility"]; ok {
|
||||
t.Fatal("team roles should not have utility after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// USAGE TESTS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// seedUsage inserts a usage_log row directly for testing.
|
||||
func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) {
|
||||
t.Helper()
|
||||
_, err := database.TestDB.Exec(`
|
||||
INSERT INTO usage_log (user_id, provider_config_id, provider_scope,
|
||||
model_id, prompt_tokens, completion_tokens,
|
||||
cost_input, cost_output)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, userID, provCfgID, scope, model, prompt, completion, costIn, costOut)
|
||||
if err != nil {
|
||||
t.Fatalf("seedUsage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_AdminView(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
userID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "TestOAI", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
|
||||
// Seed usage data
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.005, 0.006)
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "global", 500, 100, 0.0025, 0.003)
|
||||
seedUsage(t, userID, cfgID, "gpt-4o-mini", "global", 2000, 400, 0.001, 0.002)
|
||||
|
||||
// Admin usage — grouped by model (default)
|
||||
w = h.request("GET", "/api/v1/admin/usage?group_by=model", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
totals := resp["totals"].(map[string]interface{})
|
||||
if int(totals["requests"].(float64)) != 3 {
|
||||
t.Errorf("totals.requests: want 3, got %v", totals["requests"])
|
||||
}
|
||||
if int(totals["input_tokens"].(float64)) != 3500 {
|
||||
t.Errorf("totals.input_tokens: want 3500, got %v", totals["input_tokens"])
|
||||
}
|
||||
|
||||
results := resp["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("want 2 model groups, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_AdminExcludesBYOK(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
userID := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Global1", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g",
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
globalCfgID := cfg["id"].(string)
|
||||
|
||||
// Seed: 1 global usage + 1 personal BYOK usage
|
||||
seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
||||
seedUsage(t, userID, globalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
||||
|
||||
// Admin view should exclude personal
|
||||
w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
totals := resp["totals"].(map[string]interface{})
|
||||
|
||||
if int(totals["requests"].(float64)) != 1 {
|
||||
t.Errorf("admin should see 1 request (excludes BYOK), got %v", totals["requests"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
|
||||
|
||||
// Create global provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Global2", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
|
||||
seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
|
||||
|
||||
// Personal view should include all scopes
|
||||
w = h.request("GET", "/api/v1/usage", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
totals := resp["totals"].(map[string]interface{})
|
||||
|
||||
if int(totals["requests"].(float64)) != 2 {
|
||||
t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_NonAdmin403(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("dave", "dave@test.com", "password123")
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/usage", userToken, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin usage: want 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team Usage ────────────────────────────
|
||||
|
||||
func TestIntegration_Usage_TeamAdmin(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create team admin + member
|
||||
teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
|
||||
teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user")
|
||||
|
||||
memberID := database.SeedTestUser(t, "member2", "member2@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
|
||||
|
||||
// Create team
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "EngTeam", "description": "Test",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": teamAdminID, "role": "admin"})
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": memberID, "role": "member"})
|
||||
|
||||
// Create team provider via team admin self-service
|
||||
w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken,
|
||||
map[string]interface{}{
|
||||
"name": "TeamOpenAI", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-team",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var tprov map[string]interface{}
|
||||
decode(w, &tprov)
|
||||
teamProvID := tprov["id"].(string)
|
||||
|
||||
// Also create a global provider (usage against it should NOT appear in team view)
|
||||
w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "GlobalProv", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
|
||||
})
|
||||
var gcfg map[string]interface{}
|
||||
decode(w, &gcfg)
|
||||
globalProvID := gcfg["id"].(string)
|
||||
|
||||
// Seed: 2 rows against team provider, 1 against global
|
||||
seedUsage(t, memberID, teamProvID, "gpt-4o", "team", 1000, 200, 0.01, 0.01)
|
||||
seedUsage(t, teamAdminID, teamProvID, "gpt-4o", "team", 500, 100, 0.005, 0.005)
|
||||
seedUsage(t, memberID, globalProvID, "gpt-4o", "global", 2000, 400, 0.02, 0.02)
|
||||
|
||||
// Team usage should show only team provider usage (2 rows)
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), teamAdminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("team usage: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
totals := resp["totals"].(map[string]interface{})
|
||||
|
||||
if int(totals["requests"].(float64)) != 2 {
|
||||
t.Errorf("team usage should show 2 requests (team provider only), got %v", totals["requests"])
|
||||
}
|
||||
if int(totals["input_tokens"].(float64)) != 1500 {
|
||||
t.Errorf("team usage input_tokens: want 1500, got %v", totals["input_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
memberID := database.SeedTestUser(t, "member3", "member3@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
|
||||
memberToken := makeToken(memberID, "member3@test.com", "user")
|
||||
|
||||
// Create team, add member (NOT admin)
|
||||
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
|
||||
"name": "EngTeam2", "description": "Test2",
|
||||
})
|
||||
var team map[string]interface{}
|
||||
decode(w, &team)
|
||||
teamID := team["id"].(string)
|
||||
|
||||
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
|
||||
map[string]string{"user_id": memberID, "role": "member"})
|
||||
|
||||
// Non-admin member should be forbidden
|
||||
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), memberToken, nil)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("team usage non-admin: want 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// PRICING TESTS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestIntegration_Pricing_CRUD(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create provider to reference
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "PricingProv", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-p",
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
cfgID := cfg["id"].(string)
|
||||
|
||||
// Upsert pricing
|
||||
w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
|
||||
"provider_config_id": cfgID,
|
||||
"model_id": "gpt-4o",
|
||||
"input_per_m": 2.5,
|
||||
"output_per_m": 10.0,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("upsert pricing: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List pricing
|
||||
w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list pricing: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var entries []interface{}
|
||||
decode(w, &entries)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("want 1 pricing entry, got %d", len(entries))
|
||||
}
|
||||
entry := entries[0].(map[string]interface{})
|
||||
if entry["model_id"] != "gpt-4o" {
|
||||
t.Errorf("pricing model: want gpt-4o, got %v", entry["model_id"])
|
||||
}
|
||||
if entry["source"] != "manual" {
|
||||
t.Errorf("pricing source: want manual, got %v", entry["source"])
|
||||
}
|
||||
|
||||
// Delete pricing
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/pricing/%s/gpt-4o", cfgID), adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete pricing: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List should be empty
|
||||
w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
|
||||
decode(w, &entries)
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("want 0 pricing entries after delete, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("byokuser", "byokuser@test.com", "password123")
|
||||
|
||||
// Global provider with pricing
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "GlobalP", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
|
||||
})
|
||||
var gcfg map[string]interface{}
|
||||
decode(w, &gcfg)
|
||||
globalID := gcfg["id"].(string)
|
||||
|
||||
// Set global pricing
|
||||
h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
|
||||
"provider_config_id": globalID,
|
||||
"model_id": "gpt-4o",
|
||||
"input_per_m": 2.5,
|
||||
"output_per_m": 10.0,
|
||||
})
|
||||
|
||||
// BYOK provider (personal scope)
|
||||
w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
|
||||
"name": "MyKey", "provider": "openai",
|
||||
"endpoint": "http://localhost:1/v1", "api_key": "sk-byok",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create BYOK: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var bcfg map[string]interface{}
|
||||
decode(w, &bcfg)
|
||||
byokID := bcfg["id"].(string)
|
||||
|
||||
// Simulate catalog pricing for BYOK provider (as model sync would)
|
||||
database.TestDB.Exec(`
|
||||
INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source)
|
||||
VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog')
|
||||
`, byokID)
|
||||
|
||||
// Admin list should only show the 1 global entry, not the BYOK one
|
||||
w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
|
||||
var entries []interface{}
|
||||
decode(w, &entries)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("admin pricing should show 1 (global only), got %d", len(entries))
|
||||
}
|
||||
entry := entries[0].(map[string]interface{})
|
||||
if entry["provider_config_id"] != globalID {
|
||||
t.Errorf("pricing entry should be global provider, got %v", entry["provider_config_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||
_, userToken := h.registerUser("byokuser2", "byokuser2@test.com", "password123")
|
||||
|
||||
// Create BYOK provider
|
||||
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
|
||||
"name": "MyKey2", "provider": "openai",
|
||||
"endpoint": "http://localhost:1/v1", "api_key": "sk-byok2",
|
||||
})
|
||||
var bcfg map[string]interface{}
|
||||
decode(w, &bcfg)
|
||||
byokID := bcfg["id"].(string)
|
||||
|
||||
// Admin tries to set pricing on personal provider — should be rejected
|
||||
w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
|
||||
"provider_config_id": byokID,
|
||||
"model_id": "gpt-4o",
|
||||
"input_per_m": 2.5,
|
||||
"output_per_m": 10.0,
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -18,8 +20,12 @@ import (
|
||||
//
|
||||
// They exercise the full flow: create provider →
|
||||
// fetch models → enable model → resolve → complete.
|
||||
//
|
||||
// Model: qwen3-4b (Venice Small) — cheapest at $0.05/$0.15 per 1M tokens
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const veniceTestModel = "qwen3-4b"
|
||||
|
||||
func requireVeniceKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := os.Getenv("VENICE_API_KEY")
|
||||
@@ -29,6 +35,57 @@ func requireVeniceKey(t *testing.T) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// setupVeniceWithModel creates a Venice provider, fetches models, and enables
|
||||
// the specified model. Returns (configID, catalogEntryID).
|
||||
func setupVeniceWithModel(t *testing.T, h *testHarness, adminToken, apiKey, modelID string) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Test", "provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1", "api_key": apiKey,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
configID := cfg["id"].(string)
|
||||
|
||||
// Fetch models
|
||||
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
|
||||
map[string]interface{}{"provider_config_id": configID})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fetch models: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Find and enable target model
|
||||
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
|
||||
var modelsResp map[string]interface{}
|
||||
decode(w, &modelsResp)
|
||||
|
||||
var catalogID string
|
||||
for _, raw := range modelsResp["models"].([]interface{}) {
|
||||
m := raw.(map[string]interface{})
|
||||
if m["model_id"].(string) == modelID {
|
||||
catalogID = m["id"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
if catalogID == "" {
|
||||
t.Fatalf("model %s not found in Venice catalog", modelID)
|
||||
}
|
||||
|
||||
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
|
||||
map[string]interface{}{"visibility": "enabled"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("enable model: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
t.Logf(" Venice provider %s ready, model %s enabled", configID, modelID)
|
||||
return configID, catalogID
|
||||
}
|
||||
|
||||
// TestLive_VeniceProviderFullFlow exercises the complete admin workflow:
|
||||
// create provider → fetch models → enable a model → user sees it → chat completion
|
||||
func TestLive_VeniceProviderFullFlow(t *testing.T) {
|
||||
@@ -231,92 +288,232 @@ func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLive_VeniceChatCompletion sends an actual chat completion.
|
||||
// TestLive_VeniceChatCompletion sends an actual non-streaming chat completion
|
||||
// using the cheapest model (qwen3-4b = $0.05/$0.15 per 1M tokens).
|
||||
func TestLive_VeniceChatCompletion(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
_ = adminID
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Chat Test", "provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
configID := cfg["id"].(string)
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
|
||||
// Fetch + enable a fast model
|
||||
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
|
||||
map[string]interface{}{"provider_config_id": configID})
|
||||
|
||||
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
|
||||
var modelsResp map[string]interface{}
|
||||
decode(w, &modelsResp)
|
||||
|
||||
// Find and enable a small model (prefer llama or qwen for speed)
|
||||
var targetModelID, targetCatalogID string
|
||||
for _, raw := range modelsResp["models"].([]interface{}) {
|
||||
m := raw.(map[string]interface{})
|
||||
mid := m["model_id"].(string)
|
||||
// Pick any available model - first disabled one
|
||||
if m["visibility"].(string) == "disabled" {
|
||||
targetModelID = mid
|
||||
targetCatalogID = m["id"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetModelID == "" {
|
||||
t.Skip("no model available to test chat completion")
|
||||
}
|
||||
|
||||
h.request("PUT", "/api/v1/admin/models/"+targetCatalogID, adminToken,
|
||||
map[string]interface{}{"visibility": "enabled"})
|
||||
|
||||
// Set as default model and send completion
|
||||
// First get enabled model's composite ID
|
||||
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
|
||||
var enabled map[string]interface{}
|
||||
decode(w, &enabled)
|
||||
if len(enabled["models"].([]interface{})) == 0 {
|
||||
t.Fatal("no enabled models for completion test")
|
||||
}
|
||||
firstModel := enabled["models"].([]interface{})[0].(map[string]interface{})
|
||||
modelForChat := firstModel["model_id"].(string)
|
||||
configForChat := firstModel["config_id"].(string)
|
||||
|
||||
t.Logf("Sending completion to %s via config %s", modelForChat, configForChat)
|
||||
|
||||
// Create a channel first
|
||||
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Test Chat", "type": "direct",
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Chat Test", "type": "direct",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
// Some channel handlers may use database.DB directly
|
||||
t.Skipf("channel creation failed (may need database.DB global): %d %s", w.Code, w.Body.String())
|
||||
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Send completion
|
||||
// Non-streaming completion with correct field names
|
||||
stream := false
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"model": modelForChat,
|
||||
"config_id": configForChat,
|
||||
"stream": false,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "Say hello in exactly 3 words."},
|
||||
},
|
||||
"channel_id": channelID,
|
||||
"content": "Say ok",
|
||||
"model": veniceTestModel,
|
||||
"provider_config_id": configID,
|
||||
"stream": &stream,
|
||||
"max_tokens": 10,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Logf("completion response: %s", w.Body.String())
|
||||
t.Skipf("chat completion failed with %d (may need full router wiring)", w.Code)
|
||||
t.Fatalf("completion: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
|
||||
}
|
||||
|
||||
// TestLive_VeniceUsageLogging verifies that a non-streaming completion
|
||||
// creates a usage_log row with token counts from the provider.
|
||||
func TestLive_VeniceUsageLogging(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Usage Test", "type": "direct",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Non-streaming — providers reliably return usage in non-streaming mode
|
||||
stream := false
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"content": "Say ok",
|
||||
"model": veniceTestModel,
|
||||
"provider_config_id": configID,
|
||||
"stream": &stream,
|
||||
"max_tokens": 10,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("completion: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify usage_log row exists
|
||||
var rowCount int
|
||||
var promptTokens, completionTokens int
|
||||
err := database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0)
|
||||
FROM usage_log WHERE provider_config_id = $1
|
||||
`, configID).Scan(&rowCount, &promptTokens, &completionTokens)
|
||||
if err != nil {
|
||||
t.Fatalf("query usage_log: %v", err)
|
||||
}
|
||||
if rowCount == 0 {
|
||||
t.Fatal("usage_log should have a row after non-streaming completion")
|
||||
}
|
||||
t.Logf(" ✓ Usage logged: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
|
||||
|
||||
if promptTokens == 0 {
|
||||
t.Fatal("non-streaming completion should report prompt tokens — check provider response parsing")
|
||||
}
|
||||
t.Logf(" ✓ Token counts: prompt=%d completion=%d", promptTokens, completionTokens)
|
||||
}
|
||||
|
||||
// TestLive_VeniceStreamingUsageLogging verifies that streaming completions
|
||||
// create a usage_log row with actual token counts. Venice supports
|
||||
// stream_options.include_usage — the parser must capture the usage chunk
|
||||
// that arrives after finish_reason but before [DONE].
|
||||
func TestLive_VeniceStreamingUsageLogging(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
|
||||
// Create channel
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Stream Usage Test", "type": "direct",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Streaming completion
|
||||
stream := true
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"content": "Say ok",
|
||||
"model": veniceTestModel,
|
||||
"provider_config_id": configID,
|
||||
"stream": &stream,
|
||||
"max_tokens": 10,
|
||||
})
|
||||
// Streaming returns 200 with SSE — the recorder captures the full body
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("streaming completion: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify usage_log row exists (even if tokens are 0)
|
||||
var rowCount int
|
||||
var promptTokens, completionTokens int
|
||||
err := database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0)
|
||||
FROM usage_log WHERE provider_config_id = $1
|
||||
`, configID).Scan(&rowCount, &promptTokens, &completionTokens)
|
||||
if err != nil {
|
||||
t.Fatalf("query usage_log: %v", err)
|
||||
}
|
||||
if rowCount == 0 {
|
||||
t.Fatal("usage_log should have a row after streaming completion")
|
||||
}
|
||||
if promptTokens == 0 {
|
||||
t.Fatal("streaming completion should report prompt tokens — check pendingFinish logic in openai.go parser")
|
||||
}
|
||||
t.Logf(" ✓ Streaming usage logged: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
|
||||
}
|
||||
|
||||
// TestLive_VenicePricingFromCatalog verifies that model sync populates
|
||||
// the model_pricing table from Venice's pricing data.
|
||||
func TestLive_VenicePricingFromCatalog(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
|
||||
// Check if pricing was populated during fetch
|
||||
var pricingCount int
|
||||
database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = $1
|
||||
`, configID).Scan(&pricingCount)
|
||||
|
||||
if pricingCount == 0 {
|
||||
t.Skip("Venice model sync did not populate pricing — may need provider pricing support")
|
||||
}
|
||||
|
||||
t.Logf(" ✓ Catalog sync populated %d pricing entries", pricingCount)
|
||||
|
||||
// Verify our test model has pricing
|
||||
var inputPerM, outputPerM float64
|
||||
err := database.TestDB.QueryRow(`
|
||||
SELECT COALESCE(input_per_m, 0), COALESCE(output_per_m, 0)
|
||||
FROM model_pricing
|
||||
WHERE provider_config_id = $1 AND model_id = $2
|
||||
`, configID, veniceTestModel).Scan(&inputPerM, &outputPerM)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠ No pricing for %s specifically (may use different model ID)", veniceTestModel)
|
||||
} else {
|
||||
t.Logf(" ✓ %s pricing: $%.4f input, $%.4f output (per 1M)", veniceTestModel, inputPerM, outputPerM)
|
||||
}
|
||||
|
||||
// Admin pricing list should show these (scope=global, not personal)
|
||||
w := h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin pricing: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var entries []interface{}
|
||||
decode(w, &entries)
|
||||
if len(entries) == 0 {
|
||||
t.Error("admin pricing API should return catalog-synced entries")
|
||||
} else {
|
||||
t.Logf(" ✓ Admin pricing API returns %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLive_VeniceEmbeddings tests the Venice embeddings endpoint directly
|
||||
// using the BGE-M3 model ($0.15 per 1M tokens).
|
||||
func TestLive_VeniceEmbeddings(t *testing.T) {
|
||||
veniceKey := requireVeniceKey(t)
|
||||
|
||||
provider := &providers.VeniceProvider{}
|
||||
cfg := providers.ProviderConfig{
|
||||
Endpoint: "https://api.venice.ai/api/v1",
|
||||
APIKey: veniceKey,
|
||||
}
|
||||
|
||||
resp, err := provider.Embed(
|
||||
context.Background(), cfg,
|
||||
providers.EmbeddingRequest{
|
||||
Model: "text-embedding-bge-m3",
|
||||
Input: []string{"test embedding"},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Venice Embed: %v", err)
|
||||
}
|
||||
if len(resp.Embeddings) == 0 {
|
||||
t.Fatal("expected at least 1 embedding vector")
|
||||
}
|
||||
if len(resp.Embeddings[0]) == 0 {
|
||||
t.Fatal("embedding vector should not be empty")
|
||||
}
|
||||
t.Logf(" ✓ Embedding returned %d dimensions, input_tokens=%d",
|
||||
len(resp.Embeddings[0]), resp.InputTokens)
|
||||
}
|
||||
|
||||
// TestLive_VeniceModelDeletion tests cleanup: delete provider removes catalog entries
|
||||
func TestLive_VeniceModelDeletion(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
@@ -55,13 +56,14 @@ type cursorRequest struct {
|
||||
}
|
||||
|
||||
// MessageHandler holds dependencies for message endpoints.
|
||||
type MessageHandler struct{
|
||||
vault *crypto.KeyResolver
|
||||
type MessageHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewMessageHandler creates a new message handler.
|
||||
func NewMessageHandler(vault *crypto.KeyResolver) *MessageHandler {
|
||||
return &MessageHandler{vault: vault}
|
||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
|
||||
return &MessageHandler{vault: vault, stores: stores}
|
||||
}
|
||||
|
||||
// ── List Messages (flat, all branches) ──────
|
||||
@@ -356,7 +358,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Resolve model + provider ──
|
||||
|
||||
comp := NewCompletionHandler(h.vault)
|
||||
comp := NewCompletionHandler(h.vault, h.stores)
|
||||
|
||||
var presetSystemPrompt string
|
||||
model := req.Model
|
||||
@@ -396,7 +398,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
providerCfg, providerID, model, configID, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||
Model: model,
|
||||
APIConfigID: apiConfigID,
|
||||
})
|
||||
@@ -497,6 +499,11 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||
}
|
||||
|
||||
// Log usage for regeneration
|
||||
comp.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
result.InputTokens, result.OutputTokens,
|
||||
result.CacheCreationTokens, result.CacheReadTokens)
|
||||
}
|
||||
|
||||
// ── Switch Branch (update cursor) ───────────
|
||||
|
||||
@@ -76,6 +76,17 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
|
||||
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
|
||||
}
|
||||
|
||||
// Sync pricing from provider catalog (won't overwrite manual admin overrides)
|
||||
if stores.Pricing != nil {
|
||||
for _, m := range provModels {
|
||||
if m.Pricing != nil {
|
||||
if err := stores.Pricing.UpsertFromCatalog(ctx, cfg.ID, m.ID, m.Pricing); err != nil {
|
||||
log.Printf("warn: pricing sync for %s/%s failed: %v", cfg.ID, m.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
|
||||
}
|
||||
|
||||
|
||||
243
server/handlers/roles.go
Normal file
243
server/handlers/roles.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// RolesHandler manages model role configuration.
|
||||
type RolesHandler struct {
|
||||
stores store.Stores
|
||||
resolver *roles.Resolver
|
||||
}
|
||||
|
||||
// NewRolesHandler creates a roles handler.
|
||||
func NewRolesHandler(s store.Stores, resolver *roles.Resolver) *RolesHandler {
|
||||
return &RolesHandler{stores: s, resolver: resolver}
|
||||
}
|
||||
|
||||
// ── List All Role Configs ──────────────────
|
||||
// GET /admin/roles
|
||||
|
||||
func (h *RolesHandler) ListRoles(c *gin.Context) {
|
||||
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, allRoles)
|
||||
}
|
||||
|
||||
// ── Get Single Role Config ─────────────────
|
||||
// GET /admin/roles/:role
|
||||
|
||||
func (h *RolesHandler) GetRole(c *gin.Context) {
|
||||
role := c.Param("role")
|
||||
if !roles.IsValidRole(role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// ── Update Role Config ─────────────────────
|
||||
// PUT /admin/roles/:role
|
||||
|
||||
func (h *RolesHandler) UpdateRole(c *gin.Context) {
|
||||
role := c.Param("role")
|
||||
if !roles.IsValidRole(role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
|
||||
return
|
||||
}
|
||||
|
||||
var req roles.RoleConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Load current global model_roles
|
||||
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
|
||||
return
|
||||
}
|
||||
if allRoles == nil {
|
||||
allRoles = models.JSONMap{}
|
||||
}
|
||||
|
||||
// Update the specific role
|
||||
allRoles[role] = req
|
||||
|
||||
// Persist
|
||||
userID := getUserID(c)
|
||||
if err := h.stores.GlobalConfig.Set(c.Request.Context(), "model_roles", allRoles, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save role"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
// ── Test Role ──────────────────────────────
|
||||
// POST /admin/roles/:role/test
|
||||
|
||||
func (h *RolesHandler) TestRole(c *gin.Context) {
|
||||
role := c.Param("role")
|
||||
if !roles.IsValidRole(role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
|
||||
return
|
||||
}
|
||||
|
||||
if role == roles.RoleEmbedding {
|
||||
// Test embedding
|
||||
result, err := h.resolver.Embed(c.Request.Context(), role, nil, []string{"test embedding"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"model": result.Model,
|
||||
"provider": result.ProviderID,
|
||||
"dimensions": len(result.Embeddings[0]),
|
||||
"used_fallback": result.UsedFallback,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Test completion with a minimal prompt
|
||||
result, err := h.resolver.Complete(c.Request.Context(), role, nil, []providers.Message{
|
||||
{Role: "user", Content: "Say 'ok' and nothing else."},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"model": result.Model,
|
||||
"provider": result.ProviderID,
|
||||
"content": result.Content,
|
||||
"input_tokens": result.InputTokens,
|
||||
"output_tokens": result.OutputTokens,
|
||||
"used_fallback": result.UsedFallback,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Team Role Overrides ────────────────────
|
||||
|
||||
// ListTeamRoles returns role overrides for a specific team.
|
||||
// GET /teams/:teamId/roles
|
||||
func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
}
|
||||
|
||||
roleOverrides := make(map[string]interface{})
|
||||
if team.Settings != nil {
|
||||
if raw, ok := team.Settings["model_roles"]; ok {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
roleOverrides = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, roleOverrides)
|
||||
}
|
||||
|
||||
// UpdateTeamRole sets a team role override.
|
||||
// PUT /teams/:teamId/roles/:role
|
||||
func (h *RolesHandler) UpdateTeamRole(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
role := c.Param("role")
|
||||
if !roles.IsValidRole(role) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
|
||||
return
|
||||
}
|
||||
|
||||
var req roles.RoleConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
}
|
||||
|
||||
settings := team.Settings
|
||||
if settings == nil {
|
||||
settings = models.JSONMap{}
|
||||
}
|
||||
|
||||
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
|
||||
if roleOverrides == nil {
|
||||
roleOverrides = make(map[string]interface{})
|
||||
}
|
||||
roleOverrides[role] = req
|
||||
settings["model_roles"] = roleOverrides
|
||||
|
||||
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
|
||||
"settings": settings,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save team role"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, req)
|
||||
}
|
||||
|
||||
// DeleteTeamRole removes a team role override (falls back to global).
|
||||
// DELETE /teams/:teamId/roles/:role
|
||||
func (h *RolesHandler) DeleteTeamRole(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
role := c.Param("role")
|
||||
|
||||
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
|
||||
return
|
||||
}
|
||||
|
||||
settings := team.Settings
|
||||
if settings == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "no override to remove"})
|
||||
return
|
||||
}
|
||||
|
||||
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
|
||||
if roleOverrides != nil {
|
||||
delete(roleOverrides, role)
|
||||
settings["model_roles"] = roleOverrides
|
||||
}
|
||||
|
||||
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
|
||||
"settings": settings,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove team role"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "override removed"})
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package handlers
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
@@ -36,11 +38,13 @@ type profileResponse struct {
|
||||
}
|
||||
|
||||
// SettingsHandler manages user profile and preferences.
|
||||
type SettingsHandler struct{}
|
||||
type SettingsHandler struct {
|
||||
uekCache *crypto.UEKCache
|
||||
}
|
||||
|
||||
// NewSettingsHandler creates a new handler.
|
||||
func NewSettingsHandler() *SettingsHandler {
|
||||
return &SettingsHandler{}
|
||||
func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler {
|
||||
return &SettingsHandler{uekCache: uekCache}
|
||||
}
|
||||
|
||||
// ── Get Profile ─────────────────────────────
|
||||
@@ -155,6 +159,9 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-wrap UEK with new password so personal BYOK keys remain accessible
|
||||
h.rewrapVault(userID, req.CurrentPassword, req.NewPassword)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
|
||||
}
|
||||
|
||||
@@ -207,3 +214,60 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
h.GetSettings(c)
|
||||
}
|
||||
|
||||
// ── Vault Re-wrap ──────────────────────────
|
||||
|
||||
// rewrapVault decrypts the UEK with the old password and re-encrypts it with
|
||||
// the new password. This keeps personal BYOK keys accessible after a password
|
||||
// change. Uses a new salt for forward secrecy.
|
||||
func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
|
||||
if h.uekCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var vaultSet bool
|
||||
var encUEK, salt, nonce []byte
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
|
||||
if err != nil || !vaultSet || len(encUEK) == 0 {
|
||||
return // No vault to re-wrap
|
||||
}
|
||||
|
||||
// Decrypt UEK with old password
|
||||
oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt)
|
||||
uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault re-wrap failed for user %s (unwrap): %v", userID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-wrap with new password using fresh salt
|
||||
newSalt, err := crypto.GenerateSalt()
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault re-wrap failed for user %s (salt): %v", userID, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPDK := crypto.DeriveKeyFromPassword(newPassword, newSalt)
|
||||
newEncUEK, newNonce, err := crypto.WrapUEK(uek, newPDK)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault re-wrap failed for user %s (wrap): %v", userID, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = database.DB.Exec(`
|
||||
UPDATE users
|
||||
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, newEncUEK, newSalt, newNonce, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Session cache: UEK itself hasn't changed, just its wrapper
|
||||
h.uekCache.Store(userID, uek)
|
||||
log.Printf(" 🔐 Vault re-wrapped for user %s", userID)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,12 @@ import (
|
||||
// streamResult holds the accumulated output from a streaming completion
|
||||
// with tool execution. Callers are responsible for persistence.
|
||||
type streamResult struct {
|
||||
Content string
|
||||
ToolActivity []map[string]interface{}
|
||||
Content string
|
||||
ToolActivity []map[string]interface{}
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
CacheCreationTokens int
|
||||
CacheReadTokens int
|
||||
}
|
||||
|
||||
// streamWithToolLoop is the single, canonical streaming implementation.
|
||||
@@ -100,6 +104,11 @@ func streamWithToolLoop(
|
||||
if event.Done {
|
||||
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
|
||||
toolCalls = event.ToolCalls
|
||||
// Accumulate tokens from this tool-call iteration
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
} else {
|
||||
// Normal completion — send finish event
|
||||
finishReason := event.FinishReason
|
||||
@@ -110,6 +119,11 @@ func streamWithToolLoop(
|
||||
result.Content += "<think>" + iterReasoning + "</think>"
|
||||
}
|
||||
result.Content += iterContent
|
||||
// Accumulate final tokens
|
||||
result.InputTokens += event.InputTokens
|
||||
result.OutputTokens += event.OutputTokens
|
||||
result.CacheCreationTokens += event.CacheCreationTokens
|
||||
result.CacheReadTokens += event.CacheReadTokens
|
||||
sendSSE(fmt.Sprintf(
|
||||
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
|
||||
finishReason, model))
|
||||
|
||||
233
server/handlers/usage.go
Normal file
233
server/handlers/usage.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// UsageHandler manages usage tracking and pricing endpoints.
|
||||
type UsageHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewUsageHandler creates a usage handler.
|
||||
func NewUsageHandler(s store.Stores) *UsageHandler {
|
||||
return &UsageHandler{stores: s}
|
||||
}
|
||||
|
||||
// ── Admin: Aggregated Usage ────────────────
|
||||
// GET /admin/usage?since=...&until=...&group_by=model|user|day|provider
|
||||
|
||||
func (h *UsageHandler) AdminUsage(c *gin.Context) {
|
||||
opts := h.parseQueryOptions(c)
|
||||
opts.ExcludeBYOK = true // Admin views exclude personal BYOK
|
||||
|
||||
results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
|
||||
return
|
||||
}
|
||||
|
||||
totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"totals": totals,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin: Per-User Usage ──────────────────
|
||||
// GET /admin/usage/users/:id
|
||||
|
||||
func (h *UsageHandler) AdminUserUsage(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
opts := h.parseQueryOptions(c)
|
||||
opts.ExcludeBYOK = true
|
||||
|
||||
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
|
||||
// ── Admin: Per-Team Usage ──────────────────
|
||||
// GET /admin/usage/teams/:id
|
||||
|
||||
func (h *UsageHandler) AdminTeamUsage(c *gin.Context) {
|
||||
teamID := c.Param("id")
|
||||
opts := h.parseQueryOptions(c)
|
||||
opts.ExcludeBYOK = true
|
||||
|
||||
results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
|
||||
// ── User: Personal Usage ───────────────────
|
||||
// GET /usage
|
||||
|
||||
func (h *UsageHandler) PersonalUsage(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
opts := h.parseQueryOptions(c)
|
||||
|
||||
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"totals": totals,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Team Admin: Team Provider Usage ────────
|
||||
// GET /teams/:teamId/usage
|
||||
//
|
||||
// Shows usage against providers owned by this team. Only team admins
|
||||
// (enforced by RequireTeamAdmin middleware) can see this view.
|
||||
|
||||
func (h *UsageHandler) TeamUsage(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
opts := h.parseQueryOptions(c)
|
||||
|
||||
totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"})
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"totals": totals,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin: List Pricing ────────────────────
|
||||
// GET /admin/pricing
|
||||
|
||||
func (h *UsageHandler) ListPricing(c *gin.Context) {
|
||||
entries, err := h.stores.Pricing.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
// ── Admin: Upsert Pricing ──────────────────
|
||||
// PUT /admin/pricing
|
||||
|
||||
func (h *UsageHandler) UpsertPricing(c *gin.Context) {
|
||||
var req models.PricingEntry
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider is admin-managed (global or team), not personal BYOK
|
||||
pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
|
||||
if err != nil || pc == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"})
|
||||
return
|
||||
}
|
||||
if pc.Scope == "personal" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
req.Source = "manual"
|
||||
req.UpdatedBy = &userID
|
||||
|
||||
if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pricing saved"})
|
||||
}
|
||||
|
||||
// ── Admin: Delete Pricing ──────────────────
|
||||
// DELETE /admin/pricing/:provider/:model
|
||||
|
||||
func (h *UsageHandler) DeletePricing(c *gin.Context) {
|
||||
providerConfigID := c.Param("provider")
|
||||
modelID := c.Param("model")
|
||||
|
||||
if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"})
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
|
||||
func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions {
|
||||
opts := store.UsageQueryOptions{
|
||||
GroupBy: c.DefaultQuery("group_by", "model"),
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||
opts.Since = &t
|
||||
}
|
||||
}
|
||||
if until := c.Query("until"); until != "" {
|
||||
if t, err := time.Parse(time.RFC3339, until); err == nil {
|
||||
opts.Until = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience shorthand: ?period=7d|30d|90d
|
||||
if period := c.Query("period"); period != "" && opts.Since == nil {
|
||||
var dur time.Duration
|
||||
switch period {
|
||||
case "7d":
|
||||
dur = 7 * 24 * time.Hour
|
||||
case "30d":
|
||||
dur = 30 * 24 * time.Hour
|
||||
case "90d":
|
||||
dur = 90 * 24 * time.Hour
|
||||
}
|
||||
if dur > 0 {
|
||||
t := time.Now().Add(-dur)
|
||||
opts.Since = &t
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
Reference in New Issue
Block a user