first commit
This commit is contained in:
2740
core/providers/anthropic/anthropic.go
Normal file
2740
core/providers/anthropic/anthropic.go
Normal file
File diff suppressed because it is too large
Load Diff
84
core/providers/anthropic/anthropic_test.go
Normal file
84
core/providers/anthropic/anthropic_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
359
core/providers/anthropic/batch.go
Normal file
359
core/providers/anthropic/batch.go
Normal 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)
|
||||
}
|
||||
1388
core/providers/anthropic/chat.go
Normal file
1388
core/providers/anthropic/chat.go
Normal file
File diff suppressed because it is too large
Load Diff
366
core/providers/anthropic/chat_server_tools_test.go
Normal file
366
core/providers/anthropic/chat_server_tools_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
752
core/providers/anthropic/chat_test.go
Normal file
752
core/providers/anthropic/chat_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
703
core/providers/anthropic/compaction_test.go
Normal file
703
core/providers/anthropic/compaction_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
34
core/providers/anthropic/count_tokens.go
Normal file
34
core/providers/anthropic/count_tokens.go
Normal 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,
|
||||
}
|
||||
}
|
||||
68
core/providers/anthropic/errors.go
Normal file
68
core/providers/anthropic/errors.go
Normal 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
|
||||
}
|
||||
96
core/providers/anthropic/errors_test.go
Normal file
96
core/providers/anthropic/errors_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
71
core/providers/anthropic/files.go
Normal file
71
core/providers/anthropic/files.go
Normal 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)
|
||||
}
|
||||
108
core/providers/anthropic/models.go
Normal file
108
core/providers/anthropic/models.go
Normal 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
|
||||
}
|
||||
52
core/providers/anthropic/payload_ordering_test.go
Normal file
52
core/providers/anthropic/payload_ordering_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
5900
core/providers/anthropic/responses.go
Normal file
5900
core/providers/anthropic/responses.go
Normal file
File diff suppressed because it is too large
Load Diff
137
core/providers/anthropic/text.go
Normal file
137
core/providers/anthropic/text.go
Normal 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
|
||||
}
|
||||
1635
core/providers/anthropic/types.go
Normal file
1635
core/providers/anthropic/types.go
Normal file
File diff suppressed because it is too large
Load Diff
2712
core/providers/anthropic/utils.go
Normal file
2712
core/providers/anthropic/utils.go
Normal file
File diff suppressed because it is too large
Load Diff
1984
core/providers/anthropic/utils_test.go
Normal file
1984
core/providers/anthropic/utils_test.go
Normal file
File diff suppressed because it is too large
Load Diff
138
core/providers/anthropic/validate_chat_tools_test.go
Normal file
138
core/providers/anthropic/validate_chat_tools_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
270
core/providers/anthropic/websearch_test.go
Normal file
270
core/providers/anthropic/websearch_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user