refactor!: remove interceptions API, request logs view, and associated code (#26213)

## Summary

Removes the deprecated `/api/v2/aibridge/interceptions` endpoint and the
Request Logs frontend page, both replaced by the session-based view.

Closes https://linear.app/codercom/issue/AIGOV-266
Closes https://linear.app/codercom/issue/AIGOV-324

## Changes

### Backend
- Remove `GET /api/v2/aibridge/interceptions` HTTP handler and route
- Remove SDK types and client method (`AIBridgeInterception`,
`AIBridgeTokenUsage`, `AIBridgeUserPrompt`, `AIBridgeToolUsage`,
`AIBridgeListInterceptionsResponse`, `AIBridgeListInterceptionsFilter`)
- Remove SQL queries `CountAIBridgeInterceptions` and
`ListAIBridgeInterceptions`
- Remove `searchquery.AIBridgeInterceptions` parser
- Remove dbauthz wrappers, in-memory implementations, metrics, and mocks
for the interceptions list queries
- Remove the `coder aibridge interceptions list` CLI command and golden
files
- Regenerate API docs, swagger, mocks, and metrics

The `/models`, `/clients`, and `/sessions` endpoints stay; the sessions
list page still consumes all three.

### Frontend
- Delete the entire `RequestLogsPage/` directory (page, view, row,
filter, stories, tests)
- Remove the `/aibridge/request-logs` route and its lazy import
- Remove the `getAIBridgeInterceptions` API method,
`paginatedInterceptions` query, and mock interception entities
- `git mv` the shared filter and icon components used by the sessions
pages:
- `RequestLogsPage/RequestLogsFilter/{Client,Model,Provider}Filter.tsx`
→ `AIBridgePage/filters/`
- `RequestLogsPage/icons/AIBridge{Client,Model,Provider}Icon.tsx` →
`AIBridgePage/icons/`
- Drop the `getProviderIconName` hack and the duplicate `anthropic-neue`
icon case now that the FIXME no longer applies

## Commits

1. `refactor: remove interceptions API and request logs view` — the bulk
removal, with explicit renames for the shared filter/icon files.
2. `refactor(site/src/pages/AIBridgePage): drop getProviderIconName
hack` — cleanup of the FIXME that depended on RequestLogsPage existing.

> [!NOTE]
> Generated by Coder Agents on behalf of @dannykopping
This commit is contained in:
Danny Kopping
2026-06-12 07:50:46 +02:00
committed by GitHub
parent b1c6010eb9
commit 4a07f61c50
56 changed files with 36 additions and 4548 deletions
-225
View File
@@ -1422,58 +1422,6 @@ const docTemplate = `{
]
}
},
"/api/v2/aibridge/interceptions": {
"get": {
"produces": [
"application/json"
],
"tags": [
"AI Bridge"
],
"summary": "List AI Bridge interceptions",
"operationId": "list-ai-bridge-interceptions",
"deprecated": true,
"parameters": [
{
"type": "string",
"description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: initiator, provider, provider_name, model, started_after, started_before.",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Cursor pagination after ID (cannot be used with offset)",
"name": "after_id",
"in": "query"
},
{
"type": "integer",
"description": "Offset pagination (cannot be used with after_id)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIBridgeListInterceptionsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/keys": {
"get": {
"produces": [
@@ -14657,77 +14605,6 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeInterception": {
"type": "object",
"properties": {
"api_key_id": {
"type": "string"
},
"client": {
"type": "string"
},
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"provider_name": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"token_usages": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeTokenUsage"
}
},
"tool_usages": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeToolUsage"
}
},
"user_prompts": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeUserPrompt"
}
}
}
},
"codersdk.AIBridgeListInterceptionsResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeInterception"
}
}
}
},
"codersdk.AIBridgeListSessionsResponse": {
"type": "object",
"properties": {
@@ -14990,42 +14867,6 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeTokenUsage": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"input_tokens": {
"type": "integer"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"output_tokens": {
"type": "integer"
},
"provider_response_id": {
"type": "string"
}
}
},
"codersdk.AIBridgeToolCall": {
"type": "object",
"properties": {
@@ -15062,72 +14903,6 @@ const docTemplate = `{
}
}
},
"codersdk.AIBridgeToolUsage": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"injected": {
"type": "boolean"
},
"input": {
"type": "string"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"invocation_error": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"provider_response_id": {
"type": "string"
},
"server_url": {
"type": "string"
},
"tool": {
"type": "string"
}
}
},
"codersdk.AIBridgeUserPrompt": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"prompt": {
"type": "string"
},
"provider_response_id": {
"type": "string"
}
}
},
"codersdk.AIConfig": {
"type": "object",
"properties": {
-221
View File
@@ -1255,54 +1255,6 @@
]
}
},
"/api/v2/aibridge/interceptions": {
"get": {
"produces": ["application/json"],
"tags": ["AI Bridge"],
"summary": "List AI Bridge interceptions",
"operationId": "list-ai-bridge-interceptions",
"deprecated": true,
"parameters": [
{
"type": "string",
"description": "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, started_after, started_before.",
"name": "q",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Cursor pagination after ID (cannot be used with offset)",
"name": "after_id",
"in": "query"
},
{
"type": "integer",
"description": "Offset pagination (cannot be used with after_id)",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIBridgeListInterceptionsResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/keys": {
"get": {
"produces": ["application/json"],
@@ -13037,77 +12989,6 @@
}
}
},
"codersdk.AIBridgeInterception": {
"type": "object",
"properties": {
"api_key_id": {
"type": "string"
},
"client": {
"type": "string"
},
"ended_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"model": {
"type": "string"
},
"provider": {
"type": "string"
},
"provider_name": {
"type": "string"
},
"started_at": {
"type": "string",
"format": "date-time"
},
"token_usages": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeTokenUsage"
}
},
"tool_usages": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeToolUsage"
}
},
"user_prompts": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeUserPrompt"
}
}
}
},
"codersdk.AIBridgeListInterceptionsResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIBridgeInterception"
}
}
}
},
"codersdk.AIBridgeListSessionsResponse": {
"type": "object",
"properties": {
@@ -13370,42 +13251,6 @@
}
}
},
"codersdk.AIBridgeTokenUsage": {
"type": "object",
"properties": {
"cache_read_input_tokens": {
"type": "integer"
},
"cache_write_input_tokens": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"input_tokens": {
"type": "integer"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"output_tokens": {
"type": "integer"
},
"provider_response_id": {
"type": "string"
}
}
},
"codersdk.AIBridgeToolCall": {
"type": "object",
"properties": {
@@ -13442,72 +13287,6 @@
}
}
},
"codersdk.AIBridgeToolUsage": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"injected": {
"type": "boolean"
},
"input": {
"type": "string"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"invocation_error": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"provider_response_id": {
"type": "string"
},
"server_url": {
"type": "string"
},
"tool": {
"type": "string"
}
}
},
"codersdk.AIBridgeUserPrompt": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"interception_id": {
"type": "string",
"format": "uuid"
},
"metadata": {
"type": "object",
"additionalProperties": {}
},
"prompt": {
"type": "string"
},
"provider_response_id": {
"type": "string"
}
}
},
"codersdk.AIConfig": {
"type": "object",
"properties": {
-80
View File
@@ -1093,46 +1093,6 @@ func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.Pr
}
}
func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
sdkTokenUsages := slice.List(tokenUsages, AIBridgeTokenUsage)
sort.Slice(sdkTokenUsages, func(i, j int) bool {
// created_at ASC
return sdkTokenUsages[i].CreatedAt.Before(sdkTokenUsages[j].CreatedAt)
})
sdkUserPrompts := slice.List(userPrompts, AIBridgeUserPrompt)
sort.Slice(sdkUserPrompts, func(i, j int) bool {
// created_at ASC
return sdkUserPrompts[i].CreatedAt.Before(sdkUserPrompts[j].CreatedAt)
})
sdkToolUsages := slice.List(toolUsages, AIBridgeToolUsage)
sort.Slice(sdkToolUsages, func(i, j int) bool {
// created_at ASC
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
})
intc := codersdk.AIBridgeInterception{
ID: interception.ID,
Initiator: MinimalUserFromVisibleUser(initiator),
Provider: interception.Provider,
ProviderName: interception.ProviderName,
Model: interception.Model,
Metadata: jsonOrEmptyMap(interception.Metadata),
StartedAt: interception.StartedAt,
TokenUsages: sdkTokenUsages,
UserPrompts: sdkUserPrompts,
ToolUsages: sdkToolUsages,
}
if interception.APIKeyID.Valid {
intc.APIKeyID = &interception.APIKeyID.String
}
if interception.EndedAt.Valid {
intc.EndedAt = &interception.EndedAt.Time
}
if interception.Client.Valid {
intc.Client = &interception.Client.String
}
return intc
}
func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSession {
session := codersdk.AIBridgeSession{
ID: row.SessionID,
@@ -1174,46 +1134,6 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess
return session
}
func AIBridgeTokenUsage(usage database.AIBridgeTokenUsage) codersdk.AIBridgeTokenUsage {
return codersdk.AIBridgeTokenUsage{
ID: usage.ID,
InterceptionID: usage.InterceptionID,
ProviderResponseID: usage.ProviderResponseID,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
CacheReadInputTokens: usage.CacheReadInputTokens,
CacheWriteInputTokens: usage.CacheWriteInputTokens,
Metadata: jsonOrEmptyMap(usage.Metadata),
CreatedAt: usage.CreatedAt,
}
}
func AIBridgeUserPrompt(prompt database.AIBridgeUserPrompt) codersdk.AIBridgeUserPrompt {
return codersdk.AIBridgeUserPrompt{
ID: prompt.ID,
InterceptionID: prompt.InterceptionID,
ProviderResponseID: prompt.ProviderResponseID,
Prompt: prompt.Prompt,
Metadata: jsonOrEmptyMap(prompt.Metadata),
CreatedAt: prompt.CreatedAt,
}
}
func AIBridgeToolUsage(usage database.AIBridgeToolUsage) codersdk.AIBridgeToolUsage {
return codersdk.AIBridgeToolUsage{
ID: usage.ID,
InterceptionID: usage.InterceptionID,
ProviderResponseID: usage.ProviderResponseID,
ServerURL: usage.ServerUrl.String,
Tool: usage.Tool,
Input: usage.Input,
Injected: usage.Injected,
InvocationError: usage.InvocationError.String,
Metadata: jsonOrEmptyMap(usage.Metadata),
CreatedAt: usage.CreatedAt,
}
}
// AIBridgeSessionThreads converts session metadata and thread interceptions
// into the threads response. It groups interceptions into threads, builds
// agentic actions from tool usages and model thoughts, and aggregates
-232
View File
@@ -599,238 +599,6 @@ func TestChatDebugRunDetail_NullableFieldsNil(t *testing.T) {
require.Empty(t, sdk.Steps)
}
func TestAIBridgeInterception(t *testing.T) {
t.Parallel()
now := dbtime.Now()
interceptionID := uuid.New()
initiatorID := uuid.New()
cases := []struct {
name string
interception database.AIBridgeInterception
initiator database.VisibleUser
tokenUsages []database.AIBridgeTokenUsage
userPrompts []database.AIBridgeUserPrompt
toolUsages []database.AIBridgeToolUsage
expected codersdk.AIBridgeInterception
}{
{
name: "all_optional_values_set",
interception: database.AIBridgeInterception{
ID: interceptionID,
InitiatorID: initiatorID,
Provider: "anthropic",
Model: "claude-3-opus",
StartedAt: now,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"key":"value"}`),
Valid: true,
},
EndedAt: sql.NullTime{
Time: now.Add(time.Minute),
Valid: true,
},
APIKeyID: sql.NullString{
String: "api-key-123",
Valid: true,
},
Client: sql.NullString{
String: "claude-code/1.0.0",
Valid: true,
},
},
initiator: database.VisibleUser{
ID: initiatorID,
Username: "testuser",
Name: "Test User",
AvatarURL: "https://example.com/avatar.png",
},
tokenUsages: []database.AIBridgeTokenUsage{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
InputTokens: 100,
OutputTokens: 200,
CacheReadInputTokens: 50,
CacheWriteInputTokens: 10,
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"cache":"hit"}`),
Valid: true,
},
CreatedAt: now.Add(10 * time.Second),
},
},
userPrompts: []database.AIBridgeUserPrompt{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
Prompt: "Hello, world!",
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"role":"user"}`),
Valid: true,
},
CreatedAt: now.Add(5 * time.Second),
},
},
toolUsages: []database.AIBridgeToolUsage{
{
ID: uuid.New(),
InterceptionID: interceptionID,
ProviderResponseID: "resp-123",
ServerUrl: sql.NullString{
String: "https://mcp.example.com",
Valid: true,
},
Tool: "read_file",
Input: `{"path":"/tmp/test.txt"}`,
Injected: true,
InvocationError: sql.NullString{
String: "file not found",
Valid: true,
},
Metadata: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`{"duration_ms":50}`),
Valid: true,
},
CreatedAt: now.Add(15 * time.Second),
},
},
expected: codersdk.AIBridgeInterception{
ID: interceptionID,
Initiator: codersdk.MinimalUser{
ID: initiatorID,
Username: "testuser",
Name: "Test User",
AvatarURL: "https://example.com/avatar.png",
},
Provider: "anthropic",
Model: "claude-3-opus",
Metadata: map[string]any{"key": "value"},
StartedAt: now,
},
},
{
name: "no_optional_values_set",
interception: database.AIBridgeInterception{
ID: interceptionID,
InitiatorID: initiatorID,
Provider: "openai",
Model: "gpt-4",
StartedAt: now,
Metadata: pqtype.NullRawMessage{Valid: false},
EndedAt: sql.NullTime{Valid: false},
APIKeyID: sql.NullString{Valid: false},
Client: sql.NullString{Valid: false},
},
initiator: database.VisibleUser{
ID: initiatorID,
Username: "minimaluser",
Name: "",
AvatarURL: "",
},
tokenUsages: nil,
userPrompts: nil,
toolUsages: nil,
expected: codersdk.AIBridgeInterception{
ID: interceptionID,
Initiator: codersdk.MinimalUser{
ID: initiatorID,
Username: "minimaluser",
Name: "",
AvatarURL: "",
},
Provider: "openai",
Model: "gpt-4",
Metadata: nil,
StartedAt: now,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := db2sdk.AIBridgeInterception(
tc.interception,
tc.initiator,
tc.tokenUsages,
tc.userPrompts,
tc.toolUsages,
)
// Check basic fields.
require.Equal(t, tc.expected.ID, result.ID)
require.Equal(t, tc.expected.Initiator, result.Initiator)
require.Equal(t, tc.expected.Provider, result.Provider)
require.Equal(t, tc.expected.Model, result.Model)
require.Equal(t, tc.expected.StartedAt.UTC(), result.StartedAt.UTC())
require.Equal(t, tc.expected.Metadata, result.Metadata)
// Check optional pointer fields.
if tc.interception.APIKeyID.Valid {
require.NotNil(t, result.APIKeyID)
require.Equal(t, tc.interception.APIKeyID.String, *result.APIKeyID)
} else {
require.Nil(t, result.APIKeyID)
}
if tc.interception.EndedAt.Valid {
require.NotNil(t, result.EndedAt)
require.Equal(t, tc.interception.EndedAt.Time.UTC(), result.EndedAt.UTC())
} else {
require.Nil(t, result.EndedAt)
}
if tc.interception.Client.Valid {
require.NotNil(t, result.Client)
require.Equal(t, tc.interception.Client.String, *result.Client)
} else {
require.Nil(t, result.Client)
}
// Check slices.
require.Len(t, result.TokenUsages, len(tc.tokenUsages))
require.Len(t, result.UserPrompts, len(tc.userPrompts))
require.Len(t, result.ToolUsages, len(tc.toolUsages))
// Verify token usages are converted correctly.
for i, tu := range tc.tokenUsages {
require.Equal(t, tu.ID, result.TokenUsages[i].ID)
require.Equal(t, tu.InterceptionID, result.TokenUsages[i].InterceptionID)
require.Equal(t, tu.ProviderResponseID, result.TokenUsages[i].ProviderResponseID)
require.Equal(t, tu.InputTokens, result.TokenUsages[i].InputTokens)
require.Equal(t, tu.OutputTokens, result.TokenUsages[i].OutputTokens)
require.Equal(t, tu.CacheReadInputTokens, result.TokenUsages[i].CacheReadInputTokens)
require.Equal(t, tu.CacheWriteInputTokens, result.TokenUsages[i].CacheWriteInputTokens)
}
// Verify user prompts are converted correctly.
for i, up := range tc.userPrompts {
require.Equal(t, up.ID, result.UserPrompts[i].ID)
require.Equal(t, up.InterceptionID, result.UserPrompts[i].InterceptionID)
require.Equal(t, up.ProviderResponseID, result.UserPrompts[i].ProviderResponseID)
require.Equal(t, up.Prompt, result.UserPrompts[i].Prompt)
}
// Verify tool usages are converted correctly.
for i, toolUsage := range tc.toolUsages {
require.Equal(t, toolUsage.ID, result.ToolUsages[i].ID)
require.Equal(t, toolUsage.InterceptionID, result.ToolUsages[i].InterceptionID)
require.Equal(t, toolUsage.ProviderResponseID, result.ToolUsages[i].ProviderResponseID)
require.Equal(t, toolUsage.ServerUrl.String, result.ToolUsages[i].ServerURL)
require.Equal(t, toolUsage.Tool, result.ToolUsages[i].Tool)
require.Equal(t, toolUsage.Input, result.ToolUsages[i].Input)
require.Equal(t, toolUsage.Injected, result.ToolUsages[i].Injected)
require.Equal(t, toolUsage.InvocationError.String, result.ToolUsages[i].InvocationError)
}
})
}
}
func TestChatMessage_PreservesProviderExecutedOnToolResults(t *testing.T) {
t.Parallel()
-24
View File
@@ -1835,14 +1835,6 @@ func (q *querier) ClearChatMessageProviderResponseIDsByChatID(ctx context.Contex
return q.db.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID)
}
func (q *querier) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
return 0, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
return q.db.CountAuthorizedAIBridgeInterceptions(ctx, arg, prep)
}
func (q *querier) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type)
if err != nil {
@@ -6237,14 +6229,6 @@ func (q *querier) ListAIBridgeClients(ctx context.Context, arg database.ListAIBr
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 {
return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err)
}
return q.db.ListAuthorizedAIBridgeInterceptions(ctx, arg, prep)
}
func (q *querier) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg database.ListAIBridgeInterceptionsTelemetrySummariesParams) ([]database.ListAIBridgeInterceptionsTelemetrySummariesRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil {
return nil, err
@@ -8677,14 +8661,6 @@ func (q *querier) CountAuthorizedConnectionLogs(ctx context.Context, arg databas
return q.CountConnectionLogs(ctx, arg)
}
func (q *querier) ListAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.ListAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) ([]database.ListAIBridgeInterceptionsRow, error) {
return q.db.ListAuthorizedAIBridgeInterceptions(ctx, arg, prepared)
}
func (q *querier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error) {
return q.db.CountAuthorizedAIBridgeInterceptions(ctx, arg, prepared)
}
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
-28
View File
@@ -6322,34 +6322,6 @@ func (s *MethodTestSuite) TestAIBridge() {
check.Args(intID).Asserts(intc, policy.ActionRead).Returns(tools)
}))
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.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.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params, emptyPreparedAuthorized{}).Asserts()
}))
s.Run("CountAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.CountAIBridgeInterceptionsParams{}
db.EXPECT().CountAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return(int64(0), nil).AnyTimes()
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("CountAuthorizedAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.CountAIBridgeInterceptionsParams{}
db.EXPECT().CountAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return(int64(0), nil).AnyTimes()
// No asserts here because SQLFilter.
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()
-32
View File
@@ -306,14 +306,6 @@ func (m queryMetricsStore) ClearChatMessageProviderResponseIDsByChatID(ctx conte
return r0
}
func (m queryMetricsStore) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountAIBridgeInterceptions(ctx, arg)
m.queryLatencies.WithLabelValues("CountAIBridgeInterceptions").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountAIBridgeInterceptions").Inc()
return r0, r1
}
func (m queryMetricsStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountAIBridgeSessions(ctx, arg)
@@ -4386,14 +4378,6 @@ func (m queryMetricsStore) ListAIBridgeClients(ctx context.Context, arg database
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)
m.queryLatencies.WithLabelValues("ListAIBridgeInterceptions").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeInterceptions").Inc()
return r0, r1
}
func (m queryMetricsStore) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg database.ListAIBridgeInterceptionsTelemetrySummariesParams) ([]database.ListAIBridgeInterceptionsTelemetrySummariesRow, error) {
start := time.Now()
r0, r1 := m.s.ListAIBridgeInterceptionsTelemetrySummaries(ctx, arg)
@@ -6290,22 +6274,6 @@ 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.ListAIBridgeInterceptionsRow, error) {
start := time.Now()
r0, r1 := m.s.ListAuthorizedAIBridgeInterceptions(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("ListAuthorizedAIBridgeInterceptions").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAuthorizedAIBridgeInterceptions").Inc()
return r0, r1
}
func (m queryMetricsStore) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountAuthorizedAIBridgeInterceptions(ctx, arg, prepared)
m.queryLatencies.WithLabelValues("CountAuthorizedAIBridgeInterceptions").Observe(time.Since(start).Seconds())
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)
-60
View File
@@ -409,21 +409,6 @@ func (mr *MockStoreMockRecorder) ClearChatMessageProviderResponseIDsByChatID(ctx
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearChatMessageProviderResponseIDsByChatID", reflect.TypeOf((*MockStore)(nil).ClearChatMessageProviderResponseIDsByChatID), ctx, chatID)
}
// CountAIBridgeInterceptions mocks base method.
func (m *MockStore) CountAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAIBridgeInterceptions", ctx, arg)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAIBridgeInterceptions indicates an expected call of CountAIBridgeInterceptions.
func (mr *MockStoreMockRecorder) CountAIBridgeInterceptions(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).CountAIBridgeInterceptions), ctx, arg)
}
// CountAIBridgeSessions mocks base method.
func (m *MockStore) CountAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams) (int64, error) {
m.ctrl.T.Helper()
@@ -454,21 +439,6 @@ func (mr *MockStoreMockRecorder) CountAuditLogs(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAuditLogs", reflect.TypeOf((*MockStore)(nil).CountAuditLogs), ctx, arg)
}
// CountAuthorizedAIBridgeInterceptions mocks base method.
func (m *MockStore) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg database.CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAuthorizedAIBridgeInterceptions", ctx, arg, prepared)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAuthorizedAIBridgeInterceptions indicates an expected call of CountAuthorizedAIBridgeInterceptions.
func (mr *MockStoreMockRecorder) CountAuthorizedAIBridgeInterceptions(ctx, arg, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAuthorizedAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).CountAuthorizedAIBridgeInterceptions), ctx, arg, prepared)
}
// CountAuthorizedAIBridgeSessions mocks base method.
func (m *MockStore) CountAuthorizedAIBridgeSessions(ctx context.Context, arg database.CountAIBridgeSessionsParams, prepared rbac.PreparedAuthorized) (int64, error) {
m.ctrl.T.Helper()
@@ -8220,21 +8190,6 @@ func (mr *MockStoreMockRecorder) ListAIBridgeClients(ctx, arg any) *gomock.Call
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()
ret := m.ctrl.Call(m, "ListAIBridgeInterceptions", ctx, arg)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAIBridgeInterceptions indicates an expected call of ListAIBridgeInterceptions.
func (mr *MockStoreMockRecorder) ListAIBridgeInterceptions(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).ListAIBridgeInterceptions), ctx, arg)
}
// ListAIBridgeInterceptionsTelemetrySummaries mocks base method.
func (m *MockStore) ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg database.ListAIBridgeInterceptionsTelemetrySummariesParams) ([]database.ListAIBridgeInterceptionsTelemetrySummariesRow, error) {
m.ctrl.T.Helper()
@@ -8385,21 +8340,6 @@ func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeClients(ctx, arg, prepare
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()
ret := m.ctrl.Call(m, "ListAuthorizedAIBridgeInterceptions", ctx, arg, prepared)
ret0, _ := ret[0].([]database.ListAIBridgeInterceptionsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListAuthorizedAIBridgeInterceptions indicates an expected call of ListAuthorizedAIBridgeInterceptions.
func (mr *MockStoreMockRecorder) ListAuthorizedAIBridgeInterceptions(ctx, arg, prepared any) *gomock.Call {
mr.mock.ctrl.T.Helper()
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()
-110
View File
@@ -909,8 +909,6 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID
}
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)
@@ -918,114 +916,6 @@ type aibridgeQuerier interface {
ListAuthorizedAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams, prepared rbac.PreparedAuthorized) ([]ListAIBridgeSessionThreadsRow, 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(),
})
if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(listAIBridgeInterceptions, fmt.Sprintf(" AND %s", authorizedFilter))
if err != nil {
return nil, xerrors.Errorf("insert authorized filter: %w", err)
}
query := fmt.Sprintf("-- name: ListAuthorizedAIBridgeInterceptions :many\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.StartedAfter,
arg.StartedBefore,
arg.InitiatorID,
arg.Provider,
arg.ProviderName,
arg.Model,
arg.Client,
arg.AfterID,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAIBridgeInterceptionsRow
for rows.Next() {
var i ListAIBridgeInterceptionsRow
if err := rows.Scan(
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.AIBridgeInterception.EndedAt,
&i.AIBridgeInterception.APIKeyID,
&i.AIBridgeInterception.Client,
&i.AIBridgeInterception.ThreadParentID,
&i.AIBridgeInterception.ThreadRootID,
&i.AIBridgeInterception.ClientSessionID,
&i.AIBridgeInterception.SessionID,
&i.AIBridgeInterception.ProviderName,
&i.AIBridgeInterception.CredentialKind,
&i.AIBridgeInterception.CredentialHint,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
&i.VisibleUser.AvatarURL,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (q *sqlQuerier) CountAuthorizedAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams, prepared rbac.PreparedAuthorized) (int64, error) {
authorizedFilter, err := prepared.CompileToSQL(ctx, regosql.ConvertConfig{
VariableConverter: regosql.AIBridgeInterceptionConverter(),
})
if err != nil {
return 0, xerrors.Errorf("compile authorized filter: %w", err)
}
filtered, err := insertAuthorizedFilter(countAIBridgeInterceptions, fmt.Sprintf(" AND %s", authorizedFilter))
if err != nil {
return 0, xerrors.Errorf("insert authorized filter: %w", err)
}
query := fmt.Sprintf("-- name: CountAuthorizedAIBridgeInterceptions :one\n%s", filtered)
rows, err := q.db.QueryContext(ctx, query,
arg.StartedAfter,
arg.StartedBefore,
arg.InitiatorID,
arg.Provider,
arg.ProviderName,
arg.Model,
arg.Client,
)
if err != nil {
return 0, err
}
defer rows.Close()
var count int64
for rows.Next() {
if err := rows.Scan(&count); err != nil {
return 0, err
}
}
if err := rows.Close(); err != nil {
return 0, err
}
if err := rows.Err(); err != nil {
return 0, err
}
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(),
-2
View File
@@ -92,7 +92,6 @@ type sqlcQuerier interface {
CleanTailnetTunnels(ctx context.Context) error
CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error
ClearChatMessageProviderResponseIDsByChatID(ctx context.Context, chatID uuid.UUID) error
CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error)
CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error)
CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error)
CountConnectionLogs(ctx context.Context, arg CountConnectionLogsParams) (int64, error)
@@ -1052,7 +1051,6 @@ type sqlcQuerier interface {
// new links.
LinkChatFiles(ctx context.Context, arg LinkChatFilesParams) (int32, 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.
ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg ListAIBridgeInterceptionsTelemetrySummariesParams) ([]ListAIBridgeInterceptionsTelemetrySummariesRow, error)
-214
View File
@@ -974,77 +974,6 @@ func (q *sqlQuerier) CalculateAIBridgeInterceptionsTelemetrySummary(ctx context.
return i, err
}
const countAIBridgeInterceptions = `-- name: CountAIBridgeInterceptions :one
SELECT
COUNT(*)
FROM
aibridge_interceptions
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
aibridge_interceptions.ended_at IS NOT NULL
-- Filter by time frame
AND CASE
WHEN $1::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= $1::timestamptz
ELSE true
END
AND CASE
WHEN $2::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= $2::timestamptz
ELSE true
END
-- Filter initiator_id
AND CASE
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = $3::uuid
ELSE true
END
-- Filter provider
AND CASE
WHEN $4::text != '' THEN aibridge_interceptions.provider = $4::text
ELSE true
END
-- Filter provider_name
AND CASE
WHEN $5::text != '' THEN aibridge_interceptions.provider_name = $5::text
ELSE true
END
-- Filter model
AND CASE
WHEN $6::text != '' THEN aibridge_interceptions.model = $6::text
ELSE true
END
-- Filter client
AND CASE
WHEN $7::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $7::text
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
-- @authorize_filter
`
type CountAIBridgeInterceptionsParams struct {
StartedAfter time.Time `db:"started_after" json:"started_after"`
StartedBefore time.Time `db:"started_before" json:"started_before"`
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
Provider string `db:"provider" json:"provider"`
ProviderName string `db:"provider_name" json:"provider_name"`
Model string `db:"model" json:"model"`
Client string `db:"client" json:"client"`
}
func (q *sqlQuerier) CountAIBridgeInterceptions(ctx context.Context, arg CountAIBridgeInterceptionsParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countAIBridgeInterceptions,
arg.StartedAfter,
arg.StartedBefore,
arg.InitiatorID,
arg.Provider,
arg.ProviderName,
arg.Model,
arg.Client,
)
var count int64
err := row.Scan(&count)
return count, err
}
const countAIBridgeSessions = `-- name: CountAIBridgeSessions :one
SELECT
COUNT(DISTINCT (aibridge_interceptions.session_id, aibridge_interceptions.initiator_id))
@@ -1704,149 +1633,6 @@ func (q *sqlQuerier) ListAIBridgeClients(ctx context.Context, arg ListAIBridgeCl
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, aibridge_interceptions.provider_name, aibridge_interceptions.credential_kind, aibridge_interceptions.credential_hint,
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
-- Remove inflight interceptions (ones which lack an ended_at value).
aibridge_interceptions.ended_at IS NOT NULL
-- Filter by time frame
AND CASE
WHEN $1::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= $1::timestamptz
ELSE true
END
AND CASE
WHEN $2::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= $2::timestamptz
ELSE true
END
-- Filter initiator_id
AND CASE
WHEN $3::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = $3::uuid
ELSE true
END
-- Filter provider
AND CASE
WHEN $4::text != '' THEN aibridge_interceptions.provider = $4::text
ELSE true
END
-- Filter provider_name
AND CASE
WHEN $5::text != '' THEN aibridge_interceptions.provider_name = $5::text
ELSE true
END
-- Filter model
AND CASE
WHEN $6::text != '' THEN aibridge_interceptions.model = $6::text
ELSE true
END
-- Filter client
AND CASE
WHEN $7::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = $7::text
ELSE true
END
-- Cursor pagination
AND CASE
WHEN $8::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the started_at field, so select all
-- rows before the cursor and before the after_id UUID.
-- This uses a less than operator because we're sorting DESC. The
-- "after_id" terminology comes from our pagination parser in
-- coderd.
(aibridge_interceptions.started_at, aibridge_interceptions.id) < (
(SELECT started_at FROM aibridge_interceptions WHERE id = $8),
$8::uuid
)
)
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
-- @authorize_filter
ORDER BY
aibridge_interceptions.started_at DESC,
aibridge_interceptions.id DESC
LIMIT COALESCE(NULLIF($10::integer, 0), 100)
OFFSET $9
`
type ListAIBridgeInterceptionsParams struct {
StartedAfter time.Time `db:"started_after" json:"started_after"`
StartedBefore time.Time `db:"started_before" json:"started_before"`
InitiatorID uuid.UUID `db:"initiator_id" json:"initiator_id"`
Provider string `db:"provider" json:"provider"`
ProviderName string `db:"provider_name" json:"provider_name"`
Model string `db:"model" json:"model"`
Client string `db:"client" json:"client"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
}
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,
arg.InitiatorID,
arg.Provider,
arg.ProviderName,
arg.Model,
arg.Client,
arg.AfterID,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAIBridgeInterceptionsRow
for rows.Next() {
var i ListAIBridgeInterceptionsRow
if err := rows.Scan(
&i.AIBridgeInterception.ID,
&i.AIBridgeInterception.InitiatorID,
&i.AIBridgeInterception.Provider,
&i.AIBridgeInterception.Model,
&i.AIBridgeInterception.StartedAt,
&i.AIBridgeInterception.Metadata,
&i.AIBridgeInterception.EndedAt,
&i.AIBridgeInterception.APIKeyID,
&i.AIBridgeInterception.Client,
&i.AIBridgeInterception.ThreadParentID,
&i.AIBridgeInterception.ThreadRootID,
&i.AIBridgeInterception.ClientSessionID,
&i.AIBridgeInterception.SessionID,
&i.AIBridgeInterception.ProviderName,
&i.AIBridgeInterception.CredentialKind,
&i.AIBridgeInterception.CredentialHint,
&i.VisibleUser.ID,
&i.VisibleUser.Username,
&i.VisibleUser.Name,
&i.VisibleUser.AvatarURL,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAIBridgeInterceptionsTelemetrySummaries = `-- name: ListAIBridgeInterceptionsTelemetrySummaries :many
SELECT
DISTINCT ON (provider, model, client)
-116
View File
@@ -113,122 +113,6 @@ ORDER BY
created_at ASC,
id ASC;
-- name: CountAIBridgeInterceptions :one
SELECT
COUNT(*)
FROM
aibridge_interceptions
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
aibridge_interceptions.ended_at IS NOT NULL
-- Filter by time frame
AND CASE
WHEN @started_after::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= @started_after::timestamptz
ELSE true
END
AND CASE
WHEN @started_before::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= @started_before::timestamptz
ELSE true
END
-- Filter initiator_id
AND CASE
WHEN @initiator_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = @initiator_id::uuid
ELSE true
END
-- Filter provider
AND CASE
WHEN @provider::text != '' THEN aibridge_interceptions.provider = @provider::text
ELSE true
END
-- Filter provider_name
AND CASE
WHEN @provider_name::text != '' THEN aibridge_interceptions.provider_name = @provider_name::text
ELSE true
END
-- Filter model
AND CASE
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
ELSE true
END
-- Filter client
AND CASE
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
-- @authorize_filter
;
-- 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
-- Remove inflight interceptions (ones which lack an ended_at value).
aibridge_interceptions.ended_at IS NOT NULL
-- Filter by time frame
AND CASE
WHEN @started_after::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at >= @started_after::timestamptz
ELSE true
END
AND CASE
WHEN @started_before::timestamptz != '0001-01-01 00:00:00+00'::timestamptz THEN aibridge_interceptions.started_at <= @started_before::timestamptz
ELSE true
END
-- Filter initiator_id
AND CASE
WHEN @initiator_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN aibridge_interceptions.initiator_id = @initiator_id::uuid
ELSE true
END
-- Filter provider
AND CASE
WHEN @provider::text != '' THEN aibridge_interceptions.provider = @provider::text
ELSE true
END
-- Filter provider_name
AND CASE
WHEN @provider_name::text != '' THEN aibridge_interceptions.provider_name = @provider_name::text
ELSE true
END
-- Filter model
AND CASE
WHEN @model::text != '' THEN aibridge_interceptions.model = @model::text
ELSE true
END
-- Filter client
AND CASE
WHEN @client::text != '' THEN COALESCE(aibridge_interceptions.client, 'Unknown') = @client::text
ELSE true
END
-- Cursor pagination
AND CASE
WHEN @after_id::uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the started_at field, so select all
-- rows before the cursor and before the after_id UUID.
-- This uses a less than operator because we're sorting DESC. The
-- "after_id" terminology comes from our pagination parser in
-- coderd.
(aibridge_interceptions.started_at, aibridge_interceptions.id) < (
(SELECT started_at FROM aibridge_interceptions WHERE id = @after_id),
@after_id::uuid
)
)
ELSE true
END
-- Authorize Filter clause will be injected below in ListAuthorizedAIBridgeInterceptions
-- @authorize_filter
ORDER BY
aibridge_interceptions.started_at DESC,
aibridge_interceptions.id DESC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
;
-- name: ListAIBridgeTokenUsagesByInterceptionIDs :many
SELECT
*
-44
View File
@@ -362,50 +362,6 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query
return filter, parser.Errors
}
func AIBridgeInterceptions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID) (database.ListAIBridgeInterceptionsParams, []codersdk.ValidationError) {
// nolint:exhaustruct // Empty values just means "don't filter by that field".
filter := database.ListAIBridgeInterceptionsParams{
AfterID: page.AfterID,
// #nosec G115 - Safe conversion for pagination limit which is expected to be within int32 range
Limit: int32(page.Limit),
// #nosec G115 - Safe conversion for pagination offset which is expected to be within int32 range
Offset: int32(page.Offset),
}
if query == "" {
return filter, nil
}
values, errors := searchTerms(query, func(term string, values url.Values) error {
// Default to the initiating user
values.Add("initiator", term)
return nil
})
if len(errors) > 0 {
return filter, errors
}
parser := httpapi.NewQueryParamParser()
filter.InitiatorID = parseUser(ctx, db, parser, values, "initiator", actorID)
filter.Provider = parser.String(values, "", "provider")
filter.ProviderName = parseAIProviderName(ctx, db, parser, values)
filter.Model = parser.String(values, "", "model")
filter.Client = parser.String(values, "", "client")
// Time must be between started_after and started_before.
filter.StartedAfter = parser.Time3339Nano(values, time.Time{}, "started_after")
filter.StartedBefore = parser.Time3339Nano(values, time.Time{}, "started_before")
if !filter.StartedBefore.IsZero() && !filter.StartedAfter.IsZero() && !filter.StartedBefore.After(filter.StartedAfter) {
parser.Errors = append(parser.Errors, codersdk.ValidationError{
Field: "started_before",
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
})
}
parser.ErrorExcessParams(values)
return filter, parser.Errors
}
func AIBridgeSessions(ctx context.Context, db database.Store, query string, page codersdk.Pagination, actorID uuid.UUID, afterSessionID string) (database.ListAIBridgeSessionsParams, []codersdk.ValidationError) {
// nolint:exhaustruct // Empty values just means "don't filter by that field".
filter := database.ListAIBridgeSessionsParams{
-137
View File
@@ -12,61 +12,6 @@ import (
"golang.org/x/xerrors"
)
type AIBridgeInterception struct {
ID uuid.UUID `json:"id" format:"uuid"`
APIKeyID *string `json:"api_key_id"`
Initiator MinimalUser `json:"initiator"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
Model string `json:"model"`
Client *string `json:"client"`
Metadata map[string]any `json:"metadata"`
StartedAt time.Time `json:"started_at" format:"date-time"`
EndedAt *time.Time `json:"ended_at" format:"date-time"`
TokenUsages []AIBridgeTokenUsage `json:"token_usages"`
UserPrompts []AIBridgeUserPrompt `json:"user_prompts"`
ToolUsages []AIBridgeToolUsage `json:"tool_usages"`
}
type AIBridgeTokenUsage struct {
ID uuid.UUID `json:"id" format:"uuid"`
InterceptionID uuid.UUID `json:"interception_id" format:"uuid"`
ProviderResponseID string `json:"provider_response_id"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheWriteInputTokens int64 `json:"cache_write_input_tokens"`
Metadata map[string]any `json:"metadata"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
}
type AIBridgeUserPrompt struct {
ID uuid.UUID `json:"id" format:"uuid"`
InterceptionID uuid.UUID `json:"interception_id" format:"uuid"`
ProviderResponseID string `json:"provider_response_id"`
Prompt string `json:"prompt"`
Metadata map[string]any `json:"metadata"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
}
type AIBridgeToolUsage struct {
ID uuid.UUID `json:"id" format:"uuid"`
InterceptionID uuid.UUID `json:"interception_id" format:"uuid"`
ProviderResponseID string `json:"provider_response_id"`
ServerURL string `json:"server_url"`
Tool string `json:"tool"`
Input string `json:"input"`
Injected bool `json:"injected"`
InvocationError string `json:"invocation_error"`
Metadata map[string]any `json:"metadata"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
}
type AIBridgeListInterceptionsResponse struct {
Count int64 `json:"count"`
Results []AIBridgeInterception `json:"results"`
}
type AIBridgeSession struct {
ID string `json:"id"`
Initiator MinimalUser `json:"initiator"`
@@ -194,70 +139,6 @@ type AIBridgeListSessionsFilter struct {
FilterQuery string `json:"q,omitempty"`
}
// @typescript-ignore AIBridgeListInterceptionsFilter
type AIBridgeListInterceptionsFilter struct {
// Limit defaults to 100, max is 1000.
// Offset based pagination is not supported for AI Bridge interceptions. Use
// cursor pagination instead with after_id.
Pagination Pagination `json:"pagination,omitempty"`
// Initiator is a user ID, username, or "me".
Initiator string `json:"initiator,omitempty"`
StartedBefore time.Time `json:"started_before,omitempty" format:"date-time"`
StartedAfter time.Time `json:"started_after,omitempty" format:"date-time"`
// Provider matches the runtime provider type column (openai,
// anthropic, copilot). The runtime type collapses the configured
// ai_provider_type: azure, google, openai-compat, openrouter, and
// vercel route through openai; bedrock routes through anthropic.
// Retained for backward compatibility; new clients should prefer
// ProviderName, which scopes to a specific configured row.
Provider string `json:"provider,omitempty"`
ProviderName string `json:"provider_name,omitempty"`
Model string `json:"model,omitempty"`
Client string `json:"client,omitempty"`
FilterQuery string `json:"q,omitempty"`
}
// asRequestOption returns a function that can be used in (*Client).Request.
// It modifies the request query parameters.
func (f AIBridgeListInterceptionsFilter) asRequestOption() RequestOption {
return func(r *http.Request) {
var params []string
// Make sure all user input is quoted to ensure it's parsed as a single
// string.
if f.Initiator != "" {
params = append(params, fmt.Sprintf("initiator:%q", f.Initiator))
}
if !f.StartedBefore.IsZero() {
params = append(params, fmt.Sprintf("started_before:%q", f.StartedBefore.Format(time.RFC3339Nano)))
}
if !f.StartedAfter.IsZero() {
params = append(params, fmt.Sprintf("started_after:%q", f.StartedAfter.Format(time.RFC3339Nano)))
}
if f.Provider != "" {
params = append(params, fmt.Sprintf("provider:%q", f.Provider))
}
if f.ProviderName != "" {
params = append(params, fmt.Sprintf("provider_name:%q", f.ProviderName))
}
if f.Model != "" {
params = append(params, fmt.Sprintf("model:%q", f.Model))
}
if f.Client != "" {
params = append(params, fmt.Sprintf("client:%q", f.Client))
}
if f.FilterQuery != "" {
// If custom stuff is added, just add it on here.
params = append(params, f.FilterQuery)
}
q := r.URL.Query()
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
}
}
// asRequestOption returns a function that can be used in (*Client).Request.
func (f AIBridgeListSessionsFilter) asRequestOption() RequestOption {
return func(r *http.Request) {
@@ -299,24 +180,6 @@ func (f AIBridgeListSessionsFilter) asRequestOption() RequestOption {
}
}
// AIBridgeListInterceptions returns AI Bridge interceptions with the given
// filter.
//
// Deprecated: Use AIBridgeListSessions instead, which provides richer
// session-level aggregation including threads and agentic actions.
func (c *Client) AIBridgeListInterceptions(ctx context.Context, filter AIBridgeListInterceptionsFilter) (AIBridgeListInterceptionsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/interceptions", nil, filter.asRequestOption(), filter.Pagination.asRequestOption(), filter.Pagination.asRequestOption())
if err != nil {
return AIBridgeListInterceptionsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AIBridgeListInterceptionsResponse{}, ReadBodyAsError(res)
}
var resp AIBridgeListInterceptionsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// AIBridgeListSessions returns AI Bridge sessions with the given filter.
func (c *Client) AIBridgeListSessions(ctx context.Context, filter AIBridgeListSessionsFilter) (AIBridgeListSessionsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/sessions", nil, filter.asRequestOption(), filter.Pagination.asRequestOption())
+2 -21
View File
@@ -101,30 +101,11 @@ Available query filters:
- `initiator` - Filter by user ID or username
- `provider` - Filter by AI provider (e.g., `openai`, `anthropic`)
- `model` - Filter by model name
- `started_after` - Filter interceptions after a timestamp
- `started_before` - Filter interceptions before a timestamp
- `started_after` - Filter sessions after a timestamp
- `started_before` - Filter sessions before a timestamp
See the [API documentation](../../reference/api/aibridge.md) for full details.
### CLI
Export interceptions as JSON using the CLI:
```sh
coder aibridge interceptions list --initiator me --limit 1000
```
You can filter by time range, provider, model, and user:
```sh
coder aibridge interceptions list \
--started-after "2025-01-01T00:00:00Z" \
--started-before "2025-02-01T00:00:00Z" \
--provider anthropic
```
See `coder aibridge interceptions list --help` for all options.
## Data Retention
AI Gateway data is retained for **60 days by default**. Configure the retention
-15
View File
@@ -1635,21 +1635,6 @@
"path": "./reference/cli/index.md",
"icon_path": "./images/icons/terminal.svg",
"children": [
{
"title": "aibridge",
"description": "Manage AI Bridge.",
"path": "reference/cli/aibridge.md"
},
{
"title": "aibridge interceptions",
"description": "Manage AI Bridge interceptions.",
"path": "reference/cli/aibridge_interceptions.md"
},
{
"title": "aibridge interceptions list",
"description": "List AI Bridge interceptions as JSON.",
"path": "reference/cli/aibridge_interceptions_list.md"
},
{
"title": "autoupdate",
"description": "Toggle auto-update policy for a workspace",
-108
View File
@@ -33,114 +33,6 @@ curl -X GET http://coder-server:8080/api/v2/aibridge/clients \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## List AI Bridge interceptions
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/aibridge/interceptions \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /api/v2/aibridge/interceptions`
### Parameters
| Name | In | Type | Required | Description |
|------------|-------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------|
| `q` | query | string | false | Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, started_after, started_before. |
| `limit` | query | integer | false | Page limit |
| `after_id` | query | string | false | Cursor pagination after ID (cannot be used with offset) |
| `offset` | query | integer | false | Offset pagination (cannot be used with after_id) |
### Example responses
> 200 Response
```json
{
"count": 0,
"results": [
{
"api_key_id": "string",
"client": "string",
"ended_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"metadata": {
"property1": null,
"property2": null
},
"model": "string",
"provider": "string",
"provider_name": "string",
"started_at": "2019-08-24T14:15:22Z",
"token_usages": [
{
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0,
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"input_tokens": 0,
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"output_tokens": 0,
"provider_response_id": "string"
}
],
"tool_usages": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"injected": true,
"input": "string",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"invocation_error": "string",
"metadata": {
"property1": null,
"property2": null
},
"provider_response_id": "string",
"server_url": "string",
"tool": "string"
}
],
"user_prompts": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"prompt": "string",
"provider_response_id": "string"
}
]
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------|
| 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
-271
View File
@@ -456,179 +456,6 @@
| `send_actor_headers` | boolean | false | | |
| `structured_logging` | boolean | false | | |
## codersdk.AIBridgeInterception
```json
{
"api_key_id": "string",
"client": "string",
"ended_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"metadata": {
"property1": null,
"property2": null
},
"model": "string",
"provider": "string",
"provider_name": "string",
"started_at": "2019-08-24T14:15:22Z",
"token_usages": [
{
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0,
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"input_tokens": 0,
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"output_tokens": 0,
"provider_response_id": "string"
}
],
"tool_usages": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"injected": true,
"input": "string",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"invocation_error": "string",
"metadata": {
"property1": null,
"property2": null
},
"provider_response_id": "string",
"server_url": "string",
"tool": "string"
}
],
"user_prompts": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"prompt": "string",
"provider_response_id": "string"
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------------|---------------------------------------------------------------------|----------|--------------|-------------|
| `api_key_id` | string | false | | |
| `client` | string | false | | |
| `ended_at` | string | false | | |
| `id` | string | false | | |
| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | |
| `metadata` | object | false | | |
| » `[any property]` | any | false | | |
| `model` | string | false | | |
| `provider` | string | false | | |
| `provider_name` | string | false | | |
| `started_at` | string | false | | |
| `token_usages` | array of [codersdk.AIBridgeTokenUsage](#codersdkaibridgetokenusage) | false | | |
| `tool_usages` | array of [codersdk.AIBridgeToolUsage](#codersdkaibridgetoolusage) | false | | |
| `user_prompts` | array of [codersdk.AIBridgeUserPrompt](#codersdkaibridgeuserprompt) | false | | |
## codersdk.AIBridgeListInterceptionsResponse
```json
{
"count": 0,
"results": [
{
"api_key_id": "string",
"client": "string",
"ended_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator": {
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"username": "string"
},
"metadata": {
"property1": null,
"property2": null
},
"model": "string",
"provider": "string",
"provider_name": "string",
"started_at": "2019-08-24T14:15:22Z",
"token_usages": [
{
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0,
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"input_tokens": 0,
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"output_tokens": 0,
"provider_response_id": "string"
}
],
"tool_usages": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"injected": true,
"input": "string",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"invocation_error": "string",
"metadata": {
"property1": null,
"property2": null
},
"provider_response_id": "string",
"server_url": "string",
"tool": "string"
}
],
"user_prompts": [
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"prompt": "string",
"provider_response_id": "string"
}
]
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-----------|-------------------------------------------------------------------------|----------|--------------|-------------|
| `count` | integer | false | | |
| `results` | array of [codersdk.AIBridgeInterception](#codersdkaibridgeinterception) | false | | |
## codersdk.AIBridgeListSessionsResponse
```json
@@ -1036,40 +863,6 @@
| `started_at` | string | false | | |
| `token_usage` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | |
## codersdk.AIBridgeTokenUsage
```json
{
"cache_read_input_tokens": 0,
"cache_write_input_tokens": 0,
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"input_tokens": 0,
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"output_tokens": 0,
"provider_response_id": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------------------|---------|----------|--------------|-------------|
| `cache_read_input_tokens` | integer | false | | |
| `cache_write_input_tokens` | integer | false | | |
| `created_at` | string | false | | |
| `id` | string | false | | |
| `input_tokens` | integer | false | | |
| `interception_id` | string | false | | |
| `metadata` | object | false | | |
| » `[any property]` | any | false | | |
| `output_tokens` | integer | false | | |
| `provider_response_id` | string | false | | |
## codersdk.AIBridgeToolCall
```json
@@ -1104,70 +897,6 @@
| `server_url` | string | false | | |
| `tool` | string | false | | |
## codersdk.AIBridgeToolUsage
```json
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"injected": true,
"input": "string",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"invocation_error": "string",
"metadata": {
"property1": null,
"property2": null
},
"provider_response_id": "string",
"server_url": "string",
"tool": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------|---------|----------|--------------|-------------|
| `created_at` | string | false | | |
| `id` | string | false | | |
| `injected` | boolean | false | | |
| `input` | string | false | | |
| `interception_id` | string | false | | |
| `invocation_error` | string | false | | |
| `metadata` | object | false | | |
| » `[any property]` | any | false | | |
| `provider_response_id` | string | false | | |
| `server_url` | string | false | | |
| `tool` | string | false | | |
## codersdk.AIBridgeUserPrompt
```json
{
"created_at": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"interception_id": "34d9b688-63ad-46f4-88b5-665c1e7f7824",
"metadata": {
"property1": null,
"property2": null
},
"prompt": "string",
"provider_response_id": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------|--------|----------|--------------|-------------|
| `created_at` | string | false | | |
| `id` | string | false | | |
| `interception_id` | string | false | | |
| `metadata` | object | false | | |
| » `[any property]` | any | false | | |
| `prompt` | string | false | | |
| `provider_response_id` | string | false | | |
## codersdk.AIConfig
```json
-16
View File
@@ -1,16 +0,0 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# aibridge
Manage AI Bridge.
## Usage
```console
coder aibridge
```
## Subcommands
| Name | Purpose |
|-----------------------------------------------------------|---------------------------------|
| [<code>interceptions</code>](./aibridge_interceptions.md) | Manage AI Bridge interceptions. |
-16
View File
@@ -1,16 +0,0 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# aibridge interceptions
Manage AI Bridge interceptions.
## Usage
```console
coder aibridge interceptions
```
## Subcommands
| Name | Purpose |
|-------------------------------------------------------|---------------------------------------|
| [<code>list</code>](./aibridge_interceptions_list.md) | List AI Bridge interceptions as JSON. |
-85
View File
@@ -1,85 +0,0 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# aibridge interceptions list
List AI Bridge interceptions as JSON.
## Usage
```console
coder aibridge interceptions list [flags]
```
## Options
### --initiator
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions initiated by this user. Accepts a user ID, username, or "me".
### --started-before
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions started before this time. Must be after 'started-after' if set. Accepts a time in the RFC 3339 format, e.g. "2006-01-02T15:04:05Z07:00".
### --started-after
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions started after this time. Must be before 'started-before' if set. Accepts a time in the RFC 3339 format, e.g. "2006-01-02T15:04:05Z07:00".
### --provider
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions from this provider.
### --provider-name
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions from the named provider.
### --model
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions from this model.
### --client
| | |
|------|---------------------|
| Type | <code>string</code> |
Only return interceptions from this client.
### --after-id
| | |
|------|---------------------|
| Type | <code>string</code> |
The ID of the last result on the previous page to use as a pagination cursor.
### --limit
| | |
|---------|------------------|
| Type | <code>int</code> |
| Default | <code>100</code> |
The limit of results to return. Must be between 1 and 1000.
-1
View File
@@ -72,7 +72,6 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>prebuilds</code>](./prebuilds.md) | Manage Coder prebuilds |
| [<code>external-workspaces</code>](./external-workspaces.md) | Create or manage external workspaces |
| [<code>aibridge</code>](./aibridge.md) | Manage AI Bridge. |
## Options
-181
View File
@@ -1,181 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
const maxInterceptionsLimit = 1000
func (r *RootCmd) aibridge() *serpent.Command {
cmd := &serpent.Command{
Use: "aibridge",
Short: "Manage AI Bridge.",
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.aibridgeInterceptions(),
},
}
return cmd
}
func (r *RootCmd) aibridgeInterceptions() *serpent.Command {
cmd := &serpent.Command{
Use: "interceptions",
Short: "Manage AI Bridge interceptions.",
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.aibridgeInterceptionsList(),
},
}
return cmd
}
func (r *RootCmd) aibridgeInterceptionsList() *serpent.Command {
var (
initiator string
startedBeforeRaw string
startedAfterRaw string
provider string
providerName string
model string
client string
afterIDRaw string
limit int64
)
return &serpent.Command{
Use: "list",
Short: "List AI Bridge interceptions as JSON.",
Options: serpent.OptionSet{
{
Flag: "initiator",
Description: `Only return interceptions initiated by this user. Accepts a user ID, username, or "me".`,
Default: "",
Value: serpent.StringOf(&initiator),
},
{
Flag: "started-before",
Description: fmt.Sprintf("Only return interceptions started before this time. Must be after 'started-after' if set. Accepts a time in the RFC 3339 format, e.g. %q.", time.RFC3339),
Default: "",
Value: serpent.StringOf(&startedBeforeRaw),
},
{
Flag: "started-after",
Description: fmt.Sprintf("Only return interceptions started after this time. Must be before 'started-before' if set. Accepts a time in the RFC 3339 format, e.g. %q.", time.RFC3339),
Default: "",
Value: serpent.StringOf(&startedAfterRaw),
},
{
Flag: "provider",
Description: `Only return interceptions from this provider.`,
Default: "",
Value: serpent.StringOf(&provider),
},
{
Flag: "provider-name",
Description: `Only return interceptions from the named provider.`,
Default: "",
Value: serpent.StringOf(&providerName),
},
{
Flag: "model",
Description: `Only return interceptions from this model.`,
Default: "",
Value: serpent.StringOf(&model),
},
{
Flag: "client",
Description: `Only return interceptions from this client.`,
Default: "",
Value: serpent.StringOf(&client),
},
{
Flag: "after-id",
Description: "The ID of the last result on the previous page to use as a pagination cursor.",
Default: "",
Value: serpent.StringOf(&afterIDRaw),
},
{
Flag: "limit",
Description: fmt.Sprintf(`The limit of results to return. Must be between 1 and %d.`, maxInterceptionsLimit),
Default: "100",
Value: serpent.Int64Of(&limit),
},
},
Handler: func(inv *serpent.Invocation) error {
serpetClient, err := r.InitClient(inv)
if err != nil {
return err
}
startedBefore := time.Time{}
if startedBeforeRaw != "" {
startedBefore, err = time.Parse(time.RFC3339, startedBeforeRaw)
if err != nil {
return xerrors.Errorf("parse started before filter value %q: %w", startedBeforeRaw, err)
}
}
startedAfter := time.Time{}
if startedAfterRaw != "" {
startedAfter, err = time.Parse(time.RFC3339, startedAfterRaw)
if err != nil {
return xerrors.Errorf("parse started after filter value %q: %w", startedAfterRaw, err)
}
}
afterID := uuid.Nil
if afterIDRaw != "" {
afterID, err = uuid.Parse(afterIDRaw)
if err != nil {
return xerrors.Errorf("parse after_id filter value %q: %w", afterIDRaw, err)
}
}
if limit < 1 || limit > maxInterceptionsLimit {
return xerrors.Errorf("limit value must be between 1 and %d", maxInterceptionsLimit)
}
resp, err := serpetClient.AIBridgeListInterceptions(inv.Context(), codersdk.AIBridgeListInterceptionsFilter{
Pagination: codersdk.Pagination{
AfterID: afterID,
// #nosec G115 - Checked above.
Limit: int(limit),
},
Client: client,
Initiator: initiator,
StartedBefore: startedBefore,
StartedAfter: startedAfter,
Provider: provider,
ProviderName: providerName,
Model: model,
})
if err != nil {
return xerrors.Errorf("list interceptions: %w", err)
}
// We currently only support JSON output, so we don't use a
// formatter.
enc := json.NewEncoder(inv.Stdout)
enc.SetIndent("", " ")
err = enc.Encode(resp.Results)
if err != nil {
return err
}
return err
},
}
}
-274
View File
@@ -1,274 +0,0 @@
package cli_test
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
func TestAIBridgeListInterceptions(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
_, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
now := dbtime.Now()
interception1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: member.ID,
StartedAt: now.Add(-time.Hour),
}, &now)
interception2EndedAt := now.Add(time.Minute)
interception2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: member.ID,
StartedAt: now,
}, &interception2EndedAt)
interception3EndedAt := now.Add(-time.Hour)
interception3 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: owner.UserID,
StartedAt: now.Add(-2 * time.Hour),
}, &interception3EndedAt)
args := []string{
"aibridge",
"interceptions",
"list",
}
inv, root := newCLI(t, args...)
//nolint:gocritic // Owner can read all interceptions.
clitest.SetupConfig(t, ownerClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// Owner sees all interceptions. Ordered by started_at DESC.
requireHasInterceptions(t, out.Bytes(), []uuid.UUID{interception2.ID, interception1.ID, interception3.ID})
})
t.Run("Filter", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
_, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
now := dbtime.Now()
// This interception should be returned since it matches all filters.
goodInterceptionEndedAt := now.Add(time.Minute)
goodInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: member.ID,
Provider: "real-provider",
Model: "real-model",
StartedAt: now,
}, &goodInterceptionEndedAt)
// These interceptions should not be returned since they don't match the
// filters.
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: owner.UserID,
Provider: goodInterception.Provider,
Model: goodInterception.Model,
StartedAt: goodInterception.StartedAt,
}, nil)
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: goodInterception.InitiatorID,
Provider: "bad-provider",
Model: goodInterception.Model,
StartedAt: goodInterception.StartedAt,
}, nil)
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: goodInterception.InitiatorID,
Provider: goodInterception.Provider,
Model: "bad-model",
StartedAt: goodInterception.StartedAt,
}, nil)
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: goodInterception.InitiatorID,
Provider: goodInterception.Provider,
Model: goodInterception.Model,
// Violates the started after filter.
StartedAt: now.Add(-2 * time.Hour),
}, nil)
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: goodInterception.InitiatorID,
Provider: goodInterception.Provider,
Model: goodInterception.Model,
// Violates the started before filter.
StartedAt: now.Add(2 * time.Hour),
}, nil)
args := []string{
"aibridge",
"interceptions",
"list",
"--started-after", now.Add(-time.Hour).Format(time.RFC3339),
"--started-before", now.Add(time.Hour).Format(time.RFC3339),
"--initiator", member.Username,
"--provider", goodInterception.Provider,
"--model", goodInterception.Model,
}
inv, root := newCLI(t, args...)
//nolint:gocritic // Owner can read all interceptions.
clitest.SetupConfig(t, ownerClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
requireHasInterceptions(t, out.Bytes(), []uuid.UUID{goodInterception.ID})
})
t.Run("FilterByMe", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
now := dbtime.Now()
// Create an interception initiated by the member.
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: member.ID,
StartedAt: now,
}, nil)
args := []string{
"aibridge",
"interceptions",
"list",
"--initiator", codersdk.Me,
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// Member cannot read their own interceptions.
requireHasInterceptions(t, out.Bytes(), []uuid.UUID{})
})
t.Run("Pagination", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
ownerClient, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
now := dbtime.Now()
firstInterceptionEndedAt := now.Add(time.Minute)
firstInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: owner.UserID,
StartedAt: now,
}, &firstInterceptionEndedAt)
returnedInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: owner.UserID,
StartedAt: now.Add(-time.Hour),
}, &now)
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: owner.UserID,
StartedAt: now.Add(-2 * time.Hour),
}, nil)
args := []string{
"aibridge",
"interceptions",
"list",
"--limit", "1",
"--after-id", firstInterception.ID.String(),
}
inv, root := newCLI(t, args...)
//nolint:gocritic // Owner can read all interceptions.
clitest.SetupConfig(t, ownerClient, root)
ctx := testutil.Context(t, testutil.WaitLong)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
// Only contains the second interception because after_id is the first
// interception, and we set a limit of 1.
requireHasInterceptions(t, out.Bytes(), []uuid.UUID{returnedInterception.ID})
})
}
func requireHasInterceptions(t *testing.T, out []byte, ids []uuid.UUID) {
t.Helper()
var results []codersdk.AIBridgeInterception
require.NoError(t, json.Unmarshal(out, &results))
require.Len(t, results, len(ids))
for i, id := range ids {
require.Equal(t, id, results[i].ID)
}
}
-1
View File
@@ -27,7 +27,6 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command {
r.prebuilds(),
r.provisionerd(),
r.externalWorkspaces(),
r.aibridge(),
}
}
-1
View File
@@ -16,7 +16,6 @@ USAGE:
SUBCOMMANDS:
agent-firewall Network isolation tool for monitoring and restricting
HTTP/HTTPS requests
aibridge Manage AI Bridge.
external-workspaces Create or manage external workspaces
features List Enterprise features
groups Manage groups
-12
View File
@@ -1,12 +0,0 @@
coder v0.0.0-devel
USAGE:
coder aibridge
Manage AI Bridge.
SUBCOMMANDS:
interceptions Manage AI Bridge interceptions.
———
Run `coder --help` for a list of global options.
@@ -1,12 +0,0 @@
coder v0.0.0-devel
USAGE:
coder aibridge interceptions
Manage AI Bridge interceptions.
SUBCOMMANDS:
list List AI Bridge interceptions as JSON.
———
Run `coder --help` for a list of global options.
@@ -1,43 +0,0 @@
coder v0.0.0-devel
USAGE:
coder aibridge interceptions list [flags]
List AI Bridge interceptions as JSON.
OPTIONS:
--after-id string
The ID of the last result on the previous page to use as a pagination
cursor.
--client string
Only return interceptions from this client.
--initiator string
Only return interceptions initiated by this user. Accepts a user ID,
username, or "me".
--limit int (default: 100)
The limit of results to return. Must be between 1 and 1000.
--model string
Only return interceptions from this model.
--provider string
Only return interceptions from this provider.
--provider-name string
Only return interceptions from the named provider.
--started-after string
Only return interceptions started after this time. Must be before
'started-before' if set. Accepts a time in the RFC 3339 format, e.g.
"====[timestamp]=====07:00".
--started-before string
Only return interceptions started before this time. Must be after
'started-after' if set. Accepts a time in the RFC 3339 format, e.g.
"====[timestamp]=====07:00".
———
Run `coder --help` for a list of global options.
+6 -179
View File
@@ -27,14 +27,12 @@ import (
)
const (
maxListInterceptionsLimit = 1000
maxListSessionsLimit = 1000
maxListModelsLimit = 1000
maxListClientsLimit = 1000
defaultListInterceptionsLimit = 100
defaultListSessionsLimit = 100
defaultListModelsLimit = 100
defaultListClientsLimit = 100
maxListSessionsLimit = 1000
maxListModelsLimit = 1000
maxListClientsLimit = 1000
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
@@ -61,7 +59,6 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f
r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge))
r.Group(func(r chi.Router) {
r.Use(middlewares...)
r.Get("/interceptions", api.aiBridgeListInterceptions)
r.Get("/sessions", api.aiBridgeListSessions)
r.Get("/sessions/{session_id}", api.aiBridgeGetSessionThreads)
r.Get("/models", api.aiBridgeListModels)
@@ -98,125 +95,6 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f
}
}
// aiBridgeListInterceptions returns all AI Bridge interceptions a user can read.
// Optional filters with query params.
//
// Deprecated: Use /aibridge/sessions instead, which provides richer
// session-level aggregation including threads and agentic actions.
//
// @Summary List AI Bridge interceptions
// @ID list-ai-bridge-interceptions
// @Security CoderSessionToken
// @Produce json
// @Tags AI Bridge
// @Param q query string false "Search query in the format `key:value`. Available keys are: initiator, provider, provider_name, model, started_after, started_before."
// @Param limit query int false "Page limit"
// @Param after_id query string false "Cursor pagination after ID (cannot be used with offset)"
// @Param offset query int false "Offset pagination (cannot be used with after_id)"
// @Success 200 {object} codersdk.AIBridgeListInterceptionsResponse
// @Router /api/v2/aibridge/interceptions [get]
// @Deprecated Use /aibridge/sessions instead.
func (api *API) aiBridgeListInterceptions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
page, ok := coderd.ParsePagination(rw, r)
if !ok {
return
}
if page.AfterID != uuid.Nil && page.Offset != 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Detail: "Cannot use both after_id and offset pagination in the same request.",
})
return
}
if page.Limit == 0 {
page.Limit = defaultListInterceptionsLimit
}
if page.Limit > maxListInterceptionsLimit || 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]", maxListInterceptionsLimit),
})
return
}
queryStr := r.URL.Query().Get("q")
filter, errs := searchquery.AIBridgeInterceptions(ctx, api.Database, queryStr, page, apiKey.UserID)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid workspace search query.",
Validations: errs,
})
return
}
var (
count int64
rows []database.ListAIBridgeInterceptionsRow
)
err := api.Database.InTx(func(db database.Store) error {
// Validate the cursor interception exists and is visible.
if err := validateInterceptionCursor(ctx, db, page.AfterID, "after_id", ""); err != nil {
return err
}
var err error
// Get the full count of authorized interceptions matching the filter
// for pagination purposes.
count, err = db.CountAIBridgeInterceptions(ctx, database.CountAIBridgeInterceptionsParams{
StartedAfter: filter.StartedAfter,
StartedBefore: filter.StartedBefore,
InitiatorID: filter.InitiatorID,
Provider: filter.Provider,
ProviderName: filter.ProviderName,
Model: filter.Model,
Client: filter.Client,
})
if err != nil {
return xerrors.Errorf("count authorized aibridge interceptions: %w", err)
}
// This only returns authorized interceptions (when using dbauthz).
rows, err = db.ListAIBridgeInterceptions(ctx, filter)
if err != nil {
return xerrors.Errorf("list aibridge interceptions: %w", err)
}
return nil
}, nil)
if err != nil {
if errors.Is(err, errInvalidCursor) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid pagination cursor.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting AI Bridge interceptions.",
Detail: err.Error(),
})
return
}
// This fetches the other rows associated with the interceptions.
items, err := populatedAndConvertAIBridgeInterceptions(ctx, api.Database, rows)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting database rows to API response.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AIBridgeListInterceptionsResponse{
Count: count,
Results: items,
})
}
// aiBridgeListSessions returns AI Bridge sessions (aggregated interceptions).
//
// @Summary List AI Bridge sessions
@@ -656,57 +534,6 @@ func validateInterceptionCursor(ctx context.Context, db database.Store, cursorID
return nil
}
func populatedAndConvertAIBridgeInterceptions(ctx context.Context, db database.Store, dbInterceptions []database.ListAIBridgeInterceptionsRow) ([]codersdk.AIBridgeInterception, error) {
if len(dbInterceptions) == 0 {
return []codersdk.AIBridgeInterception{}, nil
}
ids := make([]uuid.UUID, len(dbInterceptions))
for i, row := range dbInterceptions {
ids[i] = row.AIBridgeInterception.ID
}
tokenUsagesRows, err := db.ListAIBridgeTokenUsagesByInterceptionIDs(ctx, ids)
if err != nil {
return nil, xerrors.Errorf("get linked aibridge token usages from database: %w", err)
}
tokenUsagesMap := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(dbInterceptions))
for _, row := range tokenUsagesRows {
tokenUsagesMap[row.InterceptionID] = append(tokenUsagesMap[row.InterceptionID], row)
}
userPromptRows, err := db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, ids)
if err != nil {
return nil, xerrors.Errorf("get linked aibridge user prompts from database: %w", err)
}
userPromptsMap := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(dbInterceptions))
for _, row := range userPromptRows {
userPromptsMap[row.InterceptionID] = append(userPromptsMap[row.InterceptionID], row)
}
toolUsagesRows, err := db.ListAIBridgeToolUsagesByInterceptionIDs(ctx, ids)
if err != nil {
return nil, xerrors.Errorf("get linked aibridge tool usages from database: %w", err)
}
toolUsagesMap := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(dbInterceptions))
for _, row := range toolUsagesRows {
toolUsagesMap[row.InterceptionID] = append(toolUsagesMap[row.InterceptionID], row)
}
items := make([]codersdk.AIBridgeInterception, len(dbInterceptions))
for i, row := range dbInterceptions {
items[i] = db2sdk.AIBridgeInterception(
row.AIBridgeInterception,
row.VisibleUser,
tokenUsagesMap[row.AIBridgeInterception.ID],
userPromptsMap[row.AIBridgeInterception.ID],
toolUsagesMap[row.AIBridgeInterception.ID],
)
}
return items, nil
}
// @Summary Get group AI budget
// @ID get-group-ai-budget
// @Security CoderSessionToken
-621
View File
@@ -16,13 +16,11 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
entaudit "github.com/coder/coder/v2/enterprise/audit"
"github.com/coder/coder/v2/enterprise/audit/backends"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
@@ -31,625 +29,6 @@ import (
"github.com/coder/serpent"
)
func TestAIBridgeListInterceptions(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{
// No aibridge feature
Features: license.Features{},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is irrelevant here.
_, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
require.Equal(t, "AI Gateway is a Premium feature. Contact sales!", sdkErr.Message)
})
t.Run("EmptyDB", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // Owner role is irrelevant here.
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
require.NoError(t, err)
require.Empty(t, res.Results)
})
t.Run("OK", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
user1, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
user1Visible := database.VisibleUser{
ID: user1.ID,
Username: user1.Username,
Name: user1.Name,
AvatarURL: user1.AvatarURL,
}
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
user2Visible := database.VisibleUser{
ID: user2.ID,
Username: user2.Username,
Name: user2.Name,
AvatarURL: user2.AvatarURL,
}
// Insert a bunch of test data.
now := dbtime.Now()
i1ApiKey := sql.NullString{String: "some-api-key", Valid: true}
i1EndedAt := now.Add(-time.Hour + time.Minute)
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
APIKeyID: i1ApiKey,
InitiatorID: user1.ID,
StartedAt: now.Add(-time.Hour),
}, &i1EndedAt)
i1tok1 := dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
InterceptionID: i1.ID,
CreatedAt: now,
})
i1tok2 := dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
InterceptionID: i1.ID,
CreatedAt: now.Add(-time.Minute),
})
i1up1 := dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: i1.ID,
CreatedAt: now,
})
i1up2 := dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: i1.ID,
CreatedAt: now.Add(-time.Minute),
})
i1tool1 := dbgen.AIBridgeToolUsage(t, db, database.InsertAIBridgeToolUsageParams{
InterceptionID: i1.ID,
CreatedAt: now,
})
i1tool2 := dbgen.AIBridgeToolUsage(t, db, database.InsertAIBridgeToolUsageParams{
InterceptionID: i1.ID,
CreatedAt: now.Add(-time.Minute),
})
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: user2.ID,
StartedAt: now,
}, &now)
// Convert to SDK types for response comparison.
// You may notice that the ordering of the inner arrays are ASC, this is
// intentional.
i1SDK := db2sdk.AIBridgeInterception(i1, user1Visible, []database.AIBridgeTokenUsage{i1tok2, i1tok1}, []database.AIBridgeUserPrompt{i1up2, i1up1}, []database.AIBridgeToolUsage{i1tool2, i1tool1})
i2SDK := db2sdk.AIBridgeInterception(i2, user2Visible, nil, nil, nil)
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
require.NoError(t, err)
require.Len(t, res.Results, 2)
require.Equal(t, i2SDK.ID, res.Results[0].ID)
require.Equal(t, i1SDK.ID, res.Results[1].ID)
require.Equal(t, &i1ApiKey.String, i1SDK.APIKeyID)
require.Nil(t, i2SDK.APIKeyID)
// Normalize timestamps in the response so we can compare the whole
// thing easily.
res.Results[0].StartedAt = i2SDK.StartedAt
res.Results[1].StartedAt = i1SDK.StartedAt
require.Len(t, res.Results[1].TokenUsages, 2)
require.Equal(t, i1SDK.TokenUsages[0].ID, res.Results[1].TokenUsages[0].ID)
require.Equal(t, i1SDK.TokenUsages[1].ID, res.Results[1].TokenUsages[1].ID)
res.Results[1].TokenUsages[0].CreatedAt = i1SDK.TokenUsages[0].CreatedAt
res.Results[1].TokenUsages[1].CreatedAt = i1SDK.TokenUsages[1].CreatedAt
require.Len(t, res.Results[1].UserPrompts, 2)
require.Equal(t, i1SDK.UserPrompts[0].ID, res.Results[1].UserPrompts[0].ID)
require.Equal(t, i1SDK.UserPrompts[1].ID, res.Results[1].UserPrompts[1].ID)
res.Results[1].UserPrompts[0].CreatedAt = i1SDK.UserPrompts[0].CreatedAt
res.Results[1].UserPrompts[1].CreatedAt = i1SDK.UserPrompts[1].CreatedAt
require.Len(t, res.Results[1].ToolUsages, 2)
require.Equal(t, i1SDK.ToolUsages[0].ID, res.Results[1].ToolUsages[0].ID)
require.Equal(t, i1SDK.ToolUsages[1].ID, res.Results[1].ToolUsages[1].ID)
res.Results[1].ToolUsages[0].CreatedAt = i1SDK.ToolUsages[0].CreatedAt
res.Results[1].ToolUsages[1].CreatedAt = i1SDK.ToolUsages[1].CreatedAt
// Time comparison
require.Len(t, res.Results, 2)
require.Equal(t, res.Results[0].ID, i2SDK.ID)
require.NotNil(t, res.Results[0].EndedAt)
require.WithinDuration(t, now, *res.Results[0].EndedAt, 5*time.Second)
res.Results[0].EndedAt = i2SDK.EndedAt
require.NotNil(t, res.Results[1].EndedAt)
res.Results[1].EndedAt = i1SDK.EndedAt
require.Equal(t, []codersdk.AIBridgeInterception{i2SDK, i1SDK}, res.Results)
})
t.Run("Pagination", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
allInterceptionIDs := make([]uuid.UUID, 0, 20)
// Create 10 interceptions with the same started_at time. The returned
// order for these should still be deterministic.
now := dbtime.Now()
for i := range 10 {
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.UUID{byte(i)},
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &now)
allInterceptionIDs = append(allInterceptionIDs, interception.ID)
}
// Create 10 interceptions with a random started_at time.
for i := range 10 {
randomOffset, err := cryptorand.Intn(10000)
require.NoError(t, err)
randomOffsetDur := time.Duration(randomOffset) * time.Second
endedAt := now.Add(randomOffsetDur + time.Minute)
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.UUID{byte(i + 10)},
InitiatorID: firstUser.UserID,
StartedAt: now.Add(randomOffsetDur),
}, &endedAt)
allInterceptionIDs = append(allInterceptionIDs, interception.ID)
}
// Try to fetch with an invalid limit.
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
Pagination: codersdk.Pagination{
Limit: 1001,
},
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Contains(t, sdkErr.Message, "Invalid pagination limit value.")
require.Empty(t, res.Results)
// Try to fetch with both after_id and offset pagination.
res, err = client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
Pagination: codersdk.Pagination{
AfterID: allInterceptionIDs[0],
Offset: 1,
},
})
require.ErrorAs(t, err, &sdkErr)
require.Contains(t, sdkErr.Message, "Query parameters have invalid values")
require.Contains(t, sdkErr.Detail, "Cannot use both after_id and offset pagination in the same request.")
// Iterate over all interceptions using both cursor and offset
// pagination modes.
for _, paginationMode := range []string{"after_id", "offset"} {
t.Run(paginationMode, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Get all interceptions one by one using the given pagination
// mode.
getAllInterceptionsOneByOne := func() []uuid.UUID {
interceptionIDs := []uuid.UUID{}
for {
pagination := codersdk.Pagination{
Limit: 1,
}
if paginationMode == "after_id" {
if len(interceptionIDs) > 0 {
pagination.AfterID = interceptionIDs[len(interceptionIDs)-1]
}
} else {
pagination.Offset = len(interceptionIDs)
}
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
Pagination: pagination,
})
require.NoError(t, err)
if len(res.Results) == 0 {
break
}
require.EqualValues(t, len(allInterceptionIDs), res.Count)
require.Len(t, res.Results, 1)
interceptionIDs = append(interceptionIDs, res.Results[0].ID)
}
return interceptionIDs
}
// First attempt: get all interceptions one by one.
gotInterceptionIDs1 := getAllInterceptionsOneByOne()
// We should have all of the interceptions returned:
require.ElementsMatch(t, allInterceptionIDs, gotInterceptionIDs1)
// Second attempt: get all interceptions one by one again.
gotInterceptionIDs2 := getAllInterceptionsOneByOne()
// They should be returned in the exact same order.
require.Equal(t, gotInterceptionIDs1, gotInterceptionIDs2)
})
}
})
t.Run("InflightInterceptions", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
i1EndedAt := now.Add(time.Minute)
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &i1EndedAt)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-time.Hour),
}, nil)
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
require.NoError(t, err)
require.EqualValues(t, 1, res.Count)
require.Len(t, res.Results, 1)
require.Equal(t, i1.ID, res.Results[0].ID)
})
t.Run("Authorized", func(t *testing.T) {
t.Parallel()
adminClient, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
now := dbtime.Now()
i1EndedAt := now.Add(time.Minute)
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &i1EndedAt)
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: secondUser.ID,
StartedAt: now.Add(-time.Hour),
}, &now)
// Members cannot read AIBridge interceptions, not even their
// own (i2 is owned by secondUser).
res, err := secondUserClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
require.NoError(t, err)
require.EqualValues(t, 0, res.Count)
require.Empty(t, res.Results)
// Owner can see all interceptions, including secondUser's,
// proving the data exists and the member was filtered out.
res, err = adminClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
require.NoError(t, err)
require.EqualValues(t, 2, res.Count)
require.Len(t, res.Results, 2)
require.Equal(t, i1.ID, res.Results[0].ID)
require.Equal(t, i2.ID, res.Results[1].ID)
})
t.Run("Filter", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
user1, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
user1Visible := database.VisibleUser{
ID: user1.ID,
Username: user1.Username,
Name: user1.Name,
AvatarURL: user1.AvatarURL,
}
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
user2Visible := database.VisibleUser{
ID: user2.ID,
Username: user2.Username,
Name: user2.Name,
AvatarURL: user2.AvatarURL,
}
// Insert a bunch of test data with varying filterable fields.
now := dbtime.Now()
i1EndedAt := now.Add(time.Minute)
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
InitiatorID: user1.ID,
Provider: "one",
Model: "one",
StartedAt: now,
}, &i1EndedAt)
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
InitiatorID: user1.ID,
Provider: "two",
Model: "two",
StartedAt: now.Add(-time.Hour),
Client: sql.NullString{String: string(aiblib.ClientCursor), Valid: true},
}, &now)
i3 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000003"),
InitiatorID: user2.ID,
Provider: "three",
Model: "three",
StartedAt: now.Add(-2 * time.Hour),
Client: sql.NullString{String: string(aiblib.ClientClaudeCode), Valid: true},
}, &now)
// Convert to SDK types for response comparison. We don't care about the
// inner arrays for this test.
i1SDK := db2sdk.AIBridgeInterception(i1, user1Visible, nil, nil, nil)
i2SDK := db2sdk.AIBridgeInterception(i2, user1Visible, nil, nil, nil)
i3SDK := db2sdk.AIBridgeInterception(i3, user2Visible, nil, nil, nil)
cases := []struct {
name string
filter codersdk.AIBridgeListInterceptionsFilter
want []codersdk.AIBridgeInterception
}{
{
name: "NoFilter",
filter: codersdk.AIBridgeListInterceptionsFilter{},
want: []codersdk.AIBridgeInterception{i1SDK, i2SDK, i3SDK},
},
{
name: "Initiator/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: uuid.New().String()},
want: []codersdk.AIBridgeInterception{},
},
{
name: "Initiator/Me",
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: codersdk.Me},
want: []codersdk.AIBridgeInterception{i1SDK, i2SDK},
},
{
name: "Initiator/UserID",
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: user2.ID.String()},
want: []codersdk.AIBridgeInterception{i3SDK},
},
{
name: "Initiator/Username",
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: user2.Username},
want: []codersdk.AIBridgeInterception{i3SDK},
},
{
name: "Provider/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{Provider: "nonsense"},
want: []codersdk.AIBridgeInterception{},
},
{
name: "Provider/OK",
filter: codersdk.AIBridgeListInterceptionsFilter{Provider: "two"},
want: []codersdk.AIBridgeInterception{i2SDK},
},
{
name: "Model/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{Model: "nonsense"},
want: []codersdk.AIBridgeInterception{},
},
{
name: "Model/OK",
filter: codersdk.AIBridgeListInterceptionsFilter{Model: "three"},
want: []codersdk.AIBridgeInterception{i3SDK},
},
{
name: "Client/Unknown",
filter: codersdk.AIBridgeListInterceptionsFilter{Client: string(aiblib.ClientUnknown)},
want: []codersdk.AIBridgeInterception{i1SDK},
},
{
name: "Client/Match",
filter: codersdk.AIBridgeListInterceptionsFilter{Client: string(aiblib.ClientCursor)},
want: []codersdk.AIBridgeInterception{i2SDK},
},
{
name: "Client/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{Client: "nonsense"},
want: []codersdk.AIBridgeInterception{},
},
{
name: "StartedAfter/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{
StartedAfter: i1.StartedAt.Add(10 * time.Minute),
},
want: []codersdk.AIBridgeInterception{},
},
{
name: "StartedAfter/OK",
filter: codersdk.AIBridgeListInterceptionsFilter{
StartedAfter: i2.StartedAt.Add(-10 * time.Minute),
},
want: []codersdk.AIBridgeInterception{i1SDK, i2SDK},
},
{
name: "StartedBefore/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{
StartedBefore: i3.StartedAt.Add(-10 * time.Minute),
},
want: []codersdk.AIBridgeInterception{},
},
{
name: "StartedBefore/OK",
filter: codersdk.AIBridgeListInterceptionsFilter{
StartedBefore: i3.StartedAt.Add(10 * time.Minute),
},
want: []codersdk.AIBridgeInterception{i3SDK},
},
{
name: "BothBeforeAndAfter/NoMatch",
filter: codersdk.AIBridgeListInterceptionsFilter{
StartedAfter: i1.StartedAt.Add(10 * time.Minute),
StartedBefore: i1.StartedAt.Add(20 * time.Minute),
},
want: []codersdk.AIBridgeInterception{},
},
{
name: "BothBeforeAndAfter/OK",
filter: codersdk.AIBridgeListInterceptionsFilter{
StartedAfter: i2.StartedAt.Add(-10 * time.Minute),
StartedBefore: i2.StartedAt.Add(10 * time.Minute),
},
want: []codersdk.AIBridgeInterception{i2SDK},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.AIBridgeListInterceptions(ctx, tc.filter)
require.NoError(t, err)
require.EqualValues(t, len(tc.want), res.Count)
// We just compare UUID strings for the sake of this test.
wantIDs := make([]string, len(tc.want))
for i, r := range tc.want {
wantIDs[i] = r.ID.String()
}
gotIDs := make([]string, len(res.Results))
for i, r := range res.Results {
gotIDs[i] = r.ID.String()
}
require.Equal(t, wantIDs, gotIDs)
})
}
})
t.Run("FilterByMe/MemberCannotReadOwn", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
ownerClient, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitLong)
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID)
now := dbtime.Now()
// Create an interception initiated by the member.
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: member.ID,
StartedAt: now,
}, nil)
// Member cannot read their own interceptions, even when
// filtering by "me".
res, err := memberClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
Initiator: codersdk.Me,
})
require.NoError(t, err)
require.EqualValues(t, 0, res.Count)
require.Empty(t, res.Results)
})
t.Run("FilterErrors", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, aibridgeOpts(t))
// No need to insert any test data, we're just testing the filter
// errors.
cases := []struct {
name string
q string
want []codersdk.ValidationError
}{
{
name: "UnknownUsername",
q: "initiator:unknown",
want: []codersdk.ValidationError{
{
Field: "initiator",
Detail: `Query param "initiator" has invalid value: user "unknown" either does not exist, or you are unauthorized to view them`,
},
},
},
{
name: "InvalidStartedAfter",
q: "started_after:invalid",
want: []codersdk.ValidationError{
{
Field: "started_after",
Detail: `Query param "started_after" must be a valid date format (2006-01-02T15:04:05.999999999Z07:00): parsing time "INVALID" as "2006-01-02T15:04:05.999999999Z07:00": cannot parse "INVALID" as "2006"`,
},
},
},
{
name: "InvalidStartedBefore",
q: "started_before:invalid",
want: []codersdk.ValidationError{
{
Field: "started_before",
Detail: `Query param "started_before" must be a valid date format (2006-01-02T15:04:05.999999999Z07:00): parsing time "INVALID" as "2006-01-02T15:04:05.999999999Z07:00": cannot parse "INVALID" as "2006"`,
},
},
},
{
name: "InvalidBeforeAfterRange",
// Before MUST be after After if both are set
q: `started_after:"2025-01-01T00:00:00Z" started_before:"2024-01-01T00:00:00Z"`,
want: []codersdk.ValidationError{
{
Field: "started_before",
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
FilterQuery: tc.q,
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, tc.want, sdkErr.Validations)
require.Empty(t, res.Results)
})
}
})
t.Run("InvalidCursor", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
// Using a nonexistent UUID as after_id should return 400,
// not silently return an empty page.
//nolint:gocritic // Owner role is irrelevant here.
_, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
Pagination: codersdk.Pagination{
AfterID: uuid.New(),
},
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "Invalid pagination cursor")
})
}
func aibridgeOpts(t *testing.T) *coderdenttest.Options {
t.Helper()
dv := coderdtest.DeploymentValues(t)
+11 -21
View File
@@ -3047,13 +3047,17 @@ class ApiMethods {
});
};
getAIBridgeInterceptions = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams(
"/api/v2/aibridge/interceptions",
options,
);
const response =
await this.axios.get<TypesGen.AIBridgeListInterceptionsResponse>(url);
getAIBridgeModels = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
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;
};
@@ -3077,20 +3081,6 @@ class ApiMethods {
return response.data;
};
getAIBridgeModels = async (options: SearchParamOptions) => {
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
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;
};
getAIProviders = async (): Promise<TypesGen.AIProvider[]> => {
const response = await this.axios.get<TypesGen.AIProvider[]>(
"/api/v2/ai/providers",
-19
View File
@@ -1,7 +1,6 @@
import type { UseInfiniteQueryOptions } from "react-query";
import { API } from "#/api/api";
import type {
AIBridgeListInterceptionsResponse,
AIBridgeListSessionsResponse,
AIBridgeSessionThreadsResponse,
} from "#/api/typesGenerated";
@@ -10,24 +9,6 @@ import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
const SESSION_THREADS_INFINITE_PAGE_SIZE = 20;
export const paginatedInterceptions = (
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<AIBridgeListInterceptionsResponse, string> => {
return {
searchParams,
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
queryKey: ({ limit, offset, payload }) => {
return ["aiBridgeInterceptions", limit, offset, payload] as const;
},
queryFn: ({ limit, offset, payload }) =>
API.getAIBridgeInterceptions({
offset,
limit,
q: payload,
}),
};
};
export const paginatedSessions = (
searchParams: URLSearchParams,
): UsePaginatedQueryOptions<AIBridgeListSessionsResponse, string> => {
-64
View File
@@ -90,30 +90,6 @@ export interface AIBridgeConfig {
readonly api_dump_dir: string;
}
// From codersdk/aibridge.go
export interface AIBridgeInterception {
readonly id: string;
readonly api_key_id: string | null;
readonly initiator: MinimalUser;
readonly provider: string;
readonly provider_name: string;
readonly model: string;
readonly client: string | null;
// empty interface{} type, falling back to unknown
readonly metadata: Record<string, unknown>;
readonly started_at: string;
readonly ended_at: string | null;
readonly token_usages: readonly AIBridgeTokenUsage[];
readonly user_prompts: readonly AIBridgeUserPrompt[];
readonly tool_usages: readonly AIBridgeToolUsage[];
}
// From codersdk/aibridge.go
export interface AIBridgeListInterceptionsResponse {
readonly count: number;
readonly results: readonly AIBridgeInterception[];
}
// From codersdk/aibridge.go
export interface AIBridgeListSessionsResponse {
readonly count: number;
@@ -229,20 +205,6 @@ export interface AIBridgeThread {
readonly agentic_actions: readonly AIBridgeAgenticAction[];
}
// From codersdk/aibridge.go
export interface AIBridgeTokenUsage {
readonly id: string;
readonly interception_id: string;
readonly provider_response_id: string;
readonly input_tokens: number;
readonly output_tokens: number;
readonly cache_read_input_tokens: number;
readonly cache_write_input_tokens: number;
// empty interface{} type, falling back to unknown
readonly metadata: Record<string, unknown>;
readonly created_at: string;
}
// From codersdk/aibridge.go
/**
* AIBridgeToolCall represents a tool call recorded during an
@@ -261,32 +223,6 @@ export interface AIBridgeToolCall {
readonly created_at: string;
}
// From codersdk/aibridge.go
export interface AIBridgeToolUsage {
readonly id: string;
readonly interception_id: string;
readonly provider_response_id: string;
readonly server_url: string;
readonly tool: string;
readonly input: string;
readonly injected: boolean;
readonly invocation_error: string;
// empty interface{} type, falling back to unknown
readonly metadata: Record<string, unknown>;
readonly created_at: string;
}
// From codersdk/aibridge.go
export interface AIBridgeUserPrompt {
readonly id: string;
readonly interception_id: string;
readonly provider_response_id: string;
readonly prompt: string;
// empty interface{} type, falling back to unknown
readonly metadata: Record<string, unknown>;
readonly created_at: string;
}
// From codersdk/deployment.go
export type AIBudgetPeriod = "month";
@@ -5,18 +5,12 @@ import {
type useFilter,
} from "#/components/Filter/Filter";
import { type UserFilterMenu, UserMenu } from "#/components/Filter/UserFilter";
import {
ClientFilter,
type ClientFilterMenu,
} from "../RequestLogsPage/RequestLogsFilter/ClientFilter";
import {
ModelFilter,
type ModelFilterMenu,
} from "../RequestLogsPage/RequestLogsFilter/ModelFilter";
import { ClientFilter, type ClientFilterMenu } from "../filters/ClientFilter";
import { ModelFilter, type ModelFilterMenu } from "../filters/ModelFilter";
import {
ProviderFilter,
type ProviderFilterMenu,
} from "../RequestLogsPage/RequestLogsFilter/ProviderFilter";
} from "../filters/ProviderFilter";
interface ListSessionsFilterProps {
filter: ReturnType<typeof useFilter>;
@@ -8,10 +8,10 @@ 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 "../filters/ClientFilter";
import { useModelFilterMenu } from "../filters/ModelFilter";
import { useProviderFilterMenu } from "../filters/ProviderFilter";
import { getAIBridgePermissions } from "../getAIBridgePermissions";
import { useClientFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ClientFilter";
import { useModelFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ModelFilter";
import { useProviderFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ProviderFilter";
import { ListSessionsPageView } from "./ListSessionsPageView";
const AISessionListPage: FC = () => {
@@ -10,11 +10,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon";
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon";
import { DATE_FORMAT, formatDateTime } from "#/utils/time";
import { TokenBadges } from "../TokenBadges";
import { getProviderDisplayName, getProviderIconName } from "../utils";
import { getProviderDisplayName } from "../utils";
type ListSessionsRowProps = {
session: AIBridgeSession;
@@ -71,7 +71,7 @@ export const ListSessionsRow: FC<ListSessionsRowProps> = ({
<Badge className="gap-1.5 max-w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeProviderIcon
provider={getProviderIconName(session.providers[0])}
provider={session.providers[0]}
className="size-icon-xs"
/>
</div>
@@ -1,54 +0,0 @@
import type { FC } from "react";
import {
Filter,
MenuSkeleton,
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";
interface RequestLogsFilterProps {
filter: ReturnType<typeof useFilter>;
error?: unknown;
menus: {
user: UserFilterMenu;
provider: ProviderFilterMenu;
model: ModelFilterMenu;
client: ClientFilterMenu;
};
}
export const RequestLogsFilter: FC<RequestLogsFilterProps> = ({
filter,
error,
menus,
}) => {
return (
<Filter
filter={filter}
optionsSkeleton={<MenuSkeleton />}
isLoading={menus.user.isInitializing}
presets={[
{
name: "All requests",
query: "",
},
{
name: "My requests",
query: "initiator:me",
},
]}
error={error}
options={
<>
<UserMenu menu={menus.user} placeholder="All initiators" />
<ProviderFilter menu={menus.provider} />
<ModelFilter menu={menus.model} />
<ClientFilter menu={menus.client} />
</>
}
/>
);
};
@@ -1,100 +0,0 @@
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/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { pageTitle } from "#/utils/page";
import { getAIBridgePermissions } from "../getAIBridgePermissions";
import { useClientFilterMenu } from "./RequestLogsFilter/ClientFilter";
import { useModelFilterMenu } from "./RequestLogsFilter/ModelFilter";
import { useProviderFilterMenu } from "./RequestLogsFilter/ProviderFilter";
import { RequestLogsPageView } from "./RequestLogsPageView";
const RequestLogsPage: FC = () => {
const { permissions } = useAuthenticated();
const { entitlements } = useDashboard();
const { isEntitled, isEnabled, hasPermission } = getAIBridgePermissions(
entitlements,
permissions,
);
const canViewRequestLogs = isEntitled && hasPermission;
const [searchParams, setSearchParams] = useSearchParams();
const interceptionsQuery = usePaginatedQuery({
...paginatedInterceptions(searchParams),
enabled: canViewRequestLogs,
});
const filter = useFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: interceptionsQuery.goToFirstPage,
});
const userMenu = useUserFilterMenu({
value: filter.values.initiator,
onChange: (option) =>
filter.update({
...filter.values,
initiator: option?.value,
}),
});
const providerMenu = useProviderFilterMenu({
value: filter.values.provider_name,
onChange: (option) =>
filter.update({
...filter.values,
provider_name: option?.value,
}),
});
const modelMenu = useModelFilterMenu({
value: filter.values.model,
onChange: (option) =>
filter.update({
...filter.values,
model: option?.value,
}),
});
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 Gateway")}</title>
<RequestLogsPageView
isLoading={interceptionsQuery.isLoading}
isRequestLogsEntitled={isEntitled}
isRequestLogsEnabled={isEnabled}
interceptions={interceptionsQuery.data?.results}
interceptionsQuery={interceptionsQuery}
filterProps={{
filter,
error: interceptionsQuery.error,
menus: {
user: userMenu,
provider: providerMenu,
model: modelMenu,
client: clientMenu,
},
}}
/>
</RequirePermission>
);
};
export default RequestLogsPage;
@@ -1,98 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import {
getDefaultFilterProps,
MockMenu,
} from "#/components/Filter/storyHelpers";
import {
mockInitialRenderResult,
mockSuccessResult,
} from "#/components/PaginationWidget/PaginationContainer.mocks";
import {
MockInterception,
MockInterceptionAnthropic,
MockInterceptionCopilot,
} from "#/testHelpers/entities";
import { RequestLogsPageView } from "./RequestLogsPageView";
type FilterProps = ComponentProps<typeof RequestLogsPageView>["filterProps"];
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
query: "owner:me",
values: {
username: undefined,
provider: undefined,
},
menus: {
user: MockMenu,
provider: MockMenu,
model: MockMenu,
client: MockMenu,
},
});
const interceptions = [
MockInterception,
MockInterceptionAnthropic,
MockInterceptionCopilot,
];
const meta: Meta<typeof RequestLogsPageView> = {
title: "pages/AIBridgePage/RequestLogsPageView",
component: RequestLogsPageView,
args: {},
};
export default meta;
type Story = StoryObj<typeof RequestLogsPageView>;
export const Paywall: Story = {
args: {
isRequestLogsEntitled: false,
isRequestLogsEnabled: false,
},
};
export const NotEnabled: Story = {
args: {
isRequestLogsEntitled: true,
isRequestLogsEnabled: false,
},
};
export const Loaded: Story = {
args: {
isRequestLogsEntitled: true,
isRequestLogsEnabled: true,
interceptions,
filterProps: {
...defaultFilterProps,
},
interceptionsQuery: mockSuccessResult,
},
};
export const Empty: Story = {
args: {
isRequestLogsEntitled: true,
isRequestLogsEnabled: true,
interceptions: [],
filterProps: {
...defaultFilterProps,
},
interceptionsQuery: mockSuccessResult,
},
};
export const Loading: Story = {
args: {
isLoading: true,
isRequestLogsEntitled: true,
isRequestLogsEnabled: true,
interceptions: [],
filterProps: {
...defaultFilterProps,
},
interceptionsQuery: mockInitialRenderResult,
},
};
@@ -1,93 +0,0 @@
import type { ComponentProps, FC } from "react";
import type { AIBridgeInterception } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Link } from "#/components/Link/Link";
import {
PaginationContainer,
type PaginationResult,
} from "#/components/PaginationWidget/PaginationContainer";
import { PaywallAIGovernance } from "#/components/Paywall/PaywallAIGovernance";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import { AIBridgeSetupAlert } from "../AIBridgeSetupAlert";
import { RequestLogsFilter } from "./RequestLogsFilter/RequestLogsFilter";
import { RequestLogsRow } from "./RequestLogsRow/RequestLogsRow";
interface RequestLogsPageViewProps {
isLoading: boolean;
isRequestLogsEntitled: boolean;
isRequestLogsEnabled: boolean;
interceptions?: readonly AIBridgeInterception[];
interceptionsQuery: PaginationResult;
filterProps: ComponentProps<typeof RequestLogsFilter>;
}
export const RequestLogsPageView: FC<RequestLogsPageViewProps> = ({
isLoading,
isRequestLogsEntitled,
isRequestLogsEnabled,
interceptions,
interceptionsQuery,
filterProps,
}) => {
if (!isRequestLogsEntitled) {
return <PaywallAIGovernance />;
}
if (!isRequestLogsEnabled) {
return <AIBridgeSetupAlert />;
}
return (
<>
<Alert severity="info" className="mb-4">
Visit the new{" "}
<Link href="/aibridge/sessions" className="text-content-link italic">
AI Sessions
</Link>{" "}
page for a more comprehensive view of AI activity.
</Alert>
<RequestLogsFilter {...filterProps} />
<PaginationContainer
query={interceptionsQuery}
paginationUnitLabel="interceptions"
>
<Table className="text-sm">
<TableHeader>
<TableRow className="text-xs">
<TableHead>Timestamp</TableHead>
<TableHead>Initiator</TableHead>
<TableHead>Tokens</TableHead>
<TableHead>Client</TableHead>
<TableHead>Model</TableHead>
<TableHead>Tool Calls</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableLoader />
) : interceptions?.length === 0 ? (
<TableEmpty message="No request logs available" />
) : (
interceptions?.map((interception) => (
<RequestLogsRow
interception={interception}
key={interception.id}
/>
))
)}
</TableBody>
</Table>
</PaginationContainer>
</>
);
};
File diff suppressed because one or more lines are too long
@@ -1,70 +0,0 @@
import { tokenUsageMetadataMerge } from "./RequestLogsRow";
describe("tokenUsageMetadataMerge", () => {
it("returns null when inputs are null or empty", () => {
const result = tokenUsageMetadataMerge(null, {}, null);
expect(result).toBeNull();
});
it("returns data when there are no shared keys across metadata", () => {
const metadataA = { input_tokens: 5 };
const metadataB = { output_tokens: 2 };
const result = tokenUsageMetadataMerge(metadataA, metadataB);
expect(result).toEqual([metadataA, metadataB]);
});
it("sums numeric values for common keys and keeps non-common keys", () => {
const metadataA = {
input_tokens: 5,
model: "gpt-4",
};
const metadataB = {
input_tokens: 3,
output_tokens: 2,
};
const result = tokenUsageMetadataMerge(metadataA, metadataB);
expect(result).toEqual({
input_tokens: 8,
model: "gpt-4",
output_tokens: 2,
});
});
it("preserves identical non-numeric values for common keys", () => {
const metadataA = {
note: "sync",
status: "ok",
};
const metadataB = {
note: "sync",
status: "ok",
};
const result = tokenUsageMetadataMerge(metadataA, metadataB);
expect(result).toEqual({
note: "sync",
status: "ok",
});
});
it("returns the original metadata array when a conflict cannot be resolved", () => {
const metadataA = {
input_tokens: 1,
label: "a",
};
const metadataB = {
input_tokens: 3,
label: "b",
};
const result = tokenUsageMetadataMerge(metadataA, metadataB);
expect(result).toEqual([metadataA, metadataB]);
});
});
@@ -1,387 +0,0 @@
import { ChevronRightIcon } from "lucide-react";
import { type FC, Fragment, useState } from "react";
import type { AIBridgeInterception } from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { Badge } from "#/components/Badge/Badge";
import { TableCell, TableRow } from "#/components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { formatDate, humanDuration } from "#/utils/time";
import { TokenBadges } from "../../TokenBadges";
import { AIBridgeClientIcon } from "../icons/AIBridgeClientIcon";
import { AIBridgeModelIcon } from "../icons/AIBridgeModelIcon";
type RequestLogsRowProps = {
interception: AIBridgeInterception;
};
type TokenUsageMetadataMerged =
| null
| Record<string, unknown>
| Array<Record<string, unknown>>;
/**
* This function merges multiple objects with the same keys into a single object.
* It's super unconventional, but it's only a temporary workaround until we
* structure our metadata field for rendering in the UI.
* @param objects - The objects to merge.
* @returns The merged object.
*/
export function tokenUsageMetadataMerge(
...objects: Array<
AIBridgeInterception["token_usages"][number]["metadata"] | null
>
): TokenUsageMetadataMerged {
const validObjects = objects.filter((obj) => obj !== null);
// Filter out empty objects
const nonEmptyObjects = validObjects.filter(
(obj) => Object.keys(obj).length > 0,
);
if (nonEmptyObjects.length === 0) {
return null;
}
const allKeys = new Set(nonEmptyObjects.flatMap((obj) => Object.keys(obj)));
const commonKeys = Array.from(allKeys).filter((key) =>
nonEmptyObjects.every((obj) => key in obj),
);
if (commonKeys.length === 0) {
return nonEmptyObjects;
}
// Check for unresolvable conflicts: values that aren't all numeric or all
// the same.
for (const key of allKeys) {
const objectsWithKey = nonEmptyObjects.filter((obj) => key in obj);
if (objectsWithKey.length > 1) {
const values = objectsWithKey.map((obj) => obj[key]);
const allNumeric = values.every((v: unknown) => typeof v === "number");
const allSame = new Set(values).size === 1;
if (!allNumeric && !allSame) {
return nonEmptyObjects;
}
}
}
// Merge common keys: sum numeric values, preserve identical values, mark
// conflicts as null.
const result: Record<string, unknown> = {};
for (const key of commonKeys) {
const values = nonEmptyObjects.map((obj) => obj[key]);
const allNumeric = values.every((v: unknown) => typeof v === "number");
const allSame = new Set(values).size === 1;
if (allNumeric) {
result[key] = values.reduce((acc, v) => acc + (v as number), 0);
} else if (allSame) {
result[key] = values[0];
} else {
result[key] = null;
}
}
// Add non-common keys from the first object that has them.
for (const obj of nonEmptyObjects) {
for (const key of Object.keys(obj)) {
if (!commonKeys.includes(key) && !(key in result)) {
result[key] = obj[key];
}
}
}
// If any conflicts were marked, return original objects.
return Object.values(result).some((v: unknown) => v === null)
? nonEmptyObjects
: result;
}
export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
const [isOpen, setIsOpen] = useState(false);
const inputTokens = interception.token_usages.reduce(
(acc, tokenUsage) => acc + tokenUsage.input_tokens,
0,
);
const outputTokens = interception.token_usages.reduce(
(acc, tokenUsage) => acc + tokenUsage.output_tokens,
0,
);
const tokenUsagesMetadata = tokenUsageMetadataMerge(
...interception.token_usages.map((tokenUsage) => tokenUsage.metadata),
);
const toolCalls = interception.tool_usages.length;
const duration =
interception.ended_at &&
Math.max(
0,
new Date(interception.ended_at).getTime() -
new Date(interception.started_at).getTime(),
);
return (
<>
<TableRow
className="select-none cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
hover
>
<TableCell className="w-48 whitespace-nowrap">
<div
className={cn([
"flex items-center gap-2",
isOpen && "text-content-primary",
])}
>
<ChevronRightIcon
className={cn(
"mr-4 transition-transform size-3.5",
isOpen && "rotate-90",
)}
/>
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
{formatDate(new Date(interception.started_at))}
</div>
</TableCell>
<TableCell className="w-48 max-w-48">
<div className="w-full min-w-0 overflow-hidden">
<div className="flex items-center gap-3 min-w-0">
<Avatar
fallback={interception.initiator.username}
src={interception.initiator.avatar_url}
size="lg"
className="flex-shrink-0"
/>
<div className="font-medium truncate min-w-0 flex-1 overflow-hidden">
{interception.initiator.name ?? interception.initiator.username}
</div>
</div>
</div>
</TableCell>
<TableCell className="w-32">
<div className="flex items-center">
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
/>
</div>
</TableCell>
<TableCell className="w-40 max-w-40">
<div className="min-w-0 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-1.5 max-w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeClientIcon
client={interception.client}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0">
{interception.client ?? "Unknown"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{interception.client}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell className="w-40 max-w-40">
<div className="min-w-0 overflow-hidden">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="gap-1.5 max-w-full">
<div className="flex-shrink-0 flex items-center">
<AIBridgeModelIcon
model={interception.model}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0">
{interception.model}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{interception.model}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell className="w-32 text-center">
<Badge>{toolCalls}</Badge>
</TableCell>
</TableRow>
{isOpen && (
<TableRow>
<TableCell colSpan={999} className="p-4 border-t-0">
<div className="flex flex-col gap-6">
<dl
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 items-center",
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
])}
>
<dt>Request ID:</dt>
<dd data-chromatic="ignore">{interception.id}</dd>
<dt>Start Time:</dt>
<dd data-chromatic="ignore">
{formatDate(new Date(interception.started_at))}
</dd>
{interception.ended_at && (
<>
<dt>End Time:</dt>
<dd data-chromatic="ignore">
{formatDate(new Date(interception.ended_at))}
</dd>
</>
)}
{(duration || duration === 0) && (
<>
<dt>Duration:</dt>
<dd title={duration.toString()} data-chromatic="ignore">
{humanDuration(duration)}
</dd>
</>
)}
<dt>Initiator:</dt>
<dd
data-chromatic="ignore"
className="flex items-center gap-1.5"
>
<Avatar
fallback={interception.initiator.username}
src={interception.initiator.avatar_url}
size="sm"
className="flex-shrink-0"
/>
<span className="truncate min-w-0 w-full">
{interception.initiator.name ??
interception.initiator.username}
</span>
</dd>
<dt>Client:</dt>
<dd data-chromatic="ignore">
<Badge className="gap-2">
<div className="flex-shrink-0 flex items-center">
<AIBridgeClientIcon
client={interception.client}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0 w-full text-2xs">
{interception.client ?? "Unknown"}
</span>
</Badge>
</dd>
<dt>Model:</dt>
<dd data-chromatic="ignore">
<Badge className="gap-2">
<div className="flex-shrink-0 flex items-center">
<AIBridgeModelIcon
model={interception.model}
className="size-icon-xs"
/>
</div>
<span className="truncate min-w-0 w-full text-2xs">
{interception.model}
</span>
</Badge>
</dd>
<dt>Tool Calls:</dt>
<dd data-chromatic="ignore">
<Badge>{interception.tool_usages.length}</Badge>
</dd>
<dt>Input/Output Tokens:</dt>
<dd data-chromatic="ignore">
<div className="flex items-center">
<TokenBadges
inputTokens={inputTokens}
outputTokens={outputTokens}
/>
</div>
</dd>
</dl>
{interception.user_prompts.length > 0 && (
<div className="flex flex-col gap-2">
<div>Prompts</div>
<div
className="bg-surface-secondary rounded-md p-4 text-xs leading-4"
data-chromatic="ignore"
>
{interception.user_prompts.map((prompt) => (
<Fragment key={prompt.id}>{prompt.prompt}</Fragment>
))}
</div>
</div>
)}
{interception.tool_usages.length > 0 && (
<div className="flex flex-col gap-2">
<div>Tool Usages</div>
<div
className="bg-surface-secondary rounded-md p-4"
data-chromatic="ignore"
>
{interception.tool_usages.map((toolUsage) => {
return (
<dl
key={toolUsage.id}
className={cn([
"text-xs text-content-secondary",
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
"[&_dt]:text-content-primary [&_dd]:font-mono [&_dt]:leading-[22px] [&_dt]:font-medium",
])}
>
<dt>{toolUsage.tool}</dt>
<dd className="overflow-x-auto">
<div className="flex flex-col gap-2">
<div>{toolUsage.input}</div>
{toolUsage.invocation_error && (
<div className="text-content-destructive">
{toolUsage.invocation_error}
</div>
)}
</div>
</dd>
</dl>
);
})}
</div>
</div>
)}
{tokenUsagesMetadata !== null && (
<div className="flex flex-col gap-2">
<div>Token Usage Metadata</div>
<div className="bg-surface-secondary rounded-md p-4">
<pre>{JSON.stringify(tokenUsagesMetadata, null, 2)}</pre>
</div>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
};
@@ -1,31 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AIBridgeClientIcon } from "./AIBridgeClientIcon";
const meta: Meta<typeof AIBridgeClientIcon> = {
title: "pages/AIBridgePage/AIBridgeClientIcon",
component: AIBridgeClientIcon,
args: {
className: "size-8",
},
};
export default meta;
type Story = StoryObj<typeof AIBridgeClientIcon>;
export const OpenCode: Story = {
args: {
client: "OpenCode",
},
};
export const ClaudeCode: Story = {
args: {
client: "Claude Code",
},
};
export const Unknown: Story = {
args: {
client: "Unknown",
},
};
@@ -1,11 +1,11 @@
import type { MinimalUser } from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
import { Badge } from "#/components/Badge/Badge";
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon";
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon";
import { formatDateTime } from "#/utils/time";
import { TokenBadges } from "../TokenBadges";
import { getProviderDisplayName, getProviderIconName } from "../utils";
import { getProviderDisplayName } from "../utils";
const Separator = () => <div className="border-0 border-t border-solid my-1" />;
@@ -123,10 +123,7 @@ export const SessionSummaryTable = ({
key={p}
className="gap-1.5 max-w-full min-w-0 overflow-hidden"
>
<AIBridgeProviderIcon
provider={getProviderIconName(p)}
className="size-icon-xs"
/>
<AIBridgeProviderIcon provider={p} className="size-icon-xs" />
<span
className="truncate min-w-0 flex-1"
title={getProviderDisplayName(p)}
@@ -6,7 +6,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { AIBridgeModelIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon";
import { AIBridgeModelIcon } from "#/pages/AIBridgePage/icons/AIBridgeModelIcon";
import { cn } from "#/utils/cn";
import { formatDate } from "#/utils/time";
import { TokenBadges } from "../../TokenBadges";
@@ -1,5 +1,4 @@
import { CircleQuestionMarkIcon } from "lucide-react";
import type { AIBridgeInterception } from "#/api/typesGenerated";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { cn } from "#/utils/cn";
@@ -8,7 +7,7 @@ export const AIBridgeClientIcon = ({
className,
...props
}: {
client: AIBridgeInterception["client"];
client: string | null;
} & React.ComponentProps<"svg">) => {
const iconClassName = "flex-shrink-0";
// This should be kept in sync with the client names in
@@ -1,5 +1,4 @@
import { CircleQuestionMarkIcon } from "lucide-react";
import type { AIBridgeInterception } from "#/api/typesGenerated";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { cn } from "#/utils/cn";
@@ -8,7 +7,7 @@ export const AIBridgeProviderIcon = ({
className,
...props
}: {
provider: AIBridgeInterception["provider"];
provider: string;
} & React.ComponentProps<"svg">) => {
const iconClassName = "flex-shrink-0";
switch (provider) {
@@ -26,13 +25,6 @@ export const AIBridgeProviderIcon = ({
className={cn(iconClassName, className)}
/>
);
case "anthropic-neue":
return (
<ExternalImage
src="/icon/anthropic.svg"
className={cn(iconClassName, className)}
/>
);
case "copilot":
return (
<ExternalImage
-11
View File
@@ -24,14 +24,3 @@ export const getProviderDisplayName = (provider: string) => {
return "Unknown";
}
};
// FIXME the current AIBridgeProviderIcon uses the claude icon for the
// anthropic provider. while it's still in use in the RequestLogsPage, we need
// to hack around it here, but when we delete that page, we can just swap the
// icon
export const getProviderIconName = (provider: string) => {
if (provider === "anthropic") {
return "anthropic-neue";
}
return provider;
};
-5
View File
@@ -417,10 +417,6 @@ const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
const AIBridgeLayout = lazy(
() => import("./pages/AIBridgePage/AIBridgeLayout"),
);
const AIBridgeRequestLogsPage = lazy(
() => import("./pages/AIBridgePage/RequestLogsPage/RequestLogsPage"),
);
const AIBridgeSessionsLayout = lazy(
() => import("./pages/AIBridgePage/AIBridgeSessionsLayout"),
);
@@ -690,7 +686,6 @@ export const router = createBrowserRouter(
index
element={<Navigate to="/aibridge/sessions" replace />}
/>
<Route path="request-logs" element={<AIBridgeRequestLogsPage />} />
</Route>
<Route path="/aibridge/sessions" element={<AIBridgeSessionsLayout />}>
-75
View File
@@ -5417,81 +5417,6 @@ export const MockDisplayNameTasks = [
},
] satisfies TypesGen.Task[];
export const MockInterception: TypesGen.AIBridgeInterception = {
id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
initiator: {
id: "1ebb7622-e6ea-45b4-b244-dda30afc7238",
username: "testuser",
avatar_url: "https://example.com/avatar.png",
},
provider: "openai",
provider_name: "openai",
model: "gpt-4o",
started_at: "2022-05-17T17:39:01.382927298Z",
ended_at: "2022-05-17T17:39:01.382927298Z",
token_usages: [
{
id: "32e7fd17-24be-46b9-b867-2f0adfd42aff",
interception_id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
provider_response_id: "res_1234567890",
input_tokens: 5,
output_tokens: 1,
cache_read_input_tokens: 3,
cache_write_input_tokens: 1,
metadata: {},
created_at: "2022-05-17T17:39:01.382927298Z",
},
],
metadata: {},
user_prompts: [
{
id: "85154044-818e-4ee4-bac2-87f3ac8f066b",
interception_id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
provider_response_id: "res_1234567890",
prompt: "Hello OpenAI",
metadata: {},
created_at: "2022-05-17T17:39:01.382927298Z",
},
],
tool_usages: [],
api_key_id: "5c1da48a-9eb0-440e-9c82-5bc5692a603d",
client: "Claude Code",
};
export const MockInterceptionAnthropic: TypesGen.AIBridgeInterception = {
...MockInterception,
id: "e5610f5b-2d6c-43db-b1c0-1dfcc6531f04",
provider: "anthropic",
model: "claude-sonnet-4.5",
user_prompts: [
{
id: "c820f31f-0170-4044-8b7c-b1b18747b4fb",
interception_id: "e5610f5b-2d6c-43db-b1c0-1dfcc6531f04",
provider_response_id: "res_2345678901",
prompt: "Hello Anthropic",
metadata: {},
created_at: "2022-05-17T17:39:01.382927298Z",
},
],
};
export const MockInterceptionCopilot: TypesGen.AIBridgeInterception = {
...MockInterception,
id: "22c9d31e-1a1f-464a-b397-562958599aa8",
provider: "copilot",
model: "claude-opus-4-5",
user_prompts: [
{
id: "c6c613d1-177e-416f-95b5-c7f0eeefb922",
interception_id: "22c9d31e-1a1f-464a-b397-562958599aa8",
provider_response_id: "res_3456789012",
prompt: "Hello Copilot",
metadata: {},
created_at: "2022-05-17T17:39:01.382927298Z",
},
],
};
export const MockSession: TypesGen.AIBridgeSession = {
id: "c8f2df8c-149c-43e1-9d51-898daaa2c505",
initiator: {