mirror of
https://github.com/coder/coder.git
synced 2026-06-07 23:18:20 +00:00
e00e85765b
This PR merges code from `coder/aibridge` repository into `coder/coder`. It was split into 4 PRs for easier review but stacked PRs will need to be merged into this PR so all checks pass. * https://github.com/coder/coder/pull/24190 -> raw code copy (this PR, before merging PRs on top of it, it was just 1 commit: https://github.com/coder/coder/commit/70d33f33200c7e77df910957595715f81f9bec24) * https://github.com/coder/coder/pull/24570 -> update imports in `coder/coder` to use copied code * https://github.com/coder/coder/pull/24586 -> linter fixes and CI integration (also added README.md) * https://github.com/coder/coder/pull/24571 -> added exclude to scripts/check_emdash.sh check Original PR message (before PR squash): Moves coder/aibridge code into coder/coder repository. Omitted files: - `go.mod`, `go.sum`, `.gitignore`, `.github/workflows/ci.yml,` `Makefile`, `LICENSE`, `README.md` (modified README.md is added later) - `.github`, `example`, `buildinfo,` `scripts` directories Simple verification script (will list omitted files) ``` tmp=$(mktemp -d) echo "$tmp" git clone --depth=1 https://github.com/coder/aibridge "$tmp/aibridge" git clone --depth=1 --branch pb/aibridge-code-move https://github.com/coder/coder "$tmp/coder" diff -rq --exclude=.git "$tmp/aibridge" "$tmp/coder/aibridge" # rm -rf "$tmp" ```
262 lines
7.8 KiB
Go
262 lines
7.8 KiB
Go
package integrationtest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/sjson"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/aibridge"
|
|
"github.com/coder/coder/v2/aibridge/config"
|
|
aibcontext "github.com/coder/coder/v2/aibridge/context"
|
|
"github.com/coder/coder/v2/aibridge/fixtures"
|
|
"github.com/coder/coder/v2/aibridge/internal/testutil"
|
|
"github.com/coder/coder/v2/aibridge/mcp"
|
|
"github.com/coder/coder/v2/aibridge/metrics"
|
|
"github.com/coder/coder/v2/aibridge/provider"
|
|
"github.com/coder/coder/v2/aibridge/recorder"
|
|
)
|
|
|
|
const (
|
|
pathAnthropicMessages = "/anthropic/v1/messages"
|
|
pathOpenAIChatCompletions = "/openai/v1/chat/completions"
|
|
pathOpenAIResponses = "/openai/v1/responses"
|
|
pathCopilotChatCompletions = "/copilot/chat/completions"
|
|
pathCopilotResponses = "/copilot/responses"
|
|
|
|
// providerBedrock identifies a Bedrock provider in [withProvider].
|
|
// other providers use config.Provider* constants.
|
|
providerBedrock = "bedrock"
|
|
|
|
// defaults
|
|
apiKey = "api-key"
|
|
defaultActorID = "ae235cc1-9f8f-417d-a636-a7b170bac62e"
|
|
)
|
|
|
|
var defaultTracer = otel.Tracer("integrationtest")
|
|
|
|
type bridgeConfig struct {
|
|
providerBuilders []func(upstreamURL string) aibridge.Provider
|
|
metrics *metrics.Metrics
|
|
tracer trace.Tracer
|
|
mcpProxy mcp.ServerProxier
|
|
userID string
|
|
metadata recorder.Metadata
|
|
logger slog.Logger
|
|
}
|
|
|
|
// bridgeTestServer wraps an httptest.Server running a RequestBridge.
|
|
type bridgeTestServer struct {
|
|
*httptest.Server
|
|
Recorder *testutil.MockRecorder
|
|
Bridge *aibridge.RequestBridge
|
|
}
|
|
|
|
// makeRequest builds and executes an HTTP request against this server.
|
|
// Optional headers are applied after the default Content-Type.
|
|
func (s *bridgeTestServer) makeRequest(t *testing.T, method string, path string, body []byte, header ...http.Header) (*http.Response, error) {
|
|
t.Helper()
|
|
|
|
req, err := http.NewRequestWithContext(t.Context(), method, s.URL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
for _, h := range header {
|
|
for k, vals := range h {
|
|
for _, v := range vals {
|
|
req.Header.Add(k, v)
|
|
}
|
|
}
|
|
}
|
|
return http.DefaultClient.Do(req)
|
|
}
|
|
|
|
type bridgeOption func(*bridgeConfig)
|
|
|
|
// withProvider adds a default-configured provider of the given type.
|
|
// When any provider option is used, the default "all providers" set is not created.
|
|
func withProvider(providerType string) bridgeOption {
|
|
return func(c *bridgeConfig) {
|
|
c.providerBuilders = append(c.providerBuilders, func(addr string) aibridge.Provider {
|
|
return newDefaultProvider(providerType, addr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// withCustomProvider adds a pre-built provider. The upstream URL passed to
|
|
// [newBridgeTestServer] is ignored for this provider.
|
|
// When any provider option is used, the default "all providers" set is not created.
|
|
func withCustomProvider(p aibridge.Provider) bridgeOption {
|
|
return func(c *bridgeConfig) {
|
|
c.providerBuilders = append(c.providerBuilders, func(string) aibridge.Provider {
|
|
return p
|
|
})
|
|
}
|
|
}
|
|
|
|
// withMetrics sets the Prometheus metrics for the bridge.
|
|
func withMetrics(m *metrics.Metrics) bridgeOption {
|
|
return func(c *bridgeConfig) { c.metrics = m }
|
|
}
|
|
|
|
// withTracer overrides the default tracer.
|
|
func withTracer(t trace.Tracer) bridgeOption {
|
|
return func(c *bridgeConfig) { c.tracer = t }
|
|
}
|
|
|
|
// withMCP sets the MCP server proxier (default: NoopMCPManager).
|
|
func withMCP(p mcp.ServerProxier) bridgeOption {
|
|
return func(c *bridgeConfig) { c.mcpProxy = p }
|
|
}
|
|
|
|
// withActor sets the actor ID and metadata for the BaseContext.
|
|
func withActor(id string, md recorder.Metadata) bridgeOption {
|
|
return func(c *bridgeConfig) { c.userID = id; c.metadata = md }
|
|
}
|
|
|
|
// newBridgeTestServer creates a fully configured test server running
|
|
// a RequestBridge with sensible defaults:
|
|
// - All standard providers (unless withProvider / withCustomProvider)
|
|
// - NoopMCPManager (unless withMCP)
|
|
// - slogtest debug logger
|
|
// - defaultTracer (unless withTracer)
|
|
// - defaultActorID (unless withActor)
|
|
func newBridgeTestServer(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
upstreamURL string,
|
|
opts ...bridgeOption,
|
|
) *bridgeTestServer {
|
|
t.Helper()
|
|
|
|
cfg := &bridgeConfig{
|
|
userID: defaultActorID,
|
|
}
|
|
for _, o := range opts {
|
|
o(cfg)
|
|
}
|
|
if cfg.tracer == nil {
|
|
cfg.tracer = defaultTracer
|
|
}
|
|
cfg.logger = newLogger(t)
|
|
if cfg.mcpProxy == nil {
|
|
cfg.mcpProxy = newNoopMCPManager()
|
|
}
|
|
|
|
// Resolve providers: use explicit builders when provided, otherwise
|
|
// create default providers for every supported type.
|
|
var providers []aibridge.Provider
|
|
if len(cfg.providerBuilders) > 0 {
|
|
for _, b := range cfg.providerBuilders {
|
|
providers = append(providers, b(upstreamURL))
|
|
}
|
|
} else {
|
|
providers = []aibridge.Provider{
|
|
newDefaultProvider(config.ProviderAnthropic, upstreamURL),
|
|
newDefaultProvider(config.ProviderOpenAI, upstreamURL),
|
|
}
|
|
}
|
|
|
|
mockRec := &testutil.MockRecorder{}
|
|
rec := aibridge.NewRecorder(cfg.logger, cfg.tracer, func() (aibridge.Recorder, error) {
|
|
return mockRec, nil
|
|
})
|
|
|
|
bridge, err := aibridge.NewRequestBridge(
|
|
ctx, providers, rec, cfg.mcpProxy,
|
|
cfg.logger, cfg.metrics, cfg.tracer,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
actorID, md := cfg.userID, cfg.metadata
|
|
srv := httptest.NewUnstartedServer(bridge)
|
|
srv.Config.BaseContext = func(_ net.Listener) context.Context {
|
|
return aibcontext.AsActor(ctx, actorID, md)
|
|
}
|
|
srv.Start()
|
|
t.Cleanup(srv.Close)
|
|
|
|
return &bridgeTestServer{
|
|
Server: srv,
|
|
Recorder: mockRec,
|
|
Bridge: bridge,
|
|
}
|
|
}
|
|
|
|
// setupInjectedToolTest abstracts common setup required for injected-tool integration tests.
|
|
// Extra bridge options (e.g. [withProvider]) are appended after the built-in
|
|
// MCP / tracer / actor options. When no provider option is given the default
|
|
// provider set (all providers) is used.
|
|
func setupInjectedToolTest(
|
|
t *testing.T,
|
|
fixture []byte,
|
|
streaming bool,
|
|
tracer trace.Tracer,
|
|
path string,
|
|
toolRequestValidatorFn func(*http.Request, []byte),
|
|
opts ...bridgeOption,
|
|
) (*bridgeTestServer, *mockMCP, *http.Response) {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
t.Cleanup(cancel)
|
|
|
|
fix := fixtures.Parse(t, fixture)
|
|
|
|
// Setup mock server for multi-turn interaction.
|
|
// First request → tool call response
|
|
// Second request → final response.
|
|
firstResp := newFixtureResponse(fix)
|
|
toolResp := newFixtureToolResponse(fix)
|
|
toolResp.OnRequest = toolRequestValidatorFn
|
|
upstream := newMockUpstream(ctx, t, firstResp, toolResp)
|
|
|
|
mockMCP := setupMCPForTest(t, tracer)
|
|
|
|
allOpts := []bridgeOption{
|
|
withMCP(mockMCP),
|
|
withTracer(tracer),
|
|
withActor(defaultActorID, nil),
|
|
}
|
|
allOpts = append(allOpts, opts...)
|
|
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, allOpts...)
|
|
|
|
// 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, path, reqBody)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Wait both requests (initial + tool call result)
|
|
require.Eventually(t, func() bool {
|
|
return upstream.Calls.Load() == 2
|
|
}, testutil.WaitMedium, testutil.IntervalFast)
|
|
|
|
return bridgeServer, mockMCP, resp
|
|
}
|
|
|
|
// newDefaultProvider creates a Provider with default test configuration.
|
|
func newDefaultProvider(providerType string, addr string) aibridge.Provider {
|
|
switch providerType {
|
|
case config.ProviderAnthropic:
|
|
return provider.NewAnthropic(anthropicCfg(addr, apiKey), nil)
|
|
case config.ProviderOpenAI:
|
|
return provider.NewOpenAI(openAICfg(addr, apiKey))
|
|
case providerBedrock:
|
|
return provider.NewAnthropic(anthropicCfg(addr, apiKey), bedrockCfg(addr))
|
|
default:
|
|
panic("unknown provider type: " + providerType)
|
|
}
|
|
}
|