Files
GoClaw/gateway/internal/api/handlers.go
bboxwtf 73bfa99c67 feat(metrics): persist orchestrator call stats to agentMetrics + agentHistory
- db.go: added SaveMetric(MetricInput) and SaveHistory(HistoryInput) methods
  that write directly to MySQL; non-fatal (log-only on error)
- handlers.go (OrchestratorStream): after each SSE stream finishes, an async
  goroutine saves agentMetrics (agentId, requestId, tokens, processingTimeMs,
  model, toolsCalled, status) and agentHistory (userMessage, agentResponse);
  both error and success paths covered; orchAgentID resolved from DB
- routers.ts (agents.chat): saveMetric() called for both success and error paths
  in the Node.js direct-chat fallback (was only saving agentHistory before)
- Verified: agentMetrics row ID=2 shows processingTimeMs=2133, totalTokens=143,
  model=minimax-m2.7, Cyrillic text stored correctly as UTF-8
2026-03-21 16:17:15 +00:00

680 lines
21 KiB
Go

// Package api implements the HTTP REST API for the GoClaw Gateway.
package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
"git.softuniq.eu/UniqAI/GoClaw/gateway/config"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/db"
dockerclient "git.softuniq.eu/UniqAI/GoClaw/gateway/internal/docker"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/llm"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/orchestrator"
"git.softuniq.eu/UniqAI/GoClaw/gateway/internal/tools"
)
// Handler holds all dependencies for HTTP handlers.
type Handler struct {
cfg *config.Config
llm *llm.Client
orch *orchestrator.Orchestrator
db *db.DB
docker *dockerclient.DockerClient
}
func NewHandler(cfg *config.Config, llmClient *llm.Client, orch *orchestrator.Orchestrator, database *db.DB) *Handler {
return &Handler{
cfg: cfg,
llm: llmClient,
orch: orch,
db: database,
docker: dockerclient.NewDockerClient(),
}
}
// ─── Health ───────────────────────────────────────────────────────────────────
// GET /health
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
ollamaOK, latency, _ := h.llm.Health(ctx)
respond(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "goclaw-gateway",
"version": "1.0.0",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"ollama": map[string]any{
"connected": ollamaOK,
"latencyMs": latency,
},
})
}
// ─── Orchestrator ─────────────────────────────────────────────────────────────
// POST /api/orchestrator/chat
func (h *Handler) OrchestratorChat(w http.ResponseWriter, r *http.Request) {
var req struct {
Messages []orchestrator.Message `json:"messages"`
Model string `json:"model,omitempty"`
MaxIter int `json:"maxIter,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if len(req.Messages) == 0 {
respondError(w, http.StatusBadRequest, "messages array is required")
return
}
log.Printf("[API] POST /api/orchestrator/chat — messages=%d model=%q", len(req.Messages), req.Model)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.cfg.RequestTimeoutSecs)*time.Second)
defer cancel()
result := h.orch.Chat(ctx, req.Messages, req.Model, req.MaxIter)
respond(w, http.StatusOK, result)
}
// GET /api/orchestrator/config
func (h *Handler) OrchestratorConfig(w http.ResponseWriter, r *http.Request) {
cfg := h.orch.GetConfig()
respond(w, http.StatusOK, map[string]any{
"id": cfg.ID,
"name": cfg.Name,
"model": cfg.Model,
"temperature": cfg.Temperature,
"maxTokens": cfg.MaxTokens,
"allowedTools": cfg.AllowedTools,
"systemPromptPreview": truncate(cfg.SystemPrompt, 200),
})
}
// ─── SSE Stream ───────────────────────────────────────────────────────────────
// SSE event types
const (
sseEventToolCall = "tool_call"
sseEventDelta = "delta"
sseEventDone = "done"
sseEventError = "error"
sseEventThinking = "thinking"
)
// streamEvent is a single SSE event sent to the client.
type streamEvent struct {
Type string `json:"type"`
// For delta events
Content string `json:"content,omitempty"`
// For tool_call events
Tool string `json:"tool,omitempty"`
Args any `json:"args,omitempty"`
Result any `json:"result,omitempty"`
Success *bool `json:"success,omitempty"`
DurationMs *int64 `json:"durationMs,omitempty"`
// For done events
Model string `json:"model,omitempty"`
ModelWarning string `json:"modelWarning,omitempty"`
Usage *llm.Usage `json:"usage,omitempty"`
// For error events
Error string `json:"error,omitempty"`
}
// writeSSE writes a single SSE event to the response writer and flushes.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event streamEvent) {
data, err := json.Marshal(event)
if err != nil {
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
// POST /api/orchestrator/stream
// SSE endpoint: streams tool-call events and LLM delta tokens in real time.
func (h *Handler) OrchestratorStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
respondError(w, http.StatusInternalServerError, "streaming not supported")
return
}
var req struct {
Messages []orchestrator.Message `json:"messages"`
Model string `json:"model,omitempty"`
MaxIter int `json:"maxIter,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if len(req.Messages) == 0 {
respondError(w, http.StatusBadRequest, "messages array is required")
return
}
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
flusher.Flush()
log.Printf("[API] POST /api/orchestrator/stream — messages=%d model=%q", len(req.Messages), req.Model)
// Extract the last user message for history/metrics storage
userMessage := ""
for i := len(req.Messages) - 1; i >= 0; i-- {
if req.Messages[i].Role == "user" {
userMessage = req.Messages[i].Content
break
}
}
// Determine orchestrator agent ID (look for isOrchestrator=1 in DB)
orchAgentID := 1 // fallback to agent ID 1
if h.db != nil {
if cfg, err := h.db.GetOrchestratorConfig(); err == nil && cfg != nil {
orchAgentID = cfg.ID
}
}
startTime := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.cfg.RequestTimeoutSecs)*time.Second)
defer cancel()
// Run orchestration in a goroutine, streaming events via channel
type toolEvent struct {
step orchestrator.ToolCallStep
}
toolCh := make(chan toolEvent, 32)
doneCh := make(chan orchestrator.ChatResult, 1)
// Custom streaming orchestrator
go func() {
result := h.orch.ChatWithEvents(ctx, req.Messages, req.Model, req.MaxIter, func(step orchestrator.ToolCallStep) {
toolCh <- toolEvent{step: step}
})
close(toolCh)
doneCh <- result
}()
// Send thinking event
writeSSE(w, flusher, streamEvent{Type: sseEventThinking})
// Drain tool events
for ev := range toolCh {
success := ev.step.Success
dur := ev.step.DurationMs
writeSSE(w, flusher, streamEvent{
Type: sseEventToolCall,
Tool: ev.step.Tool,
Args: ev.step.Args,
Result: ev.step.Result,
Success: &success,
DurationMs: &dur,
Error: ev.step.Error,
})
}
// Get final result
result := <-doneCh
if !result.Success {
writeSSE(w, flusher, streamEvent{Type: sseEventError, Error: result.Error})
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
// Persist error metric + history (fire-and-forget goroutine)
if h.db != nil {
go func() {
reqID := fmt.Sprintf("orch-%d", time.Now().UnixNano())
h.db.SaveMetric(db.MetricInput{
AgentID: orchAgentID,
RequestID: reqID,
UserMessage: userMessage,
ProcessingTimeMs: time.Since(startTime).Milliseconds(),
Status: "error",
ErrorMessage: result.Error,
Model: result.Model,
})
h.db.SaveHistory(db.HistoryInput{
AgentID: orchAgentID,
UserMessage: userMessage,
AgentResponse: "",
Status: "error",
})
}()
}
return
}
// Stream the response in rune-safe chunks (important for UTF-8 / Cyrillic).
// We convert to []rune first so we never split a multi-byte character.
const runeChunkSize = 6
runes := []rune(result.Response)
for i := 0; i < len(runes); i += runeChunkSize {
end := i + runeChunkSize
if end > len(runes) {
end = len(runes)
}
writeSSE(w, flusher, streamEvent{
Type: sseEventDelta,
Content: string(runes[i:end]),
})
select {
case <-ctx.Done():
return
default:
}
}
// Send done event
writeSSE(w, flusher, streamEvent{
Type: sseEventDone,
Model: result.Model,
ModelWarning: result.ModelWarning,
Usage: result.Usage,
})
fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
// Persist metrics + history asynchronously (never blocks the response)
if h.db != nil {
go func() {
reqID := fmt.Sprintf("orch-%d", time.Now().UnixNano())
var inputTok, outputTok, totalTok int
if result.Usage != nil {
inputTok = result.Usage.PromptTokens
outputTok = result.Usage.CompletionTokens
totalTok = result.Usage.TotalTokens
}
toolNames := make([]string, len(result.ToolCalls))
for i, tc := range result.ToolCalls {
toolNames[i] = tc.Tool
}
h.db.SaveMetric(db.MetricInput{
AgentID: orchAgentID,
RequestID: reqID,
UserMessage: userMessage,
AgentResponse: result.Response,
InputTokens: inputTok,
OutputTokens: outputTok,
TotalTokens: totalTok,
ProcessingTimeMs: time.Since(startTime).Milliseconds(),
Status: "success",
ToolsCalled: toolNames,
Model: result.Model,
})
h.db.SaveHistory(db.HistoryInput{
AgentID: orchAgentID,
UserMessage: userMessage,
AgentResponse: result.Response,
Status: "success",
})
}()
}
}
// ─── Providers Reload ─────────────────────────────────────────────────────────
// POST /api/providers/reload
// Node.js calls this after activating a provider, sending the decrypted API key in the body.
// Body: { "name": "...", "baseUrl": "...", "apiKey": "...", "modelDefault": "..." }
func (h *Handler) ProvidersReload(w http.ResponseWriter, r *http.Request) {
// Try to read the decrypted credentials from the request body (preferred path)
var body struct {
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
ModelDefault string `json:"modelDefault"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err == nil && body.BaseURL != "" {
h.llm.UpdateCredentials(body.BaseURL, body.APIKey)
log.Printf("[API] Provider reloaded from body: %s (%s)", body.Name, body.BaseURL)
respond(w, http.StatusOK, map[string]any{
"ok": true,
"name": body.Name,
"baseUrl": body.BaseURL,
})
return
}
// Fallback: try to read from DB (key will be empty since Go can't decrypt it)
if h.db != nil {
provider, err := h.db.GetActiveProvider()
if err == nil && provider != nil {
h.llm.UpdateCredentials(provider.BaseURL, provider.APIKey)
log.Printf("[API] Provider reloaded from DB: %s (%s)", provider.Name, provider.BaseURL)
respond(w, http.StatusOK, map[string]any{
"ok": true,
"name": provider.Name,
"baseUrl": provider.BaseURL,
})
return
}
if err != nil {
log.Printf("[API] ProvidersReload: DB error: %v", err)
}
}
respond(w, http.StatusOK, map[string]any{"ok": true, "note": "No provider data received"})
}
// ─── Agents ───────────────────────────────────────────────────────────────────
// GET /api/agents
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
if h.db == nil {
respond(w, http.StatusOK, map[string]any{"agents": []any{}, "note": "DB not connected"})
return
}
agents, err := h.db.ListAgents()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list agents: "+err.Error())
return
}
respond(w, http.StatusOK, map[string]any{"agents": agents, "count": len(agents)})
}
// GET /api/agents/{id}
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid agent id")
return
}
if h.db == nil {
respondError(w, http.StatusServiceUnavailable, "DB not connected")
return
}
agent, err := h.db.GetAgentByID(id)
if err != nil {
respondError(w, http.StatusNotFound, "agent not found")
return
}
respond(w, http.StatusOK, agent)
}
// ─── Models ───────────────────────────────────────────────────────────────────
// GET /api/models
func (h *Handler) ListModels(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
models, err := h.llm.ListModels(ctx)
if err != nil {
respondError(w, http.StatusBadGateway, "failed to fetch models: "+err.Error())
return
}
respond(w, http.StatusOK, models)
}
// ─── Tools ────────────────────────────────────────────────────────────────────
// GET /api/tools
func (h *Handler) ListTools(w http.ResponseWriter, r *http.Request) {
toolDefs := tools.OrchestratorTools()
respond(w, http.StatusOK, map[string]any{
"tools": toolDefs,
"count": len(toolDefs),
})
}
// POST /api/tools/execute
func (h *Handler) ExecuteTool(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if req.Name == "" {
respondError(w, http.StatusBadRequest, "tool name is required")
return
}
argsJSON, _ := json.Marshal(req.Arguments)
executor := tools.NewExecutor("/", nil)
result := executor.Execute(r.Context(), req.Name, string(argsJSON))
respond(w, http.StatusOK, map[string]any{"result": result})
}
// ─── Nodes ────────────────────────────────────────────────────────────────────
// NodeInfo is the unified node response sent to the frontend.
type NodeInfo struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Role string `json:"role"`
Status string `json:"status"`
Availability string `json:"availability"`
IP string `json:"ip"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUCores int `json:"cpuCores"`
MemTotalMB int64 `json:"memTotalMB"`
DockerVersion string `json:"dockerVersion"`
IsLeader bool `json:"isLeader"`
ManagerAddr string `json:"managerAddr,omitempty"`
Labels map[string]string `json:"labels"`
UpdatedAt string `json:"updatedAt"`
}
// ContainerInfo is a slim container summary per node.
type ContainerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
}
// GET /api/nodes
func (h *Handler) ListNodes(w http.ResponseWriter, r *http.Request) {
// Check if Swarm is active
swarmActive := h.docker.IsSwarmActive()
if swarmActive {
// Return real Swarm nodes
nodes, err := h.docker.ListNodes()
if err != nil {
log.Printf("[API] ListNodes swarm error: %v — falling back to local info", err)
h.listLocalNode(w, r)
return
}
result := make([]NodeInfo, 0, len(nodes))
for _, n := range nodes {
info := NodeInfo{
ID: n.ID[:12],
Hostname: n.Description.Hostname,
Role: n.Spec.Role,
Status: n.Status.State,
Availability: n.Spec.Availability,
IP: n.Status.Addr,
OS: n.Description.Platform.OS,
Arch: n.Description.Platform.Architecture,
CPUCores: int(n.Description.Resources.NanoCPUs / 1e9),
MemTotalMB: n.Description.Resources.MemoryBytes / (1024 * 1024),
DockerVersion: n.Description.Engine.EngineVersion,
Labels: n.Spec.Labels,
UpdatedAt: n.UpdatedAt.UTC().Format(time.RFC3339),
}
if n.ManagerStatus != nil {
info.IsLeader = n.ManagerStatus.Leader
info.ManagerAddr = n.ManagerStatus.Addr
}
if info.Labels == nil {
info.Labels = map[string]string{}
}
result = append(result, info)
}
swarmInfo, _ := h.docker.GetSwarmInfo()
managers, totalNodes := 0, len(result)
if swarmInfo != nil {
managers = swarmInfo.Swarm.Managers
totalNodes = swarmInfo.Swarm.Nodes
}
respond(w, http.StatusOK, map[string]any{
"nodes": result,
"count": len(result),
"swarmActive": true,
"managers": managers,
"totalNodes": totalNodes,
"fetchedAt": time.Now().UTC().Format(time.RFC3339),
})
return
}
// Swarm not active — return local Docker host info
h.listLocalNode(w, r)
}
// listLocalNode returns info about the current Docker host as a single "node".
func (h *Handler) listLocalNode(w http.ResponseWriter, r *http.Request) {
info, err := h.docker.GetSwarmInfo()
hostname := "localhost"
if err == nil && info != nil {
_ = info // use for future enrichment
}
// Get containers running on this host
containers, _ := h.docker.ListContainers()
containerInfos := make([]ContainerInfo, 0, len(containers))
for _, c := range containers {
name := c.ID[:12]
if len(c.Names) > 0 {
name = c.Names[0]
if len(name) > 0 && name[0] == '/' {
name = name[1:]
}
}
containerInfos = append(containerInfos, ContainerInfo{
ID: c.ID[:12],
Name: name,
Image: c.Image,
State: c.State,
Status: c.Status,
})
}
node := NodeInfo{
ID: "local-01",
Hostname: hostname,
Role: "standalone",
Status: "ready",
Availability: "active",
IP: "127.0.0.1",
DockerVersion: "unknown",
Labels: map[string]string{},
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
respond(w, http.StatusOK, map[string]any{
"nodes": []NodeInfo{node},
"count": 1,
"swarmActive": false,
"containers": containerInfos,
"fetchedAt": time.Now().UTC().Format(time.RFC3339),
})
}
// GET /api/nodes/stats
// Returns live container stats (CPU%, RAM) for containers on this host.
func (h *Handler) NodeStats(w http.ResponseWriter, r *http.Request) {
containers, err := h.docker.ListContainers()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list containers: "+err.Error())
return
}
type ContainerStat struct {
ID string `json:"id"`
Name string `json:"name"`
CPUPct float64 `json:"cpuPct"`
MemUseMB float64 `json:"memUseMB"`
MemLimMB float64 `json:"memLimMB"`
MemPct float64 `json:"memPct"`
}
stats := make([]ContainerStat, 0, len(containers))
for _, c := range containers {
s, err := h.docker.GetContainerStats(c.ID)
if err != nil {
continue
}
name := c.ID[:12]
if len(c.Names) > 0 {
name = c.Names[0]
if len(name) > 0 && name[0] == '/' {
name = name[1:]
}
}
cpuPct := dockerclient.CalcCPUPercent(s)
memUse := float64(s.MemoryStats.Usage) / (1024 * 1024)
memLim := float64(s.MemoryStats.Limit) / (1024 * 1024)
memPct := 0.0
if memLim > 0 {
memPct = (memUse / memLim) * 100
}
stats = append(stats, ContainerStat{
ID: c.ID[:12],
Name: name,
CPUPct: round2(cpuPct),
MemUseMB: round2(memUse),
MemLimMB: round2(memLim),
MemPct: round2(memPct),
})
}
respond(w, http.StatusOK, map[string]any{
"stats": stats,
"count": len(stats),
"fetchedAt": time.Now().UTC().Format(time.RFC3339),
})
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func respond(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, msg string) {
respond(w, status, map[string]any{"error": msg})
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func round2(f float64) float64 {
return float64(int(f*100)) / 100
}
func init() {
_ = fmt.Sprintf // suppress unused import
}