diff --git a/CHANGELOG.md b/CHANGELOG.md
index 282e2e2..6bcbb17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,95 @@
All notable changes to Chat Switchboard.
+## [0.10.0] — 2026-02-24
+
+### Added
+- **Model Roles** — Named model slots (`utility`, `embedding`, `generation`)
+ with primary + fallback bindings. Stored in `global_settings['model_roles']`.
+ Team-level overrides via `teams.settings` JSONB. New `server/roles/` package
+ with `Resolver.Complete()` and `Resolver.Embed()` for automatic failover.
+ Admin API: `GET/PUT /admin/roles/:role`, `POST /admin/roles/:role/test`.
+ Team API: `GET/PUT/DELETE /teams/:id/roles/:role`.
+- **Provider Embed() interface** — All providers implement `Embed()`. OpenAI,
+ Venice, OpenRouter support `/v1/embeddings`; Anthropic returns `ErrNotSupported`.
+ New types: `EmbeddingRequest`, `EmbeddingResponse`.
+- **Usage Tracking** — Every completion (streaming and non-streaming) logs
+ token counts and cost to `usage_log` table. Cost calculated at insert time
+ from `model_pricing` (no retroactive recalculation). Provider scope
+ denormalized for efficient admin filtering. Admin views exclude BYOK.
+ New stores: `UsageStore`, `PricingStore`. Admin API:
+ `GET /admin/usage`, `GET /admin/usage/users/:id`, `GET /admin/usage/teams/:id`.
+ User API: `GET /usage` (personal summary).
+- **Model Pricing** — `model_pricing` table with catalog sync and manual admin
+ overrides. Sync from provider APIs (Venice, OpenRouter) auto-populates
+ pricing with `source='catalog'`; manual entries never overwritten. Admin API:
+ `GET/PUT/DELETE /admin/pricing`.
+- **Streaming token capture** — OpenAI: `stream_options.include_usage` +
+ parse usage/cache from final chunk. Anthropic: parse `message_start` for
+ input/cache tokens, `message_delta` for output tokens. Cache token fields
+ (`CacheCreationTokens`, `CacheReadTokens`) on `CompletionResponse`,
+ `StreamEvent`, and `streamResult`.
+- **Admin Roles tab** — Configure primary/fallback provider+model per role,
+ test-fire from UI.
+- **Admin Usage dashboard** — Period selector (7d/30d/90d), group-by
+ (model/user/day/provider), totals cards, breakdown table, pricing table.
+- **Team Usage dashboard** — Team admins can view usage against their
+ team-owned providers via `GET /teams/:teamId/usage`. Filters to
+ `provider_configs WHERE scope='team' AND owner_id=teamId`. Integrated
+ into team management panel with period/group-by selectors.
+- **Admin Reset Password** — Button in user list with vault destruction
+ warning dialog (two-step confirmation).
+- **User Usage tab** — Settings modal "Usage" tab shows personal token
+ consumption and estimated costs across all conversations, including BYOK.
+ Period selector (7d/30d/90d) and group-by (model/day).
+- **Live Venice integration tests** — Gated behind `VENICE_API_KEY` env var.
+ Tests: non-streaming completion, streaming completion, usage logging for
+ both modes, pricing from catalog sync, and direct embeddings via BGE-M3.
+ Uses `qwen3-4b` (Venice Small) — cheapest at $0.05/$0.15 per 1M tokens.
+- **Migration 004** — `usage_log` table, `model_pricing` table, `model_roles`
+ seed in `global_settings`.
+
+### Fixed
+- **UEK re-wrap on password change** — `ChangePassword` now decrypts UEK
+ with old password and re-encrypts with new password + fresh salt. Previously,
+ password changes silently broke all personal BYOK keys.
+- **UEK destruction on admin password reset** — `ResetPassword` now nullifies
+ vault columns, evicts UEK from cache, deletes personal provider configs,
+ and logs an audit event. Previously, admin resets left orphaned encrypted
+ UEK that silently broke personal keys.
+- **Streaming completions logged zero tokens** — `StreamEvent` lacked token
+ fields and providers discarded usage data from final chunks. Both OpenAI
+ and Anthropic streaming parsers now capture and propagate token counts.
+- **OpenAI streaming usage race condition** — The OpenAI protocol sends
+ chunks in order: content → finish_reason → usage → [DONE]. The parser
+ sent `Done=true` on the finish_reason chunk (step 2) before the usage
+ chunk arrived (step 3, with `choices:[]`). The receiver returned
+ immediately on Done, so usage was captured but never delivered.
+ Fix: defer the Done event as `pendingFinish` until the usage chunk
+ arrives, then flush with tokens attached. Tool-call finishes flush
+ immediately (tool loop needs the event). Affects all OpenAI-compatible
+ providers (OpenAI, Venice, OpenRouter).
+- **Token accumulation across tool iterations** — `streamResult` in
+ `stream_loop.go` now accumulates input/output/cache tokens across
+ multi-tool-call iterations instead of only capturing the final iteration.
+- **Admin pricing leaked BYOK entries** — `PricingStore.List()` returned
+ all model_pricing rows including those from personal BYOK providers.
+ Admin panel showed pricing entries for user-private providers they
+ shouldn't see, with O(users × models) scale explosion. Now joins on
+ `provider_configs` and filters `scope != 'personal'`. Admin `UpsertPricing`
+ also validates provider scope, rejecting manual pricing on personal
+ providers.
+- **Team role handlers used wrong param name** — `ListTeamRoles`,
+ `UpdateTeamRole`, `DeleteTeamRole` read `c.Param("id")` but routes use
+ `:teamId`. Every team role operation silently got an empty team ID.
+- **Usage logging suppressed for zero-token streams** — `logUsage` had an
+ early exit when `inputTokens == 0 && outputTokens == 0`. Combined with the
+ OpenAI streaming parser bug (below), this silently dropped all streaming
+ usage rows. Removed the guard — requests are always recorded.
+- **Live chat completion test used wrong field names** — `TestLive_VeniceChatCompletion`
+ sent `messages` and `config_id` but the handler expects `content` and
+ `provider_config_id`. Test silently skipped on the binding error.
+
## [0.9.4] — 2026-02-24
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index 438c123..68d56ab 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -346,7 +346,7 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
**Admin tooling**
- [ ] `switchboard vault rekey` CLI command _(deferred)_
- [ ] Admin UI: encryption status indicator _(deferred)_
-- [ ] Admin UI: password reset warning _(deferred)_
+- [x] Admin UI: password reset warning (v0.10.0 — vault destruction dialog)
**CI/CD**
- [x] `ENCRYPTION_KEY` Gitea secret → k8s `switchboard-encryption` secret sync
@@ -360,26 +360,47 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
---
-Two pieces of infrastructure that everything downstream depends on.
+## ✅ v0.10.0 — Model Roles + Usage Tracking + Vault Debt
+
+Three tracks of infrastructure that everything downstream depends on.
+
+**Vault Debt (Security Fixes)**
+- [x] UEK re-wrap on user password change (prevents permanent key loss)
+- [x] UEK destruction on admin password reset (vault + personal BYOK keys deleted)
+- [x] Admin UI: password reset confirmation with vault destruction warning
+- [x] Audit logging for vault destruction events
**Model Roles** (see [EXTENSIONS.md Appendix B](EXTENSIONS.md#appendix-b-model-roles))
-- [ ] `model_roles` in global_settings: named slots (utility, embedding, generation)
-- [ ] Each role: primary provider+model, optional fallback provider+model
-- [ ] Team-level role overrides (team.settings JSONB)
-- [ ] Backend API: `roles.Complete(ctx, "utility", messages)` with automatic fallback
-- [ ] Backend API: `roles.Embed(ctx, "embedding", text)` with automatic fallback
-- [ ] Admin UI: role configuration (select provider + model per role)
+- [x] `model_roles` in global_settings: named slots (utility, embedding, generation)
+- [x] Each role: primary provider+model, optional fallback provider+model
+- [x] Team-level role overrides (team.settings JSONB)
+- [x] `Provider.Embed()` interface — OpenAI/Venice/OpenRouter implement, Anthropic returns `ErrNotSupported`
+- [x] `roles.Resolver` package: `Complete()` + `Embed()` with automatic fallback
+- [x] Admin API: `GET/PUT /admin/roles/:role`, `POST /admin/roles/:role/test`
+- [x] Team API: `GET/PUT/DELETE /teams/:id/roles/:role`
+- [x] Admin UI: role configuration tab (select provider + model per role, test fire)
**Usage Tracking**
-- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
- cache_creation_tokens, cache_read_tokens
-- [ ] `usage_log` table: channel_id, user_id, model_id, provider_id,
- role (nullable — "utility", "embedding", or NULL for user chat),
- token counts, timestamp
-- [ ] Model pricing from catalog sync (Venice/OpenAI report pricing)
-- [ ] Admin override pricing (per M tokens, decimal)
-- [ ] Cost calculated at query time (join usage × pricing)
-- [ ] Per-user and per-team usage dashboard in admin panel
+- [x] Streaming token capture fix — OpenAI: `stream_options.include_usage` + parse usage chunk;
+ Anthropic: parse `message_start`/`message_delta` usage events
+- [x] Cache token fields across all types (`CompletionResponse`, `StreamEvent`, `streamResult`)
+- [x] `usage_log` table: channel_id, user_id, model_id, provider_config_id,
+ role, token counts (prompt, completion, cache_creation, cache_read), cost, timestamp
+- [x] `model_pricing` table: provider+model, input/output/cache per-M, source (catalog/manual)
+- [x] Cost calculated at insert time (baked, not query-time)
+- [x] BYOK exclusion: admin views filter `provider_scope != 'personal'`, BYOK gets separate endpoint
+- [x] Model pricing from catalog sync (won't overwrite manual overrides)
+- [x] Admin API: `GET /admin/usage`, `GET /admin/usage/users/:id`, `GET /admin/usage/teams/:id`
+- [x] Admin API: `GET/PUT/DELETE /admin/pricing`
+- [x] User API: `GET /usage` (personal usage summary)
+- [x] Admin UI: usage dashboard (period selector, group-by, totals cards, breakdown table, pricing table)
+- [x] Team admin UI: usage dashboard for team-owned providers (`GET /teams/:teamId/usage`)
+- [x] Token accumulation across multi-tool-call iterations in `stream_loop.go`
+
+**Schema (migration 004)**
+- [x] `usage_log` with indexes on user, created_at, provider, model, scope
+- [x] `model_pricing` with unique constraint on (provider_config_id, model_id)
+- [x] `model_roles` seed in global_settings
---
diff --git a/VERSION b/VERSION
index 2bd77c7..2774f85 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.9.4
\ No newline at end of file
+0.10.0
\ No newline at end of file
diff --git a/server/database/migrations/004_roles_usage.sql b/server/database/migrations/004_roles_usage.sql
new file mode 100644
index 0000000..a77fec7
--- /dev/null
+++ b/server/database/migrations/004_roles_usage.sql
@@ -0,0 +1,64 @@
+-- 004_roles_usage.sql
+-- v0.10.0: Model Roles + Usage Tracking
+--
+-- New tables: usage_log, model_pricing
+-- Seed: model_roles in global_settings
+
+-- ── Model roles seed ──────────────────────────
+-- Uses existing global_settings table. No new tables for roles.
+-- Team overrides go in teams.settings JSONB (existing column).
+
+INSERT INTO global_settings (key, value) VALUES
+ ('model_roles', '{
+ "utility": { "primary": null, "fallback": null },
+ "embedding": { "primary": null, "fallback": null },
+ "generation": { "primary": null, "fallback": null }
+ }'::jsonb)
+ON CONFLICT (key) DO NOTHING;
+
+-- ── Usage tracking ────────────────────────────
+-- Every completion (streaming and non-streaming) logs token counts and cost.
+-- Cost is calculated at insert time from model_pricing.
+-- provider_scope is denormalized for efficient admin filtering (excludes BYOK).
+
+CREATE TABLE IF NOT EXISTS usage_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
+ provider_scope TEXT NOT NULL DEFAULT 'global',
+ model_id TEXT NOT NULL,
+ role TEXT, -- NULL = user chat, 'utility', 'embedding', 'generation'
+ prompt_tokens INT NOT NULL DEFAULT 0,
+ completion_tokens INT NOT NULL DEFAULT 0,
+ cache_creation_tokens INT NOT NULL DEFAULT 0,
+ cache_read_tokens INT NOT NULL DEFAULT 0,
+ cost_input NUMERIC(12,6),
+ cost_output NUMERIC(12,6),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_usage_log_user ON usage_log(user_id);
+CREATE INDEX IF NOT EXISTS idx_usage_log_created ON usage_log(created_at);
+CREATE INDEX IF NOT EXISTS idx_usage_log_provider ON usage_log(provider_config_id);
+CREATE INDEX IF NOT EXISTS idx_usage_log_model ON usage_log(model_id);
+CREATE INDEX IF NOT EXISTS idx_usage_log_scope ON usage_log(provider_scope);
+
+-- ── Model pricing ─────────────────────────────
+-- Two sources: 'catalog' (auto-populated from provider API sync) and
+-- 'manual' (admin override). Manual entries are never overwritten by sync.
+
+CREATE TABLE IF NOT EXISTS model_pricing (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
+ model_id TEXT NOT NULL,
+ input_per_m NUMERIC(10,6),
+ output_per_m NUMERIC(10,6),
+ cache_create_per_m NUMERIC(10,6),
+ cache_read_per_m NUMERIC(10,6),
+ currency TEXT NOT NULL DEFAULT 'USD',
+ source TEXT NOT NULL DEFAULT 'manual',
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES users(id),
+ UNIQUE(provider_config_id, model_id)
+);
diff --git a/server/database/testhelper.go b/server/database/testhelper.go
index c45b46f..1a0db70 100644
--- a/server/database/testhelper.go
+++ b/server/database/testhelper.go
@@ -158,6 +158,8 @@ func TruncateAll(t *testing.T) {
}
// Order matters due to foreign keys — truncate with CASCADE
tables := []string{
+ "usage_log",
+ "model_pricing",
"notes",
"audit_log",
"channel_cursors",
@@ -178,6 +180,22 @@ func TruncateAll(t *testing.T) {
for _, table := range tables {
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
}
+
+ // Re-seed global_settings — CASCADE from users wipes rows with updated_by FK.
+ // Re-run the seed SQL from migrations 001 + 004.
+ DB.Exec(`
+ INSERT INTO global_settings (key, value) VALUES
+ ('registration', '{"enabled": true}'::jsonb),
+ ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
+ ('banner', '{"enabled": false, "text": "", "position": "both", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
+ ('banner_presets', '{}'::jsonb),
+ ('model_roles', '{
+ "utility": { "primary": null, "fallback": null },
+ "embedding": { "primary": null, "fallback": null },
+ "generation": { "primary": null, "fallback": null }
+ }'::jsonb)
+ ON CONFLICT (key) DO NOTHING
+ `)
}
// SeedTestUser creates a test user and returns the user ID.
diff --git a/server/handlers/admin.go b/server/handlers/admin.go
index fe83b7a..3a832d7 100644
--- a/server/handlers/admin.go
+++ b/server/handlers/admin.go
@@ -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 {
diff --git a/server/handlers/completion.go b/server/handlers/completion.go
index 74e64b8..1f4cfc0 100644
--- a/server/handlers/completion.go
+++ b/server/handlers/completion.go
@@ -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
+}
diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go
index 8b9d7c9..94b92fd 100644
--- a/server/handlers/integration_test.go
+++ b/server/handlers/integration_test.go
@@ -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())
+ }
+}
diff --git a/server/handlers/live_provider_test.go b/server/handlers/live_provider_test.go
index f39bf57..9623571 100644
--- a/server/handlers/live_provider_test.go
+++ b/server/handlers/live_provider_test.go
@@ -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)
diff --git a/server/handlers/messages.go b/server/handlers/messages.go
index e03a253..584df4f 100644
--- a/server/handlers/messages.go
+++ b/server/handlers/messages.go
@@ -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) ───────────
diff --git a/server/handlers/model_sync.go b/server/handlers/model_sync.go
index 875e116..6500a36 100644
--- a/server/handlers/model_sync.go
+++ b/server/handlers/model_sync.go
@@ -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
}
diff --git a/server/handlers/roles.go b/server/handlers/roles.go
new file mode 100644
index 0000000..4bae563
--- /dev/null
+++ b/server/handlers/roles.go
@@ -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"})
+}
diff --git a/server/handlers/settings.go b/server/handlers/settings.go
index cce0502..8c4765b 100644
--- a/server/handlers/settings.go
+++ b/server/handlers/settings.go
@@ -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)
+}
diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go
index 214b79f..25401fa 100644
--- a/server/handlers/stream_loop.go
+++ b/server/handlers/stream_loop.go
@@ -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 += "" + iterReasoning + ""
}
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))
diff --git a/server/handlers/usage.go b/server/handlers/usage.go
new file mode 100644
index 0000000..7863c80
--- /dev/null
+++ b/server/handlers/usage.go
@@ -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
+}
diff --git a/server/main.go b/server/main.go
index c7ced14..e276174 100644
--- a/server/main.go
+++ b/server/main.go
@@ -12,6 +12,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
+ "git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
@@ -69,6 +70,9 @@ func main() {
}
defer database.Close()
+ // Role resolver for model role dispatch (needs stores + vault)
+ roleResolver := roles.NewResolver(stores, keyResolver)
+
r := gin.Default()
r.Use(middleware.CORS())
@@ -135,7 +139,7 @@ func main() {
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
- msgs := handlers.NewMessageHandler(keyResolver)
+ msgs := handlers.NewMessageHandler(keyResolver, stores)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
@@ -147,7 +151,7 @@ func main() {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
- comp := handlers.NewCompletionHandler(keyResolver)
+ comp := handlers.NewCompletionHandler(keyResolver, stores)
protected.POST("/chat/completions", comp.Complete)
// Provider Configs (user-facing — replaces /api-configs)
@@ -172,7 +176,7 @@ func main() {
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
- settings := handlers.NewSettingsHandler()
+ settings := handlers.NewSettingsHandler(uekCache)
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
@@ -181,6 +185,10 @@ func main() {
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
+ // Usage (personal)
+ usage := handlers.NewUsageHandler(stores)
+ protected.GET("/usage", usage.PersonalUsage)
+
// Personas (replaces /presets)
personas := handlers.NewPersonaHandler(stores)
protected.GET("/presets", personas.ListUserPersonas) // backward compat
@@ -222,19 +230,29 @@ func main() {
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
- // Team audit log (scoped to team members)
+ // Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
+ // Team usage (team admins only — usage against team-owned providers)
+ teamUsage := handlers.NewUsageHandler(stores)
+ teamScoped.GET("/usage", teamUsage.TeamUsage)
+
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
+
+ // Team role overrides
+ teamRoles := handlers.NewRolesHandler(stores, roleResolver)
+ teamScoped.GET("/roles", teamRoles.ListTeamRoles)
+ teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
+ teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
}
// Public global settings (non-admin users can read safe subset)
- adm := handlers.NewAdminHandler(stores, keyResolver)
+ adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
protected.GET("/settings/public", adm.PublicSettings)
}
@@ -243,7 +261,7 @@ func main() {
admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin())
{
- adm := handlers.NewAdminHandler(stores, keyResolver)
+ adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
// User management
admin.GET("/users", adm.ListUsers)
@@ -298,6 +316,22 @@ func main() {
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
+
+ // Model Roles
+ rolesH := handlers.NewRolesHandler(stores, roleResolver)
+ admin.GET("/roles", rolesH.ListRoles)
+ admin.GET("/roles/:role", rolesH.GetRole)
+ admin.PUT("/roles/:role", rolesH.UpdateRole)
+ admin.POST("/roles/:role/test", rolesH.TestRole)
+
+ // Usage & Pricing
+ usageH := handlers.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)
}
}
diff --git a/server/models/models.go b/server/models/models.go
index ed400d8..a9ef731 100644
--- a/server/models/models.go
+++ b/server/models/models.go
@@ -404,6 +404,62 @@ type AuditEntry struct {
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
+// =========================================
+// USAGE TRACKING
+// =========================================
+
+type UsageEntry struct {
+ ID string `json:"id" db:"id"`
+ ChannelID *string `json:"channel_id,omitempty" db:"channel_id"`
+ UserID string `json:"user_id" db:"user_id"`
+ ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
+ ProviderScope string `json:"provider_scope" db:"provider_scope"`
+ ModelID string `json:"model_id" db:"model_id"`
+ Role *string `json:"role,omitempty" db:"role"` // null=chat, "utility", "embedding"
+ PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens" db:"completion_tokens"`
+ CacheCreationTokens int `json:"cache_creation_tokens" db:"cache_creation_tokens"`
+ CacheReadTokens int `json:"cache_read_tokens" db:"cache_read_tokens"`
+ CostInput *float64 `json:"cost_input,omitempty" db:"cost_input"`
+ CostOutput *float64 `json:"cost_output,omitempty" db:"cost_output"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
+}
+
+type UsageAggregate struct {
+ GroupKey string `json:"group_key"`
+ Label string `json:"label"`
+ Requests int `json:"requests"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ TotalCost float64 `json:"total_cost"`
+}
+
+type UsageTotals struct {
+ Requests int `json:"requests"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ TotalCost float64 `json:"total_cost"`
+ Period string `json:"period"`
+}
+
+// =========================================
+// MODEL PRICING
+// =========================================
+
+type PricingEntry struct {
+ ID string `json:"id" db:"id"`
+ ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
+ ModelID string `json:"model_id" db:"model_id"`
+ InputPerM *float64 `json:"input_per_m" db:"input_per_m"`
+ OutputPerM *float64 `json:"output_per_m" db:"output_per_m"`
+ CacheCreatePerM *float64 `json:"cache_create_per_m" db:"cache_create_per_m"`
+ CacheReadPerM *float64 `json:"cache_read_per_m" db:"cache_read_per_m"`
+ Currency string `json:"currency" db:"currency"`
+ Source string `json:"source" db:"source"` // "catalog" | "manual"
+ UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+ UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"`
+}
+
// =========================================
// VIEW MODELS (computed, not stored)
// =========================================
diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go
index f45f799..ecc7d1a 100644
--- a/server/providers/anthropic.go
+++ b/server/providers/anthropic.go
@@ -38,10 +38,12 @@ func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConf
}
result := &CompletionResponse{
- Model: resp.Model,
- FinishReason: resp.StopReason,
- InputTokens: resp.Usage.InputTokens,
- OutputTokens: resp.Usage.OutputTokens,
+ Model: resp.Model,
+ FinishReason: resp.StopReason,
+ InputTokens: resp.Usage.InputTokens,
+ OutputTokens: resp.Usage.OutputTokens,
+ CacheCreationTokens: resp.Usage.CacheCreationInputTokens,
+ CacheReadTokens: resp.Usage.CacheReadInputTokens,
}
// Extract text and tool_use blocks
@@ -89,6 +91,9 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
var toolCalls []ToolCall
var currentToolIdx int = -1
+ // Usage accumulates across stream events
+ var inputTokens, outputTokens, cacheCreation, cacheRead int
+
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
@@ -105,6 +110,14 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
}
switch event.Type {
+ case "message_start":
+ // Capture input token counts (including cache)
+ if event.Message != nil {
+ inputTokens = event.Message.Usage.InputTokens
+ cacheCreation = event.Message.Usage.CacheCreationInputTokens
+ cacheRead = event.Message.Usage.CacheReadInputTokens
+ }
+
case "content_block_start":
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
currentToolIdx++
@@ -126,10 +139,19 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
}
case "message_delta":
+ // Capture output tokens
+ if event.Usage != nil {
+ outputTokens = event.Usage.OutputTokens
+ }
+
stopReason := event.Delta.StopReason
ev := StreamEvent{
- Done: true,
- FinishReason: stopReason,
+ Done: true,
+ FinishReason: stopReason,
+ InputTokens: inputTokens,
+ OutputTokens: outputTokens,
+ CacheCreationTokens: cacheCreation,
+ CacheReadTokens: cacheRead,
}
if stopReason == "tool_use" {
ev.FinishReason = "tool_calls"
@@ -181,6 +203,13 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M
return out, nil
}
+// ── Embeddings ─────────────────────────────
+
+// Embed is not supported by the Anthropic API.
+func (p *AnthropicProvider) Embed(_ context.Context, _ ProviderConfig, _ EmbeddingRequest) (*EmbeddingResponse, error) {
+ return nil, ErrNotSupported
+}
+
// ── HTTP Layer ──────────────────────────────
func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
@@ -366,20 +395,30 @@ type anthropicResponse struct {
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
} `json:"content"`
- Usage struct {
- InputTokens int `json:"input_tokens"`
- OutputTokens int `json:"output_tokens"`
- } `json:"usage"`
+ Usage anthropicUsage `json:"usage"`
+}
+
+type anthropicUsage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
+ CacheReadInputTokens int `json:"cache_read_input_tokens"`
}
type anthropicStreamEvent struct {
- Type string `json:"type"`
+ Type string `json:"type"`
+ Message *struct {
+ Usage anthropicUsage `json:"usage"`
+ } `json:"message,omitempty"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
StopReason string `json:"stop_reason"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
+ Usage *struct {
+ OutputTokens int `json:"output_tokens"`
+ } `json:"usage,omitempty"`
ContentBlock *struct {
Type string `json:"type"`
ID string `json:"id"`
diff --git a/server/providers/openai.go b/server/providers/openai.go
index 69a004b..11c5287 100644
--- a/server/providers/openai.go
+++ b/server/providers/openai.go
@@ -45,6 +45,11 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig,
OutputTokens: resp.Usage.CompletionTokens,
}
+ // Cache tokens (OpenAI, Venice report these in prompt_tokens_details)
+ if resp.Usage.PromptTokensDetails != nil {
+ result.CacheReadTokens = resp.Usage.PromptTokensDetails.CachedTokens
+ }
+
// Extract tool calls if present
if len(resp.Choices[0].Message.ToolCalls) > 0 {
for _, tc := range resp.Choices[0].Message.ToolCalls {
@@ -84,6 +89,13 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
// Accumulate tool calls across stream chunks
toolCallMap := map[int]*ToolCall{} // index → accumulated call
+ // Usage arrives in a separate chunk (or on the final chunk).
+ // OpenAI protocol order: content → finish_reason → usage → [DONE]
+ // The usage chunk has choices=[] so we must hold the finish event
+ // until usage arrives, otherwise tokens are always 0.
+ var streamUsage *openaiUsage
+ var pendingFinish *StreamEvent // deferred until usage arrives
+
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
@@ -94,7 +106,12 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
- ch <- StreamEvent{Done: true}
+ // Flush any pending finish event (usage may or may not have arrived)
+ if pendingFinish != nil {
+ ch <- *pendingFinish
+ } else {
+ ch <- StreamEvent{Done: true}
+ }
return
}
@@ -103,6 +120,21 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
continue
}
+ // Capture usage — may arrive in a separate chunk with empty choices
+ if chunk.Usage != nil {
+ streamUsage = chunk.Usage
+ // If finish already arrived, attach usage and flush immediately
+ if pendingFinish != nil {
+ pendingFinish.InputTokens = streamUsage.PromptTokens
+ pendingFinish.OutputTokens = streamUsage.CompletionTokens
+ if streamUsage.PromptTokensDetails != nil {
+ pendingFinish.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens
+ }
+ ch <- *pendingFinish
+ return // Done — [DONE] will be handled by defer body.Close()
+ }
+ }
+
if len(chunk.Choices) == 0 {
continue
}
@@ -146,7 +178,32 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
for _, tc := range toolCallMap {
ev.ToolCalls = append(ev.ToolCalls, *tc)
}
+ // Tool calls need to flush immediately — the tool loop
+ // needs the event to start executing tools
+ if streamUsage != nil {
+ ev.InputTokens = streamUsage.PromptTokens
+ ev.OutputTokens = streamUsage.CompletionTokens
+ if streamUsage.PromptTokensDetails != nil {
+ ev.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens
+ }
+ }
+ ch <- ev
+ continue
}
+ // Normal finish — defer until usage chunk arrives
+ if streamUsage != nil {
+ // Usage already captured (rare: same chunk or earlier)
+ ev.InputTokens = streamUsage.PromptTokens
+ ev.OutputTokens = streamUsage.CompletionTokens
+ if streamUsage.PromptTokensDetails != nil {
+ ev.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens
+ }
+ ch <- ev
+ continue
+ }
+ // Hold — usage chunk hasn't arrived yet
+ pendingFinish = &ev
+ continue
}
ch <- ev
@@ -154,6 +211,9 @@ func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
if err := scanner.Err(); err != nil {
ch <- StreamEvent{Error: err}
+ } else if pendingFinish != nil {
+ // Scanner ended without [DONE] — flush pending finish
+ ch <- *pendingFinish
}
}()
@@ -210,6 +270,59 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
return out, nil
}
+// ── Embeddings ─────────────────────────────
+
+func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
+ endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/embeddings"
+
+ oaiReq := openaiEmbeddingRequest{
+ Model: req.Model,
+ Input: req.Input,
+ }
+ body, err := json.Marshal(oaiReq)
+ if err != nil {
+ return nil, fmt.Errorf("marshal embedding request: %w", err)
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ if cfg.APIKey != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+ }
+ for k, v := range cfg.CustomHeaders {
+ httpReq.Header.Set(k, v)
+ }
+
+ resp, err := http.DefaultClient.Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("embedding request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("embedding error: HTTP %d: %s", resp.StatusCode, string(b))
+ }
+
+ var oaiResp openaiEmbeddingResponse
+ if err := json.NewDecoder(resp.Body).Decode(&oaiResp); err != nil {
+ return nil, fmt.Errorf("decode embedding response: %w", err)
+ }
+
+ result := &EmbeddingResponse{
+ Model: oaiResp.Model,
+ InputTokens: oaiResp.Usage.PromptTokens,
+ Embeddings: make([][]float64, len(oaiResp.Data)),
+ }
+ for i, d := range oaiResp.Data {
+ result.Embeddings[i] = d.Embedding
+ }
+ return result, nil
+}
+
// ── HTTP Layer ──────────────────────────────
func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
@@ -221,6 +334,9 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
Stream: req.Stream,
Messages: make([]openaiMessage, 0, len(req.Messages)),
}
+ if req.Stream {
+ oaiReq.StreamOptions = &openaiStreamOptions{IncludeUsage: true}
+ }
if req.MaxTokens > 0 {
oaiReq.MaxTokens = req.MaxTokens
}
@@ -339,13 +455,18 @@ type openaiToolDef struct {
}
type openaiChatRequest struct {
- Model string `json:"model"`
- Messages []openaiMessage `json:"messages"`
- MaxTokens int `json:"max_tokens,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- TopP *float64 `json:"top_p,omitempty"`
- Stream bool `json:"stream"`
- Tools []openaiToolDef `json:"tools,omitempty"`
+ Model string `json:"model"`
+ Messages []openaiMessage `json:"messages"`
+ MaxTokens int `json:"max_tokens,omitempty"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ TopP *float64 `json:"top_p,omitempty"`
+ Stream bool `json:"stream"`
+ StreamOptions *openaiStreamOptions `json:"stream_options,omitempty"`
+ Tools []openaiToolDef `json:"tools,omitempty"`
+}
+
+type openaiStreamOptions struct {
+ IncludeUsage bool `json:"include_usage"`
}
type openaiChatResponse struct {
@@ -354,10 +475,18 @@ type openaiChatResponse struct {
Message openaiMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
- Usage struct {
- PromptTokens int `json:"prompt_tokens"`
- CompletionTokens int `json:"completion_tokens"`
- } `json:"usage"`
+ Usage openaiUsage `json:"usage"`
+}
+
+type openaiUsage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ PromptTokensDetails *struct {
+ CachedTokens int `json:"cached_tokens"`
+ } `json:"prompt_tokens_details,omitempty"`
+ CompletionTokensDetails *struct {
+ ReasoningTokens int `json:"reasoning_tokens"`
+ } `json:"completion_tokens_details,omitempty"`
}
type openaiStreamChunk struct {
@@ -370,6 +499,7 @@ type openaiStreamChunk struct {
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
+ Usage *openaiUsage `json:"usage,omitempty"`
}
type openaiModelsResponse struct {
@@ -379,3 +509,21 @@ type openaiModelsResponse struct {
ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter
} `json:"data"`
}
+
+// ── Embedding Wire Types ──────────────────
+
+type openaiEmbeddingRequest struct {
+ Model string `json:"model"`
+ Input []string `json:"input"`
+}
+
+type openaiEmbeddingResponse struct {
+ Data []struct {
+ Embedding []float64 `json:"embedding"`
+ Index int `json:"index"`
+ } `json:"data"`
+ Model string `json:"model"`
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ } `json:"usage"`
+}
diff --git a/server/providers/openrouter.go b/server/providers/openrouter.go
index 57a50b8..eb915a6 100644
--- a/server/providers/openrouter.go
+++ b/server/providers/openrouter.go
@@ -34,6 +34,11 @@ func (p *OpenRouterProvider) StreamCompletion(ctx context.Context, cfg ProviderC
return oai.StreamCompletion(ctx, cfg, req)
}
+func (p *OpenRouterProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
+ oai := &OpenAIProvider{}
+ return oai.Embed(ctx, cfg, req)
+}
+
// ── List Models ─────────────────────────────
// OpenRouter /v1/models returns architecture, pricing, context_length.
diff --git a/server/providers/provider.go b/server/providers/provider.go
index f01bf82..ffc4917 100644
--- a/server/providers/provider.go
+++ b/server/providers/provider.go
@@ -3,10 +3,15 @@ package providers
import (
"context"
"encoding/json"
+ "errors"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
+// ── Errors ─────────────────────────────────
+
+var ErrNotSupported = errors.New("operation not supported by this provider")
+
// ── Provider Interface ──────────────────────
// Provider is the contract for LLM API adapters.
@@ -23,6 +28,10 @@ type Provider interface {
// ListModels returns available models from this provider.
ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error)
+
+ // Embed generates embeddings for the given input texts.
+ // Returns ErrNotSupported if the provider has no embedding endpoint.
+ Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error)
}
// ── Configuration ───────────────────────────
@@ -88,23 +97,44 @@ type CompletionRequest struct {
// CompletionResponse is the normalized non-streaming response.
type CompletionResponse struct {
- Content string `json:"content"`
- Model string `json:"model"`
- FinishReason string `json:"finish_reason"`
- InputTokens int `json:"input_tokens"`
- OutputTokens int `json:"output_tokens"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls"
+ Content string `json:"content"`
+ Model string `json:"model"`
+ FinishReason string `json:"finish_reason"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
+ CacheReadTokens int `json:"cache_read_tokens,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls"
}
// StreamEvent is a single chunk from a streaming response.
type StreamEvent struct {
- Delta string `json:"delta,omitempty"`
- Reasoning string `json:"reasoning,omitempty"`
- Done bool `json:"done,omitempty"`
- FinishReason string `json:"finish_reason,omitempty"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- Model string `json:"model,omitempty"`
- Error error `json:"-"`
+ Delta string `json:"delta,omitempty"`
+ Reasoning string `json:"reasoning,omitempty"`
+ Done bool `json:"done,omitempty"`
+ FinishReason string `json:"finish_reason,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ Model string `json:"model,omitempty"`
+ Error error `json:"-"`
+ InputTokens int `json:"input_tokens,omitempty"`
+ OutputTokens int `json:"output_tokens,omitempty"`
+ CacheCreationTokens int `json:"cache_creation_tokens,omitempty"`
+ CacheReadTokens int `json:"cache_read_tokens,omitempty"`
+}
+
+// ── Embedding Types ────────────────────────
+
+// EmbeddingRequest is the normalized embedding request.
+type EmbeddingRequest struct {
+ Model string `json:"model"`
+ Input []string `json:"input"` // batch of texts to embed
+}
+
+// EmbeddingResponse is the normalized embedding response.
+type EmbeddingResponse struct {
+ Embeddings [][]float64 `json:"embeddings"`
+ Model string `json:"model"`
+ InputTokens int `json:"input_tokens"`
}
// Model represents an available model from a provider, with capabilities.
diff --git a/server/providers/venice.go b/server/providers/venice.go
index 16d9a38..9574163 100644
--- a/server/providers/venice.go
+++ b/server/providers/venice.go
@@ -32,6 +32,11 @@ func (p *VeniceProvider) StreamCompletion(ctx context.Context, cfg ProviderConfi
return oai.StreamCompletion(ctx, cfg, req)
}
+func (p *VeniceProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) {
+ oai := &OpenAIProvider{}
+ return oai.Embed(ctx, cfg, req)
+}
+
// ── List Models ─────────────────────────────
// Venice /v1/models returns model_spec with capabilities, pricing,
// context tokens, and traits. We parse all of it.
diff --git a/server/roles/roles.go b/server/roles/roles.go
new file mode 100644
index 0000000..6efa829
--- /dev/null
+++ b/server/roles/roles.go
@@ -0,0 +1,342 @@
+package roles
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+
+ "git.gobha.me/xcaliber/chat-switchboard/crypto"
+ "git.gobha.me/xcaliber/chat-switchboard/providers"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+// ── Known Roles ────────────────────────────
+
+const (
+ RoleUtility = "utility" // Internal tasks: summarization, title generation
+ RoleEmbedding = "embedding" // Vector embedding for knowledge bases
+ RoleGeneration = "generation" // Primary chat generation (future routing)
+)
+
+// ValidRoles lists all recognized role names.
+var ValidRoles = []string{RoleUtility, RoleEmbedding, RoleGeneration}
+
+// IsValidRole returns true if the given role name is recognized.
+func IsValidRole(role string) bool {
+ for _, r := range ValidRoles {
+ if r == role {
+ return true
+ }
+ }
+ return false
+}
+
+// ── Types ──────────────────────────────────
+
+// RoleBinding pairs a provider config with a specific model.
+type RoleBinding struct {
+ ProviderConfigID string `json:"provider_config_id"`
+ ModelID string `json:"model_id"`
+}
+
+// RoleConfig holds primary and fallback bindings for a role slot.
+type RoleConfig struct {
+ Primary *RoleBinding `json:"primary"`
+ Fallback *RoleBinding `json:"fallback"`
+}
+
+// CompletionResult wraps a completion response with usage metadata.
+type CompletionResult struct {
+ Content string
+ Model string
+ ProviderID string
+ ConfigID string
+ ProviderScope string
+ InputTokens int
+ OutputTokens int
+ CacheCreationTokens int
+ CacheReadTokens int
+ Role string
+ UsedFallback bool
+}
+
+// EmbeddingResult wraps an embedding response with usage metadata.
+type EmbeddingResult struct {
+ Embeddings [][]float64
+ Model string
+ ProviderID string
+ ConfigID string
+ ProviderScope string
+ InputTokens int
+ Role string
+ UsedFallback bool
+}
+
+// ── Errors ─────────────────────────────────
+
+var (
+ ErrRoleNotConfigured = fmt.Errorf("role not configured")
+ ErrNoPrimary = fmt.Errorf("no primary binding for role")
+)
+
+// ── Resolver ───────────────────────────────
+
+// Resolver resolves named model roles to provider+model pairs and
+// executes completions/embeddings against them. It handles the full
+// pipeline: config lookup → key decryption → provider dispatch → fallback.
+type Resolver struct {
+ stores store.Stores
+ vault *crypto.KeyResolver
+}
+
+// NewResolver creates a role resolver with access to stores and vault.
+func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver {
+ return &Resolver{stores: s, vault: vault}
+}
+
+// Complete sends a chat completion using the named role.
+// Resolution: team override → global config → try primary → fallback on error.
+func (r *Resolver) Complete(ctx context.Context, role string, teamID *string, messages []providers.Message) (*CompletionResult, error) {
+ cfg, err := r.GetConfig(ctx, role, teamID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Try primary
+ if cfg.Primary != nil {
+ result, err := r.doComplete(ctx, role, cfg.Primary, messages)
+ if err == nil {
+ return result, nil
+ }
+ log.Printf("⚠ Role %q primary failed: %v", role, err)
+ }
+
+ // Try fallback
+ if cfg.Fallback != nil {
+ result, err := r.doComplete(ctx, role, cfg.Fallback, messages)
+ if err == nil {
+ result.UsedFallback = true
+ return result, nil
+ }
+ return nil, fmt.Errorf("role %q: both primary and fallback failed: %w", role, err)
+ }
+
+ if cfg.Primary == nil {
+ return nil, fmt.Errorf("%w: %s", ErrNoPrimary, role)
+ }
+ return nil, fmt.Errorf("role %q: primary failed with no fallback configured", role)
+}
+
+// Embed generates embeddings using the named role.
+func (r *Resolver) Embed(ctx context.Context, role string, teamID *string, input []string) (*EmbeddingResult, error) {
+ cfg, err := r.GetConfig(ctx, role, teamID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Try primary
+ if cfg.Primary != nil {
+ result, err := r.doEmbed(ctx, role, cfg.Primary, input)
+ if err == nil {
+ return result, nil
+ }
+ log.Printf("⚠ Role %q primary embed failed: %v", role, err)
+ }
+
+ // Try fallback
+ if cfg.Fallback != nil {
+ result, err := r.doEmbed(ctx, role, cfg.Fallback, input)
+ if err == nil {
+ result.UsedFallback = true
+ return result, nil
+ }
+ return nil, fmt.Errorf("role %q: both primary and fallback embed failed: %w", role, err)
+ }
+
+ if cfg.Primary == nil {
+ return nil, fmt.Errorf("%w: %s", ErrNoPrimary, role)
+ }
+ return nil, fmt.Errorf("role %q: primary embed failed with no fallback", role)
+}
+
+// GetConfig returns the resolved role config for the given role name.
+// Team overrides take precedence over global config.
+func (r *Resolver) GetConfig(ctx context.Context, role string, teamID *string) (*RoleConfig, error) {
+ if !IsValidRole(role) {
+ return nil, fmt.Errorf("unknown role: %q", role)
+ }
+
+ // Check team override first
+ if teamID != nil && *teamID != "" {
+ teamCfg, err := r.getTeamRoleConfig(ctx, *teamID, role)
+ if err == nil && teamCfg != nil && (teamCfg.Primary != nil || teamCfg.Fallback != nil) {
+ return teamCfg, nil
+ }
+ }
+
+ // Fall back to global
+ return r.getGlobalRoleConfig(ctx, role)
+}
+
+// IsConfigured returns true if the named role has at least a primary binding.
+func (r *Resolver) IsConfigured(ctx context.Context, role string) bool {
+ cfg, err := r.GetConfig(ctx, role, nil)
+ return err == nil && cfg != nil && cfg.Primary != nil
+}
+
+// ── Internal: Completion ───────────────────
+
+func (r *Resolver) doComplete(ctx context.Context, role string, binding *RoleBinding, messages []providers.Message) (*CompletionResult, error) {
+ prov, provCfg, scope, err := r.resolveBinding(ctx, binding)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := prov.ChatCompletion(ctx, provCfg, providers.CompletionRequest{
+ Model: binding.ModelID,
+ Messages: messages,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &CompletionResult{
+ Content: resp.Content,
+ Model: resp.Model,
+ ProviderID: prov.ID(),
+ ConfigID: binding.ProviderConfigID,
+ ProviderScope: scope,
+ InputTokens: resp.InputTokens,
+ OutputTokens: resp.OutputTokens,
+ CacheCreationTokens: resp.CacheCreationTokens,
+ CacheReadTokens: resp.CacheReadTokens,
+ Role: role,
+ }, nil
+}
+
+// ── Internal: Embedding ────────────────────
+
+func (r *Resolver) doEmbed(ctx context.Context, role string, binding *RoleBinding, input []string) (*EmbeddingResult, error) {
+ prov, provCfg, scope, err := r.resolveBinding(ctx, binding)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := prov.Embed(ctx, provCfg, providers.EmbeddingRequest{
+ Model: binding.ModelID,
+ Input: input,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &EmbeddingResult{
+ Embeddings: resp.Embeddings,
+ Model: resp.Model,
+ ProviderID: prov.ID(),
+ ConfigID: binding.ProviderConfigID,
+ ProviderScope: scope,
+ InputTokens: resp.InputTokens,
+ Role: role,
+ }, nil
+}
+
+// ── Internal: Provider Resolution ──────────
+
+// resolveBinding loads a provider config, decrypts the API key, and returns
+// a ready-to-use Provider + ProviderConfig pair + scope.
+func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (providers.Provider, providers.ProviderConfig, string, error) {
+ cfg, err := r.stores.Providers.GetByID(ctx, binding.ProviderConfigID)
+ if err != nil {
+ return nil, providers.ProviderConfig{}, "", fmt.Errorf("load provider config %s: %w", binding.ProviderConfigID, err)
+ }
+
+ prov, err := providers.Get(cfg.Provider)
+ if err != nil {
+ return nil, providers.ProviderConfig{}, "", fmt.Errorf("unknown provider %s: %w", cfg.Provider, err)
+ }
+
+ // Decrypt API key
+ apiKey := ""
+ if len(cfg.APIKeyEnc) > 0 && r.vault != nil {
+ apiKey, err = r.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
+ if err != nil {
+ return nil, providers.ProviderConfig{}, "", fmt.Errorf("decrypt API key: %w", err)
+ }
+ }
+
+ // Parse headers
+ headers := make(map[string]string)
+ if cfg.Headers != nil {
+ for k, v := range cfg.Headers {
+ if s, ok := v.(string); ok {
+ headers[k] = s
+ }
+ }
+ }
+
+ return prov, providers.ProviderConfig{
+ Endpoint: cfg.Endpoint,
+ APIKey: apiKey,
+ CustomHeaders: headers,
+ }, cfg.Scope, nil
+}
+
+// ── Internal: Config Loading ───────────────
+
+func (r *Resolver) getGlobalRoleConfig(ctx context.Context, role string) (*RoleConfig, error) {
+ allRoles, err := r.stores.GlobalConfig.Get(ctx, "model_roles")
+ if err != nil {
+ return nil, fmt.Errorf("load global model_roles: %w", err)
+ }
+
+ roleData, ok := allRoles[role]
+ if !ok {
+ return nil, fmt.Errorf("%w: %s (not in global settings)", ErrRoleNotConfigured, role)
+ }
+
+ return parseRoleConfig(roleData)
+}
+
+func (r *Resolver) getTeamRoleConfig(ctx context.Context, teamID, role string) (*RoleConfig, error) {
+ team, err := r.stores.Teams.GetByID(ctx, teamID)
+ if err != nil {
+ return nil, err
+ }
+
+ settings := team.Settings
+ if settings == nil {
+ return nil, nil
+ }
+
+ rolesRaw, ok := settings["model_roles"]
+ if !ok {
+ return nil, nil
+ }
+
+ rolesMap, ok := rolesRaw.(map[string]interface{})
+ if !ok {
+ return nil, nil
+ }
+
+ roleData, ok := rolesMap[role]
+ if !ok {
+ return nil, nil
+ }
+
+ return parseRoleConfig(roleData)
+}
+
+// parseRoleConfig converts an interface{} (from JSONB) into a typed RoleConfig.
+func parseRoleConfig(data interface{}) (*RoleConfig, error) {
+ b, err := json.Marshal(data)
+ if err != nil {
+ return nil, err
+ }
+ var cfg RoleConfig
+ if err := json.Unmarshal(b, &cfg); err != nil {
+ return nil, err
+ }
+ return &cfg, nil
+}
diff --git a/server/store/interfaces.go b/server/store/interfaces.go
index a26ee86..ebaeccf 100644
--- a/server/store/interfaces.go
+++ b/server/store/interfaces.go
@@ -30,6 +30,8 @@ type Stores struct {
Audit AuditStore
Notes NoteStore
GlobalConfig GlobalConfigStore
+ Usage UsageStore
+ Pricing PricingStore
}
// =========================================
@@ -276,6 +278,41 @@ type GlobalConfigStore interface {
GetAll(ctx context.Context) (map[string]models.JSONMap, error)
}
+// =========================================
+// USAGE STORE
+// =========================================
+
+type UsageStore interface {
+ Log(ctx context.Context, entry *models.UsageEntry) error
+ QueryByUser(ctx context.Context, userID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
+ QueryByTeam(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
+ QueryByTeamProviders(ctx context.Context, teamID string, opts UsageQueryOptions) ([]models.UsageAggregate, error)
+ QueryByModel(ctx context.Context, opts UsageQueryOptions) ([]models.UsageAggregate, error)
+ GetTotals(ctx context.Context, opts UsageQueryOptions) (*models.UsageTotals, error)
+ GetTeamProviderTotals(ctx context.Context, teamID string, opts UsageQueryOptions) (*models.UsageTotals, error)
+ GetPersonalTotals(ctx context.Context, userID string, opts UsageQueryOptions) (*models.UsageTotals, error)
+}
+
+type UsageQueryOptions struct {
+ Since *time.Time
+ Until *time.Time
+ GroupBy string // "day", "model", "user", "provider"
+ ExcludeBYOK bool // true for admin views — filters provider_scope != 'personal'
+ Limit int
+}
+
+// =========================================
+// PRICING STORE
+// =========================================
+
+type PricingStore interface {
+ GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error)
+ Upsert(ctx context.Context, entry *models.PricingEntry) error
+ UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error
+ List(ctx context.Context) ([]models.PricingEntry, error)
+ Delete(ctx context.Context, providerConfigID, modelID string) error
+}
+
// =========================================
// SHARED TYPES
// =========================================
diff --git a/server/store/postgres/pricing.go b/server/store/postgres/pricing.go
new file mode 100644
index 0000000..cf9578b
--- /dev/null
+++ b/server/store/postgres/pricing.go
@@ -0,0 +1,131 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+)
+
+type PricingStore struct{}
+
+func NewPricingStore() *PricingStore { return &PricingStore{} }
+
+// GetForModel returns the pricing entry for a specific provider+model pair.
+func (s *PricingStore) GetForModel(ctx context.Context, providerConfigID, modelID string) (*models.PricingEntry, error) {
+ var p models.PricingEntry
+ err := DB.QueryRowContext(ctx, `
+ SELECT id, provider_config_id, model_id,
+ input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
+ currency, source, updated_at, updated_by
+ FROM model_pricing
+ WHERE provider_config_id = $1 AND model_id = $2
+ `, providerConfigID, modelID).Scan(
+ &p.ID, &p.ProviderConfigID, &p.ModelID,
+ &p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
+ &p.Currency, &p.Source, &p.UpdatedAt, &p.UpdatedBy,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return &p, err
+}
+
+// Upsert inserts or updates a pricing entry (manual admin override).
+func (s *PricingStore) Upsert(ctx context.Context, entry *models.PricingEntry) error {
+ _, err := DB.ExecContext(ctx, `
+ INSERT INTO model_pricing (
+ provider_config_id, model_id,
+ input_per_m, output_per_m, cache_create_per_m, cache_read_per_m,
+ currency, source, updated_by, updated_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
+ ON CONFLICT (provider_config_id, model_id)
+ DO UPDATE SET
+ input_per_m = EXCLUDED.input_per_m,
+ output_per_m = EXCLUDED.output_per_m,
+ cache_create_per_m = EXCLUDED.cache_create_per_m,
+ cache_read_per_m = EXCLUDED.cache_read_per_m,
+ currency = EXCLUDED.currency,
+ source = EXCLUDED.source,
+ updated_by = EXCLUDED.updated_by,
+ updated_at = NOW()
+ `,
+ entry.ProviderConfigID, entry.ModelID,
+ entry.InputPerM, entry.OutputPerM, entry.CacheCreatePerM, entry.CacheReadPerM,
+ entry.Currency, entry.Source, entry.UpdatedBy,
+ )
+ return err
+}
+
+// UpsertFromCatalog inserts or updates pricing from a catalog sync.
+// Manual overrides (source='manual') are never overwritten.
+func (s *PricingStore) UpsertFromCatalog(ctx context.Context, providerConfigID, modelID string, pricing *models.ModelPricing) error {
+ if pricing == nil || (pricing.InputPerM == 0 && pricing.OutputPerM == 0) {
+ return nil // No pricing to store
+ }
+
+ currency := pricing.Currency
+ if currency == "" {
+ currency = "USD"
+ }
+
+ _, err := DB.ExecContext(ctx, `
+ INSERT INTO model_pricing (
+ provider_config_id, model_id,
+ input_per_m, output_per_m,
+ currency, source, updated_at
+ ) VALUES ($1, $2, $3, $4, $5, 'catalog', NOW())
+ ON CONFLICT (provider_config_id, model_id)
+ DO UPDATE SET
+ input_per_m = EXCLUDED.input_per_m,
+ output_per_m = EXCLUDED.output_per_m,
+ currency = EXCLUDED.currency,
+ updated_at = NOW()
+ WHERE model_pricing.source = 'catalog'
+ `,
+ providerConfigID, modelID,
+ pricing.InputPerM, pricing.OutputPerM,
+ currency,
+ )
+ return err
+}
+
+// List returns pricing entries for admin-managed providers (global + team).
+// Excludes personal BYOK providers — those are not the admin's concern.
+func (s *PricingStore) List(ctx context.Context) ([]models.PricingEntry, error) {
+ rows, err := DB.QueryContext(ctx, `
+ SELECT mp.id, mp.provider_config_id, mp.model_id,
+ mp.input_per_m, mp.output_per_m, mp.cache_create_per_m, mp.cache_read_per_m,
+ mp.currency, mp.source, mp.updated_at, mp.updated_by
+ FROM model_pricing mp
+ JOIN provider_configs pc ON pc.id = mp.provider_config_id
+ WHERE pc.scope != 'personal'
+ ORDER BY mp.provider_config_id, mp.model_id
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []models.PricingEntry
+ for rows.Next() {
+ var p models.PricingEntry
+ if err := rows.Scan(
+ &p.ID, &p.ProviderConfigID, &p.ModelID,
+ &p.InputPerM, &p.OutputPerM, &p.CacheCreatePerM, &p.CacheReadPerM,
+ &p.Currency, &p.Source, &p.UpdatedAt, &p.UpdatedBy,
+ ); err != nil {
+ return nil, err
+ }
+ results = append(results, p)
+ }
+ return results, rows.Err()
+}
+
+// Delete removes a pricing entry.
+func (s *PricingStore) Delete(ctx context.Context, providerConfigID, modelID string) error {
+ _, err := DB.ExecContext(ctx, `
+ DELETE FROM model_pricing WHERE provider_config_id = $1 AND model_id = $2
+ `, providerConfigID, modelID)
+ return err
+}
diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go
index c7e4992..c3e23bc 100644
--- a/server/store/postgres/stores.go
+++ b/server/store/postgres/stores.go
@@ -23,5 +23,7 @@ func NewStores(db *sql.DB) store.Stores {
Audit: NewAuditStore(),
Notes: NewNoteStore(),
GlobalConfig: NewGlobalConfigStore(),
+ Usage: NewUsageStore(),
+ Pricing: NewPricingStore(),
}
}
diff --git a/server/store/postgres/usage.go b/server/store/postgres/usage.go
new file mode 100644
index 0000000..5974614
--- /dev/null
+++ b/server/store/postgres/usage.go
@@ -0,0 +1,220 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "git.gobha.me/xcaliber/chat-switchboard/models"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+type UsageStore struct{}
+
+func NewUsageStore() *UsageStore { return &UsageStore{} }
+
+// Log inserts a usage entry.
+func (s *UsageStore) Log(ctx context.Context, entry *models.UsageEntry) error {
+ _, err := DB.ExecContext(ctx, `
+ INSERT INTO usage_log (
+ channel_id, user_id, provider_config_id, provider_scope,
+ model_id, role,
+ prompt_tokens, completion_tokens,
+ cache_creation_tokens, cache_read_tokens,
+ cost_input, cost_output
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ `,
+ entry.ChannelID, entry.UserID, entry.ProviderConfigID, entry.ProviderScope,
+ entry.ModelID, entry.Role,
+ entry.PromptTokens, entry.CompletionTokens,
+ entry.CacheCreationTokens, entry.CacheReadTokens,
+ entry.CostInput, entry.CostOutput,
+ )
+ return err
+}
+
+// QueryByUser returns aggregated usage for a specific user.
+func (s *UsageStore) QueryByUser(ctx context.Context, userID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
+ where := []string{"user_id = $1"}
+ args := []interface{}{userID}
+ return s.query(ctx, where, args, opts)
+}
+
+// QueryByTeam returns aggregated usage for all members of a team (admin view).
+func (s *UsageStore) QueryByTeam(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
+ where := []string{"user_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"}
+ args := []interface{}{teamID}
+ return s.query(ctx, where, args, opts)
+}
+
+// QueryByTeamProviders returns usage against providers owned by this team.
+// Used by team admins to see how their team's API keys are being consumed.
+func (s *UsageStore) QueryByTeamProviders(ctx context.Context, teamID string, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
+ where := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = $1)"}
+ args := []interface{}{teamID}
+ return s.query(ctx, where, args, opts)
+}
+
+// QueryByModel returns aggregated usage grouped by model.
+func (s *UsageStore) QueryByModel(ctx context.Context, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
+ return s.query(ctx, nil, nil, opts)
+}
+
+// GetTotals returns aggregate totals (admin view — excludes BYOK by default).
+func (s *UsageStore) GetTotals(ctx context.Context, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
+ where, args := s.buildFilters(nil, nil, opts)
+
+ q := fmt.Sprintf(`
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(prompt_tokens), 0),
+ COALESCE(SUM(completion_tokens), 0),
+ COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
+ FROM usage_log
+ WHERE %s
+ `, strings.Join(where, " AND "))
+
+ var t models.UsageTotals
+ err := DB.QueryRowContext(ctx, q, args...).Scan(
+ &t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
+ )
+ return &t, err
+}
+
+// GetTeamProviderTotals returns aggregate totals for providers owned by a team.
+func (s *UsageStore) GetTeamProviderTotals(ctx context.Context, teamID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
+ baseWhere := []string{"provider_config_id IN (SELECT id FROM provider_configs WHERE scope = 'team' AND owner_id = $1)"}
+ baseArgs := []interface{}{teamID}
+ where, args := s.buildFilters(baseWhere, baseArgs, opts)
+
+ q := fmt.Sprintf(`
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(prompt_tokens), 0),
+ COALESCE(SUM(completion_tokens), 0),
+ COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
+ FROM usage_log
+ WHERE %s
+ `, strings.Join(where, " AND "))
+
+ var t models.UsageTotals
+ err := DB.QueryRowContext(ctx, q, args...).Scan(
+ &t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
+ )
+ return &t, err
+}
+
+// GetPersonalTotals returns aggregate totals for a specific user (all scopes).
+func (s *UsageStore) GetPersonalTotals(ctx context.Context, userID string, opts store.UsageQueryOptions) (*models.UsageTotals, error) {
+ baseWhere := []string{"user_id = $1"}
+ baseArgs := []interface{}{userID}
+ // Don't exclude BYOK for personal view
+ opts.ExcludeBYOK = false
+ where, args := s.buildFilters(baseWhere, baseArgs, opts)
+
+ q := fmt.Sprintf(`
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(prompt_tokens), 0),
+ COALESCE(SUM(completion_tokens), 0),
+ COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0)
+ FROM usage_log
+ WHERE %s
+ `, strings.Join(where, " AND "))
+
+ var t models.UsageTotals
+ err := DB.QueryRowContext(ctx, q, args...).Scan(
+ &t.Requests, &t.InputTokens, &t.OutputTokens, &t.TotalCost,
+ )
+ return &t, err
+}
+
+// ── Internal ───────────────────────────────
+
+func (s *UsageStore) buildFilters(baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]string, []interface{}) {
+ where := append([]string{}, baseWhere...)
+ args := append([]interface{}{}, baseArgs...)
+ idx := len(args) + 1
+
+ if opts.ExcludeBYOK {
+ where = append(where, fmt.Sprintf("provider_scope != $%d", idx))
+ args = append(args, "personal")
+ idx++
+ }
+ if opts.Since != nil {
+ where = append(where, fmt.Sprintf("created_at >= $%d", idx))
+ args = append(args, *opts.Since)
+ idx++
+ }
+ if opts.Until != nil {
+ where = append(where, fmt.Sprintf("created_at < $%d", idx))
+ args = append(args, *opts.Until)
+ idx++
+ }
+
+ if len(where) == 0 {
+ where = append(where, "1=1")
+ }
+ return where, args
+}
+
+func (s *UsageStore) query(ctx context.Context, baseWhere []string, baseArgs []interface{}, opts store.UsageQueryOptions) ([]models.UsageAggregate, error) {
+ where, args := s.buildFilters(baseWhere, baseArgs, opts)
+
+ // Determine GROUP BY expression and label
+ groupExpr := "model_id"
+ labelExpr := "model_id"
+ switch opts.GroupBy {
+ case "day":
+ groupExpr = "DATE(created_at)"
+ labelExpr = "DATE(created_at)::text"
+ case "user":
+ groupExpr = "user_id"
+ labelExpr = "user_id::text"
+ case "provider":
+ groupExpr = "provider_config_id"
+ labelExpr = "provider_config_id::text"
+ case "model":
+ groupExpr = "model_id"
+ labelExpr = "model_id"
+ default:
+ groupExpr = "model_id"
+ labelExpr = "model_id"
+ }
+
+ limit := opts.Limit
+ if limit <= 0 {
+ limit = 100
+ }
+
+ q := fmt.Sprintf(`
+ SELECT
+ %s AS group_key,
+ %s AS label,
+ COUNT(*) AS requests,
+ COALESCE(SUM(prompt_tokens), 0) AS input_tokens,
+ COALESCE(SUM(completion_tokens), 0) AS output_tokens,
+ COALESCE(SUM(COALESCE(cost_input, 0) + COALESCE(cost_output, 0)), 0) AS total_cost
+ FROM usage_log
+ WHERE %s
+ GROUP BY %s
+ ORDER BY total_cost DESC
+ LIMIT %d
+ `, groupExpr, labelExpr, strings.Join(where, " AND "), groupExpr, limit)
+
+ rows, err := DB.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var results []models.UsageAggregate
+ for rows.Next() {
+ var a models.UsageAggregate
+ if err := rows.Scan(&a.GroupKey, &a.Label, &a.Requests, &a.InputTokens, &a.OutputTokens, &a.TotalCost); err != nil {
+ return nil, err
+ }
+ results = append(results, a)
+ }
+ return results, rows.Err()
+}
diff --git a/src/index.html b/src/index.html
index 90bf6bb..a44a6cb 100644
--- a/src/index.html
+++ b/src/index.html
@@ -321,6 +321,7 @@
+
@@ -421,6 +422,26 @@
+
+
+
+ My Usage
+ Token consumption and estimated costs across all your conversations.
+
+
+
+
+
+
+
+
@@ -480,7 +501,25 @@
-
+
+
+ Usage
+
+
+
+
+
+
+
+
Activity Log
@@ -513,6 +552,8 @@
+
+
@@ -659,6 +700,37 @@
+
+
+
+
Configure system model roles. Each role has a primary and optional fallback model.
+
+
+
+
+
+
+
+
+
+
+
+
+
Model Pricing
+
+
+
diff --git a/src/js/api.js b/src/js/api.js
index cd44553..6729085 100644
--- a/src/js/api.js
+++ b/src/js/api.js
@@ -310,7 +310,7 @@ const API = {
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
- adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); },
+ adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
@@ -433,6 +433,60 @@ const API = {
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
+ // ── Admin Roles ─────────────────────────
+ adminListRoles() { return this._get('/api/v1/admin/roles'); },
+ adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
+ adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
+ adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
+
+ // ── Admin Usage & Pricing ───────────────
+ adminGetUsage(params) {
+ const q = new URLSearchParams();
+ if (params?.period) q.set('period', params.period);
+ if (params?.since) q.set('since', params.since);
+ if (params?.until) q.set('until', params.until);
+ if (params?.group_by) q.set('group_by', params.group_by);
+ return this._get(`/api/v1/admin/usage?${q}`);
+ },
+ adminGetUserUsage(userId, params) {
+ const q = new URLSearchParams();
+ if (params?.period) q.set('period', params.period);
+ if (params?.group_by) q.set('group_by', params.group_by);
+ return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
+ },
+ adminGetTeamUsage(teamId, params) {
+ const q = new URLSearchParams();
+ if (params?.period) q.set('period', params.period);
+ if (params?.group_by) q.set('group_by', params.group_by);
+ return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
+ },
+ adminListPricing() { return this._get('/api/v1/admin/pricing'); },
+ adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
+ adminDeletePricing(providerConfigId, modelId) {
+ return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
+ },
+
+ // ── User Usage ──────────────────────────
+ getMyUsage(params) {
+ const q = new URLSearchParams();
+ if (params?.period) q.set('period', params.period);
+ if (params?.group_by) q.set('group_by', params.group_by);
+ return this._get(`/api/v1/usage?${q}`);
+ },
+
+ // ── Team Roles ──────────────────────────
+ teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
+ teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
+ teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
+
+ // ── Team Usage ──────────────────────────
+ teamGetUsage(teamId, params) {
+ const q = new URLSearchParams();
+ if (params?.period) q.set('period', params.period);
+ if (params?.group_by) q.set('group_by', params.group_by);
+ return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
+ },
+
// ── HTTP Internals ───────────────────────
_esc(s) {
diff --git a/src/js/app.js b/src/js/app.js
index 08a1ecf..746bb9f 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -1381,6 +1381,11 @@ function initListeners() {
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
+ // Admin — usage
+ document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
+ document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
+ document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
+
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
@@ -1807,6 +1812,23 @@ async function createAdminUser() {
} catch (e) { UI.toast(e.message, 'error'); }
}
+async function adminResetUserPassword(id, username) {
+ const pw = prompt(
+ `Reset password for "${username}"?\n\n` +
+ `⚠️ WARNING: This will DESTROY the user's personal vault.\n` +
+ `All personal API keys (BYOK) will be permanently deleted.\n` +
+ `The user will need to re-add any personal provider keys.\n\n` +
+ `Enter new password (min 8 chars):`
+ );
+ if (!pw) return;
+ if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
+ if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
+ try {
+ await API.adminResetPassword(id, pw);
+ UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
+ } catch (e) { UI.toast(e.message, 'error'); }
+}
+
async function deleteGlobalProvider(id) {
if (!confirm('Delete this global provider?')) return;
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
@@ -1905,6 +1927,59 @@ async function bulkSetVisibility(visibility) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
+// ── Admin Roles ────────────────────────────
+
+async function adminRoleProviderChanged(role, slot) {
+ const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value;
+ const modelSelect = document.getElementById(`role-${role}-${slot}-model`);
+ if (!modelSelect) return;
+
+ modelSelect.innerHTML = '
';
+ if (!provId || !window._adminModelList) return;
+
+ const models = window._adminModelList.filter(m => m.provider_config_id === provId);
+ models.forEach(m => {
+ const opt = document.createElement('option');
+ opt.value = m.model_id;
+ opt.textContent = m.display_name || m.model_id;
+ modelSelect.appendChild(opt);
+ });
+}
+
+async function adminSaveRole(role) {
+ const status = document.getElementById(`role-${role}-status`);
+ try {
+ const getBinding = (slot) => {
+ const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value;
+ const model = document.getElementById(`role-${role}-${slot}-model`)?.value;
+ return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
+ };
+ await API.adminUpdateRole(role, {
+ primary: getBinding('primary'),
+ fallback: getBinding('fallback')
+ });
+ if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; }
+ setTimeout(() => { if (status) status.textContent = ''; }, 3000);
+ } catch (e) {
+ if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
+ }
+}
+
+async function adminTestRole(role) {
+ const status = document.getElementById(`role-${role}-status`);
+ if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; }
+ try {
+ const result = await API.adminTestRole(role);
+ if (status) {
+ const fb = result.used_fallback ? ' (fallback)' : '';
+ status.textContent = `✓ ${result.model}${fb}`;
+ status.style.color = 'var(--success)';
+ }
+ } catch (e) {
+ if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
+ }
+}
+
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) {
diff --git a/src/js/ui.js b/src/js/ui.js
index 5f107ed..0c75e5d 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -843,6 +843,7 @@ const UI = {
UI.checkUserProvidersAllowed();
}
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
+ if (tab === 'usage') UI.loadMyUsage();
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'teams') UI.loadTeamsTab();
},
@@ -1000,6 +1001,7 @@ const UI = {
UI.loadTeamManageMembers(teamId),
UI.loadTeamManageProviders(teamId),
UI.loadTeamManagePresets(teamId),
+ UI.loadTeamUsage(),
UI.loadTeamAuditLog(1),
]);
},
@@ -1083,6 +1085,111 @@ const UI = {
} catch (e) { el.innerHTML = `
${esc(e.message)}
`; }
},
+ async loadMyUsage() {
+ const totalsEl = document.getElementById('myUsageTotals');
+ const resultsEl = document.getElementById('myUsageResults');
+ if (!totalsEl) return;
+
+ const period = document.getElementById('myUsagePeriod')?.value || '30d';
+ const groupBy = document.getElementById('myUsageGroupBy')?.value || 'model';
+
+ totalsEl.innerHTML = '
Loading...
';
+ resultsEl.innerHTML = '';
+
+ try {
+ const data = await API.getMyUsage({ period, group_by: groupBy });
+ const t = data.totals || {};
+
+ if (!t.requests) {
+ totalsEl.innerHTML = '
No usage recorded in this period
';
+ return;
+ }
+
+ totalsEl.innerHTML = `
+
+
${(t.requests || 0).toLocaleString()}
Requests
+
${(t.input_tokens || 0).toLocaleString()}
Input
+
${(t.output_tokens || 0).toLocaleString()}
Output
+
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
+
`;
+
+ const results = data.results || [];
+ if (results.length > 0) {
+ resultsEl.innerHTML = `
+
+
+ | ${groupBy === 'day' ? 'Date' : 'Model'} |
+ Reqs |
+ Input |
+ Output |
+ Cost |
+
+ ${results.map(r => `
+ | ${esc(r.label || r.group_key)} |
+ ${(r.requests || 0).toLocaleString()} |
+ ${(r.input_tokens || 0).toLocaleString()} |
+ ${(r.output_tokens || 0).toLocaleString()} |
+ $${(r.total_cost || 0).toFixed(4)} |
+
`).join('')}
+
`;
+ }
+ } catch (e) { totalsEl.innerHTML = `
${esc(e.message)}
`; }
+ },
+
+ async loadTeamUsage() {
+ const teamId = UI._managingTeamId;
+ if (!teamId) return;
+
+ const totalsEl = document.getElementById('settingsTeamUsageTotals');
+ const resultsEl = document.getElementById('settingsTeamUsageResults');
+ if (!totalsEl) return;
+
+ const period = document.getElementById('teamUsagePeriod')?.value || '30d';
+ const groupBy = document.getElementById('teamUsageGroupBy')?.value || 'model';
+
+ totalsEl.innerHTML = '
Loading...
';
+ resultsEl.innerHTML = '';
+
+ try {
+ const data = await API.teamGetUsage(teamId, { period, group_by: groupBy });
+ const t = data.totals || {};
+
+ if (!t.requests) {
+ totalsEl.innerHTML = '
No usage recorded for team providers in this period
';
+ return;
+ }
+
+ totalsEl.innerHTML = `
+
+
${(t.requests || 0).toLocaleString()}
Requests
+
${(t.input_tokens || 0).toLocaleString()}
Input
+
${(t.output_tokens || 0).toLocaleString()}
Output
+
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
+
`;
+
+ const results = data.results || [];
+ if (results.length > 0) {
+ resultsEl.innerHTML = `
+
+
+ | ${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'} |
+ Reqs |
+ Input |
+ Output |
+ Cost |
+
+ ${results.map(r => `
+ | ${esc(r.label || r.group_key)} |
+ ${(r.requests || 0).toLocaleString()} |
+ ${(r.input_tokens || 0).toLocaleString()} |
+ ${(r.output_tokens || 0).toLocaleString()} |
+ $${(r.total_cost || 0).toFixed(4)} |
+
`).join('')}
+
`;
+ }
+ } catch (e) { totalsEl.innerHTML = `
${esc(e.message)}
`; }
+ },
+
_teamAuditPage: 1,
_teamAuditPerPage: 20,
@@ -1253,6 +1360,8 @@ const UI = {
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
+ if (tab === 'roles') await this.loadAdminRoles();
+ if (tab === 'usage') await this.loadAdminUsage();
},
async loadAdminUsers(quiet) {
@@ -1280,6 +1389,7 @@ const UI = {
: `
`
}
+
@@ -1310,6 +1420,136 @@ const UI = {
} catch (e) { el.innerHTML = `${esc(e.message)}
`; }
},
+ // ── Admin: Roles ────────────────────────
+
+ async loadAdminRoles() {
+ const el = document.getElementById('adminRolesContent');
+ if (!el) return;
+ el.innerHTML = 'Loading...
';
+ try {
+ const roles = await API.adminListRoles();
+ const configs = await API.adminListGlobalConfigs();
+ const providers = configs.configs || configs || [];
+ const models = await API.adminListModels();
+ const modelList = models.models || models.data || models || [];
+
+ const roleNames = ['utility', 'embedding', 'generation'];
+ el.innerHTML = roleNames.map(role => {
+ const cfg = roles[role] || { primary: null, fallback: null };
+ return `
+
+
${role}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+ }).join('');
+
+ // Store models list for provider change handler
+ window._adminModelList = modelList;
+ } catch (e) { el.innerHTML = `${esc(e.message)}
`; }
+ },
+
+ // ── Admin: Usage ────────────────────────
+
+ async loadAdminUsage() {
+ const period = document.getElementById('usagePeriod')?.value || '30d';
+ const groupBy = document.getElementById('usageGroupBy')?.value || 'model';
+
+ const totalsEl = document.getElementById('adminUsageTotals');
+ const resultsEl = document.getElementById('adminUsageResults');
+ const pricingEl = document.getElementById('adminPricingTable');
+ if (!totalsEl) return;
+
+ totalsEl.innerHTML = 'Loading...
';
+ resultsEl.innerHTML = '';
+
+ try {
+ const data = await API.adminGetUsage({ period, group_by: groupBy });
+ const t = data.totals || {};
+
+ totalsEl.innerHTML = `
+
+
${(t.requests || 0).toLocaleString()}
Requests
+
${(t.input_tokens || 0).toLocaleString()}
Input Tokens
+
${(t.output_tokens || 0).toLocaleString()}
Output Tokens
+
$${(t.total_cost || 0).toFixed(4)}
Est. Cost
+
`;
+
+ const results = data.results || [];
+ if (results.length === 0) {
+ resultsEl.innerHTML = 'No usage data for this period
';
+ } else {
+ resultsEl.innerHTML = `
+
+
+ | ${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'} |
+ Requests |
+ Input |
+ Output |
+ Cost |
+
+ ${results.map(r => `
+ | ${esc(r.label || r.group_key)} |
+ ${(r.requests || 0).toLocaleString()} |
+ ${(r.input_tokens || 0).toLocaleString()} |
+ ${(r.output_tokens || 0).toLocaleString()} |
+ $${(r.total_cost || 0).toFixed(4)} |
+
`).join('')}
+
`;
+ }
+ } catch (e) { totalsEl.innerHTML = `${esc(e.message)}
`; }
+
+ // Load pricing table
+ try {
+ const pricing = await API.adminListPricing();
+ const entries = pricing || [];
+ if (entries.length === 0) {
+ pricingEl.innerHTML = 'No pricing configured. Sync providers to populate from catalog.
';
+ } else {
+ pricingEl.innerHTML = `
+
+
+ | Model |
+ Input $/M |
+ Output $/M |
+ Source |
+
+ ${entries.map(p => `
+ | ${esc(p.model_id)} |
+ ${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'} |
+ ${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'} |
+ ${p.source} |
+
`).join('')}
+
`;
+ }
+ } catch (e) { pricingEl.innerHTML = `${esc(e.message)}
`; }
+ },
+
_auditPage: 1,
_auditPerPage: 30,