You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
497 lines
21 KiB
497 lines
21 KiB
package console
|
|
|
|
// 第三方服务可用性探针。
|
|
//
|
|
// 巡检器把一份「有效配置」(svc_config 默认字段 + 区域覆盖,解密后的明文)交给 probeService,
|
|
// 由它按 provider(+category) 分派到具体探针,用**服务商官方的最轻量鉴权接口**验证凭据是否真的可用。
|
|
//
|
|
// 三条设计原则:
|
|
// 1. 证据分级不掺水:探针返回 ProbeCredential(真调通了) / ProbeReachable(只证明地址在线) /
|
|
// ProbeNone(没探)。没覆盖的服务商一律如实返回 ProbeNone,绝不把「没探到」渲染成「是好的」。
|
|
// 2. 只读、免费、无副作用:一律选 list-models / issueToken / GetCallerIdentity 这类查询接口,
|
|
// 不产生推理计费、不写任何数据。唯一例外是 Azure 翻译(没有纯查询的鉴权接口),发一个 4 字文本。
|
|
// 3. 401/403 才算凭据错。429(限流)、5xx(服务商自身故障) 说明凭据是被接受的,分别判为可用/服务商故障,
|
|
// 避免把服务商抖动误报成「密钥失效」让运营去换密钥。
|
|
//
|
|
// 新增服务商 = 在 probeService 的 switch 里加一个 case + 一个探针函数,无需改巡检器与前端。
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
ossSdk "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
|
"golang.org/x/oauth2/google"
|
|
|
|
"yunyan/comm"
|
|
)
|
|
|
|
// probeResult 一次探测的结论。Kind 标明证据强度(见 comm.ProbeNone/Reachable/Credential)。
|
|
type probeResult struct {
|
|
Kind string
|
|
Ok bool
|
|
Msg string
|
|
}
|
|
|
|
func credOK(msg string) probeResult { return probeResult{comm.ProbeCredential, true, msg} }
|
|
func credFail(msg string) probeResult { return probeResult{comm.ProbeCredential, false, msg} }
|
|
func reachOK(msg string) probeResult { return probeResult{comm.ProbeReachable, true, msg} }
|
|
func reachFail(msg string) probeResult {
|
|
return probeResult{comm.ProbeReachable, false, msg}
|
|
}
|
|
|
|
// noProbe 没有可用探针时的诚实结论:Ok=true 只表示「未发现问题」,Kind=none 表示「也没验证过」。
|
|
// 巡检器据此把状态定为 warn 而非 ok,前端会显式提示「未探测」。
|
|
func noProbe(msg string) probeResult { return probeResult{comm.ProbeNone, true, msg} }
|
|
|
|
// ============================== 分派入口 ==============================
|
|
|
|
// probeService 按 provider(+类别) 选择探针。fields 为解密后的明文字段表。
|
|
// cats 是该服务的类别集合(一个服务可同属多类,如 GPT-4o 同属文本/多媒体大模型)。
|
|
func probeService(ctx context.Context, provider string, cats map[int32]bool, fields map[string]string) probeResult {
|
|
// MCP 服务与 provider 无关(模板 provider 是 custom,实际由运营自填),先按类别拦下。
|
|
if cats[svcCatMCP] {
|
|
return probeMCP(ctx, fields)
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case "openai":
|
|
return probeOpenAICompat(ctx, fields, "https://api.openai.com/v1", "OpenAI")
|
|
case "deepseek":
|
|
return probeOpenAICompat(ctx, fields, "https://api.deepseek.com/v1", "DeepSeek")
|
|
case "qwen":
|
|
return probeOpenAICompat(ctx, fields, "https://dashscope.aliyuncs.com/compatible-mode/v1", "通义千问")
|
|
case "anthropic":
|
|
return probeAnthropic(ctx, fields)
|
|
case "elevenlabs":
|
|
return probeElevenLabs(ctx, fields)
|
|
case "azure":
|
|
return probeAzure(ctx, cats, fields)
|
|
case "google":
|
|
return probeGoogle(ctx, fields)
|
|
case "aliyun_oss":
|
|
return probeAliyunOSS(ctx, fields)
|
|
case "alibaba":
|
|
return probeAlibaba(ctx, fields)
|
|
case "doubao":
|
|
return probeDoubao(ctx, fields)
|
|
case "volcengine", "bytedance":
|
|
return probeVolcengine(fields)
|
|
}
|
|
// 未知/自定义服务商:能找到地址就做连通性探测,否则如实说没探。
|
|
if u := firstURLField(fields); u != "" {
|
|
return httpReachable(ctx, u)
|
|
}
|
|
return noProbe("暂无该服务商(" + provider + ")的探针,且配置中无可探测地址;本次仅完成配置校验")
|
|
}
|
|
|
|
// ============================== HTTP 基础设施 ==============================
|
|
|
|
// probeClient 探测专用 HTTP 客户端:连接不复用(每轮巡检间隔远大于 keep-alive,留着也是浪费),
|
|
// 超时由调用方的 ctx 控制。
|
|
var probeClient = &http.Client{
|
|
Transport: &http.Transport{DisableKeepAlives: true},
|
|
}
|
|
|
|
// doProbe 发一个请求并读回有限长度的响应体(截断防止把服务商的长错误页塞满日志/库)。
|
|
func doProbe(ctx context.Context, method, rawurl string, headers map[string]string, body []byte) (int, string, error) {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
rdr = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, rawurl, rdr)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := probeClient.Do(req)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
buf, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return resp.StatusCode, strings.TrimSpace(string(buf)), nil
|
|
}
|
|
|
|
// classify 把 HTTP 状态码翻译成统一结论。svcName 用于组装人话消息。
|
|
//
|
|
// 判定表(对齐本文件开头第 3 条原则):
|
|
//
|
|
// 2xx → 凭据有效
|
|
// 401/403 → 凭据无效/权限不足(这才是要运营去处理的)
|
|
// 404/405 → 端点不存在,说明探针路径不适用于该部署,退回「未探测」而不是报故障
|
|
// 429 → 被限流,说明凭据是被接受的 → 判可用并提示
|
|
// 5xx → 服务商自身故障,凭据未知
|
|
func classify(status int, bodySnippet, svcName string) probeResult {
|
|
switch {
|
|
case status >= 200 && status < 300:
|
|
return credOK(svcName + " 凭据有效 (HTTP " + strconv.Itoa(status) + ")")
|
|
case status == 401 || status == 403:
|
|
return credFail(svcName + " 拒绝凭据 (HTTP " + strconv.Itoa(status) + "): " + bodySnippet)
|
|
case status == 404 || status == 405:
|
|
return noProbe(svcName + " 探测端点不可用 (HTTP " + strconv.Itoa(status) + "),无法验证凭据;请人工确认")
|
|
case status == 429:
|
|
return credOK(svcName + " 凭据有效但当前被限流 (HTTP 429)")
|
|
case status >= 500:
|
|
return credFail(svcName + " 服务端故障 (HTTP " + strconv.Itoa(status) + "),凭据未能验证: " + bodySnippet)
|
|
default:
|
|
return credFail(svcName + " 返回异常 (HTTP " + strconv.Itoa(status) + "): " + bodySnippet)
|
|
}
|
|
}
|
|
|
|
// httpReachable 纯连通性探测:能拿到任意响应即视为地址在线(含 4xx/5xx)。
|
|
// 与 conntest.go 的 testAPIReachable 同口径,但走 ctx 超时并标记为 ProbeReachable 证据等级。
|
|
func httpReachable(ctx context.Context, rawurl string) probeResult {
|
|
status, _, err := doProbe(ctx, http.MethodGet, rawurl, nil, nil)
|
|
if err != nil {
|
|
return reachFail("地址不可达: " + err.Error())
|
|
}
|
|
return reachOK("地址可达 (HTTP " + strconv.Itoa(status) + "),但未验证凭据")
|
|
}
|
|
|
|
// firstURLField 在字段表里找第一个像地址的值,用于给未覆盖的服务商兜底做连通性探测。
|
|
func firstURLField(fields map[string]string) string {
|
|
for _, k := range []string{"url", "base_url", "endpoint", "api_url", "host"} {
|
|
v := strings.TrimSpace(fields[k])
|
|
if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pick 取第一个非空字段值,按 keys 顺序优先。
|
|
func pick(fields map[string]string, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v := strings.TrimSpace(fields[k]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ============================== OpenAI 兼容协议 ==============================
|
|
|
|
// probeOpenAICompat 覆盖所有 OpenAI 兼容网关(OpenAI/DeepSeek/通义/火山方舟/自建中转)。
|
|
//
|
|
// 两段式:先 GET {base}/models(只读免费);若该网关没开放 models 端点(404/405),
|
|
// 退化为 POST {base}/chat/completions 发一个 max_tokens=1 的极小请求——只看鉴权是否被接受,
|
|
// 内容不关心。第二段有极小的推理计费,但这是验证中转网关凭据的唯一可靠手段。
|
|
func probeOpenAICompat(ctx context.Context, fields map[string]string, defBase, svcName string) probeResult {
|
|
key := pick(fields, "api_key", "token")
|
|
if key == "" {
|
|
return credFail(svcName + " 未配置 api_key,无法验证")
|
|
}
|
|
base := strings.TrimRight(pick(fields, "base_url", "endpoint"), "/")
|
|
if base == "" {
|
|
base = defBase
|
|
}
|
|
auth := map[string]string{"Authorization": "Bearer " + key}
|
|
status, snippet, err := doProbe(ctx, http.MethodGet, base+"/models", auth, nil)
|
|
if err != nil {
|
|
return credFail(svcName + " 请求失败: " + err.Error())
|
|
}
|
|
if res := classify(status, snippet, svcName); status != 404 && status != 405 {
|
|
return res
|
|
}
|
|
// 退化路径:用最小推理请求验鉴权。
|
|
model := pick(fields, "model", "endpoint_id")
|
|
if model == "" {
|
|
return noProbe(svcName + " 网关未提供 /models 端点,且未配置 model,无法验证凭据")
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"model": model,
|
|
"messages": []map[string]string{{"role": "user", "content": "ping"}},
|
|
"max_tokens": 1,
|
|
})
|
|
status, snippet, err = doProbe(ctx, http.MethodPost, base+"/chat/completions",
|
|
map[string]string{"Authorization": "Bearer " + key, "Content-Type": "application/json"}, body)
|
|
if err != nil {
|
|
return credFail(svcName + " 请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, svcName)
|
|
}
|
|
|
|
// probeAnthropic Anthropic 用 x-api-key + 版本头,GET /v1/models 只读免费。
|
|
func probeAnthropic(ctx context.Context, fields map[string]string) probeResult {
|
|
key := pick(fields, "api_key")
|
|
if key == "" {
|
|
return credFail("Anthropic 未配置 api_key,无法验证")
|
|
}
|
|
status, snippet, err := doProbe(ctx, http.MethodGet, "https://api.anthropic.com/v1/models",
|
|
map[string]string{"x-api-key": key, "anthropic-version": "2023-06-01"}, nil)
|
|
if err != nil {
|
|
return credFail("Anthropic 请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, "Anthropic")
|
|
}
|
|
|
|
// probeElevenLabs GET /v1/user 返回账号信息,是官方最轻的鉴权校验端点。
|
|
func probeElevenLabs(ctx context.Context, fields map[string]string) probeResult {
|
|
key := pick(fields, "api_key")
|
|
if key == "" {
|
|
return credFail("ElevenLabs 未配置 api_key,无法验证")
|
|
}
|
|
status, snippet, err := doProbe(ctx, http.MethodGet, "https://api.elevenlabs.io/v1/user",
|
|
map[string]string{"xi-api-key": key}, nil)
|
|
if err != nil {
|
|
return credFail("ElevenLabs 请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, "ElevenLabs")
|
|
}
|
|
|
|
// ============================== 微软 Azure ==============================
|
|
|
|
// probeAzure Azure 按服务族分两套鉴权域:
|
|
// - 翻译(MT):走全局 api.cognitive.microsofttranslator.com,密钥 + 区域头;
|
|
// - 语音(STT/TTS/AST/STS/录音识别):走区域化 {region}.api.cognitive.microsoft.com 的 issueToken,
|
|
// 换取临时 token 成功即证明订阅密钥有效——官方推荐的密钥校验方式,免费无副作用。
|
|
func probeAzure(ctx context.Context, cats map[int32]bool, fields map[string]string) probeResult {
|
|
key := pick(fields, "subscription_key", "api_key")
|
|
if key == "" {
|
|
return credFail("Azure 未配置 subscription_key,无法验证")
|
|
}
|
|
if cats[svcCatMT] {
|
|
return probeAzureTranslator(ctx, key, pick(fields, "region"))
|
|
}
|
|
region := pick(fields, "region")
|
|
if region == "" {
|
|
return credFail("Azure 语音服务未配置 region,无法验证")
|
|
}
|
|
// region 可能被填成完整终结点(模板描述允许「区域/终结点」),此时直接用它拼 issueToken。
|
|
host := region + ".api.cognitive.microsoft.com"
|
|
if strings.Contains(region, ".") {
|
|
host = strings.TrimPrefix(strings.TrimPrefix(strings.TrimRight(region, "/"), "https://"), "http://")
|
|
}
|
|
status, snippet, err := doProbe(ctx, http.MethodPost, "https://"+host+"/sts/v1.0/issueToken",
|
|
map[string]string{"Ocp-Apim-Subscription-Key": key, "Content-Length": "0"}, []byte{})
|
|
if err != nil {
|
|
return credFail("Azure 语音请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, "Azure 语音")
|
|
}
|
|
|
|
// probeAzureTranslator 翻译没有纯查询型鉴权接口,发一个 4 字文本的最小翻译请求(费用可忽略)。
|
|
func probeAzureTranslator(ctx context.Context, key, region string) probeResult {
|
|
headers := map[string]string{
|
|
"Ocp-Apim-Subscription-Key": key,
|
|
"Content-Type": "application/json",
|
|
}
|
|
if region != "" {
|
|
headers["Ocp-Apim-Subscription-Region"] = region
|
|
}
|
|
body, _ := json.Marshal([]map[string]string{{"Text": "ping"}})
|
|
status, snippet, err := doProbe(ctx, http.MethodPost,
|
|
"https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh-Hans", headers, body)
|
|
if err != nil {
|
|
return credFail("Azure 翻译请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, "Azure 翻译")
|
|
}
|
|
|
|
// ============================== Google ==============================
|
|
|
|
// probeGoogle Google 两种凭据形态:
|
|
// - json:服务账号密钥(Speech/翻译用)。用它签 JWT 去 OAuth2 换 access_token,
|
|
// 换到即证明密钥有效且未被吊销——不调用任何计费 API。
|
|
// - api_key:Gemini 用。GET /v1beta/models?key= 只读免费。
|
|
func probeGoogle(ctx context.Context, fields map[string]string) probeResult {
|
|
if raw := pick(fields, "json"); raw != "" {
|
|
return probeGoogleServiceAccount(ctx, raw)
|
|
}
|
|
key := pick(fields, "api_key")
|
|
if key == "" {
|
|
return credFail("Google 未配置服务账号 JSON 或 api_key,无法验证")
|
|
}
|
|
endpoint := strings.TrimRight(pick(fields, "endpoint"), "/")
|
|
if endpoint == "" {
|
|
endpoint = "https://generativelanguage.googleapis.com/v1beta"
|
|
}
|
|
status, snippet, err := doProbe(ctx, http.MethodGet, endpoint+"/models?key="+url.QueryEscape(key), nil, nil)
|
|
if err != nil {
|
|
return credFail("Google 请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, "Google")
|
|
}
|
|
|
|
// probeGoogleServiceAccount 字段值兼容两种存法:JSON 内容本身,或指向密钥文件的路径
|
|
// (sys/google 走的是路径,模板描述也写明「内容或路径」)。
|
|
func probeGoogleServiceAccount(ctx context.Context, raw string) probeResult {
|
|
blob := []byte(raw)
|
|
if !strings.HasPrefix(strings.TrimSpace(raw), "{") {
|
|
b, err := os.ReadFile(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return credFail("Google 服务账号密钥文件读取失败: " + err.Error())
|
|
}
|
|
blob = b
|
|
}
|
|
cfg, err := google.JWTConfigFromJSON(blob, "https://www.googleapis.com/auth/cloud-platform")
|
|
if err != nil {
|
|
return credFail("Google 服务账号 JSON 解析失败: " + err.Error())
|
|
}
|
|
if _, err = cfg.TokenSource(ctx).Token(); err != nil {
|
|
return credFail("Google 服务账号换取令牌失败(密钥可能已吊销): " + err.Error())
|
|
}
|
|
return credOK("Google 服务账号有效(已成功换取访问令牌)")
|
|
}
|
|
|
|
// ============================== 阿里云 ==============================
|
|
|
|
// probeAlibaba 阿里云两种凭据形态:
|
|
// - api_key:DashScope(录音识别/百炼),GET compatible-mode/v1/models 只读免费;
|
|
// - AccessKey:其余(STT/TTS/MT/AST)。用 STS GetCallerIdentity 校验 AK/SK——
|
|
// 它是阿里云唯一免费、只读、不依赖具体产品开通状态的身份校验接口,
|
|
// 用具体产品接口(如翻译)校验会把「产品未开通」误报成「密钥无效」。
|
|
func probeAlibaba(ctx context.Context, fields map[string]string) probeResult {
|
|
if key := pick(fields, "api_key"); key != "" {
|
|
status, snippet, err := doProbe(ctx, http.MethodGet,
|
|
"https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
|
map[string]string{"Authorization": "Bearer " + key}, nil)
|
|
if err != nil {
|
|
return credFail("阿里云 DashScope 请求失败: " + err.Error())
|
|
}
|
|
return classify(status, snippet, "阿里云 DashScope")
|
|
}
|
|
ak, sk := pick(fields, "access_key_id"), pick(fields, "access_key_secret")
|
|
if ak == "" || sk == "" {
|
|
return credFail("阿里云未配置 AccessKey,无法验证")
|
|
}
|
|
return probeAliyunAK(ctx, ak, sk)
|
|
}
|
|
|
|
// probeAliyunAK 调用 STS GetCallerIdentity(RPC 风格签名 V1,HMAC-SHA1)校验 AK/SK。
|
|
func probeAliyunAK(ctx context.Context, ak, sk string) probeResult {
|
|
q := url.Values{}
|
|
q.Set("Action", "GetCallerIdentity")
|
|
q.Set("Version", "2015-04-01")
|
|
q.Set("Format", "JSON")
|
|
q.Set("AccessKeyId", ak)
|
|
q.Set("SignatureMethod", "HMAC-SHA1")
|
|
q.Set("SignatureVersion", "1.0")
|
|
q.Set("SignatureNonce", strconv.FormatInt(time.Now().UnixNano(), 10))
|
|
q.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
|
|
|
|
// StringToSign = HTTPMethod & encode("/") & encode(规范化查询串);签名密钥是 SK + "&"。
|
|
stringToSign := "GET&" + aliyunEncode("/") + "&" + aliyunEncode(q.Encode())
|
|
mac := hmac.New(sha1.New, []byte(sk+"&"))
|
|
mac.Write([]byte(stringToSign))
|
|
q.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
|
|
|
status, snippet, err := doProbe(ctx, http.MethodGet, "https://sts.aliyuncs.com/?"+q.Encode(), nil, nil)
|
|
if err != nil {
|
|
return credFail("阿里云 STS 请求失败: " + err.Error())
|
|
}
|
|
// 阿里云对签名/密钥错误返回 400 + 具体错误码,需要单独识别,否则会落进 classify 的兜底分支。
|
|
if status == 400 && (strings.Contains(snippet, "InvalidAccessKeyId") ||
|
|
strings.Contains(snippet, "SignatureDoesNotMatch") || strings.Contains(snippet, "Forbidden")) {
|
|
return credFail("阿里云拒绝 AccessKey: " + snippet)
|
|
}
|
|
return classify(status, snippet, "阿里云")
|
|
}
|
|
|
|
// aliyunEncode 阿里云 RPC 签名要求的 percent-encode:在 RFC3986 基础上把 + * ~ 三处
|
|
// 与 Go 的 QueryEscape 对齐差异修正。
|
|
func aliyunEncode(s string) string {
|
|
e := url.QueryEscape(s)
|
|
e = strings.ReplaceAll(e, "+", "%20")
|
|
e = strings.ReplaceAll(e, "*", "%2A")
|
|
e = strings.ReplaceAll(e, "%7E", "~")
|
|
return e
|
|
}
|
|
|
|
// probeAliyunOSS GetBucketInfo:只读、免费,同时验证 AK/SK 有效 + 桶存在 + 有访问权限。
|
|
func probeAliyunOSS(ctx context.Context, fields map[string]string) probeResult {
|
|
ak, sk := pick(fields, "access_key_id"), pick(fields, "access_key_secret")
|
|
bucket, endpoint := pick(fields, "bucket"), pick(fields, "endpoint")
|
|
if ak == "" || sk == "" || bucket == "" || endpoint == "" {
|
|
return credFail("阿里云 OSS 缺少 AccessKey/bucket/endpoint,无法验证")
|
|
}
|
|
client, err := ossSdk.New(endpoint, ak, sk, ossSdk.Timeout(5, 10))
|
|
if err != nil {
|
|
return credFail("阿里云 OSS 客户端创建失败: " + err.Error())
|
|
}
|
|
if _, err = client.GetBucketInfo(bucket); err != nil {
|
|
return credFail("阿里云 OSS 访问存储桶失败: " + err.Error())
|
|
}
|
|
return credOK("阿里云 OSS 凭据有效,存储桶 " + bucket + " 可访问")
|
|
}
|
|
|
|
// ============================== 火山引擎 / 字节跳动 ==============================
|
|
|
|
// probeDoubao 豆包有两套凭据:方舟大模型走 api_key(OpenAI 兼容),翻译走火山 AK/SK 签名。
|
|
func probeDoubao(ctx context.Context, fields map[string]string) probeResult {
|
|
if pick(fields, "api_key") != "" {
|
|
return probeOpenAICompat(ctx, fields, "https://ark.cn-beijing.volces.com/api/v3", "火山方舟")
|
|
}
|
|
return probeVolcengine(fields)
|
|
}
|
|
|
|
// probeVolcengine 火山/字节的语音族(STT/TTS/AST/STS/录音识别)与翻译:
|
|
// 前者鉴权发生在 WebSocket 握手内、后者用火山 V4 签名,都没有免费只读的 REST 校验入口——
|
|
// 硬造一个「故意打错的请求看返回码」的探针极易把服务商的参数校验误判成密钥失效,
|
|
// 所以这里如实返回「未探测」,由配置校验保证字段齐全,可用性交由业务侧真实调用暴露。
|
|
func probeVolcengine(fields map[string]string) probeResult {
|
|
missing := []string{}
|
|
for _, k := range []string{"appid", "token"} {
|
|
if pick(fields, k) == "" {
|
|
missing = append(missing, k)
|
|
}
|
|
}
|
|
if len(missing) > 0 && pick(fields, "access_key") == "" {
|
|
return credFail("火山引擎缺少必要凭据字段: " + strings.Join(missing, "/"))
|
|
}
|
|
return noProbe("火山引擎该服务走 WebSocket/V4 签名鉴权,无免费只读的校验接口,本次未验证凭据(配置字段已齐全)")
|
|
}
|
|
|
|
// ============================== MCP 服务 ==============================
|
|
|
|
// probeMCP 按 MCP 传输类型探测:
|
|
// - HTTP(type=0):发标准 JSON-RPC initialize 握手,服务端接受即证明可用;
|
|
// - SSE(type=1) :SSE 端点是长连接流,握手不能用同一路径完成,只做连通性探测。
|
|
func probeMCP(ctx context.Context, fields map[string]string) probeResult {
|
|
raw := pick(fields, "url")
|
|
if raw == "" {
|
|
return credFail("MCP 服务未配置 url")
|
|
}
|
|
if strings.TrimSpace(fields["type"]) == "1" {
|
|
res := httpReachable(ctx, raw)
|
|
if res.Ok {
|
|
res.Msg = "SSE 端点可达(SSE 为长连接流,未做协议握手)"
|
|
}
|
|
return res
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": map[string]any{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": map[string]any{},
|
|
"clientInfo": map[string]string{"name": "starpivot-console-inspect", "version": "1.0"},
|
|
},
|
|
})
|
|
status, snippet, err := doProbe(ctx, http.MethodPost, raw, map[string]string{
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
}, body)
|
|
if err != nil {
|
|
return credFail("MCP 服务请求失败: " + err.Error())
|
|
}
|
|
if status >= 200 && status < 300 {
|
|
// 握手通了但返回 JSON-RPC error,说明服务在线却拒绝了初始化,属于要人工看的异常。
|
|
if strings.Contains(snippet, `"error"`) {
|
|
return credFail("MCP 服务握手被拒绝: " + snippet)
|
|
}
|
|
return credOK("MCP 服务握手成功 (initialize)")
|
|
}
|
|
return classify(status, snippet, "MCP 服务")
|
|
}
|
|
|