chore: handle deprecated aibridge experimental routes (#20565)

In v2.28 we're [removing the aibridge
experiment](https://github.com/coder/coder/pull/20544).

We need to handle `/api/experimental/aibridge/*` until Beta (next
release).

Signed-off-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Danny Kopping
2025-10-29 19:11:34 -06:00
committed by GitHub
parent 54497f4f6b
commit ff532d9bf3
3 changed files with 107 additions and 19 deletions
+34
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -23,6 +25,38 @@ const (
defaultListInterceptionsLimit = 100
)
// aibridgeHandler handles all aibridged-related endpoints.
func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
return func(r chi.Router) {
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
r.Group(func(r chi.Router) {
r.Use(middlewares...)
r.Get("/interceptions", api.aiBridgeListInterceptions)
})
// This is a bit funky but since aibridge only exposes a HTTP
// handler, this is how it has to be.
r.HandleFunc("/*", func(rw http.ResponseWriter, r *http.Request) {
if api.aibridgedHandler == nil {
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
Message: "aibridged handler not mounted",
})
return
}
// Strip either the experimental or stable prefix.
// TODO: experimental route is deprecated and must be removed with Beta.
prefixes := []string{"/api/experimental/aibridge", "/api/v2/aibridge"}
for _, prefix := range prefixes {
if strings.Contains(r.URL.String(), prefix) {
http.StripPrefix(prefix, api.aibridgedHandler).ServeHTTP(rw, r)
break
}
}
})
}
}
// aiBridgeListInterceptions returns all AIBridge interceptions a user can read.
// Optional filters with query params
//
+66
View File
@@ -1,6 +1,7 @@
package coderd_test
import (
"io"
"net/http"
"testing"
"time"
@@ -592,3 +593,68 @@ func TestAIBridgeListInterceptions(t *testing.T) {
}
})
}
func TestAIBridgeRouting(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
t.Cleanup(func() {
_ = closer.Close()
})
// Register a simple test handler that echoes back the request path.
testHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write([]byte(r.URL.Path))
})
api.RegisterInMemoryAIBridgedHTTPHandler(testHandler)
cases := []struct {
name string
path string
expectedPath string
}{
{
name: "StablePrefix",
path: "/api/v2/aibridge/openai/v1/chat/completions",
expectedPath: "/openai/v1/chat/completions",
},
{
name: "ExperimentalPrefix",
path: "/api/experimental/aibridge/openai/v1/chat/completions",
expectedPath: "/openai/v1/chat/completions",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.URL.String()+tc.path, nil)
require.NoError(t, err)
req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Verify that the prefix was stripped correctly and the path was forwarded.
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, tc.expectedPath, string(body))
})
}
}
+7 -19
View File
@@ -226,26 +226,14 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
return api.refreshEntitlements(ctx)
}
api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Route("/aibridge", func(r chi.Router) {
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/interceptions", api.aiBridgeListInterceptions)
})
api.AGPL.ExperimentalHandler.Group(func(r chi.Router) {
// Deprecated.
// TODO: remove with Beta release.
r.Route("/aibridge", aibridgeHandler(api, apiKeyMiddleware))
})
// This is a bit funky but since aibridge only exposes a HTTP
// handler, this is how it has to be.
r.HandleFunc("/*", func(rw http.ResponseWriter, r *http.Request) {
if api.aibridgedHandler == nil {
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
Message: "aibridged handler not mounted",
})
return
}
http.StripPrefix("/api/v2/aibridge", api.aibridgedHandler).ServeHTTP(rw, r)
})
})
api.AGPL.APIHandler.Group(func(r chi.Router) {
r.Route("/aibridge", aibridgeHandler(api, apiKeyMiddleware))
})
api.AGPL.APIHandler.Group(func(r chi.Router) {