276 lines
10 KiB
Go
276 lines
10 KiB
Go
// Tests for the Anthropic-Messages-protocol built-in providers (US-006, node
|
|
// #187): anthropic, minimax, minimax-cn. They assert the registry metadata
|
|
// (base_url, protocol, key env var) and that the constructed anthropic-compat
|
|
// driver attaches the auth header dictated by the provider's AuthScheme.
|
|
//
|
|
// No real network calls are made: the auth header is exercised by invoking the
|
|
// driver's authHeader func against a dummy *http.Request and inspecting the
|
|
// resulting headers (the same package can reach the unexported driver fields).
|
|
package provider
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
)
|
|
|
|
// TestAnthropicProtocolProviderRegistrySpecs asserts the registry metadata for
|
|
// each Anthropic-Messages-protocol built-in: default base URL, wire protocol,
|
|
// and the key env var (via envAPIKey + t.Setenv).
|
|
func TestAnthropicProtocolProviderRegistrySpecs(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
wantBaseURL string
|
|
wantEnv string
|
|
}{
|
|
{"anthropic", "https://api.anthropic.com/v1", "ANTHROPIC_API_KEY"},
|
|
{"minimax", "https://api.minimax.io/anthropic", "MINIMAX_API_KEY"},
|
|
{"minimax-cn", "https://api.minimaxi.com/anthropic", "MINIMAX_CN_API_KEY"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
spec, ok := LookupProviderSpec(tc.name)
|
|
if !ok {
|
|
t.Fatalf("provider %q not found in registry", tc.name)
|
|
}
|
|
if spec.Protocol != ProtocolAnthropic {
|
|
t.Errorf("protocol = %q, want %q", spec.Protocol, ProtocolAnthropic)
|
|
}
|
|
if spec.DefaultBaseURL != tc.wantBaseURL {
|
|
t.Errorf("base_url = %q, want %q", spec.DefaultBaseURL, tc.wantBaseURL)
|
|
}
|
|
// Key resolution: the provider's key comes from tc.wantEnv.
|
|
t.Setenv(tc.wantEnv, "SEKRET-"+tc.name)
|
|
if got := envAPIKey(tc.name); got != "SEKRET-"+tc.name {
|
|
t.Errorf("envAPIKey(%q) = %q, want key resolved from %s", tc.name, got, tc.wantEnv)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAnthropicProtocolProviderAuthHeader verifies that the driver built for
|
|
// each provider (the way resolveNamedProvider builds it: name + resolved
|
|
// base_url + spec.AuthScheme) targets the provider's base URL and sets the auth
|
|
// header matching its AuthScheme. anthropic/minimax/minimax-cn are all
|
|
// x-api-key + anthropic-version per pi's convention.
|
|
func TestAnthropicProtocolProviderAuthHeader(t *testing.T) {
|
|
for _, name := range []string{"anthropic", "minimax", "minimax-cn"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
spec, ok := LookupProviderSpec(name)
|
|
if !ok {
|
|
t.Fatalf("provider %q not found in registry", name)
|
|
}
|
|
p := NewAnthropicProtocolProvider(spec.Name, spec.DefaultBaseURL, spec.AuthScheme, nil)
|
|
d, ok := p.(*anthropicCompatDriver)
|
|
if !ok {
|
|
t.Fatalf("provider %q is not an *anthropicCompatDriver", name)
|
|
}
|
|
if d.baseURL != spec.DefaultBaseURL {
|
|
t.Errorf("baseURL = %q, want %q", d.baseURL, spec.DefaultBaseURL)
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, d.baseURL+"/messages", nil)
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
d.authHeader(req, "SEKRET")
|
|
assertAuthHeaderForScheme(t, req, spec.AuthScheme)
|
|
if got := req.Header.Get("Authorization"); got != "" && spec.AuthScheme != AuthBearer {
|
|
t.Errorf("unexpected Authorization header %q for x-api-key scheme", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAnthropicAuthSchemeSelection proves the auth-header mechanism itself:
|
|
// AuthBearer yields Authorization: Bearer, AuthXAPIKey yields x-api-key plus the
|
|
// anthropic-version header. This guarantees an anthropic-protocol provider can
|
|
// select either header shape from its registry AuthScheme.
|
|
func TestAnthropicAuthSchemeSelection(t *testing.T) {
|
|
t.Run("bearer", func(t *testing.T) {
|
|
p := NewAnthropicProtocolProvider("gw", "https://example.test/anthropic", AuthBearer, nil)
|
|
req := newDummyReq(t)
|
|
p.(*anthropicCompatDriver).authHeader(req, "SEKRET")
|
|
if got := req.Header.Get("Authorization"); got != "Bearer SEKRET" {
|
|
t.Errorf("Authorization = %q, want %q", got, "Bearer SEKRET")
|
|
}
|
|
if got := req.Header.Get("x-api-key"); got != "" {
|
|
t.Errorf("x-api-key = %q, want empty for bearer scheme", got)
|
|
}
|
|
})
|
|
t.Run("x-api-key", func(t *testing.T) {
|
|
p := NewAnthropicProtocolProvider("anthropic", "", AuthXAPIKey, nil)
|
|
req := newDummyReq(t)
|
|
p.(*anthropicCompatDriver).authHeader(req, "SEKRET")
|
|
if got := req.Header.Get("x-api-key"); got != "SEKRET" {
|
|
t.Errorf("x-api-key = %q, want %q", got, "SEKRET")
|
|
}
|
|
if got := req.Header.Get("anthropic-version"); got != anthropicAPIVersion {
|
|
t.Errorf("anthropic-version = %q, want %q", got, anthropicAPIVersion)
|
|
}
|
|
})
|
|
// A non-crashing fallback for unwired schemes (e.g. Bedrock's AuthAWS): must
|
|
// not panic and defaults to the x-api-key convention.
|
|
t.Run("fallback", func(t *testing.T) {
|
|
p := NewAnthropicProtocolProvider("bedrock", "", AuthAWS, nil)
|
|
req := newDummyReq(t)
|
|
p.(*anthropicCompatDriver).authHeader(req, "SEKRET")
|
|
if got := req.Header.Get("x-api-key"); got != "SEKRET" {
|
|
t.Errorf("fallback x-api-key = %q, want %q", got, "SEKRET")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestNewAnthropicProviderUnchanged guards against regressing the direct
|
|
// Anthropic constructor: it must still default to the public endpoint and use
|
|
// x-api-key + anthropic-version.
|
|
func TestNewAnthropicProviderUnchanged(t *testing.T) {
|
|
d, ok := NewAnthropicProvider("", nil).(*anthropicCompatDriver)
|
|
if !ok {
|
|
t.Fatal("NewAnthropicProvider did not return *anthropicCompatDriver")
|
|
}
|
|
if d.baseURL != anthropicBaseURL {
|
|
t.Errorf("baseURL = %q, want %q", d.baseURL, anthropicBaseURL)
|
|
}
|
|
req := newDummyReq(t)
|
|
d.authHeader(req, "SEKRET")
|
|
if got := req.Header.Get("x-api-key"); got != "SEKRET" {
|
|
t.Errorf("x-api-key = %q, want %q", got, "SEKRET")
|
|
}
|
|
if got := req.Header.Get("anthropic-version"); got != anthropicAPIVersion {
|
|
t.Errorf("anthropic-version = %q, want %q", got, anthropicAPIVersion)
|
|
}
|
|
}
|
|
|
|
func newDummyReq(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodPost, "https://example.test/messages", nil)
|
|
if err != nil {
|
|
t.Fatalf("build request: %v", err)
|
|
}
|
|
return req
|
|
}
|
|
|
|
func assertAuthHeaderForScheme(t *testing.T, req *http.Request, scheme string) {
|
|
t.Helper()
|
|
if scheme == AuthBearer {
|
|
if got := req.Header.Get("Authorization"); got != "Bearer SEKRET" {
|
|
t.Errorf("Authorization = %q, want %q", got, "Bearer SEKRET")
|
|
}
|
|
return
|
|
}
|
|
if got := req.Header.Get("x-api-key"); got != "SEKRET" {
|
|
t.Errorf("x-api-key = %q, want %q", got, "SEKRET")
|
|
}
|
|
if got := req.Header.Get("anthropic-version"); got != anthropicAPIVersion {
|
|
t.Errorf("anthropic-version = %q, want %q", got, anthropicAPIVersion)
|
|
}
|
|
}
|
|
|
|
// TestEncodeAnthropicRequestIncludesModel guards a required field of the
|
|
// Anthropic Messages API: the request body must carry the "model" id. Omitting
|
|
// it makes the public API return 400 and OpenAI-compatible gateways return an
|
|
// empty/error response, which the SSE decoder silently turns into an empty
|
|
// assistant turn — a confusing "no output, no error" failure.
|
|
func TestEncodeAnthropicRequestIncludesModel(t *testing.T) {
|
|
req := CompletionRequest{
|
|
Model: "claude-opus-4-8",
|
|
}
|
|
body, err := encodeAnthropicRequest(req, nil)
|
|
if err != nil {
|
|
t.Fatalf("encodeAnthropicRequest: %v", err)
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
t.Fatalf("unmarshal body: %v", err)
|
|
}
|
|
if got, ok := decoded["model"]; !ok {
|
|
t.Fatalf("request body missing required \"model\" field; keys=%v", keysOf(decoded))
|
|
} else if got != "claude-opus-4-8" {
|
|
t.Errorf("model = %v, want claude-opus-4-8", got)
|
|
}
|
|
}
|
|
|
|
func keysOf(m map[string]any) []string {
|
|
ks := make([]string, 0, len(m))
|
|
for k := range m {
|
|
ks = append(ks, k)
|
|
}
|
|
return ks
|
|
}
|
|
|
|
// TestEncodeAnthropicRequestCoalescesToolResults guards the Anthropic protocol
|
|
// rule that every tool_use must be answered by tool_result blocks in a single
|
|
// immediately-following user message. Before the fix, each tool result was
|
|
// encoded as its own user turn, which the API rejected with "tool_use ids were
|
|
// found without tool_result blocks immediately after" whenever one assistant
|
|
// turn carried multiple tool_use calls.
|
|
func TestEncodeAnthropicRequestCoalescesToolResults(t *testing.T) {
|
|
req := CompletionRequest{
|
|
Model: "claude-x",
|
|
Context: LlmContext{
|
|
Messages: agentcore.MessageList{
|
|
// Assistant turn with two tool_use blocks.
|
|
agentcore.AssistantMessage{
|
|
Content: agentcore.ContentList{
|
|
agentcore.NewToolCallContent("call_1", "read", json.RawMessage(`{}`)),
|
|
agentcore.NewToolCallContent("call_2", "write", json.RawMessage(`{}`)),
|
|
},
|
|
},
|
|
// Two consecutive tool results for that turn.
|
|
agentcore.ToolResultMessage{
|
|
ToolCallID: "call_1",
|
|
Content: agentcore.ContentList{agentcore.NewTextContent("result-1")},
|
|
},
|
|
agentcore.ToolResultMessage{
|
|
ToolCallID: "call_2",
|
|
Content: agentcore.ContentList{agentcore.NewTextContent("result-2")},
|
|
},
|
|
// Next assistant turn (text reply).
|
|
agentcore.AssistantMessage{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent("done")},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
body, err := encodeAnthropicRequest(req, nil)
|
|
if err != nil {
|
|
t.Fatalf("encodeAnthropicRequest: %v", err)
|
|
}
|
|
decoded := decodeBody(t, body)
|
|
msgs, ok := decoded["messages"].([]any)
|
|
if !ok {
|
|
t.Fatalf("messages not an array: %T", decoded["messages"])
|
|
}
|
|
if len(msgs) != 3 {
|
|
t.Fatalf("want 3 top-level messages (assistant tool_use, one user tool_result turn, assistant reply), got %d:\n%v", len(msgs), msgs)
|
|
}
|
|
|
|
// Message 2 must be the single user turn holding BOTH tool_result blocks.
|
|
second, ok := msgs[1].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("message[1] not an object: %T", msgs[1])
|
|
}
|
|
if second["role"] != "user" {
|
|
t.Fatalf("message[1].role = %v, want user", second["role"])
|
|
}
|
|
blocks, ok := second["content"].([]any)
|
|
if !ok {
|
|
t.Fatalf("message[1].content not an array: %T", second["content"])
|
|
}
|
|
if len(blocks) != 2 {
|
|
t.Fatalf("want 2 tool_result blocks in the coalesced turn, got %d:\n%v", len(blocks), second["content"])
|
|
}
|
|
for i, wantID := range []string{"call_1", "call_2"} {
|
|
block, ok := blocks[i].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("block[%d] not an object: %T", i, blocks[i])
|
|
}
|
|
if block["type"] != "tool_result" || block["tool_use_id"] != wantID {
|
|
t.Errorf("block[%d] = %v, want tool_result for %s", i, block, wantID)
|
|
}
|
|
}
|
|
}
|