chore: add user details to aibridge interception list endpoint (#20397)

- Adds FK from `aibridge_interceptions.initiator_id` to `users.id`
- This is enforced by deleting any rows that don't have any users. Since
this is an experimental feature AND coder never deletes user rows I
think this is acceptable.
- Adds `name` as a property on `codersdk.MinimalUser`
- This matches the `visible_users` view in the database. I'm unsure why
`name` wasn't already included given that `username` is.
- Adds a new `initiator` field to `codersdk.AIBridgeInterception` which
contains `codersdk.MinimalUser` (ID, username, name, avatar URL)
- Removes `initiator_id` from `codersdk.AIBridgeInterception`
    - Should be fine since we're still in early access
This commit is contained in:
Dean Sheather
2025-10-22 16:18:31 +11:00
committed by GitHub
parent 9da60a9dc5
commit 69c2c40512
30 changed files with 329 additions and 78 deletions
+12 -3
View File
@@ -189,6 +189,16 @@ func MinimalUser(user database.User) codersdk.MinimalUser {
return codersdk.MinimalUser{
ID: user.ID,
Username: user.Username,
Name: user.Name,
AvatarURL: user.AvatarURL,
}
}
func MinimalUserFromVisibleUser(user database.VisibleUser) codersdk.MinimalUser {
return codersdk.MinimalUser{
ID: user.ID,
Username: user.Username,
Name: user.Name,
AvatarURL: user.AvatarURL,
}
}
@@ -197,7 +207,6 @@ func ReducedUser(user database.User) codersdk.ReducedUser {
return codersdk.ReducedUser{
MinimalUser: MinimalUser(user),
Email: user.Email,
Name: user.Name,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
LastSeenAt: user.LastSeenAt,
@@ -927,7 +936,7 @@ func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.Pr
}
}
func AIBridgeInterception(interception database.AIBridgeInterception, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
sdkTokenUsages := List(tokenUsages, AIBridgeTokenUsage)
sort.Slice(sdkTokenUsages, func(i, j int) bool {
// created_at ASC
@@ -945,7 +954,7 @@ func AIBridgeInterception(interception database.AIBridgeInterception, tokenUsage
})
return codersdk.AIBridgeInterception{
ID: interception.ID,
InitiatorID: interception.InitiatorID,
Initiator: MinimalUserFromVisibleUser(initiator),
Provider: interception.Provider,
Model: interception.Model,
Metadata: jsonOrEmptyMap(interception.Metadata),
+2 -2
View File
@@ -4461,7 +4461,7 @@ func (q *querier) InsertWorkspaceResourceMetadata(ctx context.Context, arg datab
return q.db.InsertWorkspaceResourceMetadata(ctx, arg)
}
func (q *querier) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.AIBridgeInterception, error) {
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 {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
@@ -5870,7 +5870,7 @@ func (q *querier) CountAuthorizedConnectionLogs(ctx context.Context, arg databas
return q.CountConnectionLogs(ctx, arg)
}
func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, _ rbac.PreparedAuthorized) ([]database.AIBridgeInterception, error) {
func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, _ rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
// TODO: Delete this function, all ListAIBridgeInterceptions should be authorized. For now just call ListAIBridgeInterceptions 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.
+2 -2
View File
@@ -4537,14 +4537,14 @@ func (s *MethodTestSuite) TestAIBridge() {
s.Run("ListAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeInterceptionsParams{}
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.AIBridgeInterception{}, nil).AnyTimes()
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("ListAuthorizedAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListAIBridgeInterceptionsParams{}
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.AIBridgeInterception{}, nil).AnyTimes()
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
+2 -2
View File
@@ -2714,7 +2714,7 @@ func (m queryMetricsStore) InsertWorkspaceResourceMetadata(ctx context.Context,
return metadata, err
}
func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.AIBridgeInterception, error) {
func (m queryMetricsStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeInterceptions(ctx, arg)
m.queryLatencies.WithLabelValues("ListAIBridgeInterceptions").Observe(time.Since(start).Seconds())
@@ -3722,7 +3722,7 @@ func (m queryMetricsStore) CountAuthorizedConnectionLogs(ctx context.Context, ar
return r0, r1
}
func (m queryMetricsStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.AIBridgeInterception, error) {
func (m queryMetricsStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAuthorizedAIBridgeInterceptions(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeInterceptions").Observe(time.Since(start).Seconds())
+4 -4
View File
@@ -5804,10 +5804,10 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceResourceMetadata(ctx, arg any) *
}
// ListAIBridgeInterceptions mocks base method.
func (m *MockStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.AIBridgeInterception, error) {
func (m *MockStore) ListAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams) ([]database.ListAIBridgeInterceptionsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAIBridgeInterceptions", ctx, arg)
ret0, _ := ret[0].([]database.AIBridgeInterception)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -5864,10 +5864,10 @@ func (mr *MockStoreMockRecorder) ListAIBridgeUserPromptsByInterceptionIDs(ctx, i
}
// ListAuthorizedAIBridgeInterceptions mocks base method.
func (m *MockStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.AIBridgeInterception, error) {
func (m *MockStore) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeInterceptions", ctx, arg, prepared)
ret0, _ := ret[0].([]database.AIBridgeInterception)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
+3
View File
@@ -3511,6 +3511,9 @@ COMMENT ON TRIGGER workspace_agent_name_unique_trigger ON workspace_agents IS 'U
the uniqueness requirement. A trigger allows us to enforce uniqueness going
forward without requiring a migration to clean up historical data.';
ALTER TABLE ONLY aibridge_interceptions
ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ALTER TABLE ONLY api_keys
ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@@ -6,6 +6,7 @@ type ForeignKeyConstraint string
// ForeignKeyConstraint enums.
const (
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE;
@@ -0,0 +1,49 @@
-- We didn't add an FK as a premature optimization when the aibridge tables were
-- added, but for the initiator_id it's pretty annoying not having a strong
-- reference.
--
-- Since the aibridge feature is still in early access, we're going to add the
-- FK and drop any rows that violate it (which should be none). This isn't a
-- very efficient migration, but since the feature is behind an experimental
-- flag, it shouldn't have any impact on deployments that aren't using the
-- feature.
-- Step 1: Add FK without validating it
ALTER TABLE aibridge_interceptions
ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey
FOREIGN KEY (initiator_id)
REFERENCES users(id)
-- We can't:
-- - Cascade delete because this is an auditing feature, and it also
-- wouldn't delete related aibridge rows since we don't FK them.
-- - Set null because you can't correlate to the original user ID if the
-- user somehow gets deleted.
--
-- So we just use the default and don't do anything. This will result in a
-- deferred constraint violation error when the user is deleted.
--
-- In Coder, we don't delete user rows ever, so this should never happen
-- unless an admin manually deletes a user with SQL.
ON DELETE NO ACTION
-- Delay validation of existing data until after we've dropped rows that
-- violate the FK.
NOT VALID;
-- Step 2: Drop existing interceptions that violate the FK.
DELETE FROM aibridge_interceptions
WHERE initiator_id NOT IN (SELECT id FROM users);
-- Step 3: Drop existing rows from other tables that no longer have a valid
-- interception in the database.
DELETE FROM aibridge_token_usages
WHERE interception_id NOT IN (SELECT id FROM aibridge_interceptions);
DELETE FROM aibridge_user_prompts
WHERE interception_id NOT IN (SELECT id FROM aibridge_interceptions);
DELETE FROM aibridge_tool_usages
WHERE interception_id NOT IN (SELECT id FROM aibridge_interceptions);
-- Step 4: Validate the FK
ALTER TABLE aibridge_interceptions
VALIDATE CONSTRAINT aibridge_interceptions_initiator_id_fkey;
@@ -8,7 +8,7 @@ INSERT INTO
)
VALUES (
'be003e1e-b38f-43bf-847d-928074dd0aa8',
'30095c71-380b-457a-8995-97b8ee6e5307',
'30095c71-380b-457a-8995-97b8ee6e5307', -- admin@coder.com, from 000022_initial_v0.6.6.up.sql
'openai',
'gpt-5',
'2025-09-15 12:45:13.921148+00'
@@ -77,3 +77,82 @@ VALUES (
'{}',
'2025-09-15 12:45:21.674335+00'
);
-- For a later migration, we'll add an invalid interception without a valid
-- initiator_id.
INSERT INTO
aibridge_interceptions (
id,
initiator_id,
provider,
model,
started_at
)
VALUES (
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'cab8d56a-8922-4999-81a9-046b43ac1312', -- user does not exist
'openai',
'gpt-5',
'2025-09-15 12:45:13.921148+00'
);
INSERT INTO
aibridge_token_usages (
id,
interception_id,
provider_response_id,
input_tokens,
output_tokens,
metadata,
created_at
)
VALUES (
'5650db6c-0b7c-49e3-bb26-9b2ba0107e11',
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k',
10950,
118,
'{}',
'2025-09-15 12:45:21.674413+00'
);
INSERT INTO
aibridge_user_prompts (
id,
interception_id,
provider_response_id,
prompt,
metadata,
created_at
)
VALUES (
'1e76cb5b-7c34-4160-b604-a4256f856169',
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k',
'how many workspaces do i have',
'{}',
'2025-09-15 12:45:21.674335+00'
);
INSERT INTO
aibridge_tool_usages (
id,
interception_id,
provider_response_id,
tool,
server_url,
input,
injected,
invocation_error,
metadata,
created_at
)
VALUES (
'351b440f-d605-4f37-8ceb-011f0377b695',
'c6d29c6e-26a3-4137-bb2e-9dfeef3c1c26',
'chatcmpl-CG2s28QlpKIoooUtXuLTmGbdtyS1k',
'coder_list_workspaces',
'http://localhost:3000/api/experimental/mcp/http',
'{}',
true,
NULL,
'{}',
'2025-09-15 12:45:21.674413+00'
);
+14 -10
View File
@@ -763,11 +763,11 @@ func (q *sqlQuerier) CountAuthorizedConnectionLogs(ctx context.Context, arg Coun
}
type aibridgeQuerier interface {
ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]AIBridgeInterception, error)
ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error)
CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error)
}
func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]AIBridgeInterception, error) {
func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeInterceptionsRow, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.AIBridgeInterceptionConverter(),
})
@@ -794,16 +794,20 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, ar
return nil, err
}
defer rows.Close()
var items []AIBridgeInterception
var items []ListAIBridgeInterceptionsRow
for rows.Next() {
var i AIBridgeInterception
var i ListAIBridgeInterceptionsRow
if err := rows.Scan(
&i.ID,
&i.InitiatorID,
&i.Provider,
&i.Model,
&i.StartedAt,
&i.Metadata,
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
&i.VisibleUser.AvatarURL,
); err != nil {
return nil, err
}
+1 -1
View File
@@ -592,7 +592,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)
ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]AIBridgeInterception, error)
ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, 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)
+22 -10
View File
@@ -528,9 +528,12 @@ func (q *sqlQuerier) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIB
const listAIBridgeInterceptions = `-- name: ListAIBridgeInterceptions :many
SELECT
id, initiator_id, provider, model, started_at, metadata
aibridge_interceptions.id, aibridge_interceptions.initiator_id, aibridge_interceptions.provider, aibridge_interceptions.model, aibridge_interceptions.started_at, aibridge_interceptions.metadata,
visible_users.id, visible_users.username, visible_users.name, visible_users.avatar_url
FROM
aibridge_interceptions
JOIN
visible_users ON visible_users.id = aibridge_interceptions.initiator_id
WHERE
-- Filter by time frame
CASE
@@ -592,7 +595,12 @@ type ListAIBridgeInterceptionsParams struct {
Limit int32 `db:"limit_" json:"limit_"`
}
func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]AIBridgeInterception, error) {
type ListAIBridgeInterceptionsRow struct {
AIBridgeInterception AIBridgeInterception `db:"aibridge_interception" json:"aibridge_interception"`
VisibleUser VisibleUser `db:"visible_user" json:"visible_user"`
}
func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBridgeInterceptionsParams) ([]ListAIBridgeInterceptionsRow, error) {
rows, err := q.db.QueryContext(ctx, listAIBridgeInterceptions,
arg.StartedAfter,
arg.StartedBefore,
@@ -607,16 +615,20 @@ func (q *sqlQuerier) ListAIBridgeInterceptions(ctx context.Context, arg ListAIBr
return nil, err
}
defer rows.Close()
var items []AIBridgeInterception
var items []ListAIBridgeInterceptionsRow
for rows.Next() {
var i AIBridgeInterception
var i ListAIBridgeInterceptionsRow
if err := rows.Scan(
&i.ID,
&i.InitiatorID,
&i.Provider,
&i.Model,
&i.StartedAt,
&i.Metadata,
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
&i.VisibleUser.AvatarURL,
); err != nil {
return nil, err
}
+4 -1
View File
@@ -111,9 +111,12 @@ WHERE
-- name: ListAIBridgeInterceptions :many
SELECT
*
sqlc.embed(aibridge_interceptions),
sqlc.embed(visible_users)
FROM
aibridge_interceptions
JOIN
visible_users ON visible_users.id = aibridge_interceptions.initiator_id
WHERE
-- Filter by time frame
CASE