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.
253 lines
8.7 KiB
253 lines
8.7 KiB
package console
|
|
|
|
// 探针的端到端测试:用 httptest 起本地假服务商,验证探针发出的请求形状
|
|
// (路径/鉴权头/退化路径)与对各种响应的判定。全程不出网,不碰任何真实服务商。
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"yunyan/comm"
|
|
)
|
|
|
|
func ctx(t *testing.T) context.Context {
|
|
t.Helper()
|
|
c, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
return c
|
|
}
|
|
|
|
// TestProbeOpenAICompatOK 正常路径:GET {base}/models 带 Bearer,200 即凭据有效。
|
|
func TestProbeOpenAICompatOK(t *testing.T) {
|
|
var gotPath, gotAuth string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"data":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL}, "", "测试")
|
|
if !res.Ok || res.Kind != comm.ProbeCredential {
|
|
t.Fatalf("期望凭据校验通过,实得 %+v", res)
|
|
}
|
|
if gotPath != "/models" {
|
|
t.Errorf("探测路径 = %q, want /models", gotPath)
|
|
}
|
|
if gotAuth != "Bearer sk-1" {
|
|
t.Errorf("鉴权头 = %q, want 'Bearer sk-1'", gotAuth)
|
|
}
|
|
}
|
|
|
|
// TestProbeOpenAICompat401 密钥被拒必须判为不可用,这是巡检要抓的主要故障。
|
|
func TestProbeOpenAICompat401(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(401)
|
|
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "bad", "base_url": srv.URL}, "", "测试")
|
|
if res.Ok {
|
|
t.Fatalf("401 必须判为失败,实得 %+v", res)
|
|
}
|
|
if !strings.Contains(res.Msg, "invalid api key") {
|
|
t.Errorf("失败原因应带上服务商返回内容,实得 %q", res.Msg)
|
|
}
|
|
}
|
|
|
|
// TestProbeOpenAICompatFallback 中转网关常不实现 /models,此时应退化为最小 chat 请求验鉴权。
|
|
func TestProbeOpenAICompatFallback(t *testing.T) {
|
|
var hitChat bool
|
|
var chatBody string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/models" {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
if r.URL.Path == "/chat/completions" {
|
|
hitChat = true
|
|
buf := make([]byte, r.ContentLength)
|
|
_, _ = r.Body.Read(buf)
|
|
chatBody = string(buf)
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
|
return
|
|
}
|
|
w.WriteHeader(500)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeOpenAICompat(ctx(t),
|
|
map[string]string{"api_key": "sk-1", "base_url": srv.URL, "model": "test-model"}, "", "测试")
|
|
if !hitChat {
|
|
t.Fatal("/models 返回 404 后应退化调用 /chat/completions")
|
|
}
|
|
if !res.Ok || res.Kind != comm.ProbeCredential {
|
|
t.Fatalf("退化路径 200 应判为凭据有效,实得 %+v", res)
|
|
}
|
|
// 退化请求必须是最小的,不能真跑一次推理。
|
|
if !strings.Contains(chatBody, `"max_tokens":1`) {
|
|
t.Errorf("退化请求应限制 max_tokens=1,实得 %s", chatBody)
|
|
}
|
|
}
|
|
|
|
// TestProbeOpenAICompatFallbackNoModel 没配 model 就无法退化,此时必须如实说「未探测」,
|
|
// 不能默默判成可用。
|
|
func TestProbeOpenAICompatFallbackNoModel(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(404)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL}, "", "测试")
|
|
if res.Kind != comm.ProbeNone {
|
|
t.Fatalf("无法验证时必须标记为未探测,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestProbeOpenAICompatNoKey(t *testing.T) {
|
|
res := probeOpenAICompat(ctx(t), map[string]string{}, "https://example.invalid", "测试")
|
|
if res.Ok {
|
|
t.Fatalf("缺 api_key 应直接判失败,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestProbeAnthropic Anthropic 用 x-api-key + 版本头,头错会被判 401,必须发对。
|
|
func TestProbeAnthropicHeaders(t *testing.T) {
|
|
var key, ver string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
key, ver = r.Header.Get("x-api-key"), r.Header.Get("anthropic-version")
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// 直接验证头部构造:借 doProbe 复现 probeAnthropic 的请求形状。
|
|
_, _, err := doProbe(ctx(t), http.MethodGet, srv.URL,
|
|
map[string]string{"x-api-key": "k", "anthropic-version": "2023-06-01"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if key != "k" || ver != "2023-06-01" {
|
|
t.Errorf("Anthropic 鉴权头不正确: key=%q version=%q", key, ver)
|
|
}
|
|
}
|
|
|
|
// TestProbeMCPHandshake MCP 走 JSON-RPC initialize 握手。
|
|
func TestProbeMCPHandshake(t *testing.T) {
|
|
var body string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
buf := make([]byte, r.ContentLength)
|
|
_, _ = r.Body.Read(buf)
|
|
body = string(buf)
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "0"})
|
|
if !res.Ok || res.Kind != comm.ProbeCredential {
|
|
t.Fatalf("握手成功应判可用,实得 %+v", res)
|
|
}
|
|
if !strings.Contains(body, `"method":"initialize"`) {
|
|
t.Errorf("应发送 initialize 握手,实得 %s", body)
|
|
}
|
|
}
|
|
|
|
// TestProbeMCPRpcError 服务在线但拒绝初始化,属于要人工看的异常,不能算通过。
|
|
func TestProbeMCPRpcError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "0"})
|
|
if res.Ok {
|
|
t.Fatalf("JSON-RPC error 应判为失败,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestProbeMCPSSE SSE 是长连接流,只能做连通性探测,证据等级必须降级。
|
|
func TestProbeMCPSSE(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "1"})
|
|
if !res.Ok || res.Kind != comm.ProbeReachable {
|
|
t.Fatalf("SSE 应判为仅连通性,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestProbeMCPNoURL(t *testing.T) {
|
|
if res := probeMCP(ctx(t), map[string]string{}); res.Ok {
|
|
t.Fatalf("缺 url 应判失败,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestHTTPReachable 连不上要判失败;连上任意响应都算地址在线但不验证凭据。
|
|
func TestHTTPReachable(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(503)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := httpReachable(ctx(t), srv.URL)
|
|
if !res.Ok || res.Kind != comm.ProbeReachable {
|
|
t.Fatalf("503 也说明地址在线,实得 %+v", res)
|
|
}
|
|
|
|
srv.Close()
|
|
if res = httpReachable(ctx(t), srv.URL); res.Ok {
|
|
t.Fatalf("服务已关闭应判不可达,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestProbeServiceUnknownProvider 未覆盖的服务商:有地址就探连通性,没地址就如实说没探。
|
|
func TestProbeServiceUnknownProvider(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
res := probeService(ctx(t), "某个没接过的服务商", map[int32]bool{}, map[string]string{"base_url": srv.URL})
|
|
if res.Kind != comm.ProbeReachable {
|
|
t.Errorf("未知服务商有地址时应做连通性探测,实得 %+v", res)
|
|
}
|
|
|
|
res = probeService(ctx(t), "某个没接过的服务商", map[int32]bool{}, map[string]string{"secret": "x"})
|
|
if res.Kind != comm.ProbeNone {
|
|
t.Errorf("未知服务商且无地址时应标记未探测,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestProbeVolcengine 火山没有免费只读校验接口:字段齐全应如实标记未探测(而不是伪装成通过),
|
|
// 字段缺失则直接判失败。
|
|
func TestProbeVolcengine(t *testing.T) {
|
|
res := probeVolcengine(map[string]string{"appid": "a", "token": "t"})
|
|
if res.Kind != comm.ProbeNone || !res.Ok {
|
|
t.Errorf("字段齐全应标记未探测,实得 %+v", res)
|
|
}
|
|
res = probeVolcengine(map[string]string{"appid": "a"})
|
|
if res.Ok {
|
|
t.Errorf("缺 token 应判失败,实得 %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestFirstURLFieldAndPick(t *testing.T) {
|
|
f := map[string]string{"endpoint": "not-a-url", "base_url": "https://x.test"}
|
|
if got := firstURLField(f); got != "https://x.test" {
|
|
t.Errorf("firstURLField = %q, 应跳过非 http 值取到 base_url", got)
|
|
}
|
|
if got := pick(f, "missing", "endpoint"); got != "not-a-url" {
|
|
t.Errorf("pick 应按顺序取第一个非空值,实得 %q", got)
|
|
}
|
|
if got := pick(f, "missing"); got != "" {
|
|
t.Errorf("pick 全缺失应返回空串,实得 %q", got)
|
|
}
|
|
}
|
|
|