From 71a492a37418a269e0b49aa99c482d9fff391c81 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Sat, 28 Mar 2026 08:18:28 +1100 Subject: [PATCH] feat: implement `` to AI Bridge request logs (#22694) Closes #22136 This pull-request implements a `` to our `Request Logs` page for AI Bridge. This will allow the user to select a client which they wish to filter against. Technically the backend is able to actually filter against multiple clients at once however the frontend doesn't currently have a nice way of supporting this (future improvement). image --------- Co-authored-by: Jeremy Ruppel Co-authored-by: Claude Sonnet 4.6 --- coderd/apidoc/docs.go | 28 +++++++ coderd/apidoc/swagger.json | 24 ++++++ coderd/database/dbauthz/dbauthz.go | 16 ++++ coderd/database/dbauthz/dbauthz_test.go | 14 ++++ coderd/database/dbmetrics/querymetrics.go | 16 ++++ coderd/database/dbmock/dbmock.go | 30 +++++++ coderd/database/modelqueries.go | 30 +++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 52 ++++++++++++ coderd/database/queries/aibridge.sql | 24 ++++++ coderd/searchquery/search.go | 28 +++++++ codersdk/aibridge.go | 14 ++++ docs/reference/api/aibridge.md | 33 ++++++++ enterprise/coderd/aibridge.go | 55 ++++++++++++ enterprise/coderd/aibridge_test.go | 84 +++++++++++++++++++ site/src/api/api.ts | 7 ++ site/src/components/Filter/Filter.tsx | 15 ++-- .../RequestLogsFilter/ClientFilter.tsx | 79 +++++++++++++++++ .../RequestLogsFilter/RequestLogsFilter.tsx | 3 + .../RequestLogsPage/RequestLogsPage.tsx | 13 ++- .../RequestLogsPageView.stories.tsx | 1 + 21 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ClientFilter.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7ef90f7e8a..78481a71f4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -84,6 +84,34 @@ const docTemplate = `{ } } }, + "/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": [] + } + ] + } + }, "/aibridge/interceptions": { "get": { "produces": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0d220380ed..b6c8ae7490 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -65,6 +65,30 @@ } } }, + "/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": [] + } + ] + } + }, "/aibridge/interceptions": { "get": { "produces": ["application/json"], diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ea3284664b..bf75a2595a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5356,6 +5356,14 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab return q.db.InsertWorkspaceResourceMetadata(ctx, arg) } +func (q *querier) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) { + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + return q.db.ListAuthorizedAIBridgeClients(ctx, arg, prep) +} + func (q *querier) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { @@ -7302,6 +7310,14 @@ func (q *querier) ListAuthorizedAIBridgeModels(ctx context.Context, arg database return q.ListAIBridgeModels(ctx, arg) } +func (q *querier) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, _ rbac.PreparedAuthorized) ([]string, error) { + // TODO: Delete this function, all ListAIBridgeClients should be + // authorized. For now just call ListAIBridgeClients on the authz + // querier. This cannot be deleted for now because it's included in + // the database.Store interface, so dbauthz needs to implement it. + return q.ListAIBridgeClients(ctx, arg) +} + func (q *querier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionsRow, error) { return q.db.ListAuthorizedAIBridgeSessions(ctx, arg, prepared) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a76e892b44..4e67955f3d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5542,6 +5542,20 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(params, emptyPreparedAuthorized{}).Asserts() })) + s.Run("ListAIBridgeClients", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeClientsParams{} + db.EXPECT().ListAuthorizedAIBridgeClients(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args(params).Asserts() + })) + + s.Run("ListAuthorizedAIBridgeClients", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeClientsParams{} + db.EXPECT().ListAuthorizedAIBridgeClients(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args(params, emptyPreparedAuthorized{}).Asserts() + })) + s.Run("ListAIBridgeSessions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { params := database.ListAIBridgeSessionsParams{} db.EXPECT().ListAuthorizedAIBridgeSessions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeSessionsRow{}, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 1b8707a08a..1ed0237ef5 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3752,6 +3752,14 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) { + start := time.Now() + r0, r1 := m.s.ListAIBridgeClients(ctx, arg) + m.queryLatencies.WithLabelValues("ListAIBridgeClients").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeClients").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) { start := time.Now() r0, r1 := m.s.ListAIBridgeInterceptions(ctx, arg) @@ -5296,6 +5304,14 @@ func (m queryMetricsStore) ListAuthorizedAIBridgeModels(ctx context.Context, arg return r0, r1 } +func (m queryMetricsStore) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) { + start := time.Now() + r0, r1 := m.s.ListAuthorizedAIBridgeClients(ctx, arg, prepared) + m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeClients").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAuthorizedAIBridgeClients").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListAuthorizedAIBridgeSessions(ctx context.Context, arg database.ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeSessionsRow, error) { start := time.Now() r0, r1 := m.s.ListAuthorizedAIBridgeSessions(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 3ea7b17ce7..d789e2b1f5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7022,6 +7022,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceResourceMetadata", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceResourceMetadata), ctx, arg) } +// ListAIBridgeClients mocks base method. +func (m *MockStore) ListAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAIBridgeClients", ctx, arg) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAIBridgeClients indicates an expected call of ListAIBridgeClients. +func (mr *MockStoreMockRecorder) ListAIBridgeClients(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeClients", reflect.TypeOf((*MockStore)(nil).ListAIBridgeClients), ctx, arg) +} + // ListAIBridgeInterceptions mocks base method. func (m *MockStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) { m.ctrl.T.Helper() @@ -7157,6 +7172,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeUserPromptsByInterceptionIDs(ctx, i return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeUserPromptsByInterceptionIDs", reflect.TypeOf((*MockStore)(nil).ListAIBridgeUserPromptsByInterceptionIDs), ctx, interceptionIds) } +// ListAuthorizedAIBridgeClients mocks base method. +func (m *MockStore) ListAuthorizedAIBridgeClients(ctx context.Context, arg database.ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeClients", ctx, arg, prepared) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAuthorizedAIBridgeClients indicates an expected call of ListAuthorizedAIBridgeClients. +func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeClients(ctx, arg, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeClients", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeClients), ctx, arg, prepared) +} + // ListAuthorizedAIBridgeInterceptions mocks base method. func (m *MockStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 9e2f00fc4f..4409ddc9d0 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -813,6 +813,7 @@ type aibridgeQuerier interface { ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error) ListAuthorizedAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams, prepared rbac.PreparedAuthorized) ([]string, error) + ListAuthorizedAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) ListAuthorizedAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionsRow, error) CountAuthorizedAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) (int64, error) ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionThreadsRow, error) @@ -950,6 +951,35 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeModels(ctx context.Context, arg ListA return items, nil } +func (q *sqlQuerier) ListAuthorizedAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams, prepared rbac.PreparedAuthorized) ([]string, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ + VariableConverter: regosql.AIBridgeInterceptionConverter(), + }) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + filtered, err := insertAuthorizedFilter(listAIBridgeClients, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: ListAIBridgeClients :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, arg.Client, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var client string + if err := rows.Scan(&client); err != nil { + return nil, err + } + items = append(items, client) + } + return items, nil +} + func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionsRow, error) { authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{ VariableConverter: regosql.AIBridgeInterceptionConverter(), diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d45b564173..b6a7fc07ce 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -775,6 +775,7 @@ type sqlcQuerier interface { InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) InsertWorkspaceResource(ctx context.Context, arg InsertWorkspaceResourceParams) (WorkspaceResource, error) InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error) + ListAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams) ([]string, error) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, error) // Finds all unique AI Bridge interception telemetry summaries combinations // (provider, model, client) in the given timeframe for telemetry reporting. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4280a44e68..1f78c9e86b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -909,6 +909,58 @@ func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIB return i, err } +const listAIBridgeClients = `-- name: ListAIBridgeClients :many +SELECT + COALESCE(client, 'Unknown') AS client +FROM + aibridge_interceptions +WHERE + ended_at IS NOT NULL + -- Filter client (prefix match to allow B-tree index usage). + AND CASE + WHEN $1::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') LIKE $1::text || '%' + ELSE true + END + -- We use an ` + "`" + `@authorize_filter` + "`" + ` as we are attempting to list clients + -- that are relevant to the user and what they are allowed to see. + -- Authorize Filter clause will be injected below in + -- ListAIBridgeClientsAuthorized. + -- @authorize_filter +GROUP BY + client +LIMIT COALESCE(NULLIF($3::integer, 0), 100) +OFFSET $2 +` + +type ListAIBridgeClientsParams struct { + Client string `db:"client" json:"client"` + Offset int32 `db:"offset_" json:"offset_"` + Limit int32 `db:"limit_" json:"limit_"` +} + +func (q *sqlQuerier) ListAIBridgeClients(ctx context.Context, arg ListAIBridgeClientsParams) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listAIBridgeClients, arg.Client, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var client string + if err := rows.Scan(&client); err != nil { + return nil, err + } + items = append(items, client) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many SELECT aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata, aibridge_interceptions.ended_at, aibridge_interceptions.api_key_id, aibridge_interceptions.client, aibridge_interceptions.thread_parent_id, aibridge_interceptions.thread_root_id, aibridge_interceptions.client_session_id, aibridge_interceptions.session_id, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 0d62ddb301..f4f03ff1cc 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -680,3 +680,27 @@ ORDER BY LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100) OFFSET @offset_ ; + + +-- name: ListAIBridgeClients :many +SELECT + COALESCE(client, 'Unknown') AS client +FROM + aibridge_interceptions +WHERE + ended_at IS NOT NULL + -- Filter client (prefix match to allow B-tree index usage). + AND CASE + WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') LIKE @client::text || '%' + ELSE true + END + -- We use an `@authorize_filter` as we are attempting to list clients + -- that are relevant to the user and what they are allowed to see. + -- Authorize Filter clause will be injected below in + -- ListAIBridgeClientsAuthorized. + -- @authorize_filter +GROUP BY + client +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100) +OFFSET @offset_ +; diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 814ba985ab..330c2e6eb4 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -474,6 +474,34 @@ func AIBridgeModels(query string, page codersdk.Pagination) (database.ListAIBrid return filter, parser.Errors } +func AIBridgeClients(query string, page codersdk.Pagination) (database.ListAIBridgeClientsParams, []codersdk.ValidationError) { + // nolint:exhaustruct // Empty values just means "don't filter by that field". + filter := database.ListAIBridgeClientsParams{ + // #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range + Offset: int32(page.Offset), + // #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range + Limit: int32(page.Limit), + } + + if query == "" { + return filter, nil + } + + values, errors := searchTerms(query, func(term string, values url.Values) error { + values.Add("client", term) + return nil + }) + if len(errors) > 0 { + return filter, errors + } + + parser := httpapi.NewQueryParamParser() + filter.Client = parser.String(values, "", "client") + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + // Tasks parses a search query for tasks. // // Supported query parameters: diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 1b80b73488..4c382940af 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -324,3 +324,17 @@ func (c *Client) AIBridgeGetSessionThreads(ctx context.Context, sessionID string var resp AIBridgeSessionThreadsResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// 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) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var clients []string + return clients, json.NewDecoder(res.Body).Decode(&clients) +} diff --git a/docs/reference/api/aibridge.md b/docs/reference/api/aibridge.md index 6852e42d91..8d69db7c72 100644 --- a/docs/reference/api/aibridge.md +++ b/docs/reference/api/aibridge.md @@ -1,5 +1,38 @@ # AI Bridge +## List AI Bridge clients + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/aibridge/clients \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /aibridge/clients` + +### Example responses + +> 200 Response + +```json +[ + "string" +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|-----------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of string | + +

Response Schema

+ +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## List AI Bridge interceptions ### Code samples diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index d1766ed4b2..1d11315790 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -27,9 +27,11 @@ const ( maxListInterceptionsLimit = 1000 maxListSessionsLimit = 1000 maxListModelsLimit = 1000 + maxListClientsLimit = 1000 defaultListInterceptionsLimit = 100 defaultListSessionsLimit = 100 defaultListModelsLimit = 100 + defaultListClientsLimit = 100 // aiBridgeRateLimitWindow is the fixed duration for rate limiting AI Bridge // requests. This is hardcoded to keep configuration simple. aiBridgeRateLimitWindow = time.Second @@ -55,6 +57,7 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f r.Get("/sessions", api.aiBridgeListSessions) r.Get("/sessions/{session_id}", api.aiBridgeGetSessionThreads) r.Get("/models", api.aiBridgeListModels) + r.Get("/clients", api.aiBridgeListClients) }) // Apply overload protection middleware to the aibridged handler. @@ -559,6 +562,58 @@ func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, models) } +// aiBridgeListClients returns all AI Bridge clients a user can see. +// +// @Summary List AI Bridge clients +// @ID list-ai-bridge-clients +// @Security CoderSessionToken +// @Produce json +// @Tags AI Bridge +// @Success 200 {array} string +// @Router /aibridge/clients [get] +func (api *API) aiBridgeListClients(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + page, ok := coderd.ParsePagination(rw, r) + if !ok { + return + } + + if page.Limit == 0 { + page.Limit = defaultListClientsLimit + } + + if page.Limit > maxListClientsLimit || page.Limit < 1 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid pagination limit value.", + Detail: fmt.Sprintf("Pagination limit must be in range (0, %d]", maxListClientsLimit), + }) + return + } + + queryStr := r.URL.Query().Get("q") + filter, errs := searchquery.AIBridgeClients(queryStr, page) + + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid AI Bridge clients search query.", + Validations: errs, + }) + return + } + + 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.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, clients) +} + // validateInterceptionCursor checks that a pagination cursor refers to an // existing interception. When sessionID is non-empty the interception must // also belong to that session. Returns errInvalidCursor on failure so diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 9808916230..6f2f80258b 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1215,6 +1215,90 @@ func TestAIBridgeListSessions(t *testing.T) { }) } +func TestAIBridgeListClients(t *testing.T) { + t.Parallel() + + t.Run("RequiresLicenseFeature", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{}, + }, + }) + + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Owner role is irrelevant here. + _, err := client.AIBridgeListClients(ctx) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, + }) + + now := dbtime.Now() + endedAt := now.Add(time.Minute) + + // Completed interception with an explicit client. + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + StartedAt: now, + Client: sql.NullString{String: string(aiblib.ClientCursor), Valid: true}, + }, &endedAt) + + // Completed interception with a different client. + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + StartedAt: now, + Client: sql.NullString{String: string(aiblib.ClientClaudeCode), Valid: true}, + }, &endedAt) + + // Completed interception with no client — should appear as "Unknown". + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + StartedAt: now, + }, &endedAt) + + // Duplicate client — should be deduplicated in results. + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + StartedAt: now, + Client: sql.NullString{String: string(aiblib.ClientCursor), Valid: true}, + }, &endedAt) + + // In-flight interception (no ended_at) — must NOT appear in results. + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + StartedAt: now, + Client: sql.NullString{String: string(aiblib.ClientCopilotCLI), Valid: true}, + }, nil) + + ctx := testutil.Context(t, testutil.WaitLong) + clients, err := client.AIBridgeListClients(ctx) + require.NoError(t, err) + require.ElementsMatch(t, []string{ + string(aiblib.ClientCursor), + string(aiblib.ClientClaudeCode), + "Unknown", + }, clients) +} + func TestAIBridgeRouting(t *testing.T) { t.Parallel() diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7fff88626b..a34d040909 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3019,6 +3019,13 @@ class ApiMethods { const response = await this.axios.get(url); return response.data; }; + + getAIBridgeClients = async (options: SearchParamOptions) => { + const url = getURLWithSearchParams("/api/v2/aibridge/clients", options); + + const response = await this.axios.get(url); + return response.data; + }; } export type TaskFeedbackRating = "good" | "okay" | "bad"; diff --git a/site/src/components/Filter/Filter.tsx b/site/src/components/Filter/Filter.tsx index 4568aaf83b..60a3315884 100644 --- a/site/src/components/Filter/Filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -101,15 +101,13 @@ const parseFilterQuery = (filterQuery: string): FilterValues => { return {}; } - const pairs = filterQuery.split(" "); const result: FilterValues = {}; + const keyValuePair = /(\w+):"([^"]+)"|(\w+):(\S+)/g; - for (const pair of pairs) { - const [key, value] = pair.split(":") as [ - keyof FilterValues, - string | undefined, - ]; - if (value) { + for (const match of filterQuery.matchAll(keyValuePair)) { + const key = match[1] ?? match[3]; + const value = match[2] ?? match[4]; + if (key && value) { result[key] = value; } } @@ -123,7 +121,8 @@ const stringifyFilter = (filterValue: FilterValues): string => { for (const key in filterValue) { const value = filterValue[key]; if (value) { - result += `${key}:${value} `; + const needsQuotes = value.includes(" "); + result += needsQuotes ? `${key}:"${value}" ` : `${key}:${value} `; } } diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ClientFilter.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ClientFilter.tsx new file mode 100644 index 0000000000..9474a450d8 --- /dev/null +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ClientFilter.tsx @@ -0,0 +1,79 @@ +import { API } from "api/api"; +import { ComboboxInput } from "components/Combobox/Combobox"; +import { + type UseFilterMenuOptions, + useFilterMenu, +} from "components/Filter/menu"; +import { SelectFilter } from "components/Filter/SelectFilter"; +import { AIBridgeClientIcon } from "../icons/AIBridgeClientIcon"; + +export const useClientFilterMenu = ({ + value, + onChange, + enabled, +}: Pick) => { + return useFilterMenu({ + id: "client", + getSelectedOption: async () => { + const clientsRes = await API.getAIBridgeClients({ + q: value, + limit: 1, + }); + const firstClient = clientsRes.at(0); + + if (firstClient) { + return { + startIcon: ( + + ), + label: firstClient, + value: firstClient, + }; + } + + return null; + }, + getOptions: async (query) => { + const clientsRes = await API.getAIBridgeClients({ + q: query, + limit: 25, + }); + return clientsRes.map((client) => ({ + startIcon: ( + + ), + label: client, + value: client, + })); + }, + value, + onChange, + enabled, + }); +}; + +export type ClientFilterMenu = ReturnType; + +interface ClientFilterProps { + menu: ClientFilterMenu; +} + +export const ClientFilter: React.FC = ({ menu }) => { + return ( + menu.selectOption(option)} + selectedOption={menu.selectedOption ?? undefined} + selectFilterSearch={ + + } + /> + ); +}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx index bcc9cade05..3144e26ad7 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx @@ -5,6 +5,7 @@ import { type useFilter, } from "#/components/Filter/Filter"; import { type UserFilterMenu, UserMenu } from "#/components/Filter/UserFilter"; +import { ClientFilter, type ClientFilterMenu } from "./ClientFilter"; import { ModelFilter, type ModelFilterMenu } from "./ModelFilter"; import { ProviderFilter, type ProviderFilterMenu } from "./ProviderFilter"; @@ -15,6 +16,7 @@ interface RequestLogsFilterProps { user: UserFilterMenu; provider: ProviderFilterMenu; model: ModelFilterMenu; + client: ClientFilterMenu; }; } @@ -44,6 +46,7 @@ export const RequestLogsFilter: FC = ({ + } /> diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx index e29d54f3e9..0ce2720de7 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx @@ -1,13 +1,14 @@ -import { useAuthenticated } from "hooks"; import type { FC } from "react"; import { useSearchParams } from "react-router"; import { paginatedInterceptions } from "#/api/queries/aiBridge"; import { useFilter } from "#/components/Filter/Filter"; import { useUserFilterMenu } from "#/components/Filter/UserFilter"; +import { useAuthenticated } from "#/hooks"; import { usePaginatedQuery } from "#/hooks/usePaginatedQuery"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; +import { useClientFilterMenu } from "./RequestLogsFilter/ClientFilter"; import { useModelFilterMenu } from "./RequestLogsFilter/ModelFilter"; import { useProviderFilterMenu } from "./RequestLogsFilter/ProviderFilter"; import { RequestLogsPageView } from "./RequestLogsPageView"; @@ -65,6 +66,15 @@ const RequestLogsPage: FC = () => { }), }); + const clientMenu = useClientFilterMenu({ + value: filter.values.client, + onChange: (option) => + filter.update({ + ...filter.values, + client: option?.value, + }), + }); + return ( {pageTitle("Request Logs", "AI Bridge")} @@ -82,6 +92,7 @@ const RequestLogsPage: FC = () => { user: userMenu, provider: providerMenu, model: modelMenu, + client: clientMenu, }, }} /> diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx index 624bbbf74e..85415e0eee 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx @@ -27,6 +27,7 @@ const defaultFilterProps = getDefaultFilterProps({ user: MockMenu, provider: MockMenu, model: MockMenu, + client: MockMenu, }, });