From f7baa0f08f2fe2c80337762e85bed8d1b1c7b81f Mon Sep 17 00:00:00 2001 From: zenfun Date: Sun, 21 Dec 2025 20:22:56 +0800 Subject: [PATCH] feat(ops): add status test and model fetch --- cmd/server/main.go | 3 + internal/api/health_handler.go | 37 ++++ internal/api/provider_admin_handler.go | 235 +++++++++++++++++++++---- 3 files changed, 237 insertions(+), 38 deletions(-) create mode 100644 internal/api/health_handler.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 2f015c3..e735583 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -150,6 +150,7 @@ func main() { } masterService := service.NewMasterService(db) healthService := service.NewHealthCheckService(db, rdb) + healthHandler := api.NewHealthHandler(healthService) handler := api.NewHandler(db, logDB, syncService, logWriter, rdb) adminHandler := api.NewAdminHandler(db, logDB, masterService, syncService) @@ -201,6 +202,7 @@ func main() { } c.JSON(httpStatus, status) }) + r.GET("/api/status/test", healthHandler.TestDeps) // Swagger Documentation r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) @@ -245,6 +247,7 @@ func main() { adminGroup.PUT("/providers/:id", handler.UpdateProvider) adminGroup.DELETE("/providers/:id", handler.DeleteProvider) adminGroup.POST("/providers/:id/test", handler.TestProvider) + adminGroup.POST("/providers/:id/fetch-models", handler.FetchProviderModels) adminGroup.POST("/models", handler.CreateModel) adminGroup.GET("/models", handler.ListModels) adminGroup.PUT("/models/:id", handler.UpdateModel) diff --git a/internal/api/health_handler.go b/internal/api/health_handler.go new file mode 100644 index 0000000..93ece24 --- /dev/null +++ b/internal/api/health_handler.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" + + "github.com/ez-api/ez-api/internal/service" + "github.com/gin-gonic/gin" +) + +type HealthHandler struct { + svc *service.HealthCheckService +} + +func NewHealthHandler(svc *service.HealthCheckService) *HealthHandler { + return &HealthHandler{svc: svc} +} + +// TestDeps godoc +// @Summary Test dependency connectivity +// @Description Checks Redis/PostgreSQL connections and reports status +// @Tags system +// @Produce json +// @Success 200 {object} service.HealthStatus +// @Failure 503 {object} service.HealthStatus +// @Router /api/status/test [get] +func (h *HealthHandler) TestDeps(c *gin.Context) { + if h == nil || h.svc == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "down"}) + return + } + status := h.svc.Check(c.Request.Context()) + httpStatus := http.StatusOK + if status.Status == "down" { + httpStatus = http.StatusServiceUnavailable + } + c.JSON(httpStatus, status) +} diff --git a/internal/api/provider_admin_handler.go b/internal/api/provider_admin_handler.go index 8286771..b68d4ad 100644 --- a/internal/api/provider_admin_handler.go +++ b/internal/api/provider_admin_handler.go @@ -1,8 +1,11 @@ package api import ( + "encoding/json" + "fmt" "io" "net/http" + "sort" "strings" "time" @@ -123,49 +126,16 @@ func (h *Handler) TestProvider(c *gin.Context) { return } - pt := provider.NormalizeType(p.Type) - baseURL := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/") - if baseURL == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required for provider test"}) - return - } - - url := "" - var req *http.Request - - switch pt { - case provider.TypeOpenAI, provider.TypeCompatible: - url = baseURL + "/models" - r, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build request", "details": err.Error()}) - return - } - req = r - if strings.TrimSpace(p.APIKey) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(p.APIKey)) - } - case provider.TypeAnthropic, provider.TypeClaude: - url = baseURL + "/v1/models" - r, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build request", "details": err.Error()}) - return - } - req = r - if strings.TrimSpace(p.APIKey) != "" { - req.Header.Set("x-api-key", strings.TrimSpace(p.APIKey)) - } - req.Header.Set("anthropic-version", "2023-06-01") - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "provider type not supported for test"}) + req, err := buildProviderModelsRequest(&p) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { - c.JSON(http.StatusOK, testProviderResponse{StatusCode: 0, OK: false, URL: url, Body: err.Error()}) + c.JSON(http.StatusOK, testProviderResponse{StatusCode: 0, OK: false, URL: req.URL.String(), Body: err.Error()}) return } defer resp.Body.Close() @@ -176,7 +146,196 @@ func (h *Handler) TestProvider(c *gin.Context) { c.JSON(http.StatusOK, testProviderResponse{ StatusCode: resp.StatusCode, OK: ok, - URL: url, + URL: req.URL.String(), Body: string(body), }) } + +// FetchProviderModels godoc +// @Summary Fetch models from provider +// @Description Calls upstream /models (or /v1/models) and updates provider model list +// @Tags admin +// @Produce json +// @Security AdminAuth +// @Param id path int true "Provider ID" +// @Success 200 {object} gin.H +// @Failure 400 {object} gin.H +// @Failure 404 {object} gin.H +// @Failure 502 {object} gin.H +// @Router /admin/providers/{id}/fetch-models [post] +func (h *Handler) FetchProviderModels(c *gin.Context) { + id, ok := parseUintParam(c, "id") + if !ok { + return + } + var p model.Provider + if err := h.db.First(&p, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + + req, err := buildProviderModelsRequest(&p) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models", "details": err.Error()}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + c.JSON(http.StatusBadGateway, gin.H{ + "error": "upstream returned non-2xx", + "status_code": resp.StatusCode, + "body": string(body), + }) + return + } + + models, err := parseProviderModelIDs(body) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse models", "details": err.Error()}) + return + } + + if err := h.db.Model(&p).Update("models", strings.Join(models, ",")).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider models", "details": err.Error()}) + return + } + if err := h.db.First(&p, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload provider", "details": err.Error()}) + return + } + if err := h.sync.SyncProvider(&p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync provider", "details": err.Error()}) + return + } + if err := h.sync.SyncBindings(h.db); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync bindings", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "updated", + "count": len(models), + "models": models, + }) +} + +func buildProviderModelsRequest(p *model.Provider) (*http.Request, error) { + if p == nil { + return nil, fmt.Errorf("provider required") + } + pt := provider.NormalizeType(p.Type) + baseURL := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/") + if baseURL == "" { + return nil, fmt.Errorf("base_url required for provider models fetch") + } + + url := "" + switch pt { + case provider.TypeOpenAI, provider.TypeCompatible: + if strings.HasSuffix(baseURL, "/v1") { + url = baseURL + "/models" + } else { + url = baseURL + "/v1/models" + } + case provider.TypeAnthropic, provider.TypeClaude: + url = baseURL + "/v1/models" + default: + return nil, fmt.Errorf("provider type not supported for model fetch") + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + apiKey := strings.TrimSpace(p.APIKey) + switch pt { + case provider.TypeOpenAI, provider.TypeCompatible: + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + case provider.TypeAnthropic, provider.TypeClaude: + if apiKey != "" { + req.Header.Set("x-api-key", apiKey) + } + req.Header.Set("anthropic-version", "2023-06-01") + } + + return req, nil +} + +type providerModelsResponse struct { + Data []json.RawMessage `json:"data"` + Models []string `json:"models"` +} + +func parseProviderModelIDs(payload []byte) ([]string, error) { + var resp providerModelsResponse + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + models := make([]string, 0, len(resp.Data)+len(resp.Models)) + for _, name := range resp.Models { + name = strings.TrimSpace(name) + if name != "" { + models = append(models, name) + } + } + for _, raw := range resp.Data { + var item struct { + ID string `json:"id"` + Model string `json:"model"` + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &item); err == nil { + if item.ID != "" { + models = append(models, strings.TrimSpace(item.ID)) + continue + } + if item.Model != "" { + models = append(models, strings.TrimSpace(item.Model)) + continue + } + if item.Name != "" { + models = append(models, strings.TrimSpace(item.Name)) + continue + } + } + var name string + if err := json.Unmarshal(raw, &name); err == nil { + name = strings.TrimSpace(name) + if name != "" { + models = append(models, name) + } + } + } + + unique := make(map[string]struct{}, len(models)) + out := make([]string, 0, len(models)) + for _, name := range models { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if _, ok := unique[name]; ok { + continue + } + unique[name] = struct{}{} + out = append(out, name) + } + if len(out) == 0 { + return nil, fmt.Errorf("no models found in response") + } + sort.Strings(out) + return out, nil +}