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