package api import ( "encoding/json" "fmt" "io" "net/http" "sort" "strings" "time" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/foundation/provider" "github.com/gin-gonic/gin" ) // ListProviders godoc // @Summary List providers // @Description List all configured upstream providers // @Tags admin // @Produce json // @Security AdminAuth // @Param page query int false "page (1-based)" // @Param limit query int false "limit (default 50, max 200)" // @Param search query string false "search by name/type/base_url/group" // @Success 200 {array} model.Provider // @Failure 500 {object} gin.H // @Router /admin/providers [get] func (h *Handler) ListProviders(c *gin.Context) { var providers []model.Provider q := h.db.Model(&model.Provider{}).Order("id desc") query := parseListQuery(c) q = applyListSearch(q, query.Search, "name", `"type"`, "base_url", `"group"`) q = applyListPagination(q, query) if err := q.Find(&providers).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers", "details": err.Error()}) return } c.JSON(http.StatusOK, providers) } // GetProvider godoc // @Summary Get provider // @Description Get a provider by id // @Tags admin // @Produce json // @Security AdminAuth // @Param id path int true "Provider ID" // @Success 200 {object} model.Provider // @Failure 400 {object} gin.H // @Failure 404 {object} gin.H // @Failure 500 {object} gin.H // @Router /admin/providers/{id} [get] func (h *Handler) GetProvider(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 } c.JSON(http.StatusOK, p) } // DeleteProvider godoc // @Summary Delete provider // @Description Deletes a provider and triggers a full snapshot sync to avoid stale routing // @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 500 {object} gin.H // @Router /admin/providers/{id} [delete] func (h *Handler) DeleteProvider(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 } if err := h.db.Delete(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider", "details": err.Error()}) return } if err := h.sync.SyncProviderDelete(&p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync provider delete", "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": "deleted"}) } type testProviderResponse struct { StatusCode int `json:"status_code"` OK bool `json:"ok"` URL string `json:"url"` Body string `json:"body,omitempty"` } // TestProvider godoc // @Summary Test provider connectivity // @Description Performs a lightweight upstream request to verify the provider configuration // @Tags admin // @Produce json // @Security AdminAuth // @Param id path int true "Provider ID" // @Success 200 {object} testProviderResponse // @Failure 400 {object} gin.H // @Failure 404 {object} gin.H // @Failure 500 {object} gin.H // @Router /admin/providers/{id}/test [post] func (h *Handler) TestProvider(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: 10 * time.Second} resp, err := client.Do(req) if err != nil { c.JSON(http.StatusOK, testProviderResponse{StatusCode: 0, OK: false, URL: req.URL.String(), Body: err.Error()}) return } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) ok = resp.StatusCode >= 200 && resp.StatusCode < 300 c.JSON(http.StatusOK, testProviderResponse{ StatusCode: resp.StatusCode, OK: ok, 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 }