mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor!: remove interceptions API, request logs view, and associated code (#26213)
## Summary Removes the deprecated `/api/v2/aibridge/interceptions` endpoint and the Request Logs frontend page, both replaced by the session-based view. Closes https://linear.app/codercom/issue/AIGOV-266 Closes https://linear.app/codercom/issue/AIGOV-324 ## Changes ### Backend - Remove `GET /api/v2/aibridge/interceptions` HTTP handler and route - Remove SDK types and client method (`AIBridgeInterception`, `AIBridgeTokenUsage`, `AIBridgeUserPrompt`, `AIBridgeToolUsage`, `AIBridgeListInterceptionsResponse`, `AIBridgeListInterceptionsFilter`) - Remove SQL queries `CountAIBridgeInterceptions` and `ListAIBridgeInterceptions` - Remove `searchquery.AIBridgeInterceptions` parser - Remove dbauthz wrappers, in-memory implementations, metrics, and mocks for the interceptions list queries - Remove the `coder aibridge interceptions list` CLI command and golden files - Regenerate API docs, swagger, mocks, and metrics The `/models`, `/clients`, and `/sessions` endpoints stay; the sessions list page still consumes all three. ### Frontend - Delete the entire `RequestLogsPage/` directory (page, view, row, filter, stories, tests) - Remove the `/aibridge/request-logs` route and its lazy import - Remove the `getAIBridgeInterceptions` API method, `paginatedInterceptions` query, and mock interception entities - `git mv` the shared filter and icon components used by the sessions pages: - `RequestLogsPage/RequestLogsFilter/{Client,Model,Provider}Filter.tsx` → `AIBridgePage/filters/` - `RequestLogsPage/icons/AIBridge{Client,Model,Provider}Icon.tsx` → `AIBridgePage/icons/` - Drop the `getProviderIconName` hack and the duplicate `anthropic-neue` icon case now that the FIXME no longer applies ## Commits 1. `refactor: remove interceptions API and request logs view` — the bulk removal, with explicit renames for the shared filter/icon files. 2. `refactor(site/src/pages/AIBridgePage): drop getProviderIconName hack` — cleanup of the FIXME that depended on RequestLogsPage existing. > [!NOTE] > Generated by Coder Agents on behalf of @dannykopping
This commit is contained in:
Generated
-225
@@ -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": {
|
||||
|
||||
Generated
-221
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6322,34 +6322,6 @@ func (s *MethodTestSuite) TestAIBridge() {
|
||||
check.Args(intID).Asserts(intc, policy.ActionRead).Returns(tools)
|
||||
}))
|
||||
|
||||
s.Run("ListAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.ListAIBridgeInterceptionsParams{}
|
||||
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
|
||||
// No asserts here because SQLFilter.
|
||||
check.Args(params).Asserts()
|
||||
}))
|
||||
|
||||
s.Run("ListAuthorizedAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.ListAIBridgeInterceptionsParams{}
|
||||
db.EXPECT().ListAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return([]database.ListAIBridgeInterceptionsRow{}, nil).AnyTimes()
|
||||
// No asserts here because SQLFilter.
|
||||
check.Args(params, emptyPreparedAuthorized{}).Asserts()
|
||||
}))
|
||||
|
||||
s.Run("CountAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.CountAIBridgeInterceptionsParams{}
|
||||
db.EXPECT().CountAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return(int64(0), nil).AnyTimes()
|
||||
// No asserts here because SQLFilter.
|
||||
check.Args(params).Asserts()
|
||||
}))
|
||||
|
||||
s.Run("CountAuthorizedAIBridgeInterceptions", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.CountAIBridgeInterceptionsParams{}
|
||||
db.EXPECT().CountAuthorizedAIBridgeInterceptions(gomock.Any(), params, gomock.Any()).Return(int64(0), nil).AnyTimes()
|
||||
// No asserts here because SQLFilter.
|
||||
check.Args(params, emptyPreparedAuthorized{}).Asserts()
|
||||
}))
|
||||
|
||||
s.Run("ListAIBridgeModels", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.ListAIBridgeModelsParams{}
|
||||
db.EXPECT().ListAuthorizedAIBridgeModels(gomock.Any(), params, gomock.Any()).Return([]string{}, nil).AnyTimes()
|
||||
|
||||
-32
@@ -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)
|
||||
|
||||
Generated
-60
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Generated
-2
@@ -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)
|
||||
|
||||
Generated
-214
@@ -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)
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-108
@@ -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
|
||||
|
||||
Generated
-271
@@ -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
|
||||
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# aibridge
|
||||
|
||||
Manage AI Bridge.
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder aibridge
|
||||
```
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Name | Purpose |
|
||||
|-----------------------------------------------------------|---------------------------------|
|
||||
| [<code>interceptions</code>](./aibridge_interceptions.md) | Manage AI Bridge interceptions. |
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# aibridge interceptions
|
||||
|
||||
Manage AI Bridge interceptions.
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder aibridge interceptions
|
||||
```
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Name | Purpose |
|
||||
|-------------------------------------------------------|---------------------------------------|
|
||||
| [<code>list</code>](./aibridge_interceptions_list.md) | List AI Bridge interceptions as JSON. |
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# aibridge interceptions list
|
||||
|
||||
List AI Bridge interceptions as JSON.
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder aibridge interceptions list [flags]
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### --initiator
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions initiated by this user. Accepts a user ID, username, or "me".
|
||||
|
||||
### --started-before
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions started before this time. Must be after 'started-after' if set. Accepts a time in the RFC 3339 format, e.g. "2006-01-02T15:04:05Z07:00".
|
||||
|
||||
### --started-after
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions started after this time. Must be before 'started-before' if set. Accepts a time in the RFC 3339 format, e.g. "2006-01-02T15:04:05Z07:00".
|
||||
|
||||
### --provider
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions from this provider.
|
||||
|
||||
### --provider-name
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions from the named provider.
|
||||
|
||||
### --model
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions from this model.
|
||||
|
||||
### --client
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Only return interceptions from this client.
|
||||
|
||||
### --after-id
|
||||
|
||||
| | |
|
||||
|------|---------------------|
|
||||
| Type | <code>string</code> |
|
||||
|
||||
The ID of the last result on the previous page to use as a pagination cursor.
|
||||
|
||||
### --limit
|
||||
|
||||
| | |
|
||||
|---------|------------------|
|
||||
| Type | <code>int</code> |
|
||||
| Default | <code>100</code> |
|
||||
|
||||
The limit of results to return. Must be between 1 and 1000.
|
||||
Generated
-1
@@ -72,7 +72,6 @@ Coder — A tool for provisioning self-hosted development environments with Terr
|
||||
| [<code>groups</code>](./groups.md) | Manage groups |
|
||||
| [<code>prebuilds</code>](./prebuilds.md) | Manage Coder prebuilds |
|
||||
| [<code>external-workspaces</code>](./external-workspaces.md) | Create or manage external workspaces |
|
||||
| [<code>aibridge</code>](./aibridge.md) | Manage AI Bridge. |
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command {
|
||||
r.prebuilds(),
|
||||
r.provisionerd(),
|
||||
r.externalWorkspaces(),
|
||||
r.aibridge(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder aibridge
|
||||
|
||||
Manage AI Bridge.
|
||||
|
||||
SUBCOMMANDS:
|
||||
interceptions Manage AI Bridge interceptions.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -1,12 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder aibridge interceptions
|
||||
|
||||
Manage AI Bridge interceptions.
|
||||
|
||||
SUBCOMMANDS:
|
||||
list List AI Bridge interceptions as JSON.
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -1,43 +0,0 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder aibridge interceptions list [flags]
|
||||
|
||||
List AI Bridge interceptions as JSON.
|
||||
|
||||
OPTIONS:
|
||||
--after-id string
|
||||
The ID of the last result on the previous page to use as a pagination
|
||||
cursor.
|
||||
|
||||
--client string
|
||||
Only return interceptions from this client.
|
||||
|
||||
--initiator string
|
||||
Only return interceptions initiated by this user. Accepts a user ID,
|
||||
username, or "me".
|
||||
|
||||
--limit int (default: 100)
|
||||
The limit of results to return. Must be between 1 and 1000.
|
||||
|
||||
--model string
|
||||
Only return interceptions from this model.
|
||||
|
||||
--provider string
|
||||
Only return interceptions from this provider.
|
||||
|
||||
--provider-name string
|
||||
Only return interceptions from the named provider.
|
||||
|
||||
--started-after string
|
||||
Only return interceptions started after this time. Must be before
|
||||
'started-before' if set. Accepts a time in the RFC 3339 format, e.g.
|
||||
"====[timestamp]=====07:00".
|
||||
|
||||
--started-before string
|
||||
Only return interceptions started before this time. Must be after
|
||||
'started-after' if set. Accepts a time in the RFC 3339 format, e.g.
|
||||
"====[timestamp]=====07:00".
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
@@ -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
|
||||
|
||||
@@ -16,13 +16,11 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
entaudit "github.com/coder/coder/v2/enterprise/audit"
|
||||
"github.com/coder/coder/v2/enterprise/audit/backends"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
@@ -31,625 +29,6 @@ import (
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func TestAIBridgeListInterceptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RequiresLicenseFeature", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
client, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
// No aibridge feature
|
||||
Features: license.Features{},
|
||||
},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is irrelevant here.
|
||||
_, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
require.Equal(t, "AI Gateway is a Premium feature. Contact sales!", sdkErr.Message)
|
||||
})
|
||||
|
||||
t.Run("EmptyDB", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
//nolint:gocritic // Owner role is irrelevant here.
|
||||
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res.Results)
|
||||
})
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user1, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
user1Visible := database.VisibleUser{
|
||||
ID: user1.ID,
|
||||
Username: user1.Username,
|
||||
Name: user1.Name,
|
||||
AvatarURL: user1.AvatarURL,
|
||||
}
|
||||
|
||||
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
user2Visible := database.VisibleUser{
|
||||
ID: user2.ID,
|
||||
Username: user2.Username,
|
||||
Name: user2.Name,
|
||||
AvatarURL: user2.AvatarURL,
|
||||
}
|
||||
|
||||
// Insert a bunch of test data.
|
||||
now := dbtime.Now()
|
||||
i1ApiKey := sql.NullString{String: "some-api-key", Valid: true}
|
||||
i1EndedAt := now.Add(-time.Hour + time.Minute)
|
||||
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
APIKeyID: i1ApiKey,
|
||||
InitiatorID: user1.ID,
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
}, &i1EndedAt)
|
||||
i1tok1 := dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
|
||||
InterceptionID: i1.ID,
|
||||
CreatedAt: now,
|
||||
})
|
||||
i1tok2 := dbgen.AIBridgeTokenUsage(t, db, database.InsertAIBridgeTokenUsageParams{
|
||||
InterceptionID: i1.ID,
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
})
|
||||
i1up1 := dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
|
||||
InterceptionID: i1.ID,
|
||||
CreatedAt: now,
|
||||
})
|
||||
i1up2 := dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
|
||||
InterceptionID: i1.ID,
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
})
|
||||
i1tool1 := dbgen.AIBridgeToolUsage(t, db, database.InsertAIBridgeToolUsageParams{
|
||||
InterceptionID: i1.ID,
|
||||
CreatedAt: now,
|
||||
})
|
||||
i1tool2 := dbgen.AIBridgeToolUsage(t, db, database.InsertAIBridgeToolUsageParams{
|
||||
InterceptionID: i1.ID,
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
})
|
||||
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: user2.ID,
|
||||
StartedAt: now,
|
||||
}, &now)
|
||||
|
||||
// Convert to SDK types for response comparison.
|
||||
// You may notice that the ordering of the inner arrays are ASC, this is
|
||||
// intentional.
|
||||
i1SDK := db2sdk.AIBridgeInterception(i1, user1Visible, []database.AIBridgeTokenUsage{i1tok2, i1tok1}, []database.AIBridgeUserPrompt{i1up2, i1up1}, []database.AIBridgeToolUsage{i1tool2, i1tool1})
|
||||
i2SDK := db2sdk.AIBridgeInterception(i2, user2Visible, nil, nil, nil)
|
||||
|
||||
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Results, 2)
|
||||
require.Equal(t, i2SDK.ID, res.Results[0].ID)
|
||||
require.Equal(t, i1SDK.ID, res.Results[1].ID)
|
||||
|
||||
require.Equal(t, &i1ApiKey.String, i1SDK.APIKeyID)
|
||||
require.Nil(t, i2SDK.APIKeyID)
|
||||
|
||||
// Normalize timestamps in the response so we can compare the whole
|
||||
// thing easily.
|
||||
res.Results[0].StartedAt = i2SDK.StartedAt
|
||||
res.Results[1].StartedAt = i1SDK.StartedAt
|
||||
require.Len(t, res.Results[1].TokenUsages, 2)
|
||||
require.Equal(t, i1SDK.TokenUsages[0].ID, res.Results[1].TokenUsages[0].ID)
|
||||
require.Equal(t, i1SDK.TokenUsages[1].ID, res.Results[1].TokenUsages[1].ID)
|
||||
res.Results[1].TokenUsages[0].CreatedAt = i1SDK.TokenUsages[0].CreatedAt
|
||||
res.Results[1].TokenUsages[1].CreatedAt = i1SDK.TokenUsages[1].CreatedAt
|
||||
require.Len(t, res.Results[1].UserPrompts, 2)
|
||||
require.Equal(t, i1SDK.UserPrompts[0].ID, res.Results[1].UserPrompts[0].ID)
|
||||
require.Equal(t, i1SDK.UserPrompts[1].ID, res.Results[1].UserPrompts[1].ID)
|
||||
res.Results[1].UserPrompts[0].CreatedAt = i1SDK.UserPrompts[0].CreatedAt
|
||||
res.Results[1].UserPrompts[1].CreatedAt = i1SDK.UserPrompts[1].CreatedAt
|
||||
require.Len(t, res.Results[1].ToolUsages, 2)
|
||||
require.Equal(t, i1SDK.ToolUsages[0].ID, res.Results[1].ToolUsages[0].ID)
|
||||
require.Equal(t, i1SDK.ToolUsages[1].ID, res.Results[1].ToolUsages[1].ID)
|
||||
res.Results[1].ToolUsages[0].CreatedAt = i1SDK.ToolUsages[0].CreatedAt
|
||||
res.Results[1].ToolUsages[1].CreatedAt = i1SDK.ToolUsages[1].CreatedAt
|
||||
|
||||
// Time comparison
|
||||
require.Len(t, res.Results, 2)
|
||||
require.Equal(t, res.Results[0].ID, i2SDK.ID)
|
||||
require.NotNil(t, res.Results[0].EndedAt)
|
||||
require.WithinDuration(t, now, *res.Results[0].EndedAt, 5*time.Second)
|
||||
res.Results[0].EndedAt = i2SDK.EndedAt
|
||||
require.NotNil(t, res.Results[1].EndedAt)
|
||||
res.Results[1].EndedAt = i1SDK.EndedAt
|
||||
|
||||
require.Equal(t, []codersdk.AIBridgeInterception{i2SDK, i1SDK}, res.Results)
|
||||
})
|
||||
|
||||
t.Run("Pagination", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
allInterceptionIDs := make([]uuid.UUID, 0, 20)
|
||||
|
||||
// Create 10 interceptions with the same started_at time. The returned
|
||||
// order for these should still be deterministic.
|
||||
now := dbtime.Now()
|
||||
for i := range 10 {
|
||||
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.UUID{byte(i)},
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
}, &now)
|
||||
allInterceptionIDs = append(allInterceptionIDs, interception.ID)
|
||||
}
|
||||
|
||||
// Create 10 interceptions with a random started_at time.
|
||||
for i := range 10 {
|
||||
randomOffset, err := cryptorand.Intn(10000)
|
||||
require.NoError(t, err)
|
||||
randomOffsetDur := time.Duration(randomOffset) * time.Second
|
||||
endedAt := now.Add(randomOffsetDur + time.Minute)
|
||||
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.UUID{byte(i + 10)},
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(randomOffsetDur),
|
||||
}, &endedAt)
|
||||
allInterceptionIDs = append(allInterceptionIDs, interception.ID)
|
||||
}
|
||||
|
||||
// Try to fetch with an invalid limit.
|
||||
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
|
||||
Pagination: codersdk.Pagination{
|
||||
Limit: 1001,
|
||||
},
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Contains(t, sdkErr.Message, "Invalid pagination limit value.")
|
||||
require.Empty(t, res.Results)
|
||||
|
||||
// Try to fetch with both after_id and offset pagination.
|
||||
res, err = client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
|
||||
Pagination: codersdk.Pagination{
|
||||
AfterID: allInterceptionIDs[0],
|
||||
Offset: 1,
|
||||
},
|
||||
})
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Contains(t, sdkErr.Message, "Query parameters have invalid values")
|
||||
require.Contains(t, sdkErr.Detail, "Cannot use both after_id and offset pagination in the same request.")
|
||||
|
||||
// Iterate over all interceptions using both cursor and offset
|
||||
// pagination modes.
|
||||
for _, paginationMode := range []string{"after_id", "offset"} {
|
||||
t.Run(paginationMode, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Get all interceptions one by one using the given pagination
|
||||
// mode.
|
||||
getAllInterceptionsOneByOne := func() []uuid.UUID {
|
||||
interceptionIDs := []uuid.UUID{}
|
||||
for {
|
||||
pagination := codersdk.Pagination{
|
||||
Limit: 1,
|
||||
}
|
||||
if paginationMode == "after_id" {
|
||||
if len(interceptionIDs) > 0 {
|
||||
pagination.AfterID = interceptionIDs[len(interceptionIDs)-1]
|
||||
}
|
||||
} else {
|
||||
pagination.Offset = len(interceptionIDs)
|
||||
}
|
||||
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
|
||||
Pagination: pagination,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if len(res.Results) == 0 {
|
||||
break
|
||||
}
|
||||
require.EqualValues(t, len(allInterceptionIDs), res.Count)
|
||||
require.Len(t, res.Results, 1)
|
||||
interceptionIDs = append(interceptionIDs, res.Results[0].ID)
|
||||
}
|
||||
return interceptionIDs
|
||||
}
|
||||
|
||||
// First attempt: get all interceptions one by one.
|
||||
gotInterceptionIDs1 := getAllInterceptionsOneByOne()
|
||||
// We should have all of the interceptions returned:
|
||||
require.ElementsMatch(t, allInterceptionIDs, gotInterceptionIDs1)
|
||||
|
||||
// Second attempt: get all interceptions one by one again.
|
||||
gotInterceptionIDs2 := getAllInterceptionsOneByOne()
|
||||
// They should be returned in the exact same order.
|
||||
require.Equal(t, gotInterceptionIDs1, gotInterceptionIDs2)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InflightInterceptions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
now := dbtime.Now()
|
||||
i1EndedAt := now.Add(time.Minute)
|
||||
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
}, &i1EndedAt)
|
||||
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
}, nil)
|
||||
|
||||
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, res.Count)
|
||||
require.Len(t, res.Results, 1)
|
||||
require.Equal(t, i1.ID, res.Results[0].ID)
|
||||
})
|
||||
|
||||
t.Run("Authorized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminClient, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
secondUserClient, secondUser := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
||||
|
||||
now := dbtime.Now()
|
||||
i1EndedAt := now.Add(time.Minute)
|
||||
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: firstUser.UserID,
|
||||
StartedAt: now,
|
||||
}, &i1EndedAt)
|
||||
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: secondUser.ID,
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
}, &now)
|
||||
|
||||
// Members cannot read AIBridge interceptions, not even their
|
||||
// own (i2 is owned by secondUser).
|
||||
res, err := secondUserClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, res.Count)
|
||||
require.Empty(t, res.Results)
|
||||
|
||||
// Owner can see all interceptions, including secondUser's,
|
||||
// proving the data exists and the member was filtered out.
|
||||
res, err = adminClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, res.Count)
|
||||
require.Len(t, res.Results, 2)
|
||||
require.Equal(t, i1.ID, res.Results[0].ID)
|
||||
require.Equal(t, i2.ID, res.Results[1].ID)
|
||||
})
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
user1, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
user1Visible := database.VisibleUser{
|
||||
ID: user1.ID,
|
||||
Username: user1.Username,
|
||||
Name: user1.Name,
|
||||
AvatarURL: user1.AvatarURL,
|
||||
}
|
||||
|
||||
_, user2 := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
user2Visible := database.VisibleUser{
|
||||
ID: user2.ID,
|
||||
Username: user2.Username,
|
||||
Name: user2.Name,
|
||||
AvatarURL: user2.AvatarURL,
|
||||
}
|
||||
|
||||
// Insert a bunch of test data with varying filterable fields.
|
||||
now := dbtime.Now()
|
||||
i1EndedAt := now.Add(time.Minute)
|
||||
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
|
||||
InitiatorID: user1.ID,
|
||||
Provider: "one",
|
||||
Model: "one",
|
||||
StartedAt: now,
|
||||
}, &i1EndedAt)
|
||||
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
|
||||
InitiatorID: user1.ID,
|
||||
Provider: "two",
|
||||
Model: "two",
|
||||
StartedAt: now.Add(-time.Hour),
|
||||
Client: sql.NullString{String: string(aiblib.ClientCursor), Valid: true},
|
||||
}, &now)
|
||||
i3 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
ID: uuid.MustParse("00000000-0000-0000-0000-000000000003"),
|
||||
InitiatorID: user2.ID,
|
||||
Provider: "three",
|
||||
Model: "three",
|
||||
StartedAt: now.Add(-2 * time.Hour),
|
||||
Client: sql.NullString{String: string(aiblib.ClientClaudeCode), Valid: true},
|
||||
}, &now)
|
||||
|
||||
// Convert to SDK types for response comparison. We don't care about the
|
||||
// inner arrays for this test.
|
||||
i1SDK := db2sdk.AIBridgeInterception(i1, user1Visible, nil, nil, nil)
|
||||
i2SDK := db2sdk.AIBridgeInterception(i2, user1Visible, nil, nil, nil)
|
||||
i3SDK := db2sdk.AIBridgeInterception(i3, user2Visible, nil, nil, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
filter codersdk.AIBridgeListInterceptionsFilter
|
||||
want []codersdk.AIBridgeInterception
|
||||
}{
|
||||
{
|
||||
name: "NoFilter",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{},
|
||||
want: []codersdk.AIBridgeInterception{i1SDK, i2SDK, i3SDK},
|
||||
},
|
||||
{
|
||||
name: "Initiator/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: uuid.New().String()},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "Initiator/Me",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: codersdk.Me},
|
||||
want: []codersdk.AIBridgeInterception{i1SDK, i2SDK},
|
||||
},
|
||||
{
|
||||
name: "Initiator/UserID",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: user2.ID.String()},
|
||||
want: []codersdk.AIBridgeInterception{i3SDK},
|
||||
},
|
||||
{
|
||||
name: "Initiator/Username",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Initiator: user2.Username},
|
||||
want: []codersdk.AIBridgeInterception{i3SDK},
|
||||
},
|
||||
{
|
||||
name: "Provider/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Provider: "nonsense"},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "Provider/OK",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Provider: "two"},
|
||||
want: []codersdk.AIBridgeInterception{i2SDK},
|
||||
},
|
||||
{
|
||||
name: "Model/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Model: "nonsense"},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "Model/OK",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Model: "three"},
|
||||
want: []codersdk.AIBridgeInterception{i3SDK},
|
||||
},
|
||||
{
|
||||
name: "Client/Unknown",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Client: string(aiblib.ClientUnknown)},
|
||||
want: []codersdk.AIBridgeInterception{i1SDK},
|
||||
},
|
||||
{
|
||||
name: "Client/Match",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Client: string(aiblib.ClientCursor)},
|
||||
want: []codersdk.AIBridgeInterception{i2SDK},
|
||||
},
|
||||
{
|
||||
name: "Client/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{Client: "nonsense"},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "StartedAfter/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{
|
||||
StartedAfter: i1.StartedAt.Add(10 * time.Minute),
|
||||
},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "StartedAfter/OK",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{
|
||||
StartedAfter: i2.StartedAt.Add(-10 * time.Minute),
|
||||
},
|
||||
want: []codersdk.AIBridgeInterception{i1SDK, i2SDK},
|
||||
},
|
||||
{
|
||||
name: "StartedBefore/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{
|
||||
StartedBefore: i3.StartedAt.Add(-10 * time.Minute),
|
||||
},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "StartedBefore/OK",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{
|
||||
StartedBefore: i3.StartedAt.Add(10 * time.Minute),
|
||||
},
|
||||
want: []codersdk.AIBridgeInterception{i3SDK},
|
||||
},
|
||||
{
|
||||
name: "BothBeforeAndAfter/NoMatch",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{
|
||||
StartedAfter: i1.StartedAt.Add(10 * time.Minute),
|
||||
StartedBefore: i1.StartedAt.Add(20 * time.Minute),
|
||||
},
|
||||
want: []codersdk.AIBridgeInterception{},
|
||||
},
|
||||
{
|
||||
name: "BothBeforeAndAfter/OK",
|
||||
filter: codersdk.AIBridgeListInterceptionsFilter{
|
||||
StartedAfter: i2.StartedAt.Add(-10 * time.Minute),
|
||||
StartedBefore: i2.StartedAt.Add(10 * time.Minute),
|
||||
},
|
||||
want: []codersdk.AIBridgeInterception{i2SDK},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.AIBridgeListInterceptions(ctx, tc.filter)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, len(tc.want), res.Count)
|
||||
// We just compare UUID strings for the sake of this test.
|
||||
wantIDs := make([]string, len(tc.want))
|
||||
for i, r := range tc.want {
|
||||
wantIDs[i] = r.ID.String()
|
||||
}
|
||||
gotIDs := make([]string, len(res.Results))
|
||||
for i, r := range res.Results {
|
||||
gotIDs[i] = r.ID.String()
|
||||
}
|
||||
require.Equal(t, wantIDs, gotIDs)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByMe/MemberCannotReadOwn", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
|
||||
ownerClient, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
DeploymentValues: dv,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAIBridge: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID)
|
||||
|
||||
now := dbtime.Now()
|
||||
// Create an interception initiated by the member.
|
||||
_ = dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
|
||||
InitiatorID: member.ID,
|
||||
StartedAt: now,
|
||||
}, nil)
|
||||
|
||||
// Member cannot read their own interceptions, even when
|
||||
// filtering by "me".
|
||||
res, err := memberClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
|
||||
Initiator: codersdk.Me,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, res.Count)
|
||||
require.Empty(t, res.Results)
|
||||
})
|
||||
|
||||
t.Run("FilterErrors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
|
||||
// No need to insert any test data, we're just testing the filter
|
||||
// errors.
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
q string
|
||||
want []codersdk.ValidationError
|
||||
}{
|
||||
{
|
||||
name: "UnknownUsername",
|
||||
q: "initiator:unknown",
|
||||
want: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "initiator",
|
||||
Detail: `Query param "initiator" has invalid value: user "unknown" either does not exist, or you are unauthorized to view them`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidStartedAfter",
|
||||
q: "started_after:invalid",
|
||||
want: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "started_after",
|
||||
Detail: `Query param "started_after" must be a valid date format (2006-01-02T15:04:05.999999999Z07:00): parsing time "INVALID" as "2006-01-02T15:04:05.999999999Z07:00": cannot parse "INVALID" as "2006"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidStartedBefore",
|
||||
q: "started_before:invalid",
|
||||
want: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "started_before",
|
||||
Detail: `Query param "started_before" must be a valid date format (2006-01-02T15:04:05.999999999Z07:00): parsing time "INVALID" as "2006-01-02T15:04:05.999999999Z07:00": cannot parse "INVALID" as "2006"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidBeforeAfterRange",
|
||||
// Before MUST be after After if both are set
|
||||
q: `started_after:"2025-01-01T00:00:00Z" started_before:"2024-01-01T00:00:00Z"`,
|
||||
want: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "started_before",
|
||||
Detail: `Query param "started_before" has invalid value: "started_before" must be after "started_after" if set`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
res, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
|
||||
FilterQuery: tc.q,
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, tc.want, sdkErr.Validations)
|
||||
require.Empty(t, res.Results)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidCursor", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Using a nonexistent UUID as after_id should return 400,
|
||||
// not silently return an empty page.
|
||||
//nolint:gocritic // Owner role is irrelevant here.
|
||||
_, err := client.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{
|
||||
Pagination: codersdk.Pagination{
|
||||
AfterID: uuid.New(),
|
||||
},
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "Invalid pagination cursor")
|
||||
})
|
||||
}
|
||||
|
||||
func aibridgeOpts(t *testing.T) *coderdenttest.Options {
|
||||
t.Helper()
|
||||
dv := coderdtest.DeploymentValues(t)
|
||||
|
||||
+11
-21
@@ -3047,13 +3047,17 @@ class ApiMethods {
|
||||
});
|
||||
};
|
||||
|
||||
getAIBridgeInterceptions = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams(
|
||||
"/api/v2/aibridge/interceptions",
|
||||
options,
|
||||
);
|
||||
const response =
|
||||
await this.axios.get<TypesGen.AIBridgeListInterceptionsResponse>(url);
|
||||
getAIBridgeModels = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
|
||||
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeClients = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/clients", options);
|
||||
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -3077,20 +3081,6 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeModels = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/models", options);
|
||||
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIBridgeClients = async (options: SearchParamOptions) => {
|
||||
const url = getURLWithSearchParams("/api/v2/aibridge/clients", options);
|
||||
|
||||
const response = await this.axios.get<string[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getAIProviders = async (): Promise<TypesGen.AIProvider[]> => {
|
||||
const response = await this.axios.get<TypesGen.AIProvider[]>(
|
||||
"/api/v2/ai/providers",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { UseInfiniteQueryOptions } from "react-query";
|
||||
import { API } from "#/api/api";
|
||||
import type {
|
||||
AIBridgeListInterceptionsResponse,
|
||||
AIBridgeListSessionsResponse,
|
||||
AIBridgeSessionThreadsResponse,
|
||||
} from "#/api/typesGenerated";
|
||||
@@ -10,24 +9,6 @@ import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
|
||||
|
||||
const SESSION_THREADS_INFINITE_PAGE_SIZE = 20;
|
||||
|
||||
export const paginatedInterceptions = (
|
||||
searchParams: URLSearchParams,
|
||||
): UsePaginatedQueryOptions<AIBridgeListInterceptionsResponse, string> => {
|
||||
return {
|
||||
searchParams,
|
||||
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
|
||||
queryKey: ({ limit, offset, payload }) => {
|
||||
return ["aiBridgeInterceptions", limit, offset, payload] as const;
|
||||
},
|
||||
queryFn: ({ limit, offset, payload }) =>
|
||||
API.getAIBridgeInterceptions({
|
||||
offset,
|
||||
limit,
|
||||
q: payload,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const paginatedSessions = (
|
||||
searchParams: URLSearchParams,
|
||||
): UsePaginatedQueryOptions<AIBridgeListSessionsResponse, string> => {
|
||||
|
||||
Generated
-64
@@ -90,30 +90,6 @@ export interface AIBridgeConfig {
|
||||
readonly api_dump_dir: string;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface AIBridgeInterception {
|
||||
readonly id: string;
|
||||
readonly api_key_id: string | null;
|
||||
readonly initiator: MinimalUser;
|
||||
readonly provider: string;
|
||||
readonly provider_name: string;
|
||||
readonly model: string;
|
||||
readonly client: string | null;
|
||||
// empty interface{} type, falling back to unknown
|
||||
readonly metadata: Record<string, unknown>;
|
||||
readonly started_at: string;
|
||||
readonly ended_at: string | null;
|
||||
readonly token_usages: readonly AIBridgeTokenUsage[];
|
||||
readonly user_prompts: readonly AIBridgeUserPrompt[];
|
||||
readonly tool_usages: readonly AIBridgeToolUsage[];
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface AIBridgeListInterceptionsResponse {
|
||||
readonly count: number;
|
||||
readonly results: readonly AIBridgeInterception[];
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface AIBridgeListSessionsResponse {
|
||||
readonly count: number;
|
||||
@@ -229,20 +205,6 @@ export interface AIBridgeThread {
|
||||
readonly agentic_actions: readonly AIBridgeAgenticAction[];
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface AIBridgeTokenUsage {
|
||||
readonly id: string;
|
||||
readonly interception_id: string;
|
||||
readonly provider_response_id: string;
|
||||
readonly input_tokens: number;
|
||||
readonly output_tokens: number;
|
||||
readonly cache_read_input_tokens: number;
|
||||
readonly cache_write_input_tokens: number;
|
||||
// empty interface{} type, falling back to unknown
|
||||
readonly metadata: Record<string, unknown>;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
/**
|
||||
* AIBridgeToolCall represents a tool call recorded during an
|
||||
@@ -261,32 +223,6 @@ export interface AIBridgeToolCall {
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface AIBridgeToolUsage {
|
||||
readonly id: string;
|
||||
readonly interception_id: string;
|
||||
readonly provider_response_id: string;
|
||||
readonly server_url: string;
|
||||
readonly tool: string;
|
||||
readonly input: string;
|
||||
readonly injected: boolean;
|
||||
readonly invocation_error: string;
|
||||
// empty interface{} type, falling back to unknown
|
||||
readonly metadata: Record<string, unknown>;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/aibridge.go
|
||||
export interface AIBridgeUserPrompt {
|
||||
readonly id: string;
|
||||
readonly interception_id: string;
|
||||
readonly provider_response_id: string;
|
||||
readonly prompt: string;
|
||||
// empty interface{} type, falling back to unknown
|
||||
readonly metadata: Record<string, unknown>;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export type AIBudgetPeriod = "month";
|
||||
|
||||
|
||||
@@ -5,18 +5,12 @@ import {
|
||||
type useFilter,
|
||||
} from "#/components/Filter/Filter";
|
||||
import { type UserFilterMenu, UserMenu } from "#/components/Filter/UserFilter";
|
||||
import {
|
||||
ClientFilter,
|
||||
type ClientFilterMenu,
|
||||
} from "../RequestLogsPage/RequestLogsFilter/ClientFilter";
|
||||
import {
|
||||
ModelFilter,
|
||||
type ModelFilterMenu,
|
||||
} from "../RequestLogsPage/RequestLogsFilter/ModelFilter";
|
||||
import { ClientFilter, type ClientFilterMenu } from "../filters/ClientFilter";
|
||||
import { ModelFilter, type ModelFilterMenu } from "../filters/ModelFilter";
|
||||
import {
|
||||
ProviderFilter,
|
||||
type ProviderFilterMenu,
|
||||
} from "../RequestLogsPage/RequestLogsFilter/ProviderFilter";
|
||||
} from "../filters/ProviderFilter";
|
||||
|
||||
interface ListSessionsFilterProps {
|
||||
filter: ReturnType<typeof useFilter>;
|
||||
|
||||
@@ -8,10 +8,10 @@ import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { useClientFilterMenu } from "../filters/ClientFilter";
|
||||
import { useModelFilterMenu } from "../filters/ModelFilter";
|
||||
import { useProviderFilterMenu } from "../filters/ProviderFilter";
|
||||
import { getAIBridgePermissions } from "../getAIBridgePermissions";
|
||||
import { useClientFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ClientFilter";
|
||||
import { useModelFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ModelFilter";
|
||||
import { useProviderFilterMenu } from "../RequestLogsPage/RequestLogsFilter/ProviderFilter";
|
||||
import { ListSessionsPageView } from "./ListSessionsPageView";
|
||||
|
||||
const AISessionListPage: FC = () => {
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
|
||||
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
|
||||
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon";
|
||||
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon";
|
||||
import { DATE_FORMAT, formatDateTime } from "#/utils/time";
|
||||
import { TokenBadges } from "../TokenBadges";
|
||||
import { getProviderDisplayName, getProviderIconName } from "../utils";
|
||||
import { getProviderDisplayName } from "../utils";
|
||||
|
||||
type ListSessionsRowProps = {
|
||||
session: AIBridgeSession;
|
||||
@@ -71,7 +71,7 @@ export const ListSessionsRow: FC<ListSessionsRowProps> = ({
|
||||
<Badge className="gap-1.5 max-w-full">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeProviderIcon
|
||||
provider={getProviderIconName(session.providers[0])}
|
||||
provider={session.providers[0]}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
Filter,
|
||||
MenuSkeleton,
|
||||
type useFilter,
|
||||
} from "#/components/Filter/Filter";
|
||||
import { type UserFilterMenu, UserMenu } from "#/components/Filter/UserFilter";
|
||||
import { ClientFilter, type ClientFilterMenu } from "./ClientFilter";
|
||||
import { ModelFilter, type ModelFilterMenu } from "./ModelFilter";
|
||||
import { ProviderFilter, type ProviderFilterMenu } from "./ProviderFilter";
|
||||
|
||||
interface RequestLogsFilterProps {
|
||||
filter: ReturnType<typeof useFilter>;
|
||||
error?: unknown;
|
||||
menus: {
|
||||
user: UserFilterMenu;
|
||||
provider: ProviderFilterMenu;
|
||||
model: ModelFilterMenu;
|
||||
client: ClientFilterMenu;
|
||||
};
|
||||
}
|
||||
|
||||
export const RequestLogsFilter: FC<RequestLogsFilterProps> = ({
|
||||
filter,
|
||||
error,
|
||||
menus,
|
||||
}) => {
|
||||
return (
|
||||
<Filter
|
||||
filter={filter}
|
||||
optionsSkeleton={<MenuSkeleton />}
|
||||
isLoading={menus.user.isInitializing}
|
||||
presets={[
|
||||
{
|
||||
name: "All requests",
|
||||
query: "",
|
||||
},
|
||||
{
|
||||
name: "My requests",
|
||||
query: "initiator:me",
|
||||
},
|
||||
]}
|
||||
error={error}
|
||||
options={
|
||||
<>
|
||||
<UserMenu menu={menus.user} placeholder="All initiators" />
|
||||
<ProviderFilter menu={menus.provider} />
|
||||
<ModelFilter menu={menus.model} />
|
||||
<ClientFilter menu={menus.client} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { paginatedInterceptions } from "#/api/queries/aiBridge";
|
||||
import { useFilter } from "#/components/Filter/Filter";
|
||||
import { useUserFilterMenu } from "#/components/Filter/UserFilter";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { getAIBridgePermissions } from "../getAIBridgePermissions";
|
||||
import { useClientFilterMenu } from "./RequestLogsFilter/ClientFilter";
|
||||
import { useModelFilterMenu } from "./RequestLogsFilter/ModelFilter";
|
||||
import { useProviderFilterMenu } from "./RequestLogsFilter/ProviderFilter";
|
||||
import { RequestLogsPageView } from "./RequestLogsPageView";
|
||||
|
||||
const RequestLogsPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const { entitlements } = useDashboard();
|
||||
|
||||
const { isEntitled, isEnabled, hasPermission } = getAIBridgePermissions(
|
||||
entitlements,
|
||||
permissions,
|
||||
);
|
||||
|
||||
const canViewRequestLogs = isEntitled && hasPermission;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const interceptionsQuery = usePaginatedQuery({
|
||||
...paginatedInterceptions(searchParams),
|
||||
enabled: canViewRequestLogs,
|
||||
});
|
||||
const filter = useFilter({
|
||||
searchParams,
|
||||
onSearchParamsChange: setSearchParams,
|
||||
onUpdate: interceptionsQuery.goToFirstPage,
|
||||
});
|
||||
|
||||
const userMenu = useUserFilterMenu({
|
||||
value: filter.values.initiator,
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
initiator: option?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const providerMenu = useProviderFilterMenu({
|
||||
value: filter.values.provider_name,
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
provider_name: option?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const modelMenu = useModelFilterMenu({
|
||||
value: filter.values.model,
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
model: option?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const clientMenu = useClientFilterMenu({
|
||||
value: filter.values.client,
|
||||
onChange: (option) =>
|
||||
filter.update({
|
||||
...filter.values,
|
||||
client: option?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={hasPermission}>
|
||||
<title>{pageTitle("Request Logs", "AI Gateway")}</title>
|
||||
|
||||
<RequestLogsPageView
|
||||
isLoading={interceptionsQuery.isLoading}
|
||||
isRequestLogsEntitled={isEntitled}
|
||||
isRequestLogsEnabled={isEnabled}
|
||||
interceptions={interceptionsQuery.data?.results}
|
||||
interceptionsQuery={interceptionsQuery}
|
||||
filterProps={{
|
||||
filter,
|
||||
error: interceptionsQuery.error,
|
||||
menus: {
|
||||
user: userMenu,
|
||||
provider: providerMenu,
|
||||
model: modelMenu,
|
||||
client: clientMenu,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestLogsPage;
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
getDefaultFilterProps,
|
||||
MockMenu,
|
||||
} from "#/components/Filter/storyHelpers";
|
||||
import {
|
||||
mockInitialRenderResult,
|
||||
mockSuccessResult,
|
||||
} from "#/components/PaginationWidget/PaginationContainer.mocks";
|
||||
import {
|
||||
MockInterception,
|
||||
MockInterceptionAnthropic,
|
||||
MockInterceptionCopilot,
|
||||
} from "#/testHelpers/entities";
|
||||
import { RequestLogsPageView } from "./RequestLogsPageView";
|
||||
|
||||
type FilterProps = ComponentProps<typeof RequestLogsPageView>["filterProps"];
|
||||
|
||||
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
|
||||
query: "owner:me",
|
||||
values: {
|
||||
username: undefined,
|
||||
provider: undefined,
|
||||
},
|
||||
menus: {
|
||||
user: MockMenu,
|
||||
provider: MockMenu,
|
||||
model: MockMenu,
|
||||
client: MockMenu,
|
||||
},
|
||||
});
|
||||
|
||||
const interceptions = [
|
||||
MockInterception,
|
||||
MockInterceptionAnthropic,
|
||||
MockInterceptionCopilot,
|
||||
];
|
||||
|
||||
const meta: Meta<typeof RequestLogsPageView> = {
|
||||
title: "pages/AIBridgePage/RequestLogsPageView",
|
||||
component: RequestLogsPageView,
|
||||
args: {},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof RequestLogsPageView>;
|
||||
|
||||
export const Paywall: Story = {
|
||||
args: {
|
||||
isRequestLogsEntitled: false,
|
||||
isRequestLogsEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotEnabled: Story = {
|
||||
args: {
|
||||
isRequestLogsEntitled: true,
|
||||
isRequestLogsEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loaded: Story = {
|
||||
args: {
|
||||
isRequestLogsEntitled: true,
|
||||
isRequestLogsEnabled: true,
|
||||
interceptions,
|
||||
filterProps: {
|
||||
...defaultFilterProps,
|
||||
},
|
||||
interceptionsQuery: mockSuccessResult,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
isRequestLogsEntitled: true,
|
||||
isRequestLogsEnabled: true,
|
||||
interceptions: [],
|
||||
filterProps: {
|
||||
...defaultFilterProps,
|
||||
},
|
||||
interceptionsQuery: mockSuccessResult,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
isRequestLogsEntitled: true,
|
||||
isRequestLogsEnabled: true,
|
||||
interceptions: [],
|
||||
filterProps: {
|
||||
...defaultFilterProps,
|
||||
},
|
||||
interceptionsQuery: mockInitialRenderResult,
|
||||
},
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { ComponentProps, FC } from "react";
|
||||
import type { AIBridgeInterception } from "#/api/typesGenerated";
|
||||
import { Alert } from "#/components/Alert/Alert";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import {
|
||||
PaginationContainer,
|
||||
type PaginationResult,
|
||||
} from "#/components/PaginationWidget/PaginationContainer";
|
||||
import { PaywallAIGovernance } from "#/components/Paywall/PaywallAIGovernance";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "#/components/TableLoader/TableLoader";
|
||||
import { AIBridgeSetupAlert } from "../AIBridgeSetupAlert";
|
||||
import { RequestLogsFilter } from "./RequestLogsFilter/RequestLogsFilter";
|
||||
import { RequestLogsRow } from "./RequestLogsRow/RequestLogsRow";
|
||||
|
||||
interface RequestLogsPageViewProps {
|
||||
isLoading: boolean;
|
||||
isRequestLogsEntitled: boolean;
|
||||
isRequestLogsEnabled: boolean;
|
||||
interceptions?: readonly AIBridgeInterception[];
|
||||
interceptionsQuery: PaginationResult;
|
||||
filterProps: ComponentProps<typeof RequestLogsFilter>;
|
||||
}
|
||||
|
||||
export const RequestLogsPageView: FC<RequestLogsPageViewProps> = ({
|
||||
isLoading,
|
||||
isRequestLogsEntitled,
|
||||
isRequestLogsEnabled,
|
||||
interceptions,
|
||||
interceptionsQuery,
|
||||
filterProps,
|
||||
}) => {
|
||||
if (!isRequestLogsEntitled) {
|
||||
return <PaywallAIGovernance />;
|
||||
}
|
||||
|
||||
if (!isRequestLogsEnabled) {
|
||||
return <AIBridgeSetupAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert severity="info" className="mb-4">
|
||||
Visit the new{" "}
|
||||
<Link href="/aibridge/sessions" className="text-content-link italic">
|
||||
AI Sessions
|
||||
</Link>{" "}
|
||||
page for a more comprehensive view of AI activity.
|
||||
</Alert>
|
||||
|
||||
<RequestLogsFilter {...filterProps} />
|
||||
|
||||
<PaginationContainer
|
||||
query={interceptionsQuery}
|
||||
paginationUnitLabel="interceptions"
|
||||
>
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs">
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Initiator</TableHead>
|
||||
<TableHead>Tokens</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Tool Calls</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableLoader />
|
||||
) : interceptions?.length === 0 ? (
|
||||
<TableEmpty message="No request logs available" />
|
||||
) : (
|
||||
interceptions?.map((interception) => (
|
||||
<RequestLogsRow
|
||||
interception={interception}
|
||||
key={interception.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PaginationContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-103
File diff suppressed because one or more lines are too long
@@ -1,70 +0,0 @@
|
||||
import { tokenUsageMetadataMerge } from "./RequestLogsRow";
|
||||
|
||||
describe("tokenUsageMetadataMerge", () => {
|
||||
it("returns null when inputs are null or empty", () => {
|
||||
const result = tokenUsageMetadataMerge(null, {}, null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns data when there are no shared keys across metadata", () => {
|
||||
const metadataA = { input_tokens: 5 };
|
||||
const metadataB = { output_tokens: 2 };
|
||||
|
||||
const result = tokenUsageMetadataMerge(metadataA, metadataB);
|
||||
|
||||
expect(result).toEqual([metadataA, metadataB]);
|
||||
});
|
||||
|
||||
it("sums numeric values for common keys and keeps non-common keys", () => {
|
||||
const metadataA = {
|
||||
input_tokens: 5,
|
||||
model: "gpt-4",
|
||||
};
|
||||
const metadataB = {
|
||||
input_tokens: 3,
|
||||
output_tokens: 2,
|
||||
};
|
||||
|
||||
const result = tokenUsageMetadataMerge(metadataA, metadataB);
|
||||
|
||||
expect(result).toEqual({
|
||||
input_tokens: 8,
|
||||
model: "gpt-4",
|
||||
output_tokens: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves identical non-numeric values for common keys", () => {
|
||||
const metadataA = {
|
||||
note: "sync",
|
||||
status: "ok",
|
||||
};
|
||||
const metadataB = {
|
||||
note: "sync",
|
||||
status: "ok",
|
||||
};
|
||||
|
||||
const result = tokenUsageMetadataMerge(metadataA, metadataB);
|
||||
|
||||
expect(result).toEqual({
|
||||
note: "sync",
|
||||
status: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the original metadata array when a conflict cannot be resolved", () => {
|
||||
const metadataA = {
|
||||
input_tokens: 1,
|
||||
label: "a",
|
||||
};
|
||||
const metadataB = {
|
||||
input_tokens: 3,
|
||||
label: "b",
|
||||
};
|
||||
|
||||
const result = tokenUsageMetadataMerge(metadataA, metadataB);
|
||||
|
||||
expect(result).toEqual([metadataA, metadataB]);
|
||||
});
|
||||
});
|
||||
@@ -1,387 +0,0 @@
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { type FC, Fragment, useState } from "react";
|
||||
import type { AIBridgeInterception } from "#/api/typesGenerated";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { TableCell, TableRow } from "#/components/Table/Table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatDate, humanDuration } from "#/utils/time";
|
||||
import { TokenBadges } from "../../TokenBadges";
|
||||
import { AIBridgeClientIcon } from "../icons/AIBridgeClientIcon";
|
||||
import { AIBridgeModelIcon } from "../icons/AIBridgeModelIcon";
|
||||
|
||||
type RequestLogsRowProps = {
|
||||
interception: AIBridgeInterception;
|
||||
};
|
||||
|
||||
type TokenUsageMetadataMerged =
|
||||
| null
|
||||
| Record<string, unknown>
|
||||
| Array<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* This function merges multiple objects with the same keys into a single object.
|
||||
* It's super unconventional, but it's only a temporary workaround until we
|
||||
* structure our metadata field for rendering in the UI.
|
||||
* @param objects - The objects to merge.
|
||||
* @returns The merged object.
|
||||
*/
|
||||
export function tokenUsageMetadataMerge(
|
||||
...objects: Array<
|
||||
AIBridgeInterception["token_usages"][number]["metadata"] | null
|
||||
>
|
||||
): TokenUsageMetadataMerged {
|
||||
const validObjects = objects.filter((obj) => obj !== null);
|
||||
|
||||
// Filter out empty objects
|
||||
const nonEmptyObjects = validObjects.filter(
|
||||
(obj) => Object.keys(obj).length > 0,
|
||||
);
|
||||
if (nonEmptyObjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allKeys = new Set(nonEmptyObjects.flatMap((obj) => Object.keys(obj)));
|
||||
const commonKeys = Array.from(allKeys).filter((key) =>
|
||||
nonEmptyObjects.every((obj) => key in obj),
|
||||
);
|
||||
if (commonKeys.length === 0) {
|
||||
return nonEmptyObjects;
|
||||
}
|
||||
|
||||
// Check for unresolvable conflicts: values that aren't all numeric or all
|
||||
// the same.
|
||||
for (const key of allKeys) {
|
||||
const objectsWithKey = nonEmptyObjects.filter((obj) => key in obj);
|
||||
if (objectsWithKey.length > 1) {
|
||||
const values = objectsWithKey.map((obj) => obj[key]);
|
||||
const allNumeric = values.every((v: unknown) => typeof v === "number");
|
||||
const allSame = new Set(values).size === 1;
|
||||
if (!allNumeric && !allSame) {
|
||||
return nonEmptyObjects;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge common keys: sum numeric values, preserve identical values, mark
|
||||
// conflicts as null.
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of commonKeys) {
|
||||
const values = nonEmptyObjects.map((obj) => obj[key]);
|
||||
const allNumeric = values.every((v: unknown) => typeof v === "number");
|
||||
const allSame = new Set(values).size === 1;
|
||||
|
||||
if (allNumeric) {
|
||||
result[key] = values.reduce((acc, v) => acc + (v as number), 0);
|
||||
} else if (allSame) {
|
||||
result[key] = values[0];
|
||||
} else {
|
||||
result[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add non-common keys from the first object that has them.
|
||||
for (const obj of nonEmptyObjects) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!commonKeys.includes(key) && !(key in result)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any conflicts were marked, return original objects.
|
||||
return Object.values(result).some((v: unknown) => v === null)
|
||||
? nonEmptyObjects
|
||||
: result;
|
||||
}
|
||||
|
||||
export const RequestLogsRow: FC<RequestLogsRowProps> = ({ interception }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const inputTokens = interception.token_usages.reduce(
|
||||
(acc, tokenUsage) => acc + tokenUsage.input_tokens,
|
||||
0,
|
||||
);
|
||||
const outputTokens = interception.token_usages.reduce(
|
||||
(acc, tokenUsage) => acc + tokenUsage.output_tokens,
|
||||
0,
|
||||
);
|
||||
|
||||
const tokenUsagesMetadata = tokenUsageMetadataMerge(
|
||||
...interception.token_usages.map((tokenUsage) => tokenUsage.metadata),
|
||||
);
|
||||
|
||||
const toolCalls = interception.tool_usages.length;
|
||||
const duration =
|
||||
interception.ended_at &&
|
||||
Math.max(
|
||||
0,
|
||||
new Date(interception.ended_at).getTime() -
|
||||
new Date(interception.started_at).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="select-none cursor-pointer"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
hover
|
||||
>
|
||||
<TableCell className="w-48 whitespace-nowrap">
|
||||
<div
|
||||
className={cn([
|
||||
"flex items-center gap-2",
|
||||
isOpen && "text-content-primary",
|
||||
])}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"mr-4 transition-transform size-3.5",
|
||||
isOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">({isOpen ? "Hide" : "Show more"})</span>
|
||||
{formatDate(new Date(interception.started_at))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-48 max-w-48">
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
fallback={interception.initiator.username}
|
||||
src={interception.initiator.avatar_url}
|
||||
size="lg"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="font-medium truncate min-w-0 flex-1 overflow-hidden">
|
||||
{interception.initiator.name ?? interception.initiator.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-32">
|
||||
<div className="flex items-center">
|
||||
<TokenBadges
|
||||
inputTokens={inputTokens}
|
||||
outputTokens={outputTokens}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-40 max-w-40">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="gap-1.5 max-w-full">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeClientIcon
|
||||
client={interception.client}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate min-w-0">
|
||||
{interception.client ?? "Unknown"}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{interception.client}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-40 max-w-40">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="gap-1.5 max-w-full">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeModelIcon
|
||||
model={interception.model}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate min-w-0">
|
||||
{interception.model}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{interception.model}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-32 text-center">
|
||||
<Badge>{toolCalls}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999} className="p-4 border-t-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
<dl
|
||||
className={cn([
|
||||
"text-xs text-content-secondary",
|
||||
"m-0 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 items-center",
|
||||
"[&_dd]:text-content-primary [&_dd]:font-mono [&_dd]:leading-[22px] [&_dt]:font-medium",
|
||||
])}
|
||||
>
|
||||
<dt>Request ID:</dt>
|
||||
<dd data-chromatic="ignore">{interception.id}</dd>
|
||||
|
||||
<dt>Start Time:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
{formatDate(new Date(interception.started_at))}
|
||||
</dd>
|
||||
|
||||
{interception.ended_at && (
|
||||
<>
|
||||
<dt>End Time:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
{formatDate(new Date(interception.ended_at))}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(duration || duration === 0) && (
|
||||
<>
|
||||
<dt>Duration:</dt>
|
||||
<dd title={duration.toString()} data-chromatic="ignore">
|
||||
{humanDuration(duration)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
<dt>Initiator:</dt>
|
||||
<dd
|
||||
data-chromatic="ignore"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Avatar
|
||||
fallback={interception.initiator.username}
|
||||
src={interception.initiator.avatar_url}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="truncate min-w-0 w-full">
|
||||
{interception.initiator.name ??
|
||||
interception.initiator.username}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>Client:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
<Badge className="gap-2">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeClientIcon
|
||||
client={interception.client}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate min-w-0 w-full text-2xs">
|
||||
{interception.client ?? "Unknown"}
|
||||
</span>
|
||||
</Badge>
|
||||
</dd>
|
||||
|
||||
<dt>Model:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
<Badge className="gap-2">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<AIBridgeModelIcon
|
||||
model={interception.model}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate min-w-0 w-full text-2xs">
|
||||
{interception.model}
|
||||
</span>
|
||||
</Badge>
|
||||
</dd>
|
||||
|
||||
<dt>Tool Calls:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
<Badge>{interception.tool_usages.length}</Badge>
|
||||
</dd>
|
||||
|
||||
<dt>Input/Output Tokens:</dt>
|
||||
<dd data-chromatic="ignore">
|
||||
<div className="flex items-center">
|
||||
<TokenBadges
|
||||
inputTokens={inputTokens}
|
||||
outputTokens={outputTokens}
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{interception.user_prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Prompts</div>
|
||||
<div
|
||||
className="bg-surface-secondary rounded-md p-4 text-xs leading-4"
|
||||
data-chromatic="ignore"
|
||||
>
|
||||
{interception.user_prompts.map((prompt) => (
|
||||
<Fragment key={prompt.id}>{prompt.prompt}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{interception.tool_usages.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Tool Usages</div>
|
||||
<div
|
||||
className="bg-surface-secondary rounded-md p-4"
|
||||
data-chromatic="ignore"
|
||||
>
|
||||
{interception.tool_usages.map((toolUsage) => {
|
||||
return (
|
||||
<dl
|
||||
key={toolUsage.id}
|
||||
className={cn([
|
||||
"text-xs text-content-secondary",
|
||||
"m-0 grid grid-cols-[auto_1fr] gap-x-4 items-center",
|
||||
"[&_dt]:text-content-primary [&_dd]:font-mono [&_dt]:leading-[22px] [&_dt]:font-medium",
|
||||
])}
|
||||
>
|
||||
<dt>{toolUsage.tool}</dt>
|
||||
<dd className="overflow-x-auto">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>{toolUsage.input}</div>
|
||||
{toolUsage.invocation_error && (
|
||||
<div className="text-content-destructive">
|
||||
{toolUsage.invocation_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenUsagesMetadata !== null && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>Token Usage Metadata</div>
|
||||
<div className="bg-surface-secondary rounded-md p-4">
|
||||
<pre>{JSON.stringify(tokenUsagesMetadata, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AIBridgeClientIcon } from "./AIBridgeClientIcon";
|
||||
|
||||
const meta: Meta<typeof AIBridgeClientIcon> = {
|
||||
title: "pages/AIBridgePage/AIBridgeClientIcon",
|
||||
component: AIBridgeClientIcon,
|
||||
args: {
|
||||
className: "size-8",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AIBridgeClientIcon>;
|
||||
|
||||
export const OpenCode: Story = {
|
||||
args: {
|
||||
client: "OpenCode",
|
||||
},
|
||||
};
|
||||
|
||||
export const ClaudeCode: Story = {
|
||||
args: {
|
||||
client: "Claude Code",
|
||||
},
|
||||
};
|
||||
|
||||
export const Unknown: Story = {
|
||||
args: {
|
||||
client: "Unknown",
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { MinimalUser } from "#/api/typesGenerated";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeClientIcon";
|
||||
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeProviderIcon";
|
||||
import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon";
|
||||
import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon";
|
||||
import { formatDateTime } from "#/utils/time";
|
||||
import { TokenBadges } from "../TokenBadges";
|
||||
import { getProviderDisplayName, getProviderIconName } from "../utils";
|
||||
import { getProviderDisplayName } from "../utils";
|
||||
|
||||
const Separator = () => <div className="border-0 border-t border-solid my-1" />;
|
||||
|
||||
@@ -123,10 +123,7 @@ export const SessionSummaryTable = ({
|
||||
key={p}
|
||||
className="gap-1.5 max-w-full min-w-0 overflow-hidden"
|
||||
>
|
||||
<AIBridgeProviderIcon
|
||||
provider={getProviderIconName(p)}
|
||||
className="size-icon-xs"
|
||||
/>
|
||||
<AIBridgeProviderIcon provider={p} className="size-icon-xs" />
|
||||
<span
|
||||
className="truncate min-w-0 flex-1"
|
||||
title={getProviderDisplayName(p)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { AIBridgeModelIcon } from "#/pages/AIBridgePage/RequestLogsPage/icons/AIBridgeModelIcon";
|
||||
import { AIBridgeModelIcon } from "#/pages/AIBridgePage/icons/AIBridgeModelIcon";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { formatDate } from "#/utils/time";
|
||||
import { TokenBadges } from "../../TokenBadges";
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import { CircleQuestionMarkIcon } from "lucide-react";
|
||||
import type { AIBridgeInterception } from "#/api/typesGenerated";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
@@ -8,7 +7,7 @@ export const AIBridgeClientIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
client: AIBridgeInterception["client"];
|
||||
client: string | null;
|
||||
} & React.ComponentProps<"svg">) => {
|
||||
const iconClassName = "flex-shrink-0";
|
||||
// This should be kept in sync with the client names in
|
||||
+1
-9
@@ -1,5 +1,4 @@
|
||||
import { CircleQuestionMarkIcon } from "lucide-react";
|
||||
import type { AIBridgeInterception } from "#/api/typesGenerated";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
@@ -8,7 +7,7 @@ export const AIBridgeProviderIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
provider: AIBridgeInterception["provider"];
|
||||
provider: string;
|
||||
} & React.ComponentProps<"svg">) => {
|
||||
const iconClassName = "flex-shrink-0";
|
||||
switch (provider) {
|
||||
@@ -26,13 +25,6 @@ export const AIBridgeProviderIcon = ({
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "anthropic-neue":
|
||||
return (
|
||||
<ExternalImage
|
||||
src="/icon/anthropic.svg"
|
||||
className={cn(iconClassName, className)}
|
||||
/>
|
||||
);
|
||||
case "copilot":
|
||||
return (
|
||||
<ExternalImage
|
||||
@@ -24,14 +24,3 @@ export const getProviderDisplayName = (provider: string) => {
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
// FIXME the current AIBridgeProviderIcon uses the claude icon for the
|
||||
// anthropic provider. while it's still in use in the RequestLogsPage, we need
|
||||
// to hack around it here, but when we delete that page, we can just swap the
|
||||
// icon
|
||||
export const getProviderIconName = (provider: string) => {
|
||||
if (provider === "anthropic") {
|
||||
return "anthropic-neue";
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
@@ -417,10 +417,6 @@ const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
|
||||
const AIBridgeLayout = lazy(
|
||||
() => import("./pages/AIBridgePage/AIBridgeLayout"),
|
||||
);
|
||||
const AIBridgeRequestLogsPage = lazy(
|
||||
() => import("./pages/AIBridgePage/RequestLogsPage/RequestLogsPage"),
|
||||
);
|
||||
|
||||
const AIBridgeSessionsLayout = lazy(
|
||||
() => import("./pages/AIBridgePage/AIBridgeSessionsLayout"),
|
||||
);
|
||||
@@ -690,7 +686,6 @@ export const router = createBrowserRouter(
|
||||
index
|
||||
element={<Navigate to="/aibridge/sessions" replace />}
|
||||
/>
|
||||
<Route path="request-logs" element={<AIBridgeRequestLogsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/aibridge/sessions" element={<AIBridgeSessionsLayout />}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user