Files
yunzerwebsiteallinone/go/controllers/platform_agent_api.go
T
2026-08-27 01:02:09 +08:00

978 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAgentApiController 智能体API管理(yz_platform_agent_api,平台端)
type PlatformAgentApiController struct {
beego.Controller
}
// agentApiHTTPClient 用于探测上游接口连通性
var agentApiHTTPClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
// 支持的请求协议白名单,与前端 constants.js 的 PROTOCOLS 保持一致
var agentApiProtocols = map[string]bool{
models.AgentProtocolChatCompletions: true,
models.AgentProtocolResponses: true,
models.AgentProtocolAnthropic: true,
models.AgentProtocolGemini: true,
models.AgentProtocolCustom: true,
}
// agentApiEndpointPath 各协议在根地址之后要追加的路径
// custom 与 gemini 不在此表中:custom 不追加任何路径,gemini 需要嵌入模型名
var agentApiEndpointPath = map[string]string{
models.AgentProtocolChatCompletions: "/chat/completions",
models.AgentProtocolResponses: "/responses",
models.AgentProtocolAnthropic: "/messages",
}
// normalizeProtocol 缺省或非法值统一回落到 Chat Completions
// 该协议被绝大多数官方接口、国产网关与自建中转支持,作为兜底最安全
func normalizeProtocol(p string) string {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" || !agentApiProtocols[p] {
return models.AgentProtocolChatCompletions
}
return p
}
func (c *PlatformAgentApiController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformAgentApiController) ok(msg string, data interface{}) {
resp := map[string]interface{}{"code": 200, "msg": msg}
if data != nil {
resp["data"] = data
}
c.Data["json"] = resp
_ = c.ServeJSON()
}
func (c *PlatformAgentApiController) platformClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
// agentApiPayload 新增/更新请求体
// ApiKeys 用指针:编辑时不传该字段表示不修改已存密钥列表
type agentApiPayload struct {
Provider string `json:"provider"`
Protocol string `json:"protocol"`
BaseURL string `json:"base_url"`
UseCustomURL int8 `json:"use_custom_url"`
CustomURL string `json:"custom_url"`
ApiKeys *[]models.AgentApiKey `json:"api_keys"`
Models []string `json:"models"`
Status *int8 `json:"status"`
Remark string `json:"remark"`
}
// apiKeysToJSON 把密钥列表序列化为 JSON 字符串入库
// 去掉空密钥、按 key 去重,备注允许为空
func apiKeysToJSON(list []models.AgentApiKey) string {
cleaned := make([]models.AgentApiKey, 0, len(list))
seen := make(map[string]bool, len(list))
for _, item := range list {
k := strings.TrimSpace(item.Key)
if k == "" || seen[k] {
continue
}
seen[k] = true
cleaned = append(cleaned, models.AgentApiKey{
Key: k,
Remark: strings.TrimSpace(item.Remark),
})
}
b, err := json.Marshal(cleaned)
if err != nil {
return "[]"
}
return string(b)
}
// apiKeysFromJSON 把库里的 JSON 字符串反序列化为密钥列表
// 兼容三种历史/异常格式:对象数组、纯字符串数组、单个裸密钥字符串
func apiKeysFromJSON(raw string) []models.AgentApiKey {
raw = strings.TrimSpace(raw)
if raw == "" {
return []models.AgentApiKey{}
}
if strings.HasPrefix(raw, "[") {
// 标准格式:[{"key":"sk-x","remark":"mimo198"}]
var objs []models.AgentApiKey
if err := json.Unmarshal([]byte(raw), &objs); err == nil {
out := make([]models.AgentApiKey, 0, len(objs))
for _, o := range objs {
if strings.TrimSpace(o.Key) != "" {
out = append(out, o)
}
}
return out
}
// 退化格式:["sk-x","sk-y"]
var strs []string
if err := json.Unmarshal([]byte(raw), &strs); err == nil {
out := make([]models.AgentApiKey, 0, len(strs))
for _, s := range strs {
if s = strings.TrimSpace(s); s != "" {
out = append(out, models.AgentApiKey{Key: s})
}
}
return out
}
return []models.AgentApiKey{}
}
// 迁移遗漏时的兜底:整个字段就是一个裸密钥
return []models.AgentApiKey{{Key: raw}}
}
// modelsToJSON 把模型数组序列化为 JSON 字符串入库
func modelsToJSON(list []string) string {
cleaned := make([]string, 0, len(list))
seen := make(map[string]bool, len(list))
for _, m := range list {
m = strings.TrimSpace(m)
if m == "" || seen[m] {
continue
}
seen[m] = true
cleaned = append(cleaned, m)
}
b, err := json.Marshal(cleaned)
if err != nil {
return "[]"
}
return string(b)
}
// modelsFromJSON 把库里的 JSON 字符串反序列化为模型数组
// 兼容历史数据可能存的逗号分隔格式
func modelsFromJSON(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
if strings.HasPrefix(raw, "[") {
var out []string
if err := json.Unmarshal([]byte(raw), &out); err == nil {
return out
}
return []string{}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// agentApiToMap 统一输出结构
// 密钥以明文返回,前端卡片默认脱敏、可切换显示;接口本身已受平台端鉴权保护
func agentApiToMap(row *models.PlatformAgentApi) map[string]interface{} {
keys := apiKeysFromJSON(row.ApiKeys)
protocol := normalizeProtocol(row.Protocol)
out := map[string]interface{}{
"id": row.ID,
"provider": row.Provider,
"protocol": protocol,
"api_keys": keys,
"key_count": len(keys),
"base_url": row.BaseURL,
"use_custom_url": row.UseCustomURL,
"custom_url": row.CustomURL,
"models": modelsFromJSON(row.Models),
"status": row.Status,
"remark": row.Remark,
"user_id": row.UserID,
"user_name": row.UserName,
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
"update_time": "",
}
if row.UpdateTime != nil {
out["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05")
}
return out
}
// validateAgentApiPayload 校验必填与地址格式
func validateAgentApiPayload(p *agentApiPayload, isCreate bool) error {
// 上游接口为用户自由填写的文本,不做枚举校验
p.Provider = strings.TrimSpace(p.Provider)
if p.Provider == "" {
return fmt.Errorf("请输入上游接口名称")
}
if len([]rune(p.Provider)) > 100 {
return fmt.Errorf("上游接口名称过长")
}
// 请求协议必须是白名单内的值,缺省回落到 Chat Completions
p.Protocol = strings.TrimSpace(strings.ToLower(p.Protocol))
if p.Protocol == "" {
p.Protocol = models.AgentProtocolChatCompletions
}
if !agentApiProtocols[p.Protocol] {
return fmt.Errorf("请求协议不正确")
}
p.BaseURL = strings.TrimSpace(p.BaseURL)
p.CustomURL = strings.TrimSpace(p.CustomURL)
if p.UseCustomURL == 1 {
if p.CustomURL == "" {
return fmt.Errorf("请输入自定义地址")
}
if !isHTTPURL(p.CustomURL) {
return fmt.Errorf("自定义地址需以 http:// 或 https:// 开头")
}
} else {
p.CustomURL = ""
if p.BaseURL == "" {
return fmt.Errorf("请输入接口地址")
}
if !isHTTPURL(p.BaseURL) {
return fmt.Errorf("接口地址需以 http:// 或 https:// 开头")
}
}
if len(p.Models) == 0 {
return fmt.Errorf("请至少添加一个模型")
}
// 新增时必须带密钥;编辑时不传表示沿用已存密钥列表
if p.ApiKeys != nil {
valid := 0
for _, k := range *p.ApiKeys {
if strings.TrimSpace(k.Key) != "" {
valid++
}
if len([]rune(k.Remark)) > 100 {
return fmt.Errorf("密钥备注过长,单条限 100 字")
}
}
if valid == 0 {
return fmt.Errorf("请至少添加一个 API Key")
}
} else if isCreate {
return fmt.Errorf("请至少添加一个 API Key")
}
return nil
}
func isHTTPURL(s string) bool {
low := strings.ToLower(s)
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
}
// buildAgentEndpoint 依据协议算出实际请求地址
// custom 协议直接把填写的地址当完整端点,不做任何追加;
// 其余协议在根地址后追加各自路径,若地址已以该路径结尾则不重复追加
func buildAgentEndpoint(protocol, base, model, apiKey string) string {
base = strings.TrimRight(base, "/")
switch protocol {
case models.AgentProtocolCustom:
return base
case models.AgentProtocolGemini:
// 模型名嵌在路径中间,无固定后缀可判重
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, model, apiKey)
default:
path := agentApiEndpointPath[protocol]
if path == "" {
path = "/chat/completions"
}
// 用户可能填根地址,也可能直接填完整端点,后者不再追加
if strings.HasSuffix(strings.ToLower(base), strings.ToLower(path)) {
return base
}
return base + path
}
}
// List GET /platform/agentApi/list?page=1&pageSize=24&keyword=&provider=&status=
func (c *PlatformAgentApiController) List() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 24)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 24
}
if pageSize > 200 {
pageSize = 200
}
keyword := strings.TrimSpace(c.GetString("keyword"))
provider := strings.TrimSpace(c.GetString("provider"))
statusStr := strings.TrimSpace(c.GetString("status"))
qs := models.Orm.QueryTable(new(models.PlatformAgentApi)).Filter("is_deleted", 0)
cond := orm.NewCondition()
needCond := false
// 上游名称为自由文本,用模糊匹配而非精确相等
if provider != "" {
cond = cond.And("provider__icontains", provider)
needCond = true
}
if statusStr != "" {
if st, err := strconv.Atoi(statusStr); err == nil {
cond = cond.And("status", st)
needCond = true
}
}
if keyword != "" {
kw := orm.NewCondition().
Or("provider__icontains", keyword).
Or("base_url__icontains", keyword).
Or("custom_url__icontains", keyword).
Or("models__icontains", keyword).
Or("remark__icontains", keyword)
cond = cond.AndCond(kw)
needCond = true
}
if needCond {
qs = qs.SetCond(cond)
}
total, err := qs.Count()
if err != nil {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
var rows []models.PlatformAgentApi
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
list = append(list, agentApiToMap(&rows[i]))
}
c.ok("success", map[string]interface{}{
"list": list,
"total": total,
})
}
// Detail GET /platform/agentApi/:id
func (c *PlatformAgentApiController) Detail() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
row, err := c.findByID()
if err != nil {
return
}
c.ok("success", agentApiToMap(row))
}
// findByID 读取路径参数并查询记录,出错时已写入响应
func (c *PlatformAgentApiController) findByID() (*models.PlatformAgentApi, error) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return nil, fmt.Errorf("无效ID")
}
var row models.PlatformAgentApi
err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
Filter("id", id).
Filter("is_deleted", 0).
One(&row)
if err != nil {
if err == orm.ErrNoRows {
c.jsonErr(404, 404, "配置不存在")
} else {
c.jsonErr(500, 500, "查询失败: "+err.Error())
}
return nil, err
}
return &row, nil
}
// Create POST /platform/agentApi
func (c *PlatformAgentApiController) Create() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p agentApiPayload
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if err := validateAgentApiPayload(&p, true); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
status := int8(1)
if p.Status != nil {
status = *p.Status
}
userID := uint64(claims.UserID)
row := &models.PlatformAgentApi{
Provider: p.Provider,
Protocol: p.Protocol,
BaseURL: p.BaseURL,
UseCustomURL: p.UseCustomURL,
CustomURL: p.CustomURL,
ApiKeys: apiKeysToJSON(*p.ApiKeys),
Models: modelsToJSON(p.Models),
Status: status,
Remark: strings.TrimSpace(p.Remark),
UserID: &userID,
UserName: &claims.Username,
IsDeleted: 0,
}
id, err := models.Orm.Insert(row)
if err != nil {
c.jsonErr(500, 500, "创建失败: "+err.Error())
return
}
row.ID = uint64(id)
c.ok("创建成功", agentApiToMap(row))
}
// Update PUT /platform/agentApi/:id
func (c *PlatformAgentApiController) Update() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
row, err := c.findByID()
if err != nil {
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p agentApiPayload
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if err := validateAgentApiPayload(&p, false); err != nil {
c.jsonErr(400, 400, err.Error())
return
}
now := time.Now()
updates := map[string]interface{}{
"provider": p.Provider,
"protocol": p.Protocol,
"base_url": p.BaseURL,
"use_custom_url": p.UseCustomURL,
"custom_url": p.CustomURL,
"models": modelsToJSON(p.Models),
"remark": strings.TrimSpace(p.Remark),
"update_time": now,
}
if p.Status != nil {
updates["status"] = *p.Status
}
// 未上送 api_keys 表示不修改已存密钥列表
if p.ApiKeys != nil {
updates["api_keys"] = apiKeysToJSON(*p.ApiKeys)
}
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
Filter("id", row.ID).
Update(updates)
if err != nil {
c.jsonErr(500, 500, "更新失败: "+err.Error())
return
}
c.ok("更新成功", nil)
}
// Delete DELETE /platform/agentApi/:id
func (c *PlatformAgentApiController) Delete() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
row, err := c.findByID()
if err != nil {
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
Filter("id", row.ID).
Update(map[string]interface{}{
"is_deleted": 1,
"delete_time": now,
})
if err != nil {
c.jsonErr(500, 500, "删除失败: "+err.Error())
return
}
c.ok("删除成功", nil)
}
// BatchDelete POST /platform/agentApi/batchDelete
func (c *PlatformAgentApiController) BatchDelete() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p struct {
IDs []uint64 `json:"ids"`
}
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if len(p.IDs) == 0 {
c.jsonErr(400, 400, "请选择要删除的配置")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
Filter("id__in", p.IDs).
Filter("is_deleted", 0).
Update(map[string]interface{}{
"is_deleted": 1,
"delete_time": now,
})
if err != nil {
c.jsonErr(500, 500, "批量删除失败: "+err.Error())
return
}
c.ok("批量删除成功", nil)
}
// ToggleStatus POST /platform/agentApi/:id/status
func (c *PlatformAgentApiController) ToggleStatus() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
row, err := c.findByID()
if err != nil {
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p struct {
Status *int8 `json:"status"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Status == nil {
c.jsonErr(400, 400, "参数错误")
return
}
if *p.Status != 0 && *p.Status != 1 {
c.jsonErr(400, 400, "状态值不正确")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
Filter("id", row.ID).
Update(map[string]interface{}{
"status": *p.Status,
"update_time": now,
})
if err != nil {
c.jsonErr(500, 500, "状态切换失败: "+err.Error())
return
}
c.ok("操作成功", nil)
}
// agentApiTestResult 测试结果统一结构,与前端 test.vue 字段对应
// Endpoint 回显实际请求的完整地址,便于排查路径拼接类问题(如上游根地址少了版本前缀)
type agentApiTestResult struct {
Success bool `json:"success"`
Message string `json:"message"`
StatusCode int `json:"status_code,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Model string `json:"model,omitempty"`
KeyRemark string `json:"key_remark,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Response string `json:"response,omitempty"`
Detail string `json:"detail,omitempty"`
}
// redactKeyInURL 隐去 URL 查询串里的密钥
// Gemini 协议把 key 放在 query 中,回显 endpoint 前必须脱敏
func redactKeyInURL(rawURL, key string) string {
if key == "" {
return rawURL
}
return strings.ReplaceAll(rawURL, key, "***")
}
// Test POST /platform/agentApi/test
// 请求体:{ "id": 1, "model": "gpt-4o", "key_index": 0, "prompt": "你好" }
// key_index 指定用密钥列表中的第几个密钥,缺省用第一个
func (c *PlatformAgentApiController) Test() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
var p struct {
ID uint64 `json:"id"`
Model string `json:"model"`
KeyIndex *int `json:"key_index"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal(raw, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if p.ID == 0 {
c.jsonErr(400, 400, "缺少配置ID")
return
}
var row models.PlatformAgentApi
err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
Filter("id", p.ID).
Filter("is_deleted", 0).
One(&row)
if err != nil {
c.jsonErr(404, 404, "配置不存在")
return
}
model := strings.TrimSpace(p.Model)
if model == "" {
list := modelsFromJSON(row.Models)
if len(list) == 0 {
c.jsonErr(400, 400, "该配置未添加模型")
return
}
model = list[0]
}
// 挑选待测密钥
keys := apiKeysFromJSON(row.ApiKeys)
if len(keys) == 0 {
c.jsonErr(400, 400, "该配置未添加 API Key")
return
}
idx := 0
if p.KeyIndex != nil {
idx = *p.KeyIndex
}
if idx < 0 || idx >= len(keys) {
c.jsonErr(400, 400, "指定的密钥不存在")
return
}
chosen := keys[idx]
prompt := strings.TrimSpace(p.Prompt)
if prompt == "" {
prompt = "你好"
}
result := probeAgentApi(&row, chosen, model, prompt)
c.ok("success", result)
}
// probeAgentApi 用指定密钥按配置的请求协议发起一次最小化对话请求
func probeAgentApi(
row *models.PlatformAgentApi,
apiKey models.AgentApiKey,
model, prompt string,
) agentApiTestResult {
base := strings.TrimRight(row.EffectiveURL(), "/")
if base == "" {
return agentApiTestResult{
Success: false, Message: "接口地址为空",
Model: model, KeyRemark: apiKey.Remark,
}
}
// 协议由配置显式指定,不再根据上游名称猜测
protocol := normalizeProtocol(row.Protocol)
endpoint := buildAgentEndpoint(protocol, base, model, apiKey.Key)
var (
body []byte
headers = map[string]string{"Content-Type": "application/json"}
err error
)
switch protocol {
case models.AgentProtocolAnthropic:
headers["x-api-key"] = apiKey.Key
headers["anthropic-version"] = "2023-06-01"
body, err = json.Marshal(map[string]interface{}{
"model": model,
"max_tokens": 64,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
})
case models.AgentProtocolGemini:
// 密钥已放在 query 中,无需鉴权头
body, err = json.Marshal(map[string]interface{}{
"contents": []map[string]interface{}{
{"parts": []map[string]string{{"text": prompt}}},
},
})
case models.AgentProtocolResponses:
// Responses API 用 input 字段替代 messages
headers["Authorization"] = "Bearer " + apiKey.Key
body, err = json.Marshal(map[string]interface{}{
"model": model,
"input": prompt,
"max_output_tokens": 64,
})
default:
// Chat Completions 与 custom 均按 OpenAI 标准报文发送
headers["Authorization"] = "Bearer " + apiKey.Key
body, err = json.Marshal(map[string]interface{}{
"model": model,
"max_tokens": 64,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
})
}
if err != nil {
return agentApiTestResult{
Success: false, Message: "构造请求失败", Detail: err.Error(),
Model: model, KeyRemark: apiKey.Remark,
}
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return agentApiTestResult{
Success: false, Message: "构造请求失败", Detail: err.Error(),
Model: model, KeyRemark: apiKey.Remark,
Endpoint: redactKeyInURL(endpoint, apiKey.Key),
}
}
for k, v := range headers {
req.Header.Set(k, v)
}
safeEndpoint := redactKeyInURL(endpoint, apiKey.Key)
start := time.Now()
resp, err := agentApiHTTPClient.Do(req)
latency := time.Since(start).Milliseconds()
if err != nil {
return agentApiTestResult{
Success: false,
Message: "请求上游失败(网络不通或地址错误)",
Detail: err.Error(),
LatencyMs: latency,
Model: model,
KeyRemark: apiKey.Remark,
Endpoint: safeEndpoint,
}
}
defer resp.Body.Close()
// 限制读取长度,避免超长响应占满内存
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
preview := string(respBody)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := fmt.Sprintf("上游返回 HTTP %d", resp.StatusCode)
// 404 多半是接口地址少了版本前缀,给出可操作的提示
if resp.StatusCode == 404 {
msg += ":接口地址可能不正确,请检查是否缺少版本路径(如 /v1、/compatible-mode/v2)"
}
return agentApiTestResult{
Success: false,
Message: msg,
StatusCode: resp.StatusCode,
LatencyMs: latency,
Model: model,
KeyRemark: apiKey.Remark,
Endpoint: safeEndpoint,
Detail: preview,
}
}
return agentApiTestResult{
Success: true,
Message: "连接正常,密钥与模型可用",
StatusCode: resp.StatusCode,
LatencyMs: latency,
Model: model,
KeyRemark: apiKey.Remark,
Endpoint: safeEndpoint,
Response: extractAgentReply(protocol, respBody, preview),
}
}
// extractAgentReply 从上游响应里提取模型回复文本,解析失败则回退为原始预览
func extractAgentReply(protocol string, respBody []byte, fallback string) string {
switch protocol {
case models.AgentProtocolAnthropic:
var r struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(respBody, &r) == nil && len(r.Content) > 0 {
return r.Content[0].Text
}
case models.AgentProtocolGemini:
var r struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if json.Unmarshal(respBody, &r) == nil &&
len(r.Candidates) > 0 && len(r.Candidates[0].Content.Parts) > 0 {
return r.Candidates[0].Content.Parts[0].Text
}
case models.AgentProtocolResponses:
// Responses API 优先取聚合字段 output_text,回退到 output 数组
var r struct {
OutputText string `json:"output_text"`
Output []struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"output"`
}
if json.Unmarshal(respBody, &r) == nil {
if r.OutputText != "" {
return r.OutputText
}
if len(r.Output) > 0 && len(r.Output[0].Content) > 0 {
return r.Output[0].Content[0].Text
}
}
default:
var r struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if json.Unmarshal(respBody, &r) == nil && len(r.Choices) > 0 {
return r.Choices[0].Message.Content
}
}
return fallback
}