From 4a07f61c501aa0cadcee4d7aa4f126c74c7872f4 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 12 Jun 2026 07:50:46 +0200 Subject: [PATCH] refactor!: remove interceptions API, request logs view, and associated code (#26213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- coderd/apidoc/docs.go | 225 ------- coderd/apidoc/swagger.json | 221 ------- coderd/database/db2sdk/db2sdk.go | 80 --- coderd/database/db2sdk/db2sdk_test.go | 232 ------- coderd/database/dbauthz/dbauthz.go | 24 - coderd/database/dbauthz/dbauthz_test.go | 28 - coderd/database/dbmetrics/querymetrics.go | 32 - coderd/database/dbmock/dbmock.go | 60 -- coderd/database/modelqueries.go | 110 ---- coderd/database/querier.go | 2 - coderd/database/queries.sql.go | 214 ------ coderd/database/queries/aibridge.sql | 116 ---- coderd/searchquery/search.go | 44 -- codersdk/aibridge.go | 137 ---- docs/ai-coder/ai-gateway/monitoring.md | 23 +- docs/manifest.json | 15 - docs/reference/api/aibridge.md | 108 --- docs/reference/api/schemas.md | 271 -------- docs/reference/cli/aibridge.md | 16 - docs/reference/cli/aibridge_interceptions.md | 16 - .../cli/aibridge_interceptions_list.md | 85 --- docs/reference/cli/index.md | 1 - enterprise/cli/aibridge.go | 181 ----- enterprise/cli/aibridge_test.go | 274 -------- enterprise/cli/root.go | 1 - enterprise/cli/testdata/coder_--help.golden | 1 - .../cli/testdata/coder_aibridge_--help.golden | 12 - ...coder_aibridge_interceptions_--help.golden | 12 - ..._aibridge_interceptions_list_--help.golden | 43 -- enterprise/coderd/aibridge.go | 185 +----- enterprise/coderd/aibridge_test.go | 621 ------------------ site/src/api/api.ts | 32 +- site/src/api/queries/aiBridge.ts | 19 - site/src/api/typesGenerated.ts | 64 -- .../ListSessionsPage/ListSessionsFilter.tsx | 12 +- .../ListSessionsPage/ListSessionsPage.tsx | 6 +- .../ListSessionsPage/ListSessionsRow.tsx | 8 +- .../RequestLogsFilter/RequestLogsFilter.tsx | 54 -- .../RequestLogsPage/RequestLogsPage.tsx | 100 --- .../RequestLogsPageView.stories.tsx | 98 --- .../RequestLogsPage/RequestLogsPageView.tsx | 93 --- .../RequestLogsRow/RequestLogsRow.stories.tsx | 103 --- .../RequestLogsRow/RequestLogsRow.test.ts | 70 -- .../RequestLogsRow/RequestLogsRow.tsx | 387 ----------- .../icons/AIBridgeClientIcon.stories.tsx | 31 - .../SessionSummaryTable.tsx | 11 +- .../SessionTimeline/PromptTable.tsx | 2 +- .../ClientFilter.tsx | 0 .../ModelFilter.tsx | 0 .../ProviderFilter.tsx | 0 .../icons/AIBridgeClientIcon.tsx | 3 +- .../icons/AIBridgeModelIcon.tsx | 0 .../icons/AIBridgeProviderIcon.tsx | 10 +- site/src/pages/AIBridgePage/utils.ts | 11 - site/src/router.tsx | 5 - site/src/testHelpers/entities.ts | 75 --- 56 files changed, 36 insertions(+), 4548 deletions(-) delete mode 100644 docs/reference/cli/aibridge.md delete mode 100644 docs/reference/cli/aibridge_interceptions.md delete mode 100644 docs/reference/cli/aibridge_interceptions_list.md delete mode 100644 enterprise/cli/aibridge.go delete mode 100644 enterprise/cli/aibridge_test.go delete mode 100644 enterprise/cli/testdata/coder_aibridge_--help.golden delete mode 100644 enterprise/cli/testdata/coder_aibridge_interceptions_--help.golden delete mode 100644 enterprise/cli/testdata/coder_aibridge_interceptions_list_--help.golden delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.stories.tsx delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.test.ts delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx delete mode 100644 site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon.stories.tsx rename site/src/pages/AIBridgePage/{RequestLogsPage/RequestLogsFilter => filters}/ClientFilter.tsx (100%) rename site/src/pages/AIBridgePage/{RequestLogsPage/RequestLogsFilter => filters}/ModelFilter.tsx (100%) rename site/src/pages/AIBridgePage/{RequestLogsPage/RequestLogsFilter => filters}/ProviderFilter.tsx (100%) rename site/src/pages/AIBridgePage/{RequestLogsPage => }/icons/AIBridgeClientIcon.tsx (95%) rename site/src/pages/AIBridgePage/{RequestLogsPage => }/icons/AIBridgeModelIcon.tsx (100%) rename site/src/pages/AIBridgePage/{RequestLogsPage => }/icons/AIBridgeProviderIcon.tsx (77%) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 022da464d8..6ed25d6046 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index caf7f1f312..4440fe155b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index f368ab5b02..c4203ff2cc 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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 diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 8f4df7ef56..284cdd88d1 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -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() diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2e4104b3c6..4bdbdcf6cf 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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 diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 409de50cf5..4cbd1a4fdc 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index cee2a97f64..6c873b3841 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 5483e08296..60ddd85446 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 98102aa34f..f0645220f4 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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(), diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 55ed227b2f..63f95141af 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1b30d96415..3cc6884f0b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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) diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index a1b49d25cd..6fbc83b2f5 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -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 * diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index ac2ae5a34a..f90f76040d 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -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{ diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index d04359acb3..d8356a559f 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -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()) diff --git a/docs/ai-coder/ai-gateway/monitoring.md b/docs/ai-coder/ai-gateway/monitoring.md index 8abbc1d4ce..f5d59c908d 100644 --- a/docs/ai-coder/ai-gateway/monitoring.md +++ b/docs/ai-coder/ai-gateway/monitoring.md @@ -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 diff --git a/docs/manifest.json b/docs/manifest.json index b6137b8c34..0dac15fbd8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -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", diff --git a/docs/reference/api/aibridge.md b/docs/reference/api/aibridge.md index ce6ee6cb86..4a5757fe01 100644 --- a/docs/reference/api/aibridge.md +++ b/docs/reference/api/aibridge.md @@ -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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ef805f7ec0..6ffdefb5f9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/docs/reference/cli/aibridge.md b/docs/reference/cli/aibridge.md deleted file mode 100644 index 67e633682d..0000000000 --- a/docs/reference/cli/aibridge.md +++ /dev/null @@ -1,16 +0,0 @@ - -# aibridge - -Manage AI Bridge. - -## Usage - -```console -coder aibridge -``` - -## Subcommands - -| Name | Purpose | -|-----------------------------------------------------------|---------------------------------| -| [interceptions](./aibridge_interceptions.md) | Manage AI Bridge interceptions. | diff --git a/docs/reference/cli/aibridge_interceptions.md b/docs/reference/cli/aibridge_interceptions.md deleted file mode 100644 index 80c2135b07..0000000000 --- a/docs/reference/cli/aibridge_interceptions.md +++ /dev/null @@ -1,16 +0,0 @@ - -# aibridge interceptions - -Manage AI Bridge interceptions. - -## Usage - -```console -coder aibridge interceptions -``` - -## Subcommands - -| Name | Purpose | -|-------------------------------------------------------|---------------------------------------| -| [list](./aibridge_interceptions_list.md) | List AI Bridge interceptions as JSON. | diff --git a/docs/reference/cli/aibridge_interceptions_list.md b/docs/reference/cli/aibridge_interceptions_list.md deleted file mode 100644 index 796032edbe..0000000000 --- a/docs/reference/cli/aibridge_interceptions_list.md +++ /dev/null @@ -1,85 +0,0 @@ - -# aibridge interceptions list - -List AI Bridge interceptions as JSON. - -## Usage - -```console -coder aibridge interceptions list [flags] -``` - -## Options - -### --initiator - -| | | -|------|---------------------| -| Type | string | - -Only return interceptions initiated by this user. Accepts a user ID, username, or "me". - -### --started-before - -| | | -|------|---------------------| -| Type | 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. "2006-01-02T15:04:05Z07:00". - -### --started-after - -| | | -|------|---------------------| -| Type | 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. "2006-01-02T15:04:05Z07:00". - -### --provider - -| | | -|------|---------------------| -| Type | string | - -Only return interceptions from this provider. - -### --provider-name - -| | | -|------|---------------------| -| Type | string | - -Only return interceptions from the named provider. - -### --model - -| | | -|------|---------------------| -| Type | string | - -Only return interceptions from this model. - -### --client - -| | | -|------|---------------------| -| Type | string | - -Only return interceptions from this client. - -### --after-id - -| | | -|------|---------------------| -| Type | string | - -The ID of the last result on the previous page to use as a pagination cursor. - -### --limit - -| | | -|---------|------------------| -| Type | int | -| Default | 100 | - -The limit of results to return. Must be between 1 and 1000. diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index bbb7e85a31..a25269fd06 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -72,7 +72,6 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [groups](./groups.md) | Manage groups | | [prebuilds](./prebuilds.md) | Manage Coder prebuilds | | [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | -| [aibridge](./aibridge.md) | Manage AI Bridge. | ## Options diff --git a/enterprise/cli/aibridge.go b/enterprise/cli/aibridge.go deleted file mode 100644 index d809580bd3..0000000000 --- a/enterprise/cli/aibridge.go +++ /dev/null @@ -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 - }, - } -} diff --git a/enterprise/cli/aibridge_test.go b/enterprise/cli/aibridge_test.go deleted file mode 100644 index 018d7bb0c9..0000000000 --- a/enterprise/cli/aibridge_test.go +++ /dev/null @@ -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) - } -} diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index b211c0d598..720624031a 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -27,7 +27,6 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command { r.prebuilds(), r.provisionerd(), r.externalWorkspaces(), - r.aibridge(), } } diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index 373a3609e4..4e392a8dd6 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -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 diff --git a/enterprise/cli/testdata/coder_aibridge_--help.golden b/enterprise/cli/testdata/coder_aibridge_--help.golden deleted file mode 100644 index 5fdb98d21a..0000000000 --- a/enterprise/cli/testdata/coder_aibridge_--help.golden +++ /dev/null @@ -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. diff --git a/enterprise/cli/testdata/coder_aibridge_interceptions_--help.golden b/enterprise/cli/testdata/coder_aibridge_interceptions_--help.golden deleted file mode 100644 index 49e36fb712..0000000000 --- a/enterprise/cli/testdata/coder_aibridge_interceptions_--help.golden +++ /dev/null @@ -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. diff --git a/enterprise/cli/testdata/coder_aibridge_interceptions_list_--help.golden b/enterprise/cli/testdata/coder_aibridge_interceptions_list_--help.golden deleted file mode 100644 index eaf45dc169..0000000000 --- a/enterprise/cli/testdata/coder_aibridge_interceptions_list_--help.golden +++ /dev/null @@ -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. diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index cb4171ee2b..02a52c1495 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -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 diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index b239cce860..64682cd20f 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -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) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8976bf901c..d2b09d9b40 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3047,13 +3047,17 @@ class ApiMethods { }); }; - getAIBridgeInterceptions = async (options: SearchParamOptions) => { - const url = getURLWithSearchParams( - "/api/v2/aibridge/interceptions", - options, - ); - const response = - await this.axios.get(url); + getAIBridgeModels = async (options: SearchParamOptions) => { + const url = getURLWithSearchParams("/api/v2/aibridge/models", options); + + const response = await this.axios.get(url); + return response.data; + }; + + getAIBridgeClients = async (options: SearchParamOptions) => { + const url = getURLWithSearchParams("/api/v2/aibridge/clients", options); + + const response = await this.axios.get(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(url); - return response.data; - }; - - getAIBridgeClients = async (options: SearchParamOptions) => { - const url = getURLWithSearchParams("/api/v2/aibridge/clients", options); - - const response = await this.axios.get(url); - return response.data; - }; - getAIProviders = async (): Promise => { const response = await this.axios.get( "/api/v2/ai/providers", diff --git a/site/src/api/queries/aiBridge.ts b/site/src/api/queries/aiBridge.ts index 45a9d11fce..88c04be694 100644 --- a/site/src/api/queries/aiBridge.ts +++ b/site/src/api/queries/aiBridge.ts @@ -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 => { - 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 => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a734f9a7d8..34d8c6f18d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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; - 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; - 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; - 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; - readonly created_at: string; -} - // From codersdk/deployment.go export type AIBudgetPeriod = "month"; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx index 516caae4bb..1ff6c32043 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsFilter.tsx @@ -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; diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx index b12fd6a491..0ccba38c80 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPage.tsx @@ -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 = () => { diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx index 92dd035178..a655964575 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx @@ -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 = ({
diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx deleted file mode 100644 index 3144e26ad7..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsFilter/RequestLogsFilter.tsx +++ /dev/null @@ -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; - error?: unknown; - menus: { - user: UserFilterMenu; - provider: ProviderFilterMenu; - model: ModelFilterMenu; - client: ClientFilterMenu; - }; -} - -export const RequestLogsFilter: FC = ({ - filter, - error, - menus, -}) => { - return ( - } - isLoading={menus.user.isInitializing} - presets={[ - { - name: "All requests", - query: "", - }, - { - name: "My requests", - query: "initiator:me", - }, - ]} - error={error} - options={ - <> - - - - - - } - /> - ); -}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx deleted file mode 100644 index 469e56a352..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPage.tsx +++ /dev/null @@ -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 ( - - {pageTitle("Request Logs", "AI Gateway")} - - - - ); -}; - -export default RequestLogsPage; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx deleted file mode 100644 index 85415e0eee..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.stories.tsx +++ /dev/null @@ -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["filterProps"]; - -const defaultFilterProps = getDefaultFilterProps({ - 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 = { - title: "pages/AIBridgePage/RequestLogsPageView", - component: RequestLogsPageView, - args: {}, -}; - -export default meta; -type Story = StoryObj; - -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, - }, -}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx deleted file mode 100644 index 69c8635743..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsPageView.tsx +++ /dev/null @@ -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; -} - -export const RequestLogsPageView: FC = ({ - isLoading, - isRequestLogsEntitled, - isRequestLogsEnabled, - interceptions, - interceptionsQuery, - filterProps, -}) => { - if (!isRequestLogsEntitled) { - return ; - } - - if (!isRequestLogsEnabled) { - return ; - } - - return ( - <> - - Visit the new{" "} - - AI Sessions - {" "} - page for a more comprehensive view of AI activity. - - - - - - - - - Timestamp - Initiator - Tokens - Client - Model - Tool Calls - - - - {isLoading ? ( - - ) : interceptions?.length === 0 ? ( - - ) : ( - interceptions?.map((interception) => ( - - )) - )} - -
-
- - ); -}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.stories.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.stories.tsx deleted file mode 100644 index be3ac46d11..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.stories.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { userEvent, within } from "storybook/test"; -import { Table, TableBody } from "#/components/Table/Table"; -import { MockInterception } from "#/testHelpers/entities"; -import { RequestLogsRow } from "./RequestLogsRow"; - -const meta: Meta = { - title: "pages/AIBridgePage/RequestLogsRow", - component: RequestLogsRow, - decorators: [ - (Story) => ( - - - - -
- ), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Close: Story = { - args: { - interception: MockInterception, - }, -}; - -export const OpenWithToolCallAndMetadata: Story = { - args: { - interception: { - ...MockInterception, - token_usages: [ - { - ...MockInterception.token_usages[0], - input_tokens: 1024, - output_tokens: 256, - metadata: { - reasoning_tokens: 128, - cached_tokens: 512, - }, - }, - ], - tool_usages: [ - { - id: "a1b2c3d4-0000-0000-0000-000000000001", - interception_id: MockInterception.id, - provider_response_id: "res_tool_001", - server_url: "https://mcp.example.com/tools", - tool: "read_file", - input: JSON.stringify({ path: "/home/coder/project/main.go" }), - injected: false, - invocation_error: "", - metadata: {}, - created_at: MockInterception.started_at, - }, - ], - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const row = canvas.getAllByRole("row")[0]; - await userEvent.click(row); - }, -}; - -export const OpenWithLongToolCall: Story = { - args: { - interception: { - ...MockInterception, - tool_usages: [ - { - id: "a1b2c3d4-0000-0000-0000-000000000002", - interception_id: MockInterception.id, - provider_response_id: "res_tool_002", - server_url: "https://mcp.example.com/tools", - tool: "Write", - input: JSON.stringify({ - command: { - file_path: - "/home/coder/coder/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx", - // extremely long tool call content - content: - 'import type { MinimalUser } from "#/api/typesGenerated";\nimport { Avatar } from "#/components/Avatar/Avatar";\nimport { Badge } from "#/components/Badge/Badge";\nimport { AIBridgeClientIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";\nimport { AIBridgeProviderIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";\nimport { cn } from "#/utils/cn";\nimport { formatDateTime } from "#/utils/time";\nimport { TokenBadges } from "../TokenBadges";\nimport { getProviderDisplayName, getProviderIconName } from "../utils";\n\nconst Separator = () =\u003e \u003cdiv className="border-0 border-t border-solid my-1" /\u003e;\n\ninterface SessionSummaryTableProps {\n\tsessionId: string;\n\tstartTime: Date;\n\tendTime?: Date;\n\tinitiator: MinimalUser;\n\tclient: string;\n\tproviders: readonly string[];\n\tinputTokens: number;\n\toutputTokens: number;\n\tthreadCount: number;\n\ttoolCallCount: number;\n\ttokenUsageMetadata?: Record\u003cstring, unknown\u003e;\n}\n\nexport const SessionSummaryTable = ({\n\tsessionId,\n\tstartTime,\n\tendTime,\n\tinitiator,\n\tproviders,\n\tclient,\n\tinputTokens,\n\toutputTokens,\n\tthreadCount,\n\ttoolCallCount,\n\ttokenUsageMetadata,\n}: SessionSummaryTableProps) =\u003e {\n\tconst durationInMs =\n\t\tendTime !== undefined\n\t\t\t? new Date(endTime).getTime() - new Date(startTime).getTime()\n\t\t\t: undefined;\n\n\treturn (\n\t\t\u003cdl className="text-sm text-content-secondary m-0 whitespace-nowrap grid grid-cols-[auto_1fr] gap-y-2"\u003e\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eSession ID\u003c/dt\u003e\n\t\t\t\u003cdd\n\t\t\t\tclassName="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end text-xs font-mono"\n\t\t\t\ttitle={sessionId}\n\t\t\t\u003e\n\t\t\t\t\u003cspan className="truncate w-full text-right"\u003e{sessionId}\u003c/span\u003e\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eStart time\u003c/dt\u003e\n\t\t\t\u003cdd\n\t\t\t\tclassName="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end text-xs font-mono"\n\t\t\t\ttitle={formatDateTime(startTime)}\n\t\t\t\u003e\n\t\t\t\t{formatDateTime(startTime)}\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eEnd time\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end text-xs font-mono"\u003e\n\t\t\t\t{endTime ? formatDateTime(endTime) : "—"}\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eDuration\u003c/dt\u003e\n\t\t\t\u003cdd\n\t\t\t\tclassName="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end text-xs font-mono"\n\t\t\t\ttitle={durationInMs !== undefined ? `${durationInMs} ms` : undefined}\n\t\t\t\u003e\n\t\t\t\t{durationInMs !== undefined\n\t\t\t\t\t? `${Math.round(durationInMs / 1000)} s`\n\t\t\t\t\t: "—"}\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eInitiator\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end"\u003e\n\t\t\t\t\u003cdiv className="flex w-full min-w-0 items-center justify-end gap-2"\u003e\n\t\t\t\t\t\u003cAvatar\n\t\t\t\t\t\tsize="sm"\n\t\t\t\t\t\tsrc={initiator.avatar_url}\n\t\t\t\t\t\tfallback={initiator.name}\n\t\t\t\t\t/\u003e\n\t\t\t\t\t\u003cspan className="truncate min-w-0 text-right" title={initiator.name}\u003e\n\t\t\t\t\t\t{initiator.name}\n\t\t\t\t\t\u003c/span\u003e\n\t\t\t\t\u003c/div\u003e\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eClient\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end"\u003e\n\t\t\t\t\u003cBadge className="gap-1.5 max-w-full min-w-0 overflow-hidden"\u003e\n\t\t\t\t\t\u003cdiv className="flex-shrink-0 flex items-center"\u003e\n\t\t\t\t\t\t\u003cAIBridgeClientIcon client={client} className="size-icon-xs" /\u003e\n\t\t\t\t\t\u003c/div\u003e\n\t\t\t\t\t\u003cspan className="truncate min-w-0 flex-1" title={client ?? "Unknown"}\u003e\n\t\t\t\t\t\t{client ?? "Unknown"}\n\t\t\t\t\t\u003c/span\u003e\n\t\t\t\t\u003c/Badge\u003e\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal self-start"\u003e\n\t\t\t\tProvider\n\t\t\t\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end"\u003e\n\t\t\t\t{providers.map((p) =\u003e (\n\t\t\t\t\t\u003cBadge key={p} className="gap-1.5 max-w-full min-w-0 overflow-hidden"\u003e\n\t\t\t\t\t\t\u003cAIBridgeProviderIcon\n\t\t\t\t\t\t\tprovider={getProviderIconName(p)}\n\t\t\t\t\t\t\tclassName="size-icon-xs"\n\t\t\t\t\t\t/\u003e\n\t\t\t\t\t\t\u003cspan\n\t\t\t\t\t\t\tclassName="truncate min-w-0 flex-1"\n\t\t\t\t\t\t\ttitle={getProviderDisplayName(p)}\n\t\t\t\t\t\t\u003e\n\t\t\t\t\t\t\t{getProviderDisplayName(p)}\n\t\t\t\t\t\t\u003c/span\u003e\n\t\t\t\t\t\u003c/Badge\u003e\n\t\t\t\t))}\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdiv className="col-span-2"\u003e\n\t\t\t\t\u003cSeparator /\u003e\n\t\t\t\u003c/div\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003e\n\t\t\t\tIn / out tokens\n\t\t\t\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end"\u003e\n\t\t\t\t\u003cTokenBadges\n\t\t\t\t\tinputTokens={inputTokens}\n\t\t\t\t\toutputTokens={outputTokens}\n\t\t\t\t\ttokenUsageMetadata={tokenUsageMetadata}\n\t\t\t\t/\u003e\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eThreads\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end"\u003e\n\t\t\t\t\u003cBadge\u003e{threadCount}\u003c/Badge\u003e\n\t\t\t\u003c/dd\u003e\n\n\t\t\t\u003cdt className="h-6 inline-flex items-center font-normal"\u003eTool calls\u003c/dt\u003e\n\t\t\t\u003cdd className="ml-0 text-content-primary h-6 flex min-w-0 items-center justify-end"\u003e\n\t\t\t\t\u003cBadge\u003e{toolCallCount}\u003c/Badge\u003e\n\t\t\t\u003c/dd\u003e\n\t\t\u003c/dl\u003e\n\t);\n};\n', - }, - explanation: "Write SessionSummaryTable", - require_user_approval: false, - }), - injected: false, - invocation_error: "", - metadata: {}, - created_at: MockInterception.started_at, - }, - ], - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const row = canvas.getAllByRole("row")[0]; - await userEvent.click(row); - }, -}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.test.ts b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.test.ts deleted file mode 100644 index c4c27245a8..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.test.ts +++ /dev/null @@ -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]); - }); -}); diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx deleted file mode 100644 index d21513bcb9..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/RequestLogsRow/RequestLogsRow.tsx +++ /dev/null @@ -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 - | Array>; - -/** - * 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 = {}; - 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 = ({ 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 ( - <> - setIsOpen(!isOpen)} - hover - > - -
- - ({isOpen ? "Hide" : "Show more"}) - {formatDate(new Date(interception.started_at))} -
-
- -
-
- -
- {interception.initiator.name ?? interception.initiator.username} -
-
-
-
- -
- -
-
- -
- - - - -
- -
- - {interception.client ?? "Unknown"} - -
-
- {interception.client} -
-
-
-
- -
- - - - -
- -
- - {interception.model} - -
-
- {interception.model} -
-
-
-
- - {toolCalls} - -
- {isOpen && ( - - -
-
-
Request ID:
-
{interception.id}
- -
Start Time:
-
- {formatDate(new Date(interception.started_at))} -
- - {interception.ended_at && ( - <> -
End Time:
-
- {formatDate(new Date(interception.ended_at))} -
- - )} - - {(duration || duration === 0) && ( - <> -
Duration:
-
- {humanDuration(duration)} -
- - )} - -
Initiator:
-
- - - {interception.initiator.name ?? - interception.initiator.username} - -
- -
Client:
-
- -
- -
- - {interception.client ?? "Unknown"} - -
-
- -
Model:
-
- -
- -
- - {interception.model} - -
-
- -
Tool Calls:
-
- {interception.tool_usages.length} -
- -
Input/Output Tokens:
-
-
- -
-
-
- - {interception.user_prompts.length > 0 && ( -
-
Prompts
-
- {interception.user_prompts.map((prompt) => ( - {prompt.prompt} - ))} -
-
- )} - - {interception.tool_usages.length > 0 && ( -
-
Tool Usages
-
- {interception.tool_usages.map((toolUsage) => { - return ( -
-
{toolUsage.tool}
-
-
-
{toolUsage.input}
- {toolUsage.invocation_error && ( -
- {toolUsage.invocation_error} -
- )} -
-
-
- ); - })} -
-
- )} - - {tokenUsagesMetadata !== null && ( -
-
Token Usage Metadata
-
-
{JSON.stringify(tokenUsagesMetadata, null, 2)}
-
-
- )} -
-
-
- )} - - ); -}; diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon.stories.tsx b/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon.stories.tsx deleted file mode 100644 index f94c75c205..0000000000 --- a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AIBridgeClientIcon } from "./AIBridgeClientIcon"; - -const meta: Meta = { - title: "pages/AIBridgePage/AIBridgeClientIcon", - component: AIBridgeClientIcon, - args: { - className: "size-8", - }, -}; - -export default meta; -type Story = StoryObj; - -export const OpenCode: Story = { - args: { - client: "OpenCode", - }, -}; - -export const ClaudeCode: Story = { - args: { - client: "Claude Code", - }, -}; - -export const Unknown: Story = { - args: { - client: "Unknown", - }, -}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx index bd14441ff1..7d6feaf93d 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx @@ -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 = () =>
; @@ -123,10 +123,7 @@ export const SessionSummaryTable = ({ key={p} className="gap-1.5 max-w-full min-w-0 overflow-hidden" > - + ) => { const iconClassName = "flex-shrink-0"; // This should be kept in sync with the client names in diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon.tsx b/site/src/pages/AIBridgePage/icons/AIBridgeModelIcon.tsx similarity index 100% rename from site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon.tsx rename to site/src/pages/AIBridgePage/icons/AIBridgeModelIcon.tsx diff --git a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx b/site/src/pages/AIBridgePage/icons/AIBridgeProviderIcon.tsx similarity index 77% rename from site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx rename to site/src/pages/AIBridgePage/icons/AIBridgeProviderIcon.tsx index 7af1b4593d..5605d1c531 100644 --- a/site/src/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon.tsx +++ b/site/src/pages/AIBridgePage/icons/AIBridgeProviderIcon.tsx @@ -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 ( - - ); case "copilot": return ( { 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; -}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 1a6da237c6..ce40bbd982 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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={} /> - } /> }> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2972fc97d4..d7e85cf1cd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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: {