From 1a855d3e0ebd306206d7ca5db323911aad80a04e Mon Sep 17 00:00:00 2001 From: mark-ly-wang Date: Sat, 25 Apr 2026 19:08:15 +0800 Subject: [PATCH] fix(monitor): extract anthropic text blocks (cherry picked from commit 76cf1ee834c78e3b15dee6fc23244fbae94a4bd1) --- .../service/channel_monitor_checker.go | 36 ++++++++++++-- .../channel_monitor_checker_body_test.go | 47 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index ad4058f9e..910a6e80d 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -149,11 +149,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 { @@ -161,6 +161,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* 字符串。 @@ -184,7 +185,7 @@ var providerAdapters = map[string]providerAdapter{ "anthropic-version": monitorAnthropicAPIVersion, } }, - textPath: "content.0.text", + extractText: extractAnthropicMonitorText, }, MonitorProviderGemini: { // Gemini 把 model 名写在 URL path 上:/v1beta/models/{model}:generateContent @@ -293,7 +294,34 @@ func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt if provider == MonitorProviderOpenAI && apiMode == MonitorAPIModeResponses { return extractOpenAIResponsesText(respBytes), string(respBytes), status, nil } - 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") } // extractOpenAIResponsesText 聚合 Responses API 的最终 assistant 文本。 diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index bcf7af0b9..3375ee6ac 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -451,3 +451,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") + } +}