first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
package anthropic_test
import (
"os"
"strings"
"testing"
"github.com/maximhq/bifrost/core/internal/llmtests"
"github.com/maximhq/bifrost/core/schemas"
)
func TestAnthropic(t *testing.T) {
t.Parallel()
if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) == "" {
t.Skip("Skipping Anthropic tests because ANTHROPIC_API_KEY is not set")
}
client, ctx, cancel, err := llmtests.SetupTest()
if err != nil {
t.Fatalf("Error initializing test setup: %v", err)
}
defer cancel()
defer client.Shutdown()
testConfig := llmtests.ComprehensiveTestConfig{
Provider: schemas.Anthropic,
ChatModel: "claude-sonnet-4-5",
Fallbacks: []schemas.Fallback{
{Provider: schemas.Anthropic, Model: "claude-3-7-sonnet-20250219"},
{Provider: schemas.Anthropic, Model: "claude-sonnet-4-20250514"},
},
VisionModel: "claude-sonnet-4-5", // Same model supports vision
ReasoningModel: "claude-opus-4-5",
PromptCachingModel: "claude-sonnet-4-20250514",
PassthroughModel: "claude-sonnet-4-5",
Scenarios: llmtests.TestScenarios{
TextCompletion: false, // Not supported
SimpleChat: true,
CompletionStream: true,
MultiTurnConversation: true,
ToolCalls: true,
ToolCallsStreaming: true,
MultipleToolCalls: true,
MultipleToolCallsStreaming: true,
End2EndToolCalling: true,
AutomaticFunctionCall: true,
WebSearchTool: true,
ImageURL: true,
ImageBase64: true,
MultipleImages: true,
FileBase64: true,
FileURL: true,
CompleteEnd2End: true,
Embedding: false,
Reasoning: true,
PromptCaching: true,
ListModels: true,
BatchCreate: true,
BatchList: true,
BatchRetrieve: true,
BatchCancel: true,
BatchResults: true,
FileUpload: true,
FileList: true,
FileRetrieve: true,
FileDelete: true,
FileContent: false,
FileBatchInput: false, // Anthropic batch API only supports inline requests, not file-based input
CountTokens: true,
StructuredOutputs: true, // Structured outputs with nullable enum support
PassthroughAPI: true,
Compaction: true,
InterleavedThinking: true,
FastMode: false, // Enable when test API key has Opus 4.6 access
EagerInputStreaming: true, // fine-grained-tool-streaming-2025-05-14 (GA on Anthropic)
ServerToolsViaOpenAIEndpoint: true, // web_search / web_fetch / code_execution via /v1/chat/completions
},
}
t.Run("AnthropicTests", func(t *testing.T) {
llmtests.RunAllComprehensiveTests(t, client, ctx, testConfig)
})
}

View File

@@ -0,0 +1,359 @@
package anthropic
import (
"time"
"github.com/maximhq/bifrost/core/schemas"
)
// Anthropic Batch API Types
// AnthropicBatchRequestItem represents a single request in a batch.
type AnthropicBatchRequestItem struct {
CustomID string `json:"custom_id"`
Params map[string]any `json:"params"`
}
// AnthropicBatchCreateRequest represents the request body for creating a batch.
type AnthropicBatchCreateRequest struct {
Requests []AnthropicBatchRequestItem `json:"requests"`
}
// AnthropicBatchCancelRequest represents the request body for canceling a batch.
type AnthropicBatchCancelRequest struct {
BatchID string `json:"batch_id"`
}
// AnthropicBatchRetrieveRequest represents the request body for retrieving a batch.
type AnthropicBatchRetrieveRequest struct {
BatchID string `json:"batch_id"`
}
// AnthropicBatchListRequest represents the request body for listing batches.
type AnthropicBatchListRequest struct {
PageToken *string `json:"page_token"`
PageSize int `json:"page_size"`
}
// AnthropicBatchResultsRequest represents the request body for retrieving batch results.
type AnthropicBatchResultsRequest struct {
BatchID string `json:"batch_id"`
}
// AnthropicBatchResponse represents an Anthropic batch response.
type AnthropicBatchResponse struct {
ID string `json:"id"`
Type string `json:"type"`
ProcessingStatus string `json:"processing_status"`
RequestCounts *AnthropicBatchRequestCounts `json:"request_counts,omitempty"`
EndedAt *string `json:"ended_at,omitempty"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
ArchivedAt *string `json:"archived_at,omitempty"`
CancelInitiatedAt *string `json:"cancel_initiated_at,omitempty"`
ResultsURL *string `json:"results_url,omitempty"`
}
// AnthropicBatchRequestCounts represents the request counts for a batch.
type AnthropicBatchRequestCounts struct {
Processing int `json:"processing"`
Succeeded int `json:"succeeded"`
Errored int `json:"errored"`
Canceled int `json:"canceled"`
Expired int `json:"expired"`
}
// AnthropicBatchListResponse represents the response from listing batches.
type AnthropicBatchListResponse struct {
Data []AnthropicBatchResponse `json:"data"`
HasMore bool `json:"has_more"`
FirstID *string `json:"first_id,omitempty"`
LastID *string `json:"last_id,omitempty"`
}
// AnthropicBatchResultItem represents a single result from a batch.
type AnthropicBatchResultItem struct {
CustomID string `json:"custom_id"`
Result AnthropicBatchResultData `json:"result"`
}
// AnthropicBatchResultData represents the result data.
type AnthropicBatchResultData struct {
Type string `json:"type"` // "succeeded", "errored", "expired", "canceled"
Message map[string]interface{} `json:"message,omitempty"`
Error *AnthropicBatchError `json:"error,omitempty"`
}
// AnthropicBatchError represents an error in batch results.
type AnthropicBatchError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// ToBifrostBatchStatus converts Anthropic processing_status to Bifrost status.
func ToBifrostBatchStatus(status string) schemas.BatchStatus {
switch status {
case "in_progress":
return schemas.BatchStatusInProgress
case "canceling":
return schemas.BatchStatusCancelling
case "ended":
return schemas.BatchStatusEnded
default:
return schemas.BatchStatus(status)
}
}
// parseAnthropicTimestamp converts Anthropic ISO timestamp to Unix timestamp.
func parseAnthropicTimestamp(timestamp string) int64 {
if timestamp == "" {
return 0
}
t, err := time.Parse(time.RFC3339Nano, timestamp)
if err != nil {
return 0
}
return t.Unix()
}
// ToBifrostObjectType converts Anthropic type to Bifrost object type.
func ToBifrostObjectType(anthropicType string) string {
switch anthropicType {
case "message_batch":
return "batch"
default:
return anthropicType
}
}
// ToBifrostBatchCreateResponse converts Anthropic batch response to Bifrost batch create response.
func (r *AnthropicBatchResponse) ToBifrostBatchCreateResponse(latency time.Duration, sendBackRawRequest bool, sendBackRawResponse bool, rawRequest interface{}, rawResponse interface{}) *schemas.BifrostBatchCreateResponse {
expiresAt := parseAnthropicTimestamp(r.ExpiresAt)
resp := &schemas.BifrostBatchCreateResponse{
ID: r.ID,
Object: ToBifrostObjectType(r.Type),
Status: ToBifrostBatchStatus(r.ProcessingStatus),
ProcessingStatus: &r.ProcessingStatus,
ResultsURL: r.ResultsURL,
CreatedAt: parseAnthropicTimestamp(r.CreatedAt),
ExpiresAt: &expiresAt,
ExtraFields: schemas.BifrostResponseExtraFields{
Latency: latency.Milliseconds(),
},
}
if r.RequestCounts != nil {
resp.RequestCounts = schemas.BatchRequestCounts{
Total: r.RequestCounts.Processing + r.RequestCounts.Succeeded + r.RequestCounts.Errored + r.RequestCounts.Canceled + r.RequestCounts.Expired,
Completed: r.RequestCounts.Succeeded,
Failed: r.RequestCounts.Errored,
Succeeded: r.RequestCounts.Succeeded,
Expired: r.RequestCounts.Expired,
Canceled: r.RequestCounts.Canceled,
Pending: r.RequestCounts.Processing,
}
}
if sendBackRawRequest {
resp.ExtraFields.RawRequest = rawRequest
}
if sendBackRawResponse {
resp.ExtraFields.RawResponse = rawResponse
}
return resp
}
// ToBifrostBatchRetrieveResponse converts Anthropic batch response to Bifrost batch retrieve response.
func (r *AnthropicBatchResponse) ToBifrostBatchRetrieveResponse(latency time.Duration, sendBackRawRequest bool, sendBackRawResponse bool, rawRequest interface{}, rawResponse interface{}) *schemas.BifrostBatchRetrieveResponse {
resp := &schemas.BifrostBatchRetrieveResponse{
ID: r.ID,
Object: ToBifrostObjectType(r.Type),
Status: ToBifrostBatchStatus(r.ProcessingStatus),
ProcessingStatus: &r.ProcessingStatus,
ResultsURL: r.ResultsURL,
CreatedAt: parseAnthropicTimestamp(r.CreatedAt),
ExtraFields: schemas.BifrostResponseExtraFields{
Latency: latency.Milliseconds(),
},
}
if sendBackRawRequest {
resp.ExtraFields.RawRequest = rawRequest
}
expiresAt := parseAnthropicTimestamp(r.ExpiresAt)
if expiresAt > 0 {
resp.ExpiresAt = &expiresAt
}
if r.EndedAt != nil {
endedAt := parseAnthropicTimestamp(*r.EndedAt)
resp.CompletedAt = &endedAt
}
if r.ArchivedAt != nil {
archivedAt := parseAnthropicTimestamp(*r.ArchivedAt)
resp.ArchivedAt = &archivedAt
}
if r.CancelInitiatedAt != nil {
cancellingAt := parseAnthropicTimestamp(*r.CancelInitiatedAt)
resp.CancellingAt = &cancellingAt
}
if r.RequestCounts != nil {
resp.RequestCounts = schemas.BatchRequestCounts{
Total: r.RequestCounts.Processing + r.RequestCounts.Succeeded + r.RequestCounts.Errored + r.RequestCounts.Canceled + r.RequestCounts.Expired,
Completed: r.RequestCounts.Succeeded,
Failed: r.RequestCounts.Errored,
Succeeded: r.RequestCounts.Succeeded,
Expired: r.RequestCounts.Expired,
Canceled: r.RequestCounts.Canceled,
Pending: r.RequestCounts.Processing,
}
}
if sendBackRawResponse {
resp.ExtraFields.RawResponse = rawResponse
}
return resp
}
// ToAnthropicBatchCreateResponse converts a Bifrost batch create response to Anthropic format.
func ToAnthropicBatchCreateResponse(resp *schemas.BifrostBatchCreateResponse) *AnthropicBatchResponse {
result := &AnthropicBatchResponse{
ID: resp.ID,
Type: "message_batch",
ProcessingStatus: toAnthropicProcessingStatus(resp.Status),
CreatedAt: formatAnthropicTimestamp(resp.CreatedAt),
ResultsURL: resp.ResultsURL,
}
if resp.ExpiresAt != nil {
result.ExpiresAt = formatAnthropicTimestamp(*resp.ExpiresAt)
} else {
// This is a fallback for worst case scenario where expires_at is not available
// Which is never expected to happen, but just in case.
result.ExpiresAt = formatAnthropicTimestamp(time.Now().Add(24 * time.Hour).Unix())
}
if resp.RequestCounts.Total > 0 {
result.RequestCounts = &AnthropicBatchRequestCounts{
Processing: resp.RequestCounts.Pending,
Succeeded: resp.RequestCounts.Succeeded,
Errored: resp.RequestCounts.Failed,
Canceled: resp.RequestCounts.Canceled,
Expired: resp.RequestCounts.Expired,
}
}
return result
}
// ToAnthropicBatchListResponse converts a Bifrost batch list response to Anthropic format.
func ToAnthropicBatchListResponse(resp *schemas.BifrostBatchListResponse) *AnthropicBatchListResponse {
result := &AnthropicBatchListResponse{
Data: make([]AnthropicBatchResponse, len(resp.Data)),
HasMore: resp.HasMore,
FirstID: resp.FirstID,
LastID: resp.LastID,
}
for i, batch := range resp.Data {
result.Data[i] = *ToAnthropicBatchRetrieveResponse(&batch)
}
return result
}
// ToAnthropicBatchRetrieveResponse converts a Bifrost batch retrieve response to Anthropic format.
func ToAnthropicBatchRetrieveResponse(resp *schemas.BifrostBatchRetrieveResponse) *AnthropicBatchResponse {
result := &AnthropicBatchResponse{
ID: resp.ID,
Type: "message_batch",
ProcessingStatus: toAnthropicProcessingStatus(resp.Status),
CreatedAt: formatAnthropicTimestamp(resp.CreatedAt),
ResultsURL: resp.ResultsURL,
}
if resp.ExpiresAt != nil {
result.ExpiresAt = formatAnthropicTimestamp(*resp.ExpiresAt)
}
if resp.CompletedAt != nil {
endedAt := formatAnthropicTimestamp(*resp.CompletedAt)
result.EndedAt = &endedAt
}
if resp.ArchivedAt != nil {
archivedAt := formatAnthropicTimestamp(*resp.ArchivedAt)
result.ArchivedAt = &archivedAt
}
if resp.CancellingAt != nil {
cancelInitiatedAt := formatAnthropicTimestamp(*resp.CancellingAt)
result.CancelInitiatedAt = &cancelInitiatedAt
}
if resp.RequestCounts.Total > 0 {
result.RequestCounts = &AnthropicBatchRequestCounts{
Processing: resp.RequestCounts.Pending,
Succeeded: resp.RequestCounts.Succeeded,
Errored: resp.RequestCounts.Failed,
Canceled: resp.RequestCounts.Canceled,
Expired: resp.RequestCounts.Expired,
}
}
return result
}
// ToAnthropicBatchCancelResponse converts a Bifrost batch cancel response to Anthropic format.
func ToAnthropicBatchCancelResponse(resp *schemas.BifrostBatchCancelResponse) *AnthropicBatchResponse {
result := &AnthropicBatchResponse{
ID: resp.ID,
Type: "message_batch",
ProcessingStatus: toAnthropicProcessingStatus(resp.Status),
}
if resp.CancellingAt != nil {
cancelInitiatedAt := formatAnthropicTimestamp(*resp.CancellingAt)
result.CancelInitiatedAt = &cancelInitiatedAt
}
if resp.RequestCounts.Total > 0 {
result.RequestCounts = &AnthropicBatchRequestCounts{
Processing: resp.RequestCounts.Pending,
Succeeded: resp.RequestCounts.Succeeded,
Canceled: resp.RequestCounts.Canceled,
Expired: resp.RequestCounts.Expired,
Errored: resp.RequestCounts.Failed,
}
}
return result
}
// toAnthropicProcessingStatus converts Bifrost batch status to Anthropic processing_status.
func toAnthropicProcessingStatus(status schemas.BatchStatus) string {
switch status {
case schemas.BatchStatusInProgress:
fallthrough
case schemas.BatchStatusValidating:
return "in_progress"
case schemas.BatchStatusCancelling:
return "canceling"
case schemas.BatchStatusEnded, schemas.BatchStatusCompleted, schemas.BatchStatusCancelled:
return "ended"
default:
return string(status)
}
}
// formatAnthropicTimestamp converts Unix timestamp to Anthropic ISO timestamp format.
func formatAnthropicTimestamp(unixTime int64) string {
if unixTime == 0 {
return ""
}
return time.Unix(unixTime, 0).UTC().Format(time.RFC3339)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
package anthropic
import (
"encoding/json"
"testing"
"github.com/bytedance/sonic"
"github.com/maximhq/bifrost/core/schemas"
)
// TestChatTool_ServerToolRoundTrip verifies that every Anthropic server-tool
// variant survives Marshal/Unmarshal through the neutral ChatTool schema.
// This locks in the fix for the user-reported bug where a raw JSON tool like
// {"type":"web_search_20260209","name":"web_search","max_uses":5} was being
// dropped at the neutral-schema layer because ChatTool had no slots for the
// server-tool metadata.
func TestChatTool_ServerToolRoundTrip(t *testing.T) {
five := 5
ptrTrue := true
w, h := 1280, 800
maxChars := 16000
maxContent := 32000
cases := []struct {
name string
raw string
}{
{
name: "web_search_20260209",
raw: `{"type":"web_search_20260209","name":"web_search","max_uses":5,"allowed_callers":["direct"]}`,
},
{
name: "web_search_with_domains",
raw: `{"type":"web_search_20250305","name":"web_search","allowed_domains":["example.com","docs.example.com"]}`,
},
{
name: "web_search_with_user_location",
raw: `{"type":"web_search_20250305","name":"web_search","user_location":{"type":"approximate","city":"San Francisco","country":"US","timezone":"America/Los_Angeles"}}`,
},
{
name: "web_fetch_20260309",
raw: `{"type":"web_fetch_20260309","name":"web_fetch","max_uses":5,"max_content_tokens":32000,"citations":{"enabled":true},"use_cache":true}`,
},
{
name: "computer_20251124",
raw: `{"type":"computer_20251124","name":"computer","display_width_px":1280,"display_height_px":800,"display_number":1,"enable_zoom":true}`,
},
{
name: "text_editor_20250728",
raw: `{"type":"text_editor_20250728","name":"str_replace_based_edit_tool","max_characters":16000}`,
},
{
name: "bash_20250124",
raw: `{"type":"bash_20250124","name":"bash"}`,
},
{
name: "memory_20250818",
raw: `{"type":"memory_20250818","name":"memory"}`,
},
{
name: "code_execution_20250825",
raw: `{"type":"code_execution_20250825","name":"code_execution"}`,
},
{
name: "tool_search_tool_bm25",
raw: `{"type":"tool_search_tool_bm25","name":"tool_search_tool_bm25"}`,
},
{
name: "mcp_toolset",
raw: `{"type":"mcp_toolset","name":"my_mcp","mcp_server_name":"notion","configs":{"search":{"enabled":true}}}`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Variant-specific field assertions. Invoked twice — once after
// initial decode, once after round-trip — so that a regression in
// MarshalSorted that silently drops any variant-specific field
// fails this test instead of sneaking through.
assertVariantFields := func(label string, tl schemas.ChatTool) {
t.Helper()
switch tc.name {
case "web_search_20260209":
if tl.MaxUses == nil || *tl.MaxUses != five {
t.Errorf("%s: MaxUses not preserved, got %v", label, tl.MaxUses)
}
if len(tl.AllowedCallers) != 1 || tl.AllowedCallers[0] != "direct" {
t.Errorf("%s: AllowedCallers not preserved, got %v", label, tl.AllowedCallers)
}
case "web_fetch_20260309":
if tl.MaxContentTokens == nil || *tl.MaxContentTokens != maxContent {
t.Errorf("%s: MaxContentTokens not preserved, got %v", label, tl.MaxContentTokens)
}
if tl.Citations == nil || tl.Citations.Enabled == nil || !*tl.Citations.Enabled {
t.Errorf("%s: Citations not preserved, got %v", label, tl.Citations)
}
if tl.UseCache == nil || !*tl.UseCache {
t.Errorf("%s: UseCache not preserved", label)
}
_ = ptrTrue
case "computer_20251124":
if tl.DisplayWidthPx == nil || *tl.DisplayWidthPx != w {
t.Errorf("%s: DisplayWidthPx not preserved, got %v", label, tl.DisplayWidthPx)
}
if tl.DisplayHeightPx == nil || *tl.DisplayHeightPx != h {
t.Errorf("%s: DisplayHeightPx not preserved, got %v", label, tl.DisplayHeightPx)
}
case "text_editor_20250728":
if tl.MaxCharacters == nil || *tl.MaxCharacters != maxChars {
t.Errorf("%s: MaxCharacters not preserved, got %v", label, tl.MaxCharacters)
}
case "mcp_toolset":
if tl.MCPServerName != "notion" {
t.Errorf("%s: MCPServerName not preserved, got %q", label, tl.MCPServerName)
}
if len(tl.Configs) != 1 {
t.Errorf("%s: Configs not preserved, got %v", label, tl.Configs)
}
}
}
var tool schemas.ChatTool
if err := sonic.Unmarshal([]byte(tc.raw), &tool); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if string(tool.Type) == "" {
t.Errorf("Type should be preserved, got empty")
}
if tool.Name == "" {
t.Errorf("Name should be preserved, got empty")
}
assertVariantFields("first decode", tool)
// Re-marshal and re-decode — all preserved fields should survive round trip.
out, err := schemas.MarshalSorted(tool)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var tool2 schemas.ChatTool
if err := sonic.Unmarshal(out, &tool2); err != nil {
t.Fatalf("second unmarshal failed: %v\njson: %s", err, string(out))
}
if tool.Name != tool2.Name || tool.Type != tool2.Type {
t.Errorf("round-trip mismatch\n in: %s\n out: %s", tc.raw, string(out))
}
assertVariantFields("round trip", tool2)
})
}
}
// TestToAnthropicChatRequest_ServerTools verifies every ChatTool server-tool
// shape converts correctly through ToAnthropicChatRequest.
func TestToAnthropicChatRequest_ServerTools(t *testing.T) {
mk := func(rawTool string) *schemas.BifrostChatRequest {
var tool schemas.ChatTool
if err := sonic.Unmarshal([]byte(rawTool), &tool); err != nil {
t.Fatalf("test setup: %v", err)
}
return &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-sonnet-4-6",
Input: []schemas.ChatMessage{{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}}},
Params: &schemas.ChatParameters{Tools: []schemas.ChatTool{tool}},
}
}
type check struct {
expectName string
expectType AnthropicToolType
expectWebSearch bool
expectWebFetch bool
expectComputer bool
expectTextEditor bool
expectMCPToolset bool
}
cases := []struct {
name string
raw string
want check
}{
{
name: "web_search",
raw: `{"type":"web_search_20260209","name":"web_search","max_uses":5}`,
want: check{expectName: "web_search", expectType: "web_search_20260209", expectWebSearch: true},
},
{
name: "web_fetch",
raw: `{"type":"web_fetch_20260309","name":"web_fetch","max_uses":3,"use_cache":true}`,
want: check{expectName: "web_fetch", expectType: "web_fetch_20260309", expectWebFetch: true},
},
{
name: "computer_20251124",
raw: `{"type":"computer_20251124","name":"computer","display_width_px":1280,"display_height_px":800}`,
want: check{expectName: "computer", expectType: "computer_20251124", expectComputer: true},
},
{
name: "text_editor_20250728",
raw: `{"type":"text_editor_20250728","name":"str_replace_based_edit_tool","max_characters":16000}`,
want: check{expectName: "str_replace_based_edit_tool", expectType: "text_editor_20250728", expectTextEditor: true},
},
{
name: "bash_20250124",
raw: `{"type":"bash_20250124","name":"bash"}`,
want: check{expectName: "bash", expectType: "bash_20250124"},
},
{
name: "mcp_toolset",
raw: `{"type":"mcp_toolset","name":"notion","mcp_server_name":"notion"}`,
want: check{expectMCPToolset: true},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := mk(tc.raw)
out, err := ToAnthropicChatRequest(nil, req)
if err != nil {
t.Fatalf("conversion failed: %v", err)
}
if len(out.Tools) != 1 {
t.Fatalf("expected 1 tool, got %d (raw: %s)", len(out.Tools), tc.raw)
}
at := out.Tools[0]
if tc.want.expectMCPToolset {
if at.MCPToolset == nil {
t.Errorf("expected MCPToolset to be set")
}
return
}
if at.Name != tc.want.expectName {
t.Errorf("Name: got %q want %q", at.Name, tc.want.expectName)
}
if at.Type == nil || *at.Type != tc.want.expectType {
t.Errorf("Type: got %v want %q", at.Type, tc.want.expectType)
}
if tc.want.expectWebSearch && at.AnthropicToolWebSearch == nil {
t.Errorf("expected AnthropicToolWebSearch populated")
}
if tc.want.expectWebFetch && at.AnthropicToolWebFetch == nil {
t.Errorf("expected AnthropicToolWebFetch populated")
}
if tc.want.expectComputer && at.AnthropicToolComputerUse == nil {
t.Errorf("expected AnthropicToolComputerUse populated")
}
if tc.want.expectTextEditor && at.AnthropicToolTextEditor == nil {
t.Errorf("expected AnthropicToolTextEditor populated")
}
})
}
}
// TestToBifrostResponsesRequest_MCPToolsetPreservesAnthropicFlags verifies
// that when an Anthropic request carries an mcp_toolset tool with the four
// Anthropic-native flags (DeferLoading, AllowedCallers, InputExamples,
// EagerInputStreaming), those flags survive the inbound conversion into the
// neutral ResponsesTool on the mcp_servers merge path. Before the fix, the
// merge path only applied MCP configs (allowlist/cache-control) and dropped
// the flags because convertAnthropicToolToBifrost skips mcp_toolset entries.
func TestToBifrostResponsesRequest_MCPToolsetPreservesAnthropicFlags(t *testing.T) {
toolsetType := "mcp_toolset"
_ = toolsetType // shape documentation only; AnthropicTool.Type is pointer-to-enum and left nil for mcp_toolset
req := &AnthropicMessageRequest{
Model: "claude-sonnet-4-6",
Tools: []AnthropicTool{
{
Name: "notion",
DeferLoading: schemas.Ptr(true),
AllowedCallers: []string{"direct", "agent"},
EagerInputStreaming: schemas.Ptr(false),
InputExamples: []AnthropicToolInputExample{
{Input: json.RawMessage(`{"q":"hello"}`), Description: schemas.Ptr("basic")},
},
MCPToolset: &AnthropicMCPToolsetTool{
Type: "mcp_toolset",
MCPServerName: "notion",
DefaultConfig: &AnthropicMCPToolsetConfig{Enabled: schemas.Ptr(true)},
},
},
},
MCPServers: []AnthropicMCPServerV2{
{Type: "url", URL: "https://mcp.example.com", Name: "notion"},
},
}
got := req.ToBifrostResponsesRequest(nil)
if got == nil || got.Params == nil {
t.Fatalf("ToBifrostResponsesRequest returned nil params")
}
// The mcp_toolset tool should have been dropped by convertAnthropicToolToBifrost
// and re-created on the mcp_servers merge path — end result: exactly one tool,
// of type mcp, carrying the Anthropic flags we set.
if len(got.Params.Tools) != 1 {
t.Fatalf("expected 1 mcp tool after merge, got %d", len(got.Params.Tools))
}
mcp := got.Params.Tools[0]
if mcp.Type != schemas.ResponsesToolTypeMCP {
t.Errorf("expected MCP tool, got type=%q", mcp.Type)
}
if mcp.DeferLoading == nil || !*mcp.DeferLoading {
t.Errorf("DeferLoading dropped on mcp_toolset merge path")
}
if len(mcp.AllowedCallers) != 2 || mcp.AllowedCallers[0] != "direct" {
t.Errorf("AllowedCallers dropped on mcp_toolset merge path, got %v", mcp.AllowedCallers)
}
if len(mcp.InputExamples) != 1 {
t.Errorf("InputExamples dropped on mcp_toolset merge path, got len=%d", len(mcp.InputExamples))
}
if mcp.EagerInputStreaming == nil || *mcp.EagerInputStreaming {
t.Errorf("EagerInputStreaming dropped on mcp_toolset merge path, got %v", mcp.EagerInputStreaming)
}
}
// TestToAnthropicChatRequest_ServerTools_ReproUserBug is the exact shape
// from the reported curl — web_search_20260209 with max_uses + allowed_callers.
// Verifies the request reaches ToAnthropicChatRequest output with a populated
// tools array (previously it was silently dropped).
func TestToAnthropicChatRequest_ServerTools_ReproUserBug(t *testing.T) {
raw := []byte(`{
"model":"claude-sonnet-4-6",
"messages":[{"role":"user","content":"What is the weather in SF?"}],
"tools":[{"name":"web_search","type":"web_search_20260209","max_uses":5,"allowed_callers":["direct"]}]
}`)
// Unmarshal through the neutral schema the way the OpenAI endpoint does.
var inner struct {
Model string `json:"model"`
Messages []json.RawMessage `json:"messages"`
Tools []schemas.ChatTool `json:"tools"`
}
if err := sonic.Unmarshal(raw, &inner); err != nil {
t.Fatalf("outer unmarshal: %v", err)
}
if len(inner.Tools) != 1 {
t.Fatalf("setup: expected 1 tool in raw JSON, got %d", len(inner.Tools))
}
if inner.Tools[0].Name == "" {
t.Errorf("Name lost at neutral-schema decode (was the bug). Got: %+v", inner.Tools[0])
}
if inner.Tools[0].MaxUses == nil {
t.Errorf("MaxUses lost at neutral-schema decode (was the bug)")
}
req := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: inner.Model,
Input: []schemas.ChatMessage{{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}}},
Params: &schemas.ChatParameters{Tools: inner.Tools},
}
out, err := ToAnthropicChatRequest(nil, req)
if err != nil {
t.Fatalf("conversion failed: %v", err)
}
if len(out.Tools) != 1 {
t.Fatalf("repro bug: expected 1 tool after conversion, got %d (tools array was empty — this was the bug)", len(out.Tools))
}
if out.Tools[0].Name != "web_search" {
t.Errorf("tool Name: got %q, want %q", out.Tools[0].Name, "web_search")
}
if out.Tools[0].AnthropicToolWebSearch == nil ||
out.Tools[0].AnthropicToolWebSearch.MaxUses == nil ||
*out.Tools[0].AnthropicToolWebSearch.MaxUses != 5 {
t.Errorf("tool max_uses lost: %+v", out.Tools[0])
}
}

View File

@@ -0,0 +1,752 @@
package anthropic
import (
"encoding/json"
"strings"
"testing"
"github.com/maximhq/bifrost/core/schemas"
)
func TestToAnthropicChatRequest_PreservesPropertyOrder(t *testing.T) {
params := &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("chain_of_thought", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
schemas.KV("description", "Reasoning steps"),
)),
schemas.KV("answer", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
schemas.KV("description", "The answer"),
)),
schemas.KV("citations", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "array"),
)),
schemas.KV("is_unanswered", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "boolean"),
)),
),
Required: []string{"answer", "is_unanswered"},
}
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-sonnet-4-20250514",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
}},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "AnswerResponseModel",
Description: schemas.Ptr("Extract answer"),
Parameters: params,
},
}},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Tools) == 0 {
t.Fatal("expected at least one tool")
}
inputSchema := result.Tools[0].InputSchema
if inputSchema == nil {
t.Fatal("expected InputSchema to be non-nil")
}
// CoT: property order preserved
keys := inputSchema.Properties.Keys()
expected := []string{"chain_of_thought", "answer", "citations", "is_unanswered"}
if len(keys) != len(expected) {
t.Fatalf("expected %d properties, got %d: %v", len(expected), len(keys), keys)
}
for i, k := range expected {
if keys[i] != k {
t.Errorf("property %d: expected %q, got %q (full order: %v)", i, k, keys[i], keys)
}
}
}
func TestToAnthropicChatRequest_CachingDeterminism(t *testing.T) {
makeReq := func(props *schemas.OrderedMap) *schemas.BifrostChatRequest {
return &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-sonnet-4-20250514",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: new("test")},
}},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "test",
Parameters: &schemas.ToolFunctionParameters{
Type: "object",
Properties: props,
},
},
}},
},
}
}
// Version A: type before description
propsA := schemas.NewOrderedMapFromPairs(
schemas.KV("reasoning", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
schemas.KV("description", "Step by step"),
)),
schemas.KV("answer", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "string"),
schemas.KV("description", "Final answer"),
)),
)
// Version B: description before type
propsB := schemas.NewOrderedMapFromPairs(
schemas.KV("reasoning", schemas.NewOrderedMapFromPairs(
schemas.KV("description", "Step by step"),
schemas.KV("type", "string"),
)),
schemas.KV("answer", schemas.NewOrderedMapFromPairs(
schemas.KV("description", "Final answer"),
schemas.KV("type", "string"),
)),
)
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
resultA, err := ToAnthropicChatRequest(ctx, makeReq(propsA))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
resultB, err := ToAnthropicChatRequest(ctx, makeReq(propsB))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
jsonA, err := schemas.Marshal(resultA.Tools[0].InputSchema)
if err != nil {
t.Fatalf("failed to marshal params A: %v", err)
}
jsonB, err := schemas.Marshal(resultB.Tools[0].InputSchema)
if err != nil {
t.Fatalf("failed to marshal params B: %v", err)
}
if string(jsonA) != string(jsonB) {
t.Errorf("caching broken: same schema produced different JSON\nA: %s\nB: %s", jsonA, jsonB)
}
}
func TestToAnthropicChatRequest_NestedProperties_Preserved(t *testing.T) {
params := &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("output", schemas.NewOrderedMapFromPairs(
schemas.KV("type", "object"),
schemas.KV("properties", schemas.NewOrderedMapFromPairs(
schemas.KV("verdict", schemas.NewOrderedMapFromPairs(schemas.KV("type", "string"))),
schemas.KV("score", schemas.NewOrderedMapFromPairs(schemas.KV("type", "number"))),
schemas.KV("explanation", schemas.NewOrderedMapFromPairs(schemas.KV("type", "string"))),
)),
)),
schemas.KV("reasoning", schemas.NewOrderedMapFromPairs(schemas.KV("type", "string"))),
),
}
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-sonnet-4-20250514",
Input: []schemas.ChatMessage{{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
}},
Params: &schemas.ChatParameters{
Tools: []schemas.ChatTool{{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{
Name: "nested_tool",
Parameters: params,
},
}},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Tools) == 0 {
t.Fatal("expected at least one tool")
}
inputSchema := result.Tools[0].InputSchema
// CoT: top-level property order preserved
keys := inputSchema.Properties.Keys()
if len(keys) != 2 || keys[0] != "output" || keys[1] != "reasoning" {
t.Errorf("expected top-level property order [output, reasoning], got %v", keys)
}
// CoT: nested property order preserved
output, ok := inputSchema.Properties.Get("output")
if !ok {
t.Fatal("expected output property")
}
outputOM, ok := output.(*schemas.OrderedMap)
if !ok {
t.Fatalf("expected output to be *schemas.OrderedMap, got %T", output)
}
nestedProps, ok := outputOM.Get("properties")
if !ok {
t.Fatal("expected nested properties in output")
}
nestedPropsOM, ok := nestedProps.(*schemas.OrderedMap)
if !ok {
t.Fatalf("expected nested properties to be *schemas.OrderedMap, got %T", nestedProps)
}
nestedKeys := nestedPropsOM.Keys()
if len(nestedKeys) != 3 || nestedKeys[0] != "verdict" || nestedKeys[1] != "score" || nestedKeys[2] != "explanation" {
t.Errorf("expected nested property order [verdict, score, explanation], got %v", nestedKeys)
}
}
// TestToAnthropicChatRequest_ToolInputKeyOrderPreservation verifies that tool_use input
// arguments preserve the client's original key ordering after conversion to Anthropic format.
// This is critical for prompt caching, which relies on exact byte-for-byte prefix matching.
// The test uses multiple parallel tool calls in a single assistant message — each with
// a different key ordering — matching real-world Claude Code usage patterns.
func TestToAnthropicChatRequest_ToolInputKeyOrderPreservation(t *testing.T) {
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-sonnet-4-20250514",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("test")},
},
{
// Multiple parallel tool calls with different key orderings per block
Role: schemas.ChatMessageRoleAssistant,
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ToolCalls: []schemas.ChatAssistantMessageToolCall{
{
Index: 0,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_vrtx_013t7gabfKz98BKpdwrnS6LP"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"description":"Find references to auth_injector quickly","timeout":30000,"command":"grep -r \"auth_injector\" . --include=\"Makefile\" -l 2>/dev/null"}`,
},
},
{
Index: 1,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_vrtx_01K2kr3wi7M4RriLgE7Kq3vJ"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"command":"git diff main...HEAD --stat","description":"Show diff of commits in branch"}`,
},
},
{
Index: 2,
Type: schemas.Ptr("function"),
ID: schemas.Ptr("toolu_vrtx_01D1mMkcvpfqGrEhkcxUQpGc"),
Function: schemas.ChatAssistantMessageToolCallFunction{
Name: schemas.Ptr("bash"),
Arguments: `{"command":"git log main..HEAD --format=\"%H %s\" | head -20","description":"Show detailed commits in branch"}`,
},
},
},
},
},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Collect all tool_use content blocks
var toolUseBlocks []AnthropicContentBlock
for _, msg := range result.Messages {
for _, block := range msg.Content.ContentBlocks {
if block.Type == AnthropicContentBlockTypeToolUse {
toolUseBlocks = append(toolUseBlocks, block)
}
}
}
if len(toolUseBlocks) != 3 {
t.Fatalf("expected 3 tool_use blocks, got %d", len(toolUseBlocks))
}
// Block 0: keys should be description, timeout, command (NOT alphabetical)
json0, _ := json.Marshal(toolUseBlocks[0].Input)
s0 := string(json0)
descIdx0 := strings.Index(s0, `"description"`)
timeIdx0 := strings.Index(s0, `"timeout"`)
cmdIdx0 := strings.Index(s0, `"command"`)
if descIdx0 < 0 || timeIdx0 < 0 || cmdIdx0 < 0 {
t.Fatalf("block 0: missing expected key(s) in: %s", s0)
}
if !(descIdx0 < timeIdx0 && timeIdx0 < cmdIdx0) {
t.Errorf("block 0: key order not preserved, expected description < timeout < command in: %s", s0)
}
// Block 1: keys should be command, description (NOT alphabetical)
json1, _ := json.Marshal(toolUseBlocks[1].Input)
s1 := string(json1)
cmdIdx1 := strings.Index(s1, `"command"`)
descIdx1 := strings.Index(s1, `"description"`)
if cmdIdx1 < 0 || descIdx1 < 0 {
t.Fatalf("block 1: missing expected key(s) in: %s", s1)
}
if !(cmdIdx1 < descIdx1) {
t.Errorf("block 1: key order not preserved, expected command < description in: %s", s1)
}
// Block 2: keys should be command, description (same as block 1)
json2, _ := json.Marshal(toolUseBlocks[2].Input)
s2 := string(json2)
cmdIdx2 := strings.Index(s2, `"command"`)
descIdx2 := strings.Index(s2, `"description"`)
if cmdIdx2 < 0 || descIdx2 < 0 {
t.Fatalf("block 2: missing expected key(s) in: %s", s2)
}
if !(cmdIdx2 < descIdx2) {
t.Errorf("block 2: key order not preserved, expected command < description in: %s", s2)
}
}
func TestToBifrostChatResponse_MultipleTextBlocksWithThinking(t *testing.T) {
thinkingText := "Let me reason step by step about this problem."
textBlock1 := "The answer is 42."
textBlock2 := "Here is why that is the case."
signature := "sig_abc123"
response := &AnthropicMessageResponse{
ID: "msg_test123",
Type: "message",
Role: "assistant",
Model: "claude-opus-4-6-20250514",
Content: []AnthropicContentBlock{
{
Type: AnthropicContentBlockTypeThinking,
Thinking: &thinkingText,
Signature: &signature,
},
{
Type: AnthropicContentBlockTypeText,
Text: &textBlock1,
},
{
Type: AnthropicContentBlockTypeText,
Text: &textBlock2,
},
},
StopReason: "end_turn",
Usage: &AnthropicUsage{
InputTokens: 100,
OutputTokens: 50,
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result := response.ToBifrostChatResponse(ctx)
if result == nil {
t.Fatal("expected non-nil result")
}
// With multiple text blocks, ToBifrostChatResponse preserves them as ContentBlocks
// (only a single text block collapses to ContentStr — see chat.go:812-815).
// Thinking flows through ReasoningDetails below, not ContentStr.
choice := result.Choices[0]
msg := choice.ChatNonStreamResponseChoice.Message
if msg.Content.ContentStr != nil {
t.Errorf("expected ContentStr to be nil with multiple text blocks, got %q", *msg.Content.ContentStr)
}
if len(msg.Content.ContentBlocks) != 2 {
t.Fatalf("expected 2 content blocks (one per text block), got %d", len(msg.Content.ContentBlocks))
}
if msg.Content.ContentBlocks[0].Text == nil || *msg.Content.ContentBlocks[0].Text != textBlock1 {
t.Errorf("block 0 text mismatch: got %v, want %q", msg.Content.ContentBlocks[0].Text, textBlock1)
}
if msg.Content.ContentBlocks[1].Text == nil || *msg.Content.ContentBlocks[1].Text != textBlock2 {
t.Errorf("block 1 text mismatch: got %v, want %q", msg.Content.ContentBlocks[1].Text, textBlock2)
}
// Thinking is surfaced via ReasoningDetails with the signature preserved
// (see chat.go:798-807).
if msg.ChatAssistantMessage == nil {
t.Fatal("expected ChatAssistantMessage to be non-nil")
}
rd := msg.ChatAssistantMessage.ReasoningDetails
if len(rd) != 1 {
t.Fatalf("expected 1 reasoning details entry (the thinking block), got %d", len(rd))
}
if rd[0].Type != schemas.BifrostReasoningDetailsTypeText {
t.Errorf("expected reasoning detail type %s, got %s", schemas.BifrostReasoningDetailsTypeText, rd[0].Type)
}
if rd[0].Signature == nil || *rd[0].Signature != signature {
t.Error("expected thinking signature to be preserved on reasoning detail")
}
if rd[0].Text == nil || *rd[0].Text != thinkingText {
t.Errorf("expected reasoning text to match thinking text")
}
}
func TestToBifrostChatResponse_SingleTextBlockNoThinking(t *testing.T) {
// Verify existing behavior: single text block without thinking collapses to string
text := "Simple response"
response := &AnthropicMessageResponse{
ID: "msg_simple",
Type: "message",
Role: "assistant",
Model: "claude-sonnet-4-6-20250514",
Content: []AnthropicContentBlock{
{Type: AnthropicContentBlockTypeText, Text: &text},
},
StopReason: "end_turn",
Usage: &AnthropicUsage{InputTokens: 10, OutputTokens: 5},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result := response.ToBifrostChatResponse(ctx)
msg := result.Choices[0].ChatNonStreamResponseChoice.Message
if msg.Content.ContentStr == nil || *msg.Content.ContentStr != text {
t.Error("expected ContentStr to be the text")
}
if msg.Content.ContentBlocks != nil {
t.Error("expected ContentBlocks to be nil")
}
// No reasoning details for plain text
if msg.ChatAssistantMessage != nil && len(msg.ChatAssistantMessage.ReasoningDetails) > 0 {
t.Error("expected no reasoning details for single text block without thinking")
}
}
func TestToAnthropicChatRequest_BoundaryMismatchFallback(t *testing.T) {
// If content was modified by the client, boundaries won't match — fall back to single text block
signature := "sig_fallback"
modifiedContent := "The user edited this content"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-6-20250514",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("Hi")},
},
{
Role: schemas.ChatMessageRoleAssistant,
Content: &schemas.ChatMessageContent{ContentStr: &modifiedContent},
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ReasoningDetails: []schemas.ChatReasoningDetails{
{Index: 0, Type: schemas.BifrostReasoningDetailsTypeText, Text: &modifiedContent, Signature: &signature},
},
},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("Continue")},
},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var assistantMsg *AnthropicMessage
for i := range result.Messages {
if result.Messages[i].Role == "assistant" {
assistantMsg = &result.Messages[i]
break
}
}
if assistantMsg == nil {
t.Fatal("expected assistant message")
}
// Should have thinking block (from reasoning_details with signature) + single text fallback
blocks := assistantMsg.Content.ContentBlocks
// First block: thinking (from reasoning_details, text is nil since it was cleared)
// Plus: fallback single text block with the full modified content
foundText := false
for _, block := range blocks {
if block.Type == AnthropicContentBlockTypeText {
if block.Text != nil && *block.Text == modifiedContent {
foundText = true
}
}
}
if !foundText {
t.Error("expected fallback to single text block with full content")
}
}
func TestToAnthropicChatRequest_NormalFlowUnchanged(t *testing.T) {
// Verify that the normal multi-turn flow (reasoning_details with text + signature,
// no bifrost.content_blocks) produces the same output as before.
thinkingText := "I need to think about this carefully"
signature := "sig_normal"
responseText := "Here is my answer"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-6-20250514",
Input: []schemas.ChatMessage{
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("What is 2+2?")},
},
{
Role: schemas.ChatMessageRoleAssistant,
Content: &schemas.ChatMessageContent{ContentStr: &responseText},
ChatAssistantMessage: &schemas.ChatAssistantMessage{
ReasoningDetails: []schemas.ChatReasoningDetails{
{
Index: 0,
Type: schemas.BifrostReasoningDetailsTypeText,
Text: &thinkingText,
Signature: &signature,
},
},
},
},
{
Role: schemas.ChatMessageRoleUser,
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("Are you sure?")},
},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var assistantMsg *AnthropicMessage
for i := range result.Messages {
if result.Messages[i].Role == "assistant" {
assistantMsg = &result.Messages[i]
break
}
}
if assistantMsg == nil {
t.Fatal("expected assistant message")
}
blocks := assistantMsg.Content.ContentBlocks
if len(blocks) != 2 {
t.Fatalf("expected 2 content blocks (thinking + text), got %d", len(blocks))
}
// Block 0: thinking with original text and signature
if blocks[0].Type != AnthropicContentBlockTypeThinking {
t.Errorf("block 0: expected thinking, got %s", blocks[0].Type)
}
if blocks[0].Thinking == nil || *blocks[0].Thinking != thinkingText {
t.Errorf("block 0: expected thinking text %q, got %v", thinkingText, blocks[0].Thinking)
}
if blocks[0].Signature == nil || *blocks[0].Signature != signature {
t.Errorf("block 0: expected signature %q, got %v", signature, blocks[0].Signature)
}
// Block 1: text with response
if blocks[1].Type != AnthropicContentBlockTypeText {
t.Errorf("block 1: expected text, got %s", blocks[1].Type)
}
if blocks[1].Text == nil || *blocks[1].Text != responseText {
t.Errorf("block 1: expected text %q, got %v", responseText, blocks[1].Text)
}
}
func TestToAnthropicChatRequest_Opus47_StripsTemperatureTopPTopK(t *testing.T) {
temp := 0.7
topP := 0.9
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-7-20260401",
Input: []schemas.ChatMessage{
{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}},
},
Params: &schemas.ChatParameters{
Temperature: &temp,
TopP: &topP,
ExtraParams: map[string]interface{}{"top_k": 40},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Temperature != nil {
t.Errorf("expected Temperature to be nil for Opus 4.7, got %v", result.Temperature)
}
if result.TopP != nil {
t.Errorf("expected TopP to be nil for Opus 4.7, got %v", result.TopP)
}
if result.TopK != nil {
t.Errorf("expected TopK to be nil for Opus 4.7, got %v", result.TopK)
}
}
func TestToAnthropicChatRequest_NonOpus47_PreservesTemperature(t *testing.T) {
temp := 0.7
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-6-20250514",
Input: []schemas.ChatMessage{
{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("hi")}},
},
Params: &schemas.ChatParameters{
Temperature: &temp,
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Temperature == nil || *result.Temperature != temp {
t.Errorf("expected Temperature %v, got %v", temp, result.Temperature)
}
}
func TestToAnthropicChatRequest_Opus47_ReasoningMaxTokens_AdaptiveOnly(t *testing.T) {
maxTok := 2048
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-7-20260401",
Input: []schemas.ChatMessage{
{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("think")}},
},
Params: &schemas.ChatParameters{
MaxCompletionTokens: schemas.Ptr(8192),
Reasoning: &schemas.ChatReasoning{MaxTokens: &maxTok},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Thinking == nil {
t.Fatal("expected Thinking to be set")
}
if result.Thinking.Type != "adaptive" {
t.Errorf("expected thinking type 'adaptive' for Opus 4.7, got %q", result.Thinking.Type)
}
if result.Thinking.BudgetTokens != nil {
t.Errorf("expected BudgetTokens to be nil for Opus 4.7, got %v", result.Thinking.BudgetTokens)
}
}
func TestToAnthropicChatRequest_NonOpus47_ReasoningMaxTokens_EnabledWithBudget(t *testing.T) {
maxTok := 2048
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-6-20250514",
Input: []schemas.ChatMessage{
{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("think")}},
},
Params: &schemas.ChatParameters{
MaxCompletionTokens: schemas.Ptr(8192),
Reasoning: &schemas.ChatReasoning{MaxTokens: &maxTok},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Thinking == nil {
t.Fatal("expected Thinking to be set")
}
if result.Thinking.Type != "enabled" {
t.Errorf("expected thinking type 'enabled' for Opus 4.6, got %q", result.Thinking.Type)
}
if result.Thinking.BudgetTokens == nil || *result.Thinking.BudgetTokens != maxTok {
t.Errorf("expected BudgetTokens %d, got %v", maxTok, result.Thinking.BudgetTokens)
}
}
func TestToAnthropicChatRequest_Opus47_ReasoningEffort_AdaptiveWithEffort(t *testing.T) {
effort := "high"
bifrostReq := &schemas.BifrostChatRequest{
Provider: schemas.Anthropic,
Model: "claude-opus-4-7-20260401",
Input: []schemas.ChatMessage{
{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("think")}},
},
Params: &schemas.ChatParameters{
MaxCompletionTokens: schemas.Ptr(8192),
Reasoning: &schemas.ChatReasoning{Effort: &effort},
},
}
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
result, err := ToAnthropicChatRequest(ctx, bifrostReq)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Thinking == nil {
t.Fatal("expected Thinking to be set")
}
if result.Thinking.Type != "adaptive" {
t.Errorf("expected thinking type 'adaptive' for Opus 4.7 effort-based, got %q", result.Thinking.Type)
}
if result.OutputConfig == nil || result.OutputConfig.Effort == nil {
t.Error("expected OutputConfig.Effort to be set for Opus 4.7 effort-based reasoning")
}
}

View File

@@ -0,0 +1,703 @@
package anthropic
import (
"context"
"testing"
"github.com/maximhq/bifrost/core/schemas"
)
// --- isCompactionItem tests ---
func TestIsCompactionItem(t *testing.T) {
t.Parallel()
tests := []struct {
name string
item *schemas.ResponsesMessage
expected bool
}{
{
name: "nil item",
item: nil,
expected: false,
},
{
name: "nil type",
item: &schemas.ResponsesMessage{
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{Type: schemas.ResponsesOutputMessageContentTypeCompaction},
},
},
},
expected: false,
},
{
name: "message type with compaction content block",
item: &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeCompaction,
ResponsesOutputMessageContentCompaction: &schemas.ResponsesOutputMessageContentCompaction{
Summary: "Summary of conversation",
},
},
},
},
},
expected: true,
},
{
name: "message type with text content block",
item: &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr("Hello"),
},
},
},
},
expected: false,
},
{
name: "function call type",
item: &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
},
expected: false,
},
{
name: "message type with nil content",
item: &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: nil,
},
expected: false,
},
{
name: "message type with empty content blocks",
item: &schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{},
},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCompactionItem(tt.item)
if result != tt.expected {
t.Errorf("isCompactionItem() = %v, want %v", result, tt.expected)
}
})
}
}
// --- Streaming: Anthropic → Bifrost (inbound) ---
func TestToBifrostResponsesStream_CompactionContentBlockStart(t *testing.T) {
t.Parallel()
state := &AnthropicResponsesStreamState{
ContentIndexToOutputIndex: make(map[int]int),
ContentIndexToBlockType: make(map[int]AnthropicContentBlockType),
ToolArgumentBuffers: make(map[int]string),
MCPCallOutputIndices: make(map[int]bool),
ItemIDs: make(map[int]string),
OutputItems: make(map[int]*schemas.ResponsesMessage),
ReasoningSignatures: make(map[int]string),
TextContentIndices: make(map[int]bool),
ReasoningContentIndices: make(map[int]bool),
CompactionContentIndices: make(map[int]*schemas.CacheControl),
CurrentOutputIndex: 0,
CreatedAt: 1234567890,
HasEmittedCreated: true,
HasEmittedInProgress: true,
}
// content_block_start with compaction type should return nil (defers to delta)
chunk := &AnthropicStreamEvent{
Type: AnthropicStreamEventTypeContentBlockStart,
Index: schemas.Ptr(0),
ContentBlock: &AnthropicContentBlock{
Type: AnthropicContentBlockTypeCompaction,
CacheControl: &schemas.CacheControl{
Type: "ephemeral",
},
},
}
responses, err, isLast := chunk.ToBifrostResponsesStream(context.Background(), 0, state)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if isLast {
t.Error("should not be last chunk")
}
if len(responses) != 0 {
t.Errorf("expected 0 responses for compaction content_block_start, got %d", len(responses))
}
// Verify state was tracked
if _, exists := state.CompactionContentIndices[0]; !exists {
t.Error("expected compaction to be tracked in CompactionContentIndices")
}
if blockType, exists := state.ContentIndexToBlockType[0]; !exists || blockType != AnthropicContentBlockTypeCompaction {
t.Error("expected compaction block type tracked in ContentIndexToBlockType")
}
}
func TestToBifrostResponsesStream_CompactionDelta(t *testing.T) {
t.Parallel()
state := &AnthropicResponsesStreamState{
ContentIndexToOutputIndex: map[int]int{0: 0},
ContentIndexToBlockType: map[int]AnthropicContentBlockType{0: AnthropicContentBlockTypeCompaction},
ToolArgumentBuffers: make(map[int]string),
MCPCallOutputIndices: make(map[int]bool),
ItemIDs: map[int]string{0: "cmp_0"},
OutputItems: make(map[int]*schemas.ResponsesMessage),
ReasoningSignatures: make(map[int]string),
TextContentIndices: make(map[int]bool),
ReasoningContentIndices: make(map[int]bool),
CompactionContentIndices: map[int]*schemas.CacheControl{0: {Type: "ephemeral"}},
CurrentOutputIndex: 1,
CreatedAt: 1234567890,
HasEmittedCreated: true,
HasEmittedInProgress: true,
}
summary := "The user asked about building a website. We discussed HTML, CSS, and JavaScript."
chunk := &AnthropicStreamEvent{
Type: AnthropicStreamEventTypeContentBlockDelta,
Index: schemas.Ptr(0),
Delta: &AnthropicStreamDelta{
Type: AnthropicStreamDeltaTypeCompaction,
Content: &summary,
},
}
responses, err, isLast := chunk.ToBifrostResponsesStream(context.Background(), 0, state)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if isLast {
t.Error("should not be last chunk")
}
// Should emit output_item.added and output_item.done
if len(responses) != 2 {
t.Fatalf("expected 2 responses for compaction delta, got %d", len(responses))
}
// First: output_item.added
added := responses[0]
if added.Type != schemas.ResponsesStreamResponseTypeOutputItemAdded {
t.Errorf("first response type = %v, want %v", added.Type, schemas.ResponsesStreamResponseTypeOutputItemAdded)
}
if added.Item == nil || added.Item.Content == nil || len(added.Item.Content.ContentBlocks) == 0 {
t.Fatal("output_item.added should have content blocks")
}
block := added.Item.Content.ContentBlocks[0]
if block.Type != schemas.ResponsesOutputMessageContentTypeCompaction {
t.Errorf("content block type = %v, want compaction", block.Type)
}
if block.ResponsesOutputMessageContentCompaction == nil {
t.Fatal("expected compaction content to be non-nil")
}
if block.ResponsesOutputMessageContentCompaction.Summary != summary {
t.Errorf("summary = %q, want %q", block.ResponsesOutputMessageContentCompaction.Summary, summary)
}
// Cache control should be preserved from content_block_start
if block.CacheControl == nil || block.CacheControl.Type != "ephemeral" {
t.Error("expected cache control to be preserved")
}
// Second: output_item.done
done := responses[1]
if done.Type != schemas.ResponsesStreamResponseTypeOutputItemDone {
t.Errorf("second response type = %v, want %v", done.Type, schemas.ResponsesStreamResponseTypeOutputItemDone)
}
}
func TestToBifrostResponsesStream_CompactionContentBlockStop(t *testing.T) {
t.Parallel()
state := &AnthropicResponsesStreamState{
ContentIndexToOutputIndex: map[int]int{0: 0},
ContentIndexToBlockType: map[int]AnthropicContentBlockType{0: AnthropicContentBlockTypeCompaction},
ToolArgumentBuffers: make(map[int]string),
MCPCallOutputIndices: make(map[int]bool),
ItemIDs: map[int]string{0: "cmp_0"},
OutputItems: make(map[int]*schemas.ResponsesMessage),
ReasoningSignatures: make(map[int]string),
TextContentIndices: make(map[int]bool),
ReasoningContentIndices: make(map[int]bool),
CompactionContentIndices: make(map[int]*schemas.CacheControl),
CurrentOutputIndex: 1,
CreatedAt: 1234567890,
HasEmittedCreated: true,
HasEmittedInProgress: true,
}
chunk := &AnthropicStreamEvent{
Type: AnthropicStreamEventTypeContentBlockStop,
Index: schemas.Ptr(0),
}
responses, err, isLast := chunk.ToBifrostResponsesStream(context.Background(), 0, state)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if isLast {
t.Error("should not be last chunk")
}
// content_block_stop for compaction should return nil (done was already emitted with delta)
if len(responses) != 0 {
t.Errorf("expected 0 responses for compaction content_block_stop, got %d", len(responses))
}
}
// --- Streaming: Bifrost → Anthropic (outbound, non-passthrough) ---
func TestToAnthropicResponsesStreamResponse_CompactionOutputItemAdded(t *testing.T) {
t.Parallel()
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
summary := "Summary of the conversation about building a website"
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemAdded,
OutputIndex: schemas.Ptr(0),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr("cmp_test123"),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Status: schemas.Ptr("completed"),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeCompaction,
ResponsesOutputMessageContentCompaction: &schemas.ResponsesOutputMessageContentCompaction{
Summary: summary,
},
CacheControl: &schemas.CacheControl{Type: "ephemeral"},
},
},
},
},
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
// Should emit: content_block_start (compaction) + content_block_delta (compaction_delta)
if len(events) < 2 {
t.Fatalf("expected at least 2 events, got %d", len(events))
}
// Event 1: content_block_start
start := events[0]
if start.Type != AnthropicStreamEventTypeContentBlockStart {
t.Errorf("event[0] type = %v, want content_block_start", start.Type)
}
if start.ContentBlock == nil {
t.Fatal("content_block_start should have ContentBlock")
}
if start.ContentBlock.Type != AnthropicContentBlockTypeCompaction {
t.Errorf("ContentBlock.Type = %v, want compaction", start.ContentBlock.Type)
}
if start.ContentBlock.CacheControl == nil || start.ContentBlock.CacheControl.Type != "ephemeral" {
t.Error("expected cache control to be preserved on content_block_start")
}
// Event 2: content_block_delta with compaction_delta
delta := events[1]
if delta.Type != AnthropicStreamEventTypeContentBlockDelta {
t.Errorf("event[1] type = %v, want content_block_delta", delta.Type)
}
if delta.Delta == nil {
t.Fatal("content_block_delta should have Delta")
}
if delta.Delta.Type != AnthropicStreamDeltaTypeCompaction {
t.Errorf("Delta.Type = %v, want compaction_delta", delta.Delta.Type)
}
if delta.Delta.Content == nil || *delta.Delta.Content != summary {
t.Errorf("Delta.Content = %v, want %q", delta.Delta.Content, summary)
}
}
func TestToAnthropicResponsesStreamResponse_CompactionOutputItemDone(t *testing.T) {
t.Parallel()
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemDone,
OutputIndex: schemas.Ptr(0),
ItemID: schemas.Ptr("cmp_test123"),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr("cmp_test123"),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Status: schemas.Ptr("completed"),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeCompaction,
ResponsesOutputMessageContentCompaction: &schemas.ResponsesOutputMessageContentCompaction{
Summary: "Summary text",
},
},
},
},
},
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
// Should emit content_block_stop
if len(events) != 1 {
t.Fatalf("expected 1 event for output_item.done, got %d", len(events))
}
stop := events[0]
if stop.Type != AnthropicStreamEventTypeContentBlockStop {
t.Errorf("event type = %v, want content_block_stop", stop.Type)
}
}
func TestToAnthropicResponsesStreamResponse_TextOutputItemAdded_NotAffectedByCompactionCheck(t *testing.T) {
t.Parallel()
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
// Regular text message should still emit content_block_start with type=text
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemAdded,
OutputIndex: schemas.Ptr(0),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr("msg_test123"),
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Status: schemas.Ptr("in_progress"),
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Text: schemas.Ptr(""),
},
},
},
},
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
if len(events) == 0 {
t.Fatal("expected at least 1 event")
}
start := events[0]
if start.Type != AnthropicStreamEventTypeContentBlockStart {
t.Errorf("event type = %v, want content_block_start", start.Type)
}
if start.ContentBlock == nil {
t.Fatal("expected ContentBlock to be non-nil")
}
if start.ContentBlock.Type != AnthropicContentBlockTypeText {
t.Errorf("ContentBlock.Type = %v, want text", start.ContentBlock.Type)
}
}
// --- Non-Streaming: stop_reason mapping ---
func TestToBifrostResponsesResponse_PreservesStopReason(t *testing.T) {
t.Parallel()
tests := []struct {
name string
stopReason AnthropicStopReason
expectedStopReason string
}{
{
name: "compaction stop reason",
stopReason: AnthropicStopReasonCompaction,
expectedStopReason: "compaction",
},
{
name: "end_turn stop reason",
stopReason: AnthropicStopReasonEndTurn,
expectedStopReason: "end_turn",
},
{
name: "tool_use stop reason",
stopReason: AnthropicStopReasonToolUse,
expectedStopReason: "tool_use",
},
{
name: "max_tokens stop reason",
stopReason: AnthropicStopReasonMaxTokens,
expectedStopReason: "max_tokens",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
resp := &AnthropicMessageResponse{
ID: "msg_test",
Type: "message",
Role: "assistant",
Model: "claude-sonnet-4-6",
StopReason: tt.stopReason,
Content: []AnthropicContentBlock{
{Type: AnthropicContentBlockTypeText, Text: schemas.Ptr("Hello")},
},
}
bifrostResp := resp.ToBifrostResponsesResponse(ctx)
if bifrostResp.StopReason == nil {
t.Fatal("expected StopReason to be non-nil")
}
if *bifrostResp.StopReason != tt.expectedStopReason {
t.Errorf("StopReason = %q, want %q", *bifrostResp.StopReason, tt.expectedStopReason)
}
})
}
}
func TestToBifrostResponsesResponse_EmptyStopReason(t *testing.T) {
t.Parallel()
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
resp := &AnthropicMessageResponse{
ID: "msg_test",
Type: "message",
Role: "assistant",
Model: "claude-sonnet-4-6",
Content: []AnthropicContentBlock{},
}
bifrostResp := resp.ToBifrostResponsesResponse(ctx)
if bifrostResp.StopReason != nil {
t.Errorf("expected nil StopReason for empty stop_reason, got %q", *bifrostResp.StopReason)
}
}
func TestToAnthropicResponsesResponse_StopReasonFromBifrost(t *testing.T) {
t.Parallel()
tests := []struct {
name string
stopReason *string
contentBlocks []schemas.ResponsesMessage
expectedReason AnthropicStopReason
}{
{
name: "compaction stop reason from bifrost",
stopReason: schemas.Ptr("compaction"),
expectedReason: AnthropicStopReasonCompaction,
},
{
name: "end_turn mapped from stop",
stopReason: schemas.Ptr("stop"),
expectedReason: AnthropicStopReasonEndTurn,
},
{
name: "tool_use mapped from tool_calls",
stopReason: schemas.Ptr("tool_calls"),
expectedReason: AnthropicStopReasonToolUse,
},
{
name: "nil stop_reason defaults to end_turn",
stopReason: nil,
expectedReason: AnthropicStopReasonEndTurn,
},
{
name: "nil stop_reason with tool_use content defaults to tool_use",
stopReason: nil,
contentBlocks: []schemas.ResponsesMessage{
{
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr("call_123"),
Name: schemas.Ptr("my_tool"),
},
},
},
expectedReason: AnthropicStopReasonToolUse,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
bifrostResp := &schemas.BifrostResponsesResponse{
ID: schemas.Ptr("resp_test"),
Model: "claude-sonnet-4-6",
StopReason: tt.stopReason,
Output: tt.contentBlocks,
}
result := ToAnthropicResponsesResponse(ctx, bifrostResp)
if result.StopReason != tt.expectedReason {
t.Errorf("StopReason = %v, want %v", result.StopReason, tt.expectedReason)
}
})
}
}
// --- Non-Streaming: compaction content block round-trip ---
func TestCompactionContentBlock_NonStreamingRoundTrip(t *testing.T) {
t.Parallel()
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
summary := "The user requested help building a web scraper using Python with BeautifulSoup."
// Simulate Anthropic response with compaction block
anthropicResp := &AnthropicMessageResponse{
ID: "msg_compaction_test",
Type: "message",
Role: "assistant",
Model: "claude-opus-4-6",
StopReason: AnthropicStopReasonCompaction,
Content: []AnthropicContentBlock{
{
Type: AnthropicContentBlockTypeCompaction,
Content: &AnthropicContent{
ContentStr: &summary,
},
CacheControl: &schemas.CacheControl{Type: "ephemeral"},
},
},
}
// Step 1: Anthropic → Bifrost
bifrostResp := anthropicResp.ToBifrostResponsesResponse(ctx)
if bifrostResp.StopReason == nil || *bifrostResp.StopReason != "compaction" {
t.Fatalf("expected stop_reason='compaction', got %v", bifrostResp.StopReason)
}
if len(bifrostResp.Output) == 0 {
t.Fatal("expected at least one output message")
}
// Find the compaction block
var foundCompaction bool
for _, msg := range bifrostResp.Output {
if msg.Content != nil {
for _, block := range msg.Content.ContentBlocks {
if block.Type == schemas.ResponsesOutputMessageContentTypeCompaction {
foundCompaction = true
if block.ResponsesOutputMessageContentCompaction == nil {
t.Fatal("expected compaction content to be non-nil")
}
if block.ResponsesOutputMessageContentCompaction.Summary != summary {
t.Errorf("summary = %q, want %q", block.ResponsesOutputMessageContentCompaction.Summary, summary)
}
}
}
}
}
if !foundCompaction {
t.Error("compaction block not found in Bifrost output")
}
// Step 2: Bifrost → Anthropic
result := ToAnthropicResponsesResponse(ctx, bifrostResp)
if result.StopReason != AnthropicStopReasonCompaction {
t.Errorf("result StopReason = %v, want compaction", result.StopReason)
}
// Find compaction content block in result
var foundResultCompaction bool
for _, block := range result.Content {
if block.Type == AnthropicContentBlockTypeCompaction {
foundResultCompaction = true
if block.Content == nil || block.Content.ContentStr == nil {
t.Fatal("expected compaction content string")
}
if *block.Content.ContentStr != summary {
t.Errorf("result summary = %q, want %q", *block.Content.ContentStr, summary)
}
if block.CacheControl == nil || block.CacheControl.Type != "ephemeral" {
t.Error("expected cache control to be preserved")
}
}
}
if !foundResultCompaction {
t.Error("compaction block not found in Anthropic result")
}
}
// --- Streaming: compaction stop_reason in response.completed ---
func TestToAnthropicResponsesStreamResponse_CompletedWithCompactionStopReason(t *testing.T) {
t.Parallel()
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeCompleted,
Response: &schemas.BifrostResponsesResponse{
ID: schemas.Ptr("resp_test"),
Model: "claude-opus-4-6",
StopReason: schemas.Ptr("compaction"),
Usage: &schemas.ResponsesResponseUsage{
InputTokens: 1000,
OutputTokens: 500,
TotalTokens: 1500,
},
},
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
// Should emit message_delta + message_stop
if len(events) != 2 {
t.Fatalf("expected 2 events for response.completed, got %d", len(events))
}
// message_delta should have stop_reason=compaction
messageDelta := events[0]
if messageDelta.Type != AnthropicStreamEventTypeMessageDelta {
t.Errorf("event[0] type = %v, want message_delta", messageDelta.Type)
}
if messageDelta.Delta == nil || messageDelta.Delta.StopReason == nil {
t.Fatal("expected Delta.StopReason in message_delta")
}
if *messageDelta.Delta.StopReason != AnthropicStopReasonCompaction {
t.Errorf("StopReason = %v, want compaction", *messageDelta.Delta.StopReason)
}
// message_stop
messageStop := events[1]
if messageStop.Type != AnthropicStreamEventTypeMessageStop {
t.Errorf("event[1] type = %v, want message_stop", messageStop.Type)
}
}

View File

@@ -0,0 +1,34 @@
package anthropic
import (
"github.com/maximhq/bifrost/core/schemas"
)
// ToBifrostCountTokensResponse converts an Anthropic count tokens response to Bifrost format
func (resp *AnthropicCountTokensResponse) ToBifrostCountTokensResponse(model string) *schemas.BifrostCountTokensResponse {
if resp == nil {
return nil
}
totalTokens := resp.InputTokens
bifrostResp := &schemas.BifrostCountTokensResponse{
Model: model,
InputTokens: resp.InputTokens,
TotalTokens: &totalTokens,
Object: "response.input_tokens",
}
return bifrostResp
}
// ToAnthropicCountTokensResponse converts a Bifrost count tokens response to Anthropic format.
func ToAnthropicCountTokensResponse(bifrostResp *schemas.BifrostCountTokensResponse) *AnthropicCountTokensResponse {
if bifrostResp == nil {
return nil
}
return &AnthropicCountTokensResponse{
InputTokens: bifrostResp.InputTokens,
}
}

View File

@@ -0,0 +1,68 @@
package anthropic
import (
"fmt"
providerUtils "github.com/maximhq/bifrost/core/providers/utils"
schemas "github.com/maximhq/bifrost/core/schemas"
"github.com/valyala/fasthttp"
)
// ToAnthropicChatCompletionError converts a BifrostError to AnthropicMessageError
func ToAnthropicChatCompletionError(bifrostErr *schemas.BifrostError) *AnthropicMessageError {
if bifrostErr == nil {
return nil
}
// Safely extract type and message from nested error
errorType := "api_error"
message := ""
if bifrostErr.Error != nil {
if bifrostErr.Error.Type != nil && *bifrostErr.Error.Type != "" {
errorType = *bifrostErr.Error.Type
}
message = bifrostErr.Error.Message
}
// Handle nested error fields with nil checks
errorStruct := AnthropicMessageErrorStruct{
Type: errorType,
Message: message,
}
return &AnthropicMessageError{
Type: "error", // always "error" for Anthropic
Error: errorStruct,
}
}
// ToAnthropicResponsesStreamError converts a BifrostError to Anthropic responses streaming error in SSE format
func ToAnthropicResponsesStreamError(bifrostErr *schemas.BifrostError) string {
if bifrostErr == nil {
return ""
}
anthropicErr := ToAnthropicChatCompletionError(bifrostErr)
// Marshal to JSON
jsonData, err := providerUtils.MarshalSorted(anthropicErr)
if err != nil {
return ""
}
// Format as Anthropic SSE error event
return fmt.Sprintf("event: error\ndata: %s\n\n", jsonData)
}
func parseAnthropicError(resp *fasthttp.Response) *schemas.BifrostError {
var errorResp AnthropicError
bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResp)
if errorResp.Error != nil {
if bifrostErr.Error == nil {
bifrostErr.Error = &schemas.ErrorField{}
}
bifrostErr.Error.Type = &errorResp.Error.Type
bifrostErr.Error.Message = errorResp.Error.Message
}
return bifrostErr
}

View File

@@ -0,0 +1,96 @@
package anthropic
import (
"testing"
schemas "github.com/maximhq/bifrost/core/schemas"
)
func TestToAnthropicChatCompletionError(t *testing.T) {
strPtr := func(s string) *string { return &s }
tests := []struct {
name string
input *schemas.BifrostError
expectNil bool
expectedType string
}{
{
name: "nil BifrostError returns nil",
input: nil,
expectNil: true,
},
{
name: "nil ErrorField.Type defaults to api_error",
input: &schemas.BifrostError{
Error: &schemas.ErrorField{
Type: nil,
Message: "connection failed",
},
},
expectedType: "api_error",
},
{
name: "empty string Type defaults to api_error",
input: &schemas.BifrostError{
Error: &schemas.ErrorField{
Type: strPtr(""),
Message: "rate limited",
},
},
expectedType: "api_error",
},
{
name: "valid Type is preserved",
input: &schemas.BifrostError{
Error: &schemas.ErrorField{
Type: strPtr("rate_limit_error"),
Message: "rate limited",
},
},
expectedType: "rate_limit_error",
},
{
name: "internal Type is preserved",
input: &schemas.BifrostError{
Error: &schemas.ErrorField{
Type: strPtr("request_cancelled"),
Message: "cancelled",
},
},
expectedType: "request_cancelled",
},
{
name: "nil Error field defaults to api_error",
input: &schemas.BifrostError{
Error: nil,
},
expectedType: "api_error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToAnthropicChatCompletionError(tt.input)
if tt.expectNil {
if result != nil {
t.Fatalf("expected nil, got %+v", result)
}
return
}
if result == nil {
t.Fatal("expected non-nil result")
}
if result.Type != "error" {
t.Errorf("expected top-level Type %q, got %q", "error", result.Type)
}
if result.Error.Type != tt.expectedType {
t.Errorf("expected error Type %q, got %q", tt.expectedType, result.Error.Type)
}
})
}
}

View File

@@ -0,0 +1,71 @@
package anthropic
import (
"time"
"github.com/maximhq/bifrost/core/schemas"
)
// ToAnthropicFileUploadResponse converts a Bifrost file upload response to Anthropic format.
func ToAnthropicFileUploadResponse(resp *schemas.BifrostFileUploadResponse) *AnthropicFileResponse {
return &AnthropicFileResponse{
ID: resp.ID,
Type: resp.Object,
Filename: resp.Filename,
MimeType: "",
SizeBytes: resp.Bytes,
CreatedAt: formatAnthropicFileTimestamp(resp.CreatedAt),
}
}
// ToAnthropicFileListResponse converts a Bifrost file list response to Anthropic format.
func ToAnthropicFileListResponse(resp *schemas.BifrostFileListResponse) *AnthropicFileListResponse {
data := make([]AnthropicFileResponse, len(resp.Data))
for i, file := range resp.Data {
data[i] = AnthropicFileResponse{
ID: file.ID,
Type: file.Object,
Filename: file.Filename,
MimeType: "",
SizeBytes: file.Bytes,
CreatedAt: formatAnthropicFileTimestamp(file.CreatedAt),
}
}
return &AnthropicFileListResponse{
Data: data,
HasMore: resp.HasMore,
}
}
// ToAnthropicFileRetrieveResponse converts a Bifrost file retrieve response to Anthropic format.
func ToAnthropicFileRetrieveResponse(resp *schemas.BifrostFileRetrieveResponse) *AnthropicFileResponse {
return &AnthropicFileResponse{
ID: resp.ID,
Type: resp.Object,
Filename: resp.Filename,
MimeType: "", // Not supported in Bifrost responses
SizeBytes: resp.Bytes,
CreatedAt: formatAnthropicFileTimestamp(resp.CreatedAt),
}
}
// ToAnthropicFileDeleteResponse converts a Bifrost file delete response to Anthropic format.
func ToAnthropicFileDeleteResponse(resp *schemas.BifrostFileDeleteResponse) *AnthropicFileDeleteResponse {
respType := "file"
if resp.Deleted {
respType = "file_deleted"
}
return &AnthropicFileDeleteResponse{
ID: resp.ID,
Type: respType,
}
}
// formatAnthropicFileTimestamp converts Unix timestamp to Anthropic ISO timestamp format.
func formatAnthropicFileTimestamp(unixTime int64) string {
if unixTime == 0 {
return ""
}
return time.Unix(unixTime, 0).UTC().Format(time.RFC3339)
}

View File

@@ -0,0 +1,108 @@
package anthropic
import (
"strings"
"time"
providerUtils "github.com/maximhq/bifrost/core/providers/utils"
"github.com/maximhq/bifrost/core/schemas"
)
func (response *AnthropicListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider, allowedModels schemas.WhiteList, blacklistedModels schemas.BlackList, aliases map[string]string, unfiltered bool) *schemas.BifrostListModelsResponse {
if response == nil {
return nil
}
bifrostResponse := &schemas.BifrostListModelsResponse{
Data: make([]schemas.Model, 0, len(response.Data)),
FirstID: response.FirstID,
LastID: response.LastID,
HasMore: schemas.Ptr(response.HasMore),
}
// Map Anthropic's cursor-based pagination to Bifrost's token-based pagination.
// If there are more results, set next_page_token to last_id for the next request.
if response.HasMore && response.LastID != nil {
bifrostResponse.NextPageToken = *response.LastID
}
pipeline := &providerUtils.ListModelsPipeline{
AllowedModels: allowedModels,
BlacklistedModels: blacklistedModels,
Aliases: aliases,
Unfiltered: unfiltered,
ProviderKey: providerKey,
MatchFns: providerUtils.DefaultMatchFns(),
}
if pipeline.ShouldEarlyExit() {
return bifrostResponse
}
included := make(map[string]bool)
for _, model := range response.Data {
for _, result := range pipeline.FilterModel(model.ID) {
resolvedKey := strings.ToLower(result.ResolvedID)
if included[resolvedKey] {
continue
}
entry := schemas.Model{
ID: string(providerKey) + "/" + result.ResolvedID,
Name: schemas.Ptr(model.DisplayName),
Created: schemas.Ptr(model.CreatedAt.Unix()),
MaxInputTokens: model.MaxInputTokens,
MaxOutputTokens: model.MaxTokens,
ProviderExtra: model.Capabilities,
}
if result.AliasValue != "" {
entry.Alias = schemas.Ptr(result.AliasValue)
}
bifrostResponse.Data = append(bifrostResponse.Data, entry)
included[resolvedKey] = true
}
}
bifrostResponse.Data = append(bifrostResponse.Data,
pipeline.BackfillModels(included)...)
return bifrostResponse
}
func ToAnthropicListModelsResponse(response *schemas.BifrostListModelsResponse) *AnthropicListModelsResponse {
if response == nil {
return nil
}
anthropicResponse := &AnthropicListModelsResponse{
Data: make([]AnthropicModel, 0, len(response.Data)),
}
if response.FirstID != nil {
anthropicResponse.FirstID = response.FirstID
}
if response.LastID != nil {
anthropicResponse.LastID = response.LastID
}
if response.HasMore != nil {
anthropicResponse.HasMore = *response.HasMore
}
for _, model := range response.Data {
_, modelID := schemas.ParseModelString(model.ID, schemas.Anthropic)
anthropicModel := AnthropicModel{
ID: modelID,
Type: "model",
MaxInputTokens: model.MaxInputTokens,
MaxTokens: model.MaxOutputTokens,
Capabilities: model.ProviderExtra,
}
if model.Name != nil {
anthropicModel.DisplayName = *model.Name
}
if model.Created != nil {
anthropicModel.CreatedAt = time.Unix(*model.Created, 0)
}
anthropicResponse.Data = append(anthropicResponse.Data, anthropicModel)
}
return anthropicResponse
}

View File

@@ -0,0 +1,52 @@
package anthropic
import (
"testing"
providerUtils "github.com/maximhq/bifrost/core/providers/utils"
schemas "github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPayloadOrdering_AnthropicMessageRequest(t *testing.T) {
req := &AnthropicMessageRequest{
Model: "claude-sonnet-4-20250514",
MaxTokens: 1024,
Messages: []AnthropicMessage{
{
Role: "user",
Content: AnthropicContent{ContentStr: schemas.Ptr("hello")},
},
},
Temperature: schemas.Ptr(0.7),
Stream: schemas.Ptr(true),
Tools: []AnthropicTool{
{
Name: "get_weather",
Description: schemas.Ptr("Get weather"),
InputSchema: &schemas.ToolFunctionParameters{
Type: "object",
Properties: schemas.NewOrderedMapFromPairs(
schemas.KV("location", map[string]interface{}{"type": "string"}),
),
Required: []string{"location"},
},
},
},
}
result, err := providerUtils.MarshalSorted(req)
require.NoError(t, err)
golden := `{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"temperature":0.7,"stream":true,"tools":[{"name":"get_weather","description":"Get weather","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}]}`
assert.Equal(t, golden, string(result), "payload field ordering changed — if intentional, update the golden string")
// Determinism: 100 iterations must produce identical bytes
for i := 0; i < 100; i++ {
iter, err := providerUtils.MarshalSorted(req)
require.NoError(t, err)
assert.Equal(t, string(result), string(iter), "non-deterministic marshal output on iteration %d", i)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
package anthropic
import (
"fmt"
"strings"
providerUtils "github.com/maximhq/bifrost/core/providers/utils"
"github.com/maximhq/bifrost/core/schemas"
)
// ToAnthropicTextCompletionRequest converts a Bifrost text completion request to Anthropic format
func ToAnthropicTextCompletionRequest(bifrostReq *schemas.BifrostTextCompletionRequest) *AnthropicTextRequest {
if bifrostReq == nil {
return nil
}
prompt := ""
if bifrostReq.Input.PromptStr != nil {
prompt = *bifrostReq.Input.PromptStr
} else if len(bifrostReq.Input.PromptArray) > 0 {
prompt = strings.Join(bifrostReq.Input.PromptArray, "\n\n")
}
anthropicReq := &AnthropicTextRequest{
Model: bifrostReq.Model,
Prompt: fmt.Sprintf("\n\nHuman: %s\n\nAssistant:", prompt),
MaxTokensToSample: providerUtils.GetMaxOutputTokensOrDefault(bifrostReq.Model, AnthropicDefaultMaxTokens),
}
// Convert parameters
if bifrostReq.Params != nil {
if bifrostReq.Params.MaxTokens != nil {
anthropicReq.MaxTokensToSample = *bifrostReq.Params.MaxTokens
}
anthropicReq.Temperature = bifrostReq.Params.Temperature
anthropicReq.TopP = bifrostReq.Params.TopP
anthropicReq.StopSequences = bifrostReq.Params.Stop
if bifrostReq.Params.ExtraParams != nil {
anthropicReq.ExtraParams = bifrostReq.Params.ExtraParams
if topK, ok := schemas.SafeExtractIntPointer(bifrostReq.Params.ExtraParams["top_k"]); ok {
delete(anthropicReq.ExtraParams, "top_k")
anthropicReq.TopK = topK
}
}
}
return anthropicReq
}
// ToBifrostTextCompletionRequest converts an Anthropic text request back to Bifrost format
func (req *AnthropicTextRequest) ToBifrostTextCompletionRequest(ctx *schemas.BifrostContext) *schemas.BifrostTextCompletionRequest {
if req == nil {
return nil
}
provider, model := schemas.ParseModelString(req.Model, providerUtils.CheckAndSetDefaultProvider(ctx, schemas.Anthropic))
bifrostReq := &schemas.BifrostTextCompletionRequest{
Provider: provider,
Model: model,
Input: &schemas.TextCompletionInput{
PromptStr: &req.Prompt,
},
Params: &schemas.TextCompletionParameters{
MaxTokens: &req.MaxTokensToSample,
Temperature: req.Temperature,
TopP: req.TopP,
Stop: req.StopSequences,
},
Fallbacks: schemas.ParseFallbacks(req.Fallbacks),
}
// Add extra params if present
if req.TopK != nil {
bifrostReq.Params.ExtraParams = map[string]interface{}{
"top_k": *req.TopK,
}
}
return bifrostReq
}
// ToBifrostTextCompletionResponse converts an Anthropic text response back to Bifrost format
func (response *AnthropicTextResponse) ToBifrostTextCompletionResponse() *schemas.BifrostTextCompletionResponse {
if response == nil {
return nil
}
return &schemas.BifrostTextCompletionResponse{
ID: response.ID,
Object: "text_completion",
Choices: []schemas.BifrostResponseChoice{
{
Index: 0,
TextCompletionResponseChoice: &schemas.TextCompletionResponseChoice{
Text: &response.Completion,
},
},
},
Usage: &schemas.BifrostLLMUsage{
PromptTokens: response.Usage.InputTokens,
CompletionTokens: response.Usage.OutputTokens,
TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens,
},
Model: response.Model,
}
}
// ToAnthropicTextCompletionResponse converts a BifrostResponse back to Anthropic text completion format
func ToAnthropicTextCompletionResponse(bifrostResp *schemas.BifrostTextCompletionResponse) *AnthropicTextResponse {
if bifrostResp == nil {
return nil
}
anthropicResp := &AnthropicTextResponse{
ID: bifrostResp.ID,
Type: "completion",
Model: bifrostResp.Model,
}
// Convert choices to completion text
if len(bifrostResp.Choices) > 0 {
choice := bifrostResp.Choices[0] // Anthropic text API typically returns one choice
if choice.TextCompletionResponseChoice != nil && choice.TextCompletionResponseChoice.Text != nil {
anthropicResp.Completion = *choice.TextCompletionResponseChoice.Text
}
}
// Convert usage information
if bifrostResp.Usage != nil {
anthropicResp.Usage.InputTokens = bifrostResp.Usage.PromptTokens
anthropicResp.Usage.OutputTokens = bifrostResp.Usage.CompletionTokens
}
return anthropicResp
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
package anthropic
import (
"testing"
"github.com/maximhq/bifrost/core/schemas"
)
// TestValidateChatToolsForProvider locks in the partition:
// function/custom tools always survive; server tools survive only when the
// target provider's ProviderFeatures flag is true for that tool type.
func TestValidateChatToolsForProvider(t *testing.T) {
fnTool := schemas.ChatTool{
Type: schemas.ChatToolTypeFunction,
Function: &schemas.ChatToolFunction{Name: "get_weather"},
}
serverTool := func(tpe, name string) schemas.ChatTool {
return schemas.ChatTool{Type: schemas.ChatToolType(tpe), Name: name}
}
cases := []struct {
name string
provider schemas.ModelProvider
input []schemas.ChatTool
wantKeep int
wantDropped []string
assertNotes string
}{
{
name: "function tools always survive on any provider",
provider: schemas.Bedrock,
input: []schemas.ChatTool{fnTool, fnTool},
wantKeep: 2,
},
{
name: "bedrock drops web_search",
provider: schemas.Bedrock,
input: []schemas.ChatTool{serverTool("web_search_20260209", "web_search")},
wantKeep: 0,
wantDropped: []string{"web_search_20260209"},
assertNotes: "Bedrock has WebSearch=false per Table 20 (AWS user guide beta-header list + Anthropic overview)",
},
{
name: "bedrock drops web_fetch + code_execution + mcp_toolset",
provider: schemas.Bedrock,
input: []schemas.ChatTool{
serverTool("web_fetch_20260309", "web_fetch"),
serverTool("code_execution_20250825", "code_execution"),
serverTool("mcp_toolset", "notion"),
},
wantKeep: 0,
wantDropped: []string{"web_fetch_20260309", "code_execution_20250825", "mcp_toolset"},
},
{
name: "bedrock keeps computer/bash/memory/text_editor/tool_search",
provider: schemas.Bedrock,
input: []schemas.ChatTool{
serverTool("computer_20251124", "computer"),
serverTool("bash_20250124", "bash"),
serverTool("memory_20250818", "memory"),
serverTool("text_editor_20250728", "str_replace_based_edit_tool"),
serverTool("tool_search_tool_bm25", "tool_search_tool_bm25"),
},
wantKeep: 5,
},
{
name: "bedrock partial drop mixes function + server tools",
provider: schemas.Bedrock,
input: []schemas.ChatTool{
fnTool,
serverTool("web_search_20260209", "web_search"),
serverTool("bash_20250124", "bash"),
},
wantKeep: 2, // fnTool + bash
wantDropped: []string{"web_search_20260209"},
},
{
name: "vertex drops web_fetch",
provider: schemas.Vertex,
input: []schemas.ChatTool{serverTool("web_fetch_20260309", "web_fetch")},
wantKeep: 0,
wantDropped: []string{"web_fetch_20260309"},
assertNotes: "Vertex has WebFetch=false per Table 20",
},
{
name: "vertex drops mcp_toolset",
provider: schemas.Vertex,
input: []schemas.ChatTool{serverTool("mcp_toolset", "notion")},
wantKeep: 0,
wantDropped: []string{"mcp_toolset"},
assertNotes: "Vertex has MCP=false per MCP-excl (explicit exclusion in Anthropic docs)",
},
{
name: "anthropic keeps everything",
provider: schemas.Anthropic,
input: []schemas.ChatTool{
serverTool("web_search_20260209", "web_search"),
serverTool("web_fetch_20260309", "web_fetch"),
serverTool("code_execution_20250825", "code_execution"),
serverTool("mcp_toolset", "x"),
serverTool("computer_20251124", "computer"),
},
wantKeep: 5,
},
{
name: "unknown provider keeps everything (forward-compat)",
provider: schemas.ModelProvider("custom-new-provider"),
input: []schemas.ChatTool{serverTool("web_search_20260209", "web_search")},
wantKeep: 1,
},
{
name: "unknown tool type on known provider is kept (forward-compat)",
provider: schemas.Bedrock,
input: []schemas.ChatTool{serverTool("future_tool_20270101", "future")},
wantKeep: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
keep, dropped := ValidateChatToolsForProvider(tc.input, tc.provider)
if len(keep) != tc.wantKeep {
t.Errorf("keep count: got %d, want %d (%s)", len(keep), tc.wantKeep, tc.assertNotes)
}
if len(dropped) != len(tc.wantDropped) {
t.Errorf("dropped count: got %v, want %v", dropped, tc.wantDropped)
}
for i, d := range tc.wantDropped {
if i >= len(dropped) {
break
}
if dropped[i] != d {
t.Errorf("dropped[%d]: got %q, want %q", i, dropped[i], d)
}
}
})
}
}

View File

@@ -0,0 +1,270 @@
package anthropic
import (
"encoding/json"
"testing"
"github.com/maximhq/bifrost/core/schemas"
)
// TestWebSearch_OutputItemAdded_StoresID verifies that a WebSearch function_call
// output_item.added event stores the item ID in the per-request stream state so that
// subsequent argument deltas can be skipped.
func TestWebSearch_OutputItemAdded_StoresID(t *testing.T) {
t.Parallel()
const itemID = "toolu_ws_storesid_test"
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemAdded,
OutputIndex: schemas.Ptr(0),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr(itemID),
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr(itemID),
Name: schemas.Ptr("WebSearch"),
Arguments: schemas.Ptr(""),
},
},
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
// Should emit content_block_start
if len(events) == 0 {
t.Fatal("expected at least one event")
}
if events[0].Type != AnthropicStreamEventTypeContentBlockStart {
t.Errorf("event[0].Type = %v, want content_block_start", events[0].Type)
}
if events[0].ContentBlock == nil || events[0].ContentBlock.Input == nil {
t.Fatal("expected ContentBlock with Input")
}
if string(events[0].ContentBlock.Input) != "{}" {
t.Errorf("ContentBlock.Input = %s, want {}", events[0].ContentBlock.Input)
}
// ID must now be tracked in per-request state
state := getOrCreateAnthropicToResponsesStreamState(ctx)
if !state.webSearchItemIDs[itemID] {
t.Error("expected item ID to be stored in per-request stream state after output_item.added")
}
}
// TestWebSearch_FunctionCallArgumentsDelta_Skipped verifies that argument deltas
// for a tracked WebSearch item are skipped (returning nil) regardless of the
// user agent — the fix for the original bug where non-Claude Code clients lost
// the query.
func TestWebSearch_FunctionCallArgumentsDelta_Skipped(t *testing.T) {
t.Parallel()
const itemID = "toolu_ws_skip_test"
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
// Pre-seed per-request state as if output_item.added already fired
state := getOrCreateAnthropicToResponsesStreamState(ctx)
state.webSearchItemIDs = map[string]bool{itemID: true}
partial := `{"query": "world news"`
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta,
OutputIndex: schemas.Ptr(0),
ItemID: schemas.Ptr(itemID),
Delta: &partial,
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
if len(events) != 0 {
t.Errorf("expected deltas to be skipped (0 events), got %d", len(events))
}
}
// TestWebSearch_OutputItemDone_GeneratesSyntheticDeltas verifies that when
// output_item.done fires for a tracked WebSearch item, synthetic input_json_delta
// events carrying the full query are emitted, followed by content_block_stop.
// This applies for ALL clients regardless of user agent.
func TestWebSearch_OutputItemDone_GeneratesSyntheticDeltas(t *testing.T) {
t.Parallel()
const itemID = "toolu_ws_synth_test"
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
// Pre-seed per-request state as if output_item.added already fired
state := getOrCreateAnthropicToResponsesStreamState(ctx)
state.webSearchItemIDs = map[string]bool{itemID: true}
query := `{"query":"world news today"}`
bifrostResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemDone,
OutputIndex: schemas.Ptr(1),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr(itemID),
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr(itemID),
Name: schemas.Ptr("WebSearch"),
Arguments: &query,
},
},
}
events := ToAnthropicResponsesStreamResponse(ctx, bifrostResp)
// Must have at least one input_json_delta and a final content_block_stop
if len(events) < 2 {
t.Fatalf("expected at least 2 events (deltas + stop), got %d", len(events))
}
// All events except last must be input_json_delta
for i, ev := range events[:len(events)-1] {
if ev.Type != AnthropicStreamEventTypeContentBlockDelta {
t.Errorf("event[%d].Type = %v, want content_block_delta", i, ev.Type)
continue
}
if ev.Delta == nil || ev.Delta.Type != AnthropicStreamDeltaTypeInputJSON {
t.Errorf("event[%d].Delta.Type = %v, want input_json", i, ev.Delta)
}
}
// Last event must be content_block_stop
last := events[len(events)-1]
if last.Type != AnthropicStreamEventTypeContentBlockStop {
t.Errorf("last event.Type = %v, want content_block_stop", last.Type)
}
// Reconstruct the accumulated JSON from the deltas
var accumulated string
for _, ev := range events[:len(events)-1] {
if ev.Delta != nil && ev.Delta.PartialJSON != nil {
accumulated += *ev.Delta.PartialJSON
}
}
var got map[string]interface{}
if err := json.Unmarshal([]byte(accumulated), &got); err != nil {
t.Fatalf("accumulated JSON invalid: %v — got %q", err, accumulated)
}
if got["query"] != "world news today" {
t.Errorf("query = %v, want %q", got["query"], "world news today")
}
// ID must have been cleaned up from per-request state
if state.webSearchItemIDs[itemID] {
t.Error("expected item ID to be removed from per-request stream state after output_item.done")
}
}
// TestWebSearch_FullFlow_AnyUserAgent is the regression test for the original bug.
// It simulates the complete streaming sequence:
//
// output_item.added → FunctionCallArgumentsDelta (×N) → output_item.done
//
// and verifies that the client-facing Anthropic stream contains proper
// input_json_delta events with the query, regardless of user agent.
func TestWebSearch_FullFlow_AnyUserAgent(t *testing.T) {
t.Parallel()
const itemID = "toolu_ws_fullflow_test"
ctx, cancel := schemas.NewBifrostContextWithCancel(nil)
defer cancel()
var allEvents []*AnthropicStreamEvent
// Step 1: output_item.added
addedResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemAdded,
OutputIndex: schemas.Ptr(0),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr(itemID),
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr(itemID),
Name: schemas.Ptr("WebSearch"),
Arguments: schemas.Ptr(""),
},
},
}
allEvents = append(allEvents, ToAnthropicResponsesStreamResponse(ctx, addedResp)...)
// Step 2: FunctionCallArgumentsDelta events (should be skipped)
for _, partial := range []string{`{"query": "`, `latest AI`, `news"}`} {
p := partial
deltaResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeFunctionCallArgumentsDelta,
OutputIndex: schemas.Ptr(0),
ItemID: schemas.Ptr(itemID),
Delta: &p,
}
allEvents = append(allEvents, ToAnthropicResponsesStreamResponse(ctx, deltaResp)...)
}
// Step 3: output_item.done with full accumulated arguments
fullArgs := `{"query":"latest AI news"}`
doneResp := &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemDone,
OutputIndex: schemas.Ptr(0),
Item: &schemas.ResponsesMessage{
ID: schemas.Ptr(itemID),
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{
CallID: schemas.Ptr(itemID),
Name: schemas.Ptr("WebSearch"),
Arguments: &fullArgs,
},
},
}
allEvents = append(allEvents, ToAnthropicResponsesStreamResponse(ctx, doneResp)...)
// Verify the sequence:
// [0] content_block_start (input:{})
// [1..N-1] input_json_delta events
// [N] content_block_stop
if len(allEvents) < 3 {
t.Fatalf("expected at least 3 events, got %d: %v", len(allEvents), allEvents)
}
// First event: content_block_start with empty input
if allEvents[0].Type != AnthropicStreamEventTypeContentBlockStart {
t.Errorf("allEvents[0].Type = %v, want content_block_start", allEvents[0].Type)
}
// Last event: content_block_stop
last := allEvents[len(allEvents)-1]
if last.Type != AnthropicStreamEventTypeContentBlockStop {
t.Errorf("last event.Type = %v, want content_block_stop", last.Type)
}
// Middle events: all input_json_delta
for i, ev := range allEvents[1 : len(allEvents)-1] {
if ev.Type != AnthropicStreamEventTypeContentBlockDelta {
t.Errorf("allEvents[%d].Type = %v, want content_block_delta", i+1, ev.Type)
}
if ev.Delta == nil || ev.Delta.Type != AnthropicStreamDeltaTypeInputJSON {
t.Errorf("allEvents[%d].Delta.Type = %v, want input_json", i+1, ev.Delta)
}
}
// Reconstruct query from synthetic deltas
var accumulated string
for _, ev := range allEvents[1 : len(allEvents)-1] {
if ev.Delta != nil && ev.Delta.PartialJSON != nil {
accumulated += *ev.Delta.PartialJSON
}
}
var got map[string]interface{}
if err := json.Unmarshal([]byte(accumulated), &got); err != nil {
t.Fatalf("reconstructed JSON is invalid: %v — got %q", err, accumulated)
}
if got["query"] != "latest AI news" {
t.Errorf("reconstructed query = %v, want %q", got["query"], "latest AI news")
}
}