diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index 7a2233e..e234056 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -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)) diff --git a/backend/internal/logger/global.go b/backend/internal/logger/global.go new file mode 100644 index 0000000..5147fc3 --- /dev/null +++ b/backend/internal/logger/global.go @@ -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) +} diff --git a/backend/services/ai-core/background/thinker.go b/backend/services/ai-core/background/thinker.go index efb20a3..a58bc1f 100644 --- a/backend/services/ai-core/background/thinker.go +++ b/backend/services/ai-core/background/thinker.go @@ -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. diff --git a/backend/services/ai-core/llm/llm.go b/backend/services/ai-core/llm/llm.go index d502aa4..0e8de79 100644 --- a/backend/services/ai-core/llm/llm.go +++ b/backend/services/ai-core/llm/llm.go @@ -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) diff --git a/backend/services/ai-core/llm/logger.go b/backend/services/ai-core/llm/logger.go index 2db6b4d..d33035b 100644 --- a/backend/services/ai-core/llm/logger.go +++ b/backend/services/ai-core/llm/logger.go @@ -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} } diff --git a/backend/services/platform-bridge/config.go b/backend/services/platform-bridge/config.go index 780884e..49bba9d 100644 --- a/backend/services/platform-bridge/config.go +++ b/backend/services/platform-bridge/config.go @@ -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 {