diff --git a/.gitignore b/.gitignore index aff78ef6..ce234077 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .idea **/*.swp +# AI tools +.claude + # Project specific example/aibridge.db build/ diff --git a/example/main.go b/example/main.go index b6011416..0a54aace 100644 --- a/example/main.go +++ b/example/main.go @@ -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"), diff --git a/intercept/apidump/apidump.go b/intercept/apidump/apidump.go index 9f6f5ec3..63fccf7d 100644 --- a/intercept/apidump/apidump.go +++ b/intercept/apidump/apidump.go @@ -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. @@ -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 } @@ -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 @@ -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 } diff --git a/intercept/apidump/apidump_test.go b/intercept/apidump/apidump_test.go index 1aaf56c0..f3fb30e6 100644 --- a/intercept/apidump/apidump_test.go +++ b/intercept/apidump/apidump_test.go @@ -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() @@ -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) {