Files
coder/aibridge/internal/integrationtest/apidump_test.go
T
Paweł Banaszewski e00e85765b chore: move aibridge library code into coder repo (#24190)
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"
```
2026-04-22 17:01:01 +02:00

315 lines
10 KiB
Go

package integrationtest //nolint:testpackage // tests unexported internals
import (
"bufio"
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"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/apidump"
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/provider"
)
func TestAPIDump(t *testing.T) {
t.Parallel()
cases := []struct {
name string
fixture []byte
providerFunc func(addr, dumpDir string) aibridge.Provider
path string
headers http.Header
expectProviderDir string
}{
{
name: "anthropic",
fixture: fixtures.AntSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewAnthropic(anthropicCfgWithAPIDump(addr, apiKey, dumpDir), nil)
},
path: pathAnthropicMessages,
expectProviderDir: config.ProviderAnthropic,
},
{
name: "openai_chat_completions",
fixture: fixtures.OaiChatSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir))
},
path: pathOpenAIChatCompletions,
expectProviderDir: config.ProviderOpenAI,
},
{
name: "openai_responses",
fixture: fixtures.OaiResponsesBlockingSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir))
},
path: pathOpenAIResponses,
expectProviderDir: config.ProviderOpenAI,
},
{
name: "copilot_chat_completions",
fixture: fixtures.OaiChatSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir})
},
path: pathCopilotChatCompletions,
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
expectProviderDir: config.ProviderCopilot,
},
{
name: "copilot_responses",
fixture: fixtures.OaiResponsesBlockingSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir})
},
path: pathCopilotResponses,
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
expectProviderDir: config.ProviderCopilot,
},
{
name: "copilot_custom_name_chat_completions",
fixture: fixtures.OaiChatSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewCopilot(config.Copilot{
Name: "copilot-business",
BaseURL: addr,
APIDumpDir: dumpDir,
})
},
path: "/copilot-business/chat/completions",
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
expectProviderDir: "copilot-business",
},
{
name: "copilot_custom_name_responses",
fixture: fixtures.OaiChatSimple,
providerFunc: func(addr, dumpDir string) aibridge.Provider {
return provider.NewCopilot(config.Copilot{
Name: "copilot-enterprise",
BaseURL: addr,
APIDumpDir: dumpDir,
})
},
path: "/copilot-enterprise/chat/completions",
headers: http.Header{"Authorization": {"Bearer test-copilot-token"}},
expectProviderDir: "copilot-enterprise",
},
}
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 upstream server.
fix := fixtures.Parse(t, tc.fixture)
srv := newMockUpstream(ctx, t, newFixtureResponse(fix))
// Create temp dir for API dumps.
dumpDir := t.TempDir()
bridgeServer := newBridgeTestServer(ctx, t, srv.URL,
withCustomProvider(tc.providerFunc(srv.URL, dumpDir)),
)
resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request(), tc.headers)
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 dump files were created.
interceptions := bridgeServer.Recorder.RecordedInterceptions()
require.Len(t, interceptions, 1)
interceptionID := interceptions[0].ID
// Find dump files for this interception by walking the dump directory.
var reqDumpFile, respDumpFile string
err = filepath.Walk(dumpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Files are named: {timestamp}-{interceptionID}.{req|resp}.txt
if strings.Contains(path, interceptionID) {
if strings.HasSuffix(path, apidump.SuffixRequest) {
reqDumpFile = path
} else if strings.HasSuffix(path, apidump.SuffixResponse) {
respDumpFile = path
}
}
return nil
})
require.NoError(t, err)
require.NotEmpty(t, reqDumpFile, "request dump file should exist")
require.NotEmpty(t, respDumpFile, "response dump file should exist")
// Verify dump files are in the correct provider subdirectory.
require.Contains(t, reqDumpFile, filepath.Join(dumpDir, tc.expectProviderDir)+"/",
"request dump should be in the %s provider directory", tc.expectProviderDir)
require.Contains(t, respDumpFile, filepath.Join(dumpDir, tc.expectProviderDir)+"/",
"response dump should be in the %s provider directory", tc.expectProviderDir)
// Verify request dump contains expected HTTP request format.
reqDumpData, err := os.ReadFile(reqDumpFile)
require.NoError(t, err)
// Parse the dumped HTTP request.
dumpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(reqDumpData)))
require.NoError(t, err)
dumpBody, err := io.ReadAll(dumpReq.Body)
require.NoError(t, err)
// Compare requests semantically (key order may differ).
require.JSONEq(t, string(dumpBody), string(fix.Request()), "request body JSON should match semantically")
// Verify response dump contains expected HTTP response format.
respDumpData, err := os.ReadFile(respDumpFile)
require.NoError(t, err)
// Parse the dumped HTTP response.
dumpResp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respDumpData)), nil)
require.NoError(t, err)
defer dumpResp.Body.Close()
require.Equal(t, http.StatusOK, dumpResp.StatusCode)
dumpRespBody, err := io.ReadAll(dumpResp.Body)
require.NoError(t, err)
// Compare responses semantically (key order may differ).
expectedRespBody := fix.NonStreaming()
require.JSONEq(t, string(expectedRespBody), string(dumpRespBody), "response body JSON should match semantically")
bridgeServer.Recorder.VerifyAllInterceptionsEnded(t)
})
}
}
func TestAPIDumpPassthrough(t *testing.T) {
t.Parallel()
const responseBody = `{"object":"list","data":[{"id":"gpt-4","object":"model"}]}`
cases := []struct {
name string
providerFunc func(addr string, dumpDir string) aibridge.Provider
requestPath string
expectDumpName string
}{
{
name: "anthropic",
providerFunc: func(addr string, dumpDir string) aibridge.Provider {
return provider.NewAnthropic(anthropicCfgWithAPIDump(addr, apiKey, dumpDir), nil)
},
requestPath: "/anthropic/v1/models",
expectDumpName: "-v1-models-",
},
{
name: "openai",
providerFunc: func(addr string, dumpDir string) aibridge.Provider {
return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir))
},
requestPath: "/openai/v1/models",
expectDumpName: "-models-",
},
{
name: "copilot",
providerFunc: func(addr string, dumpDir string) aibridge.Provider {
return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir})
},
requestPath: "/copilot/models",
expectDumpName: "-models-",
},
}
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)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(responseBody))
}))
t.Cleanup(upstream.Close)
dumpDir := t.TempDir()
bridgeServer := newBridgeTestServer(ctx, t, upstream.URL,
withCustomProvider(tc.providerFunc(upstream.URL, dumpDir)),
)
resp, err := bridgeServer.makeRequest(t, http.MethodGet, tc.requestPath, nil)
require.NoError(t, err)
defer resp.Body.Close()
// Find dump files in the passthrough directory.
passthroughDir := filepath.Join(dumpDir, tc.name, "passthrough")
var reqDumpFile, respDumpFile string
err = filepath.Walk(passthroughDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(path, apidump.SuffixRequest) {
reqDumpFile = path
} else if strings.HasSuffix(path, apidump.SuffixResponse) {
respDumpFile = path
}
return nil
})
require.NoError(t, err, "walking failed: %v", err)
require.NotEmpty(t, reqDumpFile, "request dump file should exist")
require.FileExists(t, reqDumpFile)
require.Contains(t, reqDumpFile, "/passthrough/")
require.Contains(t, reqDumpFile, tc.expectDumpName)
require.NotEmpty(t, respDumpFile, "response dump file should exist")
require.FileExists(t, respDumpFile)
require.Contains(t, respDumpFile, "/passthrough/")
require.Contains(t, respDumpFile, tc.expectDumpName)
// Verify request dump.
reqDumpData, err := os.ReadFile(reqDumpFile)
require.NoError(t, err)
dumpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(reqDumpData)))
require.NoError(t, err)
require.Equal(t, http.MethodGet, dumpReq.Method)
// Verify response dump.
respDumpData, err := os.ReadFile(respDumpFile)
require.NoError(t, err)
dumpResp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respDumpData)), nil)
require.NoError(t, err)
defer dumpResp.Body.Close()
require.Equal(t, http.StatusOK, dumpResp.StatusCode)
dumpRespBody, err := io.ReadAll(dumpResp.Body)
require.NoError(t, err)
require.JSONEq(t, responseBody, string(dumpRespBody))
})
}
}