first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
@@ -0,0 +1,275 @@
// 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)
}
}
}