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
251 lines
8.2 KiB
Go
251 lines
8.2 KiB
Go
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)
|
||
}
|
||
}
|