mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Moves the shared aibridge mock test helpers (`MockUpstream`, `MockServerProxier`, `StubToolCaller`, and the `NewFixtureResponse`/`NewFixtureToolResponse` constructors) out of `aibridge/internal/integrationtest` into `aibridge/internal/testutil`, and exports them. This lets the per-interceptor test packages (`messages`, `chatcompletions`, `responses`) reuse one set of mocks instead of each redefining its own. The symbols are exported and call sites updated, with no behavior change. Closes: https://linear.app/codercom/issue/AIGOV-397/move-mockserverproxier-into-a-shared-testutil-package > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
2381 lines
86 KiB
Go
2381 lines
86 KiB
Go
package integrationtest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go"
|
|
"github.com/anthropics/anthropic-sdk-go/packages/ssestream"
|
|
"github.com/anthropics/anthropic-sdk-go/shared/constant"
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
v4signer "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
|
"github.com/google/uuid"
|
|
"github.com/openai/openai-go/v3"
|
|
oaissestream "github.com/openai/openai-go/v3/packages/ssestream"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
"go.uber.org/goleak"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/aibridge"
|
|
"github.com/coder/coder/v2/aibridge/config"
|
|
"github.com/coder/coder/v2/aibridge/fixtures"
|
|
"github.com/coder/coder/v2/aibridge/intercept"
|
|
"github.com/coder/coder/v2/aibridge/internal/testutil"
|
|
"github.com/coder/coder/v2/aibridge/mcp"
|
|
"github.com/coder/coder/v2/aibridge/provider"
|
|
"github.com/coder/coder/v2/aibridge/recorder"
|
|
"github.com/coder/coder/v2/aibridge/utils"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
goleak.VerifyTestMain(m)
|
|
}
|
|
|
|
func TestAnthropicMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("single builtin tool", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
streaming bool
|
|
expectedInputTokens int
|
|
expectedOutputTokens int
|
|
expectedCacheReadInputTokens int
|
|
expectedCacheWriteInputTokens int
|
|
expectedToolCallID string
|
|
}{
|
|
{
|
|
name: "streaming",
|
|
streaming: true,
|
|
expectedInputTokens: 2,
|
|
expectedOutputTokens: 66,
|
|
expectedCacheReadInputTokens: 13993,
|
|
expectedCacheWriteInputTokens: 22,
|
|
expectedToolCallID: "toolu_01RX68weRSquLx6HUTj65iBo",
|
|
},
|
|
{
|
|
name: "non-streaming",
|
|
streaming: false,
|
|
expectedInputTokens: 5,
|
|
expectedOutputTokens: 84,
|
|
expectedCacheReadInputTokens: 23490,
|
|
expectedCacheWriteInputTokens: 0,
|
|
expectedToolCallID: "toolu_01AusGgY5aKFhzWrFBv9JfHq",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
// Make API call to aibridge for Anthropic /v1/messages
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Response-specific checks.
|
|
if tc.streaming {
|
|
sp := aibridge.NewSSEParser()
|
|
require.NoError(t, sp.Parse(resp.Body))
|
|
|
|
// Ensure the message starts and completes, at a minimum.
|
|
assert.Contains(t, sp.AllEvents(), "message_start")
|
|
assert.Contains(t, sp.AllEvents(), "message_stop")
|
|
}
|
|
|
|
expectedTokenRecordings := 1
|
|
if tc.streaming {
|
|
// One for message_start, one for message_delta.
|
|
expectedTokenRecordings = 2
|
|
}
|
|
tokenUsages := bridgeServer.Recorder.RecordedTokenUsages()
|
|
require.Len(t, tokenUsages, expectedTokenRecordings)
|
|
|
|
assert.EqualValues(t, tc.expectedInputTokens, bridgeServer.Recorder.TotalInputTokens(), "input tokens miscalculated")
|
|
assert.EqualValues(t, tc.expectedOutputTokens, bridgeServer.Recorder.TotalOutputTokens(), "output tokens miscalculated")
|
|
assert.EqualValues(t, tc.expectedCacheReadInputTokens, bridgeServer.Recorder.TotalCacheReadInputTokens(), "cache read input tokens miscalculated")
|
|
assert.EqualValues(t, tc.expectedCacheWriteInputTokens, bridgeServer.Recorder.TotalCacheWriteInputTokens(), "cache write input tokens miscalculated")
|
|
|
|
toolUsages := bridgeServer.Recorder.RecordedToolUsages()
|
|
require.Len(t, toolUsages, 1)
|
|
assert.Equal(t, "Read", toolUsages[0].Tool)
|
|
assert.Equal(t, tc.expectedToolCallID, toolUsages[0].ToolCallID)
|
|
require.IsType(t, json.RawMessage{}, toolUsages[0].Args)
|
|
var args map[string]any
|
|
require.NoError(t, json.Unmarshal(toolUsages[0].Args.(json.RawMessage), &args))
|
|
require.Contains(t, args, "file_path")
|
|
assert.Equal(t, "/tmp/blah/foo", args["file_path"])
|
|
|
|
promptUsages := bridgeServer.Recorder.RecordedPromptUsages()
|
|
require.Len(t, promptUsages, 1)
|
|
assert.Equal(t, "read the foo file", promptUsages[0].Prompt)
|
|
|
|
// Verify PRM attribution is NOT present on non-Bedrock Anthropic requests.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
ua := received[0].Header.Get("User-Agent")
|
|
assert.NotContains(t, ua, "sdk-ua-app-id",
|
|
"PRM attribution should not be present on non-Bedrock requests")
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
|
|
// When the upstream's first response is an injected tool call with no
|
|
// text preamble and the next upstream call fails, the response must
|
|
// remain a well-formed SSE stream. The upstream error is relayed as a
|
|
// well-formed SSE event.
|
|
t.Run("streaming injected tool call no preamble with upstream 500", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixtures.AntSingleInjectedToolNoPreamble)
|
|
upstream := testutil.NewMockUpstream(ctx, t,
|
|
testutil.NewFixtureResponse(fix),
|
|
testutil.NewErrorResponse(http.StatusInternalServerError, ""),
|
|
)
|
|
|
|
mockMCP := setupMCPForTest(t, defaultTracer)
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, withMCP(mockMCP))
|
|
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", true)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
bodyStr := string(body)
|
|
|
|
// Once iteration 1 succeeded the response is committed as SSE,
|
|
// so the iteration-2 error MUST be an SSE event and not a raw JSON body.
|
|
require.Contains(t, bodyStr, "event: error",
|
|
"iteration-2 error must be relayed as an SSE event")
|
|
|
|
// Tool was invoked despite the iteration-2 failure.
|
|
require.Len(t, mockMCP.getCallsByTool(mockToolName), 1,
|
|
"expected MCP tool to be invoked exactly once")
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
|
|
func TestAnthropicMessagesModelThoughts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
streaming bool
|
|
fixture []byte
|
|
expectedThoughts []recorder.ModelThoughtRecord // nil means no model thoughts expected
|
|
}{
|
|
{
|
|
name: "single thinking block/streaming",
|
|
streaming: true,
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read", recorder.ThoughtSourceThinking)},
|
|
},
|
|
{
|
|
name: "single thinking block/blocking",
|
|
streaming: false,
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read", recorder.ThoughtSourceThinking)},
|
|
},
|
|
{
|
|
name: "multiple thinking blocks/streaming",
|
|
streaming: true,
|
|
fixture: fixtures.AntMultiThinkingBuiltinTool,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{
|
|
newModelThought("The user wants me to read", recorder.ThoughtSourceThinking),
|
|
newModelThought("I should use the Read tool", recorder.ThoughtSourceThinking),
|
|
},
|
|
},
|
|
{
|
|
name: "multiple thinking blocks/blocking",
|
|
streaming: false,
|
|
fixture: fixtures.AntMultiThinkingBuiltinTool,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{
|
|
newModelThought("The user wants me to read", recorder.ThoughtSourceThinking),
|
|
newModelThought("I should use the Read tool", recorder.ThoughtSourceThinking),
|
|
},
|
|
},
|
|
{
|
|
name: "parallel tool calls/streaming",
|
|
streaming: true,
|
|
fixture: fixtures.AntSingleBuiltinToolParallel,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read two files", recorder.ThoughtSourceThinking)},
|
|
},
|
|
{
|
|
name: "parallel tool calls/blocking",
|
|
streaming: false,
|
|
fixture: fixtures.AntSingleBuiltinToolParallel,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read two files", recorder.ThoughtSourceThinking)},
|
|
},
|
|
{
|
|
name: "thoughts without tool calls/streaming",
|
|
streaming: true,
|
|
fixture: fixtures.AntSimple,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("This is a classic philosophical question about medieval scholasticism", recorder.ThoughtSourceThinking)},
|
|
},
|
|
{
|
|
name: "thoughts without tool calls/blocking",
|
|
streaming: false,
|
|
fixture: fixtures.AntSimple,
|
|
expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("This is a classic philosophical question about medieval scholasticism", recorder.ThoughtSourceThinking)},
|
|
},
|
|
{
|
|
name: "no thoughts captured",
|
|
streaming: false,
|
|
fixture: fixtures.AntSingleInjectedTool,
|
|
expectedThoughts: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
if tc.streaming {
|
|
sp := aibridge.NewSSEParser()
|
|
require.NoError(t, sp.Parse(resp.Body))
|
|
assert.Contains(t, sp.AllEvents(), "message_start")
|
|
assert.Contains(t, sp.AllEvents(), "message_stop")
|
|
}
|
|
|
|
bridgeServer.Recorder.VerifyModelThoughtsRecorded(t, tc.expectedThoughts)
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAWSBedrockIntegration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("invalid config", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Invalid bedrock config - missing region & base url
|
|
bedrockCfg := &config.AWSBedrock{
|
|
Region: "",
|
|
AccessKey: "test-key",
|
|
AccessKeySecret: "test-secret",
|
|
Model: "test-model",
|
|
SmallFastModel: "test-haiku",
|
|
}
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, "http://unused",
|
|
withCustomProvider(provider.NewAnthropic(anthropicCfg("http://unused", apiKey), bedrockCfg)),
|
|
)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, fixtures.Request(t, fixtures.AntSingleBuiltinTool))
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(body), "create anthropic client")
|
|
require.Contains(t, string(body), "region or base url required")
|
|
})
|
|
|
|
t.Run("/v1/messages", func(t *testing.T) {
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("%s/streaming=%v", t.Name(), streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
// We define region here to validate that with Region & BaseURL defined, the latter takes precedence.
|
|
bedrockCfg := &config.AWSBedrock{
|
|
Region: "us-west-2",
|
|
AccessKey: "test-access-key",
|
|
AccessKeySecret: "test-secret-key",
|
|
Model: "danthropic", // This model should override the request's given one.
|
|
SmallFastModel: "danthropic-mini", // Unused but needed for validation.
|
|
BaseURL: upstream.URL, // Use the mock server.
|
|
}
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withCustomProvider(provider.NewAnthropic(anthropicCfg(upstream.URL, apiKey), bedrockCfg)),
|
|
)
|
|
|
|
// Make API call to aibridge for Anthropic /v1/messages, which will be routed via AWS Bedrock.
|
|
// We override the AWS Bedrock client to route requests through our mock server.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// For streaming responses, consume the body to allow the stream to complete.
|
|
if streaming {
|
|
// Read the streaming response.
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Verify that Bedrock-specific model name was used in the request to the mock server
|
|
// and the interception data.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
|
|
// The Anthropic SDK's Bedrock middleware extracts "model" and "stream"
|
|
// from the JSON body and encodes them in the URL path.
|
|
// See: https://github.com/anthropics/anthropic-sdk-go/blob/4d669338f2041f3c60640b6dd317c4895dc71cd4/bedrock/bedrock.go#L247-L248
|
|
pathParts := strings.Split(received[0].Path, "/")
|
|
require.True(t, len(pathParts) >= 3 && pathParts[1] == "model", "unexpected path: %s", received[0].Path)
|
|
require.Equal(t, bedrockCfg.Model, pathParts[2])
|
|
require.False(t, gjson.GetBytes(received[0].Body, "model").Exists(), "model should be stripped from body")
|
|
require.False(t, gjson.GetBytes(received[0].Body, "stream").Exists(), "stream should be stripped from body")
|
|
|
|
// Verify PRM attribution is appended to the User-Agent header.
|
|
ua := received[0].Header.Get("User-Agent")
|
|
require.Contains(t, ua, "sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24",
|
|
"expected AWS PRM attribution in User-Agent header")
|
|
|
|
interceptions := bridgeServer.Recorder.RecordedInterceptions()
|
|
require.Len(t, interceptions, 1)
|
|
require.Equal(t, interceptions[0].Model, bedrockCfg.Model)
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Tests that Bedrock-incompatible fields are stripped and adaptive thinking
|
|
// is handled correctly per model. Different Bedrock model names trigger
|
|
// different behavior for beta flag filtering and field stripping.
|
|
t.Run("unsupported fields removed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// All fields in the fixture request that Bedrock may strip. Fields
|
|
// listed in a test case's expectKeptFields survive; all others must
|
|
// be absent from the forwarded body.
|
|
strippableFields := []string{
|
|
"metadata", "service_tier", "container", "inference_geo", // always stripped
|
|
"output_config", "context_management", // stripped unless their beta flag survives
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
model string
|
|
smallFastModel string
|
|
expectThinkingType string
|
|
expectBudgetTokens int64 // 0 means budget_tokens should not be present
|
|
expectKeptFields []string // fields from strippableFields expected to survive
|
|
expectedBetaFlags []string // values expected in the anthropic_beta array in the forwarded body
|
|
}{
|
|
// "beddel" matches no model prefix, so adaptive thinking is converted
|
|
// to enabled with budget, and all model-gated beta flags are stripped.
|
|
{
|
|
name: "beddel",
|
|
model: "beddel",
|
|
smallFastModel: "modrock",
|
|
expectThinkingType: "enabled",
|
|
expectBudgetTokens: 16000, // 32000 * 0.5 (medium effort)
|
|
expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"},
|
|
},
|
|
// Opus 4.5 supports the effort beta, so output_config is kept.
|
|
{
|
|
name: "opus-4.5",
|
|
model: "anthropic.claude-opus-4-5-20250514-v1:0",
|
|
smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0",
|
|
expectThinkingType: "enabled",
|
|
expectBudgetTokens: 16000,
|
|
expectKeptFields: []string{"output_config"},
|
|
expectedBetaFlags: []string{"interleaved-thinking-2025-05-14", "effort-2025-11-24"},
|
|
},
|
|
// Sonnet 4.5 supports context-management beta, so context_management is kept.
|
|
{
|
|
name: "sonnet-4.5",
|
|
model: "anthropic.claude-sonnet-4-5-20241022-v2:0",
|
|
smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0",
|
|
expectThinkingType: "enabled",
|
|
expectBudgetTokens: 16000,
|
|
expectKeptFields: []string{"context_management"},
|
|
expectedBetaFlags: []string{"interleaved-thinking-2025-05-14", "context-management-2025-06-27"},
|
|
},
|
|
// Opus 4.6 supports adaptive thinking natively, so it is kept as-is.
|
|
// Neither effort nor context-management betas apply to this model.
|
|
{
|
|
name: "opus-4.6",
|
|
model: "anthropic.claude-opus-4-6-20260619-v1:0",
|
|
smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0",
|
|
expectThinkingType: "adaptive",
|
|
expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixtures.AntSimpleBedrock)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bCfg := &config.AWSBedrock{
|
|
Region: "us-west-2",
|
|
AccessKey: "test-access-key",
|
|
AccessKeySecret: "test-secret-key",
|
|
Model: tc.model,
|
|
SmallFastModel: tc.smallFastModel,
|
|
BaseURL: upstream.URL,
|
|
}
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withCustomProvider(provider.NewAnthropic(anthropicCfg(upstream.URL, apiKey), bCfg)),
|
|
)
|
|
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming)
|
|
require.NoError(t, err)
|
|
|
|
// Send with Anthropic-Beta header containing flags that should be filtered.
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, http.Header{
|
|
"Anthropic-Beta": {"interleaved-thinking-2025-05-14,effort-2025-11-24,context-management-2025-06-27,prompt-caching-scope-2026-01-05"},
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
body := received[0].Body
|
|
|
|
// Verify strippable fields: kept only if listed in expectKeptFields.
|
|
for _, field := range strippableFields {
|
|
assert.Equal(t, slices.Contains(tc.expectKeptFields, field), gjson.GetBytes(body, field).Exists(), "field %s", field)
|
|
}
|
|
|
|
// Verify thinking behavior.
|
|
assert.Equal(t, tc.expectThinkingType, gjson.GetBytes(body, "thinking.type").String(), "thinking type mismatch")
|
|
if tc.expectBudgetTokens > 0 {
|
|
assert.Equal(t, tc.expectBudgetTokens, gjson.GetBytes(body, "thinking.budget_tokens").Int(), "budget_tokens mismatch")
|
|
} else {
|
|
assert.False(t, gjson.GetBytes(body, "thinking.budget_tokens").Exists(), "budget_tokens should not be present")
|
|
}
|
|
|
|
// The Bedrock SDK middleware moves Anthropic-Beta from the header
|
|
// into the body as "anthropic_beta".
|
|
betaArr := gjson.GetBytes(body, "anthropic_beta").Array()
|
|
var gotFlags []string
|
|
for _, v := range betaArr {
|
|
gotFlags = append(gotFlags, v.String())
|
|
}
|
|
assert.Equal(t, tc.expectedBetaFlags, gotFlags, "beta flags mismatch")
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// SigV4 signs all headers on the outbound Bedrock request. If any header
|
|
// is modified in transit (e.g. an egress proxy appending to X-Forwarded-For),
|
|
// the signature becomes invalid and AWS rejects the request with:
|
|
// 403: "The request signature we calculated does not match the signature
|
|
// you provided."
|
|
t.Run("SigV4 signed headers", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool)
|
|
|
|
proxyHeaders := http.Header{
|
|
"X-Forwarded-For": {"203.0.113.50, 10.0.0.1"},
|
|
"X-Forwarded-Host": {"app.example.com"},
|
|
"X-Forwarded-Proto": {"https"},
|
|
}
|
|
|
|
// Credentials used for both the Bedrock config and the mock's
|
|
// signature re-verification.
|
|
accessKey := "test-access-key"
|
|
secretKey := "test-secret-key"
|
|
region := "us-west-2"
|
|
|
|
var signatureValid atomic.Bool
|
|
|
|
// Mock Bedrock endpoint (simulates AWS). The OnRequest callback
|
|
// re-signs the received request using only the declared
|
|
// SignedHeaders and stores whether the signatures match.
|
|
fixResp := testutil.NewFixtureResponse(fix)
|
|
fixResp.OnRequest = func(r *http.Request, body []byte) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
// Passthrough requests have no SigV4 auth; skip verification.
|
|
if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
|
|
return
|
|
}
|
|
originalSig := extractSigV4Field(authHeader, "Signature=")
|
|
|
|
// Rebuild the request the way AWS would: keep only
|
|
// the declared SignedHeaders.
|
|
signedHeaders := strings.Split(extractSigV4Field(authHeader, "SignedHeaders="), ";")
|
|
verifyReq := r.Clone(r.Context())
|
|
verifyReq.Header.Del("Authorization")
|
|
for h := range verifyReq.Header {
|
|
if !slices.Contains(signedHeaders, strings.ToLower(h)) {
|
|
verifyReq.Header.Del(h)
|
|
}
|
|
}
|
|
// Restore ContentLength: Go's HTTP server parses it
|
|
// from the request but does not put it in r.Header;
|
|
// the SigV4 signer reads the struct field.
|
|
verifyReq.ContentLength = int64(len(body))
|
|
|
|
// Re-sign with the same credentials, body hash, and
|
|
// timestamp. SigV4 derives the signature from all three,
|
|
// so any difference means a header was altered in transit.
|
|
signingTime, err := time.Parse("20060102T150405Z", verifyReq.Header.Get("X-Amz-Date"))
|
|
require.NoError(t, err)
|
|
bodyHash := sha256.Sum256(body)
|
|
err = v4signer.NewSigner().SignHTTP(
|
|
ctx,
|
|
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secretKey},
|
|
verifyReq, hex.EncodeToString(bodyHash[:]),
|
|
"bedrock", region, signingTime,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
recomputedSig := extractSigV4Field(verifyReq.Header.Get("Authorization"), "Signature=")
|
|
signatureValid.Store(originalSig == recomputedSig)
|
|
}
|
|
mockBedrock := testutil.NewMockUpstream(ctx, t, fixResp)
|
|
mockBedrock.AllowOverflow = true
|
|
|
|
// Simulated egress proxy: modifies X-Forwarded-For and
|
|
// forwards to mockBedrock, preserving the original Host.
|
|
mockEgressProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
r.Header.Set("X-Forwarded-For", xff+", 10.255.0.1")
|
|
}
|
|
|
|
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, mockBedrock.URL+r.URL.Path, r.Body)
|
|
require.NoError(t, err)
|
|
proxyReq.Header = r.Header.Clone()
|
|
proxyReq.Host = r.Host // preserve signed Host
|
|
|
|
resp, err := http.DefaultClient.Do(proxyReq)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
for k, vs := range resp.Header {
|
|
for _, v := range vs {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}))
|
|
t.Cleanup(mockEgressProxy.Close)
|
|
|
|
bCfg := bedrockCfg(mockEgressProxy.URL)
|
|
bCfg.AccessKey = accessKey
|
|
bCfg.AccessKeySecret = secretKey
|
|
bCfg.Region = region
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, mockEgressProxy.URL,
|
|
withCustomProvider(provider.NewAnthropic(anthropicCfg(mockEgressProxy.URL, apiKey), bCfg)),
|
|
)
|
|
|
|
// Sends a bridge request through a mock egress proxy that
|
|
// mutates X-Forwarded-For, then verifies the SigV4 signature
|
|
// still matches at the mock Bedrock endpoint.
|
|
t.Run("bridge SigV4 signature valid", func(t *testing.T) {
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", false)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, proxyHeaders)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
|
|
assert.True(t, signatureValid.Load(),
|
|
"SigV4 signature mismatch: a header modified in transit "+
|
|
"was included in the signed-headers set")
|
|
})
|
|
|
|
// Passthrough routes use httputil.ReverseProxy, which forwards
|
|
// the request as-is without SigV4 signing, so proxy headers
|
|
// are safe to include. ReverseProxy sets its own X-Forwarded-*
|
|
// headers via SetXForwarded. This verifies they arrive upstream.
|
|
t.Run("passthrough proxy sets own forwarded headers", func(t *testing.T) {
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodGet, "/anthropic/v1/models", nil, proxyHeaders)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
_, _ = io.ReadAll(resp.Body)
|
|
|
|
received := mockBedrock.ReceivedRequests()
|
|
require.NotEmpty(t, received)
|
|
last := received[len(received)-1]
|
|
|
|
assert.NotEmpty(t, last.Header.Get("X-Forwarded-For"),
|
|
"passthrough should set X-Forwarded-For via SetXForwarded")
|
|
assert.NotEmpty(t, last.Header.Get("X-Forwarded-Host"),
|
|
"passthrough should set X-Forwarded-Host via SetXForwarded")
|
|
assert.NotEmpty(t, last.Header.Get("X-Forwarded-Proto"),
|
|
"passthrough should set X-Forwarded-Proto via SetXForwarded")
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestOpenAIChatCompletions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("single builtin tool", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
streaming bool
|
|
expectedInputTokens, expectedOutputTokens int
|
|
expectedToolCallID string
|
|
}{
|
|
{
|
|
name: "streaming",
|
|
streaming: true,
|
|
expectedInputTokens: 60,
|
|
expectedOutputTokens: 15,
|
|
expectedToolCallID: "call_HjeqP7YeRkoNj0de9e3U4X4B",
|
|
},
|
|
{
|
|
name: "non-streaming",
|
|
streaming: false,
|
|
expectedInputTokens: 60,
|
|
expectedOutputTokens: 15,
|
|
expectedToolCallID: "call_KjzAbhiZC6nk81tQzL7pwlpc",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixtures.OaiChatSingleBuiltinTool)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
// Make API call to aibridge for OpenAI /v1/chat/completions
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Response-specific checks.
|
|
if tc.streaming {
|
|
sp := aibridge.NewSSEParser()
|
|
require.NoError(t, sp.Parse(resp.Body))
|
|
|
|
// OpenAI sends all events under the same type.
|
|
messageEvents := sp.MessageEvents()
|
|
assert.NotEmpty(t, messageEvents)
|
|
|
|
// OpenAI streaming ends with [DONE]
|
|
lastEvent := messageEvents[len(messageEvents)-1]
|
|
assert.Equal(t, "[DONE]", lastEvent.Data)
|
|
}
|
|
|
|
tokenUsages := bridgeServer.Recorder.RecordedTokenUsages()
|
|
require.Len(t, tokenUsages, 1)
|
|
assert.EqualValues(t, tc.expectedInputTokens, bridgeServer.Recorder.TotalInputTokens(), "input tokens miscalculated")
|
|
assert.EqualValues(t, tc.expectedOutputTokens, bridgeServer.Recorder.TotalOutputTokens(), "output tokens miscalculated")
|
|
|
|
toolUsages := bridgeServer.Recorder.RecordedToolUsages()
|
|
require.Len(t, toolUsages, 1)
|
|
assert.Equal(t, "read_file", toolUsages[0].Tool)
|
|
assert.Equal(t, tc.expectedToolCallID, toolUsages[0].ToolCallID)
|
|
require.IsType(t, map[string]any{}, toolUsages[0].Args)
|
|
require.Contains(t, toolUsages[0].Args, "path")
|
|
assert.Equal(t, "README.md", toolUsages[0].Args.(map[string]any)["path"])
|
|
|
|
promptUsages := bridgeServer.Recorder.RecordedPromptUsages()
|
|
require.Len(t, promptUsages, 1)
|
|
assert.Equal(t, "how large is the README.md file in my current path", promptUsages[0].Prompt)
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("streaming injected tool call edge cases", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
expectedArgs map[string]any
|
|
}{
|
|
{
|
|
name: "tool call no preamble",
|
|
fixture: fixtures.OaiChatStreamingInjectedToolNoPreamble,
|
|
expectedArgs: map[string]any{"owner": "me"},
|
|
},
|
|
{
|
|
name: "tool call with non-zero index",
|
|
fixture: fixtures.OaiChatStreamingInjectedToolNonzeroIndex,
|
|
expectedArgs: nil, // No arguments in this fixture
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Setup mock server for multi-turn interaction.
|
|
// First request → tool call response, second → tool response.
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix), testutil.NewFixtureToolResponse(fix))
|
|
|
|
// Setup MCP proxies with the tool from the fixture
|
|
mockMCP := setupMCPForTest(t, defaultTracer)
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withMCP(mockMCP),
|
|
)
|
|
|
|
// Add the stream param to the request.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", true)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Verify SSE headers are sent correctly
|
|
require.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
|
|
require.Equal(t, "no-cache", resp.Header.Get("Cache-Control"))
|
|
require.Equal(t, "keep-alive", resp.Header.Get("Connection"))
|
|
|
|
// Consume the full response body to ensure the interception completes
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the MCP tool was actually invoked
|
|
invocations := mockMCP.getCallsByTool(mockToolName)
|
|
require.Len(t, invocations, 1, "expected MCP tool to be invoked")
|
|
|
|
// Verify tool was invoked with the expected args (if specified)
|
|
if tc.expectedArgs != nil {
|
|
expected, err := json.Marshal(tc.expectedArgs)
|
|
require.NoError(t, err)
|
|
actual, err := json.Marshal(invocations[0])
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, expected, actual)
|
|
}
|
|
|
|
// Verify tool usage was recorded
|
|
toolUsages := bridgeServer.Recorder.RecordedToolUsages()
|
|
require.Len(t, toolUsages, 1)
|
|
assert.Equal(t, mockToolName, toolUsages[0].Tool)
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSimple(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
getAnthropicResponseID := func(streaming bool, resp *http.Response) (string, error) {
|
|
if streaming {
|
|
decoder := ssestream.NewDecoder(resp)
|
|
stream := ssestream.NewStream[anthropic.MessageStreamEventUnion](decoder, nil)
|
|
var message anthropic.Message
|
|
for stream.Next() {
|
|
event := stream.Current()
|
|
if err := message.Accumulate(event); err != nil {
|
|
return "", xerrors.Errorf("accumulate event: %w", err)
|
|
}
|
|
}
|
|
if stream.Err() != nil {
|
|
return "", xerrors.Errorf("stream error: %w", stream.Err())
|
|
}
|
|
return message.ID, nil
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("read body: %w", err)
|
|
}
|
|
|
|
var message anthropic.Message
|
|
if err := json.Unmarshal(body, &message); err != nil {
|
|
return "", xerrors.Errorf("unmarshal response: %w", err)
|
|
}
|
|
return message.ID, nil
|
|
}
|
|
|
|
getOpenAIResponseID := func(streaming bool, resp *http.Response) (string, error) {
|
|
if streaming {
|
|
// Parse the response stream.
|
|
decoder := oaissestream.NewDecoder(resp)
|
|
stream := oaissestream.NewStream[openai.ChatCompletionChunk](decoder, nil)
|
|
var message openai.ChatCompletionAccumulator
|
|
for stream.Next() {
|
|
chunk := stream.Current()
|
|
message.AddChunk(chunk)
|
|
}
|
|
if stream.Err() != nil {
|
|
return "", xerrors.Errorf("stream error: %w", stream.Err())
|
|
}
|
|
return message.ID, nil
|
|
}
|
|
|
|
// Parse & unmarshal the response.
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("read body: %w", err)
|
|
}
|
|
|
|
var message openai.ChatCompletion
|
|
if err := json.Unmarshal(body, &message); err != nil {
|
|
return "", xerrors.Errorf("unmarshal response: %w", err)
|
|
}
|
|
return message.ID, nil
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
fixture []byte
|
|
basePath string
|
|
expectedPath string
|
|
getResponseIDFunc func(streaming bool, resp *http.Response) (string, error)
|
|
path string
|
|
expectedMsgID string
|
|
userAgent string
|
|
expectedClient aibridge.Client
|
|
}{
|
|
{
|
|
name: config.ProviderAnthropic,
|
|
fixture: fixtures.AntSimple,
|
|
basePath: "",
|
|
expectedPath: "/v1/messages",
|
|
getResponseIDFunc: getAnthropicResponseID,
|
|
path: pathAnthropicMessages,
|
|
expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn",
|
|
userAgent: "claude-cli/2.0.67 (external, cli)",
|
|
expectedClient: aibridge.ClientClaudeCode,
|
|
},
|
|
{
|
|
name: config.ProviderAnthropic + "_haiku_prompt_capture",
|
|
fixture: fixtures.AntHaikuSimple,
|
|
basePath: "",
|
|
expectedPath: "/v1/messages",
|
|
getResponseIDFunc: getAnthropicResponseID,
|
|
path: pathAnthropicMessages,
|
|
expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn",
|
|
userAgent: "claude-cli/2.0.67 (external, cli)",
|
|
expectedClient: aibridge.ClientClaudeCode,
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI,
|
|
fixture: fixtures.OaiChatSimple,
|
|
basePath: "",
|
|
expectedPath: "/chat/completions",
|
|
getResponseIDFunc: getOpenAIResponseID,
|
|
path: pathOpenAIChatCompletions,
|
|
expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N",
|
|
userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64)",
|
|
expectedClient: aibridge.ClientCodex,
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI + "_opencode",
|
|
fixture: fixtures.OaiChatSimple,
|
|
basePath: "",
|
|
expectedPath: "/chat/completions",
|
|
getResponseIDFunc: getOpenAIResponseID,
|
|
path: pathOpenAIChatCompletions,
|
|
expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N",
|
|
userAgent: "opencode/1.16.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
|
expectedClient: aibridge.ClientOpenCode,
|
|
},
|
|
{
|
|
name: config.ProviderAnthropic + "_baseURL_path",
|
|
fixture: fixtures.AntSimple,
|
|
basePath: "/api",
|
|
expectedPath: "/api/v1/messages",
|
|
getResponseIDFunc: getAnthropicResponseID,
|
|
path: pathAnthropicMessages,
|
|
expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn",
|
|
userAgent: "GitHubCopilotChat/0.37.2026011603",
|
|
expectedClient: aibridge.ClientCopilotVSC,
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI + "_baseURL_path",
|
|
fixture: fixtures.OaiChatSimple,
|
|
basePath: "/api",
|
|
expectedPath: "/api/chat/completions",
|
|
getResponseIDFunc: getOpenAIResponseID,
|
|
path: pathOpenAIChatCompletions,
|
|
expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N",
|
|
userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)",
|
|
expectedClient: aibridge.ClientZed,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL+tc.basePath)
|
|
|
|
// When: calling the "API server" with the fixture's request body.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming)
|
|
require.NoError(t, err)
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody, http.Header{"User-Agent": {tc.userAgent}})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Then: I expect the upstream request to have the correct path.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
require.Equal(t, tc.expectedPath, received[0].Path)
|
|
|
|
// Then: I expect a non-empty response.
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, bodyBytes, "should have received response body")
|
|
|
|
// Reset the body after being read.
|
|
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
|
|
// Then: I expect the prompt to have been tracked.
|
|
promptUsages := bridgeServer.Recorder.RecordedPromptUsages()
|
|
require.NotEmpty(t, promptUsages, "no prompts tracked")
|
|
assert.Contains(t, promptUsages[0].Prompt, "how many angels can dance on the head of a pin")
|
|
|
|
// Validate that responses have their IDs overridden with a interception ID rather than the original ID from the upstream provider.
|
|
// The reason for this is that Bridge may make multiple upstream requests (i.e. to invoke injected tools), and clients will not be expecting
|
|
// multiple messages in response to a single request.
|
|
id, err := tc.getResponseIDFunc(streaming, resp)
|
|
require.NoError(t, err, "failed to retrieve response ID")
|
|
require.Nilf(t, uuid.Validate(id), "%s is not a valid UUID", id)
|
|
|
|
tokenUsages := bridgeServer.Recorder.RecordedTokenUsages()
|
|
require.GreaterOrEqual(t, len(tokenUsages), 1)
|
|
require.Equal(t, tokenUsages[0].MsgID, tc.expectedMsgID)
|
|
|
|
// Validate user agent and client have been recorded.
|
|
interceptions := bridgeServer.Recorder.RecordedInterceptions()
|
|
require.Len(t, interceptions, 1, "expected exactly one interception, got: %v", interceptions)
|
|
assert.Equal(t, id, interceptions[0].ID)
|
|
assert.Equal(t, tc.userAgent, interceptions[0].UserAgent)
|
|
assert.Equal(t, string(tc.expectedClient), interceptions[0].Client)
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionIDTracking(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
fixture []byte
|
|
header http.Header
|
|
metadataSessionID string
|
|
expectedClient aibridge.Client
|
|
expectSessionID string
|
|
}{
|
|
// Session in header.
|
|
{
|
|
name: "mux",
|
|
fixture: fixtures.AntSimple,
|
|
expectedClient: aibridge.ClientMux,
|
|
expectSessionID: "mux-workspace-321",
|
|
header: http.Header{
|
|
"User-Agent": []string{"mux/1.0.0"},
|
|
"X-Mux-Workspace-Id": []string{"mux-workspace-321"},
|
|
},
|
|
},
|
|
// Session in body.
|
|
{
|
|
name: "claude_code",
|
|
fixture: fixtures.AntSimple,
|
|
expectedClient: aibridge.ClientClaudeCode,
|
|
expectSessionID: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
|
header: http.Header{
|
|
"User-Agent": []string{"claude-cli/2.0.67 (external, cli)"},
|
|
},
|
|
metadataSessionID: "user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
|
},
|
|
// No session.
|
|
{
|
|
name: "zed",
|
|
fixture: fixtures.AntSimple,
|
|
expectedClient: aibridge.ClientZed,
|
|
header: http.Header{
|
|
"User-Agent": []string{"Zed/0.219.4+stable.119.abc123 (macos; aarch64)"},
|
|
},
|
|
},
|
|
{
|
|
name: "opencode",
|
|
fixture: fixtures.AntSimple,
|
|
expectedClient: aibridge.ClientOpenCode,
|
|
expectSessionID: "ses_15a48edefffe7oY0YcIHRv29dD",
|
|
header: http.Header{
|
|
"User-Agent": []string{"opencode/1.16.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14"},
|
|
"X-OpenCode-Session": []string{"ses_15a48edefffe7oY0YcIHRv29dD"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, withProvider(config.ProviderAnthropic))
|
|
|
|
reqBody := fix.Request()
|
|
if tc.metadataSessionID != "" {
|
|
var err error
|
|
reqBody, err = sjson.SetBytes(reqBody, "metadata.user_id", tc.metadataSessionID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, tc.header)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Drain the body to let the stream complete.
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
interceptions := bridgeServer.Recorder.RecordedInterceptions()
|
|
require.Len(t, interceptions, 1, "expected exactly one interception")
|
|
assert.Equal(t, string(tc.expectedClient), interceptions[0].Client)
|
|
|
|
if tc.expectSessionID == "" {
|
|
assert.Nil(t, interceptions[0].ClientSessionID, "expected nil session ID for %s", tc.name)
|
|
} else {
|
|
require.NotNil(t, interceptions[0].ClientSessionID, "expected non-nil session ID for %s", tc.name)
|
|
assert.Equal(t, tc.expectSessionID, *interceptions[0].ClientSessionID)
|
|
}
|
|
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFallthrough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
fixture []byte
|
|
basePath string
|
|
requestPath string
|
|
expectedUpstreamPath string
|
|
expectAuthHeader string
|
|
}{
|
|
{
|
|
name: "ant_empty_base_url_path",
|
|
fixture: fixtures.AntFallthrough,
|
|
basePath: "",
|
|
requestPath: "/anthropic/v1/models",
|
|
expectedUpstreamPath: "/v1/models",
|
|
expectAuthHeader: "X-Api-Key",
|
|
},
|
|
{
|
|
name: "oai_empty_base_url_path",
|
|
fixture: fixtures.OaiChatFallthrough,
|
|
basePath: "",
|
|
requestPath: "/openai/v1/models",
|
|
expectedUpstreamPath: "/models",
|
|
expectAuthHeader: "Authorization",
|
|
},
|
|
{
|
|
name: "ant_some_base_url_path",
|
|
fixture: fixtures.AntFallthrough,
|
|
basePath: "/api",
|
|
requestPath: "/anthropic/v1/models",
|
|
expectedUpstreamPath: "/api/v1/models",
|
|
expectAuthHeader: "X-Api-Key",
|
|
},
|
|
{
|
|
name: "oai_some_base_url_path",
|
|
fixture: fixtures.OaiChatFallthrough,
|
|
basePath: "/api",
|
|
requestPath: "/openai/v1/models",
|
|
expectedUpstreamPath: "/api/models",
|
|
expectAuthHeader: "Authorization",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(t.Context(), t, testutil.NewFixtureResponse(fix))
|
|
bridgeServer := newBridgeTestServer(t.Context(), t, upstream.URL+tc.basePath)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodGet, tc.requestPath, nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Verify upstream received the request at the expected path
|
|
// with the API key header.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
require.Equal(t, tc.expectedUpstreamPath, received[0].Path)
|
|
require.Contains(t, received[0].Header.Get(tc.expectAuthHeader), apiKey)
|
|
|
|
gotBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Compare JSON bodies for semantic equality.
|
|
var got any
|
|
var exp any
|
|
require.NoError(t, json.Unmarshal(gotBytes, &got))
|
|
require.NoError(t, json.Unmarshal(fix.NonStreaming(), &exp))
|
|
require.EqualValues(t, exp, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnthropicInjectedTools(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Build the requirements & make the assertions which are common to all providers.
|
|
bridgeServer, mockMCP, resp := setupInjectedToolTest(t, fixtures.AntSingleInjectedTool, streaming, defaultTracer, pathAnthropicMessages, anthropicToolResultValidator(t))
|
|
defer resp.Body.Close()
|
|
|
|
// Ensure expected tool was invoked with expected input.
|
|
toolUsages := bridgeServer.Recorder.RecordedToolUsages()
|
|
require.Len(t, toolUsages, 1)
|
|
require.Equal(t, mockToolName, toolUsages[0].Tool)
|
|
expected, err := json.Marshal(map[string]any{"owner": "admin"})
|
|
require.NoError(t, err)
|
|
actual, err := json.Marshal(toolUsages[0].Args)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, expected, actual)
|
|
invocations := mockMCP.getCallsByTool(mockToolName)
|
|
require.Len(t, invocations, 1)
|
|
actual, err = json.Marshal(invocations[0])
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, expected, actual)
|
|
|
|
var (
|
|
content *anthropic.ContentBlockUnion
|
|
message anthropic.Message
|
|
)
|
|
if streaming {
|
|
// Parse the response stream.
|
|
decoder := ssestream.NewDecoder(resp)
|
|
stream := ssestream.NewStream[anthropic.MessageStreamEventUnion](decoder, nil)
|
|
for stream.Next() {
|
|
event := stream.Current()
|
|
require.NoError(t, message.Accumulate(event), "accumulate event")
|
|
}
|
|
|
|
require.NoError(t, stream.Err(), "stream error")
|
|
require.Len(t, message.Content, 2)
|
|
|
|
content = &message.Content[1]
|
|
} else {
|
|
// Parse & unmarshal the response.
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err, "read response body")
|
|
|
|
require.NoError(t, json.Unmarshal(body, &message), "unmarshal response")
|
|
require.GreaterOrEqual(t, len(message.Content), 1)
|
|
|
|
content = &message.Content[0]
|
|
}
|
|
|
|
// Ensure tool returned expected value.
|
|
require.NotNil(t, content)
|
|
require.Contains(t, content.Text, "dd711d5c-83c6-4c08-a0af-b73055906e8c") // The ID of the workspace to be returned.
|
|
|
|
// Check the token usage from the client's perspective.
|
|
//
|
|
// We overwrite the final message_delta which is relayed to the client to include the
|
|
// accumulated tokens but currently the SDK only supports accumulating output tokens
|
|
// for message_delta events.
|
|
//
|
|
// For non-streaming requests the token usage is also overwritten and should be faithfully
|
|
// represented in the response.
|
|
//
|
|
// See https://github.com/anthropics/anthropic-sdk-go/blob/v1.12.0/message.go#L2619-L2622
|
|
if !streaming {
|
|
assert.EqualValues(t, 15308, message.Usage.InputTokens)
|
|
}
|
|
assert.EqualValues(t, 204, message.Usage.OutputTokens)
|
|
|
|
// Ensure tokens used during injected tool invocation are accounted for.
|
|
assert.EqualValues(t, 15308, bridgeServer.Recorder.TotalInputTokens())
|
|
assert.EqualValues(t, 204, bridgeServer.Recorder.TotalOutputTokens())
|
|
|
|
// Ensure we received exactly one prompt.
|
|
promptUsages := bridgeServer.Recorder.RecordedPromptUsages()
|
|
require.Len(t, promptUsages, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAIInjectedTools(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Build the requirements & make the assertions which are common to all providers.
|
|
bridgeServer, mockMCP, resp := setupInjectedToolTest(t, fixtures.OaiChatSingleInjectedTool, streaming, defaultTracer, pathOpenAIChatCompletions, openaiChatToolResultValidator(t))
|
|
defer resp.Body.Close()
|
|
|
|
// Ensure expected tool was invoked with expected input.
|
|
toolUsages := bridgeServer.Recorder.RecordedToolUsages()
|
|
require.Len(t, toolUsages, 1)
|
|
require.Equal(t, mockToolName, toolUsages[0].Tool)
|
|
expected, err := json.Marshal(map[string]any{"owner": "admin"})
|
|
require.NoError(t, err)
|
|
actual, err := json.Marshal(toolUsages[0].Args)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, expected, actual)
|
|
invocations := mockMCP.getCallsByTool(mockToolName)
|
|
require.Len(t, invocations, 1)
|
|
actual, err = json.Marshal(invocations[0])
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, expected, actual)
|
|
|
|
var (
|
|
content *openai.ChatCompletionChoice
|
|
message openai.ChatCompletion
|
|
)
|
|
if streaming {
|
|
// Parse the response stream.
|
|
decoder := oaissestream.NewDecoder(resp)
|
|
stream := oaissestream.NewStream[openai.ChatCompletionChunk](decoder, nil)
|
|
var acc openai.ChatCompletionAccumulator
|
|
detectedToolCalls := make(map[string]struct{})
|
|
for stream.Next() {
|
|
chunk := stream.Current()
|
|
acc.AddChunk(chunk)
|
|
|
|
if len(chunk.Choices) == 0 {
|
|
continue
|
|
}
|
|
|
|
for _, c := range chunk.Choices {
|
|
if len(c.Delta.ToolCalls) == 0 {
|
|
continue
|
|
}
|
|
|
|
for _, t := range c.Delta.ToolCalls {
|
|
if t.Function.Name == "" {
|
|
continue
|
|
}
|
|
|
|
detectedToolCalls[t.Function.Name] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify that no injected tool call events (or partials thereof) were sent to the client.
|
|
require.Len(t, detectedToolCalls, 0)
|
|
|
|
message = acc.ChatCompletion
|
|
require.NoError(t, stream.Err(), "stream error")
|
|
} else {
|
|
// Parse & unmarshal the response.
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err, "read response body")
|
|
require.NoError(t, json.Unmarshal(body, &message), "unmarshal response")
|
|
|
|
// Verify that no injected tools were sent to the client.
|
|
require.GreaterOrEqual(t, len(message.Choices), 1)
|
|
require.Len(t, message.Choices[0].Message.ToolCalls, 0)
|
|
}
|
|
|
|
require.GreaterOrEqual(t, len(message.Choices), 1)
|
|
content = &message.Choices[0]
|
|
|
|
// Ensure tool returned expected value.
|
|
require.NotNil(t, content)
|
|
require.Contains(t, content.Message.Content, "dd711d5c-83c6-4c08-a0af-b73055906e8c") // The ID of the workspace to be returned.
|
|
|
|
// Check the token usage from the client's perspective.
|
|
// This *should* work but the openai SDK doesn't accumulate the prompt token details :(.
|
|
// See https://github.com/openai/openai-go/blob/v2.7.0/streamaccumulator.go#L145-L147.
|
|
// assert.EqualValues(t, 5047, message.Usage.PromptTokens-message.Usage.PromptTokensDetails.CachedTokens)
|
|
assert.EqualValues(t, 105, message.Usage.CompletionTokens)
|
|
|
|
// Ensure tokens used during injected tool invocation are accounted for.
|
|
require.EqualValues(t, 5047, bridgeServer.Recorder.TotalInputTokens())
|
|
require.EqualValues(t, 105, bridgeServer.Recorder.TotalOutputTokens())
|
|
|
|
// Ensure we received exactly one prompt.
|
|
promptUsages := bridgeServer.Recorder.RecordedPromptUsages()
|
|
require.Len(t, promptUsages, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
// anthropicToolResultValidator returns a request validator that asserts the second
|
|
// upstream request contains the assistant's tool_use and user's tool_result messages
|
|
// appended by the inner agentic loop. If the raw payload is not kept in sync with
|
|
// the structured messages, the second request will be identical to the first.
|
|
func anthropicToolResultValidator(t *testing.T) func(*http.Request, []byte) {
|
|
t.Helper()
|
|
|
|
return func(_ *http.Request, raw []byte) {
|
|
messages := gjson.GetBytes(raw, "messages").Array()
|
|
|
|
// After the agentic loop the messages must contain at minimum:
|
|
// [0] original user message
|
|
// [N-2] assistant message with tool_use content block
|
|
// [N-1] user message with tool_result content block
|
|
require.GreaterOrEqual(t, len(messages), 3,
|
|
"second upstream request must contain the original message, assistant tool_use, and user tool_result")
|
|
|
|
assistantMsg := messages[len(messages)-2]
|
|
require.Equal(t, "assistant", assistantMsg.Get("role").Str,
|
|
"penultimate message must be from the assistant")
|
|
var hasToolUse bool
|
|
for _, block := range assistantMsg.Get("content").Array() {
|
|
if block.Get("type").Str == "tool_use" {
|
|
hasToolUse = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, hasToolUse, "assistant message must contain a tool_use content block")
|
|
|
|
toolResultMsg := messages[len(messages)-1]
|
|
require.Equal(t, "user", toolResultMsg.Get("role").Str,
|
|
"last message must be a user message carrying the tool_result")
|
|
var hasToolResult bool
|
|
for _, block := range toolResultMsg.Get("content").Array() {
|
|
if block.Get("type").Str == "tool_result" {
|
|
hasToolResult = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, hasToolResult, "user message must contain a tool_result content block")
|
|
}
|
|
}
|
|
|
|
// openaiChatToolResultValidator returns a request validator that asserts the second
|
|
// upstream request contains the assistant's tool_calls and a role=tool result message
|
|
// appended by the inner agentic loop.
|
|
func openaiChatToolResultValidator(t *testing.T) func(*http.Request, []byte) {
|
|
t.Helper()
|
|
|
|
return func(_ *http.Request, raw []byte) {
|
|
messages := gjson.GetBytes(raw, "messages").Array()
|
|
|
|
// After the agentic loop the messages must contain at minimum:
|
|
// [0] original user message
|
|
// [N-2] assistant message with tool_calls array
|
|
// [N-1] message with role=tool
|
|
require.GreaterOrEqual(t, len(messages), 3,
|
|
"second upstream request must contain the original message, assistant tool_calls, and tool result")
|
|
|
|
assistantMsg := messages[len(messages)-2]
|
|
require.Equal(t, "assistant", assistantMsg.Get("role").Str,
|
|
"penultimate message must be from the assistant")
|
|
require.NotEmpty(t, len(assistantMsg.Get("tool_calls").Array()),
|
|
"assistant message must contain a tool_calls array")
|
|
|
|
toolResultMsg := messages[len(messages)-1]
|
|
require.Equal(t, "tool", toolResultMsg.Get("role").Str,
|
|
"last message must have role=tool")
|
|
require.NotEmpty(t, toolResultMsg.Get("tool_call_id").Str,
|
|
"tool result message must have a tool_call_id")
|
|
}
|
|
}
|
|
|
|
func TestErrorHandling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Tests that errors which occur *before* a streaming response begins, or in non-streaming requests, are handled as expected.
|
|
t.Run("non-stream error", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
path string
|
|
responseHandlerFn func(resp *http.Response)
|
|
}{
|
|
{
|
|
name: config.ProviderAnthropic,
|
|
fixture: fixtures.AntNonStreamError,
|
|
path: pathAnthropicMessages,
|
|
responseHandlerFn: func(resp *http.Response) {
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "error", gjson.GetBytes(body, "type").Str)
|
|
require.Equal(t, "invalid_request_error", gjson.GetBytes(body, "error.type").Str)
|
|
require.Contains(t, gjson.GetBytes(body, "error.message").Str, "prompt is too long")
|
|
},
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI,
|
|
fixture: fixtures.OaiChatNonStreamError,
|
|
path: pathOpenAIChatCompletions,
|
|
responseHandlerFn: func(resp *http.Response) {
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "context_length_exceeded", gjson.GetBytes(body, "error.code").Str)
|
|
require.Equal(t, "invalid_request_error", gjson.GetBytes(body, "error.type").Str)
|
|
require.Contains(t, gjson.GetBytes(body, "error.message").Str, "Input tokens exceed the configured limit")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Setup mock server. Error fixtures contain raw HTTP
|
|
// responses that may cause the bridge to retry.
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
// Add the stream param to the request.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
tc.responseHandlerFn(resp)
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// Tests that errors which occur *during* a streaming response are handled as expected.
|
|
t.Run("mid-stream error", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
path string
|
|
responseHandlerFn func(resp *http.Response)
|
|
}{
|
|
{
|
|
name: config.ProviderAnthropic,
|
|
fixture: fixtures.AntMidStreamError,
|
|
path: pathAnthropicMessages,
|
|
responseHandlerFn: func(resp *http.Response) {
|
|
// Server responds first with 200 OK then starts streaming.
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
sp := aibridge.NewSSEParser()
|
|
require.NoError(t, sp.Parse(resp.Body))
|
|
require.Len(t, sp.EventsByType("error"), 1)
|
|
require.Contains(t, sp.EventsByType("error")[0].Data, "Overloaded")
|
|
},
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI,
|
|
fixture: fixtures.OaiChatMidStreamError,
|
|
path: pathOpenAIChatCompletions,
|
|
responseHandlerFn: func(resp *http.Response) {
|
|
// Server responds first with 200 OK then starts streaming.
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
sp := aibridge.NewSSEParser()
|
|
require.NoError(t, sp.Parse(resp.Body))
|
|
// OpenAI sends all events under the same type.
|
|
messageEvents := sp.MessageEvents()
|
|
require.NotEmpty(t, messageEvents)
|
|
|
|
errEvent := sp.MessageEvents()[len(sp.MessageEvents())-2] // Last event is termination marker ("[DONE]").
|
|
require.NotEmpty(t, errEvent)
|
|
require.Contains(t, errEvent.Data, "The server had an error while processing your request. Sorry about that!")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Setup mock server.
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
upstream.StatusCode = http.StatusInternalServerError
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request())
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
tc.responseHandlerFn(resp)
|
|
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestStableRequestEncoding validates that a given intercepted request and a
|
|
// given set of injected tools should result identical payloads.
|
|
//
|
|
// Should the payload vary, it may subvert any caching mechanisms the provider may have.
|
|
func TestStableRequestEncoding(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
path string
|
|
}{
|
|
{
|
|
name: config.ProviderAnthropic,
|
|
fixture: fixtures.AntSimple,
|
|
path: pathAnthropicMessages,
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI,
|
|
fixture: fixtures.OaiChatSimple,
|
|
path: pathOpenAIChatCompletions,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Setup MCP tools.
|
|
mockMCP := setupMCPForTest(t, defaultTracer)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
|
|
// Create a mock upstream that serves the same blocking response for each request.
|
|
count := 10
|
|
responses := make([]testutil.UpstreamResponse, count)
|
|
for i := range count {
|
|
responses[i] = testutil.NewFixtureResponse(fix)
|
|
}
|
|
upstream := testutil.NewMockUpstream(ctx, t, responses...)
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withMCP(mockMCP),
|
|
)
|
|
|
|
// Make multiple requests and verify they all have identical payloads.
|
|
for range count {
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request())
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
}
|
|
|
|
// All upstream request bodies should be identical.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, count)
|
|
reference := string(received[0].Body)
|
|
for _, r := range received[1:] {
|
|
assert.JSONEq(t, reference, string(r.Body))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAnthropicToolChoiceParallelDisabled verifies that parallel tool use is
|
|
// correctly disabled based on the tool_choice parameter in the request.
|
|
// See https://github.com/coder/aibridge/issues/2
|
|
func TestAnthropicToolChoiceParallelDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
toolChoiceAuto = string(constant.ValueOf[constant.Auto]())
|
|
toolChoiceAny = string(constant.ValueOf[constant.Any]())
|
|
toolChoiceNone = string(constant.ValueOf[constant.None]())
|
|
toolChoiceTool = string(constant.ValueOf[constant.Tool]())
|
|
)
|
|
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
toolChoice any // nil, or map with "type" key.
|
|
withInjectedTools bool
|
|
expectDisableParallel *bool // nil = field should not be present, non-nil = expected value.
|
|
expectToolChoiceTypeInRequest string
|
|
}{
|
|
// With injected tools - disable_parallel_tool_use should be set to true.
|
|
{
|
|
name: "with injected tools: no tool_choice defined defaults to auto",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: nil,
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with injected tools: tool_choice auto",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with injected tools: tool_choice any",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAny},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAny,
|
|
},
|
|
{
|
|
name: "with injected tools: tool_choice tool",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceTool, "name": "some_tool"},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceTool,
|
|
},
|
|
{
|
|
name: "with injected tools: tool_choice none",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceNone},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: nil,
|
|
expectToolChoiceTypeInRequest: toolChoiceNone,
|
|
},
|
|
// With injected tools and builtin tools - disable_parallel_tool_use should be set to true.
|
|
{
|
|
name: "with injected and builtin tools: no tool_choice defined defaults to auto",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: nil,
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: tool_choice auto",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: tool_choice any",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAny},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAny,
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: tool_choice tool",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceTool, "name": "some_tool"},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceTool,
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: tool_choice none",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceNone},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: nil,
|
|
expectToolChoiceTypeInRequest: toolChoiceNone,
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: request already disables parallel",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: request explicitly enables parallel",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
// Without injected or builtin tools - disable_parallel_tool_use should NOT be set.
|
|
{
|
|
name: "without injected tools or builtin tools: tool_choice auto",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: nil,
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "without injected tools or builtin tools: tool_choice any",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAny},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: nil,
|
|
expectToolChoiceTypeInRequest: toolChoiceAny,
|
|
},
|
|
// With builtin tools but without injected tools - disable_parallel_tool_use should NOT be set.
|
|
{
|
|
name: "with builtin tools only: tool_choice auto",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: nil,
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with builtin tools only: tool_choice any",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAny},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: nil,
|
|
expectToolChoiceTypeInRequest: toolChoiceAny,
|
|
},
|
|
{
|
|
name: "with builtin tools only: request explicitly disables parallel",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with builtin tools only: request explicitly enables parallel",
|
|
fixture: fixtures.AntSingleBuiltinTool,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: utils.PtrTo(false),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
// Without injected or builtin tools - disable_parallel_tool_use should be preserved if set.
|
|
{
|
|
name: "no tools: request explicitly disables parallel",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "no tools: request explicitly enables parallel",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: utils.PtrTo(false),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
// Request already has disable_parallel_tool_use set - with injected tools it should be set to true.
|
|
{
|
|
name: "with injected tools: request already disables parallel",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "with injected tools: request explicitly enables parallel",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false},
|
|
withInjectedTools: true,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
// Request already has disable_parallel_tool_use set - without injected tools it should be preserved.
|
|
{
|
|
name: "without injected tools: request already disables parallel",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: utils.PtrTo(true),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
{
|
|
name: "without injected tools: request explicitly enables parallel",
|
|
fixture: fixtures.AntSimple,
|
|
toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false},
|
|
withInjectedTools: false,
|
|
expectDisableParallel: utils.PtrTo(false),
|
|
expectToolChoiceTypeInRequest: toolChoiceAuto,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Setup MCP tools conditionally.
|
|
var mockMCP mcp.ServerProxier
|
|
if tc.withInjectedTools {
|
|
mockMCP = setupMCPForTest(t, defaultTracer)
|
|
} else {
|
|
mockMCP = newNoopMCPManager()
|
|
}
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withMCP(mockMCP),
|
|
)
|
|
|
|
// Prepare request body with tool_choice set.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "tool_choice", tc.toolChoice)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Verify tool_choice in the upstream request.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
var receivedRequest map[string]any
|
|
require.NoError(t, json.Unmarshal(received[0].Body, &receivedRequest))
|
|
toolChoice, ok := receivedRequest["tool_choice"].(map[string]any)
|
|
require.True(t, ok, "expected tool_choice in upstream request")
|
|
|
|
// Verify the type matches expectation.
|
|
assert.Equal(t, tc.expectToolChoiceTypeInRequest, toolChoice["type"])
|
|
|
|
// Verify name is preserved for tool_choice=tool.
|
|
if tc.expectToolChoiceTypeInRequest == toolChoiceTool {
|
|
assert.Equal(t, "some_tool", toolChoice["name"])
|
|
}
|
|
|
|
// Verify disable_parallel_tool_use based on expectations.
|
|
// See https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use#parallel-tool-use
|
|
disableParallel, hasDisableParallel := toolChoice["disable_parallel_tool_use"].(bool)
|
|
|
|
require.Equal(t, tc.expectDisableParallel != nil, hasDisableParallel,
|
|
"disable_parallel_tool_use presence mismatch")
|
|
if tc.expectDisableParallel != nil {
|
|
assert.Equal(t, *tc.expectDisableParallel, disableParallel)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsParallelToolCallsDisabled verifies that parallel_tool_calls
|
|
// is set to false only when injectable MCP tools are present and the request
|
|
// includes tools.
|
|
func TestChatCompletionsParallelToolCallsDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
fixture []byte
|
|
withInjectedTools bool
|
|
initialSetting *bool
|
|
expectedSetting *bool
|
|
}{
|
|
// With injected tools and builtin tools: parallel_tool_calls should be forced false.
|
|
{
|
|
name: "with injected and builtin tools: parallel_tool_calls true",
|
|
fixture: fixtures.OaiChatSingleBuiltinTool,
|
|
withInjectedTools: true,
|
|
initialSetting: utils.PtrTo(true),
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: parallel_tool_calls false",
|
|
fixture: fixtures.OaiChatSingleBuiltinTool,
|
|
withInjectedTools: true,
|
|
initialSetting: utils.PtrTo(false),
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
{
|
|
name: "with injected and builtin tools: parallel_tool_calls unset",
|
|
fixture: fixtures.OaiChatSingleBuiltinTool,
|
|
withInjectedTools: true,
|
|
initialSetting: nil,
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
// With injected tools but without builtin tools: parallel_tool_calls should be forced false.
|
|
{
|
|
name: "with injected tools only: parallel_tool_calls true",
|
|
fixture: fixtures.OaiChatSimple,
|
|
withInjectedTools: true,
|
|
initialSetting: utils.PtrTo(true),
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
{
|
|
name: "with injected tools only: parallel_tool_calls false",
|
|
fixture: fixtures.OaiChatSimple,
|
|
withInjectedTools: true,
|
|
initialSetting: utils.PtrTo(false),
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
{
|
|
name: "with injected tools only: parallel_tool_calls unset",
|
|
fixture: fixtures.OaiChatSimple,
|
|
withInjectedTools: true,
|
|
initialSetting: nil,
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
// With builtin tools but without injected tools: parallel_tool_calls should be preserved.
|
|
{
|
|
name: "with builtin tools only: parallel_tool_calls true",
|
|
fixture: fixtures.OaiChatSingleBuiltinTool,
|
|
withInjectedTools: false,
|
|
initialSetting: utils.PtrTo(true),
|
|
expectedSetting: utils.PtrTo(true),
|
|
},
|
|
{
|
|
name: "with builtin tools only: parallel_tool_calls false",
|
|
fixture: fixtures.OaiChatSingleBuiltinTool,
|
|
withInjectedTools: false,
|
|
initialSetting: utils.PtrTo(false),
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
{
|
|
name: "with builtin tools only: parallel_tool_calls unset",
|
|
fixture: fixtures.OaiChatSingleBuiltinTool,
|
|
withInjectedTools: false,
|
|
initialSetting: nil,
|
|
expectedSetting: nil,
|
|
},
|
|
// Without any tools: nothing is modified.
|
|
{
|
|
name: "no tools: parallel_tool_calls true",
|
|
fixture: fixtures.OaiChatSimple,
|
|
withInjectedTools: false,
|
|
initialSetting: utils.PtrTo(true),
|
|
expectedSetting: utils.PtrTo(true),
|
|
},
|
|
{
|
|
name: "no tools: parallel_tool_calls false",
|
|
fixture: fixtures.OaiChatSimple,
|
|
withInjectedTools: false,
|
|
initialSetting: utils.PtrTo(false),
|
|
expectedSetting: utils.PtrTo(false),
|
|
},
|
|
{
|
|
name: "no tools: parallel_tool_calls unset",
|
|
fixture: fixtures.OaiChatSimple,
|
|
withInjectedTools: false,
|
|
initialSetting: nil,
|
|
expectedSetting: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
var opts []bridgeOption
|
|
if tc.withInjectedTools {
|
|
opts = append(opts, withMCP(setupMCPForTest(t, defaultTracer)))
|
|
}
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, opts...)
|
|
|
|
var (
|
|
reqBody = fix.Request()
|
|
err error
|
|
)
|
|
if tc.initialSetting != nil {
|
|
reqBody, err = sjson.SetBytes(reqBody, "parallel_tool_calls", *tc.initialSetting)
|
|
require.NoError(t, err)
|
|
}
|
|
reqBody, err = sjson.SetBytes(reqBody, "stream", streaming)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
|
|
var upstreamReq map[string]any
|
|
require.NoError(t, json.Unmarshal(received[0].Body, &upstreamReq))
|
|
|
|
ptc, ok := upstreamReq["parallel_tool_calls"].(bool)
|
|
require.Equal(t, tc.expectedSetting != nil, ok,
|
|
"parallel_tool_calls presence mismatch")
|
|
if tc.expectedSetting != nil {
|
|
assert.Equal(t, *tc.expectedSetting, ptc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThinkingAdaptiveIsPreserved(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fix := fixtures.Parse(t, fixtures.AntSimple)
|
|
|
|
for _, streaming := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
// Create a mock server that captures the request body sent upstream.
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
// Inject adaptive thinking into the fixture request.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "thinking", map[string]string{"type": "adaptive"})
|
|
require.NoError(t, err)
|
|
reqBody, err = sjson.SetBytes(reqBody, "stream", streaming)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the thinking field was preserved in the upstream request.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
assert.Equal(t, "adaptive", gjson.GetBytes(received[0].Body, "thinking.type").Str)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnvironmentDoNotLeak(t *testing.T) {
|
|
// NOTE: Cannot use t.Parallel() here because subtests use t.Setenv which requires sequential execution.
|
|
|
|
// Test that environment variables containing API keys/tokens are not leaked to upstream requests.
|
|
// See https://github.com/coder/aibridge/issues/60.
|
|
testCases := []struct {
|
|
name string
|
|
fixture []byte
|
|
path string
|
|
envVars map[string]string
|
|
headerName string
|
|
}{
|
|
{
|
|
name: config.ProviderAnthropic,
|
|
fixture: fixtures.AntSimple,
|
|
path: pathAnthropicMessages,
|
|
envVars: map[string]string{
|
|
"ANTHROPIC_AUTH_TOKEN": "should-not-leak",
|
|
},
|
|
headerName: "Authorization", // We only send through the X-Api-Key, so this one should not be present.
|
|
},
|
|
{
|
|
name: config.ProviderOpenAI,
|
|
fixture: fixtures.OaiChatSimple,
|
|
path: pathOpenAIChatCompletions,
|
|
envVars: map[string]string{
|
|
"OPENAI_ORG_ID": "should-not-leak",
|
|
},
|
|
headerName: "OpenAI-Organization",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// NOTE: Cannot use t.Parallel() here because t.Setenv requires sequential execution.
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
// Set environment variables that the SDK would automatically read.
|
|
// These should NOT leak into upstream requests.
|
|
for key, val := range tc.envVars {
|
|
t.Setenv(key, val)
|
|
}
|
|
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request())
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Verify that environment values did not leak.
|
|
received := upstream.ReceivedRequests()
|
|
require.Len(t, received, 1)
|
|
require.Empty(t, received[0].Header.Get(tc.headerName))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActorHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
actorUsername := "bob"
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
createProviderFn func(url, key string, sendHeaders bool) aibridge.Provider
|
|
fixture []byte
|
|
streaming bool
|
|
}{
|
|
{
|
|
name: "openai/v1/chat/completions",
|
|
path: pathOpenAIChatCompletions,
|
|
createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider {
|
|
cfg := openAICfg(url, key)
|
|
cfg.SendActorHeaders = sendHeaders
|
|
return provider.NewOpenAI(cfg)
|
|
},
|
|
fixture: fixtures.OaiChatSimple,
|
|
streaming: true,
|
|
},
|
|
{
|
|
name: "openai/v1/chat/completions",
|
|
path: pathOpenAIChatCompletions,
|
|
createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider {
|
|
cfg := openAICfg(url, key)
|
|
cfg.SendActorHeaders = sendHeaders
|
|
return provider.NewOpenAI(cfg)
|
|
},
|
|
fixture: fixtures.OaiChatSimple,
|
|
streaming: false,
|
|
},
|
|
{
|
|
name: "openai/v1/responses",
|
|
path: pathOpenAIResponses,
|
|
createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider {
|
|
cfg := openAICfg(url, key)
|
|
cfg.SendActorHeaders = sendHeaders
|
|
return provider.NewOpenAI(cfg)
|
|
},
|
|
fixture: fixtures.OaiResponsesStreamingSimple,
|
|
streaming: true,
|
|
},
|
|
{
|
|
name: "openai/v1/responses",
|
|
path: pathOpenAIResponses,
|
|
createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider {
|
|
cfg := openAICfg(url, key)
|
|
cfg.SendActorHeaders = sendHeaders
|
|
return provider.NewOpenAI(cfg)
|
|
},
|
|
fixture: fixtures.OaiResponsesBlockingSimple,
|
|
streaming: false,
|
|
},
|
|
{
|
|
name: "anthropic/v1/messages",
|
|
path: pathAnthropicMessages,
|
|
createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider {
|
|
cfg := anthropicCfg(url, key)
|
|
cfg.SendActorHeaders = sendHeaders
|
|
return provider.NewAnthropic(cfg, nil)
|
|
},
|
|
fixture: fixtures.AntSimple,
|
|
streaming: true,
|
|
},
|
|
{
|
|
name: "anthropic/v1/messages",
|
|
path: pathAnthropicMessages,
|
|
createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider {
|
|
cfg := anthropicCfg(url, key)
|
|
cfg.SendActorHeaders = sendHeaders
|
|
return provider.NewAnthropic(cfg, nil)
|
|
},
|
|
fixture: fixtures.AntSimple,
|
|
streaming: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
for _, send := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("%s/streaming=%v/send-headers=%v", tc.name, tc.streaming, send), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, tc.fixture)
|
|
upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix))
|
|
|
|
metadataKey := "Username"
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
|
|
withCustomProvider(tc.createProviderFn(upstream.URL, apiKey, send)),
|
|
withActor(defaultActorID, recorder.Metadata{
|
|
metadataKey: actorUsername,
|
|
}),
|
|
)
|
|
|
|
// Add the stream param to the request.
|
|
reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming)
|
|
require.NoError(t, err)
|
|
|
|
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
// Drain the body so streaming responses complete without
|
|
// a "connection reset" error in the mock upstream.
|
|
_, err = io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
received := upstream.ReceivedRequests()
|
|
require.NotEmpty(t, received)
|
|
receivedHeaders := received[0].Header
|
|
|
|
// Verify that the actor headers were only received if intended.
|
|
found := make(map[string][]string)
|
|
for k, v := range receivedHeaders {
|
|
k = strings.ToLower(k)
|
|
if intercept.IsActorHeader(k) {
|
|
found[k] = v
|
|
}
|
|
}
|
|
|
|
if send {
|
|
require.Equal(t, found[strings.ToLower(intercept.ActorIDHeader())], []string{defaultActorID})
|
|
require.Equal(t, found[strings.ToLower(intercept.ActorMetadataHeader(metadataKey))], []string{actorUsername})
|
|
} else {
|
|
require.Empty(t, found)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractSigV4Field extracts a named field from an AWS SigV4
|
|
// Authorization header value.
|
|
func extractSigV4Field(authHeader, prefix string) string {
|
|
idx := strings.Index(authHeader, prefix)
|
|
if idx == -1 {
|
|
return ""
|
|
}
|
|
val := authHeader[idx+len(prefix):]
|
|
if end := strings.IndexByte(val, ','); end != -1 {
|
|
val = val[:end]
|
|
}
|
|
return strings.TrimSpace(val)
|
|
}
|