Files
Agent-Native/runtime/llm/anthropic_test.go
T
weichen e63a9f38dd
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add the native Anthropic Messages client with recorded fixtures (N2 WP2)
Why: acceptance clause 1 requires the anthropic preset to complete a
tool-call round against recorded HTTP fixtures; audit N4's root cause
was an offline body builder the real client never used, with no
tool_use/thinking support on the wire.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestAnthropicPresetToolRound drives the preset through tools request →
tool_use → tool_result → final text with thinking-signature round-trip
against byte-recorded SSE fixtures; TestAnthropicBodyShape pins block
forms and cache_control; legacy OpenAI path untouched (Client.Protocol
zero value).
Impact: the anthropic preset now means the native Messages API (the
compat shim remains reachable via models.json custom providers); audit
N4 closes. BuildBody shares anthropicBody with the live client.
Refs: Agent-System-Documents#42 (N2 plan WP2), audit N4
2026-10-07 13:21:18 +00:00

251 lines
8.2 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.
package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// ---- N2 WP2:Anthropic 原生客户端(验收① 的机器证据)----
// fixtureServer 按调用序回放 testdata/anthropic 下的录制 SSE,并记录请求。
type fixtureServer struct {
t *testing.T
requests []recordedRequest
count int
}
type recordedRequest struct {
path string
apiKey string
version string
body map[string]any
}
func (f *fixtureServer) handler(w http.ResponseWriter, r *http.Request) {
f.count++
body, _ := io.ReadAll(r.Body)
var parsed map[string]any
_ = json.Unmarshal(body, &parsed)
f.requests = append(f.requests, recordedRequest{
path: r.URL.Path,
apiKey: r.Header.Get("x-api-key"),
version: r.Header.Get("anthropic-version"),
body: parsed,
})
names := []string{"01-tool-use.sse", "02-thinking-text.sse", "03-text.sse"}
raw, err := os.ReadFile(filepath.Join("testdata", "anthropic", names[f.count-1]))
if err != nil {
f.t.Fatalf("fixture %d: %v", f.count, err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write(raw)
}
// anthropicPresetClient 经预设表构建客户端(验收的是「选 anthropic 预设」本身),
// BaseURL 指到夹具服务器(预设只是结构体,测试内覆盖端点,不打真实网络)。
func anthropicPresetClient(t *testing.T, base string) *Client {
t.Helper()
var preset Provider
for _, p := range Presets() {
if p.Name == "anthropic" {
preset = p
}
}
if preset.Name == "" {
t.Fatal("anthropic preset missing")
}
preset.BaseURL = base
preset.APIKey = "fixture-key"
c, err := ClientFor(preset)
if err != nil {
t.Fatalf("ClientFor: %v", err)
}
return c
}
func collect(t *testing.T, ch <-chan Event) (end Event, texts, reasoning []string) {
t.Helper()
for ev := range ch {
switch ev.Kind {
case KindTextDelta:
texts = append(texts, ev.Text)
case KindReasoningDelta:
reasoning = append(reasoning, ev.ReasoningDelta)
case KindMessageEnd:
end = ev
case KindError:
t.Fatalf("stream error: %s", ev.ErrorMessage)
}
}
return end, texts, reasoning
}
// TestAnthropicPresetToolRound 验收①:选 anthropic 预设,用录制 HTTP 夹具
// 完成一轮完整工具调用(tools 请求 → tool_use → tool_result → 终文本),
// 且 thinking 块带原签名在下一请求里往返。
func TestAnthropicPresetToolRound(t *testing.T) {
fs := &fixtureServer{t: t}
srv := httptest.NewServer(http.HandlerFunc(fs.handler))
defer srv.Close()
c := anthropicPresetClient(t, srv.URL)
ctx := context.Background()
tools := []ToolDecl{{Name: "read_file", Description: "读文件", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)}}
// 第一回合:带 tools 请求 → tool_use。
end1, _, _ := collect(t, c.Stream(ctx, "claude-fixture", []Message{{Role: RoleUser, Content: "查一下 main.go"}}, tools, StreamOptions{}))
if end1.Message.StopReason != StopToolCalls || len(end1.Message.ToolCalls) != 1 {
t.Fatalf("resp1: %+v", end1.Message)
}
call := end1.Message.ToolCalls[0]
if call.ID != "toolu_01ABC" || call.Name != "read_file" || call.Arguments != `{"path":"main.go"}` {
t.Fatalf("tool call: %+v", call)
}
req1 := fs.requests[0]
if req1.path != "/messages" || req1.apiKey != "fixture-key" || req1.version != AnthropicVersion {
t.Fatalf("req1 headers: %+v", req1)
}
toolsBody, _ := req1.body["tools"].([]any)
if len(toolsBody) != 1 {
t.Fatalf("req1 tools: %v", req1.body["tools"])
}
tool0 := toolsBody[0].(map[string]any)
if tool0["name"] != "read_file" || tool0["input_schema"] == nil || tool0["cache_control"] == nil {
t.Fatalf("req1 tool decl: %v", tool0)
}
// 第二回合:assistant(tool_use) + tool_result → thinking(带签名) + 终文本。
msgs2 := []Message{
{Role: RoleUser, Content: "查一下 main.go"},
end1.Message,
{Role: RoleTool, ToolCallID: call.ID, Name: call.Name, Content: "1 package main\n..."},
}
end2, texts2, reasoning2 := collect(t, c.Stream(ctx, "claude-fixture", msgs2, tools, StreamOptions{}))
if end2.Message.StopReason != StopEnd || end2.Message.Content != "查到了,main.go 一共 42 行。" {
t.Fatalf("resp2: %+v", end2.Message)
}
if len(end2.Message.Thinking) != 1 || end2.Message.Thinking[0].Signature != "EqoBCkgIARABGAIiQA" {
t.Fatalf("resp2 thinking: %+v", end2.Message.Thinking)
}
if strings.Join(texts2, "") != end2.Message.Content || len(reasoning2) == 0 {
t.Fatalf("deltas: %v %v", texts2, reasoning2)
}
req2 := fs.requests[1]
msgsBody, _ := req2.body["messages"].([]any)
var toolResult map[string]any
for _, m := range msgsBody {
mm := m.(map[string]any)
if mm["role"] != "user" {
continue
}
for _, blk := range mm["content"].([]any) {
b := blk.(map[string]any)
if b["type"] == "tool_result" {
toolResult = b
}
}
}
if toolResult == nil || toolResult["tool_use_id"] != "toolu_01ABC" {
t.Fatalf("req2 tool_result: %v", toolResult)
}
// 第三回合:assistant(thinking+text) 回送 → 签名必须原样往返。
msgs3 := append(append([]Message{}, msgs2...), end2.Message, Message{Role: RoleUser, Content: "再说一遍"})
end3, _, _ := collect(t, c.Stream(ctx, "claude-fixture", msgs3, tools, StreamOptions{}))
if end3.Message.Content != "42 行。" {
t.Fatalf("resp3: %+v", end3.Message)
}
req3 := fs.requests[2]
var thinkingBlock map[string]any
for _, m := range req3.body["messages"].([]any) {
mm := m.(map[string]any)
if mm["role"] != "assistant" {
continue
}
for _, blk := range mm["content"].([]any) {
b := blk.(map[string]any)
if b["type"] == "thinking" {
thinkingBlock = b
}
}
}
if thinkingBlock == nil || thinkingBlock["signature"] != "EqoBCkgIARABGAIiQA" {
t.Fatalf("req3 thinking round-trip: %v", thinkingBlock)
}
}
// TestAnthropicBodyShape 组包单测:tool_use/tool_result/thinking 块形态、
// system 与末位工具的 cache_control、max_tokens 默认。
func TestAnthropicBodyShape(t *testing.T) {
msgs := []Message{
{Role: RoleSystem, Content: "sys"},
{Role: RoleUser, Content: "u"},
{Role: RoleAssistant, Thinking: []ThinkingBlock{{Thinking: "想", Signature: "sig"}}, ToolCalls: []ToolCall{{ID: "t1", Name: "read", Arguments: `{"a":1}`}}},
{Role: RoleTool, ToolCallID: "t1", Name: "read", Content: "r1"},
{Role: RoleTool, ToolCallID: "t2", Name: "grep", Content: "r2", IsError: true},
}
raw, err := anthropicBody("claude", msgs, []ToolDecl{{Name: "read", Description: "读"}}, StreamOptions{})
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body["max_tokens"] != float64(128000) {
t.Fatalf("max_tokens 默认:%v", body["max_tokens"])
}
sys := body["system"].([]any)[0].(map[string]any)
if sys["cache_control"] == nil {
t.Fatal("system 应挂 cache_control")
}
out := body["messages"].([]any)
// assistant:思考块在最前,其后 tool_use。
assistant := out[1].(map[string]any)
blocks := assistant["content"].([]any)
if blocks[0].(map[string]any)["type"] != "thinking" || blocks[1].(map[string]any)["type"] != "tool_use" {
t.Fatalf("assistant 块序:%v", blocks)
}
// 连续两条 tool 结果折叠进同一条 user 消息。
folded := out[2].(map[string]any)
if folded["role"] != "user" || len(folded["content"].([]any)) != 2 {
t.Fatalf("tool_result 折叠:%v", folded)
}
second := folded["content"].([]any)[1].(map[string]any)
if second["type"] != "tool_result" || second["is_error"] != true {
t.Fatalf("tool_result is_error:%v", second)
}
// 末位工具挂 cache_control。
tool := body["tools"].([]any)[0].(map[string]any)
if tool["cache_control"] == nil {
t.Fatal("末位工具应挂 cache_control")
}
}
// TestAnthropicSSEErrorFrame 协议内 error 帧映射为 KindError。
func TestAnthropicSSEErrorFrame(t *testing.T) {
raw := "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"
ch := make(chan Event, 1)
go func() {
defer close(ch)
parseAnthropicSSE(context.Background(), strings.NewReader(raw), ch)
}()
var got []Event
for ev := range ch {
got = append(got, ev)
}
if len(got) != 1 || got[0].Kind != KindError || !strings.Contains(got[0].ErrorMessage, "Overloaded") {
t.Fatalf("%+v", got)
}
}