71f0a1abdb
- 所有Go模块路径从 github.com/yourname/cyrene-ai 迁移到 git.yeij.top/AskaEth/Cyrene - 5个Go Dockerfile添加 GOPROXY=https://goproxy.cn,direct 解决国内构建问题 - ai-core go.mod 添加 pkg/plugins replace 指令 - Caddyfile 简化为 http:// 通配 + handle 保留 /api 前缀 - ethend Dockerfile 适配 (npm install + 仅 COPY package.json) - ethend 新增 RUNNING_IN_DOCKER 环境变量,健康检查改用Docker服务名 - ethend 数据库状态检查支持Docker hostname (postgres/redis/qdrant/minio) - process-manager 新增 CONTAINER_SVC_MAP + Docker模式自动检测 - 统一 docker-compose.dev.db.yml 卷名 (pg_data/redis_data/qdrant_data/minio_data) - docker-compose.yml ethend服务挂载docker.sock + 端口变量化 - 清理 .env 统一后的残留文件与提示信息 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
298 lines
8.1 KiB
Go
298 lines
8.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene/gateway/internal/config"
|
|
)
|
|
|
|
// ModelConfigHandler exposes admin CRUD endpoints for model configuration.
|
|
type ModelConfigHandler struct {
|
|
store *config.ModelsConfigStore
|
|
}
|
|
|
|
func NewModelConfigHandler(store *config.ModelsConfigStore) *ModelConfigHandler {
|
|
return &ModelConfigHandler{store: store}
|
|
}
|
|
|
|
// ---- Providers ----
|
|
|
|
func (h *ModelConfigHandler) ListProviders(c *gin.Context) {
|
|
providers := h.store.ListProviders()
|
|
if providers == nil {
|
|
providers = []*config.ProviderConfig{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"providers": providers, "total": len(providers)})
|
|
}
|
|
|
|
func (h *ModelConfigHandler) GetProvider(c *gin.Context) {
|
|
name := c.Param("name")
|
|
p, err := h.store.GetProvider(name)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, p)
|
|
}
|
|
|
|
func (h *ModelConfigHandler) SetProvider(c *gin.Context) {
|
|
name := c.Param("name")
|
|
var body config.ProviderConfig
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
body.Name = name
|
|
if err := h.store.SetProvider(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "saved", "name": name})
|
|
}
|
|
|
|
func (h *ModelConfigHandler) DeleteProvider(c *gin.Context) {
|
|
name := c.Param("name")
|
|
if err := h.store.DeleteProvider(name); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted", "name": name})
|
|
}
|
|
|
|
// ---- Models ----
|
|
|
|
func (h *ModelConfigHandler) ListModels(c *gin.Context) {
|
|
models := h.store.ListModels()
|
|
if models == nil {
|
|
models = []*config.ModelConfig{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"models": models, "total": len(models)})
|
|
}
|
|
|
|
func (h *ModelConfigHandler) GetModel(c *gin.Context) {
|
|
id := c.Param("id")
|
|
m, err := h.store.GetModel(id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, m)
|
|
}
|
|
|
|
func (h *ModelConfigHandler) SetModel(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var body config.ModelConfig
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
body.ID = id
|
|
if err := h.store.SetModel(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "saved", "id": id})
|
|
}
|
|
|
|
func (h *ModelConfigHandler) DeleteModel(c *gin.Context) {
|
|
id := c.Param("id")
|
|
if err := h.store.DeleteModel(id); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted", "id": id})
|
|
}
|
|
|
|
// ---- Routing ----
|
|
|
|
func (h *ModelConfigHandler) ListRouting(c *gin.Context) {
|
|
routing := h.store.ListRouting()
|
|
if routing == nil {
|
|
routing = []*config.RoutingRule{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"routing": routing, "total": len(routing)})
|
|
}
|
|
|
|
func (h *ModelConfigHandler) GetRouting(c *gin.Context) {
|
|
purpose := c.Param("purpose")
|
|
r, err := h.store.GetRouting(purpose)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, r)
|
|
}
|
|
|
|
func (h *ModelConfigHandler) SetRouting(c *gin.Context) {
|
|
purpose := c.Param("purpose")
|
|
var body config.RoutingRule
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
body.Purpose = purpose
|
|
if err := h.store.SetRouting(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "saved", "purpose": purpose})
|
|
}
|
|
|
|
func (h *ModelConfigHandler) DeleteRouting(c *gin.Context) {
|
|
purpose := c.Param("purpose")
|
|
if err := h.store.DeleteRouting(purpose); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted", "purpose": purpose})
|
|
}
|
|
|
|
// ---- Health Check ----
|
|
|
|
func (h *ModelConfigHandler) TestProvider(c *gin.Context) {
|
|
var body struct {
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
p, err := h.store.GetProvider(body.Provider)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"provider": p.Name,
|
|
"base_url": p.BaseURL,
|
|
"message": "Provider 配置已保存,连接测试请通过实际 LLM 调用验证",
|
|
})
|
|
}
|
|
|
|
// ---- Remote Model List Proxy ----
|
|
|
|
// ProxyListModels forwards a request to the provider's models endpoint using the stored API key.
|
|
func (h *ModelConfigHandler) ProxyListModels(c *gin.Context) {
|
|
providerName := c.Param("name")
|
|
modelsURL := c.Query("url")
|
|
if modelsURL == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing 'url' query parameter"})
|
|
return
|
|
}
|
|
|
|
p, err := h.store.GetProvider(providerName)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if p.APIKey == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider 未配置 API Key"})
|
|
return
|
|
}
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
req, err := http.NewRequest("GET", modelsURL, nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()})
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+p.APIKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "请求模型列表失败: " + err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 2 MB limit
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "读取响应失败: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
c.JSON(http.StatusBadGateway, gin.H{
|
|
"error": fmt.Sprintf("Provider API 返回错误 (HTTP %d)", resp.StatusCode),
|
|
"body": string(body),
|
|
"models_url": modelsURL,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Parse the response body which may use different formats:
|
|
// OpenAI: {"object":"list","data":[{"id":"...","object":"model",...}]}
|
|
// DashScope: {"request_id":"...","data":{"models":[{"model_id":"..."}]}}
|
|
// Generic: {"data":[{"id":"..."}]} or {"data":[{"model_id":"..."}]}
|
|
ids := parseModelListResponse(body)
|
|
if len(ids) == 0 {
|
|
c.JSON(http.StatusBadGateway, gin.H{
|
|
"error": "无法从 Provider 响应中解析模型列表 (不支持的格式)",
|
|
"raw": string(body),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"provider": providerName,
|
|
"url": modelsURL,
|
|
"models": ids,
|
|
"total": len(ids),
|
|
})
|
|
}
|
|
|
|
// parseModelListResponse attempts to extract model IDs from various provider response formats.
|
|
// Supported formats:
|
|
// - OpenAI-compatible: {"object":"list","data":[{"id":"gpt-4o",...}]}
|
|
// - DashScope: {"data":{"models":[{"model_id":"qwen-turbo",...}]}}
|
|
// - Generic: {"data":[{"id":"..."}]} or {"data":[{"model_id":"..."}]}
|
|
func parseModelListResponse(body []byte) []string {
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Strategy 1: data is an array of objects — try "id" then "model_id"
|
|
if dataArr, ok := raw["data"].([]interface{}); ok {
|
|
ids := extractIDs(dataArr, "id")
|
|
if len(ids) > 0 {
|
|
return ids
|
|
}
|
|
return extractIDs(dataArr, "model_id")
|
|
}
|
|
|
|
// Strategy 2: data is an object with a "models" array (DashScope format)
|
|
if dataObj, ok := raw["data"].(map[string]interface{}); ok {
|
|
if modelsArr, ok := dataObj["models"].([]interface{}); ok {
|
|
ids := extractIDs(modelsArr, "model_id")
|
|
if len(ids) > 0 {
|
|
return ids
|
|
}
|
|
return extractIDs(modelsArr, "id")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func extractIDs(items []interface{}, key string) []string {
|
|
ids := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if obj, ok := item.(map[string]interface{}); ok {
|
|
if v, ok := obj[key]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
ids = append(ids, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ids
|
|
}
|