mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-15 05:07:30 +08:00
710 lines
28 KiB
Go
710 lines
28 KiB
Go
package providers
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestClaudeProviderDisablesThinkingWhenToolChoiceForced(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
body := p.requestBody([]Message{{Role: "user", Content: "hi"}}, []ToolDefinition{{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "lookup",
|
|
Description: "Lookup data",
|
|
Parameters: map[string]interface{}{
|
|
"type": "object",
|
|
},
|
|
},
|
|
}}, "claude-sonnet", map[string]interface{}{
|
|
"tool_choice": "any",
|
|
"thinking": map[string]interface{}{
|
|
"type": "enabled",
|
|
},
|
|
}, false)
|
|
|
|
if _, ok := body["thinking"]; ok {
|
|
t.Fatalf("expected thinking to be removed when tool_choice forces tool use, got %#v", body["thinking"])
|
|
}
|
|
toolChoice := mapFromAny(body["tool_choice"])
|
|
if got := asString(toolChoice["type"]); got != "any" {
|
|
t.Fatalf("expected tool_choice to remain any, got %#v", toolChoice)
|
|
}
|
|
}
|
|
|
|
func TestClaudeToolChoiceSupportsRequiredAndFunctionForms(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
|
|
requiredBody := p.requestBody([]Message{{Role: "user", Content: "hi"}}, []ToolDefinition{{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "lookup",
|
|
Parameters: map[string]interface{}{"type": "object"},
|
|
},
|
|
}}, "claude-sonnet", map[string]interface{}{
|
|
"tool_choice": "required",
|
|
}, false)
|
|
requiredChoice := mapFromAny(requiredBody["tool_choice"])
|
|
if got := asString(requiredChoice["type"]); got != "any" {
|
|
t.Fatalf("expected required -> any, got %#v", requiredChoice)
|
|
}
|
|
|
|
functionBody := p.requestBody([]Message{{Role: "user", Content: "hi"}}, []ToolDefinition{{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "lookup",
|
|
Parameters: map[string]interface{}{"type": "object"},
|
|
},
|
|
}}, "claude-sonnet", map[string]interface{}{
|
|
"tool_choice": map[string]interface{}{
|
|
"type": "function",
|
|
"function": map[string]interface{}{
|
|
"name": "lookup",
|
|
},
|
|
},
|
|
}, false)
|
|
functionChoice := mapFromAny(functionBody["tool_choice"])
|
|
if got := asString(functionChoice["type"]); got != "tool" || asString(functionChoice["name"]) != "lookup" {
|
|
t.Fatalf("expected function choice -> tool lookup, got %#v", functionChoice)
|
|
}
|
|
|
|
mapRequiredBody := p.requestBody([]Message{{Role: "user", Content: "hi"}}, nil, "claude-sonnet", map[string]interface{}{
|
|
"tool_choice": map[string]interface{}{"type": "required"},
|
|
}, false)
|
|
mapRequiredChoice := mapFromAny(mapRequiredBody["tool_choice"])
|
|
if got := asString(mapRequiredChoice["type"]); got != "any" {
|
|
t.Fatalf("expected map required -> any, got %#v", mapRequiredChoice)
|
|
}
|
|
|
|
noneBody := p.requestBody([]Message{{Role: "user", Content: "hi"}}, nil, "claude-sonnet", map[string]interface{}{
|
|
"tool_choice": "none",
|
|
}, false)
|
|
if _, ok := noneBody["tool_choice"]; ok {
|
|
t.Fatalf("expected string none tool_choice to be omitted, got %#v", noneBody["tool_choice"])
|
|
}
|
|
|
|
noneMapBody := p.requestBody([]Message{{Role: "user", Content: "hi"}}, nil, "claude-sonnet", map[string]interface{}{
|
|
"tool_choice": map[string]interface{}{"type": "none"},
|
|
}, false)
|
|
if _, ok := noneMapBody["tool_choice"]; ok {
|
|
t.Fatalf("expected none tool_choice to be omitted, got %#v", noneMapBody["tool_choice"])
|
|
}
|
|
}
|
|
|
|
func TestReadClaudeBodyDecodesGzip(t *testing.T) {
|
|
var compressed bytes.Buffer
|
|
writer := gzip.NewWriter(&compressed)
|
|
if _, err := writer.Write([]byte(`{"ok":true}`)); err != nil {
|
|
t.Fatalf("gzip write failed: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("gzip close failed: %v", err)
|
|
}
|
|
|
|
body, err := readClaudeBody(io.NopCloser(bytes.NewReader(compressed.Bytes())), "gzip")
|
|
if err != nil {
|
|
t.Fatalf("readClaudeBody failed: %v", err)
|
|
}
|
|
if string(body) != `{"ok":true}` {
|
|
t.Fatalf("unexpected decoded body: %s", string(body))
|
|
}
|
|
}
|
|
|
|
func TestClaudeCacheControlInjectionAndLimit(t *testing.T) {
|
|
body := map[string]interface{}{
|
|
"tools": []map[string]interface{}{
|
|
{"name": "t1"},
|
|
{"name": "t2"},
|
|
},
|
|
"system": []map[string]interface{}{
|
|
{"type": "text", "text": "s1"},
|
|
{"type": "text", "text": "s2"},
|
|
},
|
|
"messages": []map[string]interface{}{
|
|
{"role": "user", "content": []map[string]interface{}{{"type": "text", "text": "u1"}}},
|
|
{"role": "assistant", "content": []map[string]interface{}{{"type": "text", "text": "a1"}}},
|
|
{"role": "user", "content": []map[string]interface{}{{"type": "text", "text": "u2"}}},
|
|
},
|
|
}
|
|
body = ensureClaudeCacheControl(body)
|
|
if _, ok := body["tools"].([]map[string]interface{})[1]["cache_control"]; !ok {
|
|
t.Fatalf("expected last tool cache_control")
|
|
}
|
|
if _, ok := body["system"].([]map[string]interface{})[1]["cache_control"]; !ok {
|
|
t.Fatalf("expected last system cache_control")
|
|
}
|
|
msgs := body["messages"].([]map[string]interface{})
|
|
content := msgs[0]["content"].([]map[string]interface{})
|
|
if _, ok := content[0]["cache_control"]; !ok {
|
|
t.Fatalf("expected second-to-last user message cache_control")
|
|
}
|
|
|
|
blocks := claudeCacheBlocks(body)
|
|
if len(blocks) != 3 {
|
|
t.Fatalf("expected 3 cache blocks, got %d", len(blocks))
|
|
}
|
|
}
|
|
|
|
func TestClaudeNormalizeCacheControlTTL(t *testing.T) {
|
|
body := map[string]interface{}{
|
|
"tools": []map[string]interface{}{
|
|
{"name": "t1", "cache_control": map[string]interface{}{"type": "ephemeral", "ttl": "1h"}},
|
|
{"name": "t2", "cache_control": map[string]interface{}{"type": "ephemeral"}},
|
|
},
|
|
"messages": []map[string]interface{}{
|
|
{"role": "user", "content": []map[string]interface{}{{"type": "text", "text": "u1", "cache_control": map[string]interface{}{"type": "ephemeral", "ttl": "1h"}}}},
|
|
},
|
|
}
|
|
body = normalizeClaudeCacheControlTTL(body)
|
|
tools := body["tools"].([]map[string]interface{})
|
|
if got := asString(mapFromAny(tools[0]["cache_control"])["ttl"]); got != "1h" {
|
|
t.Fatalf("expected first ttl preserved, got %q", got)
|
|
}
|
|
msgs := body["messages"].([]map[string]interface{})
|
|
content := msgs[0]["content"].([]map[string]interface{})
|
|
if _, ok := mapFromAny(content[0]["cache_control"])["ttl"]; ok {
|
|
t.Fatalf("expected later ttl removed after default block")
|
|
}
|
|
}
|
|
|
|
func TestClaudeToolPrefixHelpers(t *testing.T) {
|
|
body := map[string]interface{}{
|
|
"tools": []map[string]interface{}{
|
|
{"type": "web_search_20250305", "name": "web_search"},
|
|
{"name": "Read"},
|
|
},
|
|
"tool_choice": map[string]interface{}{"type": "tool", "name": "Read"},
|
|
"messages": []map[string]interface{}{
|
|
{"role": "assistant", "content": []map[string]interface{}{
|
|
{"type": "tool_use", "name": "Read", "id": "t1", "input": map[string]interface{}{}},
|
|
{"type": "tool_reference", "tool_name": "abc"},
|
|
}},
|
|
{"role": "user", "content": []map[string]interface{}{
|
|
{"type": "tool_result", "tool_use_id": "t1", "content": []map[string]interface{}{
|
|
{"type": "tool_reference", "tool_name": "nested"},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
prefixed := applyClaudeToolPrefixToBody(body, "proxy_")
|
|
tools := prefixed["tools"].([]map[string]interface{})
|
|
if got := asString(tools[0]["name"]); got != "web_search" {
|
|
t.Fatalf("builtin tool should not be prefixed, got %q", got)
|
|
}
|
|
if got := asString(tools[1]["name"]); got != "proxy_Read" {
|
|
t.Fatalf("custom tool should be prefixed, got %q", got)
|
|
}
|
|
toolChoice := mapFromAny(prefixed["tool_choice"])
|
|
if got := asString(toolChoice["name"]); got != "proxy_Read" {
|
|
t.Fatalf("tool_choice should be prefixed, got %q", got)
|
|
}
|
|
msgs := prefixed["messages"].([]map[string]interface{})
|
|
assistantContent := msgs[0]["content"].([]map[string]interface{})
|
|
if got := asString(assistantContent[0]["name"]); got != "proxy_Read" {
|
|
t.Fatalf("tool_use should be prefixed, got %q", got)
|
|
}
|
|
if got := asString(assistantContent[1]["tool_name"]); got != "proxy_abc" {
|
|
t.Fatalf("tool_reference should be prefixed, got %q", got)
|
|
}
|
|
userContent := msgs[1]["content"].([]map[string]interface{})
|
|
nested := userContent[0]["content"].([]map[string]interface{})
|
|
if got := asString(nested[0]["tool_name"]); got != "proxy_nested" {
|
|
t.Fatalf("nested tool_reference should be prefixed, got %q", got)
|
|
}
|
|
|
|
raw := []byte(`{"content":[{"type":"tool_use","name":"proxy_Read"},{"type":"tool_reference","tool_name":"proxy_abc"},{"type":"tool_result","content":[{"type":"tool_reference","tool_name":"proxy_nested"}]}]}`)
|
|
stripped := stripClaudeToolPrefixFromResponse(raw, "proxy_")
|
|
if !bytes.Contains(stripped, []byte(`"name":"Read"`)) || !bytes.Contains(stripped, []byte(`"tool_name":"abc"`)) || !bytes.Contains(stripped, []byte(`"tool_name":"nested"`)) {
|
|
t.Fatalf("expected stripped response, got %s", string(stripped))
|
|
}
|
|
|
|
line := []byte(`{"content_block":{"type":"tool_reference","tool_name":"proxy_abc"}}`)
|
|
out := stripClaudeToolPrefixFromStreamLine(line, "proxy_")
|
|
if !bytes.Contains(out, []byte(`"tool_name":"abc"`)) {
|
|
t.Fatalf("expected stripped stream line, got %s", string(out))
|
|
}
|
|
|
|
sseLine := []byte(`data: {"content_block":{"type":"tool_reference","tool_name":"proxy_sse"}}`)
|
|
sseOut := stripClaudeToolPrefixFromStreamLine(sseLine, "proxy_")
|
|
if !bytes.HasPrefix(sseOut, []byte("data: ")) || !bytes.Contains(sseOut, []byte(`"tool_name":"sse"`)) {
|
|
t.Fatalf("expected stripped SSE stream line, got %s", string(sseOut))
|
|
}
|
|
}
|
|
|
|
func TestClaudeSystemBlocksAreEnriched(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
body := p.requestBody([]Message{
|
|
{Role: "system", Content: "System one"},
|
|
{Role: "developer", Content: "System two"},
|
|
{Role: "user", Content: "hi"},
|
|
}, nil, "claude-sonnet", nil, false)
|
|
|
|
system, ok := body["system"].([]map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected system blocks array, got %#v", body["system"])
|
|
}
|
|
if len(system) < 4 {
|
|
t.Fatalf("expected enriched system blocks, got %#v", system)
|
|
}
|
|
if got := asString(system[0]["text"]); !strings.HasPrefix(got, "x-anthropic-billing-header:") {
|
|
t.Fatalf("expected billing header block, got %q", got)
|
|
}
|
|
if got := asString(system[1]["text"]); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
|
|
t.Fatalf("expected agent block, got %q", got)
|
|
}
|
|
if got := asString(system[2]["text"]); got != "System one" {
|
|
t.Fatalf("expected first user system block, got %q", got)
|
|
}
|
|
if got := asString(system[3]["text"]); got != "System two" {
|
|
t.Fatalf("expected second user system block, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSystemBlocksIncludeContentPartsText(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
body := p.requestBody([]Message{
|
|
{
|
|
Role: "system",
|
|
ContentParts: []MessageContentPart{
|
|
{Type: "text", Text: "Alpha"},
|
|
{Type: "text", Text: "Beta"},
|
|
},
|
|
},
|
|
{Role: "user", Content: "hi"},
|
|
}, nil, "claude-sonnet", nil, false)
|
|
|
|
system := body["system"].([]map[string]interface{})
|
|
if got := asString(system[2]["text"]); got != "Alpha\nBeta" {
|
|
t.Fatalf("expected content parts joined into system text, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeSystemBlocksSupportStrictMode(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
body := p.requestBody([]Message{
|
|
{Role: "system", Content: "System one"},
|
|
{Role: "developer", Content: "System two"},
|
|
{Role: "user", Content: "hi"},
|
|
}, nil, "claude-sonnet", map[string]interface{}{
|
|
"claude_strict_system": true,
|
|
}, false)
|
|
|
|
system, ok := body["system"].([]map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected system blocks array, got %#v", body["system"])
|
|
}
|
|
if len(system) != 2 {
|
|
t.Fatalf("expected strict mode to keep only billing+agent blocks, got %#v", system)
|
|
}
|
|
if got := asString(system[0]["text"]); !strings.HasPrefix(got, "x-anthropic-billing-header:") {
|
|
t.Fatalf("expected billing header block, got %q", got)
|
|
}
|
|
if got := asString(system[1]["text"]); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
|
|
t.Fatalf("expected agent block, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeRequestBodyMapsImageAndFileContentParts(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
body := p.requestBody([]Message{{
|
|
Role: "user",
|
|
ContentParts: []MessageContentPart{
|
|
{Type: "text", Text: "look"},
|
|
{Type: "input_image", ImageURL: "data:image/png;base64,AAAA"},
|
|
{Type: "input_image", ImageURL: "https://example.com/a.png"},
|
|
{Type: "input_file", FileData: "data:application/pdf;base64,BBBB"},
|
|
},
|
|
}}, nil, "claude-sonnet", nil, false)
|
|
|
|
msgs := body["messages"].([]map[string]interface{})
|
|
content := msgs[0]["content"].([]map[string]interface{})
|
|
if got := asString(content[0]["type"]); got != "text" || asString(content[0]["text"]) != "look" {
|
|
t.Fatalf("expected text part preserved, got %#v", content[0])
|
|
}
|
|
imageBase64 := mapFromAny(content[1]["source"])
|
|
if got := asString(content[1]["type"]); got != "image" {
|
|
t.Fatalf("expected image part, got %#v", content[1])
|
|
}
|
|
if got := asString(imageBase64["type"]); got != "base64" || asString(imageBase64["media_type"]) != "image/png" || asString(imageBase64["data"]) != "AAAA" {
|
|
t.Fatalf("expected base64 image source, got %#v", imageBase64)
|
|
}
|
|
imageURL := mapFromAny(content[2]["source"])
|
|
if got := asString(imageURL["type"]); got != "url" || asString(imageURL["url"]) != "https://example.com/a.png" {
|
|
t.Fatalf("expected url image source, got %#v", imageURL)
|
|
}
|
|
doc := mapFromAny(content[3]["source"])
|
|
if got := asString(content[3]["type"]); got != "document" {
|
|
t.Fatalf("expected document part, got %#v", content[3])
|
|
}
|
|
if got := asString(doc["type"]); got != "base64" || asString(doc["media_type"]) != "application/pdf" || asString(doc["data"]) != "BBBB" {
|
|
t.Fatalf("expected base64 document source, got %#v", doc)
|
|
}
|
|
}
|
|
|
|
func TestClaudeRequestBodyKeepsSingleTextAsString(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
|
|
body := p.requestBody([]Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet", nil, false)
|
|
msgs := body["messages"].([]map[string]interface{})
|
|
if got := msgs[0]["content"]; got != "hello" {
|
|
t.Fatalf("expected plain string content, got %#v", got)
|
|
}
|
|
|
|
partsBody := p.requestBody([]Message{{
|
|
Role: "user",
|
|
ContentParts: []MessageContentPart{
|
|
{Type: "text", Text: "hello"},
|
|
},
|
|
}}, nil, "claude-sonnet", nil, false)
|
|
partsMsgs := partsBody["messages"].([]map[string]interface{})
|
|
if got := partsMsgs[0]["content"]; got != "hello" {
|
|
t.Fatalf("expected single text content part to collapse to string, got %#v", got)
|
|
}
|
|
|
|
assistantBody := p.requestBody([]Message{{Role: "assistant", Content: "done"}}, nil, "claude-sonnet", nil, false)
|
|
assistantMsgs := assistantBody["messages"].([]map[string]interface{})
|
|
if got := assistantMsgs[0]["content"]; got != "done" {
|
|
t.Fatalf("expected assistant single text to collapse to string, got %#v", got)
|
|
}
|
|
|
|
assistantWithTool := p.requestBody([]Message{{
|
|
Role: "assistant",
|
|
Content: "done",
|
|
ToolCalls: []ToolCall{{
|
|
ID: "call_1",
|
|
Name: "lookup",
|
|
Function: &FunctionCall{
|
|
Name: "lookup",
|
|
Arguments: `{"q":"x"}`,
|
|
},
|
|
}},
|
|
}}, nil, "claude-sonnet", nil, false)
|
|
assistantWithToolMsgs := assistantWithTool["messages"].([]map[string]interface{})
|
|
if _, ok := assistantWithToolMsgs[0]["content"].([]map[string]interface{}); !ok {
|
|
t.Fatalf("expected assistant content with tools to remain structured array, got %#v", assistantWithToolMsgs[0]["content"])
|
|
}
|
|
}
|
|
|
|
func TestClaudeRequestBodyMapsToolResultContentParts(t *testing.T) {
|
|
p := NewClaudeProvider("claude", "", "", "claude-sonnet", false, "oauth", 0, nil)
|
|
body := p.requestBody([]Message{
|
|
{
|
|
Role: "assistant",
|
|
ToolCalls: []ToolCall{{
|
|
ID: "call_1",
|
|
Name: "lookup",
|
|
Function: &FunctionCall{
|
|
Name: "lookup",
|
|
Arguments: `{"q":"x"}`,
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
Role: "tool",
|
|
ToolCallID: "call_1",
|
|
ContentParts: []MessageContentPart{
|
|
{Type: "text", Text: "done"},
|
|
{Type: "input_image", ImageURL: "data:image/png;base64,AAAA"},
|
|
{Type: "input_file", FileData: "data:application/pdf;base64,BBBB"},
|
|
},
|
|
},
|
|
}, nil, "claude-sonnet", nil, false)
|
|
|
|
msgs := body["messages"].([]map[string]interface{})
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("expected 2 messages, got %#v", msgs)
|
|
}
|
|
toolResult := msgs[1]["content"].([]map[string]interface{})[0]
|
|
resultContent := mustMapSlice(t, toolResult["content"])
|
|
if got := asString(resultContent[0]["type"]); got != "text" || asString(resultContent[0]["text"]) != "done" {
|
|
t.Fatalf("expected text tool result part, got %#v", resultContent[0])
|
|
}
|
|
if got := asString(resultContent[1]["type"]); got != "image" {
|
|
t.Fatalf("expected image tool result part, got %#v", resultContent[1])
|
|
}
|
|
if got := asString(resultContent[2]["type"]); got != "document" {
|
|
t.Fatalf("expected document tool result part, got %#v", resultContent[2])
|
|
}
|
|
}
|
|
|
|
func TestClaudeProviderCountTokens(t *testing.T) {
|
|
var requestBody map[string]interface{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/messages/count_tokens" {
|
|
t.Fatalf("expected /v1/messages/count_tokens, got %s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Accept"); got != "application/json" {
|
|
t.Fatalf("expected application/json accept header, got %q", got)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
|
t.Fatalf("decode request: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"input_tokens":321}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
p := NewClaudeProvider("claude", "sk-ant-oat-test", server.URL, "claude-sonnet", false, "bearer", 0, nil)
|
|
usage, err := p.CountTokens(t.Context(), []Message{{
|
|
Role: "user",
|
|
ContentParts: []MessageContentPart{
|
|
{Type: "text", Text: "count this"},
|
|
{Type: "input_image", ImageURL: "data:image/png;base64,AAAA"},
|
|
},
|
|
}}, nil, "claude-sonnet", map[string]interface{}{
|
|
"max_tokens": int64(128),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CountTokens error: %v", err)
|
|
}
|
|
if usage == nil || usage.PromptTokens != 321 || usage.TotalTokens != 321 || usage.CompletionTokens != 0 {
|
|
t.Fatalf("unexpected usage: %#v", usage)
|
|
}
|
|
if _, ok := requestBody["stream"]; ok {
|
|
t.Fatalf("did not expect stream in count_tokens request: %#v", requestBody)
|
|
}
|
|
if _, ok := requestBody["max_tokens"]; ok {
|
|
t.Fatalf("did not expect max_tokens in count_tokens request: %#v", requestBody)
|
|
}
|
|
msgs := mustMapSlice(t, requestBody["messages"])
|
|
content := mustMapSlice(t, msgs[0]["content"])
|
|
if got := asString(content[1]["type"]); got != "image" {
|
|
t.Fatalf("expected image content in count_tokens request, got %#v", content[1])
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeCompatHeadersUsesDynamicStainlessValues(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil)
|
|
applyClaudeCompatHeaders(req, authAttempt{kind: "oauth", token: "tok"}, false)
|
|
if got := req.Header.Get("X-Stainless-Arch"); got != claudeStainlessArch() {
|
|
t.Fatalf("expected dynamic arch %q, got %q", claudeStainlessArch(), got)
|
|
}
|
|
if got := req.Header.Get("X-Stainless-Os"); got != claudeStainlessOS() {
|
|
t.Fatalf("expected dynamic os %q, got %q", claudeStainlessOS(), got)
|
|
}
|
|
if got := req.Header.Get("Authorization"); got != "Bearer tok" {
|
|
t.Fatalf("expected bearer auth, got %q", got)
|
|
}
|
|
if req.Header.Get("x-api-key") != "" {
|
|
t.Fatalf("did not expect x-api-key for oauth attempt")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeCompatHeadersUsesIdentityEncodingForStream(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil)
|
|
applyClaudeCompatHeaders(req, authAttempt{kind: "api_key", token: "tok"}, true)
|
|
if got := req.Header.Get("Accept-Encoding"); got != "identity" {
|
|
t.Fatalf("expected identity accept-encoding for stream, got %q", got)
|
|
}
|
|
if got := req.Header.Get("x-api-key"); got != "tok" {
|
|
t.Fatalf("expected x-api-key for anthropic api key request, got %q", got)
|
|
}
|
|
if req.Header.Get("Authorization") != "" {
|
|
t.Fatalf("did not expect Authorization header for anthropic api_key request")
|
|
}
|
|
}
|
|
|
|
func TestApplyClaudeBetaHeadersAddsContext1MWhenEnabled(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil)
|
|
applyClaudeBetaHeaders(req, map[string]interface{}{
|
|
"claude_1m": true,
|
|
}, []string{"custom-beta"})
|
|
got := req.Header.Get("Anthropic-Beta")
|
|
if !strings.Contains(got, "context-1m-2025-08-07") {
|
|
t.Fatalf("expected context-1m beta, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "custom-beta") {
|
|
t.Fatalf("expected custom beta, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestClaudeStreamStateMergesUsageAcrossEvents(t *testing.T) {
|
|
state := &claudeStreamState{}
|
|
state.consume([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":12}}}`))
|
|
delta := state.consume([]byte(`{"type":"content_block_start","content_block":{"type":"text","text":"he"}}`))
|
|
if delta != "he" {
|
|
t.Fatalf("expected initial text delta, got %q", delta)
|
|
}
|
|
state.consume([]byte(`{"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}`))
|
|
state.consume([]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`))
|
|
final := state.finalBody()
|
|
|
|
resp, err := parseClaudeResponse(final)
|
|
if err != nil {
|
|
t.Fatalf("parse final body: %v", err)
|
|
}
|
|
if resp.Content != "hello" {
|
|
t.Fatalf("expected merged content, got %q", resp.Content)
|
|
}
|
|
if resp.Usage == nil || resp.Usage.PromptTokens != 12 || resp.Usage.CompletionTokens != 7 || resp.Usage.TotalTokens != 19 {
|
|
t.Fatalf("expected merged usage, got %#v", resp.Usage)
|
|
}
|
|
if resp.FinishReason != "end_turn" {
|
|
t.Fatalf("expected finish reason, got %q", resp.FinishReason)
|
|
}
|
|
}
|
|
|
|
func TestClaudeStreamStateMergesToolUseInputAcrossEvents(t *testing.T) {
|
|
state := &claudeStreamState{}
|
|
state.consume([]byte(`{"type":"content_block_start","content_block":{"type":"tool_use","id":"tool_1","name":"lookup","input":{"a":"b"}}}`))
|
|
state.consume([]byte(`{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":",\"c\":1}"}}`))
|
|
state.consume([]byte(`{"type":"content_block_stop"}`))
|
|
state.consume([]byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use"}}`))
|
|
|
|
final := state.finalBody()
|
|
resp, err := parseClaudeResponse(final)
|
|
if err != nil {
|
|
t.Fatalf("parse final body: %v", err)
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("expected one tool call, got %#v", resp.ToolCalls)
|
|
}
|
|
if resp.ToolCalls[0].Name != "lookup" {
|
|
t.Fatalf("expected tool name lookup, got %#v", resp.ToolCalls[0])
|
|
}
|
|
if resp.ToolCalls[0].Function == nil || resp.ToolCalls[0].Function.Arguments != `{"a":"b","c":1}` {
|
|
t.Fatalf("expected merged arguments, got %#v", resp.ToolCalls[0].Function)
|
|
}
|
|
if resp.FinishReason != "tool_use" {
|
|
t.Fatalf("expected finish reason tool_use, got %q", resp.FinishReason)
|
|
}
|
|
}
|
|
|
|
func TestClaudeStreamStateReadsMessageStartContent(t *testing.T) {
|
|
state := &claudeStreamState{}
|
|
state.consume([]byte(`{"type":"message_start","message":{"content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"tool_1","name":"lookup","input":{"x":1}}],"usage":{"input_tokens":3}}}`))
|
|
state.consume([]byte(`{"type":"message_stop"}`))
|
|
|
|
resp, err := parseClaudeResponse(state.finalBody())
|
|
if err != nil {
|
|
t.Fatalf("parse final body: %v", err)
|
|
}
|
|
if resp.Content != "hello" {
|
|
t.Fatalf("expected content from message_start, got %q", resp.Content)
|
|
}
|
|
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "lookup" {
|
|
t.Fatalf("expected tool call from message_start, got %#v", resp.ToolCalls)
|
|
}
|
|
if resp.Usage == nil || resp.Usage.PromptTokens != 3 {
|
|
t.Fatalf("expected usage from message_start, got %#v", resp.Usage)
|
|
}
|
|
}
|
|
|
|
func TestClaudeStreamStateDedupesMessageStartAndContentBlocks(t *testing.T) {
|
|
state := &claudeStreamState{}
|
|
state.consume([]byte(`{"type":"message_start","message":{"content":[{"type":"text","text":"hello"}]}}`))
|
|
if delta := state.consume([]byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":"he"}}`)); delta != "" {
|
|
t.Fatalf("expected no duplicate delta from content_block_start, got %q", delta)
|
|
}
|
|
if delta := state.consume([]byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"llo"}}`)); delta != "" {
|
|
t.Fatalf("expected no duplicate delta from content_block_delta, got %q", delta)
|
|
}
|
|
state.consume([]byte(`{"type":"message_stop"}`))
|
|
|
|
resp, err := parseClaudeResponse(state.finalBody())
|
|
if err != nil {
|
|
t.Fatalf("parse final body: %v", err)
|
|
}
|
|
if resp.Content != "hello" {
|
|
t.Fatalf("expected deduped content hello, got %q", resp.Content)
|
|
}
|
|
}
|
|
|
|
func TestClaudeStreamStatePreservesMessageStartToolUseAcrossDuplicateBlocks(t *testing.T) {
|
|
state := &claudeStreamState{}
|
|
state.consume([]byte(`{"type":"message_start","message":{"content":[{"type":"tool_use","id":"tool_1","name":"lookup","input":{"x":1}}]}}`))
|
|
state.consume([]byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool_1","name":"lookup","input":{}}}`))
|
|
state.consume([]byte(`{"type":"content_block_stop","index":0}`))
|
|
state.consume([]byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use"}}`))
|
|
|
|
resp, err := parseClaudeResponse(state.finalBody())
|
|
if err != nil {
|
|
t.Fatalf("parse final body: %v", err)
|
|
}
|
|
if len(resp.ToolCalls) != 1 {
|
|
t.Fatalf("expected one tool call, got %#v", resp.ToolCalls)
|
|
}
|
|
if resp.ToolCalls[0].Function == nil || resp.ToolCalls[0].Function.Arguments != `{"x":1}` {
|
|
t.Fatalf("expected original tool arguments preserved, got %#v", resp.ToolCalls[0].Function)
|
|
}
|
|
if resp.FinishReason != "tool_use" {
|
|
t.Fatalf("expected finish reason tool_use, got %q", resp.FinishReason)
|
|
}
|
|
}
|
|
|
|
func TestClaudeExtractBetasFromPayload(t *testing.T) {
|
|
payload := map[string]interface{}{
|
|
"model": "claude-sonnet",
|
|
"betas": []interface{}{"context-1m-2025-08-07", "custom-beta"},
|
|
}
|
|
betas, out := extractClaudeBetasFromPayload(payload)
|
|
if len(betas) != 2 {
|
|
t.Fatalf("expected 2 betas, got %#v", betas)
|
|
}
|
|
if _, ok := out["betas"]; ok {
|
|
t.Fatalf("expected betas removed from payload, got %#v", out)
|
|
}
|
|
}
|
|
|
|
func mustMapSlice(t *testing.T, value interface{}) []map[string]interface{} {
|
|
t.Helper()
|
|
switch typed := value.(type) {
|
|
case []map[string]interface{}:
|
|
return typed
|
|
case []interface{}:
|
|
out := make([]map[string]interface{}, 0, len(typed))
|
|
for _, item := range typed {
|
|
obj := mapFromAny(item)
|
|
if len(obj) > 0 {
|
|
out = append(out, obj)
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
t.Fatalf("expected map slice, got %#v", value)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func TestClaudeStainlessMappings(t *testing.T) {
|
|
if runtime.GOOS == "darwin" && claudeStainlessOS() != "MacOS" {
|
|
t.Fatalf("expected darwin -> MacOS, got %q", claudeStainlessOS())
|
|
}
|
|
if runtime.GOARCH == "amd64" && claudeStainlessArch() != "x64" {
|
|
t.Fatalf("expected amd64 -> x64, got %q", claudeStainlessArch())
|
|
}
|
|
}
|
|
|
|
func TestClaudeCacheControlLimitPreservesLastTool(t *testing.T) {
|
|
body := map[string]interface{}{
|
|
"tools": []map[string]interface{}{
|
|
{"name": "t1", "cache_control": map[string]interface{}{"type": "ephemeral"}},
|
|
{"name": "t2", "cache_control": map[string]interface{}{"type": "ephemeral"}},
|
|
},
|
|
"system": []map[string]interface{}{
|
|
{"type": "text", "text": "s1", "cache_control": map[string]interface{}{"type": "ephemeral"}},
|
|
},
|
|
"messages": []map[string]interface{}{
|
|
{"role": "user", "content": []map[string]interface{}{{"type": "text", "text": "u1", "cache_control": map[string]interface{}{"type": "ephemeral"}}}},
|
|
{"role": "user", "content": []map[string]interface{}{{"type": "text", "text": "u2", "cache_control": map[string]interface{}{"type": "ephemeral"}}}},
|
|
},
|
|
}
|
|
body = enforceClaudeCacheControlLimit(body, 4)
|
|
tools := body["tools"].([]map[string]interface{})
|
|
if _, ok := tools[0]["cache_control"]; ok {
|
|
t.Fatalf("expected non-last tool cache_control removed first")
|
|
}
|
|
if _, ok := tools[1]["cache_control"]; !ok {
|
|
t.Fatalf("expected last tool cache_control preserved")
|
|
}
|
|
if got := len(claudeCacheBlocks(body)); got != 4 {
|
|
t.Fatalf("expected cache blocks capped at 4, got %d", got)
|
|
}
|
|
}
|