sub2api/backend/internal/service/channel_monitor_checker_body_test.go
benjamin d3d5843b9d fix(channel-monitor): 兼容 Responses reasoning 输出
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-20 21:19:06 +08:00

365 lines
12 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.

//go:build unit
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// swapMonitorHTTPClient 临时替换 monitorHTTPClient 为不带 SSRF 校验的普通 client
// 让 httptest (127.0.0.1) 能连通。测试结束后恢复。
func swapMonitorHTTPClient(t *testing.T) {
t.Helper()
orig := monitorHTTPClient
monitorHTTPClient = &http.Client{Timeout: 5 * time.Second}
t.Cleanup(func() { monitorHTTPClient = orig })
}
// captureHandler 把每次收到的请求 body 和 headers 存起来,测试断言用。
type captureHandler struct {
lastBody map[string]any
lastHeaders http.Header
respondText string // 写到 Anthropic content[0].text 里(校验用)
status int
}
func (h *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.lastHeaders = r.Header.Clone()
defer func() { _ = r.Body.Close() }()
var parsed map[string]any
_ = json.NewDecoder(r.Body).Decode(&parsed)
h.lastBody = parsed
if h.status == 0 {
h.status = 200
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(h.status)
// 构造 Anthropic 格式的响应content[0].text = h.respondText
_ = json.NewEncoder(w).Encode(map[string]any{
"content": []map[string]any{
{"type": "text", "text": h.respondText},
},
})
}
func setupFakeAnthropic(t *testing.T, handler *captureHandler) string {
t.Helper()
swapMonitorHTTPClient(t)
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv.URL
}
type openAICaptureHandler struct {
lastBody map[string]any
lastHeaders http.Header
lastPath string
status int
responsesLeadingReasoning bool
}
func (h *openAICaptureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.lastHeaders = r.Header.Clone()
h.lastPath = r.URL.Path
defer func() { _ = r.Body.Close() }()
var parsed map[string]any
_ = json.NewDecoder(r.Body).Decode(&parsed)
h.lastBody = parsed
if h.status == 0 {
h.status = http.StatusOK
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(h.status)
answer := answerFromOpenAIRequest(parsed)
if h.lastPath == providerOpenAIResponsesPath {
output := []map[string]any{}
if h.responsesLeadingReasoning {
output = append(output, map[string]any{
"type": "reasoning",
"summary": []any{},
})
}
output = append(output, map[string]any{
"type": "message",
"status": "completed",
"role": "assistant",
"content": []map[string]any{
{"type": "output_text", "text": answer},
},
})
_ = json.NewEncoder(w).Encode(map[string]any{
"output": output,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{{"message": map[string]any{"content": answer}}},
})
}
func setupFakeOpenAI(t *testing.T, handler *openAICaptureHandler) string {
t.Helper()
swapMonitorHTTPClient(t)
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv.URL
}
func answerFromOpenAIRequest(body map[string]any) string {
prompt, _ := body["input"].(string)
if prompt == "" {
if messages, ok := body["messages"].([]any); ok && len(messages) > 0 {
if msg, ok := messages[0].(map[string]any); ok {
prompt, _ = msg["content"].(string)
}
}
}
return answerFromChallengePrompt(prompt)
}
var challengeQuestionRegex = regexp.MustCompile(`Q: (\d+) ([+-]) (\d+) = \?\nA:$`)
func answerFromChallengePrompt(prompt string) string {
m := challengeQuestionRegex.FindStringSubmatch(prompt)
if len(m) != 4 {
return "0"
}
left, _ := strconv.Atoi(m[1])
right, _ := strconv.Atoi(m[3])
if m[2] == "+" {
return strconv.Itoa(left + right)
}
return strconv.Itoa(left - right)
}
func TestRunCheckForModel_OffMode_PreservesDefaultBody(t *testing.T) {
h := &captureHandler{respondText: "the answer is 42"}
endpoint := setupFakeAnthropic(t, h)
// 跑一次 off 模式opts=nil确认默认 body 行为未变
_ = runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", nil)
if h.lastBody["model"] != "claude-x" {
t.Errorf("default body should contain model=claude-x, got %v", h.lastBody["model"])
}
if _, ok := h.lastBody["messages"]; !ok {
t.Error("default body should contain messages")
}
if h.lastHeaders.Get("x-api-key") != "sk-fake" {
t.Errorf("expected adapter's x-api-key header, got %q", h.lastHeaders.Get("x-api-key"))
}
}
func TestRunCheckForModel_OpenAI_DefaultChatRequest(t *testing.T) {
h := &openAICaptureHandler{}
endpoint := setupFakeOpenAI(t, h)
res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-test", nil)
if res.Status != MonitorStatusOperational {
t.Fatalf("default chat request should pass challenge, got status=%s message=%q", res.Status, res.Message)
}
if h.lastPath != providerOpenAIPath {
t.Fatalf("expected chat completions path %q, got %q", providerOpenAIPath, h.lastPath)
}
if h.lastBody["model"] != "gpt-test" {
t.Errorf("chat body should contain model=gpt-test, got %v", h.lastBody["model"])
}
if _, ok := h.lastBody["messages"]; !ok {
t.Error("chat body should contain messages")
}
if _, ok := h.lastBody["instructions"]; ok {
t.Error("chat body must not contain top-level instructions")
}
if h.lastBody["stream"] != false {
t.Errorf("chat body should set stream=false, got %v", h.lastBody["stream"])
}
if h.lastHeaders.Get("Authorization") != "Bearer sk-openai" {
t.Errorf("expected bearer auth header, got %q", h.lastHeaders.Get("Authorization"))
}
}
func TestRunCheckForModel_OpenAIResponses_DefaultRequest(t *testing.T) {
h := &openAICaptureHandler{}
endpoint := setupFakeOpenAI(t, h)
res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-test", &CheckOptions{
APIMode: MonitorAPIModeResponses,
})
if res.Status != MonitorStatusOperational {
t.Fatalf("default responses request should pass challenge, got status=%s message=%q", res.Status, res.Message)
}
if h.lastPath != providerOpenAIResponsesPath {
t.Fatalf("expected responses path %q, got %q", providerOpenAIResponsesPath, h.lastPath)
}
if h.lastBody["model"] != "gpt-test" {
t.Errorf("responses body should contain model=gpt-test, got %v", h.lastBody["model"])
}
instructions, _ := h.lastBody["instructions"].(string)
if strings.TrimSpace(instructions) == "" {
t.Error("responses body should contain non-empty instructions")
}
input, _ := h.lastBody["input"].(string)
if strings.TrimSpace(input) == "" {
t.Error("responses body should contain non-empty input")
}
if _, ok := h.lastBody["messages"]; ok {
t.Error("responses body must not contain chat messages")
}
if h.lastBody["stream"] != false {
t.Errorf("responses body should set stream=false, got %v", h.lastBody["stream"])
}
if h.lastHeaders.Get("Authorization") != "Bearer sk-openai" {
t.Errorf("expected bearer auth header, got %q", h.lastHeaders.Get("Authorization"))
}
}
func TestRunCheckForModel_OpenAIResponses_SkipsLeadingReasoningItem(t *testing.T) {
h := &openAICaptureHandler{responsesLeadingReasoning: true}
endpoint := setupFakeOpenAI(t, h)
res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-5.5", &CheckOptions{
APIMode: MonitorAPIModeResponses,
})
if res.Status != MonitorStatusOperational {
t.Fatalf("responses request should find text after leading reasoning item, got status=%s message=%q", res.Status, res.Message)
}
if h.lastPath != providerOpenAIResponsesPath {
t.Fatalf("expected responses path %q, got %q", providerOpenAIResponsesPath, h.lastPath)
}
}
func TestRunCheckForModel_OpenAIResponsesReplaceMissingInstructionsFailsLocally(t *testing.T) {
h := &openAICaptureHandler{}
endpoint := setupFakeOpenAI(t, h)
res := runCheckForModel(context.Background(), MonitorProviderOpenAI, endpoint, "sk-openai", "gpt-test", &CheckOptions{
APIMode: MonitorAPIModeResponses,
BodyOverrideMode: MonitorBodyOverrideModeReplace,
BodyOverride: map[string]any{
"model": "gpt-test",
"input": "hello",
},
})
if res.Status != MonitorStatusError {
t.Fatalf("invalid responses replace body should fail locally as error, got status=%s", res.Status)
}
if !strings.Contains(res.Message, "instructions and input are required") {
t.Errorf("expected local validation message about instructions/input, got %q", res.Message)
}
if h.lastPath != "" {
t.Errorf("invalid replace body should fail before HTTP request, got path %q", h.lastPath)
}
}
func TestRunCheckForModel_MergeMode_UserFieldsWinButDenyListProtects(t *testing.T) {
h := &captureHandler{respondText: "the answer is 42"}
endpoint := setupFakeAnthropic(t, h)
opts := &CheckOptions{
BodyOverrideMode: MonitorBodyOverrideModeMerge,
BodyOverride: map[string]any{
"system": "You are Claude Code...",
"max_tokens": float64(999), // 应该覆盖默认 50
"model": "hacked-model", // 应该被黑名单挡住,保留原 model
"messages": []any{}, // 同上,被挡
},
ExtraHeaders: map[string]string{
"User-Agent": "claude-cli/1.0",
"Content-Length": "999", // 黑名单
"x-custom": "ok",
},
}
_ = runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
if h.lastBody["system"] != "You are Claude Code..." {
t.Errorf("merge mode should inject system, got %v", h.lastBody["system"])
}
// max_tokens 覆盖生效
if mt, ok := h.lastBody["max_tokens"].(float64); !ok || mt != 999 {
t.Errorf("merge mode should override max_tokens to 999, got %v", h.lastBody["max_tokens"])
}
// model 在黑名单 — 应该保留默认值
if h.lastBody["model"] != "claude-x" {
t.Errorf("model should be protected by deny list, got %v", h.lastBody["model"])
}
// messages 在黑名单 — 应该保留默认值(非空)
msgs, _ := h.lastBody["messages"].([]any)
if len(msgs) == 0 {
t.Error("messages should be protected by deny list (kept default, non-empty)")
}
// header 合并
if h.lastHeaders.Get("User-Agent") != "claude-cli/1.0" {
t.Errorf("extra User-Agent should override, got %q", h.lastHeaders.Get("User-Agent"))
}
if h.lastHeaders.Get("x-custom") != "ok" {
t.Errorf("extra custom header should be present, got %q", h.lastHeaders.Get("x-custom"))
}
// Content-Length 黑名单:会被 net/http 自动重算,但不应由用户的 "999" 决定。
// 我们无法直接断言丢弃http.Client 总会填上),只断言请求成功即可。
}
func TestRunCheckForModel_ReplaceMode_FullBodyUsedAndChallengeSkipped(t *testing.T) {
// replace 模式下我们的 body 完全自定义challenge 数学题不会出现在请求里,
// 上游也不会回正确答案 — 但只要 2xx + 响应文本非空,就算 operational
h := &captureHandler{respondText: "any non-empty text"}
endpoint := setupFakeAnthropic(t, h)
userBody := map[string]any{
"model": "user-forced-model",
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
"max_tokens": float64(10),
"system": "You are someone else",
}
opts := &CheckOptions{
BodyOverrideMode: MonitorBodyOverrideModeReplace,
BodyOverride: userBody,
}
res := runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
// 请求 body = 用户提供的原样
if h.lastBody["model"] != "user-forced-model" {
t.Errorf("replace mode should use user's model, got %v", h.lastBody["model"])
}
if h.lastBody["system"] != "You are someone else" {
t.Errorf("replace mode should use user's system, got %v", h.lastBody["system"])
}
// challenge 虽然没命中,但由于 replace 模式跳过 challenge 校验 + 响应非空 → operational
if res.Status != MonitorStatusOperational {
t.Errorf("replace mode with 2xx + non-empty text should be operational, got status=%s message=%q",
res.Status, res.Message)
}
}
func TestRunCheckForModel_ReplaceMode_EmptyResponseIsFailed(t *testing.T) {
h := &captureHandler{respondText: ""} // 上游 200 但 content[0].text 为空
endpoint := setupFakeAnthropic(t, h)
opts := &CheckOptions{
BodyOverrideMode: MonitorBodyOverrideModeReplace,
BodyOverride: map[string]any{"model": "x", "messages": []any{}},
}
res := runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
if res.Status != MonitorStatusFailed {
t.Errorf("replace mode with empty text should be failed, got status=%s", res.Status)
}
if !strings.Contains(res.Message, "replace-mode") {
t.Errorf("failure message should hint replace-mode, got %q", res.Message)
}
}