diff --git a/cli/server.go b/cli/server.go index cbf4adaa41..86a8cb1fbd 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1114,7 +1114,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // In-memory aibridge daemon. Registered on coderd so chatd can // dispatch LLM requests via the in-process transport without - // crossing the gated /api/v2/aibridge HTTP route. The HTTP route + // crossing the gated /api/v2/ai-gateway HTTP route. The HTTP route // itself is registered (and license-gated) only by enterprise/coderd; // in AGPL builds it does not exist at all. The daemon starts here // unconditionally when the bridge feature is enabled by config so diff --git a/coderd/aibridge/aibridge.go b/coderd/aibridge/aibridge.go index 5c5d93ee0a..6fff76e368 100644 --- a/coderd/aibridge/aibridge.go +++ b/coderd/aibridge/aibridge.go @@ -35,6 +35,15 @@ const ( BaseURLChatGPT = "https://" + HostChatGPT + "/backend-api/codex" ) +// API route prefixes for the AI Gateway and legacy AI Bridge endpoints. +const ( + // AIGatewayRootPath is the URL prefix the AI Gateway handler + // registers all of its routes under. + AIGatewayRootPath = "/api/v2/ai-gateway" + // AIBridgeRootPath is the legacy prefix kept for backward compatibility. + AIBridgeRootPath = "/api/v2/aibridge" +) + // IsBYOK reports whether the request is using BYOK mode, determined // by the presence of the X-Coder-AI-Governance-Token header. func IsBYOK(header http.Header) bool { diff --git a/coderd/aibridge/factory.go b/coderd/aibridge/factory.go index 2746195c22..6b2e7b9a63 100644 --- a/coderd/aibridge/factory.go +++ b/coderd/aibridge/factory.go @@ -59,7 +59,7 @@ func DelegatedAPIKeyIDFromContext(ctx context.Context) (string, bool) { // // The returned RoundTripper is responsible for adapting the caller's request // to the aibridge daemon's mount path: callers hand it an upstream-shaped -// request and the transport rewrites URL.Path to "/api/v2/aibridge//..." +// request and the transport rewrites URL.Path to "/api/v2/ai-gateway//..." // before dispatching. Routing keys on the provider's instance name so callers // can use the same string the proxy daemon and the bridge mount use. // diff --git a/coderd/aibridged.go b/coderd/aibridged.go index f448be39d0..cd97ef54fc 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -19,13 +19,12 @@ import ( "github.com/coder/coder/v2/codersdk/drpcsdk" ) -// GetAIBridgedHandler returns the in-memory aibridge HTTP handler set by -// [API.RegisterInMemoryAIBridgedHTTPHandler], or nil if the daemon has not -// been wired in. Used by the enterprise /api/v2/aibridge route (license-gated) -// to forward requests into the same in-memory handler that chatd dispatches -// to in-process. -func (api *API) GetAIBridgedHandler() http.Handler { - return api.aibridgedHandler +// AIGatewayHandler returns the in-memory AI Gateway HTTP handler +// set by [API.RegisterInMemoryAIBridgedHTTPHandler], or nil if the daemon +// has not been wired in. Callers must apply their own [http.StripPrefix] +// for the route prefix they are mounting under. +func (api *API) AIGatewayHandler() http.Handler { + return api.aiGatewayHandler } // RegisterInMemoryAIBridgedHTTPHandler mounts [aibridged.Server]'s HTTP router onto @@ -42,9 +41,9 @@ func (api *API) RegisterInMemoryAIBridgedHTTPHandler(srv http.Handler) { panic("aibridged cannot be nil") } - api.aibridgedHandler = http.StripPrefix("/api/v2/aibridge", srv) + api.aiGatewayHandler = srv - factory := aibridged.NewTransportFactory(api.aibridgedHandler) + factory := aibridged.NewTransportFactory(http.StripPrefix(agplaibridge.AIGatewayRootPath, srv)) var asInterface agplaibridge.TransportFactory = factory api.AIBridgeTransportFactory.Store(&asInterface) } diff --git a/coderd/aibridged/transport.go b/coderd/aibridged/transport.go index 95b41f860e..c2e4518cfa 100644 --- a/coderd/aibridged/transport.go +++ b/coderd/aibridged/transport.go @@ -12,13 +12,6 @@ import ( "github.com/coder/coder/v2/coderd/aibridge" ) -// aibridgeRootPath is the URL prefix the in-memory aibridged handler -// registers all of its routes under. The in-process round-tripper -// prepends this plus the provider name to every request before -// dispatch so callers can hand it upstream-shaped requests without -// knowing the daemon's mount layout. -const aibridgeRootPath = "/api/v2/aibridge" - // NewTransportFactory returns an [aibridge.TransportFactory] whose RoundTripper // dispatches requests to handler in-process, streaming the response body // through an [io.Pipe] so SSE/NDJSON/chunked responses propagate token-by-token @@ -37,7 +30,7 @@ type transportFactory struct { // TransportFor returns an in-process [http.RoundTripper] that dispatches // requests through the aibridged handler. The provider name is the routing // key the daemon mounts on; the round-tripper rewrites each request's URL -// path to "/api/v2/aibridge//..." before dispatching so +// path to "/api/v2/ai-gateway//..." before dispatching so // callers can build upstream-shaped requests and stay agnostic of the // daemon's mount layout. The source is attached to the request context for // downstream logging; routing does not depend on it. @@ -69,10 +62,10 @@ func (t *inMemoryRoundTripper) RoundTrip(req *http.Request) (*http.Response, err } // Adapt the caller's upstream-shaped URL to the daemon's mount layout: - // "/api/v2/aibridge//". Done here so + // "/api/v2/ai-gateway//". Done here so // callers do not need to encode the mount prefix or the provider // routing key into the requests they hand to the transport. - newPath, err := url.JoinPath(aibridgeRootPath, t.providerName, req.URL.Path) + newPath, err := url.JoinPath(aibridge.AIGatewayRootPath, t.providerName, req.URL.Path) if err != nil { return nil, xerrors.Errorf("rewrite request URL for provider %q: %w", t.providerName, err) } diff --git a/coderd/aibridged/transport_test.go b/coderd/aibridged/transport_test.go index 6be4862c99..0fad42acc9 100644 --- a/coderd/aibridged/transport_test.go +++ b/coderd/aibridged/transport_test.go @@ -48,8 +48,8 @@ func TestTransportFactory_TransportFor(t *testing.T) { t.Parallel() // The round-tripper must adapt an upstream-shaped URL.Path - // ("/v1/messages") to the aibridge mount layout - // ("/api/v2/aibridge//v1/messages") so callers don't + // ("/v1/messages") to the ai-gateway mount layout + // ("/api/v2/ai-gateway//v1/messages") so callers don't // have to encode the daemon's routing key into their requests. got := make(chan string, 1) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -73,7 +73,7 @@ func TestTransportFactory_TransportFor(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - require.Equal(t, "/api/v2/aibridge/my-anthropic/v1/messages", <-got) + require.Equal(t, "/api/v2/ai-gateway/my-anthropic/v1/messages", <-got) require.Equal(t, origPath, req.URL.Path, "caller's request URL must not be mutated by RoundTrip") }) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a1d025f06c..006d15838e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1380,6 +1380,263 @@ const docTemplate = `{ ] } }, + "/api/v2/ai-gateway/clients": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "List AI Bridge clients", + "operationId": "list-ai-bridge-clients", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "List AI Gateway keys", + "operationId": "list-ai-gateway-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayKey" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Create AI Gateway key", + "operationId": "create-ai-gateway-key", + "parameters": [ + { + "description": "Create AI Gateway key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/keys/{key}": { + "delete": { + "tags": [ + "Enterprise" + ], + "summary": "Delete AI Gateway key", + "operationId": "delete-ai-gateway-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/models": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "List AI Bridge models", + "operationId": "list-ai-bridge-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "List AI Bridge sessions", + "operationId": "list-ai-bridge-sessions", + "parameters": [ + { + "type": "string", + "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Cursor pagination after session ID (cannot be used with offset)", + "name": "after_session_id", + "in": "query" + }, + { + "type": "integer", + "description": "Offset pagination (cannot be used with after_session_id)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions/{session_id}": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.", + "produces": [ + "application/json" + ], + "tags": [ + "AI Gateway" + ], + "summary": "Get AI Bridge session threads", + "operationId": "get-ai-bridge-session-threads", + "parameters": [ + { + "type": "string", + "description": "Session ID (client_session_id or interception UUID)", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Thread pagination cursor (forward/older)", + "name": "after_id", + "in": "query" + }, + { + "type": "string", + "description": "Thread pagination cursor (backward/newer)", + "name": "before_id", + "in": "query" + }, + { + "type": "integer", + "description": "Number of threads per page (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/ai/providers": { "get": { "produces": [ @@ -1549,259 +1806,6 @@ const docTemplate = `{ ] } }, - "/api/v2/aibridge/clients": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "AI Bridge" - ], - "summary": "List AI Bridge clients", - "operationId": "list-ai-bridge-clients", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/keys": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Enterprise" - ], - "summary": "List AI Gateway keys", - "operationId": "list-ai-gateway-keys", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.AIGatewayKey" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - }, - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Enterprise" - ], - "summary": "Create AI Gateway key", - "operationId": "create-ai-gateway-key", - "parameters": [ - { - "description": "Create AI Gateway key request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/keys/{key}": { - "delete": { - "tags": [ - "Enterprise" - ], - "summary": "Delete AI Gateway key", - "operationId": "delete-ai-gateway-key", - "parameters": [ - { - "type": "string", - "format": "uuid", - "description": "Key ID", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/models": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "AI Bridge" - ], - "summary": "List AI Bridge models", - "operationId": "list-ai-bridge-models", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/sessions": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "AI Bridge" - ], - "summary": "List AI Bridge sessions", - "operationId": "list-ai-bridge-sessions", - "parameters": [ - { - "type": "string", - "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", - "name": "q", - "in": "query" - }, - { - "type": "integer", - "description": "Page limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Cursor pagination after session ID (cannot be used with offset)", - "name": "after_session_id", - "in": "query" - }, - { - "type": "integer", - "description": "Offset pagination (cannot be used with after_session_id)", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/sessions/{session_id}": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "AI Bridge" - ], - "summary": "Get AI Bridge session threads", - "operationId": "get-ai-bridge-session-threads", - "parameters": [ - { - "type": "string", - "description": "Session ID (client_session_id or interception UUID)", - "name": "session_id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Thread pagination cursor (forward/older)", - "name": "after_id", - "in": "query" - }, - { - "type": "string", - "description": "Thread pagination cursor (backward/newer)", - "name": "before_id", - "in": "query" - }, - { - "type": "integer", - "description": "Number of threads per page (default 50)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, "/api/v2/appearance": { "get": { "produces": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9297a68cde..5483aed895 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1223,6 +1223,235 @@ ] } }, + "/api/v2/ai-gateway/clients": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/clients for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "List AI Bridge clients", + "operationId": "list-ai-bridge-clients", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/keys": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "List AI Gateway keys", + "operationId": "list-ai-gateway-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayKey" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Create AI Gateway key", + "operationId": "create-ai-gateway-key", + "parameters": [ + { + "description": "Create AI Gateway key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/keys/{key}": { + "delete": { + "tags": ["Enterprise"], + "summary": "Delete AI Gateway key", + "operationId": "delete-ai-gateway-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/models": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/models for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "List AI Bridge models", + "operationId": "list-ai-bridge-models", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "List AI Bridge sessions", + "operationId": "list-ai-bridge-sessions", + "parameters": [ + { + "type": "string", + "description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", + "name": "q", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Cursor pagination after session ID (cannot be used with offset)", + "name": "after_session_id", + "in": "query" + }, + { + "type": "integer", + "description": "Offset pagination (cannot be used with after_session_id)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/api/v2/ai-gateway/sessions/{session_id}": { + "get": { + "description": "Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility.", + "produces": ["application/json"], + "tags": ["AI Gateway"], + "summary": "Get AI Bridge session threads", + "operationId": "get-ai-bridge-session-threads", + "parameters": [ + { + "type": "string", + "description": "Session ID (client_session_id or interception UUID)", + "name": "session_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Thread pagination cursor (forward/older)", + "name": "after_id", + "in": "query" + }, + { + "type": "string", + "description": "Thread pagination cursor (backward/newer)", + "name": "before_id", + "in": "query" + }, + { + "type": "integer", + "description": "Number of threads per page (default 50)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/ai/providers": { "get": { "produces": ["application/json"], @@ -1370,231 +1599,6 @@ ] } }, - "/api/v2/aibridge/clients": { - "get": { - "produces": ["application/json"], - "tags": ["AI Bridge"], - "summary": "List AI Bridge clients", - "operationId": "list-ai-bridge-clients", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/keys": { - "get": { - "produces": ["application/json"], - "tags": ["Enterprise"], - "summary": "List AI Gateway keys", - "operationId": "list-ai-gateway-keys", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.AIGatewayKey" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - }, - "post": { - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Enterprise"], - "summary": "Create AI Gateway key", - "operationId": "create-ai-gateway-key", - "parameters": [ - { - "description": "Create AI Gateway key request", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/keys/{key}": { - "delete": { - "tags": ["Enterprise"], - "summary": "Delete AI Gateway key", - "operationId": "delete-ai-gateway-key", - "parameters": [ - { - "type": "string", - "format": "uuid", - "description": "Key ID", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "description": "No Content" - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/models": { - "get": { - "produces": ["application/json"], - "tags": ["AI Bridge"], - "summary": "List AI Bridge models", - "operationId": "list-ai-bridge-models", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/sessions": { - "get": { - "produces": ["application/json"], - "tags": ["AI Bridge"], - "summary": "List AI Bridge sessions", - "operationId": "list-ai-bridge-sessions", - "parameters": [ - { - "type": "string", - "description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before.", - "name": "q", - "in": "query" - }, - { - "type": "integer", - "description": "Page limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Cursor pagination after session ID (cannot be used with offset)", - "name": "after_session_id", - "in": "query" - }, - { - "type": "integer", - "description": "Offset pagination (cannot be used with after_session_id)", - "name": "offset", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AIBridgeListSessionsResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, - "/api/v2/aibridge/sessions/{session_id}": { - "get": { - "produces": ["application/json"], - "tags": ["AI Bridge"], - "summary": "Get AI Bridge session threads", - "operationId": "get-ai-bridge-session-threads", - "parameters": [ - { - "type": "string", - "description": "Session ID (client_session_id or interception UUID)", - "name": "session_id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Thread pagination cursor (forward/older)", - "name": "after_id", - "in": "query" - }, - { - "type": "string", - "description": "Thread pagination cursor (backward/newer)", - "name": "before_id", - "in": "query" - }, - { - "type": "integer", - "description": "Number of threads per page (default 50)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.AIBridgeSessionThreadsResponse" - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ] - } - }, "/api/v2/appearance": { "get": { "produces": ["application/json"], diff --git a/coderd/coderd.go b/coderd/coderd.go index 77e203a41a..2688b55c03 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2235,11 +2235,12 @@ type API struct { // providers directly. Registered by coderd at startup once aibridged is // wired in-memory. AIBridgeTransportFactory atomic.Pointer[aibridge.TransportFactory] - // aibridgedHandler is the in-memory aibridge HTTP handler. Set by - // RegisterInMemoryAIBridgedHTTPHandler; read both by the enterprise - // /api/v2/aibridge route (license-gated) and by the in-memory transport - // (used by chatd, license-exempt). - aibridgedHandler http.Handler + // aiGatewayHandler is the in-memory AI Gateway HTTP handler + // (no prefix stripping). Set by RegisterInMemoryAIBridgedHTTPHandler, + // used by the enterprise /api/v2/aibridge and /api/v2/ai-gateway + // routes (license-gated) which apply their own StripPrefix, and by + // the in-memory transport (used by chatd, license-exempt). + aiGatewayHandler http.Handler UpdatesProvider tailnet.WorkspaceUpdatesProvider diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 1b1ba5dbf4..dcb65fac8b 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -13,6 +13,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/aibridge" ) type SwaggerComment struct { @@ -168,6 +170,14 @@ func isExperimentalEndpoint(route string) bool { return strings.HasPrefix(route, "/api/v2/workspaceagents/me/experimental/") } +// isLegacyAIBridgeAlias returns true for /api/v2/aibridge routes that are +// backward-compatibility aliases of /api/v2/ai-gateway. The swagger +// annotations live on the canonical /ai-gateway paths, so the legacy +// routes have no matching annotation and must be skipped. +func isLegacyAIBridgeAlias(route string) bool { + return strings.HasPrefix(route, aibridge.AIBridgeRootPath+"/") +} + func VerifySwaggerDefinitions(t *testing.T, router chi.Router, swaggerComments []SwaggerComment, opts ...SwaggerOption) { cfg := swaggerOptions{} for _, opt := range opts { @@ -206,6 +216,9 @@ func VerifySwaggerDefinitions(t *testing.T, router chi.Router, swaggerComments [ if isExperimentalEndpoint(route) { return } + if isLegacyAIBridgeAlias(route) { + return + } c := findSwaggerCommentByMethodAndRoute(swaggerComments, method, route) assert.NotNil(t, c, "Missing @Router annotation") diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 6c32af0ecd..a826b753d7 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -191,7 +191,7 @@ func (f AIBridgeListSessionsFilter) asRequestOption() RequestOption { // AIBridgeListSessions returns AI Bridge sessions with the given filter. func (c *Client) AIBridgeListSessions(ctx context.Context, filter AIBridgeListSessionsFilter) (AIBridgeListSessionsResponse, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/sessions", nil, filter.asRequestOption(), filter.Pagination.asRequestOption()) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/ai-gateway/sessions", nil, filter.asRequestOption(), filter.Pagination.asRequestOption()) if err != nil { return AIBridgeListSessionsResponse{}, err } @@ -206,7 +206,7 @@ func (c *Client) AIBridgeListSessions(ctx context.Context, filter AIBridgeListSe // AIBridgeGetSessionThreads returns a single session with expanded // thread details including agentic actions and thinking blocks. func (c *Client) AIBridgeGetSessionThreads(ctx context.Context, sessionID string, afterID, beforeID uuid.UUID, limit int32) (AIBridgeSessionThreadsResponse, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/aibridge/sessions/%s", sessionID), nil, func(r *http.Request) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/ai-gateway/sessions/%s", sessionID), nil, func(r *http.Request) { q := r.URL.Query() if afterID != uuid.Nil { q.Set("after_id", afterID.String()) @@ -232,7 +232,7 @@ func (c *Client) AIBridgeGetSessionThreads(ctx context.Context, sessionID string // AIBridgeListClients returns the distinct AI clients visible to the caller. func (c *Client) AIBridgeListClients(ctx context.Context) ([]string, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/clients", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/ai-gateway/clients", nil) if err != nil { return nil, err } diff --git a/codersdk/aigatewaykeys.go b/codersdk/aigatewaykeys.go index cce57dafad..92aaee48d6 100644 --- a/codersdk/aigatewaykeys.go +++ b/codersdk/aigatewaykeys.go @@ -38,7 +38,7 @@ type CreateAIGatewayKeyResponse struct { // CreateAIGatewayKey creates a new AI Gateway key. func (c *Client) CreateAIGatewayKey(ctx context.Context, req CreateAIGatewayKeyRequest) (CreateAIGatewayKeyResponse, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/aibridge/keys", req) + res, err := c.Request(ctx, http.MethodPost, "/api/v2/ai-gateway/keys", req) if err != nil { return CreateAIGatewayKeyResponse{}, xerrors.Errorf("make request: %w", err) } @@ -53,7 +53,7 @@ func (c *Client) CreateAIGatewayKey(ctx context.Context, req CreateAIGatewayKeyR // ListAIGatewayKeys lists all AI Gateway keys. func (c *Client) ListAIGatewayKeys(ctx context.Context) ([]AIGatewayKey, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/keys", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/ai-gateway/keys", nil) if err != nil { return nil, xerrors.Errorf("make request: %w", err) } @@ -69,7 +69,7 @@ func (c *Client) ListAIGatewayKeys(ctx context.Context) ([]AIGatewayKey, error) // DeleteAIGatewayKey deletes an AI Gateway key by ID. func (c *Client) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) error { res, err := c.Request(ctx, http.MethodDelete, - fmt.Sprintf("/api/v2/aibridge/keys/%s", id.String()), nil) + fmt.Sprintf("/api/v2/ai-gateway/keys/%s", id.String()), nil) if err != nil { return xerrors.Errorf("make request: %w", err) } diff --git a/docs/ai-coder/ai-gateway/monitoring.md b/docs/ai-coder/ai-gateway/monitoring.md index 5479c49d28..5e386c1d8b 100644 --- a/docs/ai-coder/ai-gateway/monitoring.md +++ b/docs/ai-coder/ai-gateway/monitoring.md @@ -131,7 +131,7 @@ Available query filters: - `started_after` - Filter sessions after a timestamp - `started_before` - Filter sessions before a timestamp -See the [API documentation](../../reference/api/aibridge.md) for full details. +See the [API documentation](../../reference/api/aigateway.md) for full details. ## Data Retention diff --git a/docs/install/releases/esr-2.29-2.34-upgrade.md b/docs/install/releases/esr-2.29-2.34-upgrade.md index 01380f0161..21bbf6dc10 100644 --- a/docs/install/releases/esr-2.29-2.34-upgrade.md +++ b/docs/install/releases/esr-2.29-2.34-upgrade.md @@ -172,33 +172,33 @@ The CLI and dashboard gained smaller but meaningful workflow improvements: The following changes introduced after 2.29 might break workflows, require manual updates, or change administrator expectations: -| Initial State (2.29 and before) | New State (2.30-2.34) | Change Required | -|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Terraform modules are downloaded during each workspace start. | Terraform modules are cached and pinned per template version. | Publish a new template version when upstream module changes should apply. Test templates that relied on fresh module downloads. See [speed up templates](../../tutorials/best-practices/speed-up-templates.md). | -| Integrations may use experimental AI Bridge endpoints under `/api/experimental/aibridge/*`. | Experimental AI Bridge endpoints were removed after AI Gateway graduated to stable routes. | Update clients to use `/api/v2/aibridge/*` routes. Review API consumers again because `/api/v2/aibridge/interceptions` is now deprecated in favor of `/api/v2/aibridge/sessions`. See the [AI Gateway API reference](../../reference/api/aibridge.md). | -| Unknown external OAuth providers did not default to PKCE. | Unknown external OAuth providers now default to PKCE. | If a provider does not support PKCE, set `CODER_EXTERNAL_AUTH__PKCE_METHODS=none`. See [external authentication](../../admin/external-auth/index.md). | -| `--secure-auth-cookie` defaulted independently from the access URL. | Secure auth cookies are enabled automatically when `CODER_ACCESS_URL` uses HTTPS. | Confirm reverse proxies send the correct scheme headers. To preserve old behavior, explicitly set `CODER_SECURE_AUTH_COOKIE=false`. | -| SFTP and SCP connections always landed in `$HOME`. | SFTP and SCP now respect the workspace agent `dir` setting. | Update scripts that relied on implicit `$HOME` paths. Prefer explicit absolute paths for file transfers. | -| `coder_agent` `dir` attribute accepted any path without warning. | `dir` is deprecated and emits a warning. Non-`$HOME`/`~` values also break [Coder Desktop file sync](../../user-guides/desktop/desktop-connect-sync.md). | Set `dir` to `$HOME` or omit it on `coder_agent` resources. The attribute still works in 2.34 but will be removed in a future release. | -| Pre-2.28 Tasks templates might still exist in older deployments. | The pre-2.28 Tasks template format is no longer supported as of 2.30. | Update Tasks templates to use `app_id` instead of the deprecated `sidebar_app` flow. See the [Tasks migration guide](../../ai-coder/tasks-migration.md). | -| Tasks is the primary AI coding workflow. | Coder Agents is the long-term replacement, and Tasks is supported through the 2.34 ESR window (into 2026). | Plan migration from the Tasks API to the Chats API and Coder Agents. See [Migrating from the Tasks API to the Chats API](../../ai-coder/agents/tasks-to-chats-migration.md). | -| AI Gateway injected MCP tools can be used for tool exposure. | Injected MCP tools are deprecated. | Move new integrations toward Coder Agents MCP server configuration or the MCP server flow. See [AI Gateway MCP](../../ai-coder/ai-gateway/mcp.md) and [MCP servers](../../ai-coder/agents/platform-controls/mcp-servers.md). | -| AI Bridge is opt-in via `CODER_AIBRIDGE_ENABLED` (default `false`). | The toggle is renamed to `CODER_AI_GATEWAY_ENABLED` and now defaults to `true`. | The in-memory AI Gateway now starts on every deployment. Set `CODER_AI_GATEWAY_ENABLED=false`, or the deprecated `CODER_AIBRIDGE_ENABLED` alias which still works, to keep the old behavior. | -| AI Gateway providers are configured with `CODER_AIBRIDGE_PROVIDER_*` or `CODER_AI_GATEWAY_PROVIDER_*` env vars. | Provider configuration is stored in the database. Env vars seed the database once on first startup, then are deprecated. | After upgrade, visit `/ai/settings` to verify seeded providers, then remove the env vars. Coderd fails to start if env vars drift from the seeded database row. See [AI Gateway providers](../../ai-coder/ai-gateway/providers.md). | -| Regular users can read their own AI Gateway interceptions. | Only owners and auditors can read AI Gateway interception data. | Update dashboards, scripts, or user workflows that expected self-service interception reads. This intentionally narrows the RBAC surface. | -| `coder groups list -o json` returns the old command output shape. | `coder groups list -o json` returns a flat structure matching other list commands. | Update scripts that parse this command output. | -| `coder tokens rm` deletes token records by default. | `coder tokens rm` expires tokens by default and keeps records for auditability. | Use `coder tokens rm --delete` only when the token record must be deleted. Update scripts that expect removed tokens to disappear from token history. | -| Deprecated Prometheus metrics are still emitted. | Deprecated Prometheus metrics were removed. | Update dashboards and alerts that use `coderd_api_workspace_latest_build_total` or `coderd_oauth2_external_requests_rate_limit_total`. Use the replacement metrics without the `_total` suffix. | -| Authenticated rate limits are effectively shared by client IP in some deployments. | Authenticated request rate limits are keyed by user. | Review monitoring and expectations for NATed users or shared proxies. Per-user limits now apply more consistently after API key precheck. | -| `coder login` can run while `CODER_SESSION_TOKEN` is set. | `coder login` errors when `CODER_SESSION_TOKEN` is set. | Unset `CODER_SESSION_TOKEN` in interactive login flows. Keep using the environment variable for non-interactive automation. | -| Workspace starts with new parameters can proceed without an explicit stop in some flows. | Workspace starts with new parameters stop the workspace before starting. | Expect downtime when applying new parameters. Update automation that assumes the workspace remains running. | -| `mode=auto` workspace links can silently create workspaces with prefilled parameters. | Users must confirm workspace auto-creation before provisioning starts. | Update Open in Coder buttons, runbooks, or internal flows that expect one-click workspace creation without a consent dialog. | -| Users with `--login-type none` are common for automation. | `--login-type none` is deprecated. | For Premium deployments, migrate automation to service accounts. For OSS deployments, use regular users with password, GitHub, or OIDC authentication. See [headless auth](../../admin/users/headless-auth.md). | -| Terminal commands can be executed from URL parameters without extra confirmation. | The dashboard requires confirmation before executing terminal commands from URLs. | Update runbooks or deep links that expected immediate terminal execution. This protects users from accidental command execution. | -| Agent SSH port forwarding is always available when the agent allows SSH. | Reverse and local port forwarding can be disabled per agent. | Review templates and IDE workflows before enabling `--block-reverse-port-forwarding` or `--block-local-port-forwarding`. See [port forwarding](../../admin/networking/port-forwarding.md). | -| `PATCH /api/v2/templates/{template}` accepts value fields for metadata updates. | Template metadata update fields are optional pointer fields in the SDK, and 304 responses were removed. | Update SDK consumers and direct API clients that patch template metadata. Send only fields that should change, including false or zero values explicitly. | -| External provisioner daemons use the 2.29 provisionerd protocol. | The provisionerd protocol changed for provisioner operations and file upload/download. | Update external provisioner daemons to the matching 2.34 protocol. The protocol reserves removed fields such as `stop_modules`, `exp_reuse_terraform_workspace`, and `user_secrets`, and adds `DownloadFile`. | -| Helm chart health probes and observability bind addresses use older chart defaults. | Readiness and liveness probes have `enabled` toggles and more fields, and Prometheus/pprof addresses are overridable. | Review custom Helm values for probe behavior and observability bindings. Prefer restricting pprof to a local address when exposing diagnostics. | +| Initial State (2.29 and before) | New State (2.30-2.34) | Change Required | +|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Terraform modules are downloaded during each workspace start. | Terraform modules are cached and pinned per template version. | Publish a new template version when upstream module changes should apply. Test templates that relied on fresh module downloads. See [speed up templates](../../tutorials/best-practices/speed-up-templates.md). | +| Integrations may use experimental AI Bridge endpoints under `/api/experimental/aibridge/*`. | Experimental AI Bridge endpoints were removed after AI Gateway graduated to stable routes. | Update clients to use `/api/v2/aibridge/*` routes. Review API consumers again because `/api/v2/aibridge/interceptions` is now deprecated in favor of `/api/v2/aibridge/sessions`. See the [AI Gateway API reference](../../reference/api/aigateway.md). | +| Unknown external OAuth providers did not default to PKCE. | Unknown external OAuth providers now default to PKCE. | If a provider does not support PKCE, set `CODER_EXTERNAL_AUTH__PKCE_METHODS=none`. See [external authentication](../../admin/external-auth/index.md). | +| `--secure-auth-cookie` defaulted independently from the access URL. | Secure auth cookies are enabled automatically when `CODER_ACCESS_URL` uses HTTPS. | Confirm reverse proxies send the correct scheme headers. To preserve old behavior, explicitly set `CODER_SECURE_AUTH_COOKIE=false`. | +| SFTP and SCP connections always landed in `$HOME`. | SFTP and SCP now respect the workspace agent `dir` setting. | Update scripts that relied on implicit `$HOME` paths. Prefer explicit absolute paths for file transfers. | +| `coder_agent` `dir` attribute accepted any path without warning. | `dir` is deprecated and emits a warning. Non-`$HOME`/`~` values also break [Coder Desktop file sync](../../user-guides/desktop/desktop-connect-sync.md). | Set `dir` to `$HOME` or omit it on `coder_agent` resources. The attribute still works in 2.34 but will be removed in a future release. | +| Pre-2.28 Tasks templates might still exist in older deployments. | The pre-2.28 Tasks template format is no longer supported as of 2.30. | Update Tasks templates to use `app_id` instead of the deprecated `sidebar_app` flow. See the [Tasks migration guide](../../ai-coder/tasks-migration.md). | +| Tasks is the primary AI coding workflow. | Coder Agents is the long-term replacement, and Tasks is supported through the 2.34 ESR window (into 2026). | Plan migration from the Tasks API to the Chats API and Coder Agents. See [Migrating from the Tasks API to the Chats API](../../ai-coder/agents/tasks-to-chats-migration.md). | +| AI Gateway injected MCP tools can be used for tool exposure. | Injected MCP tools are deprecated. | Move new integrations toward Coder Agents MCP server configuration or the MCP server flow. See [AI Gateway MCP](../../ai-coder/ai-gateway/mcp.md) and [MCP servers](../../ai-coder/agents/platform-controls/mcp-servers.md). | +| AI Bridge is opt-in via `CODER_AIBRIDGE_ENABLED` (default `false`). | The toggle is renamed to `CODER_AI_GATEWAY_ENABLED` and now defaults to `true`. | The in-memory AI Gateway now starts on every deployment. Set `CODER_AI_GATEWAY_ENABLED=false`, or the deprecated `CODER_AIBRIDGE_ENABLED` alias which still works, to keep the old behavior. | +| AI Gateway providers are configured with `CODER_AIBRIDGE_PROVIDER_*` or `CODER_AI_GATEWAY_PROVIDER_*` env vars. | Provider configuration is stored in the database. Env vars seed the database once on first startup, then are deprecated. | After upgrade, visit `/ai/settings` to verify seeded providers, then remove the env vars. Coderd fails to start if env vars drift from the seeded database row. See [AI Gateway providers](../../ai-coder/ai-gateway/providers.md). | +| Regular users can read their own AI Gateway interceptions. | Only owners and auditors can read AI Gateway interception data. | Update dashboards, scripts, or user workflows that expected self-service interception reads. This intentionally narrows the RBAC surface. | +| `coder groups list -o json` returns the old command output shape. | `coder groups list -o json` returns a flat structure matching other list commands. | Update scripts that parse this command output. | +| `coder tokens rm` deletes token records by default. | `coder tokens rm` expires tokens by default and keeps records for auditability. | Use `coder tokens rm --delete` only when the token record must be deleted. Update scripts that expect removed tokens to disappear from token history. | +| Deprecated Prometheus metrics are still emitted. | Deprecated Prometheus metrics were removed. | Update dashboards and alerts that use `coderd_api_workspace_latest_build_total` or `coderd_oauth2_external_requests_rate_limit_total`. Use the replacement metrics without the `_total` suffix. | +| Authenticated rate limits are effectively shared by client IP in some deployments. | Authenticated request rate limits are keyed by user. | Review monitoring and expectations for NATed users or shared proxies. Per-user limits now apply more consistently after API key precheck. | +| `coder login` can run while `CODER_SESSION_TOKEN` is set. | `coder login` errors when `CODER_SESSION_TOKEN` is set. | Unset `CODER_SESSION_TOKEN` in interactive login flows. Keep using the environment variable for non-interactive automation. | +| Workspace starts with new parameters can proceed without an explicit stop in some flows. | Workspace starts with new parameters stop the workspace before starting. | Expect downtime when applying new parameters. Update automation that assumes the workspace remains running. | +| `mode=auto` workspace links can silently create workspaces with prefilled parameters. | Users must confirm workspace auto-creation before provisioning starts. | Update Open in Coder buttons, runbooks, or internal flows that expect one-click workspace creation without a consent dialog. | +| Users with `--login-type none` are common for automation. | `--login-type none` is deprecated. | For Premium deployments, migrate automation to service accounts. For OSS deployments, use regular users with password, GitHub, or OIDC authentication. See [headless auth](../../admin/users/headless-auth.md). | +| Terminal commands can be executed from URL parameters without extra confirmation. | The dashboard requires confirmation before executing terminal commands from URLs. | Update runbooks or deep links that expected immediate terminal execution. This protects users from accidental command execution. | +| Agent SSH port forwarding is always available when the agent allows SSH. | Reverse and local port forwarding can be disabled per agent. | Review templates and IDE workflows before enabling `--block-reverse-port-forwarding` or `--block-local-port-forwarding`. See [port forwarding](../../admin/networking/port-forwarding.md). | +| `PATCH /api/v2/templates/{template}` accepts value fields for metadata updates. | Template metadata update fields are optional pointer fields in the SDK, and 304 responses were removed. | Update SDK consumers and direct API clients that patch template metadata. Send only fields that should change, including false or zero values explicitly. | +| External provisioner daemons use the 2.29 provisionerd protocol. | The provisionerd protocol changed for provisioner operations and file upload/download. | Update external provisioner daemons to the matching 2.34 protocol. The protocol reserves removed fields such as `stop_modules`, `exp_reuse_terraform_workspace`, and `user_secrets`, and adds `DownloadFile`. | +| Helm chart health probes and observability bind addresses use older chart defaults. | Readiness and liveness probes have `enabled` toggles and more fields, and Prometheus/pprof addresses are overridable. | Review custom Helm values for probe behavior and observability bindings. Prefer restricting pprof to a local address when exposing diagnostics. | ## Upgrading diff --git a/docs/manifest.json b/docs/manifest.json index 8504b4fa10..01bb9155a8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1521,8 +1521,8 @@ "path": "./reference/api/general.md" }, { - "title": "AI Bridge", - "path": "./reference/api/aibridge.md" + "title": "AI Gateway", + "path": "./reference/api/aigateway.md" }, { "title": "AI Providers", diff --git a/docs/reference/api/aibridge.md b/docs/reference/api/aigateway.md similarity index 91% rename from docs/reference/api/aibridge.md rename to docs/reference/api/aigateway.md index ca86148e1a..6dac3ad671 100644 --- a/docs/reference/api/aibridge.md +++ b/docs/reference/api/aigateway.md @@ -1,4 +1,4 @@ -# AI Bridge +# AI Gateway ## List AI Bridge clients @@ -6,12 +6,14 @@ ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/aibridge/clients \ +curl -X GET http://coder-server:8080/api/v2/ai-gateway/clients \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/v2/aibridge/clients` +`GET /api/v2/ai-gateway/clients` + +Alias: also available at /api/v2/aibridge/clients for backward compatibility. ### Example responses @@ -39,12 +41,14 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/aibridge/models \ +curl -X GET http://coder-server:8080/api/v2/ai-gateway/models \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/v2/aibridge/models` +`GET /api/v2/ai-gateway/models` + +Alias: also available at /api/v2/aibridge/models for backward compatibility. ### Example responses @@ -72,12 +76,14 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/aibridge/sessions \ +curl -X GET http://coder-server:8080/api/v2/ai-gateway/sessions \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/v2/aibridge/sessions` +`GET /api/v2/ai-gateway/sessions` + +Alias: also available at /api/v2/aibridge/sessions for backward compatibility. ### Parameters @@ -145,12 +151,14 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/aibridge/sessions/{session_id} \ +curl -X GET http://coder-server:8080/api/v2/ai-gateway/sessions/{session_id} \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/v2/aibridge/sessions/{session_id}` +`GET /api/v2/ai-gateway/sessions/{session_id}` + +Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility. ### Parameters diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 9fa7e5fb0a..a12c3247b2 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -184,12 +184,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/aibridge/keys \ +curl -X GET http://coder-server:8080/api/v2/ai-gateway/keys \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/v2/aibridge/keys` +`GET /api/v2/ai-gateway/keys` ### Example responses @@ -234,13 +234,13 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/aibridge/keys \ +curl -X POST http://coder-server:8080/api/v2/ai-gateway/keys \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /api/v2/aibridge/keys` +`POST /api/v2/ai-gateway/keys` > Body parameter @@ -284,11 +284,11 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/aibridge/keys/{key} \ +curl -X DELETE http://coder-server:8080/api/v2/ai-gateway/keys/{key} \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /api/v2/aibridge/keys/{key}` +`DELETE /api/v2/ai-gateway/keys/{key}` ### Parameters diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 241de97edb..d408e26501 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -48,7 +48,7 @@ type RoundTripDumper interface { const ( // ProxyAuthRealm is the realm used in Proxy-Authenticate challenges. // The realm helps clients identify which credentials to use. - ProxyAuthRealm = `"Coder AI Bridge Proxy"` + ProxyAuthRealm = `"Coder AI Gateway Proxy"` ) // proxyAuthRequiredMsg is the response body for 407 responses. @@ -912,7 +912,7 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. ) resp := goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusProxyAuthRequired, "Proxy authentication required") - resp.Header.Set("Proxy-Authenticate", `Basic realm="Coder AI Bridge Proxy"`) + resp.Header.Set("Proxy-Authenticate", `Basic realm="Coder AI Gateway Proxy"`) return req, resp } @@ -968,16 +968,16 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Proxy misconfigured") } - aiBridgeURL, err := url.JoinPath(s.coderAccessURL.String(), "api/v2/aibridge", reqCtx.Provider, originalPath) + aiBridgeURL, err := url.JoinPath(s.coderAccessURL.String(), agplaibridge.AIGatewayRootPath, reqCtx.Provider, originalPath) if err != nil { logger.Error(s.ctx, "failed to build aibridged URL", slog.Error(err)) - return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Failed to build AI Bridge URL") + return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Failed to build AI Gateway URL") } aiBridgeParsedURL, err := url.Parse(aiBridgeURL) if err != nil { logger.Error(s.ctx, "failed to parse aibridged URL", slog.Error(err)) - return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Failed to parse AI Bridge URL") + return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Failed to parse AI Gateway URL") } // Preserve query parameters from the original request. diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index e63e66ebf5..bc9dfea3aa 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -1284,7 +1284,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.anthropic.com/v1/messages", nil }, - expectedPath: "/api/v2/aibridge/anthropic/v1/messages", + expectedPath: "/api/v2/ai-gateway/anthropic/v1/messages", provider: "anthropic", }, { @@ -1294,7 +1294,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.anthropic.com:8443/v1/messages", nil }, - expectedPath: "/api/v2/aibridge/anthropic/v1/messages", + expectedPath: "/api/v2/ai-gateway/anthropic/v1/messages", provider: "anthropic", }, { @@ -1304,7 +1304,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.openai.com/v1/chat/completions", nil }, - expectedPath: "/api/v2/aibridge/openai/v1/chat/completions", + expectedPath: "/api/v2/ai-gateway/openai/v1/chat/completions", provider: "openai", }, { @@ -1314,7 +1314,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.openai.com:8443/v1/chat/completions", nil }, - expectedPath: "/api/v2/aibridge/openai/v1/chat/completions", + expectedPath: "/api/v2/ai-gateway/openai/v1/chat/completions", provider: "openai", }, { @@ -1852,7 +1852,7 @@ func TestUpstreamProxy(t *testing.T) { buildTargetURL: func(_ *url.URL) string { return "https://api.anthropic.com:443/v1/messages" }, - expectedAIBridgePath: "/api/v2/aibridge/anthropic/v1/messages", + expectedAIBridgePath: "/api/v2/ai-gateway/anthropic/v1/messages", }, } @@ -2138,7 +2138,7 @@ func TestProxy_MITM_CustomProvider(t *testing.T) { // The proxy should route through the aibridge path using the custom // provider name. - require.Equal(t, "/api/v2/aibridge/"+openrouterProvider+"/api/v1/chat/completions", receivedPath) + require.Equal(t, "/api/v2/ai-gateway/"+openrouterProvider+"/api/v1/chat/completions", receivedPath) require.Equal(t, "coder-token", receivedBYOK) } diff --git a/enterprise/aibridgeproxyd/reload_test.go b/enterprise/aibridgeproxyd/reload_test.go index bfc90338d4..70b770f89c 100644 --- a/enterprise/aibridgeproxyd/reload_test.go +++ b/enterprise/aibridgeproxyd/reload_test.go @@ -219,7 +219,7 @@ func (h *reloadTestHarness) sendRequest(t *testing.T, targetURL string) requestR } // expectRoutedTo asserts the proxy MITM'd the request and forwarded it -// to aibridged with the expected /api/v2/aibridge//. +// to aibridged with the expected /api/v2/ai-gateway//. func (h *reloadTestHarness) expectRoutedTo(t *testing.T, targetURL, expectedPath string) { t.Helper() @@ -361,7 +361,7 @@ func TestProxy_StaleTunnelStopsRoutingAfterProviderChange(t *testing.T) { status, err := sendThroughTunnel("/v1/messages") require.NoError(t, err) require.Equal(t, http.StatusOK, status) - require.Equal(t, "/api/v2/aibridge/alpha/v1/messages", recorder.load(), + require.Equal(t, "/api/v2/ai-gateway/alpha/v1/messages", recorder.load(), "first request must be routed to aibridged while alpha is enabled") // Apply the provider change and reload. The atomic router swap @@ -404,7 +404,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") h.expectProviderStatus(t, "alpha", "enabled") // UpdateProviderName: the same BaseURL with a new name must route @@ -414,7 +414,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha-v2", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha-v2/v1/messages") h.expectProviderStatus(t, "alpha-v2", "enabled") h.expectProviderAbsent(t, "alpha") @@ -424,7 +424,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha-v2", baseURL: "https://alpha-new.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") + h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/ai-gateway/alpha-v2/v1/messages") h.expectNotRouted(t, "https://alpha.invalid/v1/messages") h.expectProviderStatus(t, "alpha-v2", "enabled") @@ -435,8 +435,8 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/aibridge/alpha-v2/v1/messages") - h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/aibridge/beta/v1/chat/completions") + h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/ai-gateway/alpha-v2/v1/messages") + h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/ai-gateway/beta/v1/chat/completions") h.expectProviderStatus(t, "alpha-v2", "enabled") h.expectProviderStatus(t, "beta", "enabled") @@ -446,7 +446,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/aibridge/beta/v1/chat/completions") + h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/ai-gateway/beta/v1/chat/completions") h.expectNotRouted(t, "https://alpha-new.invalid/v1/messages") h.expectProviderStatus(t, "beta", "enabled") h.expectProviderAbsent(t, "alpha-v2") @@ -466,7 +466,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") h.expectProviderStatus(t, "alpha", "enabled") // Both timestamp gauges must have advanced through this sequence. @@ -495,7 +495,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/aibridge/valid/v1/messages") + h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/ai-gateway/valid/v1/messages") h.expectProviderStatus(t, "no-url", "error") h.expectProviderStatus(t, "valid", "enabled") }) @@ -514,7 +514,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/aibridge/valid/v1/messages") + h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/ai-gateway/valid/v1/messages") h.expectProviderStatus(t, "malformed", "error") h.expectProviderStatus(t, "no-host", "error") h.expectProviderStatus(t, "valid", "enabled") @@ -532,7 +532,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://shared.invalid/v1/messages", "/api/v2/aibridge/first/v1/messages") + h.expectRoutedTo(t, "https://shared.invalid/v1/messages", "/api/v2/ai-gateway/first/v1/messages") h.expectProviderStatus(t, "first", "enabled") h.expectProviderStatus(t, "second", "error") }) @@ -562,7 +562,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") // A refresh error must NOT clear the router: dropping the // provider host set on every transient DB hiccup would @@ -571,7 +571,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { err := h.srv.Reload(t.Context()) require.Error(t, err) assert.Contains(t, err.Error(), "refresh ai providers for proxy routing") - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/aibridge/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") // Recovery: once the store returns providers again, the next // Reload applies the new snapshot. @@ -579,7 +579,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://beta.invalid/v1/messages", "/api/v2/aibridge/beta/v1/messages") + h.expectRoutedTo(t, "https://beta.invalid/v1/messages", "/api/v2/ai-gateway/beta/v1/messages") h.expectNotRouted(t, "https://alpha.invalid/v1/messages") }) } diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index d579719211..b282f3a440 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -47,12 +47,35 @@ var errInvalidCursor = xerrors.New("invalid pagination cursor") // check_constraint.go. const userAIBudgetOverridesMustBeGroupMemberConstraint database.CheckConstraint = "user_ai_budget_overrides_must_be_group_member" -// aibridgeHandler handles all aibridged-related endpoints. -func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { +// aibridgeHTTPHandler returns the legacy /api/v2/aibridge route tree. +// Kept for backward compatibility only. +// +// NOTE: new endpoints must be registered on the enterprise API +// handler under /api/v2/ai-gateway, not in this shared route builder. +func aibridgeHTTPHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { + return aiBridgeRoutes(api, agplaibridge.AIBridgeRootPath, middlewares...) +} + +// aiGatewayHTTPHandler returns the /api/v2/ai-gateway route tree. +// This shares the same route builder as /aibridge for endpoints that +// existed before the rename. +// +// NOTE: new endpoints must be registered on the enterprise API +// handler under /api/v2/ai-gateway, not in this shared route builder. +func aiGatewayHTTPHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { + return aiBridgeRoutes(api, agplaibridge.AIGatewayRootPath, middlewares...) +} + +// aiBridgeRoutes builds the shared route tree for the legacy /aibridge +// and /ai-gateway prefixes. It contains the upstream AI provider +// catch-all handler and the management endpoints that were released +// under /aibridge. The stripPrefix parameter selects which URL prefix +// to strip before forwarding to the in-memory aibridged handler. +func aiBridgeRoutes(api *API, stripPrefix string, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { // Build the overload protection middleware chain for the aibridged handler. // These limits are applied per-replica. bridgeCfg := api.DeploymentValues.AI.BridgeConfig - concurrencyLimiter := httpmw.ConcurrencyLimit(bridgeCfg.MaxConcurrency.Value(), "AI Bridge") + concurrencyLimiter := httpmw.ConcurrencyLimit(bridgeCfg.MaxConcurrency.Value(), "AI Gateway") rateLimiter := httpmw.RateLimitByAuthToken(int(bridgeCfg.RateLimit.Value()), aiBridgeRateLimitWindow) return func(r chi.Router) { @@ -72,7 +95,8 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f // 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.AGPL.GetAIBridgedHandler() == nil { + handler := api.AGPL.AIGatewayHandler() + if handler == nil { httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{ Message: "aibridged handler not mounted", }) @@ -89,7 +113,8 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f return } - api.AGPL.GetAIBridgedHandler().ServeHTTP(rw, r) + // Strip the prefix and relay to the aibridged handler. + http.StripPrefix(stripPrefix, handler).ServeHTTP(rw, r) }) }) } @@ -98,16 +123,17 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f // aiBridgeListSessions returns AI Bridge sessions (aggregated interceptions). // // @Summary List AI Bridge sessions +// @Description Alias: also available at /api/v2/aibridge/sessions for backward compatibility. // @ID list-ai-bridge-sessions // @Security CoderSessionToken // @Produce json -// @Tags AI Bridge +// @Tags AI Gateway // @Param q query string false "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, client, session_id, started_after, started_before." // @Param limit query int false "Page limit" // @Param after_session_id query string false "Cursor pagination after session ID (cannot be used with offset)" // @Param offset query int false "Offset pagination (cannot be used with after_session_id)" // @Success 200 {object} codersdk.AIBridgeListSessionsResponse -// @Router /api/v2/aibridge/sessions [get] +// @Router /api/v2/ai-gateway/sessions [get] func (api *API) aiBridgeListSessions(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -203,7 +229,7 @@ func (api *API) aiBridgeListSessions(rw http.ResponseWriter, r *http.Request) { }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error getting AI Bridge sessions.", + Message: "Internal error getting AI Gateway sessions.", Detail: err.Error(), }) return @@ -224,16 +250,17 @@ func (api *API) aiBridgeListSessions(rw http.ResponseWriter, r *http.Request) { // threads including agentic actions and thinking blocks. // // @Summary Get AI Bridge session threads +// @Description Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward compatibility. // @ID get-ai-bridge-session-threads // @Security CoderSessionToken // @Produce json -// @Tags AI Bridge +// @Tags AI Gateway // @Param session_id path string true "Session ID (client_session_id or interception UUID)" // @Param after_id query string false "Thread pagination cursor (forward/older)" // @Param before_id query string false "Thread pagination cursor (backward/newer)" // @Param limit query int false "Number of threads per page (default 50)" // @Success 200 {object} codersdk.AIBridgeSessionThreadsResponse -// @Router /api/v2/aibridge/sessions/{session_id} [get] +// @Router /api/v2/ai-gateway/sessions/{session_id} [get] func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -415,12 +442,13 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques // aiBridgeListModels returns all AI Bridge models a user can see. // // @Summary List AI Bridge models +// @Description Alias: also available at /api/v2/aibridge/models for backward compatibility. // @ID list-ai-bridge-models // @Security CoderSessionToken // @Produce json -// @Tags AI Bridge +// @Tags AI Gateway // @Success 200 {array} string -// @Router /api/v2/aibridge/models [get] +// @Router /api/v2/ai-gateway/models [get] func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -446,7 +474,7 @@ func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid AI Bridge models search query.", + Message: "Invalid AI Gateway models search query.", Validations: errs, }) return @@ -455,7 +483,7 @@ func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { models, err := api.Database.ListAIBridgeModels(ctx, filter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error getting AI Bridge models.", + Message: "Internal error getting AI Gateway models.", Detail: err.Error(), }) return @@ -467,12 +495,13 @@ func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { // aiBridgeListClients returns all AI Bridge clients a user can see. // // @Summary List AI Bridge clients +// @Description Alias: also available at /api/v2/aibridge/clients for backward compatibility. // @ID list-ai-bridge-clients // @Security CoderSessionToken // @Produce json -// @Tags AI Bridge +// @Tags AI Gateway // @Success 200 {array} string -// @Router /api/v2/aibridge/clients [get] +// @Router /api/v2/ai-gateway/clients [get] func (api *API) aiBridgeListClients(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -498,7 +527,7 @@ func (api *API) aiBridgeListClients(rw http.ResponseWriter, r *http.Request) { if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid AI Bridge clients search query.", + Message: "Invalid AI Gateway clients search query.", Validations: errs, }) return @@ -507,7 +536,7 @@ func (api *API) aiBridgeListClients(rw http.ResponseWriter, r *http.Request) { clients, err := api.Database.ListAIBridgeClients(ctx, filter) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error getting AI Bridge clients.", + Message: "Internal error getting AI Gateway clients.", Detail: err.Error(), }) return diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index aa99010a67..d911d3c05a 100644 --- a/enterprise/coderd/aibridge_reload_test.go +++ b/enterprise/coderd/aibridge_reload_test.go @@ -54,7 +54,7 @@ func newMockUpstream(t *testing.T, name string) *mockUpstream { // startTestAIBridgeDaemon wires an in-process aibridged daemon onto // the supplied API and subscribes it to ai_providers change events. -// This mirrors what cli/server.go does in production so /api/v2/aibridge +// This mirrors what cli/server.go does in production so /api/v2/ai-gateway // requests dispatch through the real pool and reloader. func startTestAIBridgeDaemon(t *testing.T, api *coderd.API) *aibridged.Metrics { t.Helper() @@ -109,7 +109,7 @@ func (r *testPoolReloader) Reload(ctx context.Context) error { // TestAIBridgeProviderHotReload exercises the end-to-end CRUD -> // reload -> routing path: every provider mutation made through codersdk // must, within a short window, change the routing observed at -// /api/v2/aibridge/{name}/v1/models. The OpenAI passthrough route +// /api/v2/ai-gateway/{name}/v1/models. The OpenAI passthrough route // /v1/models reverse-proxies to BaseURL, so the upstream that responds // identifies which provider the daemon's mux dispatched to. func TestAIBridgeProviderHotReload(t *testing.T) { @@ -162,11 +162,11 @@ func TestAIBridgeProviderHotReload(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - // sendRequest issues GET /api/v2/aibridge/{name}/v1/models and + // sendRequest issues GET /api/v2/ai-gateway/{name}/v1/models and // returns the status and the upstream marker decoded from the // JSON body (empty if the body was not the marker JSON). sendRequest := func(providerName string) (int, string) { - url := client.URL.String() + "/api/v2/aibridge/" + providerName + "/v1/models" + url := client.URL.String() + "/api/v2/ai-gateway/" + providerName + "/v1/models" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) require.NoError(t, err) req.Header.Set("Authorization", "Bearer "+client.SessionToken()) diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index fcd7a27611..76f98b1baf 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1232,7 +1232,7 @@ func TestAIBridgeRouting(t *testing.T) { }{ { name: "StablePrefix", - path: "/api/v2/aibridge/openai/v1/chat/completions", + path: "/api/v2/ai-gateway/openai/v1/chat/completions", expectedPath: "/openai/v1/chat/completions", }, } @@ -1290,7 +1290,7 @@ func TestAIBridgeRateLimiting(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) httpClient := &http.Client{} - url := client.URL.String() + "/api/v2/aibridge/test" + url := client.URL.String() + "/api/v2/ai-gateway/test" // Make requests up to the limit - should succeed. for range 2 { @@ -1350,7 +1350,7 @@ func TestAIBridgeConcurrencyLimiting(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) httpClient := &http.Client{} - url := client.URL.String() + "/api/v2/aibridge/test" + url := client.URL.String() + "/api/v2/ai-gateway/test" // Start a request that will block. done := make(chan struct{}) @@ -2012,7 +2012,7 @@ func TestAIBridgeAllowBYOK(t *testing.T) { api.AGPL.RegisterInMemoryAIBridgedHTTPHandler(testHandler) ctx := testutil.Context(t, testutil.WaitLong) - reqURL := client.URL.String() + "/api/v2/aibridge/test" + reqURL := client.URL.String() + "/api/v2/ai-gateway/test" req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) require.NoError(t, err) req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) diff --git a/enterprise/coderd/aibridgeproxy.go b/enterprise/coderd/aibridgeproxy.go index 3923dcaff9..cf6018abdd 100644 --- a/enterprise/coderd/aibridgeproxy.go +++ b/enterprise/coderd/aibridgeproxy.go @@ -5,10 +5,17 @@ import ( "github.com/go-chi/chi/v5" + agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/codersdk" ) +// API route prefixes for the AI Gateway Proxy and legacy AI Bridge Proxy endpoints. +const ( + AIGatewayProxyPath = agplaibridge.AIGatewayRootPath + "/proxy" + AIBridgeProxyPath = agplaibridge.AIBridgeRootPath + "/proxy" +) + // RegisterInMemoryAIBridgeProxydHTTPHandler mounts [aibridgeproxyd.Server]'s HTTP handler // onto [API]'s router, so that requests to aibridgedproxy will be relayed from Coder's API server // to the in-memory aibridgedproxy. @@ -20,8 +27,27 @@ func (api *API) RegisterInMemoryAIBridgeProxydHTTPHandler(srv http.Handler) { api.aibridgeproxydHandler = srv } -// aibridgeproxyHandler handles AI Bridge Proxy endpoints. -func aibridgeproxyHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { +// aibridgeProxyHTTPHandler returns the legacy /api/v2/aibridge/proxy route tree. +// Kept for backward compatibility only. +// +// NOTE: new endpoints must be registered on the enterprise API +// handler under /api/v2/ai-gateway, not in this shared route builder. +func aibridgeProxyHTTPHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { + return aiGatewayProxyRoutes(api, AIBridgeProxyPath, middlewares...) +} + +// aiGatewayProxyHTTPHandler returns the /api/v2/ai-gateway/proxy route tree. +// This shares the same route builder as /aibridge/proxy for endpoints that +// existed before the rename. +// +// NOTE: new endpoints must be registered on the enterprise API +// handler under /api/v2/ai-gateway, not in this shared route builder. +func aiGatewayProxyHTTPHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { + return aiGatewayProxyRoutes(api, AIGatewayProxyPath, middlewares...) +} + +// aiGatewayProxyRoutes builds the route tree for AI Gateway Proxy endpoints. +func aiGatewayProxyRoutes(api *API, stripPrefix string, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { return func(r chi.Router) { r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge)) r.Use(middlewares...) @@ -30,7 +56,7 @@ func aibridgeproxyHandler(api *API, middlewares ...func(http.Handler) http.Handl // Check if the proxy is enabled. if !api.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() { httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{ - Message: "AI Bridge Proxy is not enabled.", + Message: "AI Gateway Proxy is not enabled.", }) return } @@ -38,13 +64,13 @@ func aibridgeproxyHandler(api *API, middlewares ...func(http.Handler) http.Handl // Check if the handler is registered. if api.aibridgeproxydHandler == nil { httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{ - Message: "AI Bridge Proxy handler not mounted.", + Message: "AI Gateway Proxy handler not mounted.", }) return } // Strip the prefix and relay to the aibridgeproxyd handler. - http.StripPrefix("/api/v2/aibridge/proxy", api.aibridgeproxydHandler).ServeHTTP(rw, r) + http.StripPrefix(stripPrefix, api.aibridgeproxydHandler).ServeHTTP(rw, r) }) } } diff --git a/enterprise/coderd/aibridgeproxy_test.go b/enterprise/coderd/aibridgeproxy_test.go index 90ac52d795..0188a93b36 100644 --- a/enterprise/coderd/aibridgeproxy_test.go +++ b/enterprise/coderd/aibridgeproxy_test.go @@ -37,7 +37,7 @@ func TestAIBridgeProxyCertificateRetrieval(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Make a request to the proxy CA cert endpoint. - req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/aibridge/proxy/ca-cert.pem", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/ai-gateway/proxy/ca-cert.pem", nil) require.NoError(t, err) req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) @@ -66,7 +66,7 @@ func TestAIBridgeProxyCertificateRetrieval(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Make a request to the proxy CA cert endpoint. - req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/aibridge/proxy/ca-cert.pem", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/ai-gateway/proxy/ca-cert.pem", nil) require.NoError(t, err) req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken()) @@ -96,7 +96,7 @@ func TestAIBridgeProxyCertificateRetrieval(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) // Make a request to the proxy CA cert endpoint without authentication. - req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/aibridge/proxy/ca-cert.pem", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.URL.String()+"/api/v2/ai-gateway/proxy/ca-cert.pem", nil) require.NoError(t, err) // No session token header set. diff --git a/enterprise/coderd/aigatewaykeys.go b/enterprise/coderd/aigatewaykeys.go index f9ebb13604..227d0930d1 100644 --- a/enterprise/coderd/aigatewaykeys.go +++ b/enterprise/coderd/aigatewaykeys.go @@ -28,7 +28,7 @@ const nameFormatDetail = "Must be 64 characters or fewer, lowercase letters, num // @Tags Enterprise // @Param request body codersdk.CreateAIGatewayKeyRequest true "Create AI Gateway key request" // @Success 201 {object} codersdk.CreateAIGatewayKeyResponse -// @Router /api/v2/aibridge/keys [post] +// @Router /api/v2/ai-gateway/keys [post] func (api *API) postAIGatewayKey(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() @@ -116,7 +116,7 @@ func writeKeyInsertError(ctx context.Context, rw http.ResponseWriter, err error) // @Produce json // @Tags Enterprise // @Success 200 {array} codersdk.AIGatewayKey -// @Router /api/v2/aibridge/keys [get] +// @Router /api/v2/ai-gateway/keys [get] func (api *API) aiGatewayKeys(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -146,7 +146,7 @@ func (api *API) aiGatewayKeys(rw http.ResponseWriter, r *http.Request) { // @Tags Enterprise // @Param key path string true "Key ID" format(uuid) // @Success 204 -// @Router /api/v2/aibridge/keys/{key} [delete] +// @Router /api/v2/ai-gateway/keys/{key} [delete] func (api *API) deleteAIGatewayKey(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() diff --git a/enterprise/coderd/aigatewaykeys_test.go b/enterprise/coderd/aigatewaykeys_test.go index 7afc138e4e..cc11ed271a 100644 --- a/enterprise/coderd/aigatewaykeys_test.go +++ b/enterprise/coderd/aigatewaykeys_test.go @@ -81,7 +81,7 @@ func TestAIGatewayKeys(t *testing.T) { require.NoError(t, err) fullKey := created.Key - resp, err := ownerClient.Request(ctx, http.MethodGet, "/api/v2/aibridge/keys", nil) + resp, err := ownerClient.Request(ctx, http.MethodGet, "/api/v2/ai-gateway/keys", nil) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -137,7 +137,7 @@ func TestAIGatewayKeys(t *testing.T) { // Invalid UUID -> 400 (raw request; SDK method accepts uuid.UUID). //nolint:gocritic // Managing AI Gateway keys is owner-only. - resp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/keys/not-a-uuid", nil) + resp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/ai-gateway/keys/not-a-uuid", nil) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) require.Equal(t, http.StatusBadRequest, resp.StatusCode) @@ -148,7 +148,7 @@ func TestAIGatewayKeys(t *testing.T) { }) require.NoError(t, err) // SDK returns no code on success, using raw request to check for 204. - delResp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/keys/"+created.ID.String(), nil) + delResp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/ai-gateway/keys/"+created.ID.String(), nil) require.NoError(t, err) defer delResp.Body.Close() require.Equal(t, http.StatusNoContent, delResp.StatusCode) @@ -333,7 +333,7 @@ func TestAIGatewayKeysDatabaseErrors(t *testing.T) { name: "CreateDBError", errStore: aiGatewayKeyErrorStore{insertErr: dbErr}, method: http.MethodPost, - path: "/api/v2/aibridge/keys", + path: "/api/v2/ai-gateway/keys", body: codersdk.CreateAIGatewayKeyRequest{Name: "db-err-create"}, wantStatus: http.StatusInternalServerError, wantMsg: "Failed to create key. Please retry.", @@ -342,7 +342,7 @@ func TestAIGatewayKeysDatabaseErrors(t *testing.T) { name: "ListDBError", errStore: aiGatewayKeyErrorStore{listErr: dbErr}, method: http.MethodGet, - path: "/api/v2/aibridge/keys", + path: "/api/v2/ai-gateway/keys", wantStatus: http.StatusInternalServerError, wantMsg: "Failed to list keys.", }, @@ -350,7 +350,7 @@ func TestAIGatewayKeysDatabaseErrors(t *testing.T) { name: "DeleteDBError", errStore: aiGatewayKeyErrorStore{deleteErr: dbErr}, method: http.MethodDelete, - path: "/api/v2/aibridge/keys/" + uuid.New().String(), + path: "/api/v2/ai-gateway/keys/" + uuid.New().String(), wantStatus: http.StatusInternalServerError, wantMsg: "Failed to delete key.", }, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 29d8c39e84..666758fb02 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -291,16 +291,27 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { return api.refreshEntitlements(ctx) } + // Legacy aibridge routes: kept for backward compatibility. + // New endpoints should be added to /ai-gateway only. api.AGPL.APIHandler.Group(func(r chi.Router) { - r.Route("/aibridge", aibridgeHandler(api, apiKeyMiddleware)) + r.Route("/aibridge", aibridgeHTTPHandler(api, apiKeyMiddleware)) }) api.AGPL.APIHandler.Group(func(r chi.Router) { - r.Route("/aibridge/proxy", aibridgeproxyHandler(api, apiKeyMiddleware)) + r.Route("/aibridge/proxy", aibridgeProxyHTTPHandler(api, apiKeyMiddleware)) + }) + + // AI Gateway routes: canonical aliases for the aibridge endpoints. + api.AGPL.APIHandler.Group(func(r chi.Router) { + r.Route("/ai-gateway", aiGatewayHTTPHandler(api, apiKeyMiddleware)) }) api.AGPL.APIHandler.Group(func(r chi.Router) { - r.Route("/aibridge/keys", func(r chi.Router) { + r.Route("/ai-gateway/proxy", aiGatewayProxyHTTPHandler(api, apiKeyMiddleware)) + }) + + api.AGPL.APIHandler.Group(func(r chi.Router) { + r.Route("/ai-gateway/keys", func(r chi.Router) { r.Use( apiKeyMiddleware, api.RequireFeatureMW(codersdk.FeatureAIBridge),