From 12db90dc3b6d2e34a9e5f360584c5309aa43df29 Mon Sep 17 00:00:00 2001 From: Grendgi Date: Mon, 8 Jun 2026 13:45:55 +0300 Subject: [PATCH] Add AI provider status endpoint --- .gitignore | 1 + README.md | 2 + internal/httpapi/providers.go | 172 ++++++++++++++++++++++++++++++++++ internal/httpapi/server.go | 2 + 4 files changed, 177 insertions(+) create mode 100644 internal/httpapi/providers.go diff --git a/.gitignore b/.gitignore index 751a610..1018178 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ dist/ tmp/ .DS_Store +.gocache/ diff --git a/README.md b/README.md index ed0fdcc..6b803ca 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ service. - `POST /api/v1/jobs/{id}/fail` stores a failed job category and message. - `POST /api/v1/jobs/{id}/retry` resets failed/running jobs to `pending`. - `GET /api/v1/stats` returns queue and error counters. +- `GET /api/v1/providers/status` checks configured AI providers without + returning secrets. - `GET /healthz` returns process health. - `GET /readyz` checks PostgreSQL readiness. diff --git a/internal/httpapi/providers.go b/internal/httpapi/providers.go new file mode 100644 index 0000000..7ce3301 --- /dev/null +++ b/internal/httpapi/providers.go @@ -0,0 +1,172 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type providerStatus struct { + Name string `json:"name"` + Configured bool `json:"configured"` + OK bool `json:"ok"` + URL string `json:"url,omitempty"` + Model string `json:"model,omitempty"` + LatencyMS int64 `json:"latency_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +type providersStatusResponse struct { + At time.Time `json:"at"` + Providers []providerStatus `json:"providers"` +} + +func (s *Server) handleProviderStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := contextWithTimeout(r, 8*time.Second) + defer cancel() + + resp := providersStatusResponse{ + At: time.Now().UTC(), + Providers: []providerStatus{ + s.checkLLM(ctx), + s.checkWhisperX(ctx), + s.checkOpenClaw(ctx), + }, + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) checkLLM(ctx context.Context) providerStatus { + st := providerStatus{ + Name: "llm", + Configured: strings.TrimSpace(s.cfg.LLMBaseURL) != "", + URL: strings.TrimRight(s.cfg.LLMBaseURL, "/"), + Model: s.cfg.LLMModel, + } + if !st.Configured { + return st + } + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, st.URL+"/v1/models", nil) + if err != nil { + st.Error = err.Error() + return st + } + if s.cfg.LLMAPIKey != "" { + req.Header.Set("Authorization", "Bearer "+s.cfg.LLMAPIKey) + } + httpClient := &http.Client{Timeout: s.cfg.LLMTimeout} + res, err := httpClient.Do(req) + st.LatencyMS = time.Since(start).Milliseconds() + if err != nil { + st.Error = err.Error() + return st + } + defer res.Body.Close() + if res.StatusCode >= 300 { + st.Error = fmt.Sprintf("http %d: %s", res.StatusCode, readSmallBody(res.Body)) + return st + } + st.OK = true + return st +} + +func (s *Server) checkWhisperX(ctx context.Context) providerStatus { + baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.WhisperXURL), "/") + st := providerStatus{Name: "whisperx", Configured: baseURL != "", URL: baseURL} + if !st.Configured { + return st + } + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/health", nil) + if err != nil { + st.Error = err.Error() + return st + } + res, err := http.DefaultClient.Do(req) + st.LatencyMS = time.Since(start).Milliseconds() + if err != nil { + st.Error = err.Error() + return st + } + defer res.Body.Close() + if res.StatusCode >= 300 { + st.Error = fmt.Sprintf("http %d: %s", res.StatusCode, readSmallBody(res.Body)) + return st + } + st.OK = true + return st +} + +func (s *Server) checkOpenClaw(ctx context.Context) providerStatus { + baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.OpenClawURL), "/") + st := providerStatus{Name: "openclaw", Configured: baseURL != "", URL: baseURL} + if !st.Configured { + return st + } + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz", nil) + if err != nil { + st.Error = err.Error() + return st + } + res, err := http.DefaultClient.Do(req) + st.LatencyMS = time.Since(start).Milliseconds() + if err != nil { + st.Error = err.Error() + return st + } + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { + return checkOpenClawHealth(ctx, baseURL, start) + } + if res.StatusCode >= 300 { + st.Error = fmt.Sprintf("http %d: %s", res.StatusCode, readSmallBody(res.Body)) + return st + } + st.OK = true + return st +} + +func checkOpenClawHealth(ctx context.Context, baseURL string, start time.Time) providerStatus { + st := providerStatus{Name: "openclaw", Configured: true, URL: baseURL} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/health", nil) + if err != nil { + st.Error = err.Error() + return st + } + res, err := http.DefaultClient.Do(req) + st.LatencyMS = time.Since(start).Milliseconds() + if err != nil { + st.Error = err.Error() + return st + } + defer res.Body.Close() + if res.StatusCode >= 300 { + st.Error = fmt.Sprintf("http %d: %s", res.StatusCode, readSmallBody(res.Body)) + return st + } + st.OK = true + return st +} + +func readSmallBody(r io.Reader) string { + body, err := io.ReadAll(io.LimitReader(r, 512)) + if err != nil { + return err.Error() + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err == nil { + if msg, ok := parsed["error"].(string); ok { + return msg + } + if msg, ok := parsed["detail"].(string); ok { + return msg + } + } + return strings.TrimSpace(string(body)) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a4147c1..2ad6ade 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -51,6 +51,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleFailJob(w, r, path) case r.Method == http.MethodGet && path == "/api/v1/stats": s.handleStats(w, r) + case r.Method == http.MethodGet && path == "/api/v1/providers/status": + s.handleProviderStatus(w, r) default: writeError(w, http.StatusNotFound, "not found") }