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:
1 parent
5b2bcc9441
commit
6a4887e52a
6 files changed
+131
-67
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user