Files
coder/codersdk/aibridge.go
T
Danny Kopping 4ddda3a9db feat: filter interceptions and sessions by provider name (#25640)
Allows filtering sessions & interceptions by provider name, and adds a test to vaidate that provider name is immutable (at least until #25606 lands).
2026-05-25 16:31:48 +02:00

432 lines
17 KiB
Go

package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"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"`
Providers []string `json:"providers"`
Models []string `json:"models"`
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,omitempty" format:"date-time"`
Threads int64 `json:"threads"`
TokenUsageSummary AIBridgeSessionTokenUsageSummary `json:"token_usage_summary"`
LastPrompt *string `json:"last_prompt,omitempty"`
LastActiveAt time.Time `json:"last_active_at" format:"date-time"`
}
type AIBridgeSessionTokenUsageSummary struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheWriteInputTokens int64 `json:"cache_write_input_tokens"`
}
type AIBridgeListSessionsResponse struct {
Count int64 `json:"count"`
Sessions []AIBridgeSession `json:"sessions"`
}
// AIBridgeSessionThreadsResponse is the response for GET
// /api/v2/aibridge/sessions/{session_id} which returns a single
// session with fully expanded threads.
type AIBridgeSessionThreadsResponse struct {
ID string `json:"id"`
Initiator MinimalUser `json:"initiator"`
Providers []string `json:"providers"`
Models []string `json:"models"`
Client *string `json:"client,omitempty"`
Metadata map[string]any `json:"metadata"`
PageStartedAt *time.Time `json:"page_started_at,omitempty" format:"date-time"`
PageEndedAt *time.Time `json:"page_ended_at,omitempty" format:"date-time"`
StartedAt time.Time `json:"started_at" format:"date-time"`
EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"`
TokenUsageSummary AIBridgeSessionThreadsTokenUsage `json:"token_usage_summary"`
Threads []AIBridgeThread `json:"threads"`
}
// AIBridgeSessionThreadsTokenUsage represents aggregated token usage
// with metadata containing provider-specific fields.
type AIBridgeSessionThreadsTokenUsage struct {
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"`
}
// AIBridgeThread represents a single thread within a session.
// A thread groups interceptions by their thread_root_id.
type AIBridgeThread struct {
ID uuid.UUID `json:"id" format:"uuid"`
Prompt *string `json:"prompt,omitempty"`
Model string `json:"model"`
Provider string `json:"provider"`
CredentialKind string `json:"credential_kind"`
CredentialHint string `json:"credential_hint"`
StartedAt time.Time `json:"started_at" format:"date-time"`
EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"`
TokenUsage AIBridgeSessionThreadsTokenUsage `json:"token_usage"`
AgenticActions []AIBridgeAgenticAction `json:"agentic_actions"`
}
// AIBridgeAgenticAction represents a tool call with associated
// thinking blocks and token usage from one or more interceptions.
type AIBridgeAgenticAction struct {
Model string `json:"model"`
TokenUsage AIBridgeSessionThreadsTokenUsage `json:"token_usage"`
Thinking []AIBridgeModelThought `json:"thinking"`
ToolCalls []AIBridgeToolCall `json:"tool_calls"`
}
// AIBridgeModelThought represents a single thinking block from
// the model.
type AIBridgeModelThought struct {
Text string `json:"text"`
}
// AIBridgeToolCall represents a tool call recorded during an
// interception.
type AIBridgeToolCall 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"`
Injected bool `json:"injected"`
Input string `json:"input"`
Metadata map[string]any `json:"metadata"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
}
// @typescript-ignore AIBridgeListSessionsFilter
type AIBridgeListSessionsFilter struct {
// Limit defaults to 100, max is 1000.
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 provider type column (openai, anthropic,
// copilot). 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"`
SessionID string `json:"session_id,omitempty"`
// AfterSessionID is a cursor for pagination. It is the session ID of the
// last session in the previous page.
AfterSessionID string `json:"after_session_id,omitempty"`
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 provider type column (openai, anthropic,
// copilot). 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) {
var params []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.SessionID != "" {
params = append(params, fmt.Sprintf("session_id:%q", f.SessionID))
}
if f.FilterQuery != "" {
params = append(params, f.FilterQuery)
}
q := r.URL.Query()
q.Set("q", strings.Join(params, " "))
if f.AfterSessionID != "" {
q.Set("after_session_id", f.AfterSessionID)
}
r.URL.RawQuery = q.Encode()
}
}
// 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())
if err != nil {
return AIBridgeListSessionsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AIBridgeListSessionsResponse{}, ReadBodyAsError(res)
}
var resp AIBridgeListSessionsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// AIBridgeGetSessionThreads returns a single session with expanded
// thread details including agentic actions and thinking blocks.
func (c *Client) AIBridgeGetSessionThreads(ctx context.Context, sessionID string, afterID, beforeID uuid.UUID, limit int32) (AIBridgeSessionThreadsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/aibridge/sessions/%s", sessionID), nil, func(r *http.Request) {
q := r.URL.Query()
if afterID != uuid.Nil {
q.Set("after_id", afterID.String())
}
if beforeID != uuid.Nil {
q.Set("before_id", beforeID.String())
}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}
r.URL.RawQuery = q.Encode()
})
if err != nil {
return AIBridgeSessionThreadsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AIBridgeSessionThreadsResponse{}, ReadBodyAsError(res)
}
var resp AIBridgeSessionThreadsResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// AIBridgeListClients returns the distinct AI clients visible to the caller.
func (c *Client) AIBridgeListClients(ctx context.Context) ([]string, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/clients", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var clients []string
return clients, json.NewDecoder(res.Body).Decode(&clients)
}
type GroupAIBudget struct {
GroupID uuid.UUID `json:"group_id" format:"uuid"`
SpendLimitMicros int64 `json:"spend_limit_micros"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
type UpsertGroupAIBudgetRequest struct {
SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"`
}
// GroupAIBudget returns the AI spend budget configured for the given group.
func (c *Client) GroupAIBudget(ctx context.Context, group uuid.UUID) (GroupAIBudget, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/groups/%s/ai/budget", group.String()),
nil,
)
if err != nil {
return GroupAIBudget{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GroupAIBudget{}, ReadBodyAsError(res)
}
var resp GroupAIBudget
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// UpsertGroupAIBudget creates or updates the AI spend budget for the given group.
func (c *Client) UpsertGroupAIBudget(ctx context.Context, group uuid.UUID, req UpsertGroupAIBudgetRequest) (GroupAIBudget, error) {
res, err := c.Request(ctx, http.MethodPut,
fmt.Sprintf("/api/v2/groups/%s/ai/budget", group.String()),
req,
)
if err != nil {
return GroupAIBudget{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GroupAIBudget{}, ReadBodyAsError(res)
}
var resp GroupAIBudget
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// DeleteGroupAIBudget removes the AI spend budget for the given group.
func (c *Client) DeleteGroupAIBudget(ctx context.Context, group uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete,
fmt.Sprintf("/api/v2/groups/%s/ai/budget", group.String()),
nil,
)
if err != nil {
return xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}