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 @@ + + + + + + + + + + @@ -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 = ` + + + + + + + + + ${results.map(r => ` + + + + + + `).join('')} +
${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}RequestsInputOutputCost
${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)}
`; + } + } 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 = ` + + + + + + + + ${entries.map(p => ` + + + + + `).join('')} +
ModelInput $/MOutput $/MSource
${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}
`; + } + } catch (e) { pricingEl.innerHTML = `
${esc(e.message)}
`; } + }, + _auditPage: 1, _auditPerPage: 30,