From d2787df442ef1702b71efe02f50954270a610c67 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 26 Feb 2026 02:40:45 +1100 Subject: [PATCH] feat: add AI Bridge request logs model filter (#22230) This pull-request implements a simple filtering logic so that we're able to pick which model the user actually used when logs were sent to AI Bridge. - Add `GET /aibridge/models` API endpoint that returns distinct model names from AI Bridge interceptions, with pagination and search support - New `ListAIBridgeModels` SQL query using case-sensitive prefix matching (`LIKE model || '%'`) to allow B-tree index usage - Hand-written `ListAuthorizedAIBridgeModels` in `modelqueries.go` for RBAC authorization filter injection - `AIBridgeModels` search query parser in searchquery/search.go (defaults bare terms to the `model` field) - dbauthz wrappers, dbmetrics, and dbmock implementations for the new query image --- coderd/apidoc/docs.go | 28 +++++++ coderd/apidoc/swagger.json | 24 ++++++ coderd/database/dbauthz/dbauthz.go | 15 ++++ 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 | 54 +++++++++++++ coderd/database/queries/aibridge.sql | 25 ++++++ coderd/searchquery/search.go | 29 +++++++ docs/reference/api/aibridge.md | 33 ++++++++ enterprise/coderd/aibridge.go | 55 +++++++++++++ site/src/api/api.ts | 7 ++ .../RequestLogsFilter/ModelFilter.tsx | 78 +++++++++++++++++++ .../ProviderFilter.tsx} | 0 .../RequestLogsFilter.tsx | 5 +- .../RequestLogsPage/RequestLogsPage.tsx | 13 +++- .../RequestLogsPageView.stories.tsx | 1 + .../RequestLogsPage/RequestLogsPageView.tsx | 2 +- 20 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ModelFilter.tsx rename site/src/pages/AIBridgePage/RequestLogsPage/{filter/filter.tsx => RequestLogsFilter/ProviderFilter.tsx} (100%) rename site/src/pages/AIBridgePage/RequestLogsPage/{filter => RequestLogsFilter}/RequestLogsFilter.tsx (80%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 56d40df81e..5f4f4a9b7d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -135,6 +135,34 @@ const docTemplate = `{ } } }, + "/aibridge/models": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "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" + } + } + } + } + } + }, "/appearance": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index aae5f091c0..c6e0ad952c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -112,6 +112,30 @@ } } }, + "/aibridge/models": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "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" + } + } + } + } + } + }, "/appearance": { "get": { "security": [ diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4da842ffb2..08e87edfe7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4791,6 +4791,14 @@ func (q *querier) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Contex return q.db.ListAIBridgeInterceptionsTelemetrySummaries(ctx, arg) } +func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams) ([]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.ListAuthorizedAIBridgeModels(ctx, arg, prep) +} + func (q *querier) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIDs []uuid.UUID) ([]database.AIBridgeTokenUsage, error) { // This function is a system function until we implement a join for aibridge interceptions. // Matches the behavior of the workspaces listing endpoint. @@ -6352,3 +6360,10 @@ func (q *querier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg // database.Store interface, so dbauthz needs to implement it. return q.CountAIBridgeInterceptions(ctx, arg) } + +func (q *querier) ListAuthorizedAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams, _ rbac.PreparedAuthorized) ([]string, error) { + // TODO: Delete this function, all ListAIBridgeModels should be authorized. For now just call ListAIBridgeModels 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.ListAIBridgeModels(ctx, arg) +} diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 5215955901..7f03e65166 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -4760,6 +4760,20 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(params, emptyPreparedAuthorized{}).Asserts() })) + s.Run("ListAIBridgeModels", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeModelsParams{} + db.EXPECT().ListAuthorizedAIBridgeModels(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args(params).Asserts() + })) + + s.Run("ListAuthorizedAIBridgeModels", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeModelsParams{} + db.EXPECT().ListAuthorizedAIBridgeModels(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes() + // No asserts here because SQLFilter. + check.Args(params, emptyPreparedAuthorized{}).Asserts() + })) + s.Run("ListAIBridgeTokenUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeTokenUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeTokenUsage{}, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ac694e9b8d..ffe79f0c8b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3214,6 +3214,14 @@ func (m queryMetricsStore) ListAIBridgeInterceptionsTelemetrySummaries(ctx conte return r0, r1 } +func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams) ([]string, error) { + start := time.Now() + r0, r1 := m.s.ListAIBridgeModels(ctx, arg) + m.queryLatencies.WithLabelValues("ListAIBridgeModels").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeModels").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]database.AIBridgeTokenUsage, error) { start := time.Now() r0, r1 := m.s.ListAIBridgeTokenUsagesByInterceptionIDs(ctx, interceptionIds) @@ -4428,3 +4436,11 @@ func (m queryMetricsStore) CountAuthorizedAIBridgeInterceptions(ctx context.Cont m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountAuthorizedAIBridgeInterceptions").Inc() return r0, r1 } + +func (m queryMetricsStore) ListAuthorizedAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams, prepared rbac.PreparedAuthorized) ([]string, error) { + start := time.Now() + r0, r1 := m.s.ListAuthorizedAIBridgeModels(ctx, arg, prepared) + m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeModels").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAuthorizedAIBridgeModels").Inc() + return r0, r1 +} diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6bee7705b8..cb5451293e 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6012,6 +6012,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeInterceptionsTelemetrySummaries(ctx return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeInterceptionsTelemetrySummaries", reflect.TypeOf((*MockStore)(nil).ListAIBridgeInterceptionsTelemetrySummaries), ctx, arg) } +// ListAIBridgeModels mocks base method. +func (m *MockStore) ListAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAIBridgeModels", ctx, arg) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAIBridgeModels indicates an expected call of ListAIBridgeModels. +func (mr *MockStoreMockRecorder) ListAIBridgeModels(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAIBridgeModels), ctx, arg) +} + // ListAIBridgeTokenUsagesByInterceptionIDs mocks base method. func (m *MockStore) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]database.AIBridgeTokenUsage, error) { m.ctrl.T.Helper() @@ -6072,6 +6087,21 @@ func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeInterceptions(ctx, arg, p return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeInterceptions), ctx, arg, prepared) } +// ListAuthorizedAIBridgeModels mocks base method. +func (m *MockStore) ListAuthorizedAIBridgeModels(ctx context.Context, arg database.ListAIBridgeModelsParams, prepared rbac.PreparedAuthorized) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeModels", ctx, arg, prepared) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAuthorizedAIBridgeModels indicates an expected call of ListAuthorizedAIBridgeModels. +func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeModels(ctx, arg, prepared any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAuthorizedAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAuthorizedAIBridgeModels), ctx, arg, prepared) +} + // ListProvisionerKeysByOrganization mocks base method. func (m *MockStore) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 5ec90a78d6..d5bccd2f46 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -769,6 +769,7 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun 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) } func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error) { @@ -870,6 +871,35 @@ func (q *sqlQuerier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, a return count, nil } +func (q *sqlQuerier) ListAuthorizedAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams, 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(listAIBridgeModels, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + query := fmt.Sprintf("-- name: ListAIBridgeModels :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, arg.Model, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var model string + if err := rows.Scan(&model); err != nil { + return nil, err + } + items = append(items, model) + } + return items, nil +} + func insertAuthorizedFilter(query string, replaceWith string) (string, error) { if !strings.Contains(query, authorizedQueryPlaceholder) { return "", xerrors.Errorf("query does not contain authorized replace string, this is not an authorized query") diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 3b6c07dd05..5d19d4813c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -653,6 +653,7 @@ type sqlcQuerier interface { // Finds all unique AI Bridge interception telemetry summaries combinations // (provider, model, client) in the given timeframe for telemetry reporting. ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg ListAIBridgeInterceptionsTelemetrySummariesParams) ([]ListAIBridgeInterceptionsTelemetrySummariesRow, error) + ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeUserPrompt, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 02c93a4742..a4fc95d1ba 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -928,6 +928,60 @@ func (q *sqlQuerier) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Con return items, nil } +const listAIBridgeModels = `-- name: ListAIBridgeModels :many +SELECT + model +FROM + aibridge_interceptions +WHERE + -- Remove inflight interceptions (ones which lack an ended_at value). + aibridge_interceptions.ended_at IS NOT NULL + -- Filter model + AND CASE + WHEN $1::text != '' THEN aibridge_interceptions.model LIKE $1::text || '%' + ELSE true + END + -- We use an ` + "`" + `@authorize_filter` + "`" + ` as we are attempting to list models that are relevant + -- to the user and what they are allowed to see. + -- Authorize Filter clause will be injected below in ListAIBridgeModelsAuthorized + -- @authorize_filter +GROUP BY + model +ORDER BY + model ASC +LIMIT COALESCE(NULLIF($3::integer, 0), 100) +OFFSET $2 +` + +type ListAIBridgeModelsParams struct { + Model string `db:"model" json:"model"` + Offset int32 `db:"offset_" json:"offset_"` + Limit int32 `db:"limit_" json:"limit_"` +} + +func (q *sqlQuerier) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listAIBridgeModels, arg.Model, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var model string + if err := rows.Scan(&model); err != nil { + return nil, err + } + items = append(items, model) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAIBridgeTokenUsagesByInterceptionIDs = `-- name: ListAIBridgeTokenUsagesByInterceptionIDs :many SELECT id, interception_id, provider_response_id, input_tokens, output_tokens, metadata, created_at diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 5d6aa51817..d2170e0c49 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -374,3 +374,28 @@ SELECT ( (SELECT COUNT(*) FROM user_prompts) + (SELECT COUNT(*) FROM interceptions) )::bigint as total_deleted; + +-- name: ListAIBridgeModels :many +SELECT + model +FROM + aibridge_interceptions +WHERE + -- Remove inflight interceptions (ones which lack an ended_at value). + aibridge_interceptions.ended_at IS NOT NULL + -- Filter model + AND CASE + WHEN @model::text != '' THEN aibridge_interceptions.model LIKE @model::text || '%' + ELSE true + END + -- We use an `@authorize_filter` as we are attempting to list models that are relevant + -- to the user and what they are allowed to see. + -- Authorize Filter clause will be injected below in ListAIBridgeModelsAuthorized + -- @authorize_filter +GROUP BY + model +ORDER BY + model ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100) +OFFSET @offset_ +; diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index add8947176..1c4c3bce11 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -401,6 +401,35 @@ func AIBridgeInterceptions(ctx context.Context, db database.Store, query string, return filter, parser.Errors } +func AIBridgeModels(query string, page codersdk.Pagination) (database.ListAIBridgeModelsParams, []codersdk.ValidationError) { + // nolint:exhaustruct // Empty values just means "don't filter by that field". + filter := database.ListAIBridgeModelsParams{ + // #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 { + // Defaults to the `model` if no `key:value` pair is provided. + values.Add("model", term) + return nil + }) + if len(errors) > 0 { + return filter, errors + } + + parser := httpapi.NewQueryParamParser() + filter.Model = parser.String(values, "", "model") + + parser.ErrorExcessParams(values) + return filter, parser.Errors +} + // Tasks parses a search query for tasks. // // Supported query parameters: diff --git a/docs/reference/api/aibridge.md b/docs/reference/api/aibridge.md index 68a398c80e..d5ca02bd5b 100644 --- a/docs/reference/api/aibridge.md +++ b/docs/reference/api/aibridge.md @@ -104,3 +104,36 @@ curl -X GET http://coder-server:8080/api/v2/aibridge/interceptions \ | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.AIBridgeListInterceptionsResponse](schemas.md#codersdkaibridgelistinterceptionsresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## List AI Bridge models + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/aibridge/models \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /aibridge/models` + +### 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). diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 0d8381bf44..ce988006d3 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -22,7 +22,9 @@ import ( const ( maxListInterceptionsLimit = 1000 + maxListModelsLimit = 1000 defaultListInterceptionsLimit = 100 + defaultListModelsLimit = 100 // aiBridgeRateLimitWindow is the fixed duration for rate limiting AI Bridge // requests. This is hardcoded to keep configuration simple. aiBridgeRateLimitWindow = time.Second @@ -41,6 +43,7 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f r.Group(func(r chi.Router) { r.Use(middlewares...) r.Get("/interceptions", api.aiBridgeListInterceptions) + r.Get("/models", api.aiBridgeListModels) }) // Apply overload protection middleware to the aibridged handler. @@ -173,6 +176,58 @@ func (api *API) aiBridgeListInterceptions(rw http.ResponseWriter, r *http.Reques }) } +// aiBridgeListModels returns all AI Bridge models a user can see. +// +// @Summary List AI Bridge models +// @ID list-ai-bridge-models +// @Security CoderSessionToken +// @Produce json +// @Tags AI Bridge +// @Success 200 {array} string +// @Router /aibridge/models [get] +func (api *API) aiBridgeListModels(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + page, ok := coderd.ParsePagination(rw, r) + if !ok { + return + } + + if page.Limit == 0 { + page.Limit = defaultListModelsLimit + } + + if page.Limit > maxListModelsLimit || 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]", maxListModelsLimit), + }) + return + } + + queryStr := r.URL.Query().Get("q") + filter, errs := searchquery.AIBridgeModels(queryStr, page) + + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid AI Bridge models search query.", + Validations: errs, + }) + return + } + + 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.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, models) +} + func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.Store, dbInterceptions []database.ListAIBridgeInterceptionsRow) ([]codersdk.AIBridgeInterception, error) { ids := make([]uuid.UUID, len(dbInterceptions)) for i, row := range dbInterceptions { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 86b9a3de62..6d27c68fc2 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2864,6 +2864,13 @@ class ApiMethods { await this.axios.get(url); return response.data; }; + + getAIBridgeModels = async (options: SearchParamOptions) => { + const url = getURLWithSearchParams("/api/v2/aibridge/models", options); + + const response = await this.axios.get(url); + return response.data; + }; } export type TaskFeedbackRating = "good" | "okay" | "bad"; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ModelFilter.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ModelFilter.tsx new file mode 100644 index 0000000000..c34ef987ba --- /dev/null +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ModelFilter.tsx @@ -0,0 +1,78 @@ +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 type { FC } from "react"; +import { AIBridgeModelIcon } from "../icons/AIBridgeModelIcon"; + +export const useModelFilterMenu = ({ + value, + onChange, + enabled, +}: Pick) => { + return useFilterMenu({ + id: "model", + getSelectedOption: async () => { + const modelsRes = await API.getAIBridgeModels({ + q: value, + limit: 1, + }); + const firstModel = modelsRes.at(0); + + if (firstModel && firstModel === value) { + return { + label: firstModel, + value: firstModel, + startIcon: ( + + ), + }; + } + + return null; + }, + getOptions: async (query) => { + const modelsRes = await API.getAIBridgeModels({ + q: query, + limit: 25, + }); + return modelsRes.map((model) => ({ + label: model, + value: model, + startIcon: , + })); + }, + value, + onChange, + enabled, + }); +}; + +export type ModelFilterMenu = ReturnType; + +interface ModelFilterProps { + menu: ModelFilterMenu; +} + +export const ModelFilter: FC = ({ menu }) => { + return ( + menu.selectOption(option)} + selectedOption={menu.selectedOption ?? undefined} + selectFilterSearch={ + + } + /> + ); +}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/filter/filter.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ProviderFilter.tsx similarity index 100% rename from site/src/pages/AIBridgePage/RequestLogsPage/filter/filter.tsx rename to site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/ProviderFilter.tsx diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/filter/RequestLogsFilter.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx similarity index 80% rename from site/src/pages/AIBridgePage/RequestLogsPage/filter/RequestLogsFilter.tsx rename to site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx index 34efa48a38..5ae101704b 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/filter/RequestLogsFilter.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx @@ -1,7 +1,8 @@ import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; import type { FC } from "react"; -import { ProviderFilter, type ProviderFilterMenu } from "./filter"; +import { ModelFilter, type ModelFilterMenu } from "./ModelFilter"; +import { ProviderFilter, type ProviderFilterMenu } from "./ProviderFilter"; interface RequestLogsFilterProps { filter: ReturnType; @@ -9,6 +10,7 @@ interface RequestLogsFilterProps { menus: { user: UserFilterMenu; provider: ProviderFilterMenu; + model: ModelFilterMenu; }; } @@ -37,6 +39,7 @@ export const RequestLogsFilter: FC = ({ <> + } /> diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx index ee53cbbfde..bdb944e66c 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx @@ -8,7 +8,8 @@ import { RequirePermission } from "modules/permissions/RequirePermission"; import type { FC } from "react"; import { useSearchParams } from "react-router"; import { pageTitle } from "utils/page"; -import { useProviderFilterMenu } from "./filter/filter"; +import { useModelFilterMenu } from "./RequestLogsFilter/ModelFilter"; +import { useProviderFilterMenu } from "./RequestLogsFilter/ProviderFilter"; import { RequestLogsPageView } from "./RequestLogsPageView"; const RequestLogsPage: FC = () => { @@ -52,6 +53,15 @@ const RequestLogsPage: FC = () => { }), }); + const modelMenu = useModelFilterMenu({ + value: filter.values.model, + onChange: (option) => + filter.update({ + ...filter.values, + model: option?.value, + }), + }); + return ( {pageTitle("Request Logs", "AI Bridge")} @@ -67,6 +77,7 @@ const RequestLogsPage: FC = () => { menus: { user: userMenu, provider: providerMenu, + model: modelMenu, }, }} /> diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx index a069f4faf7..1ee05042c0 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx @@ -26,6 +26,7 @@ const defaultFilterProps = getDefaultFilterProps({ menus: { user: MockMenu, provider: MockMenu, + model: MockMenu, }, }); diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx index 2ab55bf3df..25baec869e 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx +++ b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx @@ -14,7 +14,7 @@ import { import { TableEmpty } from "components/TableEmpty/TableEmpty"; import { TableLoader } from "components/TableLoader/TableLoader"; import type { ComponentProps, FC } from "react"; -import { RequestLogsFilter } from "./filter/RequestLogsFilter"; +import { RequestLogsFilter } from "./RequestLogsFilter/RequestLogsFilter"; import { RequestLogsRow } from "./RequestLogsRow/RequestLogsRow"; interface RequestLogsPageViewProps {