mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: session list API (#23202)
<!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. --> _Disclaimer:_ _initially_ _produced_ _by_ _Claude_ _Opus_ _4\.6,_ _heavily_ _modified_ _and_ _reviewed_ _by_ _me._ Closes https://github.com/coder/internal/issues/1360 Adds a new `/api/v2/aibridge/sessions` API which returns "sessions". Sessions, as defined in the [RFC](https://www.notion.so/coderhq/AI-Bridge-Sessions-Threads-2ccd579be59280f28021d3baf7472fbe?source=copy_link), are a set of interceptions logically grouped by a session key issued by the client. The API design for this endpoint was done in [this doc](https://github.com/coder/internal/issues/1360). If the client has not provided a session ID, we will revert to the thread root ID, and if that's not present we use the interception's own ID (i.e. a session of a single interception - which is effectively what we show currently in our `/api/v2/aibridge/interceptions` API). The SQL query looks gnarly but it's relatively simple, and seems to perform well (~200ms) even when I import dogfood's `aibridge_*` tables into my workspace. If we need to improve performance on this later we can investigate materialized views, perhaps, but for now I don't think it's warranted. --- _The PR looks large but it's got a lot of generated code; the actual changes aren't huge._
This commit is contained in:
@@ -2,6 +2,7 @@ package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
@@ -22,8 +24,10 @@ import (
|
||||
|
||||
const (
|
||||
maxListInterceptionsLimit = 1000
|
||||
maxListSessionsLimit = 1000
|
||||
maxListModelsLimit = 1000
|
||||
defaultListInterceptionsLimit = 100
|
||||
defaultListSessionsLimit = 100
|
||||
defaultListModelsLimit = 100
|
||||
// aiBridgeRateLimitWindow is the fixed duration for rate limiting AI Bridge
|
||||
// requests. This is hardcoded to keep configuration simple.
|
||||
@@ -43,6 +47,7 @@ func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) f
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middlewares...)
|
||||
r.Get("/interceptions", api.aiBridgeListInterceptions)
|
||||
r.Get("/sessions", api.aiBridgeListSessions)
|
||||
r.Get("/models", api.aiBridgeListModels)
|
||||
})
|
||||
|
||||
@@ -176,6 +181,130 @@ func (api *API) aiBridgeListInterceptions(rw http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
// aiBridgeListSessions returns AI Bridge sessions (aggregated interceptions).
|
||||
//
|
||||
// @Summary List AI Bridge sessions
|
||||
// @ID list-ai-bridge-sessions
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags AI Bridge
|
||||
// @Param q query string false "Search query in the format `key:value`. Available keys are: initiator, provider, model, client, session_id, started_after, started_before."
|
||||
// @Param limit query int false "Page limit"
|
||||
// @Param after_session_id query string false "Cursor pagination after session ID (cannot be used with offset)"
|
||||
// @Param offset query int false "Offset pagination (cannot be used with after_session_id)"
|
||||
// @Success 200 {object} codersdk.AIBridgeListSessionsResponse
|
||||
// @Router /aibridge/sessions [get]
|
||||
func (api *API) aiBridgeListSessions(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
|
||||
page, ok := coderd.ParsePagination(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
afterSessionID := r.URL.Query().Get("after_session_id")
|
||||
if afterSessionID != "" && page.Offset != 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameters have invalid values.",
|
||||
Detail: "Cannot use both after_session_id and offset pagination in the same request.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if page.Limit == 0 {
|
||||
page.Limit = defaultListSessionsLimit
|
||||
}
|
||||
if page.Limit > maxListSessionsLimit || 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]", maxListSessionsLimit),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
queryStr := r.URL.Query().Get("q")
|
||||
filter, errs := searchquery.AIBridgeSessions(ctx, api.Database, queryStr, page, apiKey.UserID, afterSessionID)
|
||||
if len(errs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid session search query.",
|
||||
Validations: errs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the cursor session exists before running the main query.
|
||||
if afterSessionID != "" {
|
||||
//nolint:exhaustruct // Only need session_id filter and limit.
|
||||
cursor, err := api.Database.ListAIBridgeSessions(ctx, database.ListAIBridgeSessionsParams{
|
||||
SessionID: afterSessionID,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "error validating after_session_id cursor", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error validating after_session_id cursor.",
|
||||
Detail: "", // Don't leak database issue to client.
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(cursor) == 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Detail: fmt.Sprintf("after_session_id: session %q not found", afterSessionID),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
count int64
|
||||
rows []database.ListAIBridgeSessionsRow
|
||||
)
|
||||
err := api.Database.InTx(func(db database.Store) error {
|
||||
var err error
|
||||
count, err = db.CountAIBridgeSessions(ctx, database.CountAIBridgeSessionsParams{
|
||||
StartedAfter: filter.StartedAfter,
|
||||
StartedBefore: filter.StartedBefore,
|
||||
InitiatorID: filter.InitiatorID,
|
||||
Provider: filter.Provider,
|
||||
Model: filter.Model,
|
||||
Client: filter.Client,
|
||||
SessionID: filter.SessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("count authorized aibridge sessions: %w", err)
|
||||
}
|
||||
|
||||
rows, err = db.ListAIBridgeSessions(ctx, filter)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list aibridge sessions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, &database.TxOptions{
|
||||
Isolation: sql.LevelRepeatableRead, // Consistency across queries tables while writes may be occurring.
|
||||
ReadOnly: true,
|
||||
TxIdentifier: "aibridge_list_sessions",
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error getting AI Bridge sessions.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessions := make([]codersdk.AIBridgeSession, len(rows))
|
||||
for i, row := range rows {
|
||||
sessions[i] = db2sdk.AIBridgeSession(row)
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AIBridgeListSessionsResponse{
|
||||
Count: count,
|
||||
Sessions: sessions,
|
||||
})
|
||||
}
|
||||
|
||||
// aiBridgeListModels returns all AI Bridge models a user can see.
|
||||
//
|
||||
// @Summary List AI Bridge models
|
||||
|
||||
Reference in New Issue
Block a user