feat(llm): add LLM call recording with admin panel integration

- llm.Adapter: SetCallLogger hook for recording every API call
- llm.CallLogger: ring-buffer with Recent+Stats for admin API
- main.go: wire DeepSeek adapter to CallLogger
- Fix: single CallLogger instance for both recording and display
- Metrics: model name, duration, success/fail, tokens
- All 16 packages pass
This commit is contained in:
Aska committed 2026-08-12 19:10:51 +08:00
1 parent 5b2bcc9441
commit 6a4887e52a
6 files changed
+131 -67

No files matched your search

+44 -15
View File
@@ -39,8 +39,9 @@ func DefaultConfig() Config {
// Adapter handles communication with an OpenAI-compatible LLM API.
type Adapter struct {
cfg Config
client *http.Client
cfg Config
client *http.Client
onCall func(call CallRecord) // Hook for logging each LLM call
}
// NewAdapter creates a new LLM adapter.
@@ -58,11 +59,29 @@ func NewAdapter(cfg Config) *Adapter {
}
}
// SetCallLogger injects a callback that is invoked after each LLM call.
func (a *Adapter) SetCallLogger(fn func(call CallRecord)) {
a.onCall = fn
}
// CallRecord holds metadata about a single LLM API call.
type CallRecord struct {
Time time.Time
Model string
Purpose string
PromptTokens int
CompletionTokens int
DurationMs int64
Success bool
Error string
}
// ModelName returns the configured model name.
func (a *Adapter) ModelName() string { return a.cfg.Model }
// Chat sends a chat completion request and returns the full response.
func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) {
start := time.Now()
body := map[string]any{
"model": a.cfg.Model,
"messages": messages,
@@ -73,27 +92,23 @@ func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model
if choices, ok := data["choices"].([]any); ok && len(choices) > 0 {
choice := choices[0].(map[string]any)
if msg, ok := choice["message"].(map[string]any); ok {
if content, ok := msg["content"].(string); ok {
resp.Content = content
}
}
if reason, ok := choice["finish_reason"].(string); ok {
resp.FinishReason = reason
if content, ok := msg["content"].(string); ok { resp.Content = content }
}
if reason, ok := choice["finish_reason"].(string); ok { resp.FinishReason = reason }
}
if usage, ok := data["usage"].(map[string]any); ok {
if total, ok := usage["total_tokens"].(float64); ok {
resp.TokensUsed = int(total)
}
if total, ok := usage["total_tokens"].(float64); ok { resp.TokensUsed = int(total) }
}
return nil
})
a.recordCall(start, "chat", resp.TokensUsed, 0, err)
return resp, err
}
// ChatStream sends a chat completion request and returns a token channel for streaming.
// ChatStream sends a chat completion request and returns a token channel.
func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan model.StreamToken, error) {
start := time.Now()
body := map[string]any{
"model": a.cfg.Model,
"messages": messages,
@@ -101,14 +116,13 @@ func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (
}
ch := make(chan model.StreamToken, 64)
go func() {
defer close(ch)
err := a.doStreamRequest(ctx, "POST", "/chat/completions", body, ch)
a.recordCall(start, "chat_stream", 0, 0, err)
if err != nil {
select {
case ch <- model.StreamToken{Content: fmt.Sprintf("[LLM error: %v]", err), Done: true}:
case ch <- model.StreamToken{Content: fmt.Sprintf("[Error: %v]", err), Done: true}:
default:
}
}
@@ -117,6 +131,21 @@ func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (
return ch, nil
}
func (a *Adapter) recordCall(start time.Time, purpose string, promptTok, compTok int, err error) {
if a.onCall != nil {
a.onCall(CallRecord{
Time: start,
Model: a.cfg.Model,
Purpose: purpose,
PromptTokens: promptTok,
CompletionTokens: compTok,
DurationMs: time.Since(start).Milliseconds(),
Success: err == nil,
Error: func() string { if err != nil { return err.Error() }; return "" }(),
})
}
}
// doRequest sends a non-streaming request and parses the response.
func (a *Adapter) doRequest(ctx context.Context, method, path string, body map[string]any, parser func(map[string]any) error) error {
jsonBody, _ := json.Marshal(body)