mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: implement <ClientFilter /> to AI Bridge request logs (#22694)
Closes #22136 This pull-request implements a `<ClientFilter />` 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). <img width="1447" height="831" alt="image" src="https://github.com/user-attachments/assets/0be234e2-25f2-4a89-b971-d74817395da1" /> --------- Co-authored-by: Jeremy Ruppel <jeremy.ruppel@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+28
@@ -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": [
|
||||
|
||||
Generated
+24
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_
|
||||
;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+33
@@ -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 |
|
||||
|
||||
<h3 id="list-ai-bridge-clients-responseschema">Response Schema</h3>
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## List AI Bridge interceptions
|
||||
|
||||
### Code samples
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -3019,6 +3019,13 @@ class ApiMethods {
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeClients = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/clients", options);
|
||||
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
|
||||
export type TaskFeedbackRating = "good" | "okay" | "bad";
|
||||
|
||||
@@ -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} `;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UseFilterMenuOptions, "value" | "onChange" | "enabled">) => {
|
||||
return useFilterMenu({
|
||||
id: "client",
|
||||
getSelectedOption: async () => {
|
||||
const clientsRes = await API.getAIBridgeClients({
|
||||
q: value,
|
||||
limit: 1,
|
||||
});
|
||||
const firstClient = clientsRes.at(0);
|
||||
|
||||
if (firstClient) {
|
||||
return {
|
||||
startIcon: (
|
||||
<AIBridgeClientIcon client={firstClient} className="size-icon-sm" />
|
||||
),
|
||||
label: firstClient,
|
||||
value: firstClient,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
getOptions: async (query) => {
|
||||
const clientsRes = await API.getAIBridgeClients({
|
||||
q: query,
|
||||
limit: 25,
|
||||
});
|
||||
return clientsRes.map((client) => ({
|
||||
startIcon: (
|
||||
<AIBridgeClientIcon client={client} className="size-icon-sm" />
|
||||
),
|
||||
label: client,
|
||||
value: client,
|
||||
}));
|
||||
},
|
||||
value,
|
||||
onChange,
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export type ClientFilterMenu = ReturnType<typeof useClientFilterMenu>;
|
||||
|
||||
interface ClientFilterProps {
|
||||
menu: ClientFilterMenu;
|
||||
}
|
||||
|
||||
export const ClientFilter: React.FC<ClientFilterProps> = ({ menu }) => {
|
||||
return (
|
||||
<SelectFilter
|
||||
label="Select client"
|
||||
placeholder="All clients"
|
||||
emptyText="No clients found"
|
||||
options={menu.searchOptions}
|
||||
onSelect={(option) => menu.selectOption(option)}
|
||||
selectedOption={menu.selectedOption ?? undefined}
|
||||
selectFilterSearch={
|
||||
<ComboboxInput
|
||||
placeholder="Search client..."
|
||||
value={menu.query}
|
||||
onValueChange={menu.setQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<RequestLogsFilterProps> = ({
|
||||
<UserMenu menu={menus.user} placeholder="All initiators" />
|
||||
<ProviderFilter menu={menus.provider} />
|
||||
<ModelFilter menu={menus.model} />
|
||||
<ClientFilter menu={menus.client} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<RequirePermission isFeatureVisible={hasPermission}>
|
||||
<title>{pageTitle("Request Logs", "AI Bridge")}</title>
|
||||
@@ -82,6 +92,7 @@ const RequestLogsPage: FC = () => {
|
||||
user: userMenu,
|
||||
provider: providerMenu,
|
||||
model: modelMenu,
|
||||
client: clientMenu,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,7 @@ const defaultFilterProps = getDefaultFilterProps<FilterProps>({
|
||||
user: MockMenu,
|
||||
provider: MockMenu,
|
||||
model: MockMenu,
|
||||
client: MockMenu,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user