Claude Sonnet 5.5 는 응답에 thinking 블록을 포함한다(adaptive thinking, 비활성화 불가). 어댑터가 어시스턴트 턴을 자체 구조체로 재직렬화하면서 thinking 본문과 signature 를 떨어뜨려 두 번째 턴에서 400(thinking.thinking: Field required)이 났다(2026-10-15 C-G1 실측). 응답 content 를 json.RawMessage 로 보관해 그대로 재전송한다. 테스트에 thinking 블록 재전송 검증을 추가.
123 lines
6.3 KiB
Go
123 lines
6.3 KiB
Go
package anthropic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
|
|
)
|
|
|
|
// Two turns through a fake Messages API: the first asks for a tool, the second (with previous_response_id and a
|
|
// forced submit) must carry the tool_result, the assistant tool_use block, the nudge text and an auto tool_choice.
|
|
func TestTranslateTurns(t *testing.T) {
|
|
var bodies []map[string]any
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/messages" || r.Header.Get("x-api-key") != "k1" || r.Header.Get("anthropic-version") != "2023-06-01" {
|
|
t.Errorf("bad request line/headers: %s %v", r.URL.Path, r.Header)
|
|
}
|
|
var b map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&b)
|
|
bodies = append(bodies, b)
|
|
switch len(bodies) {
|
|
case 1:
|
|
w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"thinking","thinking":"let me probe","signature":"sig-abc"},{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_probe_status","input":{}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":0}}`))
|
|
default:
|
|
w.Write([]byte(`{"id":"msg_2","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_2","name":"submit_diagnosis","input":{"root_cause_label":"x","confidence":0.9}}],"usage":{"input_tokens":200,"output_tokens":30}}`))
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
c := New(srv.URL, func() (string, error) { return "k1", nil })
|
|
pf := false
|
|
tools := []openai.Tool{{Type: "function", Strict: true, Name: "get_probe_status", Description: "probe", Parameters: map[string]any{"type": "object", "properties": map[string]any{}}}}
|
|
r1, err := c.CreateResponse(context.Background(), openai.Request{Model: "claude-sonnet-5-5", MaxOutputTokens: 1234, ParallelToolCalls: &pf, Tools: tools, ToolChoice: "auto",
|
|
Input: []openai.Item{{Type: "message", Role: "system", Content: "SYS"}, {Type: "message", Role: "user", Content: "alert"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fcs := r1.FunctionCalls()
|
|
if r1.ID != "msg_1" || len(fcs) != 1 || fcs[0].CallID != "toolu_1" || fcs[0].Name != "get_probe_status" || fcs[0].Arguments != "{}" {
|
|
t.Fatalf("turn 1 translation: %+v", r1)
|
|
}
|
|
if r1.Usage.InputTokens != 105 || r1.Usage.OutputTokens != 20 || r1.Usage.TotalTokens != 125 {
|
|
t.Fatalf("usage: %+v", r1.Usage)
|
|
}
|
|
b1 := bodies[0]
|
|
if b1["system"] != "SYS" || b1["max_tokens"].(float64) != 1234 || b1["model"] != "claude-sonnet-5-5" {
|
|
t.Fatalf("body 1: %v", b1)
|
|
}
|
|
tl := b1["tools"].([]any)[0].(map[string]any)
|
|
if tl["name"] != "get_probe_status" || tl["input_schema"] == nil || tl["strict"] != true {
|
|
t.Fatalf("tool translation: %v", tl)
|
|
}
|
|
if tc := b1["tool_choice"].(map[string]any); tc["type"] != "auto" || tc["disable_parallel_tool_use"] != true {
|
|
t.Fatalf("tool_choice 1: %v", tc)
|
|
}
|
|
|
|
r2, err := c.CreateResponse(context.Background(), openai.Request{Model: "claude-sonnet-5-5", Tools: tools, PreviousResponseID: "msg_1",
|
|
ToolChoice: map[string]any{"type": "function", "name": "submit_diagnosis"},
|
|
Input: []openai.Item{{Type: "function_call_output", CallID: "toolu_1", Output: `{"alb":1}`}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := r2.FunctionCalls(); len(got) != 1 || got[0].Name != "submit_diagnosis" || !strings.Contains(got[0].Arguments, `"root_cause_label":"x"`) {
|
|
t.Fatalf("turn 2 translation: %+v", r2.Output)
|
|
}
|
|
b2 := bodies[1]
|
|
msgs := b2["messages"].([]any)
|
|
if len(msgs) != 3 { // user(alert) / assistant(text+tool_use) / user(tool_result+nudge)
|
|
t.Fatalf("messages: %v", msgs)
|
|
}
|
|
as := msgs[1].(map[string]any)
|
|
if as["role"] != "assistant" || !strings.Contains(mustJSON(as["content"]), `"tool_use"`) {
|
|
t.Fatalf("assistant turn not replayed: %v", as)
|
|
}
|
|
// the thinking block must come back verbatim with its text and signature (Claude 5.5 rejects a stripped one)
|
|
if ac := mustJSON(as["content"]); !strings.Contains(ac, `"thinking":"let me probe"`) || !strings.Contains(ac, `"signature":"sig-abc"`) {
|
|
t.Fatalf("thinking block not replayed verbatim: %s", ac)
|
|
}
|
|
last := msgs[2].(map[string]any)["content"].([]any)
|
|
if tr := last[0].(map[string]any); tr["type"] != "tool_result" || tr["tool_use_id"] != "toolu_1" || tr["content"] != `{"alb":1}` {
|
|
t.Fatalf("tool_result: %v", tr)
|
|
}
|
|
if nd := last[1].(map[string]any); nd["type"] != "text" || !strings.Contains(nd["text"].(string), "submit_diagnosis") {
|
|
t.Fatalf("forced nudge missing: %v", nd)
|
|
}
|
|
if tc := b2["tool_choice"].(map[string]any); tc["type"] != "auto" {
|
|
t.Fatalf("forced tool_choice must become auto for Claude 5.5: %v", tc)
|
|
}
|
|
if b2["system"] != "SYS" {
|
|
t.Fatalf("system prompt must be carried on the chained turn, got %v", b2["system"])
|
|
}
|
|
}
|
|
|
|
func TestEmptyKeyAndErrors(t *testing.T) {
|
|
c := New("http://127.0.0.1:1", func() (string, error) { return "", nil })
|
|
if c.KeyPresent() {
|
|
t.Fatal("empty key reported present")
|
|
}
|
|
_, err := c.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "x"}}})
|
|
if err == nil || !strings.Contains(err.Error(), "anthropic api key is empty") {
|
|
t.Fatalf("expected empty-key error, got %v", err)
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(400)
|
|
w.Write([]byte(`{"type":"error","error":{"type":"invalid_request_error","message":"tool_choice: type \"tool\" and \"any\" are not supported for this model."}}`))
|
|
}))
|
|
defer srv.Close()
|
|
c2 := New(srv.URL, func() (string, error) { return "k", nil })
|
|
_, err = c2.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", Input: []openai.Item{{Type: "message", Role: "user", Content: "x"}}})
|
|
if err == nil || !strings.Contains(err.Error(), "status 400") || !strings.Contains(err.Error(), "invalid_request_error") {
|
|
t.Fatalf("expected 400 error, got %v", err)
|
|
}
|
|
_, err = c2.CreateResponse(context.Background(), openai.Request{Model: "claude-haiku-5-5", PreviousResponseID: "nope", Input: []openai.Item{{Type: "function_call_output", CallID: "a", Output: "b"}}})
|
|
if err == nil || !strings.Contains(err.Error(), "unknown previous_response_id") {
|
|
t.Fatalf("expected unknown id error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func mustJSON(v any) string { b, _ := json.Marshal(v); return string(b) }
|