feat: content logging and per-key analytics
- Migration 000003: request_log_content table (messages jsonb, completion text) - InsertRequestLog now returns the new row's ID so content can be linked - Chat handler captures prompt messages and completion text (streaming accumulates deltas) - ListRequestLogs left-joins content; logs API passes key filter through - KeyStats query aggregates requests/tokens/cost/errors per key prefix - GET /api/admin/stats/keys endpoint - Logs page: expandable rows show prompt + completion inline - Analytics page: per-key usage table below the chart
This commit is contained in:
@@ -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<RequestLog[]>('/logs' + (s ? '?' + s : ''))
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
keys: (since?: string) => {
|
||||
const qs = since ? '?since=' + encodeURIComponent(since) : ''
|
||||
return req<KeyStat[]>('/stats/keys' + qs)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RequestLog[]>([])
|
||||
const [keyStats, setKeyStats] = useState<KeyStat[]>([])
|
||||
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() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-key stats */}
|
||||
<div style={{ marginTop: 14, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>Usage by API Key</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr 1fr 100px 60px', padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
{['Key','Requests','Tokens','Cost','Errors','Err %'].map(h => (
|
||||
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
{keyStats.length === 0 && !loading && <div style={{ padding: '18px 20px', color: `${D}0.3)`, fontSize: 13 }}>No data yet</div>}
|
||||
{keyStats.map(k => (
|
||||
<div key={k.key_prefix} style={{ display: 'grid', gridTemplateColumns: '140px 1fr 1fr 1fr 100px 60px', padding: '10px 18px', borderBottom: '1px solid rgba(255,255,255,0.04)', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd' }}>{k.key_prefix}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>{k.requests.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>
|
||||
{k.total_tokens > 1e6 ? `${(k.total_tokens/1e6).toFixed(1)}M` : k.total_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: `${D}0.62)` }}>${k.cost.toFixed(4)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: k.errors > 0 ? '#f87171' : `${D}0.35)` }}>{k.errors}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 12, color: k.errors > 0 ? '#f87171' : `${D}0.35)` }}>
|
||||
{k.requests ? `${Math.round(k.errors/k.requests*100)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,46 @@ const RANGES = [
|
||||
{ label: '7d', ms: 604800000 },
|
||||
]
|
||||
|
||||
function StatusBadge({ code }: { code: number }) {
|
||||
const ok = code < 400
|
||||
return <span style={{ fontSize: 11, fontFamily: "'DM Mono',monospace", fontWeight: 600, color: ok ? '#4ade80' : '#f87171', background: ok ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)', padding: '2px 7px', borderRadius: 4 }}>{code}</span>
|
||||
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 <div style={{ padding: '12px 16px', color: `${D}0.28)`, fontSize: 12 }}>No content logged</div>
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: '14px 18px', background: 'rgba(0,0,0,0.25)', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
{log.Messages && log.Messages.length > 0 && (
|
||||
<div style={{ marginBottom: log.Completion ? 14 : 0 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>Prompt</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
|
||||
{log.Messages.map((m, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1,
|
||||
background: m.role === 'user' ? 'rgba(124,58,237,0.15)' : m.role === 'assistant' ? 'rgba(34,197,94,0.1)' : 'rgba(255,255,255,0.06)',
|
||||
color: m.role === 'user' ? '#a78bfa' : m.role === 'assistant' ? '#4ade80' : `${D}0.45)`,
|
||||
}}>{m.role}</span>
|
||||
<span style={{ fontSize: 12.5, color: `${D}0.72)`, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{log.Completion && (
|
||||
<div>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: `${D}0.32)`, textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 8 }}>Completion</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 6px', borderRadius: 3, whiteSpace: 'nowrap', flexShrink: 0, marginTop: 1, background: 'rgba(34,197,94,0.1)', color: '#4ade80' }}>assistant</span>
|
||||
<span style={{ fontSize: 12.5, color: `${D}0.72)`, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{log.Completion}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className="animate-fade-up">
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{RANGES.map((r, i) => (
|
||||
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
|
||||
padding: '6px 13px', borderRadius: 6, fontSize: 12.5, cursor: 'pointer', border: 'none',
|
||||
background: i === rangeIdx ? '#7c3aed' : '#17171c',
|
||||
color: i === rangeIdx ? 'white' : `${D}0.5)`,
|
||||
}}>{r.label}</button>
|
||||
))}
|
||||
<div style={{ flex: 1, minWidth: 160, display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '6px 11px' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="rgba(237,237,240,0.28)" strokeWidth="1.9" strokeLinecap="round">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 2, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 6, padding: 3 }}>
|
||||
{RANGES.map((r, i) => (
|
||||
<button key={r.label} onClick={() => setRangeIdx(i)} style={{
|
||||
padding: '4px 13px', borderRadius: 4, fontSize: 12, fontWeight: 500, cursor: 'pointer', border: 'none',
|
||||
background: i === rangeIdx ? '#7c3aed' : 'transparent',
|
||||
color: i === rangeIdx ? 'white' : `${D}0.42)`,
|
||||
}}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, background: '#17171c', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 6, padding: '7px 11px', flex: 1, maxWidth: 320 }}>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke={`${D}0.28)`} strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="6.5" cy="6.5" r="4.5"/><line x1="10.5" y1="10.5" x2="14" y2="14"/>
|
||||
</svg>
|
||||
<input value={search} onChange={e => 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%' }}
|
||||
/>
|
||||
<input value={search} onChange={e => 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%' }}/>
|
||||
</div>
|
||||
<span style={{ fontSize: 11.5, color: `${D}0.28)`, whiteSpace: 'nowrap' }}>{visible.length} results</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: `${D}0.3)` }}>{visible.length} entries</span>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '8px 18px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
{heads.map(h => <span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
{['Time','Model','Provider','Key','Tokens','Latency','Status',''].map(h => (
|
||||
<span key={h} style={{ fontSize: 10, fontWeight: 700, color: `${D}0.28)`, textTransform: 'uppercase', letterSpacing: '0.07em' }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
{loading && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>Loading…</div>}
|
||||
{!loading && visible.length === 0 && <div style={{ padding: '24px 18px', color: `${D}0.3)` }}>No logs found</div>}
|
||||
{visible.map(l => (
|
||||
<div key={l.ID} style={{ display: 'grid', gridTemplateColumns: cols, padding: '10px 18px', borderBottom: '1px solid rgba(255,255,255,0.03)', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.32)` }}>{fmt(l.CreatedAt)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.4)` }}>{l.RequestID.slice(0,10) || l.ID.slice(0,8)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 8 }}>{l.ModelName}</span>
|
||||
<span style={{ fontSize: 12, color: `${D}0.55)` }}>{l.ProviderName}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.42)` }}>{l.PromptTokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.42)` }}>{l.CompletionTokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.LatencyMS}ms</span>
|
||||
<StatusBadge code={l.StatusCode} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && <div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>Loading…</div>}
|
||||
{!loading && visible.length === 0 && <div style={{ padding: '20px 16px', color: `${D}0.3)`, fontSize: 13 }}>No requests yet</div>}
|
||||
|
||||
{visible.map(l => {
|
||||
const isOpen = expanded === l.ID
|
||||
const ok = l.StatusCode < 400
|
||||
return (
|
||||
<div key={l.ID} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<div
|
||||
onClick={() => setExpanded(isOpen ? null : l.ID)}
|
||||
style={{ display: 'grid', gridTemplateColumns: cols, padding: '9px 16px', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.38)` }}>{fmtTime(l.CreatedAt)}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11.5, color: '#c4b5fd', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 8 }}>{l.ModelName}</span>
|
||||
<span style={{ fontSize: 12, color: `${D}0.55)` }}>{l.ProviderName}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.38)` }}>{l.KeyPrefix || '—'}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.TotalTokens.toLocaleString()}</span>
|
||||
<span style={{ fontFamily: "'DM Mono',monospace", fontSize: 11, color: `${D}0.45)` }}>{l.LatencyMS}ms</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontFamily: "'DM Mono',monospace", fontWeight: 600,
|
||||
color: ok ? '#4ade80' : '#f87171',
|
||||
background: ok ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)',
|
||||
padding: '2px 6px', borderRadius: 4, width: 'fit-content',
|
||||
}}>{l.StatusCode}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke={`${D}0.3)`} strokeWidth="1.5" strokeLinecap="round"
|
||||
style={{ transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s', justifySelf: 'center' }}>
|
||||
<polyline points="2,4 6,8 10,4"/>
|
||||
</svg>
|
||||
</div>
|
||||
{isOpen && <ContentDrawer log={l} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "<provider>/<slug>" (or "any/<slug>")
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
1
backend/migrations/000003_content_log.down.sql
Normal file
1
backend/migrations/000003_content_log.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS request_log_content;
|
||||
8
backend/migrations/000003_content_log.up.sql
Normal file
8
backend/migrations/000003_content_log.up.sql
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user