fix(monitor): extract anthropic text blocks

This commit is contained in:
mark-ly-wang
2026-04-25 19:08:15 +08:00
parent 9d1751ec57
commit 76cf1ee834
2 changed files with 79 additions and 4 deletions
@@ -146,11 +146,11 @@ func pingEndpointOrigin(ctx context.Context, endpoint string) *int {
return &ms
}
// providerAdapter 描述某个 provider 在 challenge 检测中需要的 4 件事:
// providerAdapter 描述某个 provider 在 challenge 检测中需要的几件事:
// - 拼出请求路径(含 model 占位)
// - 序列化请求体
// - 构造鉴权头
// - 从响应 JSON 中按 path 提取文本(gjson path)
// - 从响应 JSON 中提取文本(默认按 gjson path;需要时可自定义)
//
// 加新 provider 只需要在 providerAdapters 里增加一个条目,无需触碰 callProvider / validateProvider。
type providerAdapter struct {
@@ -158,6 +158,7 @@ type providerAdapter struct {
buildBody func(model, prompt string) ([]byte, error)
buildHeaders func(apiKey string) map[string]string
textPath string // gjson 提取响应文本的 path
extractText func([]byte) string
}
// providerAdapters 全部已支持的 provider。键值即 MonitorProvider* 字符串。
@@ -194,7 +195,7 @@ var providerAdapters = map[string]providerAdapter{
"anthropic-version": monitorAnthropicAPIVersion,
}
},
textPath: "content.0.text",
extractText: extractAnthropicMonitorText,
},
MonitorProviderGemini: {
// Gemini 把 model 名写在 URL path 上:/v1beta/models/{model}:generateContent
@@ -245,7 +246,34 @@ func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt
if err != nil {
return "", "", status, err
}
return gjson.GetBytes(respBytes, adapter.textPath).String(), string(respBytes), status, nil
return extractMonitorResponseText(adapter, respBytes), string(respBytes), status, nil
}
func extractMonitorResponseText(adapter providerAdapter, respBytes []byte) string {
if adapter.extractText != nil {
return adapter.extractText(respBytes)
}
return gjson.GetBytes(respBytes, adapter.textPath).String()
}
func extractAnthropicMonitorText(respBytes []byte) string {
content := gjson.GetBytes(respBytes, "content")
if !content.IsArray() {
return ""
}
parts := make([]string, 0, 1)
content.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() != "text" {
return true
}
text := strings.TrimSpace(item.Get("text").String())
if text != "" {
parts = append(parts, text)
}
return true
})
return strings.Join(parts, "\n")
}
// mergeHeaders 把用户自定义 headers 合并到 adapter 默认 headers 上。
@@ -171,3 +171,50 @@ func TestRunCheckForModel_ReplaceMode_EmptyResponseIsFailed(t *testing.T) {
t.Errorf("failure message should hint replace-mode, got %q", res.Message)
}
}
func TestExtractAnthropicMonitorText(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{
name: "text block after thinking",
body: `{"content":[{"type":"thinking","thinking":""},{"type":"text","text":"2"}]}`,
want: "2",
},
{
name: "single text block",
body: `{"content":[{"type":"text","text":"2"}]}`,
want: "2",
},
{
name: "thinking only",
body: `{"content":[{"type":"thinking","thinking":""}]}`,
want: "",
},
{
name: "multiple text blocks",
body: `{"content":[{"type":"text","text":"answer"},{"type":"tool_use","name":"x"},{"type":"text","text":"2"}]}`,
want: "answer\n2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractAnthropicMonitorText([]byte(tt.body))
if got != tt.want {
t.Fatalf("extractAnthropicMonitorText() = %q, want %q", got, tt.want)
}
})
}
}
func TestValidateChallenge_AnthropicTextAfterThinking(t *testing.T) {
body := []byte(`{"content":[{"type":"thinking","thinking":""},{"type":"text","text":"答案是 2"}]}`)
respText := extractAnthropicMonitorText(body)
if !validateChallenge(respText, "2") {
t.Fatalf("validateChallenge(%q, %q) = false, want true", respText, "2")
}
}