Changeset 0.10.0 (#56)

This commit is contained in:
2026-02-24 14:50:53 +00:00
parent cdfd69bad3
commit ea03f956ca
31 changed files with 3303 additions and 167 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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())
}
}

View File

@@ -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)

View File

@@ -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) ───────────

View File

@@ -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
View 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"})
}

View File

@@ -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)
}

View File

@@ -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
View 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
}