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
@@ -149,6 +149,11 @@ func main() {
|
||||
llmCfg.Model = cfg.LLMModel
|
||||
llmCfg.FallbackModel = cfg.LLMFallbackModel
|
||||
llmSelector := llm.NewModelSelector(llmCfg)
|
||||
// Wire LLM call logging to admin panel
|
||||
llmCallLogger := llm.NewCallLogger(200)
|
||||
llmSelector.DefaultAdapter().SetCallLogger(func(c llm.CallRecord) {
|
||||
llmCallLogger.Record(c)
|
||||
})
|
||||
log.Printf("[llm] adapter ready (model=%s)", cfg.LLMModel)
|
||||
|
||||
// === 9. AI-Core: Context Store ===
|
||||
@@ -211,7 +216,7 @@ func main() {
|
||||
authMgr := api.NewRealAuthManager(cfg.JWTSecret, cfg.JWTExpiryHours, userStore)
|
||||
|
||||
// Build dependencies for gateway handlers
|
||||
llmLogger := llmlog.NewLogger(200)
|
||||
llmLogger := &llmLoggerAdapter{CallLogger: llmCallLogger}
|
||||
traceLogger := tracepkg.NewLogger(500)
|
||||
perfMon := perfpkg.NewMonitor(120, 10*time.Second)
|
||||
perfMon.Start()
|
||||
@@ -219,7 +224,7 @@ func main() {
|
||||
gwDeps := api.Dependencies{
|
||||
AuthMgr: authMgr,
|
||||
Orchestrator: orch,
|
||||
LLMLogger: &llmLoggerAdapter{Logger: llmLogger},
|
||||
LLMLogger: llmLogger,
|
||||
TraceLog: &traceAdapter{Logger: traceLogger},
|
||||
PerfMon: &perfAdapter{Monitor: perfMon},
|
||||
ModelConfig: api.NewModelConfigStore(),
|
||||
@@ -269,9 +274,9 @@ func main() {
|
||||
_ = orch
|
||||
}
|
||||
// llmLoggerAdapter adapts llm.Logger to api.LLMCallLogger interface.
|
||||
type llmLoggerAdapter struct{ *llmlog.Logger }
|
||||
type llmLoggerAdapter struct{ *llmlog.CallLogger }
|
||||
|
||||
func (a *llmLoggerAdapter) Recent(n int) []map[string]any { return a.Logger.RecentMaps(n) }
|
||||
func (a *llmLoggerAdapter) Recent(n int) []map[string]any { return a.CallLogger.RecentMaps(n) }
|
||||
|
||||
func pluginNamesToMap(names []string) []map[string]string {
|
||||
result := make([]map[string]string, len(names))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// L is the global logger instance. Configure via Init().
|
||||
var L = New(os.Stderr, LevelInfo, "text")
|
||||
|
||||
// Init configures the global logger.
|
||||
func Init(level Level, format string) {
|
||||
L = New(os.Stderr, level, format)
|
||||
}
|
||||
|
||||
// InitNop sets the global logger to discard all output.
|
||||
func InitNop() {
|
||||
L = New(io.Discard, LevelDebug, "text")
|
||||
}
|
||||
|
||||
// G returns the global logger.
|
||||
func G() *Logger { return L }
|
||||
|
||||
// --- Global convenience methods ---
|
||||
|
||||
func Debug(msg string, args ...any) { L.Debug(msg, args...) }
|
||||
func Info(msg string, args ...any) { L.Info(msg, args...) }
|
||||
func Warn(msg string, args ...any) { L.Warn(msg, args...) }
|
||||
func Error(msg string, args ...any) { L.Error(msg, args...) }
|
||||
|
||||
func Debugf(format string, args ...any) {
|
||||
L.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
func Infof(format string, args ...any) {
|
||||
L.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
func Warnf(format string, args ...any) {
|
||||
L.Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
func Errorf(format string, args ...any) {
|
||||
L.Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func Fatalf(format string, args ...any) {
|
||||
L.Error(fmt.Sprintf(format, args...))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func Fatal(msg string, args ...any) {
|
||||
L.Error(msg, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ package background
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -45,19 +45,19 @@ func New(enabled bool, thinkInterval, silenceTimeout time.Duration) *Thinker {
|
||||
// Start begins the background thinking loops.
|
||||
func (t *Thinker) Start(ctx context.Context) {
|
||||
if !t.enabled {
|
||||
log.Println("[thinker] background thinking disabled")
|
||||
logger.Info("[thinker] background thinking disabled")
|
||||
return
|
||||
}
|
||||
|
||||
go t.silenceLoop(ctx)
|
||||
go t.postChatLoop(ctx)
|
||||
log.Printf("[thinker] started (interval=%v, silence=%v)", t.thinkInterval, t.silenceTimeout)
|
||||
logger.Infof("[thinker] started (interval=%v, silence=%v)", t.thinkInterval, t.silenceTimeout)
|
||||
}
|
||||
|
||||
// Stop shuts down the thinker.
|
||||
func (t *Thinker) Stop() {
|
||||
close(t.stopCh)
|
||||
log.Println("[thinker] stopped")
|
||||
logger.Info("[thinker] stopped")
|
||||
}
|
||||
|
||||
// TriggerPostChat schedules a post-chat think after a conversation.
|
||||
@@ -140,7 +140,7 @@ func (t *Thinker) generateThought(trigger string, sessionID string) {
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
log.Printf("[thinker] %s thought generated for session %s", trigger, sessionID)
|
||||
logger.Infof("[thinker] %s thought generated for session %s", trigger, sessionID)
|
||||
}
|
||||
|
||||
// RecentThoughts returns the most recent thoughts.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,19 +5,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type CallRecord struct {
|
||||
ID string `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Model string `json:"model"`
|
||||
Purpose string `json:"purpose"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompTokens int `json:"completion_tokens"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
type CallLogger struct {
|
||||
mu sync.RWMutex
|
||||
records []CallRecord
|
||||
max int
|
||||
@@ -25,24 +13,20 @@ type Logger struct {
|
||||
total int64
|
||||
}
|
||||
|
||||
func NewLogger(max int) *Logger { return &Logger{records: make([]CallRecord, max), max: max} }
|
||||
func NewCallLogger(max int) *CallLogger { return &CallLogger{records: make([]CallRecord, max), max: max} }
|
||||
|
||||
func (l *Logger) Record(r CallRecord) {
|
||||
func (l *CallLogger) Record(r CallRecord) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
r.ID = time.Now().Format("150405") + "-" + r.Purpose
|
||||
l.records[l.index%l.max] = r
|
||||
l.index++
|
||||
l.total++
|
||||
}
|
||||
|
||||
func (l *Logger) Recent(n int) []CallRecord {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
func (l *CallLogger) Recent(n int) []CallRecord {
|
||||
l.mu.RLock(); defer l.mu.RUnlock()
|
||||
if n > l.max { n = l.max }
|
||||
count := l.index
|
||||
if count > l.max { count = l.max }
|
||||
if n > count { n = count }
|
||||
count := l.index; if count > l.max { count = l.max }; if n > count { n = count }
|
||||
result := make([]CallRecord, n)
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (l.index - 1 - i + l.max) % l.max
|
||||
@@ -51,34 +35,27 @@ func (l *Logger) Recent(n int) []CallRecord {
|
||||
return result
|
||||
}
|
||||
|
||||
func (l *Logger) RecentMaps(n int) []map[string]any {
|
||||
records := l.Recent(n)
|
||||
result := make([]map[string]any, len(records))
|
||||
func (l *CallLogger) RecentMaps(n int) []map[string]any {
|
||||
records := l.Recent(n); result := make([]map[string]any, len(records))
|
||||
for i, r := range records {
|
||||
result[i] = map[string]any{
|
||||
"id": r.ID, "time": r.Time.Format(time.RFC3339), "model": r.Model,
|
||||
"purpose": r.Purpose, "prompt_tokens": r.PromptTokens, "completion_tokens": r.CompTokens,
|
||||
"time": r.Time.Format(time.RFC3339), "model": r.Model, "purpose": r.Purpose,
|
||||
"prompt_tokens": r.PromptTokens, "completion_tokens": r.CompletionTokens,
|
||||
"duration_ms": r.DurationMs, "success": r.Success, "error": r.Error,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (l *Logger) Stats() map[string]any {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
var calls, succ int64
|
||||
var dur, tok int64
|
||||
count := l.index
|
||||
if count > l.max { count = l.max }
|
||||
func (l *CallLogger) Stats() map[string]any {
|
||||
l.mu.RLock(); defer l.mu.RUnlock()
|
||||
var calls, succ int64; var dur, tok int64
|
||||
count := l.index; if count > l.max { count = l.max }
|
||||
for i := 0; i < count; i++ {
|
||||
r := l.records[(l.index-1-i+l.max)%l.max]
|
||||
calls++
|
||||
if r.Success { succ++ }
|
||||
dur += r.DurationMs
|
||||
tok += int64(r.PromptTokens + r.CompTokens)
|
||||
calls++; if r.Success { succ++ }
|
||||
dur += r.DurationMs; tok += int64(r.PromptTokens + r.CompletionTokens)
|
||||
}
|
||||
avg := 0
|
||||
if calls > 0 { avg = int(dur / calls) }
|
||||
avg := 0; if calls > 0 { avg = int(dur / calls) }
|
||||
return map[string]any{"total_calls": calls, "success_calls": succ, "avg_duration_ms": avg, "total_tokens": tok}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ package bridge
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -83,7 +83,7 @@ func (s *ConfigStore) StartWatching(interval time.Duration) {
|
||||
lastMod = info.ModTime()
|
||||
s.mu.Lock()
|
||||
if s.load() {
|
||||
log.Printf("[bridge] config hot-reloaded (%d adapters)", len(s.config.Adapters))
|
||||
logger.Infof("[bridge] config hot-reloaded (%d adapters)", len(s.config.Adapters))
|
||||
if s.onChange != nil {
|
||||
s.onChange(s.config)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (s *ConfigStore) load() bool {
|
||||
}
|
||||
var cfg BridgeConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
log.Printf("[bridge] config parse error: %v", err)
|
||||
logger.Infof("[bridge] config parse error: %v", err)
|
||||
return false
|
||||
}
|
||||
if cfg.Adapters == nil {
|
||||
|
||||
Reference in new issue
Block a user