diff --git a/backend/dashboard/src/lib/api.ts b/backend/dashboard/src/lib/api.ts index aba0d97..4e581e1 100644 --- a/backend/dashboard/src/lib/api.ts +++ b/backend/dashboard/src/lib/api.ts @@ -1,5 +1,5 @@ import type { - Provider, VirtualKey, AdminModel, RequestLog, + Provider, VirtualKey, AdminModel, RequestLog, KeyStat, CreateProviderParams, CreateKeyParams, ModelOverride, } from './types' @@ -55,13 +55,20 @@ export const api = { req<{ status: string; models: number }>('/catalog/refresh', { method: 'POST' }), }, logs: { - list: (params: { provider?: string; status?: string; since?: string } = {}) => { + list: (params: { provider?: string; key?: string; status?: string; since?: string } = {}) => { const qs = new URLSearchParams() if (params.provider) qs.set('provider', params.provider) + if (params.key) qs.set('key', params.key) if (params.status) qs.set('status', params.status) if (params.since) qs.set('since', params.since) const s = qs.toString() return req('/logs' + (s ? '?' + s : '')) }, }, + stats: { + keys: (since?: string) => { + const qs = since ? '?since=' + encodeURIComponent(since) : '' + return req('/stats/keys' + qs) + }, + }, } diff --git a/backend/dashboard/src/lib/types.ts b/backend/dashboard/src/lib/types.ts index 3178198..00b6582 100644 --- a/backend/dashboard/src/lib/types.ts +++ b/backend/dashboard/src/lib/types.ts @@ -50,6 +50,19 @@ export interface RequestLog { Cost: number LatencyMS: number TTFTMs: number | null + // Content (null when not yet logged or not available) + Messages: Array<{ role: string; content: string }> | null + Completion: string | null +} + +export interface KeyStat { + key_prefix: string + requests: number + prompt_tokens: number + completion_tokens: number + total_tokens: number + cost: number + errors: number } export interface CatalogEntry { diff --git a/backend/dashboard/src/pages/Analytics.tsx b/backend/dashboard/src/pages/Analytics.tsx index 6ab8569..c50b9ee 100644 --- a/backend/dashboard/src/pages/Analytics.tsx +++ b/backend/dashboard/src/pages/Analytics.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { api } from '../lib/api' -import type { RequestLog } from '../lib/types' +import type { RequestLog, KeyStat } from '../lib/types' const D = 'rgba(237,237,240,' @@ -34,13 +34,18 @@ function SparkLine({ data, color = '#7c3aed' }: { data: number[]; color?: string export default function Analytics() { const [logs, setLogs] = useState([]) + const [keyStats, setKeyStats] = useState([]) const [loading, setLoading] = useState(true) const [rangeIdx, setRangeIdx] = useState(0) useEffect(() => { setLoading(true) - api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() }) - .then(r => setLogs(r ?? [])) + const since = new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() + Promise.all([ + api.logs.list({ since }), + api.stats.keys(since), + ]) + .then(([l, k]) => { setLogs(l ?? []); setKeyStats(k ?? []) }) .finally(() => setLoading(false)) }, [rangeIdx]) @@ -131,6 +136,33 @@ export default function Analytics() { ))} + + {/* Per-key stats */} +
+
+ Usage by API Key +
+
+ {['Key','Requests','Tokens','Cost','Errors','Err %'].map(h => ( + {h} + ))} +
+ {keyStats.length === 0 && !loading &&
No data yet
} + {keyStats.map(k => ( +
+ {k.key_prefix} + {k.requests.toLocaleString()} + + {k.total_tokens > 1e6 ? `${(k.total_tokens/1e6).toFixed(1)}M` : k.total_tokens.toLocaleString()} + + ${k.cost.toFixed(4)} + 0 ? '#f87171' : `${D}0.35)` }}>{k.errors} + 0 ? '#f87171' : `${D}0.35)` }}> + {k.requests ? `${Math.round(k.errors/k.requests*100)}%` : '—'} + +
+ ))} +
) } diff --git a/backend/dashboard/src/pages/Logs.tsx b/backend/dashboard/src/pages/Logs.tsx index 9fc30d7..3f54c95 100644 --- a/backend/dashboard/src/pages/Logs.tsx +++ b/backend/dashboard/src/pages/Logs.tsx @@ -10,14 +10,46 @@ const RANGES = [ { label: '7d', ms: 604800000 }, ] -function StatusBadge({ code }: { code: number }) { - const ok = code < 400 - return {code} +function fmtTime(iso: string) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } -function fmt(iso: string) { - const d = new Date(iso) - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +function ContentDrawer({ log }: { log: RequestLog }) { + if (!log.Messages && !log.Completion) { + return
No content logged
+ } + return ( +
+ {log.Messages && log.Messages.length > 0 && ( +
+
Prompt
+
+ {log.Messages.map((m, i) => ( +
+ {m.role} + + {typeof m.content === 'string' ? m.content : JSON.stringify(m.content)} + +
+ ))} +
+
+ )} + {log.Completion && ( +
+
Completion
+
+ assistant + {log.Completion} +
+
+ )} +
+ ) } export default function Logs() { @@ -25,64 +57,87 @@ export default function Logs() { const [loading, setLoading] = useState(true) const [rangeIdx, setRangeIdx] = useState(1) const [search, setSearch] = useState('') + const [expanded, setExpanded] = useState(null) - function load(idx = rangeIdx) { + useEffect(() => { setLoading(true) - api.logs.list({ since: new Date(Date.now() - RANGES[idx].ms).toISOString() }) + api.logs.list({ since: new Date(Date.now() - RANGES[rangeIdx].ms).toISOString() }) .then(r => setLogs(r ?? [])) .finally(() => setLoading(false)) - } + }, [rangeIdx]) - useEffect(() => { load() }, [rangeIdx]) + const visible = logs.filter(l => + !search || + l.ModelName.includes(search) || + l.ProviderName.includes(search) || + l.KeyPrefix.includes(search) || + l.RequestID.includes(search) + ) - const visible = search - ? logs.filter(l => l.ModelName.includes(search) || l.ProviderName.includes(search) || l.RequestID.includes(search)) - : logs - - const cols = '86px 106px 1fr 100px 72px 72px 80px 60px' - const heads = ['Time','Request ID','Model','Provider','In tok.','Out tok.','Latency','Status'] + const cols = '80px 1fr 110px 80px 90px 72px 58px 22px' return (
- {/* Filters */} -
- {RANGES.map((r, i) => ( - - ))} -
- +
+
+ {RANGES.map((r, i) => ( + + ))} +
+
+ - setSearch(e.target.value)} - placeholder="Filter by model, request ID…" - style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: `${D}0.7)`, width: '100%' }} - /> + setSearch(e.target.value)} placeholder="Filter by model, provider, key…" + style={{ background: 'none', border: 'none', outline: 'none', fontSize: 12.5, color: `${D}0.7)`, width: '100%' }}/>
- {visible.length} results + {visible.length} entries
-
- {heads.map(h => {h})} +
+ {['Time','Model','Provider','Key','Tokens','Latency','Status',''].map(h => ( + {h} + ))}
- {loading &&
Loading…
} - {!loading && visible.length === 0 &&
No logs found
} - {visible.map(l => ( -
- {fmt(l.CreatedAt)} - {l.RequestID.slice(0,10) || l.ID.slice(0,8)} - {l.ModelName} - {l.ProviderName} - {l.PromptTokens.toLocaleString()} - {l.CompletionTokens.toLocaleString()} - {l.LatencyMS}ms - -
- ))} + + {loading &&
Loading…
} + {!loading && visible.length === 0 &&
No requests yet
} + + {visible.map(l => { + const isOpen = expanded === l.ID + const ok = l.StatusCode < 400 + return ( +
+
setExpanded(isOpen ? null : l.ID)} + style={{ display: 'grid', gridTemplateColumns: cols, padding: '9px 16px', alignItems: 'center', cursor: 'pointer' }} + > + {fmtTime(l.CreatedAt)} + {l.ModelName} + {l.ProviderName} + {l.KeyPrefix || '—'} + {l.TotalTokens.toLocaleString()} + {l.LatencyMS}ms + {l.StatusCode} + + + +
+ {isOpen && } +
+ ) + })}
) diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index a42c923..57a6fcb 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -240,8 +240,9 @@ func (a *API) adminPutSetting(w http.ResponseWriter, r *http.Request) { func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() p := store.ListLogsParams{ - Provider: q.Get("provider"), - Limit: 100, + Provider: q.Get("provider"), + KeyPrefix: q.Get("key"), + Limit: 100, } if s := q.Get("status"); s == "error" { p.StatusGte = 400 @@ -264,6 +265,24 @@ func (a *API) adminListLogs(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } +func (a *API) adminKeyStats(w http.ResponseWriter, r *http.Request) { + since := time.Now().Add(-24 * time.Hour) + if s := r.URL.Query().Get("since"); s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + since = t + } + } + stats, err := a.Store.KeyStats(r.Context(), since) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if stats == nil { + stats = []store.KeyStat{} + } + writeJSON(w, http.StatusOK, stats) +} + // --- catalog --- func (a *API) adminRefreshCatalog(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index e1a777b..42a34f7 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -74,6 +74,7 @@ func (a *API) Mount(r chi.Router) { r.Post("/keys", a.adminCreateKey) r.Delete("/keys/{id}", a.adminRevokeKey) r.Get("/logs", a.adminListLogs) + r.Get("/stats/keys", a.adminKeyStats) r.Get("/models", a.adminListModels) r.Patch("/models/{id}", a.adminUpdateModel) r.Get("/settings", a.adminGetSettings) diff --git a/backend/internal/api/chat.go b/backend/internal/api/chat.go index a54d3d3..96ac821 100644 --- a/backend/internal/api/chat.go +++ b/backend/internal/api/chat.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -9,6 +10,7 @@ import ( "maps" "net" "net/http" + "strings" "time" chimw "github.com/go-chi/chi/v5/middleware" @@ -21,9 +23,6 @@ import ( const maxRequestBody = 10 << 20 // 10 MiB -// chatCompletions is the OpenAI-compatible proxy: resolve the model address via -// the catalog, enforce the key allowlist, dispatch to the provider, relay the -// response (streaming or not), and log usage asynchronously. func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBody)) if err != nil { @@ -41,7 +40,7 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) { } key := keyFrom(r.Context()) - address := req.Model // the client-facing "/" (or "any/") + address := req.Model entry, err := a.Cache.Resolve(address) if err != nil { @@ -72,13 +71,15 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) { return } - // Route to the upstream model and apply admin parameter overrides. + // Snapshot the messages before we overwrite req.Model below. + messages := marshalMessages(req.Payload["messages"]) + req.Model = entry.Slug applyOverrides(req.Payload, entry.ParamOverrides) start := time.Now() if req.Stream { - a.streamChat(w, r, cc, req, entry, key, address, start) + a.streamChat(w, r, cc, req, entry, key, address, messages, start) return } @@ -92,10 +93,11 @@ func (a *API) chatCompletions(w http.ResponseWriter, r *http.Request) { w.WriteHeader(resp.StatusCode) _, _ = w.Write(resp.Body) - a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil) + completion := extractCompletion(resp.Body) + a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil, messages, completion) } -func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, key *store.VirtualKey, address string, start time.Time) { +func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.ChatCompleter, req *providers.ChatRequest, entry *catalog.Entry, key *store.VirtualKey, address string, messages json.RawMessage, start time.Time) { stream, err := cc.ChatCompletionStream(r.Context(), req) if err != nil { var apiErr *providers.APIError @@ -103,7 +105,7 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch w.Header().Set("Content-Type", "application/json") w.WriteHeader(apiErr.StatusCode) _, _ = w.Write(apiErr.Body) - a.logRequest(r, key, entry, address, apiErr.StatusCode, true, providers.Usage{}, "upstream error", start, nil) + a.logRequest(r, key, entry, address, apiErr.StatusCode, true, providers.Usage{}, "upstream error", start, nil, messages, "") return } writeError(w, http.StatusBadGateway, err.Error()) @@ -118,18 +120,21 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch w.WriteHeader(http.StatusOK) var ttft *int + var sb strings.Builder for { ev, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { - break // mid-stream failure; status already sent + break } if ttft == nil { ms := int(time.Since(start).Milliseconds()) ttft = &ms } + // Accumulate assistant content from delta chunks. + sb.WriteString(extractDelta(ev.Data)) _, _ = fmt.Fprintf(w, "data: %s\n\n", ev.Data) if flusher != nil { flusher.Flush() @@ -142,17 +147,17 @@ func (a *API) streamChat(w http.ResponseWriter, r *http.Request, cc providers.Ch usage := stream.Usage() a.Limiter.RecordTokens(entry.Address, usage.TotalTokens) - a.logRequest(r, key, entry, address, http.StatusOK, true, usage, "", start, ttft) + a.logRequest(r, key, entry, address, http.StatusOK, true, usage, "", start, ttft, messages, sb.String()) } func applyOverrides(payload, overrides map[string]any) { maps.Copy(payload, overrides) } -func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, address string, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int) { +func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog.Entry, address string, status int, streamed bool, usage providers.Usage, errMsg string, start time.Time, ttft *int, messages json.RawMessage, completion string) { entryID := entry.ID providerID := entry.ProviderID - log := store.RequestLog{ + rec := store.RequestLog{ ProviderModelID: &entryID, ProviderID: &providerID, ProviderName: entry.ProviderName, @@ -171,16 +176,20 @@ func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog. ClientIP: clientIP(r), } if key != nil { - log.VirtualKeyID = &key.ID - log.KeyPrefix = key.KeyPrefix + rec.VirtualKeyID = &key.ID + rec.KeyPrefix = key.KeyPrefix } - // Async, detached from the request lifecycle so billing never blocks it. go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := a.Store.InsertRequestLog(ctx, log); err != nil { + id, err := a.Store.InsertRequestLog(ctx, rec) + if err != nil { slog.Warn("failed to write request log", "err", err) + return + } + if err := a.Store.InsertLogContent(ctx, id, messages, completion); err != nil { + slog.Warn("failed to write request log content", "err", err) } if key != nil { _ = a.Store.TouchVirtualKey(ctx, key.ID) @@ -188,6 +197,48 @@ func (a *API) logRequest(r *http.Request, key *store.VirtualKey, entry *catalog. }() } +// marshalMessages extracts the messages array from the request payload as raw JSON. +func marshalMessages(v any) json.RawMessage { + if v == nil { + return json.RawMessage("[]") + } + b, err := json.Marshal(v) + if err != nil { + return json.RawMessage("[]") + } + return b +} + +// extractCompletion pulls the first choice's message content from a non-streaming response body. +func extractCompletion(body []byte) string { + var resp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(body, &resp); err != nil || len(resp.Choices) == 0 { + return "" + } + return resp.Choices[0].Message.Content +} + +// extractDelta pulls the assistant content delta from a streaming SSE data chunk. +func extractDelta(data []byte) string { + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if err := json.Unmarshal(data, &chunk); err != nil || len(chunk.Choices) == 0 { + return "" + } + return chunk.Choices[0].Delta.Content +} + func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { diff --git a/backend/internal/api/embeddings.go b/backend/internal/api/embeddings.go index 8692e39..ff22518 100644 --- a/backend/internal/api/embeddings.go +++ b/backend/internal/api/embeddings.go @@ -73,7 +73,7 @@ func (a *API) embeddings(w http.ResponseWriter, r *http.Request) { w.WriteHeader(resp.StatusCode) _, _ = w.Write(resp.Body) - a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil) + a.logRequest(r, key, entry, address, resp.StatusCode, false, resp.Usage, "", start, nil, nil, "") } // ptrInt dereferences an *int, returning 0 (no limit) when nil. diff --git a/backend/internal/store/request_logs.go b/backend/internal/store/request_logs.go index a7e4c2b..bc27e63 100644 --- a/backend/internal/store/request_logs.go +++ b/backend/internal/store/request_logs.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "net/netip" "time" @@ -31,20 +32,36 @@ type RequestLog struct { ClientIP string } -func (s *Store) InsertRequestLog(ctx context.Context, e RequestLog) error { - _, err := s.pool.Exec(ctx, ` +func (s *Store) InsertRequestLog(ctx context.Context, e RequestLog) (uuid.UUID, error) { + var id uuid.UUID + err := s.pool.QueryRow(ctx, ` INSERT INTO request_logs ( virtual_key_id, provider_model_id, provider_id, key_prefix, model_name, provider_name, status_code, streamed, finish_reason, error, prompt_tokens, completion_tokens, cached_tokens, total_tokens, cost, latency_ms, ttft_ms, request_id, client_ip - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) + RETURNING id`, e.VirtualKeyID, e.ProviderModelID, e.ProviderID, nullString(e.KeyPrefix), nullString(e.ModelName), nullString(e.ProviderName), e.StatusCode, e.Streamed, nullString(e.FinishReason), nullString(e.Error), e.PromptTokens, e.CompletionTokens, e.CachedTokens, e.TotalTokens, e.Cost, e.LatencyMS, e.TTFTMs, nullString(e.RequestID), nullInet(e.ClientIP), + ).Scan(&id) + return id, err +} + +// InsertLogContent saves the prompt messages and completion text for a log row. +func (s *Store) InsertLogContent(ctx context.Context, logID uuid.UUID, messages json.RawMessage, completion string) error { + if len(messages) == 0 { + messages = json.RawMessage("[]") + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO request_log_content (request_log_id, messages, completion) + VALUES ($1, $2, $3) + ON CONFLICT (request_log_id) DO UPDATE SET messages = EXCLUDED.messages, completion = EXCLUDED.completion`, + logID, messages, completion, ) return err } @@ -66,11 +83,15 @@ type RequestLogListRow struct { Cost float64 LatencyMS int TTFTMs *int + // Content fields — null when content logging is disabled or unavailable. + Messages *json.RawMessage `json:"Messages,omitempty"` + Completion *string `json:"Completion,omitempty"` } // ListLogsParams filters the request_logs query. type ListLogsParams struct { Provider string // empty = all + KeyPrefix string // empty = all StatusGte int // 0 = all; e.g. 500 filters ≥500 StatusLt int // 0 = no upper bound Since time.Time @@ -78,7 +99,7 @@ type ListLogsParams struct { Offset int } -// ListRequestLogs returns recent request logs for the admin view. +// ListRequestLogs returns recent request logs for the admin view, with content joined in. func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]RequestLogListRow, error) { if p.Limit <= 0 { p.Limit = 50 @@ -88,19 +109,22 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques since = &p.Since } rows, err := s.pool.Query(ctx, ` - SELECT id::text, created_at, COALESCE(request_id,''), - COALESCE(key_prefix,''), COALESCE(model_name,''), COALESCE(provider_name,''), - status_code, streamed, COALESCE(error,''), - prompt_tokens, completion_tokens, total_tokens, - cost, latency_ms, ttft_ms - FROM request_logs - WHERE ($1 = '' OR provider_name = $1) - AND ($2 = 0 OR status_code >= $2) - AND ($3 = 0 OR status_code < $3) - AND ($4::timestamptz IS NULL OR created_at >= $4) - ORDER BY created_at DESC - LIMIT $5 OFFSET $6`, - p.Provider, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset, + SELECT rl.id::text, rl.created_at, COALESCE(rl.request_id,''), + COALESCE(rl.key_prefix,''), COALESCE(rl.model_name,''), COALESCE(rl.provider_name,''), + rl.status_code, rl.streamed, COALESCE(rl.error,''), + rl.prompt_tokens, rl.completion_tokens, rl.total_tokens, + rl.cost, rl.latency_ms, rl.ttft_ms, + rlc.messages, rlc.completion + FROM request_logs rl + LEFT JOIN request_log_content rlc ON rlc.request_log_id = rl.id + WHERE ($1 = '' OR rl.provider_name = $1) + AND ($2 = '' OR rl.key_prefix = $2) + AND ($3 = 0 OR rl.status_code >= $3) + AND ($4 = 0 OR rl.status_code < $4) + AND ($5::timestamptz IS NULL OR rl.created_at >= $5) + ORDER BY rl.created_at DESC + LIMIT $6 OFFSET $7`, + p.Provider, p.KeyPrefix, p.StatusGte, p.StatusLt, since, p.Limit, p.Offset, ) if err != nil { return nil, err @@ -115,6 +139,7 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques &r.StatusCode, &r.Streamed, &r.Error, &r.PromptTokens, &r.CompletionTokens, &r.TotalTokens, &r.Cost, &r.LatencyMS, &r.TTFTMs, + &r.Messages, &r.Completion, ); err != nil { return nil, err } @@ -123,6 +148,48 @@ func (s *Store) ListRequestLogs(ctx context.Context, p ListLogsParams) ([]Reques return out, rows.Err() } +// KeyStat is per-virtual-key aggregate stats for a time window. +type KeyStat struct { + KeyPrefix string `json:"key_prefix"` + Requests int `json:"requests"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Cost float64 `json:"cost"` + Errors int `json:"errors"` +} + +// KeyStats returns per-key aggregate stats since the given time. +func (s *Store) KeyStats(ctx context.Context, since time.Time) ([]KeyStat, error) { + rows, err := s.pool.Query(ctx, ` + SELECT COALESCE(key_prefix, '(no key)'), + COUNT(*)::int, + SUM(prompt_tokens)::int, + SUM(completion_tokens)::int, + SUM(total_tokens)::int, + SUM(cost), + SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END)::int + FROM request_logs + WHERE created_at >= $1 + GROUP BY key_prefix + ORDER BY SUM(total_tokens) DESC`, + since, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []KeyStat + for rows.Next() { + var s KeyStat + if err := rows.Scan(&s.KeyPrefix, &s.Requests, &s.PromptTokens, &s.CompletionTokens, &s.TotalTokens, &s.Cost, &s.Errors); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + func nullInet(s string) any { if a, err := netip.ParseAddr(s); err == nil { return a diff --git a/backend/migrations/000003_content_log.down.sql b/backend/migrations/000003_content_log.down.sql new file mode 100644 index 0000000..70ebe39 --- /dev/null +++ b/backend/migrations/000003_content_log.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS request_log_content; diff --git a/backend/migrations/000003_content_log.up.sql b/backend/migrations/000003_content_log.up.sql new file mode 100644 index 0000000..fd877c9 --- /dev/null +++ b/backend/migrations/000003_content_log.up.sql @@ -0,0 +1,8 @@ +-- Full prompt/completion content stored separately from the metadata log so +-- large payloads don't bloat the hot request_logs table. +CREATE TABLE request_log_content ( + request_log_id uuid PRIMARY KEY REFERENCES request_logs (id) ON DELETE CASCADE, + messages jsonb NOT NULL DEFAULT '[]', -- the full messages array sent upstream + completion text NOT NULL DEFAULT '' -- the assistant turn text from the response +); +CREATE INDEX idx_rlc_log ON request_log_content (request_log_id);