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 | + +