Skip to content

Commit ff532d9

Browse files
authored
chore: handle deprecated aibridge experimental routes (coder#20565)
In v2.28 we're [removing the aibridge experiment](coder#20544). We need to handle `/api/experimental/aibridge/*` until Beta (next release). Signed-off-by: Danny Kopping <danny@coder.com>
1 parent 54497f4 commit ff532d9

File tree

3 files changed

+107
-19
lines changed

3 files changed

+107
-19
lines changed

enterprise/coderd/aibridge.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"strings"
78

9+
"github.com/go-chi/chi/v5"
810
"github.com/google/uuid"
911
"golang.org/x/xerrors"
1012

@@ -23,6 +25,38 @@ const (
2325
defaultListInterceptionsLimit = 100
2426
)
2527

28+
// aibridgeHandler handles all aibridged-related endpoints.
29+
func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
30+
return func(r chi.Router) {
31+
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
32+
r.Group(func(r chi.Router) {
33+
r.Use(middlewares...)
34+
r.Get("/interceptions", api.aiBridgeListInterceptions)
35+
})
36+
37+
// This is a bit funky but since aibridge only exposes a HTTP
38+
// handler, this is how it has to be.
39+
r.HandleFunc("/*", func(rw http.ResponseWriter, r *http.Request) {
40+
if api.aibridgedHandler == nil {
41+
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
42+
Message: "aibridged handler not mounted",
43+
})
44+
return
45+
}
46+
47+
// Strip either the experimental or stable prefix.
48+
// TODO: experimental route is deprecated and must be removed with Beta.
49+
prefixes := []string{"/api/experimental/aibridge", "/api/v2/aibridge"}
50+
for _, prefix := range prefixes {
51+
if strings.Contains(r.URL.String(), prefix) {
52+
http.StripPrefix(prefix, api.aibridgedHandler).ServeHTTP(rw, r)
53+
break
54+
}
55+
}
56+
})
57+
}
58+
}
59+
2660
// aiBridgeListInterceptions returns all AIBridge interceptions a user can read.
2761
// Optional filters with query params
2862
//

enterprise/coderd/aibridge_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd_test
22

33
import (
4+
"io"
45
"net/http"
56
"testing"
67
"time"
@@ -592,3 +593,68 @@ func TestAIBridgeListInterceptions(t *testing.T) {
592593
}
593594
})
594595
}
596+
597+
func TestAIBridgeRouting(t *testing.T) {
598+
t.Parallel()
599+
600+
dv := coderdtest.DeploymentValues(t)
601+
client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
602+
Options: &coderdtest.Options{
603+
DeploymentValues: dv,
604+
},
605+
LicenseOptions: &coderdenttest.LicenseOptions{
606+
Features: license.Features{
607+
codersdk.FeatureAIBridge: 1,
608+
},
609+
},
610+
})
611+
t.Cleanup(func() {
612+
_ = closer.Close()
613+
})
614+
615+
// Register a simple test handler that echoes back the request path.
616+
testHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
617+
rw.WriteHeader(http.StatusOK)
618+
_, _ = rw.Write([]byte(r.URL.Path))
619+
})
620+
api.RegisterInMemoryAIBridgedHTTPHandler(testHandler)
621+
622+
cases := []struct {
623+
name string
624+
path string
625+
expectedPath string
626+
}{
627+
{
628+
name: "StablePrefix",
629+
path: "/api/v2/aibridge/openai/v1/chat/completions",
630+
expectedPath: "/openai/v1/chat/completions",
631+
},
632+
{
633+
name: "ExperimentalPrefix",
634+
path: "/api/experimental/aibridge/openai/v1/chat/completions",
635+
expectedPath: "/openai/v1/chat/completions",
636+
},
637+
}
638+
639+
for _, tc := range cases {
640+
t.Run(tc.name, func(t *testing.T) {
641+
t.Parallel()
642+
643+
ctx := testutil.Context(t, testutil.WaitLong)
644+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+tc.path, nil)
645+
require.NoError(t, err)
646+
req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
647+
648+
httpClient := &http.Client{}
649+
resp, err := httpClient.Do(req)
650+
require.NoError(t, err)
651+
defer resp.Body.Close()
652+
require.Equal(t, http.StatusOK, resp.StatusCode)
653+
654+
// Verify that the prefix was stripped correctly and the path was forwarded.
655+
body, err := io.ReadAll(resp.Body)
656+
require.NoError(t, err)
657+
require.Equal(t, tc.expectedPath, string(body))
658+
})
659+
}
660+
}

enterprise/coderd/coderd.go

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -226,26 +226,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
226226
return api.refreshEntitlements(ctx)
227227
}
228228

229-
api.AGPL.APIHandler.Group(func(r chi.Router) {
230-
r.Route("/aibridge", func(r chi.Router) {
231-
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
232-
r.Group(func(r chi.Router) {
233-
r.Use(apiKeyMiddleware)
234-
r.Get("/interceptions", api.aiBridgeListInterceptions)
235-
})
229+
api.AGPL.ExperimentalHandler.Group(func(r chi.Router) {
230+
// Deprecated.
231+
// TODO: remove with Beta release.
232+
r.Route("/aibridge", aibridgeHandler(api, apiKeyMiddleware))
233+
})
236234

237-
// This is a bit funky but since aibridge only exposes a HTTP
238-
// handler, this is how it has to be.
239-
r.HandleFunc("/*", func(rw http.ResponseWriter, r *http.Request) {
240-
if api.aibridgedHandler == nil {
241-
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
242-
Message: "aibridged handler not mounted",
243-
})
244-
return
245-
}
246-
http.StripPrefix("/api/v2/aibridge", api.aibridgedHandler).ServeHTTP(rw, r)
247-
})
248-
})
235+
api.AGPL.APIHandler.Group(func(r chi.Router) {
236+
r.Route("/aibridge", aibridgeHandler(api, apiKeyMiddleware))
249237
})
250238

251239
api.AGPL.APIHandler.Group(func(r chi.Router) {

0 commit comments

Comments
 (0)