Skip to content

Commit dd80481

Browse files
authored
fix: fixes double v1 prefix in passthrough openai routes (#174)
fix: fixes doulbe `v1` prefix in passthrough openai routes Bug introduced in #159 With default OpenAI base url `https://api.openai.com/v1/` pass though routes have `v1` prefix added 2 times (from base url path + pass though route prefix) resulting in requests being forwarded to `https://api.openai.com/v1/v1/` This PR fixes the issue. Fixes: #176
1 parent aed7dd9 commit dd80481

13 files changed

Lines changed: 346 additions & 144 deletions

bridge.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"net/url"
78
"strings"
89
"sync"
910
"sync/atomic"
@@ -90,7 +91,17 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
9091
// Add the known provider-specific routes which are bridged (i.e. intercepted and augmented).
9192
for _, path := range prov.BridgedRoutes() {
9293
handler := newInterceptionProcessor(prov, cbs, rec, mcpProxy, logger, m, tracer)
93-
mux.Handle(path, handler)
94+
route, err := url.JoinPath(prov.RoutePrefix(), path)
95+
if err != nil {
96+
logger.Error(ctx, "failed to join path",
97+
slog.Error(err),
98+
slog.F("provider", providerName),
99+
slog.F("prefix", prov.RoutePrefix()),
100+
slog.F("path", path),
101+
)
102+
return nil, fmt.Errorf("failed to configure provider '%v': failed to join bridged path: %w", providerName, err)
103+
}
104+
mux.Handle(route, handler)
94105
}
95106

96107
// Any requests which passthrough to this will be reverse-proxied to the upstream.
@@ -99,9 +110,17 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re
99110
// configured, so we should just reverse-proxy known-safe routes.
100111
ftr := newPassthroughRouter(prov, logger.Named(fmt.Sprintf("passthrough.%s", prov.Name())), m, tracer)
101112
for _, path := range prov.PassthroughRoutes() {
102-
prefix := fmt.Sprintf("/%s", prov.Name())
103-
route := fmt.Sprintf("%s%s", prefix, path)
104-
mux.HandleFunc(route, http.StripPrefix(prefix, ftr).ServeHTTP)
113+
route, err := url.JoinPath(prov.RoutePrefix(), path)
114+
if err != nil {
115+
logger.Error(ctx, "failed to join path",
116+
slog.Error(err),
117+
slog.F("provider", providerName),
118+
slog.F("prefix", prov.RoutePrefix()),
119+
slog.F("path", path),
120+
)
121+
return nil, fmt.Errorf("failed to configure provider '%v': failed to join passed through path: %w", providerName, err)
122+
}
123+
mux.Handle(route, http.StripPrefix(prov.RoutePrefix(), ftr))
105124
}
106125
}
107126

bridge_integration_test.go

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -772,17 +772,21 @@ func TestFallthrough(t *testing.T) {
772772
t.Parallel()
773773

774774
testCases := []struct {
775-
name string
776-
providerName string
777-
fixture []byte
778-
basePath string
779-
configureFunc func(string, aibridge.Recorder) (aibridge.Provider, *aibridge.RequestBridge)
775+
name string
776+
providerName string
777+
fixture []byte
778+
basePath string
779+
requestPath string
780+
expectedUpstreamPath string
781+
configureFunc func(string, aibridge.Recorder) (aibridge.Provider, *aibridge.RequestBridge)
780782
}{
781783
{
782-
name: "ant_empty_base_url_path",
783-
providerName: config.ProviderAnthropic,
784-
fixture: fixtures.AntFallthrough,
785-
basePath: "",
784+
name: "ant_empty_base_url_path",
785+
providerName: config.ProviderAnthropic,
786+
fixture: fixtures.AntFallthrough,
787+
basePath: "",
788+
requestPath: "/anthropic/v1/models",
789+
expectedUpstreamPath: "/v1/models",
786790
configureFunc: func(addr string, client aibridge.Recorder) (aibridge.Provider, *aibridge.RequestBridge) {
787791
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
788792
provider := provider.NewAnthropic(anthropicCfg(addr, apiKey), nil)
@@ -792,10 +796,12 @@ func TestFallthrough(t *testing.T) {
792796
},
793797
},
794798
{
795-
name: "oai_empty_base_url_path",
796-
providerName: config.ProviderOpenAI,
797-
fixture: fixtures.OaiChatFallthrough,
798-
basePath: "",
799+
name: "oai_empty_base_url_path",
800+
providerName: config.ProviderOpenAI,
801+
fixture: fixtures.OaiChatFallthrough,
802+
basePath: "",
803+
requestPath: "/openai/v1/models",
804+
expectedUpstreamPath: "/models",
799805
configureFunc: func(addr string, client aibridge.Recorder) (aibridge.Provider, *aibridge.RequestBridge) {
800806
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
801807
provider := provider.NewOpenAI(openaiCfg(addr, apiKey))
@@ -805,10 +811,12 @@ func TestFallthrough(t *testing.T) {
805811
},
806812
},
807813
{
808-
name: "ant_some_base_url_path",
809-
providerName: config.ProviderAnthropic,
810-
fixture: fixtures.AntFallthrough,
811-
basePath: "/api",
814+
name: "ant_some_base_url_path",
815+
providerName: config.ProviderAnthropic,
816+
fixture: fixtures.AntFallthrough,
817+
basePath: "/api",
818+
requestPath: "/anthropic/v1/models",
819+
expectedUpstreamPath: "/api/v1/models",
812820
configureFunc: func(addr string, client aibridge.Recorder) (aibridge.Provider, *aibridge.RequestBridge) {
813821
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
814822
provider := provider.NewAnthropic(anthropicCfg(addr, apiKey), nil)
@@ -818,10 +826,12 @@ func TestFallthrough(t *testing.T) {
818826
},
819827
},
820828
{
821-
name: "oai_some_base_url_path",
822-
providerName: config.ProviderOpenAI,
823-
fixture: fixtures.OaiChatFallthrough,
824-
basePath: "/api",
829+
name: "oai_some_base_url_path",
830+
providerName: config.ProviderOpenAI,
831+
fixture: fixtures.OaiChatFallthrough,
832+
basePath: "/api",
833+
requestPath: "/openai/v1/models",
834+
expectedUpstreamPath: "/api/models",
825835
configureFunc: func(addr string, client aibridge.Recorder) (aibridge.Provider, *aibridge.RequestBridge) {
826836
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
827837
provider := provider.NewOpenAI(openaiCfg(addr, apiKey))
@@ -841,7 +851,7 @@ func TestFallthrough(t *testing.T) {
841851

842852
files := filesMap(arc)
843853
require.Contains(t, files, fixtureResponse)
844-
expectedPath := tc.basePath + "/v1/models"
854+
expectedPath := tc.expectedUpstreamPath
845855

846856
var receivedHeaders *http.Header
847857
respBody := files[fixtureResponse]
@@ -871,7 +881,7 @@ func TestFallthrough(t *testing.T) {
871881
bridgeSrv.Start()
872882
t.Cleanup(bridgeSrv.Close)
873883

874-
req, err := http.NewRequestWithContext(t.Context(), "GET", fmt.Sprintf("%s/%s/v1/models", bridgeSrv.URL, tc.providerName), nil)
884+
req, err := http.NewRequestWithContext(t.Context(), "GET", fmt.Sprintf("%s%s", bridgeSrv.URL, tc.requestPath), nil)
875885
require.NoError(t, err)
876886

877887
resp, err := http.DefaultClient.Do(req)

bridge_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package aibridge
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"cdr.dev/slog/v3/sloggers/slogtest"
9+
"github.com/coder/aibridge/config"
10+
"github.com/coder/aibridge/internal/testutil"
11+
"github.com/coder/aibridge/provider"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestPassthroughRoutesForProviders(t *testing.T) {
17+
t.Parallel()
18+
19+
upstreamRespBody := "upstream response"
20+
tests := []struct {
21+
name string
22+
baseURLPath string
23+
requestPath string
24+
provider func(string) provider.Provider
25+
expectPath string
26+
}{
27+
{
28+
name: "openAI_no_base_path",
29+
requestPath: "/openai/v1/conversations",
30+
provider: func(baseURL string) provider.Provider {
31+
return NewOpenAIProvider(config.OpenAI{BaseURL: baseURL})
32+
},
33+
expectPath: "/conversations",
34+
},
35+
{
36+
name: "openAI_with_base_path",
37+
baseURLPath: "/v1",
38+
requestPath: "/openai/v1/conversations",
39+
provider: func(baseURL string) provider.Provider {
40+
return NewOpenAIProvider(config.OpenAI{BaseURL: baseURL})
41+
},
42+
expectPath: "/v1/conversations",
43+
},
44+
{
45+
name: "anthropic_no_base_path",
46+
requestPath: "/anthropic/v1/models",
47+
provider: func(baseURL string) provider.Provider {
48+
return NewAnthropicProvider(config.Anthropic{BaseURL: baseURL}, nil)
49+
},
50+
expectPath: "/v1/models",
51+
},
52+
{
53+
name: "anthropic_with_base_path",
54+
baseURLPath: "/v1",
55+
requestPath: "/anthropic/v1/models",
56+
provider: func(baseURL string) provider.Provider {
57+
return NewAnthropicProvider(config.Anthropic{BaseURL: baseURL}, nil)
58+
},
59+
expectPath: "/v1/v1/models",
60+
},
61+
{
62+
name: "copilot_no_base_path",
63+
requestPath: "/copilot/models",
64+
provider: func(baseURL string) provider.Provider {
65+
return NewCopilotProvider(config.Copilot{BaseURL: baseURL})
66+
},
67+
expectPath: "/models",
68+
},
69+
{
70+
name: "copilot_with_base_path",
71+
baseURLPath: "/v1",
72+
requestPath: "/copilot/models",
73+
provider: func(baseURL string) provider.Provider {
74+
return NewCopilotProvider(config.Copilot{BaseURL: baseURL})
75+
},
76+
expectPath: "/v1/models",
77+
},
78+
}
79+
80+
for _, tc := range tests {
81+
t.Run(tc.name, func(t *testing.T) {
82+
t.Parallel()
83+
84+
logger := slogtest.Make(t, nil)
85+
86+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
assert.Equal(t, tc.expectPath, r.URL.Path)
88+
w.WriteHeader(http.StatusOK)
89+
_, _ = w.Write([]byte(upstreamRespBody))
90+
}))
91+
t.Cleanup(upstream.Close)
92+
93+
recorder := testutil.MockRecorder{}
94+
prov := tc.provider(upstream.URL + tc.baseURLPath)
95+
bridge, err := NewRequestBridge(t.Context(), []provider.Provider{prov}, &recorder, nil, logger, nil, testTracer)
96+
require.NoError(t, err)
97+
98+
req := httptest.NewRequest("", tc.requestPath, nil)
99+
resp := httptest.NewRecorder()
100+
bridge.mux.ServeHTTP(resp, req)
101+
102+
assert.Equal(t, http.StatusOK, resp.Code)
103+
assert.Contains(t, resp.Body.String(), upstreamRespBody)
104+
})
105+
}
106+
}

0 commit comments

Comments
 (0)