Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
.idea
**/*.swp

# AI tools
.claude

# Project specific
example/aibridge.db
build/
Expand Down
3 changes: 2 additions & 1 deletion example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ func main() {
// Configure providers.
providers := []aibridge.Provider{
aibridge.NewAnthropicProvider(aibridge.AnthropicConfig{
Key: os.Getenv("ANTHROPIC_API_KEY"),
Key: os.Getenv("ANTHROPIC_API_KEY"),
BaseURL: os.Getenv("ANTHROPIC_BASE_URL"),
}, nil),
aibridge.NewOpenAIProvider(aibridge.OpenAIConfig{
Key: os.Getenv("OPENAI_API_KEY"),
Expand Down
17 changes: 16 additions & 1 deletion intercept/apidump/apidump.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const (
SuffixRequest = ".req.txt"
// SuffixResponse is the file suffix for response dump files.
SuffixResponse = ".resp.txt"
// SuffixError is the file suffix for error dump files written when a request fails.
SuffixError = ".req_error.txt"
)

// MiddlewareNext is the function to call the next middleware or the actual request.
Expand All @@ -51,9 +53,11 @@ func NewBridgeMiddleware(baseDir string, provider string, model string, intercep
logger.Named("apidump").Warn(req.Context(), "failed to dump request", slog.Error(err))
}

// TODO: https://github.com/coder/aibridge/issues/129
resp, err := next(req)
if err != nil {
if dumpErr := d.dumpError(err); dumpErr != nil {
logger.Named("apidump").Warn(req.Context(), "failed to dump request error", slog.Error(dumpErr))
}
return resp, err
}

Expand Down Expand Up @@ -113,6 +117,14 @@ func (d *dumper) dumpRequest(req *http.Request) error {
return os.WriteFile(dumpPath, buf.Bytes(), 0o644) //nolint:gosec // https://github.com/coder/aibridge/pull/256#discussion_r3072143983
}

func (d *dumper) dumpError(reqErr error) error {
dumpPath := d.dumpPath + SuffixError
if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil {
return xerrors.Errorf("create dump dir: %w", err)
}
return os.WriteFile(dumpPath, []byte(reqErr.Error()+"\n"), 0o644) //nolint:gosec // same rationale as other dump files
}

func (d *dumper) dumpResponse(resp *http.Response) error {
dumpPath := d.dumpPath + SuffixResponse

Expand Down Expand Up @@ -248,6 +260,9 @@ func (rt *dumpRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)

resp, err := rt.inner.RoundTrip(req)
if err != nil {
if dumpErr := dumper.dumpError(err); dumpErr != nil {
dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request error", slog.Error(dumpErr))
}
return resp, err
}

Expand Down
34 changes: 34 additions & 0 deletions intercept/apidump/apidump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,34 @@ func TestBridgedMiddleware_RedactsSensitiveResponseHeaders(t *testing.T) {
require.Contains(t, content, "X-Request-Id: req-123")
}

func TestBridgedMiddleware_WritesErrorFile_WhenNextFails(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
clk := quartz.NewMock(t)
interceptionID := uuid.New()

middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk)
require.NotNil(t, middleware)

req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`)))
require.NoError(t, err)

upstreamErr := io.ErrUnexpectedEOF
resp, err := middleware(req, func(_ *http.Request) (*http.Response, error) { //nolint:bodyclose // resp is nil on error
return nil, upstreamErr
})
require.ErrorIs(t, err, upstreamErr)
require.Nil(t, resp)

modelDir := filepath.Join(tmpDir, "openai", "gpt-4")
errDumpPath := findDumpFile(t, modelDir, SuffixError)
content, readErr := os.ReadFile(errDumpPath)
require.NoError(t, readErr)
require.Contains(t, string(content), upstreamErr.Error())
}

func TestBridgedMiddleware_EmptyBaseDir_ReturnsNil(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -365,6 +393,12 @@ func TestPassthroughMiddleware(t *testing.T) {
resp, err := rt.RoundTrip(req) //nolint:bodyclose // resp is nil on error
require.ErrorIs(t, err, innerErr)
require.Nil(t, resp)

passthroughDir := filepath.Join(tmpDir, "openai", "passthrough")
errDumpPath := findDumpFile(t, passthroughDir, SuffixError)
content, readErr := os.ReadFile(errDumpPath)
require.NoError(t, readErr)
require.Contains(t, string(content), innerErr.Error())
})

t.Run("dumps_request_and_response", func(t *testing.T) {
Expand Down
Loading