Changeset 0.10.0 (#56)

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

View File

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

View File

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

View File

@@ -1 +1 @@
0.9.4
0.10.0

View File

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

View File

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

View File

@@ -20,12 +20,13 @@ import (
)
type AdminHandler struct {
stores store.Stores
vault *crypto.KeyResolver
stores store.Stores
vault *crypto.KeyResolver
uekCache *crypto.UEKCache
}
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver) *AdminHandler {
return &AdminHandler{stores: s, vault: vault}
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler {
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
}
// ── User Management ─────────────────────────
@@ -145,10 +146,50 @@ func (h *AdminHandler) ResetPassword(c *gin.Context) {
return
}
// Destroy vault — old UEK is unrecoverable with new password.
// A fresh vault will be initialized on the user's next login.
h.destroyVault(c, id)
h.auditLog(c, "user.password_reset", "user", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
}
// destroyVault nullifies a user's encrypted UEK and deletes their personal
// provider keys. Called when an admin resets a user's password — the old
// UEK cannot be recovered without the old password.
func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
if h.uekCache == nil {
return
}
// Null out vault columns — user gets a fresh vault on next login
_, err := database.DB.Exec(`
UPDATE users
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
WHERE id = $1
`, userID)
if err != nil {
log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
}
// Evict from session cache (if user is currently logged in)
h.uekCache.Evict(userID)
// Delete personal provider configs — their keys are undecryptable now
result, err := database.DB.Exec(`
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
`, userID)
if err != nil {
log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
} else if rows, _ := result.RowsAffected(); rows > 0 {
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
}
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
"reason": "admin_password_reset",
})
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {

View File

@@ -14,6 +14,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/store"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -34,13 +35,14 @@ type completionRequest struct {
}
// CompletionHandler proxies LLM requests through the backend.
type CompletionHandler struct{
vault *crypto.KeyResolver
type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
}
// NewCompletionHandler creates a new handler.
func NewCompletionHandler(vault *crypto.KeyResolver) *CompletionHandler {
return &CompletionHandler{vault: vault}
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores}
}
// ── Chat Completion ─────────────────────────
@@ -108,7 +110,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Resolve provider config
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, channelID, req)
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -179,9 +181,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
if stream {
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
} else {
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
}
}
@@ -211,16 +213,21 @@ func (h *CompletionHandler) streamCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
channelID, userID, model string,
channelID, userID, model, configID, providerScope string,
) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
// Persist assistant response
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, 0, 0, nil, toolActivityJSON(result.ToolActivity)); err != nil {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
// Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
}
// ── Non-Streaming Completion with Tool Loop ──
@@ -230,9 +237,10 @@ func (h *CompletionHandler) syncCompletion(
provider providers.Provider,
cfg providers.ProviderConfig,
req providers.CompletionRequest,
channelID, userID, model string,
channelID, userID, model, configID, providerScope string,
) {
var totalInput, totalOutput int
var totalCacheCreation, totalCacheRead int
var finalContent string
var allToolActivity []map[string]interface{}
@@ -245,6 +253,8 @@ func (h *CompletionHandler) syncCompletion(
totalInput += resp.InputTokens
totalOutput += resp.OutputTokens
totalCacheCreation += resp.CacheCreationTokens
totalCacheRead += resp.CacheReadTokens
// No tool calls — normal response
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
@@ -255,6 +265,10 @@ func (h *CompletionHandler) syncCompletion(
log.Printf("Failed to persist assistant message: %v", err)
}
// Log usage
h.logUsage(c, channelID, userID, configID, providerScope, model,
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
// Return OpenAI-compatible response
c.JSON(http.StatusOK, gin.H{
"model": resp.Model,
@@ -353,7 +367,7 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
// ── Config Resolution ───────────────────────
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) {
var configID string
// 1. Explicit config from request
@@ -384,32 +398,33 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
LIMIT 1
`, userID).Scan(&configID)
if err != nil {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
}
}
// Load the config — allow personal, global, OR team configs the user belongs to
var providerID, endpoint string
var providerScope string
var modelDefault *string
var apiKeyEnc, keyNonce []byte
var keyScope string
var customHeadersJSON, providerSettingsJSON []byte
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_enc, key_nonce, key_scope,
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
model_default, headers, settings
FROM provider_configs
WHERE id = $1 AND is_active = true
AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`, configID, userID).Scan(&providerID, &endpoint, &apiKeyEnc, &keyNonce, &keyScope,
`, configID, userID).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible")
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
}
if err != nil {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to load API config: %w", err)
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to load API config: %w", err)
}
// Resolve model: request > config default
@@ -418,7 +433,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
model = *modelDefault
}
if model == "" {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config")
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no model specified and no default model in config")
}
// Decrypt API key using the appropriate tier
@@ -429,9 +444,9 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
if err != nil {
if err == crypto.ErrVaultLocked {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("personal vault is locked — please log in again")
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("personal vault is locked — please log in again")
}
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
@@ -456,7 +471,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
}, providerID, model, configID, nil
}, providerID, model, configID, providerScope, nil
}
// ── Conversation Loader ─────────────────────
@@ -578,3 +593,51 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
return newID, nil
}
// ── Usage Logging ─────────────────────────
// logUsage records token usage and cost in the usage_log table.
// Always logs even if tokens are zero — the request happened.
func (h *CompletionHandler) logUsage(
c *gin.Context,
channelID, userID, configID, providerScope, modelID string,
inputTokens, outputTokens, cacheCreation, cacheRead int,
) {
if h.stores.Usage == nil {
return
}
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: providerScope,
ModelID: modelID,
PromptTokens: inputTokens,
CompletionTokens: outputTokens,
CacheCreationTokens: cacheCreation,
CacheReadTokens: cacheRead,
}
// Look up pricing for cost calculation
if h.stores.Pricing != nil {
pricing, err := h.stores.Pricing.GetForModel(c.Request.Context(), configID, modelID)
if err == nil && pricing != nil {
entry.CostInput = calcCost(inputTokens, pricing.InputPerM)
entry.CostOutput = calcCost(outputTokens, pricing.OutputPerM)
}
}
if err := h.stores.Usage.Log(c.Request.Context(), entry); err != nil {
log.Printf("⚠ Failed to log usage: %v", err)
}
}
// calcCost computes the cost for a given token count and price-per-million.
func calcCost(tokens int, pricePerM *float64) *float64 {
if pricePerM == nil || tokens == 0 {
return nil
}
cost := float64(tokens) * *pricePerM / 1_000_000.0
return &cost
}

View File

@@ -16,6 +16,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store/postgres"
)
@@ -42,6 +43,9 @@ func setupHarness(t *testing.T) *testHarness {
stores := postgres.NewStores(database.TestDB)
// Roles resolver (nil vault — test-fire won't work, but CRUD will)
roleResolver := roles.NewResolver(stores, nil)
r := gin.New()
api := r.Group("/api/v1")
@@ -52,7 +56,7 @@ func setupHarness(t *testing.T) *testHarness {
authGroup.POST("/login", auth.Login)
// Public settings
adm := NewAdminHandler(stores, nil)
adm := NewAdminHandler(stores, nil, nil)
api.GET("/settings/public", adm.PublicSettings)
// Protected routes
@@ -91,10 +95,24 @@ func setupHarness(t *testing.T) *testHarness {
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team usage
teamUsage := NewUsageHandler(stores)
teamScoped.GET("/usage", teamUsage.TeamUsage)
// Team roles
teamRoles := NewRolesHandler(stores, roleResolver)
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
}
// User usage
usage := NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
// Profile / Settings
settings := NewSettingsHandler()
settings := NewSettingsHandler(nil)
protected.GET("/profile", settings.GetProfile)
// Presets
@@ -116,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/channels", channels.CreateChannel)
// Completions
completions := NewCompletionHandler(nil)
completions := NewCompletionHandler(nil, stores)
protected.POST("/chat/completions", completions.Complete)
// Admin routes
@@ -143,6 +161,21 @@ func setupHarness(t *testing.T) *testHarness {
admin.GET("/presets", presets.ListAdminPersonas)
admin.POST("/presets", presets.CreateAdminPersona)
// Admin roles
rolesH := NewRolesHandler(stores, roleResolver)
admin.GET("/roles", rolesH.ListRoles)
admin.GET("/roles/:role", rolesH.GetRole)
admin.PUT("/roles/:role", rolesH.UpdateRole)
// Admin usage + pricing
usageH := NewUsageHandler(stores)
admin.GET("/usage", usageH.AdminUsage)
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
admin.GET("/pricing", usageH.ListPricing)
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
return &testHarness{router: r, t: t}
}
@@ -1561,3 +1594,551 @@ func TestUserJourney_FullMatrix(t *testing.T) {
}
})
}
// ═══════════════════════════════════════════
// ROLES TESTS
// ═══════════════════════════════════════════
func TestIntegration_Roles_ListReturnsSeeded(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
w := h.request("GET", "/api/v1/admin/roles", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list roles: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Migration 004 seeds utility, embedding, generation
for _, role := range []string{"utility", "embedding", "generation"} {
if _, ok := resp[role]; !ok {
t.Errorf("expected role %q in response, got: %v", role, resp)
}
}
}
func TestIntegration_Roles_UpdateAndGet(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create a global provider to reference
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "TestProvider", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
})
if w.Code != http.StatusCreated {
t.Fatalf("create config: %d: %s", w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
cfgID := cfg["id"].(string)
// Update utility role
w = h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
"primary": map[string]string{
"provider_config_id": cfgID,
"model_id": "gpt-4o-mini",
},
})
if w.Code != http.StatusOK {
t.Fatalf("update role: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify via list
w = h.request("GET", "/api/v1/admin/roles", adminToken, nil)
var allRoles map[string]interface{}
decode(w, &allRoles)
utilRaw, ok := allRoles["utility"]
if !ok {
t.Fatal("utility role missing after update")
}
utilMap, ok := utilRaw.(map[string]interface{})
if !ok {
t.Fatalf("utility role unexpected type: %T", utilRaw)
}
primary, _ := utilMap["primary"].(map[string]interface{})
if primary == nil {
t.Fatal("utility primary should be set")
}
if primary["model_id"] != "gpt-4o-mini" {
t.Errorf("utility primary model: want gpt-4o-mini, got %v", primary["model_id"])
}
}
func TestIntegration_Roles_InvalidRole400(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
w := h.request("GET", "/api/v1/admin/roles/nonexistent", adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid role: want 400, got %d", w.Code)
}
}
func TestIntegration_Roles_NonAdmin403(t *testing.T) {
h := setupHarness(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("user1", "user1@test.com", "password123")
w := h.request("GET", "/api/v1/admin/roles", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin roles: want 403, got %d", w.Code)
}
}
// ── Team Roles ────────────────────────────
func TestIntegration_TeamRoles_CRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create team admin user
teamAdminID := database.SeedTestUser(t, "teamlead", "teamlead@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
teamAdminToken := makeToken(teamAdminID, "teamlead@test.com", "user")
// Create team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
"name": "Eng", "description": "Engineering",
})
var team map[string]interface{}
decode(w, &team)
teamID := team["id"].(string)
// Add team admin
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
map[string]string{"user_id": teamAdminID, "role": "admin"})
// Create a provider to reference
w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Provider1", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
})
var pcfg map[string]interface{}
decode(w, &pcfg)
cfgID := pcfg["id"].(string)
// List team roles — initially empty
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list team roles: %d: %s", w.Code, w.Body.String())
}
var roleList map[string]interface{}
decode(w, &roleList)
if len(roleList) != 0 {
t.Fatalf("team roles should be empty initially, got %d", len(roleList))
}
// Set team role override
w = h.request("PUT", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken,
map[string]interface{}{
"primary": map[string]string{
"provider_config_id": cfgID,
"model_id": "gpt-4o",
},
})
if w.Code != http.StatusOK {
t.Fatalf("set team role: %d: %s", w.Code, w.Body.String())
}
// List should now have override
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
decode(w, &roleList)
if _, ok := roleList["utility"]; !ok {
t.Fatal("team roles should have utility after update")
}
// Delete override
w = h.request("DELETE", fmt.Sprintf("/api/v1/teams/%s/roles/utility", teamID), teamAdminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete team role: %d: %s", w.Code, w.Body.String())
}
// List should be empty again
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/roles", teamID), teamAdminToken, nil)
roleList = map[string]interface{}{} // reset — json.Unmarshal merges into existing maps
decode(w, &roleList)
if _, ok := roleList["utility"]; ok {
t.Fatal("team roles should not have utility after delete")
}
}
// ═══════════════════════════════════════════
// USAGE TESTS
// ═══════════════════════════════════════════
// seedUsage inserts a usage_log row directly for testing.
func seedUsage(t *testing.T, userID, provCfgID, model, scope string, prompt, completion int, costIn, costOut float64) {
t.Helper()
_, err := database.TestDB.Exec(`
INSERT INTO usage_log (user_id, provider_config_id, provider_scope,
model_id, prompt_tokens, completion_tokens,
cost_input, cost_output)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, userID, provCfgID, scope, model, prompt, completion, costIn, costOut)
if err != nil {
t.Fatalf("seedUsage: %v", err)
}
}
func TestIntegration_Usage_AdminView(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
userID := database.SeedTestUser(t, "alice", "alice@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
// Create global provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "TestOAI", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
})
var cfg map[string]interface{}
decode(w, &cfg)
cfgID := cfg["id"].(string)
// Seed usage data
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.005, 0.006)
seedUsage(t, userID, cfgID, "gpt-4o", "global", 500, 100, 0.0025, 0.003)
seedUsage(t, userID, cfgID, "gpt-4o-mini", "global", 2000, 400, 0.001, 0.002)
// Admin usage — grouped by model (default)
w = h.request("GET", "/api/v1/admin/usage?group_by=model", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin usage: %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
totals := resp["totals"].(map[string]interface{})
if int(totals["requests"].(float64)) != 3 {
t.Errorf("totals.requests: want 3, got %v", totals["requests"])
}
if int(totals["input_tokens"].(float64)) != 3500 {
t.Errorf("totals.input_tokens: want 3500, got %v", totals["input_tokens"])
}
results := resp["results"].([]interface{})
if len(results) != 2 {
t.Fatalf("want 2 model groups, got %d", len(results))
}
}
func TestIntegration_Usage_AdminExcludesBYOK(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
userID := database.SeedTestUser(t, "bob", "bob@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
// Create global provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Global1", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g",
})
var cfg map[string]interface{}
decode(w, &cfg)
globalCfgID := cfg["id"].(string)
// Seed: 1 global usage + 1 personal BYOK usage
seedUsage(t, userID, globalCfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
seedUsage(t, userID, globalCfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
// Admin view should exclude personal
w = h.request("GET", "/api/v1/admin/usage", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
totals := resp["totals"].(map[string]interface{})
if int(totals["requests"].(float64)) != 1 {
t.Errorf("admin should see 1 request (excludes BYOK), got %v", totals["requests"])
}
}
func TestIntegration_Usage_PersonalIncludesBYOK(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
userID, userToken := h.registerUser("carol", "carol@test.com", "password123")
// Create global provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Global2", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-g2",
})
var cfg map[string]interface{}
decode(w, &cfg)
cfgID := cfg["id"].(string)
seedUsage(t, userID, cfgID, "gpt-4o", "global", 1000, 200, 0.01, 0.01)
seedUsage(t, userID, cfgID, "gpt-4o", "personal", 500, 100, 0.005, 0.005)
// Personal view should include all scopes
w = h.request("GET", "/api/v1/usage", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("personal usage: %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
totals := resp["totals"].(map[string]interface{})
if int(totals["requests"].(float64)) != 2 {
t.Errorf("personal should see 2 requests (includes BYOK), got %v", totals["requests"])
}
}
func TestIntegration_Usage_NonAdmin403(t *testing.T) {
h := setupHarness(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("dave", "dave@test.com", "password123")
w := h.request("GET", "/api/v1/admin/usage", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin usage: want 403, got %d", w.Code)
}
}
// ── Team Usage ────────────────────────────
func TestIntegration_Usage_TeamAdmin(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create team admin + member
teamAdminID := database.SeedTestUser(t, "teamlead2", "teamlead2@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", teamAdminID)
teamAdminToken := makeToken(teamAdminID, "teamlead2@test.com", "user")
memberID := database.SeedTestUser(t, "member2", "member2@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
// Create team
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
"name": "EngTeam", "description": "Test",
})
var team map[string]interface{}
decode(w, &team)
teamID := team["id"].(string)
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
map[string]string{"user_id": teamAdminID, "role": "admin"})
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
map[string]string{"user_id": memberID, "role": "member"})
// Create team provider via team admin self-service
w = h.request("POST", fmt.Sprintf("/api/v1/teams/%s/providers", teamID), teamAdminToken,
map[string]interface{}{
"name": "TeamOpenAI", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-team",
})
if w.Code != http.StatusCreated {
t.Fatalf("create team provider: %d: %s", w.Code, w.Body.String())
}
var tprov map[string]interface{}
decode(w, &tprov)
teamProvID := tprov["id"].(string)
// Also create a global provider (usage against it should NOT appear in team view)
w = h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "GlobalProv", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
})
var gcfg map[string]interface{}
decode(w, &gcfg)
globalProvID := gcfg["id"].(string)
// Seed: 2 rows against team provider, 1 against global
seedUsage(t, memberID, teamProvID, "gpt-4o", "team", 1000, 200, 0.01, 0.01)
seedUsage(t, teamAdminID, teamProvID, "gpt-4o", "team", 500, 100, 0.005, 0.005)
seedUsage(t, memberID, globalProvID, "gpt-4o", "global", 2000, 400, 0.02, 0.02)
// Team usage should show only team provider usage (2 rows)
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), teamAdminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("team usage: %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
totals := resp["totals"].(map[string]interface{})
if int(totals["requests"].(float64)) != 2 {
t.Errorf("team usage should show 2 requests (team provider only), got %v", totals["requests"])
}
if int(totals["input_tokens"].(float64)) != 1500 {
t.Errorf("team usage input_tokens: want 1500, got %v", totals["input_tokens"])
}
}
func TestIntegration_Usage_TeamNonAdmin403(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
memberID := database.SeedTestUser(t, "member3", "member3@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", memberID)
memberToken := makeToken(memberID, "member3@test.com", "user")
// Create team, add member (NOT admin)
w := h.request("POST", "/api/v1/admin/teams", adminToken, map[string]string{
"name": "EngTeam2", "description": "Test2",
})
var team map[string]interface{}
decode(w, &team)
teamID := team["id"].(string)
h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), adminToken,
map[string]string{"user_id": memberID, "role": "member"})
// Non-admin member should be forbidden
w = h.request("GET", fmt.Sprintf("/api/v1/teams/%s/usage", teamID), memberToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("team usage non-admin: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════
// PRICING TESTS
// ═══════════════════════════════════════════
func TestIntegration_Pricing_CRUD(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create provider to reference
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "PricingProv", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-p",
})
var cfg map[string]interface{}
decode(w, &cfg)
cfgID := cfg["id"].(string)
// Upsert pricing
w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
"provider_config_id": cfgID,
"model_id": "gpt-4o",
"input_per_m": 2.5,
"output_per_m": 10.0,
})
if w.Code != http.StatusOK {
t.Fatalf("upsert pricing: %d: %s", w.Code, w.Body.String())
}
// List pricing
w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list pricing: %d: %s", w.Code, w.Body.String())
}
var entries []interface{}
decode(w, &entries)
if len(entries) != 1 {
t.Fatalf("want 1 pricing entry, got %d", len(entries))
}
entry := entries[0].(map[string]interface{})
if entry["model_id"] != "gpt-4o" {
t.Errorf("pricing model: want gpt-4o, got %v", entry["model_id"])
}
if entry["source"] != "manual" {
t.Errorf("pricing source: want manual, got %v", entry["source"])
}
// Delete pricing
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/pricing/%s/gpt-4o", cfgID), adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete pricing: %d: %s", w.Code, w.Body.String())
}
// List should be empty
w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
decode(w, &entries)
if len(entries) != 0 {
t.Fatalf("want 0 pricing entries after delete, got %d", len(entries))
}
}
func TestIntegration_Pricing_ExcludesBYOK(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("byokuser", "byokuser@test.com", "password123")
// Global provider with pricing
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "GlobalP", "provider": "openai",
"endpoint": "https://api.openai.com/v1", "api_key": "sk-global",
})
var gcfg map[string]interface{}
decode(w, &gcfg)
globalID := gcfg["id"].(string)
// Set global pricing
h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
"provider_config_id": globalID,
"model_id": "gpt-4o",
"input_per_m": 2.5,
"output_per_m": 10.0,
})
// BYOK provider (personal scope)
w = h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
"name": "MyKey", "provider": "openai",
"endpoint": "http://localhost:1/v1", "api_key": "sk-byok",
})
if w.Code != http.StatusCreated {
t.Fatalf("create BYOK: %d: %s", w.Code, w.Body.String())
}
var bcfg map[string]interface{}
decode(w, &bcfg)
byokID := bcfg["id"].(string)
// Simulate catalog pricing for BYOK provider (as model sync would)
database.TestDB.Exec(`
INSERT INTO model_pricing (provider_config_id, model_id, input_per_m, output_per_m, source)
VALUES ($1, 'gpt-4o-byok', 3.0, 15.0, 'catalog')
`, byokID)
// Admin list should only show the 1 global entry, not the BYOK one
w = h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
var entries []interface{}
decode(w, &entries)
if len(entries) != 1 {
t.Fatalf("admin pricing should show 1 (global only), got %d", len(entries))
}
entry := entries[0].(map[string]interface{})
if entry["provider_config_id"] != globalID {
t.Errorf("pricing entry should be global provider, got %v", entry["provider_config_id"])
}
}
func TestIntegration_Pricing_RejectBYOKUpsert(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("byokuser2", "byokuser2@test.com", "password123")
// Create BYOK provider
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
"name": "MyKey2", "provider": "openai",
"endpoint": "http://localhost:1/v1", "api_key": "sk-byok2",
})
var bcfg map[string]interface{}
decode(w, &bcfg)
byokID := bcfg["id"].(string)
// Admin tries to set pricing on personal provider — should be rejected
w = h.request("PUT", "/api/v1/admin/pricing", adminToken, map[string]interface{}{
"provider_config_id": byokID,
"model_id": "gpt-4o",
"input_per_m": 2.5,
"output_per_m": 10.0,
})
if w.Code != http.StatusForbidden {
t.Fatalf("pricing BYOK upsert: want 403, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -1,12 +1,14 @@
package handlers
import (
"context"
"fmt"
"net/http"
"os"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
// ═══════════════════════════════════════════
@@ -18,8 +20,12 @@ import (
//
// They exercise the full flow: create provider →
// fetch models → enable model → resolve → complete.
//
// Model: qwen3-4b (Venice Small) — cheapest at $0.05/$0.15 per 1M tokens
// ═══════════════════════════════════════════
const veniceTestModel = "qwen3-4b"
func requireVeniceKey(t *testing.T) string {
t.Helper()
key := os.Getenv("VENICE_API_KEY")
@@ -29,6 +35,57 @@ func requireVeniceKey(t *testing.T) string {
return key
}
// setupVeniceWithModel creates a Venice provider, fetches models, and enables
// the specified model. Returns (configID, catalogEntryID).
func setupVeniceWithModel(t *testing.T, h *testHarness, adminToken, apiKey, modelID string) (string, string) {
t.Helper()
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": apiKey,
})
if w.Code != http.StatusCreated {
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch models
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: %d: %s", w.Code, w.Body.String())
}
// Find and enable target model
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
var catalogID string
for _, raw := range modelsResp["models"].([]interface{}) {
m := raw.(map[string]interface{})
if m["model_id"].(string) == modelID {
catalogID = m["id"].(string)
break
}
}
if catalogID == "" {
t.Fatalf("model %s not found in Venice catalog", modelID)
}
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: %d: %s", w.Code, w.Body.String())
}
t.Logf(" Venice provider %s ready, model %s enabled", configID, modelID)
return configID, catalogID
}
// TestLive_VeniceProviderFullFlow exercises the complete admin workflow:
// create provider → fetch models → enable a model → user sees it → chat completion
func TestLive_VeniceProviderFullFlow(t *testing.T) {
@@ -231,92 +288,232 @@ func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
}
}
// TestLive_VeniceChatCompletion sends an actual chat completion.
// TestLive_VeniceChatCompletion sends an actual non-streaming chat completion
// using the cheapest model (qwen3-4b = $0.05/$0.15 per 1M tokens).
func TestLive_VeniceChatCompletion(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
_ = adminID
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Chat Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
// Fetch + enable a fast model
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
// Find and enable a small model (prefer llama or qwen for speed)
var targetModelID, targetCatalogID string
for _, raw := range modelsResp["models"].([]interface{}) {
m := raw.(map[string]interface{})
mid := m["model_id"].(string)
// Pick any available model - first disabled one
if m["visibility"].(string) == "disabled" {
targetModelID = mid
targetCatalogID = m["id"].(string)
break
}
}
if targetModelID == "" {
t.Skip("no model available to test chat completion")
}
h.request("PUT", "/api/v1/admin/models/"+targetCatalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
// Set as default model and send completion
// First get enabled model's composite ID
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
var enabled map[string]interface{}
decode(w, &enabled)
if len(enabled["models"].([]interface{})) == 0 {
t.Fatal("no enabled models for completion test")
}
firstModel := enabled["models"].([]interface{})[0].(map[string]interface{})
modelForChat := firstModel["model_id"].(string)
configForChat := firstModel["config_id"].(string)
t.Logf("Sending completion to %s via config %s", modelForChat, configForChat)
// Create a channel first
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Test Chat", "type": "direct",
// Create channel
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Chat Test", "type": "direct",
})
if w.Code != http.StatusCreated {
// Some channel handlers may use database.DB directly
t.Skipf("channel creation failed (may need database.DB global): %d %s", w.Code, w.Body.String())
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Send completion
// Non-streaming completion with correct field names
stream := false
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
"channel_id": channelID,
"model": modelForChat,
"config_id": configForChat,
"stream": false,
"messages": []map[string]string{
{"role": "user", "content": "Say hello in exactly 3 words."},
},
"channel_id": channelID,
"content": "Say ok",
"model": veniceTestModel,
"provider_config_id": configID,
"stream": &stream,
"max_tokens": 10,
})
if w.Code != http.StatusOK {
t.Logf("completion response: %s", w.Body.String())
t.Skipf("chat completion failed with %d (may need full router wiring)", w.Code)
t.Fatalf("completion: want 200, got %d: %s", w.Code, w.Body.String())
}
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
}
// TestLive_VeniceUsageLogging verifies that a non-streaming completion
// creates a usage_log row with token counts from the provider.
func TestLive_VeniceUsageLogging(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
// Create channel
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Usage Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Non-streaming — providers reliably return usage in non-streaming mode
stream := false
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
"channel_id": channelID,
"content": "Say ok",
"model": veniceTestModel,
"provider_config_id": configID,
"stream": &stream,
"max_tokens": 10,
})
if w.Code != http.StatusOK {
t.Fatalf("completion: %d: %s", w.Code, w.Body.String())
}
// Verify usage_log row exists
var rowCount int
var promptTokens, completionTokens int
err := database.TestDB.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0)
FROM usage_log WHERE provider_config_id = $1
`, configID).Scan(&rowCount, &promptTokens, &completionTokens)
if err != nil {
t.Fatalf("query usage_log: %v", err)
}
if rowCount == 0 {
t.Fatal("usage_log should have a row after non-streaming completion")
}
t.Logf(" ✓ Usage logged: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
if promptTokens == 0 {
t.Fatal("non-streaming completion should report prompt tokens — check provider response parsing")
}
t.Logf(" ✓ Token counts: prompt=%d completion=%d", promptTokens, completionTokens)
}
// TestLive_VeniceStreamingUsageLogging verifies that streaming completions
// create a usage_log row with actual token counts. Venice supports
// stream_options.include_usage — the parser must capture the usage chunk
// that arrives after finish_reason but before [DONE].
func TestLive_VeniceStreamingUsageLogging(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
// Create channel
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Stream Usage Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Streaming completion
stream := true
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
"channel_id": channelID,
"content": "Say ok",
"model": veniceTestModel,
"provider_config_id": configID,
"stream": &stream,
"max_tokens": 10,
})
// Streaming returns 200 with SSE — the recorder captures the full body
if w.Code != http.StatusOK {
t.Fatalf("streaming completion: %d: %s", w.Code, w.Body.String())
}
// Verify usage_log row exists (even if tokens are 0)
var rowCount int
var promptTokens, completionTokens int
err := database.TestDB.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0)
FROM usage_log WHERE provider_config_id = $1
`, configID).Scan(&rowCount, &promptTokens, &completionTokens)
if err != nil {
t.Fatalf("query usage_log: %v", err)
}
if rowCount == 0 {
t.Fatal("usage_log should have a row after streaming completion")
}
if promptTokens == 0 {
t.Fatal("streaming completion should report prompt tokens — check pendingFinish logic in openai.go parser")
}
t.Logf(" ✓ Streaming usage logged: %d row(s), prompt=%d completion=%d", rowCount, promptTokens, completionTokens)
}
// TestLive_VenicePricingFromCatalog verifies that model sync populates
// the model_pricing table from Venice's pricing data.
func TestLive_VenicePricingFromCatalog(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
// Check if pricing was populated during fetch
var pricingCount int
database.TestDB.QueryRow(`
SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = $1
`, configID).Scan(&pricingCount)
if pricingCount == 0 {
t.Skip("Venice model sync did not populate pricing — may need provider pricing support")
}
t.Logf(" ✓ Catalog sync populated %d pricing entries", pricingCount)
// Verify our test model has pricing
var inputPerM, outputPerM float64
err := database.TestDB.QueryRow(`
SELECT COALESCE(input_per_m, 0), COALESCE(output_per_m, 0)
FROM model_pricing
WHERE provider_config_id = $1 AND model_id = $2
`, configID, veniceTestModel).Scan(&inputPerM, &outputPerM)
if err != nil {
t.Logf(" ⚠ No pricing for %s specifically (may use different model ID)", veniceTestModel)
} else {
t.Logf(" ✓ %s pricing: $%.4f input, $%.4f output (per 1M)", veniceTestModel, inputPerM, outputPerM)
}
// Admin pricing list should show these (scope=global, not personal)
w := h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin pricing: %d: %s", w.Code, w.Body.String())
}
var entries []interface{}
decode(w, &entries)
if len(entries) == 0 {
t.Error("admin pricing API should return catalog-synced entries")
} else {
t.Logf(" ✓ Admin pricing API returns %d entries", len(entries))
}
}
// TestLive_VeniceEmbeddings tests the Venice embeddings endpoint directly
// using the BGE-M3 model ($0.15 per 1M tokens).
func TestLive_VeniceEmbeddings(t *testing.T) {
veniceKey := requireVeniceKey(t)
provider := &providers.VeniceProvider{}
cfg := providers.ProviderConfig{
Endpoint: "https://api.venice.ai/api/v1",
APIKey: veniceKey,
}
resp, err := provider.Embed(
context.Background(), cfg,
providers.EmbeddingRequest{
Model: "text-embedding-bge-m3",
Input: []string{"test embedding"},
},
)
if err != nil {
t.Fatalf("Venice Embed: %v", err)
}
if len(resp.Embeddings) == 0 {
t.Fatal("expected at least 1 embedding vector")
}
if len(resp.Embeddings[0]) == 0 {
t.Fatal("embedding vector should not be empty")
}
t.Logf(" ✓ Embedding returned %d dimensions, input_tokens=%d",
len(resp.Embeddings[0]), resp.InputTokens)
}
// TestLive_VeniceModelDeletion tests cleanup: delete provider removes catalog entries
func TestLive_VeniceModelDeletion(t *testing.T) {
h := setupHarness(t)

View File

@@ -14,6 +14,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -55,13 +56,14 @@ type cursorRequest struct {
}
// MessageHandler holds dependencies for message endpoints.
type MessageHandler struct{
vault *crypto.KeyResolver
type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver) *MessageHandler {
return &MessageHandler{vault: vault}
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores}
}
// ── List Messages (flat, all branches) ──────
@@ -356,7 +358,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault)
comp := NewCompletionHandler(h.vault, h.stores)
var presetSystemPrompt string
model := req.Model
@@ -396,7 +398,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
}
}
providerCfg, providerID, model, configID, err := comp.resolveConfig(userID, channelID, completionRequest{
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
Model: model,
APIConfigID: apiConfigID,
})
@@ -497,6 +499,11 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
}
// Log usage for regeneration
comp.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
}
// ── Switch Branch (update cursor) ───────────

View File

@@ -76,6 +76,17 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
}
// Sync pricing from provider catalog (won't overwrite manual admin overrides)
if stores.Pricing != nil {
for _, m := range provModels {
if m.Pricing != nil {
if err := stores.Pricing.UpsertFromCatalog(ctx, cfg.ID, m.ID, m.Pricing); err != nil {
log.Printf("warn: pricing sync for %s/%s failed: %v", cfg.ID, m.ID, err)
}
}
}
}
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
}

243
server/handlers/roles.go Normal file
View File

@@ -0,0 +1,243 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// RolesHandler manages model role configuration.
type RolesHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewRolesHandler creates a roles handler.
func NewRolesHandler(s store.Stores, resolver *roles.Resolver) *RolesHandler {
return &RolesHandler{stores: s, resolver: resolver}
}
// ── List All Role Configs ──────────────────
// GET /admin/roles
func (h *RolesHandler) ListRoles(c *gin.Context) {
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
return
}
c.JSON(http.StatusOK, allRoles)
}
// ── Get Single Role Config ─────────────────
// GET /admin/roles/:role
func (h *RolesHandler) GetRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, nil)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
// ── Update Role Config ─────────────────────
// PUT /admin/roles/:role
func (h *RolesHandler) UpdateRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
var req roles.RoleConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Load current global model_roles
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
return
}
if allRoles == nil {
allRoles = models.JSONMap{}
}
// Update the specific role
allRoles[role] = req
// Persist
userID := getUserID(c)
if err := h.stores.GlobalConfig.Set(c.Request.Context(), "model_roles", allRoles, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save role"})
return
}
c.JSON(http.StatusOK, req)
}
// ── Test Role ──────────────────────────────
// POST /admin/roles/:role/test
func (h *RolesHandler) TestRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
if role == roles.RoleEmbedding {
// Test embedding
result, err := h.resolver.Embed(c.Request.Context(), role, nil, []string{"test embedding"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"model": result.Model,
"provider": result.ProviderID,
"dimensions": len(result.Embeddings[0]),
"used_fallback": result.UsedFallback,
})
return
}
// Test completion with a minimal prompt
result, err := h.resolver.Complete(c.Request.Context(), role, nil, []providers.Message{
{Role: "user", Content: "Say 'ok' and nothing else."},
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"model": result.Model,
"provider": result.ProviderID,
"content": result.Content,
"input_tokens": result.InputTokens,
"output_tokens": result.OutputTokens,
"used_fallback": result.UsedFallback,
})
}
// ── Team Role Overrides ────────────────────
// ListTeamRoles returns role overrides for a specific team.
// GET /teams/:teamId/roles
func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
teamID := c.Param("teamId")
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
roleOverrides := make(map[string]interface{})
if team.Settings != nil {
if raw, ok := team.Settings["model_roles"]; ok {
if m, ok := raw.(map[string]interface{}); ok {
roleOverrides = m
}
}
}
c.JSON(http.StatusOK, roleOverrides)
}
// UpdateTeamRole sets a team role override.
// PUT /teams/:teamId/roles/:role
func (h *RolesHandler) UpdateTeamRole(c *gin.Context) {
teamID := c.Param("teamId")
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
var req roles.RoleConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
settings := team.Settings
if settings == nil {
settings = models.JSONMap{}
}
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
if roleOverrides == nil {
roleOverrides = make(map[string]interface{})
}
roleOverrides[role] = req
settings["model_roles"] = roleOverrides
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
"settings": settings,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save team role"})
return
}
c.JSON(http.StatusOK, req)
}
// DeleteTeamRole removes a team role override (falls back to global).
// DELETE /teams/:teamId/roles/:role
func (h *RolesHandler) DeleteTeamRole(c *gin.Context) {
teamID := c.Param("teamId")
role := c.Param("role")
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
settings := team.Settings
if settings == nil {
c.JSON(http.StatusOK, gin.H{"message": "no override to remove"})
return
}
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
if roleOverrides != nil {
delete(roleOverrides, role)
settings["model_roles"] = roleOverrides
}
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
"settings": settings,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove team role"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "override removed"})
}

View File

@@ -3,12 +3,14 @@ package handlers
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
@@ -36,11 +38,13 @@ type profileResponse struct {
}
// SettingsHandler manages user profile and preferences.
type SettingsHandler struct{}
type SettingsHandler struct {
uekCache *crypto.UEKCache
}
// NewSettingsHandler creates a new handler.
func NewSettingsHandler() *SettingsHandler {
return &SettingsHandler{}
func NewSettingsHandler(uekCache *crypto.UEKCache) *SettingsHandler {
return &SettingsHandler{uekCache: uekCache}
}
// ── Get Profile ─────────────────────────────
@@ -155,6 +159,9 @@ func (h *SettingsHandler) ChangePassword(c *gin.Context) {
return
}
// Re-wrap UEK with new password so personal BYOK keys remain accessible
h.rewrapVault(userID, req.CurrentPassword, req.NewPassword)
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
@@ -207,3 +214,60 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
h.GetSettings(c)
}
// ── Vault Re-wrap ──────────────────────────
// rewrapVault decrypts the UEK with the old password and re-encrypts it with
// the new password. This keeps personal BYOK keys accessible after a password
// change. Uses a new salt for forward secrecy.
func (h *SettingsHandler) rewrapVault(userID, oldPassword, newPassword string) {
if h.uekCache == nil {
return
}
var vaultSet bool
var encUEK, salt, nonce []byte
err := database.DB.QueryRow(`
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`, userID).Scan(&vaultSet, &encUEK, &salt, &nonce)
if err != nil || !vaultSet || len(encUEK) == 0 {
return // No vault to re-wrap
}
// Decrypt UEK with old password
oldPDK := crypto.DeriveKeyFromPassword(oldPassword, salt)
uek, err := crypto.UnwrapUEK(encUEK, nonce, oldPDK)
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (unwrap): %v", userID, err)
return
}
// Re-wrap with new password using fresh salt
newSalt, err := crypto.GenerateSalt()
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (salt): %v", userID, err)
return
}
newPDK := crypto.DeriveKeyFromPassword(newPassword, newSalt)
newEncUEK, newNonce, err := crypto.WrapUEK(uek, newPDK)
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (wrap): %v", userID, err)
return
}
_, err = database.DB.Exec(`
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, updated_at = NOW()
WHERE id = $4
`, newEncUEK, newSalt, newNonce, userID)
if err != nil {
log.Printf("⚠ Vault re-wrap failed for user %s (persist): %v", userID, err)
return
}
// Session cache: UEK itself hasn't changed, just its wrapper
h.uekCache.Store(userID, uek)
log.Printf(" 🔐 Vault re-wrapped for user %s", userID)
}

View File

@@ -15,8 +15,12 @@ import (
// streamResult holds the accumulated output from a streaming completion
// with tool execution. Callers are responsible for persistence.
type streamResult struct {
Content string
ToolActivity []map[string]interface{}
Content string
ToolActivity []map[string]interface{}
InputTokens int
OutputTokens int
CacheCreationTokens int
CacheReadTokens int
}
// streamWithToolLoop is the single, canonical streaming implementation.
@@ -100,6 +104,11 @@ func streamWithToolLoop(
if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
// Accumulate tokens from this tool-call iteration
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
} else {
// Normal completion — send finish event
finishReason := event.FinishReason
@@ -110,6 +119,11 @@ func streamWithToolLoop(
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
// Accumulate final tokens
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
finishReason, model))

233
server/handlers/usage.go Normal file
View File

@@ -0,0 +1,233 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// UsageHandler manages usage tracking and pricing endpoints.
type UsageHandler struct {
stores store.Stores
}
// NewUsageHandler creates a usage handler.
func NewUsageHandler(s store.Stores) *UsageHandler {
return &UsageHandler{stores: s}
}
// ── Admin: Aggregated Usage ────────────────
// GET /admin/usage?since=...&until=...&group_by=model|user|day|provider
func (h *UsageHandler) AdminUsage(c *gin.Context) {
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true // Admin views exclude personal BYOK
results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: Per-User Usage ──────────────────
// GET /admin/usage/users/:id
func (h *UsageHandler) AdminUserUsage(c *gin.Context) {
userID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── Admin: Per-Team Usage ──────────────────
// GET /admin/usage/teams/:id
func (h *UsageHandler) AdminTeamUsage(c *gin.Context) {
teamID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── User: Personal Usage ───────────────────
// GET /usage
func (h *UsageHandler) PersonalUsage(c *gin.Context) {
userID := getUserID(c)
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"})
return
}
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Team Admin: Team Provider Usage ────────
// GET /teams/:teamId/usage
//
// Shows usage against providers owned by this team. Only team admins
// (enforced by RequireTeamAdmin middleware) can see this view.
func (h *UsageHandler) TeamUsage(c *gin.Context) {
teamID := c.Param("teamId")
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"})
return
}
results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: List Pricing ────────────────────
// GET /admin/pricing
func (h *UsageHandler) ListPricing(c *gin.Context) {
entries, err := h.stores.Pricing.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"})
return
}
c.JSON(http.StatusOK, entries)
}
// ── Admin: Upsert Pricing ──────────────────
// PUT /admin/pricing
func (h *UsageHandler) UpsertPricing(c *gin.Context) {
var req models.PricingEntry
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate provider is admin-managed (global or team), not personal BYOK
pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
if err != nil || pc == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"})
return
}
if pc.Scope == "personal" {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"})
return
}
userID := getUserID(c)
req.Source = "manual"
req.UpdatedBy = &userID
if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing saved"})
}
// ── Admin: Delete Pricing ──────────────────
// DELETE /admin/pricing/:provider/:model
func (h *UsageHandler) DeletePricing(c *gin.Context) {
providerConfigID := c.Param("provider")
modelID := c.Param("model")
if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"})
}
// ── Helpers ────────────────────────────────
func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions {
opts := store.UsageQueryOptions{
GroupBy: c.DefaultQuery("group_by", "model"),
Limit: 100,
}
if since := c.Query("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
opts.Since = &t
}
}
if until := c.Query("until"); until != "" {
if t, err := time.Parse(time.RFC3339, until); err == nil {
opts.Until = &t
}
}
// Convenience shorthand: ?period=7d|30d|90d
if period := c.Query("period"); period != "" && opts.Since == nil {
var dur time.Duration
switch period {
case "7d":
dur = 7 * 24 * time.Hour
case "30d":
dur = 30 * 24 * time.Hour
case "90d":
dur = 90 * 24 * time.Hour
}
if dur > 0 {
t := time.Now().Add(-dur)
opts.Since = &t
}
}
return opts
}

View File

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

View File

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

View File

@@ -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"`

View File

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

View File

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

View File

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

View File

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

342
server/roles/roles.go Normal file
View File

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

View File

@@ -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
// =========================================

View File

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

View File

@@ -23,5 +23,7 @@ func NewStores(db *sql.DB) store.Stores {
Audit: NewAuditStore(),
Notes: NewNoteStore(),
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
}
}

View File

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

View File

@@ -321,6 +321,7 @@
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')" id="settingsProvidersTabBtn">Providers</button>
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')">Usage</button>
<button class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')" id="settingsTeamsTabBtn" style="display:none">Teams</button>
</div>
<div class="modal-body">
@@ -421,6 +422,26 @@
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Preset</button>
</section>
</div>
<!-- Usage Tab -->
<div class="settings-tab-content" id="settingsUsageTab" style="display:none">
<section class="settings-section">
<h3 style="font-size:14px;margin-bottom:6px">My Usage</h3>
<p class="section-hint" style="margin-bottom:8px">Token consumption and estimated costs across all your conversations.</p>
<div class="form-row" style="gap:6px;margin-bottom:8px">
<select id="myUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
</select>
<select id="myUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
<option value="model">By Model</option>
<option value="day">By Day</option>
</select>
</div>
<div id="myUsageTotals"></div>
<div id="myUsageResults"></div>
</section>
</div>
<!-- Teams Tab (visible to team admins) -->
<div class="settings-tab-content" id="settingsTeamsTab" style="display:none">
<div id="settingsTeamPicker"></div>
@@ -480,7 +501,25 @@
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
</section>
<!-- Team Activity (audit scoped to team members) -->
<!-- Team Usage (team admins only — usage against team-owned providers) -->
<section class="settings-section">
<h4 style="font-size:13px;margin-bottom:6px">Usage</h4>
<div class="form-row" style="gap:6px;margin-bottom:8px">
<select id="teamUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
<option value="7d">7 days</option>
<option value="30d" selected>30 days</option>
<option value="90d">90 days</option>
</select>
<select id="teamUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
</select>
</div>
<div id="settingsTeamUsageTotals"></div>
<div id="settingsTeamUsageResults"></div>
</section>
<!-- Team Activity (team admins only — audit scoped to team) -->
<section class="settings-section">
<h4 style="font-size:13px;margin-bottom:6px">Activity Log</h4>
<div class="audit-filters" style="margin-bottom:8px">
@@ -513,6 +552,8 @@
<button class="admin-tab" data-tab="presets">Presets</button>
<button class="admin-tab" data-tab="teams">Teams</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="roles">Roles</button>
<button class="admin-tab" data-tab="usage">Usage</button>
<button class="admin-tab" data-tab="stats">Stats</button>
<button class="admin-tab" data-tab="audit">Audit</button>
</div>
@@ -659,6 +700,37 @@
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
<!-- ── Roles Tab ─────────────────── -->
<div class="admin-tab-content" id="adminRolesTab" style="display:none">
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
<div id="adminRolesContent">
<div class="loading">Loading roles...</div>
</div>
</div>
<!-- ── Usage Tab ─────────────────── -->
<div class="admin-tab-content" id="adminUsageTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:12px">
<select id="usagePeriod" style="min-width:100px">
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
<select id="usageGroupBy" style="min-width:100px">
<option value="model">By Model</option>
<option value="user">By User</option>
<option value="day">By Day</option>
<option value="provider">By Provider</option>
</select>
<button class="btn-secondary btn-small" id="usageRefreshBtn">Refresh</button>
</div>
<div id="adminUsageTotals"></div>
<div id="adminUsageResults"></div>
<h3 style="margin-top:16px;font-size:14px">Model Pricing</h3>
<div id="adminPricingTable"></div>
</div>
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
<div class="admin-toolbar" style="margin-bottom:8px">

View File

@@ -310,7 +310,7 @@ const API = {
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { new_password: pw }); },
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
@@ -433,6 +433,60 @@ const API = {
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── HTTP Internals ───────────────────────
_esc(s) {

View File

@@ -1381,6 +1381,11 @@ function initListeners() {
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
// Admin — usage
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
@@ -1807,6 +1812,23 @@ async function createAdminUser() {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function adminResetUserPassword(id, username) {
const pw = prompt(
`Reset password for "${username}"?\n\n` +
`⚠️ WARNING: This will DESTROY the user's personal vault.\n` +
`All personal API keys (BYOK) will be permanently deleted.\n` +
`The user will need to re-add any personal provider keys.\n\n` +
`Enter new password (min 8 chars):`
);
if (!pw) return;
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteGlobalProvider(id) {
if (!confirm('Delete this global provider?')) return;
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
@@ -1905,6 +1927,59 @@ async function bulkSetVisibility(visibility) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Roles ────────────────────────────
async function adminRoleProviderChanged(role, slot) {
const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const modelSelect = document.getElementById(`role-${role}-${slot}-model`);
if (!modelSelect) return;
modelSelect.innerHTML = '<option value="">— select model —</option>';
if (!provId || !window._adminModelList) return;
const models = window._adminModelList.filter(m => m.provider_config_id === provId);
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id;
opt.textContent = m.display_name || m.model_id;
modelSelect.appendChild(opt);
});
}
async function adminSaveRole(role) {
const status = document.getElementById(`role-${role}-status`);
try {
const getBinding = (slot) => {
const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const model = document.getElementById(`role-${role}-${slot}-model`)?.value;
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
};
await API.adminUpdateRole(role, {
primary: getBinding('primary'),
fallback: getBinding('fallback')
});
if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; }
setTimeout(() => { if (status) status.textContent = ''; }, 3000);
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
async function adminTestRole(role) {
const status = document.getElementById(`role-${role}-status`);
if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; }
try {
const result = await API.adminTestRole(role);
if (status) {
const fb = result.used_fallback ? ' (fallback)' : '';
status.textContent = `${result.model}${fb}`;
status.style.color = 'var(--success)';
}
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) {

View File

@@ -843,6 +843,7 @@ const UI = {
UI.checkUserProvidersAllowed();
}
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
if (tab === 'usage') UI.loadMyUsage();
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'teams') UI.loadTeamsTab();
},
@@ -1000,6 +1001,7 @@ const UI = {
UI.loadTeamManageMembers(teamId),
UI.loadTeamManageProviders(teamId),
UI.loadTeamManagePresets(teamId),
UI.loadTeamUsage(),
UI.loadTeamAuditLog(1),
]);
},
@@ -1083,6 +1085,111 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadMyUsage() {
const totalsEl = document.getElementById('myUsageTotals');
const resultsEl = document.getElementById('myUsageResults');
if (!totalsEl) return;
const period = document.getElementById('myUsagePeriod')?.value || '30d';
const groupBy = document.getElementById('myUsageGroupBy')?.value || 'model';
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.getMyUsage({ period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : 'Model'}</th>
<th style="text-align:right">Reqs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
},
async loadTeamUsage() {
const teamId = UI._managingTeamId;
if (!teamId) return;
const totalsEl = document.getElementById('settingsTeamUsageTotals');
const resultsEl = document.getElementById('settingsTeamUsageResults');
if (!totalsEl) return;
const period = document.getElementById('teamUsagePeriod')?.value || '30d';
const groupBy = document.getElementById('teamUsageGroupBy')?.value || 'model';
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.teamGetUsage(teamId, { period, group_by: groupBy });
const t = data.totals || {};
if (!t.requests) {
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded for team providers in this period</div>';
return;
}
totalsEl.innerHTML = `
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length > 0) {
resultsEl.innerHTML = `
<table class="admin-table" style="font-size:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Reqs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
},
_teamAuditPage: 1,
_teamAuditPerPage: 20,
@@ -1253,6 +1360,8 @@ const UI = {
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'teams') await this.loadAdminTeams();
if (tab === 'settings') await this.loadAdminSettings();
if (tab === 'roles') await this.loadAdminRoles();
if (tab === 'usage') await this.loadAdminUsage();
},
async loadAdminUsers(quiet) {
@@ -1280,6 +1389,7 @@ const UI = {
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
}
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
</div>
</div>
@@ -1310,6 +1420,136 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── Admin: Roles ────────────────────────
async loadAdminRoles() {
const el = document.getElementById('adminRolesContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
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 `
<div class="settings-section" style="margin-bottom:16px">
<h3 style="font-size:14px;margin:0 0 8px;text-transform:capitalize">${role}</h3>
<div class="form-row" style="gap:8px;align-items:center">
<label style="min-width:60px;font-size:12px">Primary</label>
<select id="role-${role}-primary-provider" onchange="adminRoleProviderChanged('${role}','primary')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-primary-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.primary ? modelList.filter(m => m.provider_config_id === cfg.primary.provider_config_id).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.primary.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;align-items:center;margin-top:6px">
<label style="min-width:60px;font-size:12px">Fallback</label>
<select id="role-${role}-fallback-provider" onchange="adminRoleProviderChanged('${role}','fallback')" style="min-width:140px">
<option value="">— none —</option>
${providers.map(p => `<option value="${p.id}" ${cfg.fallback?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
</select>
<select id="role-${role}-fallback-model" style="min-width:200px">
<option value="">— select model —</option>
${cfg.fallback ? modelList.filter(m => m.provider_config_id === cfg.fallback.provider_config_id).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.fallback.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
</select>
</div>
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-primary btn-small" onclick="adminSaveRole('${role}')">Save</button>
<button class="btn-secondary btn-small" onclick="adminTestRole('${role}')">Test</button>
<span id="role-${role}-status" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
// Store models list for provider change handler
window._adminModelList = modelList;
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
// ── 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 = '<div class="loading">Loading...</div>';
resultsEl.innerHTML = '';
try {
const data = await API.adminGetUsage({ period, group_by: groupBy });
const t = data.totals || {};
totalsEl.innerHTML = `
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">${(t.requests || 0).toLocaleString()}</div><div class="stat-label">Requests</div></div>
<div class="stat-card"><div class="stat-value">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label">Input Tokens</div></div>
<div class="stat-card"><div class="stat-value">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label">Output Tokens</div></div>
<div class="stat-card"><div class="stat-value">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label">Est. Cost</div></div>
</div>`;
const results = data.results || [];
if (results.length === 0) {
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
} else {
resultsEl.innerHTML = `
<table class="admin-table" style="margin-top:12px">
<thead><tr>
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
<th style="text-align:right">Requests</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Cost</th>
</tr></thead>
<tbody>${results.map(r => `<tr>
<td>${esc(r.label || r.group_key)}</td>
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
// Load pricing table
try {
const pricing = await API.adminListPricing();
const entries = pricing || [];
if (entries.length === 0) {
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
} else {
pricingEl.innerHTML = `
<table class="admin-table" style="margin-top:8px">
<thead><tr>
<th>Model</th>
<th style="text-align:right">Input $/M</th>
<th style="text-align:right">Output $/M</th>
<th>Source</th>
</tr></thead>
<tbody>${entries.map(p => `<tr>
<td>${esc(p.model_id)}</td>
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
</tr>`).join('')}</tbody>
</table>`;
}
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
_auditPage: 1,
_auditPerPage: 30,