Files
coder/coderd/exp_chats.go
T
Danielle Maywood 63a95d87f3 feat(coderd): key chat diff statuses by ref (#29081)
Part 1 of 6.

`chat_diff_statuses` had one row per chat, so a chat could only track
one PR, and switching branches overwrote the old branch's PR data. The
primary key is now `(chat_id, git_remote_origin, git_branch)`, so each
ref the agent reports gets its own row. The API still returns a single
`diff_status`: the first row of the chat's statuses, ordered by
`updated_at`, newest report first. The primary ref changes only when the
agent reports a ref: refreshes, claims, backoffs and URL writes do not
advance `updated_at`, so they cannot reorder the primary.

The gitsync worker now claims, backs off and clears rows by the full
key, not the whole chat. When the diff GET discovers a PR URL, it
updates the existing ref row and no longer inserts one. Telemetry picks
one row per chat.

**NOTE**: Migration `000606` swaps the primary key under an ACCESS
EXCLUSIVE lock. The table is small. Existing rows already fit the new
key, so no data is rewritten. The down migration keeps only the primary
row per chat. Stop old coderd instances before migrating: their `ON
CONFLICT (chat_id)` upserts fail once the old key is gone.

Generated by Coder Agents.
2026-10-01 10:59:27 +01:00

9066 lines
301 KiB
Go

package coderd
import (
"cmp"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"path"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/x/agenthooks/dispatch"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/agentselect"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chathooks"
"github.com/coder/coder/v2/coderd/x/chatd/chatopenai"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/coderd/x/gitsync"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/quartz"
"github.com/coder/websocket"
)
const (
chatStreamBatchSize = 256
// maxChatRequestBodyBytes is the maximum JSON body size for chat
// creation and tool result requests.
maxChatRequestBodyBytes = 256 * 1024
defaultChatContextCompressionThreshold = int32(70)
// Large-context models (1M) compact earlier; 70% of a 1M window
// carries too much stale context.
largeContextDefaultCompressionThreshold = int32(30)
largeContextLimitTokens = int64(500_000)
minChatContextCompressionThreshold = int32(0)
maxChatContextCompressionThreshold = int32(100)
)
// defaultCompressionThresholdForContextLimit returns the compaction
// threshold used when compression_threshold is omitted at creation.
func defaultCompressionThresholdForContextLimit(contextLimit int64) int32 {
if contextLimit >= largeContextLimitTokens {
return largeContextDefaultCompressionThreshold
}
return defaultChatContextCompressionThreshold
}
var allowedReasoningEffortValues = strings.Join(codersdk.ChatModelReasoningEffortValues(), ", ")
// chatGitRef holds the branch, remote origin, and optional chat
// ID reported by the workspace agent during a git operation.
type chatGitRef struct {
Branch string
RemoteOrigin string
ChatID uuid.UUID
}
type chatRepositoryRef struct {
Provider string
RemoteOrigin string
Branch string
Owner string
Repo string
}
type chatDiffReference struct {
PullRequestURL string
RepositoryRef *chatRepositoryRef
}
// Avoid returning raw dispatch errors, which may expose deployment internals.
func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, hookErr *dispatch.Error) {
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.ChatHookDispatchFailedResponse{
Response: codersdk.Response{
Message: "Chat lifecycle hook dispatch failed.",
Detail: fmt.Sprintf("Lifecycle hook dispatch %s failed (%s).", hookErr.DispatchID, hookErr.Class),
},
Kind: codersdk.ChatErrorKindHookDispatchFailed,
})
}
// writeChatHookErr writes the response for lifecycle hook denials and
// dispatch failures, reporting whether it handled the error. The fallback
// message is used when the hook denies without a user message.
func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, deniedFallback string) bool {
if denied, ok := errors.AsType[*chathooks.UserPromptDeniedError](err); ok {
message := denied.UserMessage
if message == "" {
message = deniedFallback
}
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.ChatHookDeniedResponse{
Response: codersdk.Response{Message: message},
Kind: codersdk.ChatErrorKindHookDenied,
})
return true
}
if hookErr, ok := errors.AsType[*dispatch.Error](err); ok {
writeChatHookDispatchFailed(ctx, rw, hookErr)
return true
}
return false
}
// AI Gateway budget rejections and provider quota failures classify as usage
// limits; synchronous generation reports them as conflicts instead of 500s.
func maybeWriteChatUsageLimitError(ctx context.Context, rw http.ResponseWriter, err error) bool {
classified := chaterror.Classify(err)
if classified.Kind != codersdk.ChatErrorKindUsageLimit {
return false
}
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: classified.Message,
})
return true
}
// statusClientClosedRequest is nginx's non-standard 499 status code,
// used here to distinguish a client-initiated cancel from a server-
// side failure when the manual title generation context is canceled.
const statusClientClosedRequest = 499
// maybeWriteManualTitleTimeoutErr translates context-cancel or
// title-timeout errors from the manual title pipeline into friendly
// 499/504 responses instead of a raw 500 that leaks the wrapped error
// chain. The errors bubble up wrapped, so match with errors.Is. Returns
// true when a response was written.
//
// The 499 branch additionally requires the request context itself to be
// canceled. A provider error can wrap context.Canceled (for example an
// upstream 401) while the caller context is still active; without the
// ctx.Err() guard such a provider failure would be misreported as a
// client-closed request instead of surfacing through the 500 path.
//
// The 504 branch keys off chatd.ErrManualTitleTimedOut, which chatd
// attaches only when the title-generation deadline actually expired. A
// provider failure whose chain merely contains an unrelated transport
// deadline is not tagged and keeps its provider-failure surface.
func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled):
httpapi.Write(ctx, rw, statusClientClosedRequest, codersdk.Response{
Message: "Title generation was canceled.",
})
return true
case errors.Is(err, chatd.ErrManualTitleTimedOut):
httpapi.Write(ctx, rw, http.StatusGatewayTimeout, codersdk.Response{
Message: "Title generation timed out. Try again or rename manually.",
})
return true
}
return false
}
// requireChatDaemon reports whether the chat daemon exists, writing a 503
// Service Unavailable with a remediation message when it does not. The
// daemon is nil when the in-memory AI Gateway is disabled by deployment
// config. Operations that depend on it (creating, mutating, or streaming a
// chat) must call this; pure reads (e.g. getChat) do not.
func (api *API) requireChatDaemon(ctx context.Context, rw http.ResponseWriter) bool {
if api.chatDaemon != nil {
return true
}
httpapi.Write(ctx, rw, http.StatusServiceUnavailable, codersdk.Response{
Message: "AI Gateway must be enabled for Coder Agents functionality. Please contact your deployment administrator.",
Detail: "Set CODER_AI_GATEWAY_ENABLED=true (or ai-gateway-enabled in deployment YAML) to enable.",
})
return false
}
func publishChatConfigEvent(logger slog.Logger, ps dbpubsub.Pubsub, kind pubsub.ChatConfigEventKind, entityID uuid.UUID) {
payload, err := json.Marshal(pubsub.ChatConfigEvent{
Kind: kind,
EntityID: entityID,
})
if err != nil {
logger.Error(context.Background(), "failed to marshal chat config event",
slog.F("kind", kind),
slog.F("entity_id", entityID),
slog.Error(err),
)
return
}
if err := ps.Publish(pubsub.ChatConfigEventChannel, payload); err != nil {
logger.Error(context.Background(), "failed to publish chat config event",
slog.F("kind", kind),
slog.F("entity_id", entityID),
slog.Error(err),
)
}
}
// @Summary Watch chat events for a user via WebSockets
// @ID watch-chat-events-for-a-user-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatWatchEvent
// @Router /api/v2/chats/watch [get]
func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
logger := api.Logger.Named("chat_watcher")
// Subscribe before accepting the websocket so the subscription
// is active when the client's Dial returns.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var (
encoder *json.Encoder
encoderReady = make(chan struct{})
// Capture before WebsocketNetConn reassigns ctx (data race).
ctxDone = ctx.Done()
)
cancelSubscribe, err := api.Pubsub.SubscribeWithErr(pubsub.ChatWatchEventChannel(apiKey.UserID),
pubsub.HandleChatWatchEvent(
func(cbCtx context.Context, payload codersdk.ChatWatchEvent, err error) {
if err != nil {
logger.Error(cbCtx, "chat watch event subscription error", slog.Error(err))
return
}
select {
case <-encoderReady:
case <-ctxDone:
return
case <-cbCtx.Done():
return
}
// encoderReady may close with encoder still nil on error paths.
if encoder == nil {
return
}
// The encoder is only written from the pubsub delivery
// goroutine, which processes messages serially. Do not
// add a second write path without synchronization.
if err := encoder.Encode(payload); err != nil {
logger.Debug(cbCtx, "failed to send chat watch event", slog.Error(err))
cancel()
return
}
},
))
if err != nil {
close(encoderReady)
logger.Error(ctx, "failed to subscribe to chat watch events", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to subscribe to chat events.",
Detail: err.Error(),
})
return
}
defer cancelSubscribe()
conn, err := websocket.Accept(rw, r, nil)
if err != nil {
close(encoderReady)
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to open chat watch stream.",
Detail: err.Error(),
})
return
}
_ = conn.CloseRead(context.Background())
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
defer wsNetConn.Close()
ctx = api.wsWatcher.Watch(ctx, logger, conn)
encoder = json.NewEncoder(wsNetConn)
close(encoderReady)
<-ctx.Done()
}
// the latest non-archived chat ID for each requested workspace.
// The query returns all matching chats and RBAC post-filters them;
// the handler then picks the latest per workspace in Go. This avoids
// the DISTINCT ON + post-filter bug where the sole candidate is
// silently dropped when the caller can't read it.
//
// TODO: move aggregation to a SQL view with proper in-query authz so the
// handler can return a single row per workspace without this two-pass approach.
type chatsByWorkspaceResponse map[uuid.UUID]uuid.UUID
// @Summary List chats by workspace
// @ID list-chats-by-workspace
// @Security CoderSessionToken
// @Tags Chats
// @Param workspace_ids query string false "Comma-separated workspace IDs"
// @Produce json
// @Success 200 {object} chatsByWorkspaceResponse
// @Router /api/v2/chats/by-workspace [get]
func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idsParam := r.URL.Query().Get("workspace_ids")
if idsParam == "" {
httpapi.Write(ctx, rw, http.StatusOK, chatsByWorkspaceResponse{})
return
}
raw := strings.Split(idsParam, ",")
// maxWorkspaceIDs is coupled to DEFAULT_RECORDS_PER_PAGE (25) in
// site/src/components/PaginationWidget/utils.ts.
// If the page size changes, this limit should too.
const maxWorkspaceIDs = 25
if len(raw) > maxWorkspaceIDs {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Too many workspace IDs, maximum is %d.", maxWorkspaceIDs),
})
return
}
workspaceIDs := make([]uuid.UUID, 0, len(raw))
for _, s := range raw {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid workspace ID %q: %s", s, err),
})
return
}
workspaceIDs = append(workspaceIDs, id)
}
chats, err := api.Database.GetChatsByWorkspaceIDs(ctx, workspaceIDs)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chats by workspace.",
Detail: err.Error(),
})
return
}
// The SQL orders by (workspace_id, updated_at DESC), so the first
// chat seen per workspace after RBAC filtering is the latest
// readable one.
result := make(chatsByWorkspaceResponse, len(chats))
for _, chat := range chats {
if chat.WorkspaceID.Valid {
if _, exists := result[chat.WorkspaceID.UUID]; !exists {
result[chat.WorkspaceID.UUID] = chat.ID
}
}
}
httpapi.Write(ctx, rw, http.StatusOK, result)
}
// @Summary List chats
// @ID list-chats
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param q query string false "Search query. Supports `title:<substring>` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `status:<waiting\|running\|error\|requires_action\|interrupting>` (chat status, repeated or comma-separated), `pr_status:<draft\|open\|merged\|closed\|none>` (none matches chats with no pull request) as repeated or comma-separated values, `source:<created_by_me\|shared_with_me>`, `diff_url:<url>` (quote values containing colons), `pr:<number>` (exact PR number match), `repo:<owner/repo>` (case-insensitive substring match against git remote origin or URL), `pr_title:<text>` (case-insensitive PR title substring), `search:<text>` (full-text search across chat titles, PR titles, PR numbers, and message bodies; message bodies match English word stems, e.g. `refactor` matches `refactoring`, and ignore English stopwords; titles and PR titles match whole words case-insensitively without stemming; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, e.g. punctuation only, returns an empty list). Bare terms are not supported; use `title:<value>` or `search:<value>`."
// @Param label query []string false "Filter by label as key:value. Repeat for multiple (AND logic)." collectionFormat(multi)
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {array} codersdk.Chat
// @Router /api/v2/chats [get]
func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
paginationParams, ok := ParsePagination(rw, r)
if !ok {
return
}
queryStr := r.URL.Query().Get("q")
searchParams, errs := searchquery.Chats(queryStr)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat search query.",
Validations: errs,
})
return
}
var labelFilter pqtype.NullRawMessage
if labelParams := r.URL.Query()["label"]; len(labelParams) > 0 {
labelMap := make(map[string]string, len(labelParams))
for _, lp := range labelParams {
key, value, ok := strings.Cut(lp, ":")
if !ok || key == "" || value == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid label filter: %q (expected format key:value, both must be non-empty)", lp),
})
return
}
labelMap[key] = value
}
labelsJSON, err := json.Marshal(labelMap)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal label filter.",
Detail: err.Error(),
})
return
}
labelFilter = pqtype.NullRawMessage{
RawMessage: labelsJSON,
Valid: true,
}
}
var sharedWithGroupIDs []string
if searchParams.SharedOnly {
groups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{HasMemberID: apiKey.UserID})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chats.",
Detail: err.Error(),
})
return
}
sharedWithGroupIDs = make([]string, 0, len(groups))
for _, group := range groups {
sharedWithGroupIDs = append(sharedWithGroupIDs, group.Group.ID.String())
}
}
params := database.GetChatsParams{
OwnedOnly: searchParams.OwnedOnly,
ViewerID: apiKey.UserID,
SharedOnly: searchParams.SharedOnly,
SharedWithUserID: apiKey.UserID,
SharedWithGroupIds: sharedWithGroupIDs,
Archived: searchParams.Archived,
AfterID: paginationParams.AfterID,
LabelFilter: labelFilter,
DiffURL: searchParams.DiffURL,
TitleQuery: searchParams.TitleQuery,
HasUnread: searchParams.HasUnread,
ChatStatuses: searchParams.ChatStatuses,
PullRequestStatuses: searchParams.PullRequestStatuses,
PrNumber: searchParams.PrNumber,
RepoQuery: searchParams.RepoQuery,
PrTitleQuery: searchParams.PrTitleQuery,
Search: searchParams.Search,
// #nosec G115 - Pagination offsets are small and fit in int32
OffsetOpt: int32(paginationParams.Offset),
// #nosec G115 - Pagination limits are small and fit in int32
LimitOpt: int32(paginationParams.Limit),
}
chatRows, err := api.Database.GetChats(ctx, params)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chats.",
Detail: err.Error(),
})
return
}
// Collect root chat IDs so we can fetch their children.
rootIDs := make([]uuid.UUID, len(chatRows))
for i, row := range chatRows {
rootIDs[i] = row.Chat.ID
}
// Embed children matching the caller's archive filter so
// sidebar views don't surface state-mismatched rows.
var childRows []database.GetChildChatsByParentIDsRow
if len(rootIDs) > 0 {
childRows, err = api.Database.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: rootIDs,
Archived: searchParams.Archived,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list child chats.",
Detail: err.Error(),
})
return
}
}
// Collect all chat objects (root + child) for diff status lookup.
allChats := make([]database.Chat, 0, len(chatRows)+len(childRows))
for _, row := range chatRows {
allChats = append(allChats, row.Chat)
}
for _, row := range childRows {
allChats = append(allChats, row.Chat)
}
diffStatusesByChatID, err := api.getChatDiffStatusesByChatID(ctx, allChats)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chats.",
Detail: err.Error(),
})
return
}
sdkChats := db2sdk.ChatRowsWithChildren(chatRows, childRows, diffStatusesByChatID)
api.enrichChatsWithMissingAgentIDs(ctx, sdkChats)
httpapi.Write(ctx, rw, http.StatusOK, sdkChats)
}
// enrichChatsWithMissingAgentIDs skips existing bindings on list reads to avoid
// one authorization check per bound workspace.
func (api *API) enrichChatsWithMissingAgentIDs(ctx context.Context, chats []codersdk.Chat) {
api.enrichChatAgentIDs(ctx, chats, func(chat *codersdk.Chat) bool {
return chat.AgentID == nil
})
}
// repairChatAgentIDs handles stale bindings left by workspace rebuilds. List
// reads skip this work to avoid authorization checks for bound workspaces.
func (api *API) repairChatAgentIDs(ctx context.Context, chats []codersdk.Chat) {
api.enrichChatAgentIDs(ctx, chats, func(*codersdk.Chat) bool {
return true
})
}
// enrichChatAgentIDs performs best-effort response-only updates.
func (api *API) enrichChatAgentIDs(ctx context.Context, chats []codersdk.Chat, shouldEnrich func(*codersdk.Chat) bool) {
candidateChats := make([]*codersdk.Chat, 0, len(chats))
var workspaceIDs []uuid.UUID
addCandidate := func(chat *codersdk.Chat) {
if chat.WorkspaceID == nil || !shouldEnrich(chat) {
return
}
candidateChats = append(candidateChats, chat)
workspaceIDs = append(workspaceIDs, *chat.WorkspaceID)
}
for i := range chats {
addCandidate(&chats[i])
for j := range chats[i].Children {
addCandidate(&chats[i].Children[j])
}
}
if len(candidateChats) == 0 {
return
}
slices.SortFunc(workspaceIDs, func(a, b uuid.UUID) int {
return cmp.Compare(a.String(), b.String())
})
ids := slices.Compact(workspaceIDs)
rows, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(ctx, ids)
if err != nil {
return
}
agentsByWorkspace := make(map[uuid.UUID][]database.WorkspaceAgent)
latestBuildIDs := make(map[uuid.UUID]uuid.UUID)
for _, row := range rows {
agentsByWorkspace[row.WorkspaceID] = append(agentsByWorkspace[row.WorkspaceID], row.WorkspaceAgent)
latestBuildIDs[row.WorkspaceID] = row.BuildID
}
agentIDs := make(map[uuid.UUID]uuid.UUID, len(agentsByWorkspace))
for workspaceID, agents := range agentsByWorkspace {
agent, err := agentselect.FindChatAgent(agents)
if err != nil {
api.Logger.Debug(ctx, "failed to select chat agent for enrichment", slog.F("workspace_id", workspaceID), slog.Error(err))
continue
}
agentIDs[workspaceID] = agent.ID
}
for _, chat := range candidateChats {
// Preserve bindings that still resolve in the latest build instead
// of replacing them with the selected agent.
if chat.AgentID != nil && slices.ContainsFunc(
agentsByWorkspace[*chat.WorkspaceID],
func(agent database.WorkspaceAgent) bool { return agent.ID == *chat.AgentID },
) {
continue
}
if agentID, ok := agentIDs[*chat.WorkspaceID]; ok {
id := agentID
chat.AgentID = &id
// Pair the agent with its build so the response never mixes
// the latest build's agent with a previous build's ID.
buildID := latestBuildIDs[*chat.WorkspaceID]
chat.BuildID = &buildID
}
}
}
func (api *API) getChatDiffStatusesByChatID(
ctx context.Context,
chats []database.Chat,
) (map[uuid.UUID]database.ChatDiffStatus, error) {
if len(chats) == 0 {
return map[uuid.UUID]database.ChatDiffStatus{}, nil
}
chatIDs := make([]uuid.UUID, 0, len(chats))
for _, chat := range chats {
chatIDs = append(chatIDs, chat.ID)
}
statuses, err := api.Database.GetChatDiffStatusesByChatIDs(ctx, chatIDs)
if err != nil {
return nil, xerrors.Errorf("get chat diff statuses: %w", err)
}
statusesByChatID := make(map[uuid.UUID]database.ChatDiffStatus, len(statuses))
for _, status := range statuses {
if _, ok := statusesByChatID[status.ChatID]; ok {
continue
}
statusesByChatID[status.ChatID] = status
}
return statusesByChatID, nil
}
func planModeToNullChatPlanMode(mode codersdk.ChatPlanMode) database.NullChatPlanMode {
if mode == "" {
return database.NullChatPlanMode{}
}
return database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanMode(mode),
Valid: true,
}
}
func validateChatPlanMode(mode codersdk.ChatPlanMode) bool {
switch mode {
case "", codersdk.ChatPlanModePlan:
return true
default:
return false
}
}
func lookupEnabledChatModelConfigByID(
ctx context.Context,
db database.Store,
id uuid.UUID,
) (database.ChatModelConfig, error) {
return db.GetEnabledChatModelConfigByID(ctx, id)
}
func parseChatModelCallConfig(options json.RawMessage) (*codersdk.ChatModelCallConfig, error) {
callConfig := &codersdk.ChatModelCallConfig{}
if len(options) == 0 {
return callConfig, nil
}
if err := json.Unmarshal(options, callConfig); err != nil {
return nil, err
}
return callConfig, nil
}
func validateChatModelOverrideEffort(
modelConfig database.ChatModelConfig,
effort *string,
) (int, *codersdk.Response) {
if effort == nil {
return 0, nil
}
if !chatprovider.IsValidReasoningEffort(*effort) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: "Must be one of none, minimal, low, medium, high, xhigh, max.",
}
}
callConfig, err := parseChatModelCallConfig(modelConfig.Options)
if err != nil {
return http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating reasoning effort.",
Detail: err.Error(),
}
}
selectableEfforts := chatprovider.SelectableReasoningEfforts(callConfig.ReasoningEffort)
if len(selectableEfforts) == 0 {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: "This model does not support reasoning effort.",
}
}
if !slices.Contains(selectableEfforts, *effort) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: "Must be one of " + strings.Join(selectableEfforts, ", ") + ".",
}
}
return 0, nil
}
func validateChatModelOverride(
ctx context.Context,
db database.Store,
organizationID uuid.UUID,
id *uuid.UUID,
effort *string,
) (int, *codersdk.Response) {
if id == nil {
if effort != nil {
return http.StatusBadRequest, &codersdk.Response{
Message: "reasoning_effort requires model_config_id.",
}
}
return 0, nil
}
if *id == uuid.Nil {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
modelConfig, err := lookupEnabledChatModelConfigByID(ctx, db, *id)
if err == nil && modelConfig.OrganizationID != organizationID {
err = sql.ErrNoRows
}
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
return http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
}
}
return validateChatModelOverrideEffort(modelConfig, effort)
}
func parseChatModelOverrideContext(raw string) (codersdk.ChatModelOverrideContext, error) {
overrideContext := codersdk.ChatModelOverrideContext(raw)
if overrideContext.Valid() {
return overrideContext, nil
}
return "", xerrors.Errorf("unknown chat model override context %q", raw)
}
var chatPersonalModelOverrideContexts = []codersdk.ChatPersonalModelOverrideContext{
codersdk.ChatPersonalModelOverrideContextRoot,
codersdk.ChatPersonalModelOverrideContextGeneral,
codersdk.ChatPersonalModelOverrideContextExplore,
}
func parseChatPersonalModelOverrideContext(raw string) (codersdk.ChatPersonalModelOverrideContext, bool) {
c := codersdk.ChatPersonalModelOverrideContext(raw)
return c, slices.Contains(chatPersonalModelOverrideContexts, c)
}
func chatPersonalModelOverrideContextsJoined() string {
values := make([]string, 0, len(chatPersonalModelOverrideContexts))
for _, overrideContext := range chatPersonalModelOverrideContexts {
values = append(values, string(overrideContext))
}
return strings.Join(values, ", ")
}
func defaultChatPersonalModelOverrideMode(
overrideContext codersdk.ChatPersonalModelOverrideContext,
) codersdk.ChatPersonalModelOverrideMode {
if overrideContext == codersdk.ChatPersonalModelOverrideContextRoot {
return codersdk.ChatPersonalModelOverrideModeChatDefault
}
return codersdk.ChatPersonalModelOverrideModeDeploymentDefault
}
func chatPersonalModelOverrideResponse(
overrideContext codersdk.ChatPersonalModelOverrideContext,
row *database.ChatUserModelOverride,
) codersdk.ChatPersonalModelOverride {
response := codersdk.ChatPersonalModelOverride{
Context: overrideContext,
Mode: defaultChatPersonalModelOverrideMode(overrideContext),
}
if row == nil {
return response
}
response.Mode = codersdk.ChatPersonalModelOverrideMode(row.Mode)
response.IsSet = true
if row.ModelConfigID.Valid {
response.ModelConfigID = row.ModelConfigID.UUID.String()
}
if row.ReasoningEffort.Valid {
response.ReasoningEffort = &row.ReasoningEffort.String
}
return response
}
func derefOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}
func chatOrganizationModelOverrideResponse(
row database.ChatOrganizationModelOverride,
) codersdk.ChatModelOverrideResponse {
response := codersdk.ChatModelOverrideResponse{
Context: codersdk.ChatModelOverrideContext(row.Context),
ModelConfigID: row.ModelConfigID.String(),
}
if row.ReasoningEffort.Valid {
response.ReasoningEffort = &row.ReasoningEffort.String
}
return response
}
func (api *API) chatPersonalModelOverrideDeploymentDefaultResponse(
ctx context.Context,
organizationID uuid.UUID,
overrideContext codersdk.ChatModelOverrideContext,
) (codersdk.ChatModelOverrideResponse, error) {
row, err := api.Database.GetChatOrganizationModelOverride(ctx, database.GetChatOrganizationModelOverrideParams{
OrganizationID: organizationID,
Context: string(overrideContext),
})
if errors.Is(err, sql.ErrNoRows) {
return codersdk.ChatModelOverrideResponse{Context: overrideContext}, nil
}
if err != nil {
return codersdk.ChatModelOverrideResponse{}, err
}
return chatOrganizationModelOverrideResponse(row), nil
}
func (api *API) chatPersonalModelOverrideDeploymentDefaults(
ctx context.Context,
organizationID uuid.UUID,
) (codersdk.ChatPersonalModelOverrideDeploymentDefaults, error) {
general, err := api.chatPersonalModelOverrideDeploymentDefaultResponse(
ctx,
organizationID,
codersdk.ChatModelOverrideContextGeneral,
)
if err != nil {
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{}, err
}
explore, err := api.chatPersonalModelOverrideDeploymentDefaultResponse(
ctx,
organizationID,
codersdk.ChatModelOverrideContextExplore,
)
if err != nil {
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{}, err
}
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{
General: general,
Explore: explore,
}, nil
}
type userChatModelAvailability struct {
configuredProviders []chatprovider.ConfiguredProvider
enabledModels []database.ChatModelConfig
providerStatusByID map[uuid.UUID]chatprovider.ProviderAvailability
enabledProviderIDs map[uuid.UUID]struct{}
}
// chatModelConfigUnavailableReason reports why a model config cannot be used.
// The empty value means the model config is available. Callers must check the
// error returned by userCanUseChatModelConfig before interpreting this value.
type chatModelConfigUnavailableReason string
const (
chatModelConfigAvailable chatModelConfigUnavailableReason = ""
chatModelConfigUnavailableModelNotFoundOrDisabled chatModelConfigUnavailableReason = "model_not_found_or_disabled"
chatModelConfigUnavailableProviderDisabled chatModelConfigUnavailableReason = "provider_disabled"
chatModelConfigUnavailableCredentialsMissing chatModelConfigUnavailableReason = "credentials_missing"
chatModelConfigUnavailableOutsideOrganization chatModelConfigUnavailableReason = "outside_organization"
)
// getUserChatProviderAvailability returns the enabled chat providers and models
// the user can access in one organization. Provider configuration uses Chatd
// access. Model configs and user keys use the caller's authorization context.
func (api *API) getUserChatProviderAvailability(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
) (userChatModelAvailability, error) {
//nolint:gocritic // Chatd context is required to read enabled chat providers.
chatdCtx := dbauthz.AsChatd(ctx)
enabledProviders, err := api.Database.GetAIProviders(chatdCtx, database.GetAIProvidersParams{})
if err != nil {
return userChatModelAvailability{}, err
}
modelRows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return userChatModelAvailability{}, err
}
effectiveModels := database.DeriveEffectiveChatModelConfigs(modelRows)
enabledModels := make([]database.ChatModelConfig, 0, len(effectiveModels.Configs))
for _, row := range effectiveModels.Configs {
enabledModels = append(enabledModels, row.ChatModelConfig)
}
configuredProviders, err := api.configuredProvidersFromAIProviders(chatdCtx, enabledProviders)
if err != nil {
return userChatModelAvailability{}, err
}
availability := userChatModelAvailability{
configuredProviders: configuredProviders,
enabledModels: enabledModels,
enabledProviderIDs: make(map[uuid.UUID]struct{}, len(enabledProviders)),
providerStatusByID: make(map[uuid.UUID]chatprovider.ProviderAvailability, len(enabledProviders)),
}
for _, configuredProvider := range configuredProviders {
if configuredProvider.ProviderID != uuid.Nil {
availability.enabledProviderIDs[configuredProvider.ProviderID] = struct{}{}
}
}
userKeys := []chatprovider.UserProviderKey{}
if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
userKeyStatus, err := api.userAIProviderKeyStatusByProviderID(ctx, userID)
if err != nil {
return userChatModelAvailability{}, err
}
userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyStatus))
for providerID, configured := range userKeyStatus {
if configured {
userKeys = append(userKeys, chatprovider.UserProviderKey{ChatProviderID: providerID, APIKey: "configured"})
}
}
}
for _, configuredProvider := range configuredProviders {
normalizedProvider := chatprovider.NormalizeProvider(configuredProvider.Provider)
if normalizedProvider == "" {
continue
}
_, providerStatus := chatprovider.ResolveUserProviderKeys(
chatprovider.ProviderAPIKeys{},
[]chatprovider.ConfiguredProvider{configuredProvider},
userKeys,
)
status, ok := providerStatus[normalizedProvider]
if ok && configuredProvider.ProviderID != uuid.Nil {
availability.providerStatusByID[configuredProvider.ProviderID] = status
}
}
return availability, nil
}
// userCanUseChatModelConfig returns chatModelConfigAvailable when the user can
// use the model config. If err is non-nil, callers must ignore the returned
// reason because it may be the zero-value availability sentinel.
func (api *API) userCanUseChatModelConfig(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, chatModelConfigUnavailableReason, error) {
if modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
model, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
return database.ChatModelConfig{}, chatModelConfigAvailable, err
}
if model.OrganizationID != organizationID {
return database.ChatModelConfig{}, chatModelConfigUnavailableOutsideOrganization, nil
}
if !model.Enabled {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
availability, err := api.getUserChatProviderAvailability(ctx, userID, organizationID)
if err != nil {
return database.ChatModelConfig{}, chatModelConfigAvailable, err
}
if model.AIProviderID.Valid {
providerID := model.AIProviderID.UUID
if _, ok := availability.enabledProviderIDs[providerID]; !ok {
return database.ChatModelConfig{}, chatModelConfigUnavailableProviderDisabled, nil
}
providerStatus, ok := availability.providerStatusByID[providerID]
if !ok {
return database.ChatModelConfig{}, chatModelConfigUnavailableProviderDisabled, nil
}
if !providerStatus.Available {
return database.ChatModelConfig{}, chatModelConfigUnavailableCredentialsMissing, nil
}
return model, chatModelConfigAvailable, nil
}
// Active configs always carry a provider FK (CHECK
// chat_model_configs_ai_provider_required_when_active), so an unset FK
// means the config is not usable.
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
func validateUserChatModelConfigAvailability(
modelConfig database.ChatModelConfig,
reason chatModelConfigUnavailableReason,
) (database.ChatModelConfig, int, *codersdk.Response) {
switch reason {
case chatModelConfigAvailable:
return modelConfig, 0, nil
case chatModelConfigUnavailableModelNotFoundOrDisabled,
chatModelConfigUnavailableOutsideOrganization:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id: model config not found or disabled.",
}
case chatModelConfigUnavailableCredentialsMissing:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id: provider credentials unavailable for this model.",
}
case chatModelConfigUnavailableProviderDisabled:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id: provider is not enabled for this model.",
}
default:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
}
func (api *API) validateUserChatModelConfigAvailable(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, int, *codersdk.Response) {
modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, organizationID, modelConfigID)
if err != nil {
return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
}
}
if reason != chatModelConfigAvailable &&
reason != chatModelConfigUnavailableModelNotFoundOrDisabled &&
reason != chatModelConfigUnavailableCredentialsMissing &&
reason != chatModelConfigUnavailableProviderDisabled &&
reason != chatModelConfigUnavailableOutsideOrganization {
api.Logger.Warn(ctx,
"unknown chat model config availability reason",
slog.F("user_id", userID),
slog.F("model_config_id", modelConfigID),
slog.F("reason", reason),
)
}
return validateUserChatModelConfigAvailability(modelConfig, reason)
}
// validateExplicitChatModelConfigAvailable validates a caller-supplied
// model config ID. A nil ID keeps the chat's current model and is
// validated by the daemon's fallback resolution instead.
func (api *API) validateExplicitChatModelConfigAvailable(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (int, *codersdk.Response) {
if modelConfigID == uuid.Nil {
return 0, nil
}
_, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, organizationID, modelConfigID)
return status, resp
}
func validateChatMCPServerIDs(
ctx context.Context,
db database.Store,
organizationID uuid.UUID,
ids []uuid.UUID,
) (normalized []uuid.UUID, invalid []uuid.UUID, err error) {
unique := make([]uuid.UUID, 0, len(ids))
seen := make(map[uuid.UUID]struct{}, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return unique, nil, nil
}
configs, err := db.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{
OrganizationID: organizationID,
IDs: unique,
})
if err != nil {
return nil, nil, xerrors.Errorf("get enabled MCP server configs for organization: %w", err)
}
valid := make(map[uuid.UUID]struct{}, len(configs))
for _, config := range configs {
valid[config.ID] = struct{}{}
}
invalid = make([]uuid.UUID, 0, len(unique)-len(valid))
for _, id := range unique {
if _, ok := valid[id]; !ok {
invalid = append(invalid, id)
}
}
return unique, invalid, nil
}
// normalizeRequestedChatMCPServerIDs validates a request's MCP server
// selection for an existing chat. When requested is nil there is no
// change to make. IDs already persisted on the chat are exempt from the
// enabled-in-organization check: a server that is disabled or revoked
// after selection must not block sends. The generation path skips
// servers the chat can no longer use, and keeping the ID preserves the
// selection if the server is re-enabled. A non-nil response indicates
// the caller must write it with the returned status and stop.
func (api *API) normalizeRequestedChatMCPServerIDs(ctx context.Context, chat database.Chat, requested *[]uuid.UUID) (*[]uuid.UUID, int, *codersdk.Response) {
if requested == nil {
return nil, 0, nil
}
normalized, invalid, err := validateChatMCPServerIDs(ctx, api.Database, chat.OrganizationID, *requested)
if err != nil {
return nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to validate MCP server IDs.",
Detail: err.Error(),
}
}
persisted := make(map[uuid.UUID]struct{}, len(chat.MCPServerIDs))
for _, id := range chat.MCPServerIDs {
persisted[id] = struct{}{}
}
newlyInvalid := make([]uuid.UUID, 0, len(invalid))
for _, id := range invalid {
if _, ok := persisted[id]; !ok {
newlyInvalid = append(newlyInvalid, id)
}
}
if len(newlyInvalid) > 0 {
resp := invalidChatMCPServerIDsResponse(newlyInvalid)
return nil, http.StatusBadRequest, &resp
}
return &normalized, 0, nil
}
func invalidChatMCPServerIDsResponse(ids []uuid.UUID) codersdk.Response {
invalid := make([]string, 0, len(ids))
for _, id := range ids {
invalid = append(invalid, id.String())
}
return codersdk.Response{
Message: "One or more MCP server IDs are invalid or disabled.",
Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")),
}
}
// @Summary Create chat
// @ID create-chat
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param request body codersdk.CreateChatRequest true "Create chat request"
// @Success 201 {object} codersdk.Chat
// @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB"
// @Router /api/v2/chats [post]
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
// Limit memory used to decode dynamic tool schemas.
var req codersdk.CreateChatRequest
if !httpapi.ReadLimit(ctx, rw, r, maxChatRequestBodyBytes, &req) {
return
}
aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
OrganizationID: req.OrganizationID,
})
defer commitAudit()
if req.OrganizationID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "organization_id is required.",
})
return
}
ownerID := apiKey.UserID
if req.OwnerID != nil {
if *req.OwnerID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid owner_id: must be a user ID or omitted.",
})
return
}
ownerID = *req.OwnerID
}
if ownerID == apiKey.UserID {
// Validate organization membership.
isMember, err := httpmw.UserAuthorization(ctx).HasOrganizationMembership(req.OrganizationID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate organization membership.",
Detail: xerrors.Errorf("check organization membership: %w", err).Error(),
})
return
}
if !isMember {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "You are not a member of the specified organization.",
})
return
}
}
// NOTE: This authorize check is intentionally placed after request
// parsing because we need req.OrganizationID to scope the RBAC check
// to the correct org. The request body is bounded by the ReadLimit above,
// limiting the cost of parsing before rejection.
chatObject := rbac.ResourceChat.WithOwner(ownerID.String()).InOrg(req.OrganizationID)
if !api.Authorize(r, policy.ActionCreate, chatObject) {
httpapi.Forbidden(rw)
return
}
// Chat processing runs with the owner's credentials (workspace access,
// OIDC and provider tokens), so creating a chat for another user is
// acting as that user. Org-scoped chat permissions are not enough:
// require the same authority the token endpoint demands to mint a
// session for that user.
ownerCtx := ctx
if ownerID != apiKey.UserID {
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceApiKey.WithOwner(ownerID.String())) {
httpapi.Forbidden(rw)
return
}
//nolint:gocritic // The caller may hold this authority without being able to read the owner's membership.
memberships, err := api.Database.OrganizationMembers(dbauthz.AsSystemRestricted(ctx), database.OrganizationMembersParams{
OrganizationID: req.OrganizationID,
UserID: ownerID,
IncludeSystem: false,
GithubUserID: 0,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// AI Bridge refuses to authorize inactive and system users, so a
// chat owned by one could never run. The query already omits
// system and deleted users.
if len(memberships) == 0 || memberships[0].Status != database.UserStatusActive {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat owner must be an active member of the organization.",
})
return
}
owner, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// From here on the request proceeds as the owner. The chat is the
// owner's and chatd runs it under the owner's ACLs, so the
// workspace, model config, MCP servers, and the creation itself
// must succeed for the owner, not merely for the caller.
ownerCtx = dbauthz.As(ctx, owner)
// Service accounts and custom roles may lack chat permissions
// entirely or hold only some of them. Creation inserts the first
// message and reads the chat back under the owner, so a partial
// grant would fail inside the transaction with a generic 403.
for _, action := range []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate} {
if !api.HTTPAuth.AuthorizeContext(ownerCtx, action, chatObject) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat owner does not have permission to use chats.",
})
return
}
}
}
contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
return
}
workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ownerCtx, req)
if validationError != nil {
httpapi.Write(ctx, rw, validationStatus, *validationError)
return
}
title := chatprompt.FallbackTitle(titleSource)
modelConfigID, personalOverrideEffort, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ownerCtx, ownerID, req)
if modelConfigError != nil {
httpapi.Write(ctx, rw, modelConfigStatus, *modelConfigError)
return
}
if !validateChatPlanMode(req.PlanMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
return
}
normalizedMCPServerIDs, invalidMCPServerIDs, err := validateChatMCPServerIDs(ownerCtx, api.Database, req.OrganizationID, req.MCPServerIDs)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate MCP server IDs.",
Detail: err.Error(),
})
return
}
req.MCPServerIDs = normalizedMCPServerIDs
if len(invalidMCPServerIDs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalidChatMCPServerIDsResponse(invalidMCPServerIDs))
return
}
mcpServerIDs := req.MCPServerIDs
if mcpServerIDs == nil {
mcpServerIDs = []uuid.UUID{}
}
labels := req.Labels
if labels == nil {
labels = map[string]string{}
}
if errs := httpapi.ValidateChatLabels(labels); len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid labels.",
Validations: errs,
})
return
}
if (len(req.UnsafeDynamicTools) > 0 || len(req.InlineMCPServers) > 0) && api.DeploymentValues.DisableChatCallerSuppliedTools.Value() {
writeChatCallerSuppliedToolsDisabled(ctx, rw)
return
}
if len(req.InlineMCPServers) > 0 {
if !api.Experiments.Enabled(codersdk.ExperimentChatInlineMCPServers) {
writeInlineMCPServersExperimentRequired(ctx, rw)
return
}
if validations := validateInlineMCPServers(req.InlineMCPServers, api.MCPAllowedPrivateCIDRs); len(validations) > 0 {
writeInlineMCPServersInvalid(ctx, rw, validations)
return
}
}
if len(req.UnsafeDynamicTools) > 250 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Too many dynamic tools.",
Detail: "Maximum 250 dynamic tools per chat.",
})
return
}
// Validate that dynamic tool names are non-empty and unique
// within the list. Name collision with built-in tools is
// checked at chatloop time when the full tool set is known.
if len(req.UnsafeDynamicTools) > 0 {
seenNames := make(map[string]struct{}, len(req.UnsafeDynamicTools))
for _, dt := range req.UnsafeDynamicTools {
if dt.Name == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Dynamic tool name must not be empty.",
})
return
}
if _, exists := seenNames[dt.Name]; exists {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Duplicate dynamic tool name.",
Detail: fmt.Sprintf("Tool %q appears more than once.", dt.Name),
})
return
}
seenNames[dt.Name] = struct{}{}
}
}
var dynamicToolsJSON json.RawMessage
if len(req.UnsafeDynamicTools) > 0 {
var err error
dynamicToolsJSON, err = json.Marshal(req.UnsafeDynamicTools)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal dynamic tools.",
Detail: err.Error(),
})
return
}
}
clientType := database.ChatClientTypeApi
if req.ClientType != "" {
clientType = database.ChatClientType(req.ClientType)
if !clientType.Valid() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid client_type.",
Detail: fmt.Sprintf("got %q, want one of %v", req.ClientType, database.AllChatClientTypeValues()),
})
return
}
}
reasoningEffort := req.ReasoningEffort
if reasoningEffort == nil {
reasoningEffort = personalOverrideEffort
}
if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*reasoningEffort))
return
}
chat, err := api.chatDaemon.CreateChat(ownerCtx, chatd.CreateOptions{
OrganizationID: req.OrganizationID,
OwnerID: ownerID,
CreatedBy: apiKey.UserID,
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
TitleDerivedFromContent: true,
ModelConfigID: modelConfigID,
ReasoningEffort: reasoningEffort,
PlanMode: planModeToNullChatPlanMode(req.PlanMode),
ClientType: clientType,
SystemPrompt: req.SystemPrompt,
InitialUserContent: contentBlocks,
MCPServerIDs: mcpServerIDs,
InlineMCPServers: req.InlineMCPServers,
Labels: labels,
DynamicTools: dynamicToolsJSON,
// IMPORTANT: users can only create root chats at the time of writing.
ParentChatID: uuid.NullUUID{},
})
if err != nil {
if writeChatHookErr(ctx, rw, err, "Chat creation denied by lifecycle hook.") {
return
}
if api.writeChatFileError(ctx, rw, err) {
return
}
if xerrors.Is(err, chatd.ErrInvalidModelConfigID) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
Detail: err.Error(),
})
return
}
if database.IsForeignKeyViolation(
err,
database.ForeignKeyChatsLastModelConfigID,
database.ForeignKeyChatMessagesModelConfigID,
) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
Detail: err.Error(),
})
return
}
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat.",
Detail: err.Error(),
})
return
}
aReq.New = chat
if chat.ParentChatID.Valid {
// Should not be possible. If we get here, something is very wrong. Bail.
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Developer error: ParentChatID got set somehow in api.postChats. This should never happen.",
})
return
}
chat, err = api.Database.GetChatByID(ownerCtx, chat.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to read back chat after creation.",
Detail: err.Error(),
})
return
}
aReq.New = chat
// Kick off best-effort automatic title generation now that the
// chat and its initial user message are persisted. It runs
// detached so it never blocks the create response, and only acts
// on the first user turn. Empty creates title on their first send.
if len(contentBlocks) > 0 {
api.chatDaemon.GenerateChatTitleAsync(ownerCtx, chat)
}
chatFiles := api.fetchChatFileMetadata(ownerCtx, chat.ID)
response := db2sdk.Chat(chat, nil, chatFiles)
httpapi.Write(ctx, rw, http.StatusCreated, response)
}
// @Summary Get chat by ID
// @ID get-chat-by-id
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat} [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
// Use the cached diff status from the database rather than
// resolving it inline. Inline resolution calls out to the
// git provider API (e.g. GitHub) on every request which
// blocks the response for 200-800ms. The background gitsync
// worker keeps the cached status fresh.
var diffStatus *database.ChatDiffStatus
diffStatuses, err := api.Database.GetChatDiffStatusesByChatID(ctx, chat.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
api.Logger.Error(ctx, "failed to get cached chat diff status",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
if len(diffStatuses) > 0 {
diffStatus = &diffStatuses[0]
}
// Hydrate file metadata for all files linked to this chat.
chatFiles := api.fetchChatFileMetadata(ctx, chat.ID)
sdkChat := db2sdk.Chat(chat, diffStatus, chatFiles)
if api.chatDaemon != nil {
queued, err := api.chatDaemon.ChatQueuedForCapacity(ctx, chat)
if err != nil {
api.Logger.Error(ctx, "failed to derive chat queued-for-capacity state",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
sdkChat.QueuedForCapacity = queued
}
}
// Enrich the lightweight context summary with the chat's pinned
// resources (metadata only). This detail is computed on read and only
// attached on the single-chat GET; list and watch payloads stay
// lightweight. A failure here is non-fatal: the chat is still usable
// without the detail, so we log and return the rest of the response.
if sdkChat.Context != nil && api.chatDaemon != nil {
resources, err := api.chatDaemon.ContextResources(ctx, chat)
if err != nil {
api.Logger.Error(ctx, "failed to compute chat context resources",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
sdkChat.Context.Resources = resources
}
}
// For root chats, embed children so callers get a complete
// tree in a single response.
if !chat.ParentChatID.Valid {
// Embed children matching the parent's archive state.
childRows, err := api.Database.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{chat.ID},
Archived: sql.NullBool{Bool: chat.Archived, Valid: true},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch child chats.",
Detail: err.Error(),
})
return
}
// Look up diff statuses for children.
childChats := make([]database.Chat, len(childRows))
for i, row := range childRows {
childChats[i] = row.Chat
}
childDiffStatuses, err := api.getChatDiffStatusesByChatID(ctx, childChats)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch child chat diff statuses.",
Detail: err.Error(),
})
return
}
sdkChat.Children = db2sdk.ChildChatRows(childRows, childDiffStatuses)
}
if api.Experiments.Enabled(codersdk.ExperimentChatInlineMCPServers) {
servers, err := api.chatInlineMCPServers(ctx, chat.ID)
if err != nil {
api.Logger.Error(ctx, "failed to get inline MCP servers",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
if chat.OwnerID != httpmw.APIKey(r).UserID {
for i := range servers {
servers[i].URL = ""
}
}
sdkChat.InlineMCPServers = servers
}
}
enriched := []codersdk.Chat{sdkChat}
api.repairChatAgentIDs(ctx, enriched)
sdkChat = enriched[0]
httpapi.Write(ctx, rw, http.StatusOK, sdkChat)
}
// @Summary List chat messages
// @ID list-chat-messages
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param before_id query int false "Return messages with id < before_id"
// @Param after_id query int false "Return messages with id > after_id"
// @Param limit query int false "Page size, 1 to 200. Defaults to 50."
// @Success 200 {object} codersdk.ChatMessagesResponse
// @Router /api/v2/chats/{chat}/messages [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatMessages(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
// Parse optional cursor-based pagination parameters.
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
beforeID := parser.PositiveInt64(queryParams, 0, "before_id")
afterID := parser.PositiveInt64(queryParams, 0, "after_id")
limit := parser.PositiveInt32(queryParams, 50, "limit")
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
if limit < 1 || limit > 200 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid limit parameter (1-200).",
})
return
}
// Reject transposed or equal cursors so an empty open range is loud,
// not silently indistinguishable from "no messages in this range."
if beforeID > 0 && afterID > 0 && afterID >= beforeID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "after_id must be less than before_id.",
})
return
}
// Polling with only after_id uses ASC so the cursor advances
// monotonically; a DESC limit would drop rows when a burst larger
// than `limit` lands between polls. Fetch limit+1 in both paths to
// detect whether more pages exist.
var messages []database.ChatMessage
var err error
switch {
case afterID > 0 && beforeID == 0:
messages, err = api.Database.GetChatMessagesByChatIDAscPaginated(ctx, database.GetChatMessagesByChatIDAscPaginatedParams{
ChatID: chatID,
AfterID: afterID,
LimitVal: limit + 1,
})
default:
messages, err = api.Database.GetChatMessagesByChatIDDescPaginated(ctx, database.GetChatMessagesByChatIDDescPaginatedParams{
ChatID: chatID,
BeforeID: beforeID,
AfterID: afterID,
LimitVal: limit + 1,
})
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat messages.",
Detail: err.Error(),
})
return
}
hasMore := len(messages) > int(limit)
if hasMore {
messages = messages[:limit]
}
// Queued messages are only meaningful for the initial top-of-history
// load. Suppress them whenever any cursor is set so polling callers do
// not receive the snapshot on every page fetch.
var queuedMessages []database.ChatQueuedMessage
if beforeID == 0 && afterID == 0 {
queuedMessages, err = api.Database.GetChatQueuedMessages(ctx, chatID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get queued messages.",
Detail: err.Error(),
})
return
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatMessagesResponse{
Messages: convertChatMessages(messages),
QueuedMessages: convertChatQueuedMessages(queuedMessages),
HasMore: hasMore,
})
}
// @Summary Get chat cost
// @ID get-chat-cost
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ChatCost
// @Router /api/v2/chats/{chat}/cost [get]
// @Description
// @Description Cost covers the whole chat tree: the root chat plus every
// @Description subagent chat beneath it. Requesting cost for a subagent chat
// @Description returns that same total.
// @Description
// @Description Cost is derived from AI Gateway data, which is subject to its
// @Description own retention period, 60 days by default, configured
// @Description independently of chat retention. Spend for requests older than
// @Description that period is no longer reported, so a chat whose requests
// @Description have all been purged reports zero cost.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatCost(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
// AI Gateway attributes a subagent's requests to the chat that spawned
// it, so cost is only meaningful for a whole chat tree. Resolve the root
// chat and report the tree total, including for subagent chats. Fall back
// to the parent when root_chat_id is NULL, matching the
// COALESCE(root_chat_id, parent_chat_id) resolution the chat queries use:
// both columns are ON DELETE SET NULL, so deleting a root leaves
// descendants with only a parent.
rootChatID := chat.ID
switch {
case chat.RootChatID.Valid:
rootChatID = chat.RootChatID.UUID
case chat.ParentChatID.Valid:
rootChatID = chat.ParentChatID.UUID
}
row, err := api.Database.GetAIBridgeChatCost(ctx, rootChatID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat cost.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatCost{
ChatID: chat.ID,
TotalCostMicros: row.TotalCostMicros,
RequestCount: row.RequestCount,
UnpricedRequestCount: row.UnpricedRequestCount,
})
}
// @Summary List chat user prompts
// @ID list-chat-user-prompts
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param limit query int false "Page size, 0 to 2000. 0 (the default) means the server-side default of 500."
// @Success 200 {object} codersdk.ChatPromptsResponse
// @Router /api/v2/chats/{chat}/prompts [get]
// @Description
// @Description Returns the user-authored prompts in a chat, newest first,
// @Description with each prompt's text parts concatenated in the order they
// @Description were authored. Used by the composer to power the up/down
// @Description arrow prompt-history cycle without paging through every
// @Description message in the chat.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatUserPrompts(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
// Default 0 sentinel; the SQL query treats 0 as "use the built-in
// default of 500" via COALESCE(NULLIF(@limit_val, 0), 500). The
// SDK guards opts.Limit > 0 so callers using the typed client only
// reach here with an explicit value; raw HTTP callers can omit the
// parameter (or pass 0) to opt into the default.
limit := parser.PositiveInt32(queryParams, 0, "limit")
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
// PositiveInt32 already rejects negatives via parser.Errors above,
// so we only need to cap the upper bound here.
if limit > 2000 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid limit parameter (0-2000).",
})
return
}
rows, err := api.Database.GetChatUserPromptsByChatID(ctx, database.GetChatUserPromptsByChatIDParams{
ChatID: chatID,
LimitVal: limit,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat user prompts.",
Detail: err.Error(),
})
return
}
prompts := make([]codersdk.ChatPrompt, 0, len(rows))
for _, row := range rows {
prompts = append(prompts, codersdk.ChatPrompt{
ID: row.ID,
Text: row.Text,
})
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPromptsResponse{
Prompts: prompts,
})
}
// authorizeChatWorkspaceExec enforces the workspace-level permissions
// shared by the chat stream endpoints that proxy a live websocket into
// the workspace agent (currently /stream/git and /stream/desktop).
//
// The chat row only authorizes the chat owner, so callers also need
// exec-level access (ApplicationConnect or SSH) to the bound workspace.
// The chat owner's workspace permissions may have been revoked after
// the chat was bound; skipping this check enabled CODAGT-184.
//
// On any failure the response is written and ok=false is returned.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) authorizeChatWorkspaceExec(
rw http.ResponseWriter,
r *http.Request,
chat database.Chat,
noWorkspaceMessage string,
) (database.Workspace, bool) {
return api.authorizeChatWorkspaceExecWithStatus(
rw,
r,
chat,
http.StatusBadRequest,
noWorkspaceMessage,
http.StatusBadRequest,
codersdk.ChatGitWatchWorkspaceNotFoundMessage,
)
}
// authorizeChatWorkspaceExecWithStatus is authorizeChatWorkspaceExec
// with caller-chosen statuses and messages for the no-workspace and
// workspace-not-found failures. The workspace upload endpoint reports
// these as 409 conflicts while the stream endpoints use 400.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) authorizeChatWorkspaceExecWithStatus(
rw http.ResponseWriter,
r *http.Request,
chat database.Chat,
noWorkspaceStatus int,
noWorkspaceMessage string,
notFoundStatus int,
notFoundMessage string,
) (database.Workspace, bool) {
ctx := r.Context()
if !chat.WorkspaceID.Valid {
httpapi.Write(ctx, rw, noWorkspaceStatus, codersdk.Response{
Message: noWorkspaceMessage,
})
return database.Workspace{}, false
}
workspace, err := api.Database.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID)
if httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, notFoundStatus, codersdk.Response{
Message: notFoundMessage,
})
return database.Workspace{}, false
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat workspace.",
Detail: err.Error(),
})
return database.Workspace{}, false
}
if !api.Authorize(r, policy.ActionApplicationConnect, workspace) &&
!api.Authorize(r, policy.ActionSSH, workspace) {
httpapi.Forbidden(rw)
return database.Workspace{}, false
}
return workspace, true
}
// @Summary Watch chat workspace git state via WebSockets
// @ID watch-chat-workspace-git-state-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.WorkspaceAgentGitServerMessage
// @Router /api/v2/chats/{chat}/stream/git [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) watchChatGit(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
chat = httpmw.ChatParam(r)
logger = api.Logger.Named("chat_git_watcher").With(slog.F("chat_id", chat.ID))
)
if _, ok := api.authorizeChatWorkspaceExec(rw, r, chat, codersdk.ChatGitWatchNoWorkspaceMessage); !ok {
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, chat.WorkspaceID.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
agent, err := agentselect.FindChatAgent(agents)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchNoEligibleAgentMessage,
Detail: err.Error(),
})
return
}
apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(),
*api.TailnetCoordinator.Load(),
agent,
nil,
nil,
nil,
api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchAgentStateMessage(apiAgent.Status),
})
return
}
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error dialing workspace agent.",
Detail: err.Error(),
})
return
}
defer release()
agentStream, err := agentConn.WatchGit(ctx, logger, chat.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error watching agent's git state.",
Detail: err.Error(),
})
return
}
defer agentStream.Close(websocket.StatusGoingAway)
clientConn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err != nil {
logger.Error(ctx, "failed to accept websocket", slog.Error(err))
return
}
clientStream := wsjson.NewStream[
codersdk.WorkspaceAgentGitClientMessage,
codersdk.WorkspaceAgentGitServerMessage,
](clientConn, websocket.MessageText, websocket.MessageText, logger)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
ctx = api.wsWatcher.Watch(ctx, logger, clientConn)
// Proxy agent → client.
agentCh := agentStream.Chan()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-api.ctx.Done():
return
case <-ctx.Done():
return
case msg, ok := <-agentCh:
if !ok {
cancel()
return
}
if err := clientStream.Send(msg); err != nil {
logger.Debug(ctx, "failed to forward agent message to client", slog.Error(err))
cancel()
return
}
}
}
}()
// Proxy client → agent.
clientCh := clientStream.Chan()
proxyLoop:
for {
select {
case <-api.ctx.Done():
break proxyLoop
case <-ctx.Done():
break proxyLoop
case msg, ok := <-clientCh:
if !ok {
break proxyLoop
}
if err := agentStream.Send(msg); err != nil {
logger.Debug(ctx, "failed to forward client message to agent", slog.Error(err))
break proxyLoop
}
}
}
cancel()
wg.Wait()
_ = clientStream.Close(websocket.StatusGoingAway)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Connect to chat workspace desktop via WebSockets
// @ID connect-to-chat-workspace-desktop-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce application/octet-stream
// @Param chat path string true "Chat ID" format(uuid)
// @Success 101
// @Router /api/experimental/chats/{chat}/stream/desktop [get]
// @Description Raw binary WebSocket stream of the chat workspace desktop.
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
chat = httpmw.ChatParam(r)
logger = api.Logger.Named("chat_desktop").With(slog.F("chat_id", chat.ID))
)
if _, ok := api.authorizeChatWorkspaceExec(rw, r, chat, "Chat has no workspace."); !ok {
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, chat.WorkspaceID.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
agent, err := agentselect.FindChatAgent(agents)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchNoEligibleAgentMessage,
Detail: err.Error(),
})
return
}
apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(),
*api.TailnetCoordinator.Load(),
agent,
nil,
nil,
nil,
api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Agent state is %q, must be connected.", apiAgent.Status),
})
return
}
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to dial workspace agent.",
Detail: err.Error(),
})
return
}
defer release()
desktopConn, err := agentConn.ConnectDesktopVNC(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to connect to agent desktop.",
Detail: err.Error(),
})
return
}
defer desktopConn.Close()
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
logger.Error(ctx, "failed to accept websocket", slog.Error(err))
return
}
// No read limit because RFB framebuffer updates can be large.
conn.SetReadLimit(-1)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx, wsNetConn := workspaceapps.WebsocketNetConn(ctx, conn, websocket.MessageBinary)
defer wsNetConn.Close()
ctx = api.wsWatcher.Watch(ctx, logger, conn)
agentssh.Bicopy(ctx, wsNetConn, desktopConn)
logger.Debug(ctx, "desktop Bicopy finished")
}
func (api *API) applyChatTitleUpdate(
ctx context.Context,
rw http.ResponseWriter,
chat database.Chat,
rawTitle string,
) (database.Chat, bool) {
trimmedTitle := strings.TrimSpace(rawTitle)
if trimmedTitle == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Title cannot be empty.",
})
return chat, true
}
const maxChatTitleRunes = 200
if utf8.RuneCountInString(trimmedTitle) > maxChatTitleRunes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Title must be at most %d characters.", maxChatTitleRunes),
})
return chat, true
}
if trimmedTitle == chat.Title {
return chat, false
}
updatedChat, wrote, err := api.chatDaemon.RenameChatTitle(ctx, chat, trimmedTitle)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return chat, true
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat title.",
Detail: err.Error(),
})
return chat, true
}
if wrote {
api.chatDaemon.PublishTitleChange(updatedChat)
}
return updatedChat, false
}
// refreshChatContext re-pins a chat to its agent's latest context snapshot
// and clears the dirty marker.
//
// @Summary Refresh chat context
// @ID refresh-chat-context
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/context [put]
func (api *API) refreshChatContext(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if !api.requireChatDaemon(ctx, rw) {
return
}
updated, err := api.chatDaemon.RefreshChatContext(ctx, chat)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error refreshing chat context.",
Detail: err.Error(),
})
return
}
sdkChat := db2sdk.Chat(updated, nil, nil)
// Enrich the context summary with the freshly pinned resources so the
// client reflects the refresh immediately, without a full reload. This
// mirrors getChat; we pass the re-pinned chat so the detail reflects the
// post-refresh state. A failure here is non-fatal: the refresh already
// succeeded, so we log and return the rest of the response.
if sdkChat.Context != nil && api.chatDaemon != nil {
resources, err := api.chatDaemon.ContextResources(ctx, updated)
if err != nil {
api.Logger.Error(ctx, "failed to compute chat context resources after refresh",
slog.F("chat_id", updated.ID),
slog.Error(err),
)
} else {
sdkChat.Context.Resources = resources
}
}
httpapi.Write(ctx, rw, http.StatusOK, sdkChat)
}
// patchChat updates a chat resource. Supports updating labels,
// workspace binding, archiving, pinning, pinned-chat ordering, and the
// owner's read state.
//
// @Summary Update chat
// @ID update-chat
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param chat path string true "Chat ID" format(uuid)
// @Param request body codersdk.UpdateChatRequest true "Update chat request"
// @Success 204
// @Router /api/v2/chats/{chat} [patch]
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if !api.requireChatDaemon(ctx, rw) {
return
}
aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()
aReq.Old = chat
aReq.UpdateOrganizationID(chat.OrganizationID)
var req codersdk.UpdateChatRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
var planModeUpdate *database.NullChatPlanMode
if req.PlanMode != nil {
if !validateChatPlanMode(*req.PlanMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
return
}
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
planModeUpdate = &resolvedPlanMode
}
// The read cursor is owner-scoped, so an admin with update
// permission must not move another user's unread state. Checked
// before any write so a rejected request does not commit the
// other fields of a multi-field update.
if req.Read != nil && chat.OwnerID != httpmw.APIKey(r).UserID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner can change its read state.",
})
return
}
if req.Title != nil {
updatedChat, handled := api.applyChatTitleUpdate(ctx, rw, chat, *req.Title)
if handled {
return
}
chat = updatedChat
}
if req.Labels != nil {
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid labels.",
Validations: errs,
})
return
}
labelsJSON, err := json.Marshal(*req.Labels)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal labels.",
Detail: err.Error(),
})
return
}
updatedChat, err := api.Database.UpdateChatLabelsByID(ctx, database.UpdateChatLabelsByIDParams{
ID: chat.ID,
Labels: labelsJSON,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat labels.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
if req.Archived != nil {
archived := *req.Archived
// Archive invariant is one-way: parent archived implies
// child archived. Archive state changes target the root
// chat and cascade atomically across the family; child
// chats cannot be archived or unarchived independently.
// This check precedes the no-op check so any child attempt
// surfaces the root-only error regardless of the chat's
// current archived value.
if chat.ParentChatID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat archive state can only be changed on the root chat.",
})
return
}
if archived == chat.Archived {
state := "archived"
if !archived {
state = "not archived"
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Chat is already %s.", state),
})
return
}
var err error
if archived {
err = api.chatDaemon.ArchiveChat(ctx, chat)
} else {
err = api.chatDaemon.UnarchiveChat(ctx, chat)
}
if err != nil {
if errors.Is(err, chatd.ErrArchiveRequiresRootChat) || errors.Is(err, chatstate.ErrChatNotRoot) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat archive state can only be changed on the root chat.",
})
return
}
if writeChatInvalidState(ctx, rw, err) {
return
}
if errors.Is(err, chatstate.ErrTransitionNotAllowed) {
// Archive only succeeds from idle / error execution
// states (W, E0, E1) per the chatd RFC; active
// chats refuse archive instead of being silently
// transitioned to waiting first.
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot archive an active chat. Interrupt or wait for the chat to finish first.",
Detail: err.Error(),
})
return
}
action := "archive"
if !archived {
action = "unarchive"
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Failed to %s chat.", action),
Detail: err.Error(),
})
return
}
}
if req.PinOrder != nil {
pinOrder := *req.PinOrder
if pinOrder < 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Pin order must be non-negative.",
})
return
}
if pinOrder > 0 && chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin an archived chat.",
})
return
}
if pinOrder > 0 && chat.ParentChatID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin a child chat.",
})
return
}
// The behavior depends on current pin state:
// - pinOrder == 0: unpin.
// - pinOrder > 0 && already pinned: reorder (shift
// neighbors, clamp to [1, count]).
// - pinOrder > 0 && not pinned: append to end. The
// requested value is intentionally ignored; the
// SQL ORDER BY sorts pinned chats first so they
// appear on page 1 of the paginated sidebar.
var err error
errMsg := "Failed to pin chat."
switch {
case pinOrder == 0:
errMsg = "Failed to unpin chat."
err = api.Database.UnpinChatByID(ctx, chat.ID)
case chat.PinOrder > 0:
errMsg = "Failed to reorder pinned chat."
err = api.Database.UpdateChatPinOrder(ctx, database.UpdateChatPinOrderParams{
ID: chat.ID,
PinOrder: pinOrder,
})
default:
err = api.Database.PinChatByID(ctx, chat.ID)
}
if err != nil {
switch {
case database.IsCheckViolation(err, database.CheckChatsPinOrderParentCheck):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin a child chat.",
})
case database.IsCheckViolation(err, database.CheckChatsPinOrderArchivedCheck):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin an archived chat.",
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: errMsg,
Detail: err.Error(),
})
}
return
}
}
if req.Read != nil {
markRead := *req.Read
var err error
if markRead {
err = api.advanceChatReadCursor(ctx, chat.ID)
} else {
err = api.clearChatReadCursor(ctx, chat.ID)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
action := "read"
if !markRead {
action = "unread"
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Failed to mark chat as %s.", action),
Detail: err.Error(),
})
return
}
}
if req.WorkspaceID != nil {
workspaceID := uuid.NullUUID{}
workspace := database.Workspace{}
if *req.WorkspaceID != uuid.Nil {
var status int
var resp *codersdk.Response
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
if workspace.OrganizationID != chat.OrganizationID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Workspace does not belong to this chat's organization.",
})
return
}
}
updatedChat, err := api.Database.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
ID: chat.ID,
WorkspaceID: workspaceID,
BuildID: uuid.NullUUID{},
AgentID: uuid.NullUUID{},
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat workspace binding.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
if planModeUpdate != nil {
updatedChat, err := api.Database.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
PlanMode: *planModeUpdate,
ID: chat.ID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat plan mode.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
if refreshed, err := api.Database.GetChatByID(ctx, chat.ID); err == nil {
aReq.New = refreshed
} else {
aReq.New = chat // fallback
api.Logger.Error(ctx, "failed to refresh chat for audit", slog.F("chat_id", chat.ID), slog.Error(err))
}
rw.WriteHeader(http.StatusNoContent)
}
// writeChatInvalidState writes the shared invalid-state response for
// chatstate.ErrInvalidState across every chat mutation endpoint.
// Returns true when a response has been written.
func writeChatInvalidState(ctx context.Context, rw http.ResponseWriter, err error) bool {
if !errors.Is(err, chatstate.ErrInvalidState) {
return false
}
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is in an invalid state.",
})
return true
}
func noLocalChatModelResponse() *codersdk.Response {
return &codersdk.Response{
Message: "No chat model is available in this organization.",
Detail: "Ask an organization administrator to configure and enable a chat model.",
}
}
func writeNoLocalChatModelResponse(ctx context.Context, rw http.ResponseWriter) {
httpapi.Write(ctx, rw, http.StatusBadRequest, *noLocalChatModelResponse())
}
// writeCommonChatMutationError writes responses shared by chat
// mutation endpoints. Returns true when a response has been written.
func writeCommonChatMutationError(ctx context.Context, rw http.ResponseWriter, err error, archivedMessage string) bool {
switch {
case xerrors.Is(err, chatd.ErrChatArchived):
if archivedMessage == "" {
archivedMessage = "Cannot mutate an archived chat."
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: archivedMessage,
})
case writeChatInvalidState(ctx, rw, err):
// response already written
case errors.Is(err, chatstate.ErrChatNotFound), httpapi.Is404Error(err):
httpapi.ResourceNotFound(rw)
default:
return false
}
return true
}
// @Summary Send chat message
// @ID send-chat-message
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param request body codersdk.CreateChatMessageRequest true "Create chat message request"
// @Success 200 {object} codersdk.CreateChatMessageResponse
// @Router /api/v2/chats/{chat}/messages [post]
func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
if !api.requireChatDaemon(ctx, rw) {
return
}
// Sending a message triggers LLM inference, requiring update
// permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may send messages. Org admins pass the
// RBAC check above (org-level ActionUpdate), but chat
// processing forwards the *owner's* credentials (OIDC tokens,
// provider API keys) to external services. Allowing a
// non-owner to trigger processing would leak the owner's
// tokens to MCP servers the caller controls.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may send messages.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot send messages to an archived chat.",
})
return
}
var req codersdk.CreateChatMessageRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, chat.ID, chat.WorkspaceID, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
Detail: inputError.Detail,
})
return
}
normalizedMCPServerIDs, status, mcpResp := api.normalizeRequestedChatMCPServerIDs(ctx, chat, req.MCPServerIDs)
if mcpResp != nil {
httpapi.Write(ctx, rw, status, *mcpResp)
return
}
req.MCPServerIDs = normalizedMCPServerIDs
if req.InlineMCPServers != nil {
// The field belongs to root chats, so a child rejects every value,
// including []. The kill switch and experiment gates apply only to
// non-empty declarations: [] is the detach path and must keep
// working when the feature is turned off.
if chat.ParentChatID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "inline_mcp_servers can only be declared on a root chat.",
})
return
}
if len(*req.InlineMCPServers) > 0 {
if api.DeploymentValues.DisableChatCallerSuppliedTools.Value() {
writeChatCallerSuppliedToolsDisabled(ctx, rw)
return
}
if !api.Experiments.Enabled(codersdk.ExperimentChatInlineMCPServers) {
writeInlineMCPServersExperimentRequired(ctx, rw)
return
}
if validations := validateInlineMCPServers(*req.InlineMCPServers, api.MCPAllowedPrivateCIDRs); len(validations) > 0 {
writeInlineMCPServersInvalid(ctx, rw, validations)
return
}
}
}
if req.PlanMode != nil {
if !validateChatPlanMode(*req.PlanMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
return
}
}
var sendPlanMode *database.NullChatPlanMode
if req.PlanMode != nil {
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
sendPlanMode = &resolvedPlanMode
}
busyBehavior := chatd.SendMessageBusyBehaviorQueue
switch req.BusyBehavior {
case codersdk.ChatBusyBehaviorInterrupt:
busyBehavior = chatd.SendMessageBusyBehaviorInterrupt
case codersdk.ChatBusyBehaviorQueue, "":
// Default to queue.
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid busy_behavior value.",
Detail: `Must be "queue" or "interrupt".`,
})
return
}
modelConfigID := uuid.Nil
if req.ModelConfigID != nil {
modelConfigID = *req.ModelConfigID
}
if status, resp := api.validateExplicitChatModelConfigAvailable(ctx, apiKey.UserID, chat.OrganizationID, modelConfigID); resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
reasoningEffort := req.ReasoningEffort
if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*reasoningEffort))
return
}
// Chats created without an initial message have no stored effort,
// so apply the personal override postChats would have applied.
if reasoningEffort == nil && !chat.LastReasoningEffort.Valid {
overrideModelConfigID, overrideEffort, err := api.personalRootModelOverride(ctx, chat.OwnerID, chat.OrganizationID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
})
return
}
if overrideEffort != nil &&
overrideModelConfigID == cmp.Or(modelConfigID, chat.LastModelConfigID) &&
chatprovider.IsValidReasoningEffort(*overrideEffort) {
reasoningEffort = overrideEffort
}
}
sendResult, sendErr := api.chatDaemon.SendMessage(
ctx,
chatd.SendMessageOptions{
ChatID: chatID,
CreatedBy: apiKey.UserID,
Content: contentBlocks,
ModelConfigID: modelConfigID,
ReasoningEffort: reasoningEffort,
BusyBehavior: busyBehavior,
PlanMode: sendPlanMode,
MCPServerIDs: req.MCPServerIDs,
InlineMCPServers: req.InlineMCPServers,
},
)
if sendErr != nil {
if writeChatHookErr(ctx, rw, sendErr, "Chat message denied by lifecycle hook.") {
return
}
if api.writeChatFileError(ctx, rw, sendErr) {
return
}
if xerrors.Is(sendErr, chatd.ErrChatArchived) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot send messages to an archived chat.",
})
return
}
if xerrors.Is(sendErr, chatstate.ErrMessageQueueFull) {
var queueFull *chatstate.MessageQueueFullError
detail := ""
if errors.As(sendErr, &queueFull) {
detail = fmt.Sprintf("Maximum %d messages can be queued.", queueFull.Max)
}
httpapi.Write(ctx, rw, http.StatusTooManyRequests, codersdk.Response{
Message: "Message queue is full.",
Detail: detail,
})
return
}
if xerrors.Is(sendErr, chatd.ErrInvalidModelConfigID) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
})
return
}
if xerrors.Is(sendErr, chatd.ErrNoDefaultChatModelConfig) {
writeNoLocalChatModelResponse(ctx, rw)
return
}
if errors.Is(sendErr, chatstate.ErrChatNotFound) {
httpapi.ResourceNotFound(rw)
return
}
if writeChatInvalidState(ctx, rw, sendErr) {
return
}
if errors.Is(sendErr, chatstate.ErrTransitionNotAllowed) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts new messages.",
Detail: sendErr.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat message.",
Detail: chaterror.FormatDiagnosticDetail(sendErr),
})
return
}
// Pass the inserted message as the snapshot: SendMessage published
// the ownership hint, so a fresh history read could already contain
// the assistant reply and disqualify title generation.
if sendResult.FirstUserTurn {
api.chatDaemon.GenerateChatTitleForMessagesAsync(
ctx,
sendResult.Chat,
[]database.ChatMessage{sendResult.Message},
)
}
response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued}
if sendResult.Queued {
if sendResult.QueuedMessage != nil {
response.QueuedMessage = convertChatQueuedMessagePtr(*sendResult.QueuedMessage)
}
} else {
message := convertChatMessage(sendResult.Message)
response.Message = &message
}
// Return the full user-visible inserted batch. A queued send on an errored
// chat can promote the previous queue head, which clients must cache.
for _, inserted := range sendResult.InsertedMessages {
if inserted.Visibility == database.ChatMessageVisibilityModel {
continue
}
response.Messages = append(response.Messages, convertChatMessage(inserted))
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Edit chat message
// @ID edit-chat-message
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param message path int true "Message ID"
// @Param request body codersdk.EditChatMessageRequest true "Edit chat message request"
// @Success 200 {object} codersdk.EditChatMessageResponse
// @Router /api/v2/chats/{chat}/messages/{message} [patch]
func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may edit messages. See postChatMessages
// for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may edit messages.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot edit messages in an archived chat.",
})
return
}
messageIDStr := chi.URLParam(r, "message")
messageID, err := strconv.ParseInt(messageIDStr, 10, 64)
if err != nil || messageID <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat message ID.",
Detail: "Message ID must be a positive integer.",
})
return
}
var req codersdk.EditChatMessageRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, chat.ID, chat.WorkspaceID, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
Detail: inputError.Detail,
})
return
}
editModelConfigID := uuid.Nil
if req.ModelConfigID != nil {
editModelConfigID = *req.ModelConfigID
}
if status, resp := api.validateExplicitChatModelConfigAvailable(ctx, apiKey.UserID, chat.OrganizationID, editModelConfigID); resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
editReasoningEffort := req.ReasoningEffort
if editReasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*editReasoningEffort) {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*editReasoningEffort))
return
}
editMCPServerIDs, status, mcpResp := api.normalizeRequestedChatMCPServerIDs(ctx, chat, req.MCPServerIDs)
if mcpResp != nil {
httpapi.Write(ctx, rw, status, *mcpResp)
return
}
editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
CreatedBy: apiKey.UserID,
EditedMessageID: messageID,
Content: contentBlocks,
ModelConfigID: editModelConfigID,
ReasoningEffort: editReasoningEffort,
MCPServerIDs: editMCPServerIDs,
})
if editErr != nil {
if writeChatHookErr(ctx, rw, editErr, "Chat message denied by lifecycle hook.") {
return
}
if api.writeChatFileError(ctx, rw, editErr) {
return
}
switch {
case xerrors.Is(editErr, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot edit messages in an archived chat.",
})
case xerrors.Is(editErr, chatd.ErrEditedMessageNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Chat message not found.",
Detail: "Message does not belong to this chat.",
})
case xerrors.Is(editErr, chatd.ErrEditedMessageNotUser):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Only user messages can be edited.",
})
case xerrors.Is(editErr, chatd.ErrInvalidModelConfigID):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
})
case xerrors.Is(editErr, chatd.ErrNoDefaultChatModelConfig):
writeNoLocalChatModelResponse(ctx, rw)
case errors.Is(editErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, editErr):
// response already written
case errors.Is(editErr, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts message edits.",
Detail: editErr.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to edit chat message.",
Detail: editErr.Error(),
})
}
return
}
response := codersdk.EditChatMessageResponse{Message: convertChatMessage(editResult.Message)}
// Synthetic cancellations precede the replacement with lower IDs;
// clients that seed their transcript cache from this response need
// all user-visible inserted rows, or a stream reconnect with
// after_id set to the replacement would skip the earlier ones.
for _, inserted := range editResult.InsertedMessages {
if inserted.Visibility == database.ChatMessageVisibilityModel {
continue
}
response.Messages = append(response.Messages, convertChatMessage(inserted))
}
response.DeletedMessageIDs = editResult.DeletedMessageIDs
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Delete chat queued message
// @ID delete-chat-queued-message
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Param queuedMessage path int true "Queued message ID"
// @Success 204
// @Router /api/v2/chats/{chat}/queue/{queuedMessage} [delete]
func (api *API) deleteChatQueuedMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
queuedMessageIDStr := chi.URLParam(r, "queuedMessage")
queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid queued message ID.",
Detail: err.Error(),
})
return
}
err = api.chatDaemon.DeleteQueued(ctx, chatID, queuedMessageID)
if err != nil {
switch {
case xerrors.Is(err, chatstate.ErrQueuedMessageNotFound), xerrors.Is(err, sql.ErrNoRows):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
case errors.Is(err, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, err):
// response already written
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat has no queued messages to delete.",
Detail: err.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete queued message.",
Detail: err.Error(),
})
}
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Promote chat queued message
// @ID promote-chat-queued-message
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Param queuedMessage path int true "Queued message ID"
// @Produce json
// @Success 202 {object} codersdk.Response
// @Router /api/v2/chats/{chat}/queue/{queuedMessage}/promote [post]
func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
if !api.requireChatDaemon(ctx, rw) {
return
}
// Promoting a queued message triggers LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may promote messages. See
// postChatMessages for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may promote queued messages.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot promote queued messages in an archived chat.",
})
return
}
queuedMessageIDStr := chi.URLParam(r, "queuedMessage")
queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid queued message ID.",
Detail: err.Error(),
})
return
}
_, txErr := api.chatDaemon.PromoteQueued(ctx, chatd.PromoteQueuedOptions{
ChatID: chatID,
QueuedMessageID: queuedMessageID,
})
if txErr != nil {
switch {
case xerrors.Is(txErr, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot promote queued messages in an archived chat.",
})
case xerrors.Is(txErr, chatstate.ErrQueuedMessageNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
case xerrors.Is(txErr, chatd.ErrNoDefaultChatModelConfig):
writeNoLocalChatModelResponse(ctx, rw)
case errors.Is(txErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, txErr):
// response already written
case errors.Is(txErr, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat has no queued messages to promote.",
Detail: txErr.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to promote queued message.",
Detail: txErr.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.Response{
Message: "Queued message promotion accepted.",
})
}
// markChatAsRead updates the last read message ID for a chat to the
// latest message, so subsequent unread checks treat all current
// messages as seen. This is called on stream connect and disconnect
// to avoid per-message API calls during active streaming.
func (api *API) markChatAsRead(ctx context.Context, chatID uuid.UUID) {
if err := api.advanceChatReadCursor(ctx, chatID); err != nil {
api.Logger.Warn(ctx, "failed to mark chat as read",
slog.F("chat_id", chatID),
slog.Error(err),
)
}
}
// advanceChatReadCursor moves the chat's owner-scoped read cursor to the
// latest assistant message, so nothing in the chat is unread.
func (api *API) advanceChatReadCursor(ctx context.Context, chatID uuid.UUID) error {
lastMsg, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chatID,
Role: database.ChatMessageRoleAssistant,
})
if errors.Is(err, sql.ErrNoRows) {
// No assistant messages yet, so nothing can be unread.
return nil
}
if err != nil {
return xerrors.Errorf("get last assistant message: %w", err)
}
if err := api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chatID,
LastReadMessageID: sql.NullInt64{Int64: lastMsg.ID, Valid: true},
}); err != nil {
return xerrors.Errorf("update chat last read message id: %w", err)
}
return nil
}
// clearChatReadCursor clears the chat's owner-scoped read cursor, so every
// assistant message in the chat counts as unread again.
func (api *API) clearChatReadCursor(ctx context.Context, chatID uuid.UUID) error {
if err := api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chatID,
LastReadMessageID: sql.NullInt64{},
}); err != nil {
return xerrors.Errorf("clear chat last read message id: %w", err)
}
return nil
}
// @Summary Stream chat events via WebSockets
// @ID stream-chat-events-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param after_id query int false "Skip snapshot messages with id at or before this cursor"
// @Success 200 {array} codersdk.ChatStreamEvent
// @Router /api/v2/chats/{chat}/stream [get]
func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_streamer").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
var afterMessageID int64
if v := r.URL.Query().Get("after_id"); v != "" {
var err error
afterMessageID, err = strconv.ParseInt(v, 10, 64)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid after_id parameter.",
Detail: err.Error(),
})
return
}
}
// Subscribe before accepting the WebSocket so that failures
// can still be reported as normal HTTP errors.
snapshot, events, cancelSub, ok := api.chatDaemon.SubscribeAuthorized(ctx, chat, r.Header, afterMessageID)
// Defensive against future SubscribeAuthorized failure modes.
if !ok {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat streaming is not available.",
Detail: "Chat stream state is not configured.",
})
return
}
defer cancelSub()
conn, err := websocket.Accept(rw, r, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to open chat stream.",
Detail: err.Error(),
})
return
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
_ = conn.CloseRead(context.Background())
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
defer wsNetConn.Close()
ctx = api.wsWatcher.Watch(ctx, logger, conn)
// The last_read_message_id field is owner-scoped. Shared readers
// intentionally lack chat update permission, so their streams must not
// update it.
if chat.OwnerID == httpmw.APIKey(r).UserID {
api.markChatAsRead(ctx, chatID)
defer api.markChatAsRead(context.WithoutCancel(ctx), chatID)
}
encoder := json.NewEncoder(wsNetConn)
sendChatStreamBatch := func(batch []codersdk.ChatStreamEvent) error {
if len(batch) == 0 {
return nil
}
return encoder.Encode(batch)
}
drainChatStreamBatch := func(
first codersdk.ChatStreamEvent,
maxBatchSize int,
) ([]codersdk.ChatStreamEvent, bool) {
batch := []codersdk.ChatStreamEvent{first}
if maxBatchSize <= 1 {
return batch, false
}
for len(batch) < maxBatchSize {
select {
case event, ok := <-events:
if !ok {
return batch, true
}
batch = append(batch, event)
default:
return batch, false
}
}
return batch, false
}
for start := 0; start < len(snapshot); start += chatStreamBatchSize {
end := start + chatStreamBatchSize
if end > len(snapshot) {
end = len(snapshot)
}
if err := sendChatStreamBatch(snapshot[start:end]); err != nil {
logger.Debug(ctx, "failed to send chat stream snapshot", slog.Error(err))
return
}
}
for {
select {
case <-ctx.Done():
return
case firstEvent, ok := <-events:
if !ok {
return
}
batch, streamClosed := drainChatStreamBatch(
firstEvent,
chatStreamBatchSize,
)
if err := sendChatStreamBatch(batch); err != nil {
logger.Debug(ctx, "failed to send chat stream event", slog.Error(err))
return
}
if streamClosed {
return
}
}
}
}
// @Summary Interrupt chat
// @ID interrupt-chat
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Produce json
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/interrupt [post]
func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_interrupt").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
updated, err := api.chatDaemon.InterruptChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "Cannot interrupt an archived chat.") {
return
}
switch {
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in an interruptible state.",
Detail: err.Error(),
})
default:
logger.Error(ctx, "failed to interrupt chat", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to interrupt chat.",
Detail: err.Error(),
})
}
return
}
chat = updated
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(chat, nil, nil))
}
// @Summary Compact chat
// @ID compact-chat
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Produce json
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/compact [post]
// @x-apidocgen {"skip": true}
// @Description Requests a manual context compaction on an idle or errored
// @Description chat, clearing any stored error. The compaction runs
// @Description asynchronously through the chat worker and bypasses the
// @Description automatic usage threshold.
func (api *API) compactChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_compact").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
// Compaction triggers LLM inference, requiring update permission
// on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may trigger compaction. Org admins pass the
// RBAC check above (org-level ActionUpdate), but compaction runs
// inference with the owner's delegated credentials.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may compact the chat.",
})
return
}
updated, err := api.chatDaemon.CompactChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "Cannot compact an archived chat.") {
return
}
switch {
case errors.Is(err, chatd.ErrNothingToCompact):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Nothing to compact.",
Detail: "The chat has no conversation to summarize after the latest compaction.",
})
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot compact the chat in its current state.",
Detail: "Compaction is not available while the chat is generating.",
})
default:
logger.Error(ctx, "failed to compact chat", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to compact chat.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// @Summary Clear chat context
// @ID clear-chat-context
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Produce json
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/clear [post]
// @x-apidocgen {"skip": true}
// @Description Resets the model context of an idle or errored chat,
// @Description clearing any stored error. The reset commits
// @Description synchronously with no model call: the transcript is
// @Description preserved and the next prompt starts from a fresh
// @Description context.
func (api *API) clearChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_clear").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may clear the context, matching the
// compaction endpoint so the two context operations share
// authorization semantics.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may clear the chat context.",
})
return
}
updated, err := api.chatDaemon.ClearChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "Cannot clear an archived chat.") {
return
}
switch {
case errors.Is(err, chatd.ErrNothingToClear):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Nothing to clear.",
Detail: "The chat has no conversation to clear after the latest context boundary.",
})
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot clear the chat in its current state.",
Detail: "Clearing is not available while the chat is generating or has queued messages.",
})
default:
logger.Error(ctx, "failed to clear chat context", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to clear chat context.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// @Summary Reconcile invalid chat state
// @ID reconcile-invalid-chat-state
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/reconcile-invalid [post]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) reconcileInvalidChatState(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_reconcile_invalid").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
updated, err := api.chatDaemon.ReconcileInvalidStateChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "") {
return
}
switch {
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in an invalid state.",
Detail: err.Error(),
})
default:
logger.Error(ctx, "failed to reconcile invalid chat state", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to reconcile chat state.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// @Summary Propose chat title
// @ID propose-chat-title
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ProposeChatTitleResponse
// @Router /api/v2/chats/{chat}/title/propose [post]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may propose titles. See
// postChatMessages for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may propose a title.",
})
return
}
title, err := api.chatDaemon.ProposeChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
writeNoLocalChatModelResponse(ctx, rw)
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if maybeWriteChatUsageLimitError(ctx, rw, err) {
return
}
if maybeWriteManualTitleTimeoutErr(ctx, rw, err) {
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate chat title.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ProposeChatTitleResponse{Title: title})
}
// @Summary Get chat diff contents
// @ID get-chat-diff-contents
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ChatDiffContents
// @Router /api/v2/chats/{chat}/diff [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatDiffContents(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
diff, err := api.resolveChatDiffContents(ctx, chat)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat diff.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, diff)
}
// chatCreateWorkspace provides workspace creation for the chat
// processor. RBAC authorization uses context-based checks via
// dbauthz.As rather than fake *http.Request objects.
func (api *API) chatCreateWorkspace(
ctx context.Context,
ownerID uuid.UUID,
req codersdk.CreateWorkspaceRequest,
) (codersdk.Workspace, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
return codersdk.Workspace{}, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
ownerUser, err := api.Database.GetUserByID(ctx, ownerID)
if err != nil {
return codersdk.Workspace{}, xerrors.Errorf("get workspace owner: %w", err)
}
owner := workspaceOwner{
ID: ownerUser.ID,
Username: ownerUser.Username,
AvatarURL: ownerUser.AvatarURL,
}
auditor := api.Auditor.Load()
if auditor == nil {
return codersdk.Workspace{}, xerrors.New("auditor is not configured")
}
// The audit system requires a ResponseWriter to capture the
// HTTP status code. Since this is a programmatic call, we use
// a recorder. The audit entry still captures the owner, action,
// and resource correctly.
rw := httptest.NewRecorder()
sw := &tracing.StatusWriter{ResponseWriter: rw}
// Build a minimal synthetic request so the audit commit
// closure can extract a request ID and user agent. The RBAC
// subject is already on the context via dbauthz.As above.
auditReq, err := http.NewRequestWithContext(
httpmw.WithRequestID(ctx, uuid.New()),
http.MethodPost,
"http://localhost/internal/chat/workspace",
nil,
)
if err != nil {
return codersdk.Workspace{}, xerrors.Errorf("create audit request: %w", err)
}
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](sw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: auditReq,
Action: database.AuditActionCreate,
AdditionalFields: audit.AdditionalFields{
WorkspaceOwner: owner.Username,
},
})
aReq.UserID = ownerID
defer commitAudit()
workspace, err := createWorkspace(ctx, aReq, ownerID, api, owner, req, nil)
if err != nil {
sw.WriteHeader(chatWorkspaceAuditStatus(err))
return codersdk.Workspace{}, err
}
sw.WriteHeader(http.StatusCreated)
return workspace, nil
}
// chatStartWorkspace starts a stopped workspace by creating a new
// build with the "start" transition. It mirrors chatCreateWorkspace
// but for the start path.
//
// Aliased as ChatStartWorkspace in coderd/export_test.go so external
// tests in the coderd_test package can drive the auto-update path
// end-to-end. The proper fix is to extract the request building into
// a pure function; tracked in CODAGT-292.
func (api *API) chatStartWorkspace(
ctx context.Context,
ownerID uuid.UUID,
workspaceID uuid.UUID,
req codersdk.CreateWorkspaceBuildRequest,
) (codersdk.WorkspaceBuild, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("get workspace: %w", err)
}
updatedToActiveVersion := false
if req.Transition == codersdk.WorkspaceTransitionStart {
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("get template: %w", err)
}
templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
if templateAccessControl.RequireActiveVersion {
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("get latest workspace build: %w", err)
}
updatedToActiveVersion = latestBuild.TemplateVersionID != template.ActiveVersionID
req.TemplateVersionID = template.ActiveVersionID
}
}
// Build a synthetic API key so postWorkspaceBuildsInternal can
// record the correct initiator.
syntheticKey := database.APIKey{
UserID: ownerID,
}
apiBuild, err := api.postWorkspaceBuildsInternal(
ctx,
syntheticKey,
workspace,
req,
func(action policy.Action, object rbac.Objecter) bool {
// Authorization is handled by dbauthz on the context.
authErr := api.HTTPAuth.Authorizer.Authorize(ctx, actor, action, object.RBACObject())
return authErr == nil
},
audit.WorkspaceBuildBaggage{},
)
if err != nil {
if updatedToActiveVersion && isChatStartWorkspaceManualUpdateRequiredError(err) {
const retryInstructions = "The workspace needs the template's active version before it can start. Use read_template with this workspace's template_id to inspect the active version's required parameters, then retry start_workspace with a parameters object that supplies any missing or changed values. If the correct value for a parameter is not obvious from its description or defaults, ask the user rather than guessing."
if responder, ok := httperror.IsResponder(err); ok {
status, resp := responder.Response()
resp = rewriteChatStartWorkspaceManualUpdateResponse(resp, err.Error(), retryInstructions)
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(status, resp)
}
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: retryInstructions,
Detail: err.Error(),
})
}
return codersdk.WorkspaceBuild{}, xerrors.Errorf("create workspace build: %w", err)
}
return apiBuild, nil
}
// chatStopWorkspace stops a workspace by creating a new build with the
// "stop" transition. It mirrors chatStartWorkspace, without start-only
// active-version behavior.
func (api *API) chatStopWorkspace(
ctx context.Context,
ownerID uuid.UUID,
workspaceID uuid.UUID,
req codersdk.CreateWorkspaceBuildRequest,
) (codersdk.WorkspaceBuild, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("get workspace: %w", err)
}
req.Transition = codersdk.WorkspaceTransitionStop
// Build a synthetic API key so postWorkspaceBuildsInternal can
// record the correct initiator.
syntheticKey := database.APIKey{
UserID: ownerID,
}
apiBuild, err := api.postWorkspaceBuildsInternal(
ctx,
syntheticKey,
workspace,
req,
func(action policy.Action, object rbac.Objecter) bool {
// Authorization is handled by dbauthz on the context.
authErr := api.HTTPAuth.Authorizer.Authorize(ctx, actor, action, object.RBACObject())
return authErr == nil
},
audit.WorkspaceBuildBaggage{},
)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("create workspace build: %w", err)
}
return apiBuild, nil
}
func rewriteChatStartWorkspaceManualUpdateResponse(resp codersdk.Response, fallbackDetail string, retryInstructions string) codersdk.Response {
originalMessage := resp.Message
resp.Message = retryInstructions
if len(resp.Validations) == 0 && originalMessage != "" {
if resp.Detail == "" {
resp.Detail = originalMessage
} else {
resp.Detail = originalMessage + ": " + resp.Detail
}
} else if resp.Detail == "" {
resp.Detail = fallbackDetail
}
return resp
}
func isChatStartWorkspaceManualUpdateRequiredError(err error) bool {
var diagnosticErr *dynamicparameters.DiagnosticError
if errors.As(err, &diagnosticErr) {
return true
}
return errors.Is(err, wsbuilder.ErrParameterValidation)
}
func chatWorkspaceAuditStatus(err error) int {
if responder, ok := httperror.IsResponder(err); ok {
status, _ := responder.Response()
return status
}
return http.StatusInternalServerError
}
func (api *API) resolveChatDiffContents(
ctx context.Context,
chat database.Chat,
) (codersdk.ChatDiffContents, error) {
result := codersdk.ChatDiffContents{ChatID: chat.ID}
status, found, err := api.getCachedChatDiffStatus(ctx, chat.ID)
if err != nil {
return result, err
}
reference, err := api.resolveChatDiffReference(ctx, chat, found, status)
if err != nil {
return result, err
}
if reference.RepositoryRef != nil {
provider := strings.TrimSpace(reference.RepositoryRef.Provider)
if provider != "" {
result.Provider = &provider
}
origin := strings.TrimSpace(reference.RepositoryRef.RemoteOrigin)
if origin != "" {
result.RemoteOrigin = &origin
}
branch := strings.TrimSpace(reference.RepositoryRef.Branch)
if branch != "" {
result.Branch = &branch
}
}
if reference.PullRequestURL != "" {
pullRequestURL := strings.TrimSpace(reference.PullRequestURL)
result.PullRequestURL = &pullRequestURL
// The agent's report creates the row. Discovery only fills in
// the URL, so skip until the ref was reported.
if found && (!strings.EqualFold(strings.TrimSpace(status.Url.String), pullRequestURL)) {
err := api.Database.UpdateChatDiffStatusReferenceURL(
ctx,
database.UpdateChatDiffStatusReferenceURLParams{
ChatID: status.ChatID,
GitBranch: status.GitBranch,
GitRemoteOrigin: status.GitRemoteOrigin,
StaleAt: time.Now().UTC().Add(-time.Second),
Url: pullRequestURL,
},
)
if err != nil {
return result, xerrors.Errorf("update chat diff status reference url: %w", err)
}
}
}
if reference.RepositoryRef == nil {
return result, nil
}
gp := api.resolveGitProvider(ctx, reference.RepositoryRef.RemoteOrigin)
if gp == nil {
return result, nil
}
token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin)
if errors.Is(err, gitsync.ErrNoTokenAvailable) || token == nil {
// No token available; return metadata without fetching diff.
return result, nil
} else if err != nil {
return result, xerrors.Errorf("resolve git access token: %w", err)
}
if reference.PullRequestURL != "" {
ref, ok := gp.ParsePullRequestURL(reference.PullRequestURL)
if !ok {
return result, xerrors.Errorf("invalid pull request URL %q", reference.PullRequestURL)
}
diff, err := gp.FetchPullRequestDiff(ctx, *token, ref)
if err != nil {
return result, err
}
result.Diff = diff
return result, nil
}
diff, err := gp.FetchBranchDiff(ctx, *token, gitprovider.BranchRef{
Owner: reference.RepositoryRef.Owner,
Repo: reference.RepositoryRef.Repo,
Branch: reference.RepositoryRef.Branch,
})
if err != nil {
return result, err
}
result.Diff = diff
return result, nil
}
// resolveChatDiffReference builds the diff reference from the cached
// status stored in the database. The git branch and remote origin are
// populated by the workspace agent during git operations (via the
// gitaskpass flow), so no SSH into the workspace is needed here.
//
//nolint:revive // Boolean indicates whether diff status was found.
func (api *API) resolveChatDiffReference(
ctx context.Context,
chat database.Chat,
found bool,
status database.ChatDiffStatus,
) (chatDiffReference, error) {
reference := chatDiffReference{}
if !found {
return reference, nil
}
reference.PullRequestURL = strings.TrimSpace(status.Url.String)
// Build the repository ref from the stored git branch/origin
// that the agent reported.
reference.RepositoryRef = api.buildChatRepositoryRefFromStatus(ctx, status)
// If we have a repo ref with a branch, try to resolve the
// current open PR. This picks up new PRs after the previous
// one was closed.
if reference.RepositoryRef != nil && reference.RepositoryRef.Owner != "" {
gp := api.resolveGitProvider(ctx, reference.RepositoryRef.RemoteOrigin)
if gp != nil {
token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin)
if token == nil || errors.Is(err, gitsync.ErrNoTokenAvailable) {
// No token available yet.
return reference, nil
} else if err != nil {
return chatDiffReference{}, xerrors.Errorf("resolve git access token: %w", err)
}
prRef, lookupErr := gp.ResolveBranchPullRequest(ctx, *token, gitprovider.BranchRef{
Owner: reference.RepositoryRef.Owner,
Repo: reference.RepositoryRef.Repo,
Branch: reference.RepositoryRef.Branch,
})
if lookupErr != nil {
api.Logger.Debug(ctx, "failed to resolve pull request from repository reference",
slog.F("chat_id", chat.ID),
slog.F("provider", reference.RepositoryRef.Provider),
slog.F("remote_origin", reference.RepositoryRef.RemoteOrigin),
slog.F("branch", reference.RepositoryRef.Branch),
slog.Error(lookupErr),
)
} else if prRef != nil {
reference.PullRequestURL = gp.BuildPullRequestURL(*prRef)
}
reference.PullRequestURL = gp.NormalizePullRequestURL(reference.PullRequestURL)
}
}
// If we have a PR URL but no repo ref (e.g. the agent hasn't
// reported branch/origin yet), derive a partial ref from the
// PR URL so the caller can still show provider/owner/repo.
if reference.RepositoryRef == nil && reference.PullRequestURL != "" {
for _, extAuth := range api.ExternalAuthConfigs {
gp, err := extAuth.Git()
if err != nil || gp == nil {
continue
}
if parsed, ok := gp.ParsePullRequestURL(reference.PullRequestURL); ok {
reference.RepositoryRef = &chatRepositoryRef{
Provider: strings.ToLower(extAuth.Type),
Owner: parsed.Owner,
Repo: parsed.Repo,
RemoteOrigin: gp.BuildRepositoryURL(parsed.Owner, parsed.Repo),
}
break
}
}
}
return reference, nil
}
// buildChatRepositoryRefFromStatus constructs a chatRepositoryRef
// from the git branch and remote origin stored in the cached status.
// Returns nil if no ref data is available.
func (api *API) buildChatRepositoryRefFromStatus(ctx context.Context, status database.ChatDiffStatus) *chatRepositoryRef {
branch := strings.TrimSpace(status.GitBranch)
origin := strings.TrimSpace(status.GitRemoteOrigin)
if branch == "" || origin == "" {
return nil
}
providerType, gp := api.resolveExternalAuth(ctx, origin)
repoRef := &chatRepositoryRef{
Provider: providerType,
RemoteOrigin: origin,
Branch: branch,
}
if gp != nil {
if owner, repo, normalizedOrigin, ok := gp.ParseRepositoryOrigin(repoRef.RemoteOrigin); ok {
repoRef.RemoteOrigin = normalizedOrigin
repoRef.Owner = owner
repoRef.Repo = repo
}
}
if repoRef.Provider == "" {
return nil
}
return repoRef
}
func (api *API) getCachedChatDiffStatus(
ctx context.Context,
chatID uuid.UUID,
) (database.ChatDiffStatus, bool, error) {
statuses, err := api.Database.GetChatDiffStatusesByChatID(ctx, chatID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return database.ChatDiffStatus{}, false, xerrors.Errorf(
"get chat diff status: %w",
err,
)
}
if len(statuses) == 0 {
return database.ChatDiffStatus{}, false, nil
}
return statuses[0], true, nil
}
// resolveExternalAuth finds the external auth config matching the
// given remote origin URL and returns both the provider type string
// (e.g. "github") and the gitprovider.Provider. Returns ("", nil)
// if no matching config is found or no provider could be constructed.
func (api *API) resolveExternalAuth(ctx context.Context, origin string) (providerType string, gp gitprovider.Provider) {
origin = strings.TrimSpace(origin)
if origin == "" {
return "", nil
}
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.Regex == nil || !extAuth.Regex.MatchString(origin) {
continue
}
p, err := extAuth.Git()
if err != nil {
api.Logger.Warn(ctx, "failed to construct git provider",
slog.F("provider_id", extAuth.ID),
slog.F("provider_type", extAuth.Type),
slog.Error(err),
)
continue
}
if p == nil {
continue
}
return strings.ToLower(strings.TrimSpace(extAuth.Type)), p
}
return "", nil
}
// resolveGitProvider finds the external auth config matching the
// given remote origin URL and returns its git provider. Returns
// nil if no matching git provider is configured.
func (api *API) resolveGitProvider(ctx context.Context, origin string) gitprovider.Provider {
_, gp := api.resolveExternalAuth(ctx, origin)
return gp
}
func (api *API) resolveChatGitAccessToken(
ctx context.Context,
userID uuid.UUID,
origin string,
) (*string, error) {
origin = strings.TrimSpace(origin)
// If we have an origin, find the specific matching config first.
// This ensures multi-provider setups (github.com + GHE) get the
// correct token.
if origin != "" {
for _, config := range api.ExternalAuthConfigs {
if config.Regex == nil || !config.Regex.MatchString(origin) {
continue
}
//nolint:gocritic // System access needed to read external auth
// links when called from the gitsync worker (chatd context).
link, err := api.Database.GetExternalAuthLink(dbauthz.AsSystemRestricted(ctx),
database.GetExternalAuthLinkParams{
ProviderID: config.ID,
UserID: userID,
},
)
if err != nil {
continue
}
//nolint:gocritic // System context carried through for token refresh.
refreshed, refreshErr := config.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
if refreshErr == nil {
link = refreshed
}
token := strings.TrimSpace(link.OAuthAccessToken)
if token != "" {
return new(token), nil
}
}
}
// Fallback: iterate all external auth configs.
// Used when origin is empty (inline refresh from HTTP handler)
// or when the origin-specific lookup above failed.
configs := make(map[string]*externalauth.Config)
providerIDs := []string{}
for _, config := range api.ExternalAuthConfigs {
providerIDs = append(providerIDs, config.ID)
configs[config.ID] = config
}
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
if _, ok := seen[providerID]; ok {
continue
}
seen[providerID] = struct{}{}
//nolint:gocritic // System access needed to read external auth
// links when called from the gitsync worker (chatd context).
link, err := api.Database.GetExternalAuthLink(
dbauthz.AsSystemRestricted(ctx),
database.GetExternalAuthLinkParams{
ProviderID: providerID,
UserID: userID,
},
)
if err != nil {
continue
}
// Refresh the token if there is a matching config, mirroring
// the same code path used by provisionerdserver when handing
// tokens to provisioners.
if cfg, ok := configs[providerID]; ok {
//nolint:gocritic // System context carried through for token refresh.
refreshed, refreshErr := cfg.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
if refreshErr != nil {
api.Logger.Debug(ctx, "failed to refresh external auth token for chat diff",
slog.F("provider_id", providerID),
slog.F("user_id", userID),
slog.Error(refreshErr),
)
// Fall through because the existing token may still work.
// (e.g. GitHub tokens with no expiry).
} else {
link = refreshed
}
}
token := strings.TrimSpace(link.OAuthAccessToken)
if token != "" {
return new(token), nil
}
}
return nil, gitsync.ErrNoTokenAvailable
}
type createChatWorkspaceSelection struct {
WorkspaceID uuid.NullUUID
}
func (api *API) validateChatWorkspaceSelection(
ctx context.Context,
workspaceID *uuid.UUID,
) (
uuid.NullUUID,
database.Workspace,
int,
*codersdk.Response,
) {
if workspaceID == nil {
return uuid.NullUUID{}, database.Workspace{}, 0, nil
}
workspace, err := api.Database.GetWorkspaceByID(ctx, *workspaceID)
if err != nil {
if httpapi.Is404Error(err) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return uuid.NullUUID{}, database.Workspace{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to get workspace.",
Detail: err.Error(),
}
}
selection := uuid.NullUUID{
UUID: workspace.ID,
Valid: true,
}
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionSSH, workspace) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return selection, workspace, 0, nil
}
func (api *API) validateCreateChatWorkspaceSelection(
ctx context.Context,
req codersdk.CreateChatRequest,
) (
createChatWorkspaceSelection,
int,
*codersdk.Response,
) {
selection := createChatWorkspaceSelection{}
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
return selection, status, resp
}
selection.WorkspaceID = workspaceID
if !workspaceID.Valid {
return selection, 0, nil
}
if workspace.OrganizationID != req.OrganizationID {
return selection, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace does not belong to the specified organization.",
}
}
return selection, 0, nil
}
func (api *API) resolveCreateChatModelConfigID(
ctx context.Context,
userID uuid.UUID,
req codersdk.CreateChatRequest,
) (uuid.UUID, *string, int, *codersdk.Response) {
if req.ModelConfigID != nil {
if *req.ModelConfigID == uuid.Nil {
return uuid.Nil, nil, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model config ID.",
}
}
if _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, req.OrganizationID, *req.ModelConfigID); resp != nil {
return uuid.Nil, nil, status, resp
}
return *req.ModelConfigID, nil, 0, nil
}
overrideModelConfigID, overrideEffort, err := api.personalRootModelOverride(ctx, userID, req.OrganizationID)
if err != nil {
return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
if overrideModelConfigID != uuid.Nil {
return overrideModelConfigID, overrideEffort, 0, nil
}
id, status, resp := api.defaultCreateChatModelConfigID(ctx, req.OrganizationID)
return id, nil, status, resp
}
// personalRootModelOverride returns the user's available personal root
// model override and its reasoning effort, or uuid.Nil when personal
// overrides are disabled or none applies.
func (api *API) personalRootModelOverride(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
) (uuid.UUID, *string, error) {
personalOverridesEnabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
return uuid.Nil, nil, err
}
if !personalOverridesEnabled {
return uuid.Nil, nil, nil
}
override, err := api.Database.GetChatUserModelOverride(ctx, database.GetChatUserModelOverrideParams{
UserID: userID,
OrganizationID: organizationID,
Context: string(codersdk.ChatPersonalModelOverrideContextRoot),
})
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil, nil
}
if err != nil {
return uuid.Nil, nil, err
}
switch codersdk.ChatPersonalModelOverrideMode(override.Mode) {
case codersdk.ChatPersonalModelOverrideModeChatDefault:
case codersdk.ChatPersonalModelOverrideModeModel:
if override.ModelConfigID.Valid {
_, reason, err := api.userCanUseChatModelConfig(
ctx,
userID,
organizationID,
override.ModelConfigID.UUID,
)
if err != nil {
return uuid.Nil, nil, err
}
if reason == chatModelConfigAvailable {
var effort *string
if override.ReasoningEffort.Valid {
effort = &override.ReasoningEffort.String
}
return override.ModelConfigID.UUID, effort, nil
}
api.Logger.Debug(
ctx,
"personal root model override is unavailable, using default model",
slog.F("user_id", userID),
slog.F("model_config_id", override.ModelConfigID.UUID),
slog.F("reason", reason),
)
}
default:
api.Logger.Warn(
ctx,
"unsupported personal root model override mode, using default model",
slog.F("user_id", userID),
slog.F("mode", override.Mode),
)
}
return uuid.Nil, nil, nil
}
func (api *API) defaultCreateChatModelConfigID(
ctx context.Context,
organizationID uuid.UUID,
) (uuid.UUID, int, *codersdk.Response) {
rows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
effective := database.DeriveEffectiveChatModelConfigs(rows)
if effective.DefaultConfig.ID != uuid.Nil {
return effective.DefaultConfig.ID, 0, nil
}
return uuid.Nil, http.StatusBadRequest, noLocalChatModelResponse()
}
// validateChatCompressionThreshold enforces the chat_model_configs CHECK
// constraint range.
func validateChatCompressionThreshold(threshold int32) error {
if threshold < minChatContextCompressionThreshold ||
threshold > maxChatContextCompressionThreshold {
return xerrors.Errorf(
"context_compression_threshold must be between %d and %d",
minChatContextCompressionThreshold,
maxChatContextCompressionThreshold,
)
}
return nil
}
func normalizeChatCompressionThreshold(
requested *int32,
fallback int32,
) (int32, error) {
threshold := fallback
if requested != nil {
threshold = *requested
}
if err := validateChatCompressionThreshold(threshold); err != nil {
return 0, err
}
return threshold, nil
}
func parseCompactionThresholdKey(key string) (uuid.UUID, error) {
if !strings.HasPrefix(key, codersdk.ChatCompactionThresholdKeyPrefix) {
return uuid.Nil, xerrors.Errorf("invalid compaction threshold key: %q", key)
}
id, err := uuid.Parse(key[len(codersdk.ChatCompactionThresholdKeyPrefix):])
if err != nil {
return uuid.Nil, xerrors.Errorf("invalid model config ID in key %q: %w", key, err)
}
return id, nil
}
// @Summary Get chat system prompt
// @ID get-chat-system-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatSystemPromptResponse
// @Router /api/v2/chats/config/system-prompt [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
config, err := api.Database.GetChatSystemPromptConfig(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat system prompt configuration.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPromptResponse{
SystemPrompt: config.ChatSystemPrompt,
IncludeDefaultSystemPrompt: config.IncludeDefaultSystemPrompt,
DefaultSystemPrompt: chatd.DefaultSystemPrompt,
})
}
// chatInstructionSettingsLockTimeout bounds how long a request waits for the
// per-setting advisory lock. The only other holders are sibling requests
// holding it for a single upsert.
const chatInstructionSettingsLockTimeout = 5 * time.Second
// @Summary Update chat system prompt
// @ID update-chat-system-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatSystemPromptRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/system-prompt [put]
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Identity is assigned before the authorization check so a denied PUT
// records the attempt with status 403 and an empty diff. The body is
// never read before authorization, so no request content reaches that
// row.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.Old = database.ChatInstructionSettings{
ID: audit.ChatInstructionSystemPromptID,
Name: audit.ChatInstructionSystemPromptName,
}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
var req codersdk.UpdateChatSystemPromptRequest
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &req) {
return
}
sanitizedPrompt := codersdk.SanitizePromptText(req.SystemPrompt)
if len(sanitizedPrompt) > api.chatLimits.MaxPromptBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "System prompt exceeds maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", api.chatLimits.MaxPromptBytes, len(sanitizedPrompt)),
})
return
}
var noChange bool
// The per-setting advisory lock serializes the audit change-detection
// with the write: two concurrent identical PUTs both still succeed,
// but the second transaction's comparison sees the first's committed
// state and reports no change. The lock wait is bounded so a waiter
// cannot hang past the client's deadline.
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionSystemPrompt); err != nil {
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
oldConfig, err := tx.GetChatSystemPromptConfig(ctx)
if err != nil {
return err
}
aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt
aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet
aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
// Only update the include-default flag when the caller explicitly
// provides it. Omitting the field preserves whatever is currently
// stored (or the schema-level default for new deployments),
// avoiding a backward-compatibility regression for older clients
// that only send system_prompt.
if req.IncludeDefaultSystemPrompt != nil {
if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil {
return err
}
}
// Derive New from what was written rather than re-reading: the
// upserts store $1 verbatim, so the stored prompt is the request
// value, and the effective include-default flag follows
// GetChatSystemPromptConfig's rule from the written toggle row
// and the written prompt. A post-write read would fail on
// query-local context cancellation even after a successful
// commit, silently discarding a change the client made.
newIncludeSet := oldConfig.IncludeDefaultSystemPromptSet
newIncludeValue := oldConfig.IncludeDefaultSystemPrompt
if req.IncludeDefaultSystemPrompt != nil {
newIncludeSet = true
newIncludeValue = *req.IncludeDefaultSystemPrompt
}
if !newIncludeSet {
// Legacy fallback: a non-empty custom prompt implies opting
// out; otherwise the setting defaults to true.
newIncludeValue = sanitizedPrompt == ""
}
aReq.New.SystemPrompt = sanitizedPrompt
aReq.New.IncludeDefaultSystemPromptSet = newIncludeSet
aReq.New.IncludeDefaultSystemPrompt = newIncludeValue
noChange = aReq.New.SystemPrompt == aReq.Old.SystemPrompt &&
aReq.New.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet &&
aReq.New.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: err.Error(),
})
return
}
if noChange {
// Stage the no-op decision until after the transaction commits,
// so a commit failure cannot suppress an attempt row.
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat plan mode instructions
// @ID get-chat-plan-mode-instructions
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatPlanModeInstructionsResponse
// @Router /api/v2/chats/config/plan-mode-instructions [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
instructions, err := api.Database.GetChatPlanModeInstructions(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching plan mode instructions.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPlanModeInstructionsResponse{
PlanModeInstructions: instructions,
})
}
// @Summary Update chat plan mode instructions
// @ID update-chat-plan-mode-instructions
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatPlanModeInstructionsRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/plan-mode-instructions [put]
func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Identity is assigned before the authorization check so a denied PUT
// records the attempt with status 403 and an empty diff. The body is
// never read before authorization, so no request content reaches that
// row.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.Old = database.ChatInstructionSettings{
ID: audit.ChatInstructionPlanModeID,
Name: audit.ChatInstructionPlanModeName,
}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
var req codersdk.UpdateChatPlanModeInstructionsRequest
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &req) {
return
}
sanitizedInstructions := codersdk.SanitizePromptText(req.PlanModeInstructions)
if len(sanitizedInstructions) > api.chatLimits.MaxPromptBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Plan mode instructions exceed maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", api.chatLimits.MaxPromptBytes, len(sanitizedInstructions)),
})
return
}
var noChange bool
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionPlanMode); err != nil {
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
oldInstructions, err := tx.GetChatPlanModeInstructions(ctx)
if err != nil {
return err
}
aReq.Old.PlanModeInstructions = oldInstructions
if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
return err
}
aReq.New.PlanModeInstructions = sanitizedInstructions
noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions
return nil
}, database.DefaultTXOptions().WithID("chat_plan_mode_instructions_write"))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: err.Error(),
})
return
}
if noChange {
// Stage the no-op decision until after the transaction commits,
// so a commit failure cannot suppress an attempt row.
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
func readChatModelOverrideContext(
rw http.ResponseWriter,
r *http.Request,
) (codersdk.ChatModelOverrideContext, bool) {
ctx := r.Context()
rawContext := chi.URLParam(r, "context")
overrideContext, err := parseChatModelOverrideContext(rawContext)
if err == nil {
return overrideContext, true
}
validContextValues := make(
[]string,
0,
len(codersdk.AllChatModelOverrideContexts()),
)
for _, overrideContext := range codersdk.AllChatModelOverrideContexts() {
validContextValues = append(validContextValues, string(overrideContext))
}
validContexts := strings.Join(validContextValues, ", ")
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat model override context.",
Detail: fmt.Sprintf(
"Expected one of %s. Got %q.",
validContexts,
rawContext,
),
})
return "", false
}
// @Summary List organization chat model overrides
// @ID list-organization-chat-model-overrides
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Success 200 {object} codersdk.ChatModelOverridesResponse
// @Router /api/v2/organizations/{organization}/chats/model-overrides [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getOrganizationChatModelOverrides(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
rows, err := api.Database.GetChatOrganizationModelOverrides(ctx, organization.ID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching model overrides.",
Detail: err.Error(),
})
return
}
response := codersdk.ChatModelOverridesResponse{
Overrides: make([]codersdk.ChatModelOverrideResponse, 0, len(rows)),
}
for _, row := range rows {
response.Overrides = append(response.Overrides, chatOrganizationModelOverrideResponse(row))
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Update organization chat model override
// @ID update-organization-chat-model-override
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param context path string true "Override context" Enums(general,explore,title_generation,compaction,advisor)
// @Param request body codersdk.UpdateChatModelOverrideRequest true "Model override"
// @Success 200 {object} codersdk.ChatModelOverrideResponse
// @Router /api/v2/organizations/{organization}/chats/model-overrides/{context} [put]
// @x-apidocgen {"skip": true}
func (api *API) putOrganizationChatModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organization.ID)) {
httpapi.ResourceNotFound(rw)
return
}
overrideContext, ok := readChatModelOverrideContext(rw, r)
if !ok {
return
}
if overrideContext == codersdk.ChatModelOverrideContextAdvisor &&
!api.Experiments.Enabled(codersdk.ExperimentChatAdvisor) {
httpapi.ResourceNotFound(rw)
return
}
var req codersdk.UpdateChatModelOverrideRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
response := codersdk.ChatModelOverrideResponse{Context: overrideContext}
trimmedModelConfigID := strings.TrimSpace(req.ModelConfigID)
if trimmedModelConfigID == "" {
if req.ReasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "reasoning_effort requires model_config_id.",
})
return
}
err := api.Database.DeleteChatOrganizationModelOverride(ctx, database.DeleteChatOrganizationModelOverrideParams{
OrganizationID: organization.ID,
Context: string(overrideContext),
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Internal error clearing %s model override.", overrideContext),
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, response)
return
}
modelConfigID, err := uuid.Parse(trimmedModelConfigID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id.",
Detail: fmt.Sprintf("Value %q is not a valid UUID.", req.ModelConfigID),
})
return
}
// The explicit ActionUpdate authorization above gates this endpoint. Look
// the referenced model up under a system context so a custom role that
// grants update without read can still use the endpoint; organization
// ownership is enforced inside the validation.
//nolint:gocritic // See above.
status, validationResponse := validateChatModelOverride(
dbauthz.AsSystemRestricted(ctx),
api.Database,
organization.ID,
&modelConfigID,
req.ReasoningEffort,
)
if validationResponse != nil {
httpapi.Write(ctx, rw, status, *validationResponse)
return
}
row := database.ChatOrganizationModelOverride{
OrganizationID: organization.ID,
Context: string(overrideContext),
ModelConfigID: modelConfigID,
ReasoningEffort: sql.NullString{String: derefOrEmpty(req.ReasoningEffort), Valid: req.ReasoningEffort != nil},
}
err = api.Database.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{
OrganizationID: row.OrganizationID,
Context: row.Context,
ModelConfigID: row.ModelConfigID,
ReasoningEffort: row.ReasoningEffort,
})
if database.IsForeignKeyViolation(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid model_config_id."})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Internal error updating %s model override.", overrideContext),
Detail: err.Error(),
})
return
}
response = chatOrganizationModelOverrideResponse(row)
httpapi.Write(ctx, rw, http.StatusOK, response)
}
func readChatPersonalModelOverrideContext(
rw http.ResponseWriter,
r *http.Request,
) (codersdk.ChatPersonalModelOverrideContext, bool) {
ctx := r.Context()
rawContext := chi.URLParam(r, "context")
overrideContext, ok := parseChatPersonalModelOverrideContext(rawContext)
if ok {
return overrideContext, true
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat personal model override context.",
Detail: fmt.Sprintf(
"Expected one of %s. Got %q.",
chatPersonalModelOverrideContextsJoined(),
rawContext,
),
})
return "", false
}
// @Summary Get chat personal model override settings
// @ID get-chat-personal-model-override-settings
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatPersonalModelOverridesAdminSettings
// @Router /api/v2/chats/config/personal-model-overrides [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
enabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal model override setting.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPersonalModelOverridesAdminSettings{
AllowUsers: enabled,
})
}
const chatOperationalSettingsLockTimeout = 5 * time.Second
type chatOperationalSetting string
const (
chatOperationalSettingChatRetentionDays chatOperationalSetting = "agents_chat_retention_days"
chatOperationalSettingChatDebugRetentionDays chatOperationalSetting = "agents_chat_debug_retention_days"
chatOperationalSettingChatAutoArchiveDays chatOperationalSetting = "agents_chat_auto_archive_days"
chatOperationalSettingWorkspaceTTL chatOperationalSetting = "agents_workspace_ttl"
chatOperationalSettingComputerUseProvider chatOperationalSetting = "agents_computer_use_provider"
chatOperationalSettingDebugLoggingAllowUsers chatOperationalSetting = "agents_chat_debug_logging_allow_users"
chatOperationalSettingPersonalModelOverridesEnabled chatOperationalSetting = "agents_chat_personal_model_overrides_enabled"
)
func (s chatOperationalSetting) defaultValue() string {
switch s {
case chatOperationalSettingChatRetentionDays:
return "30"
case chatOperationalSettingChatDebugRetentionDays:
return strconv.FormatInt(int64(codersdk.DefaultChatDebugRetentionDays), 10)
case chatOperationalSettingChatAutoArchiveDays:
return strconv.FormatInt(int64(codersdk.DefaultChatAutoArchiveDays), 10)
case chatOperationalSettingWorkspaceTTL:
return "0s"
case chatOperationalSettingComputerUseProvider:
return string(chattool.DefaultComputerUseProvider(""))
case chatOperationalSettingDebugLoggingAllowUsers,
chatOperationalSettingPersonalModelOverridesEnabled:
return "false"
default:
panic(fmt.Sprintf("unknown chat operational setting %q", s))
}
}
func (s chatOperationalSetting) auditValue(value string, id uuid.UUID) database.ChatOperationalSettings {
settings := database.ChatOperationalSettings{ID: id}
switch s {
case chatOperationalSettingChatRetentionDays:
settings.ChatRetentionDays = value
case chatOperationalSettingChatDebugRetentionDays:
settings.ChatDebugRetentionDays = value
case chatOperationalSettingChatAutoArchiveDays:
settings.ChatAutoArchiveDays = value
case chatOperationalSettingWorkspaceTTL:
settings.WorkspaceTTL = value
case chatOperationalSettingComputerUseProvider:
settings.ComputerUseProvider = value
case chatOperationalSettingDebugLoggingAllowUsers:
settings.DebugLoggingAllowUsers = value
case chatOperationalSettingPersonalModelOverridesEnabled:
settings.PersonalModelOverridesEnabled = value
default:
panic(fmt.Sprintf("unknown chat operational setting %q", s))
}
return settings
}
func (api *API) initChatOperationalSettingsAudit(
rw http.ResponseWriter,
r *http.Request,
) (*audit.Request[database.ChatOperationalSettings], func(bool)) {
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite,
})
aReq.New.ID = uuid.New()
return aReq, commitAudit
}
// auditedChatOperationalSettingWrite captures the effective old value and
// performs the write in one transaction. It suppresses the audit entry when
// the effective value does not change.
func (api *API) auditedChatOperationalSettingWrite(
ctx context.Context,
aReq *audit.Request[database.ChatOperationalSettings],
commitAudit func(bool),
setting chatOperationalSetting,
newValue string,
write func(database.Store) error,
) error {
var noChange bool
lockCtx, lockCancel := context.WithTimeout(ctx, chatOperationalSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.GenLockID(string(setting))); err != nil {
return xerrors.Errorf("acquire chat operational setting write lock: %w", err)
}
old, err := tx.GetChatSiteConfigValue(ctx, string(setting))
if err != nil {
return err
}
oldValue := old.Value
if !old.Exists {
oldValue = setting.defaultValue()
}
if oldValue == newValue {
noChange = true
return nil
}
if err := write(tx); err != nil {
return err
}
aReq.Old = setting.auditValue(oldValue, uuid.Nil)
aReq.New = setting.auditValue(newValue, aReq.New.ID)
return nil
}, nil)
if err != nil {
return err
}
if noChange {
commitAudit(false)
}
return nil
}
// @Summary Update chat personal model override settings
// @ID update-chat-personal-model-override-settings
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/personal-model-overrides [put]
func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingPersonalModelOverridesEnabled,
strconv.FormatBool(req.AllowUsers),
func(tx database.Store) error { return tx.UpsertChatPersonalModelOverridesEnabled(ctx, req.AllowUsers) },
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating personal model override setting.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get organization member chat model overrides
// @ID get-organization-member-chat-model-overrides
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param user path string true "User name, ID, or me"
// @Success 200 {object} codersdk.UserChatPersonalModelOverridesResponse
// @Router /api/v2/organizations/{organization}/members/{user}/chats/model-overrides [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatPersonalModelOverrides(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
member := httpmw.OrganizationMemberParam(r)
enabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal model override setting.",
Detail: err.Error(),
})
return
}
rows, err := api.Database.GetChatUserModelOverrides(ctx, database.GetChatUserModelOverridesParams{
UserID: member.UserID,
OrganizationID: organization.ID,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user personal model overrides.",
Detail: err.Error(),
})
return
}
byContext := make(map[string]database.ChatUserModelOverride, len(rows))
for _, row := range rows {
byContext[row.Context] = row
}
deploymentDefaults, err := api.chatPersonalModelOverrideDeploymentDefaults(ctx, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching organization model defaults.",
Detail: err.Error(),
})
return
}
response := codersdk.UserChatPersonalModelOverridesResponse{
Enabled: enabled,
DeploymentDefaults: deploymentDefaults,
}
for _, overrideContext := range chatPersonalModelOverrideContexts {
var row *database.ChatUserModelOverride
if contextRow, ok := byContext[string(overrideContext)]; ok {
row = &contextRow
}
override := chatPersonalModelOverrideResponse(overrideContext, row)
switch overrideContext {
case codersdk.ChatPersonalModelOverrideContextRoot:
response.Root = override
case codersdk.ChatPersonalModelOverrideContextGeneral:
response.General = override
case codersdk.ChatPersonalModelOverrideContextExplore:
response.Explore = override
}
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Update organization member chat model override
// @ID update-organization-member-chat-model-override
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param organization path string true "Organization name or ID"
// @Param user path string true "User name, ID, or me"
// @Param context path string true "Override context" Enums(root,general,explore)
// @Param request body codersdk.UpdateUserChatPersonalModelOverrideRequest true "Personal model override"
// @Success 204
// @Router /api/v2/organizations/{organization}/members/{user}/chats/model-overrides/{context} [put]
// @x-apidocgen {"skip": true}
func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
member := httpmw.OrganizationMemberParam(r)
enabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal model override setting.",
Detail: err.Error(),
})
return
}
if !enabled && apiKey.UserID == member.UserID &&
!api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "An administrator has not enabled user personal model overrides.",
})
return
}
overrideContext, ok := readChatPersonalModelOverrideContext(rw, r)
if !ok {
return
}
var req codersdk.UpdateUserChatPersonalModelOverrideRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
modelConfigID := uuid.NullUUID{}
reasoningEffort := req.ReasoningEffort
rawModelConfigID := strings.TrimSpace(req.ModelConfigID)
switch req.Mode {
case codersdk.ChatPersonalModelOverrideModeChatDefault:
if rawModelConfigID != "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "model_config_id must be empty unless mode is model."})
return
}
if reasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "reasoning_effort requires mode model."})
return
}
case codersdk.ChatPersonalModelOverrideModeDeploymentDefault:
if overrideContext == codersdk.ChatPersonalModelOverrideContextRoot {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "deployment_default is not supported for root personal model overrides."})
return
}
if rawModelConfigID != "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "model_config_id must be empty unless mode is model."})
return
}
if reasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "reasoning_effort requires mode model."})
return
}
case codersdk.ChatPersonalModelOverrideModeModel:
if rawModelConfigID == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "model_config_id is required when mode is model."})
return
}
parsedModelConfigID, err := uuid.Parse(rawModelConfigID)
if err != nil || parsedModelConfigID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id.",
Detail: fmt.Sprintf("Value %q is not a valid UUID.", req.ModelConfigID),
})
return
}
// Validate with the target member's ACLs so a model visible only to
// an administrator caller is rejected instead of persisted; chatd
// resolves the override under the member's actor at runtime.
validateCtx := ctx
if apiKey.UserID != member.UserID {
memberSubject, _, err := httpmw.UserRBACSubject(ctx, api.Database, member.UserID, rbac.ScopeAll)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
})
return
}
validateCtx = dbauthz.As(ctx, memberSubject)
}
modelConfig, status, validationResponse := api.validateUserChatModelConfigAvailable(
validateCtx, member.UserID, organization.ID, parsedModelConfigID,
)
if validationResponse != nil {
httpapi.Write(ctx, rw, status, *validationResponse)
return
}
status, validationResponse = validateChatModelOverrideEffort(modelConfig, reasoningEffort)
if validationResponse != nil {
httpapi.Write(ctx, rw, status, *validationResponse)
return
}
modelConfigID = uuid.NullUUID{UUID: parsedModelConfigID, Valid: true}
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid personal model override mode."})
return
}
err = api.Database.UpsertChatUserModelOverride(ctx, database.UpsertChatUserModelOverrideParams{
UserID: member.UserID,
OrganizationID: organization.ID,
Context: string(overrideContext),
Mode: string(req.Mode),
ModelConfigID: modelConfigID,
ReasoningEffort: sql.NullString{String: derefOrEmpty(reasoningEffort), Valid: reasoningEffort != nil},
})
if database.IsForeignKeyViolation(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id: model config not found or disabled.",
})
return
}
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user personal model override.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatComputerUseProvider(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
provider, err := api.Database.GetChatComputerUseProvider(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching computer use provider.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatComputerUseProviderResponse{
Provider: chattool.DefaultComputerUseProvider(codersdk.ChatComputerUseProvider(provider)),
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatComputerUseProvider(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatComputerUseProviderRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if !req.Provider.Valid() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid computer use provider.",
Detail: fmt.Sprintf(
"Expected one of: %s. Got %q.",
strings.Join(chattool.SupportedComputerUseProviders(), ", "),
req.Provider,
),
})
return
}
value := string(req.Provider)
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingComputerUseProvider, value,
func(tx database.Store) error { return tx.UpsertChatComputerUseProvider(ctx, value) },
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating computer use provider.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) deploymentChatDebugLoggingEnabled() bool {
return api.DeploymentValues != nil && api.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value()
}
// @Summary Get chat debug logging setting
// @ID get-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatDebugLoggingAdminSettings
// @Router /api/v2/chats/config/debug-logging [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
allowUsers, err := api.Database.GetChatDebugLoggingAllowUsers(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat debug logging setting.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugLoggingAdminSettings{
AllowUsers: err == nil && allowUsers,
ForcedByDeployment: api.deploymentChatDebugLoggingEnabled(),
})
}
// @Summary Update chat debug logging setting
// @ID update-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatDebugLoggingAllowUsersRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/debug-logging [put]
func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatDebugLoggingAllowUsersRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingDebugLoggingAllowUsers,
strconv.FormatBool(req.AllowUsers),
func(tx database.Store) error { return tx.UpsertChatDebugLoggingAllowUsers(ctx, req.AllowUsers) },
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat debug logging setting.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get user chat debug logging setting
// @ID get-user-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.UserChatDebugLoggingSettings
// @Router /api/v2/chats/config/user-debug-logging [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
forcedByDeployment := api.deploymentChatDebugLoggingEnabled()
allowUsers := false
if !forcedByDeployment {
enabled, err := api.Database.GetChatDebugLoggingAllowUsers(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat debug logging setting.",
Detail: err.Error(),
})
return
}
allowUsers = err == nil && enabled
}
debugEnabled := forcedByDeployment
if allowUsers {
enabled, err := api.Database.GetUserChatDebugLoggingEnabled(ctx, apiKey.UserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user chat debug logging setting.",
Detail: err.Error(),
})
return
}
debugEnabled = err == nil && enabled
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatDebugLoggingSettings{
DebugLoggingEnabled: debugEnabled,
UserToggleAllowed: !forcedByDeployment && allowUsers,
ForcedByDeployment: forcedByDeployment,
})
}
// @Summary Update user chat debug logging setting
// @ID update-user-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateUserChatDebugLoggingRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/user-debug-logging [put]
func (api *API) putUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if api.deploymentChatDebugLoggingEnabled() {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat debug logging is already forced on by deployment configuration.",
})
return
}
allowUsers, err := api.Database.GetChatDebugLoggingAllowUsers(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat debug logging setting.",
Detail: err.Error(),
})
return
}
if err != nil || !allowUsers {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "An administrator has not enabled user-controlled chat debug logging.",
})
return
}
var req codersdk.UpdateUserChatDebugLoggingRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := api.Database.UpsertUserChatDebugLoggingEnabled(ctx, database.UpsertUserChatDebugLoggingEnabledParams{
UserID: apiKey.UserID,
DebugLoggingEnabled: req.DebugLoggingEnabled,
}); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user chat debug logging setting.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
raw, err := api.Database.GetChatAdvisorConfig(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching advisor configuration.",
Detail: err.Error(),
})
return
}
var resp codersdk.AdvisorConfig
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Stored advisor configuration is invalid.",
Detail: err.Error(),
})
return
}
resp.MaxUsesPerRun = max(resp.MaxUsesPerRun, 0)
resp.MaxOutputTokens = max(resp.MaxOutputTokens, 0)
resp.Enabled = api.Experiments.Enabled(codersdk.ExperimentChatAdvisor)
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateAdvisorConfigRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.DeprecatedModelConfigID != nil || req.DeprecatedReasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Advisor model settings moved to PUT /api/v2/organizations/{organization}/chats/model-overrides/advisor.",
})
return
}
if req.MaxUsesPerRun < 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("max_uses_per_run %d must be non-negative.", req.MaxUsesPerRun),
})
return
}
if req.MaxOutputTokens < 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("max_output_tokens %d must be non-negative.", req.MaxOutputTokens),
})
return
}
runtimeConfig := codersdk.AdvisorConfig{
MaxUsesPerRun: req.MaxUsesPerRun,
MaxOutputTokens: req.MaxOutputTokens,
}
raw, err := json.Marshal(runtimeConfig)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error encoding advisor configuration.",
Detail: err.Error(),
})
return
}
if err := api.Database.UpsertChatAdvisorConfig(ctx, string(raw)); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating advisor configuration.",
Detail: err.Error(),
})
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventAdvisorConfig, uuid.Nil)
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat workspace time to live
// @ID get-chat-workspace-time-to-live
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatWorkspaceTTLResponse
// @Router /api/v2/chats/config/workspace-ttl [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
raw, err := api.Database.GetChatWorkspaceTTL(ctx)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace TTL setting.",
Detail: err.Error(),
})
return
}
// Validate/default the stored value so callers always receive a
// well-formed duration string.
d, err := codersdk.ParseChatWorkspaceTTL(raw)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Stored workspace TTL is invalid.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatWorkspaceTTLResponse{
WorkspaceTTLMillis: d.Milliseconds(),
})
}
// @Summary Update chat workspace time to live
// @ID update-chat-workspace-time-to-live
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatWorkspaceTTLRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/workspace-ttl [put]
func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatWorkspaceTTLRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
// Validate before converting to avoid int64 overflow in the
// multiplication by time.Millisecond.
if req.WorkspaceTTLMillis < 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Workspace TTL must be non-negative.",
})
return
}
// Convert milliseconds to duration.
d := time.Duration(req.WorkspaceTTLMillis) * time.Millisecond
// Technically a duplication of validWorkspaceTTL but this is not scoped to templates.
if d > 0 && d < ttlMinimum {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Workspace TTL must not be less than 1 minute.",
})
return
}
if d > ttlMaximum {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Workspace TTL must not exceed 30 days.",
})
return
}
value := d.String()
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingWorkspaceTTL, value,
func(tx database.Store) error { return tx.UpsertChatWorkspaceTTL(ctx, value) },
)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating workspace TTL setting.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat retention days
// @ID get-chat-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatRetentionDaysResponse
// @Router /api/v2/chats/config/retention-days [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
retentionDays, err := api.Database.GetChatRetentionDays(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat retention days.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatRetentionDaysResponse{
RetentionDays: retentionDays,
})
}
// Keep in sync with retentionDaysMaximum in
// site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx.
const retentionDaysMaximum = 3650 // ~10 years
// @Summary Update chat retention days
// @ID update-chat-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatRetentionDaysRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/retention-days [put]
// @x-apidocgen {"skip": true}
func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatRetentionDaysRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.RetentionDays < 0 || req.RetentionDays > retentionDaysMaximum {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Retention days must be between 0 and %d.", retentionDaysMaximum),
})
return
}
value := strconv.FormatInt(int64(req.RetentionDays), 10)
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingChatRetentionDays, value,
func(tx database.Store) error { return tx.UpsertChatRetentionDays(ctx, req.RetentionDays) },
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat retention days.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// getChatDebugRetentionDays returns the deployment-wide chat debug run
// retention window. Any authenticated user can read it; writes require admin.
//
// @Summary Get chat debug retention days
// @ID get-chat-debug-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatDebugRetentionDaysResponse
// @Router /api/v2/chats/config/debug-retention-days [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
retentionDays, err := api.Database.GetChatDebugRetentionDays(ctx, codersdk.DefaultChatDebugRetentionDays)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat debug retention days.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugRetentionDaysResponse{
DebugRetentionDays: retentionDays,
})
}
// Keep in sync with the validation schema in
// site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx.
const chatDebugRetentionDaysMaximum = 3650 // ~10 years
// putChatDebugRetentionDays updates the deployment-wide chat debug run
// retention window. Admin-only.
//
// @Summary Update chat debug retention days
// @ID update-chat-debug-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatDebugRetentionDaysRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/debug-retention-days [put]
func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatDebugRetentionDaysRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.DebugRetentionDays < 0 || req.DebugRetentionDays > chatDebugRetentionDaysMaximum {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Chat debug retention days must be between 0 and %d.", chatDebugRetentionDaysMaximum),
})
return
}
value := strconv.FormatInt(int64(req.DebugRetentionDays), 10)
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingChatDebugRetentionDays, value,
func(tx database.Store) error { return tx.UpsertChatDebugRetentionDays(ctx, req.DebugRetentionDays) },
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat debug retention days.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// getChatAutoArchiveDays returns the deployment-wide auto-archive
// window. Any authenticated user can read it (same as retention
// days); writes require admin.
//
// @Summary Get chat auto archive days
// @ID get-chat-auto-archive-days
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatAutoArchiveDaysResponse
// @Router /api/v2/chats/config/auto-archive-days [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
autoArchiveDays, err := api.Database.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat auto-archive days.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatAutoArchiveDaysResponse{
AutoArchiveDays: autoArchiveDays,
})
}
// Upper bound for the auto-archive window. Keep in sync with
// the validation schema in site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx.
const autoArchiveDaysMaximum = 3650 // ~10 years
// putChatAutoArchiveDays updates the deployment-wide auto-archive
// window. Admin-only; documented in docs/ai-coder/agents/chats-api.md.
//
// @Summary Update chat auto archive days
// @ID update-chat-auto-archive-days
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatAutoArchiveDaysRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/auto-archive-days [put]
func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
aReq, commitAudit := api.initChatOperationalSettingsAudit(rw, r)
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatAutoArchiveDaysRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.AutoArchiveDays < 0 || req.AutoArchiveDays > autoArchiveDaysMaximum {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Auto-archive days must be between 0 and %d.", autoArchiveDaysMaximum),
})
return
}
value := strconv.FormatInt(int64(req.AutoArchiveDays), 10)
err := api.auditedChatOperationalSettingWrite(
ctx, aReq, commitAudit, chatOperationalSettingChatAutoArchiveDays, value,
func(tx database.Store) error { return tx.UpsertChatAutoArchiveDays(ctx, req.AutoArchiveDays) },
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat auto-archive days.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get user chat custom prompt
// @ID get-user-chat-custom-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.UserChatCustomPrompt
// @Router /api/v2/chats/config/user-prompt [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatCustomPrompt(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
customPrompt, err := api.Database.GetUserChatCustomPrompt(ctx, apiKey.UserID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error reading user chat custom prompt.",
Detail: err.Error(),
})
return
}
customPrompt = ""
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCustomPrompt{
CustomPrompt: customPrompt,
})
}
// @Summary Update user chat custom prompt
// @ID update-user-chat-custom-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UserChatCustomPrompt true "Request body"
// @Produce json
// @Success 200 {object} codersdk.UserChatCustomPrompt
// @Router /api/v2/chats/config/user-prompt [put]
func (api *API) putUserChatCustomPrompt(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
var params codersdk.UserChatCustomPrompt
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &params) {
return
}
sanitizedPrompt := codersdk.SanitizePromptText(params.CustomPrompt)
// The same limit applies as for the deployment system prompt.
if len(sanitizedPrompt) > api.chatLimits.MaxPromptBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Custom prompt exceeds maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", api.chatLimits.MaxPromptBytes, len(sanitizedPrompt)),
})
return
}
updatedConfig, err := api.Database.UpdateUserChatCustomPrompt(ctx, database.UpdateUserChatCustomPromptParams{
UserID: apiKey.UserID,
ChatCustomPrompt: sanitizedPrompt,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error updating user chat custom prompt.",
Detail: err.Error(),
})
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventUserPrompt, apiKey.UserID)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCustomPrompt{
CustomPrompt: updatedConfig.Value,
})
}
// @Summary Get user chat compaction thresholds
// @ID get-user-chat-compaction-thresholds
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.UserChatCompactionThresholds
// @Router /api/v2/chats/config/user-compaction-thresholds [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatCompactionThresholds(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
rows, err := api.Database.ListUserChatCompactionThresholds(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error listing user chat compaction thresholds.",
Detail: err.Error(),
})
return
}
resp := codersdk.UserChatCompactionThresholds{
Thresholds: make([]codersdk.UserChatCompactionThreshold, 0, len(rows)),
}
for _, row := range rows {
modelConfigID, err := parseCompactionThresholdKey(row.Key)
if err != nil {
api.Logger.Warn(ctx, "skipping malformed user chat compaction threshold key",
slog.F("key", row.Key),
slog.F("value", row.Value),
slog.Error(err),
)
continue
}
thresholdPercent, err := strconv.ParseInt(row.Value, 10, 32)
if err != nil {
api.Logger.Warn(ctx, "skipping malformed user chat compaction threshold value",
slog.F("key", row.Key),
slog.F("value", row.Value),
slog.Error(err),
)
continue
}
if thresholdPercent < int64(minChatContextCompressionThreshold) ||
thresholdPercent > int64(maxChatContextCompressionThreshold) {
api.Logger.Warn(ctx, "skipping out-of-range user chat compaction threshold",
slog.F("key", row.Key),
slog.F("value", row.Value),
)
continue
}
resp.Thresholds = append(resp.Thresholds, codersdk.UserChatCompactionThreshold{
ModelConfigID: modelConfigID,
ThresholdPercent: int32(thresholdPercent),
})
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Update user chat compaction threshold
// @ID update-user-chat-compaction-threshold
// @Security CoderSessionToken
// @Tags Chats
// @Param modelConfig path string true "Model config ID" format(uuid)
// @Accept json
// @Param request body codersdk.UpdateUserChatCompactionThresholdRequest true "Request body"
// @Produce json
// @Success 200 {object} codersdk.UserChatCompactionThreshold
// @Router /api/v2/chats/config/user-compaction-thresholds/{modelConfig} [put]
// @x-apidocgen {"skip": true}
func (api *API) putUserChatCompactionThreshold(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
modelConfigID, ok := parseChatModelConfigID(rw, r)
if !ok {
return
}
var req codersdk.UpdateUserChatCompactionThresholdRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.ThresholdPercent < minChatContextCompressionThreshold ||
req.ThresholdPercent > maxChatContextCompressionThreshold {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "threshold_percent is out of range.",
Detail: fmt.Sprintf(
"threshold_percent must be between %d and %d, got %d.",
minChatContextCompressionThreshold,
maxChatContextCompressionThreshold,
req.ThresholdPercent,
),
})
return
}
// The preference is personal, so model existence checks must not depend on
// whether the user can read the organization model configuration.
//nolint:gocritic // Non-admin users need this lookup to save their own setting.
modelConfig, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat model config.",
Detail: err.Error(),
})
return
}
if !modelConfig.Enabled {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Model config is disabled.",
})
return
}
_, err = api.Database.UpdateUserChatCompactionThreshold(ctx, database.UpdateUserChatCompactionThresholdParams{
UserID: apiKey.UserID,
Key: codersdk.CompactionThresholdKey(modelConfigID),
ThresholdPercent: req.ThresholdPercent,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error updating user chat compaction threshold.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCompactionThreshold{
ModelConfigID: modelConfigID,
ThresholdPercent: req.ThresholdPercent,
})
}
// @Summary Delete user chat compaction threshold
// @ID delete-user-chat-compaction-threshold
// @Security CoderSessionToken
// @Tags Chats
// @Param modelConfig path string true "Model config ID" format(uuid)
// @Success 204
// @Router /api/v2/chats/config/user-compaction-thresholds/{modelConfig} [delete]
// @x-apidocgen {"skip": true}
func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
modelConfigID, ok := parseChatModelConfigID(rw, r)
if !ok {
return
}
if err := api.Database.DeleteUserChatCompactionThreshold(ctx, database.DeleteUserChatCompactionThresholdParams{
UserID: apiKey.UserID,
Key: codersdk.CompactionThresholdKey(modelConfigID),
}); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error deleting user chat compaction threshold.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Upload chat file
// @ID upload-chat-file
// @Security CoderSessionToken
// @Tags Chats
// @Accept image/png,image/jpeg,image/gif,image/webp,image/svg+xml,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Produce json
// @Param organization query string true "Organization ID" format(uuid)
// @Param Content-Disposition header string true "Attachment disposition carrying the file name" example(attachment; filename="image.png")
// @Param request body string true "Raw file binary data"
// @x-apidocgen {"rawBodyFile": "image.png"}
// @Success 201 {object} codersdk.UploadChatFileResponse
// @Failure 413 {object} codersdk.Response "Request body exceeds 10 MiB"
// @Router /api/v2/chats/files [post]
func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
orgIDStr := r.URL.Query().Get("organization")
if orgIDStr == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing organization query parameter.",
})
return
}
orgID, err := uuid.Parse(orgIDStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid organization ID.",
})
return
}
// NOTE: This authorize check is intentionally placed after query
// parameter parsing because we need orgID to scope the RBAC check
// to the correct org.
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).InOrg(orgID)) {
httpapi.Forbidden(rw)
return
}
contentType := r.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// Strip parameters (e.g. "image/png; charset=utf-8" → "image/png")
// so the allowlist check matches the base media type.
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil {
contentType = mediaType
}
// application/octet-stream means the client could not classify the file
// ahead of time, so we defer to byte classification below.
if contentType != "application/octet-stream" && !chatfiles.IsAllowedPromptInputMediaType(contentType) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unsupported file type.",
Detail: fmt.Sprintf("Allowed types: %s.", chatfiles.AllowedPromptInputMediaTypesString()),
})
return
}
// Extract filename from Content-Disposition header if provided.
filename := chatFilenameFromContentDisposition(r.Header.Get("Content-Disposition"))
r.Body = http.MaxBytesReader(rw, r.Body, codersdk.MaxChatFileSizeBytes)
data, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
httpapi.RecordRequestBodyLimit(ctx, codersdk.MaxChatFileSizeBytes)
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "File too large.",
Detail: fmt.Sprintf("Maximum file size is %d bytes.", codersdk.MaxChatFileSizeBytes),
})
return
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to read file from request.",
Detail: err.Error(),
})
return
}
// Classify the actual content before applying the upload policy so
// a client cannot spoof Content-Type to serve active content.
filename, detected, err := chatfiles.PrepareStoredFile(filename, filename, data)
if err != nil {
switch {
case errors.Is(err, chatfiles.ErrStoredFileNameRequired):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Filename is required.",
Detail: "Provide a filename in the Content-Disposition header.",
})
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file.",
Detail: err.Error(),
})
}
return
}
if !chatfiles.IsAllowedPromptInputMediaType(detected) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unsupported file type.",
Detail: fmt.Sprintf("Allowed types: %s.", chatfiles.AllowedPromptInputMediaTypesString()),
})
return
}
// The compatibility check below is security-critical: it keeps exact
// media-type matching by default while allowing application/
// octet-stream uploads to defer to byte classification, and letting
// text/plain refine to safe text subtypes such as JSON, CSV, and
// Markdown. Combined with the X-Content-Type-Options: nosniff header
// applied globally, this still prevents clients from smuggling binary
// or active content under a safer declared Content-Type.
if !chatfiles.IsCompatibleUploadMediaType(contentType, detected) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "File content type does not match Content-Type header.",
Detail: fmt.Sprintf("Header declared %q but file content was detected as %q.", contentType, detected),
})
return
}
chatFile, err := api.Database.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: apiKey.UserID,
OrganizationID: orgID,
Name: filename,
Mimetype: detected,
Data: data,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to save chat file.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UploadChatFileResponse{
ID: chatFile.ID,
})
}
// ChatFileDownloadClaims are the signed claims embedded in a chat file
// download URL token.
type ChatFileDownloadClaims struct {
jwtutils.RegisteredClaims
FileID uuid.UUID `json:"file_id"`
UserID uuid.UUID `json:"user_id"`
}
func (c ChatFileDownloadClaims) Validate(expected jwt.Expected) error {
if c.FileID == uuid.Nil {
return xerrors.New("file ID is required")
}
if c.UserID == uuid.Nil {
return xerrors.New("user ID is required")
}
return c.RegisteredClaims.Validate(expected)
}
// @Summary Create chat file download URL
// @ID create-chat-file-download-url
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param file path string true "File ID" format(uuid)
// @Success 200 {object} codersdk.ChatFileDownloadURLResponse
// @Router /api/v2/chats/files/{file}/download-url [post]
// @x-apidocgen {"skip": true}
func (api *API) postChatFileDownloadURL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file ID.",
})
return
}
chatFile, err := api.Database.GetChatFileByID(ctx, fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
Detail: err.Error(),
})
return
}
now := time.Now()
// Truncate to whole seconds so the advertised expiry matches the JWT
// exp claim, which jwt.NewNumericDate stores at second precision.
expiresAt := now.Add(cryptokeys.ChatFilesTokenDuration).Truncate(time.Second)
claims := ChatFileDownloadClaims{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(now),
},
FileID: fileID,
UserID: httpmw.APIKey(r).UserID,
}
token, err := jwtutils.Sign(ctx, api.ChatFileTokenKeyCache, claims)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat file download URL.",
Detail: err.Error(),
})
return
}
downloadURL := api.AccessURL.JoinPath("api", "v2", "chats", "files", fileID.String(), "download")
downloadURL.RawQuery = url.Values{"token": {token}}.Encode()
digest := sha256.Sum256(chatFile.Data)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatFileDownloadURLResponse{
URL: downloadURL.String(),
ExpiresAt: expiresAt,
SHA256: hex.EncodeToString(digest[:]),
SizeBytes: int64(len(chatFile.Data)),
Name: chatFile.Name,
MimeType: chatFile.Mimetype,
})
}
// @Summary Download chat file with signed token
// @ID download-chat-file-with-signed-token
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,image/svg+xml,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Param file path string true "File ID" format(uuid)
// @Param token query string true "Signed download token"
// @Success 200
// @Router /api/v2/chats/files/{file}/download [get]
// @x-apidocgen {"skip": true}
func (api *API) downloadChatFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
if err != nil {
httpapi.ResourceNotFound(rw)
return
}
var claims ChatFileDownloadClaims
if err := jwtutils.Verify(ctx, api.ChatFileTokenKeyCache, r.URL.Query().Get("token"), &claims); err != nil || claims.FileID != fileID {
httpapi.ResourceNotFound(rw)
return
}
subject, status, err := httpmw.UserRBACSubject(ctx, api.Database, claims.UserID, rbac.ScopeAll)
if err != nil || status != database.UserStatusActive {
httpapi.ResourceNotFound(rw)
return
}
chatFile, err := api.Database.GetChatFileByID(dbauthz.As(ctx, subject), fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
// This endpoint is reachable without a session token, so internal
// error details stay in the logs rather than the response body.
api.Logger.Error(ctx, "failed to get chat file for signed download", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
})
return
}
// Never let private caches replay a signed URL past its expiry;
// revocation is re-checked only when the request reaches coderd.
rw.Header().Set("Cache-Control", "no-store")
api.serveChatFile(ctx, rw, chatFile)
}
// @Summary Get chat file
// @ID get-chat-file
// @Security CoderSessionToken
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,image/svg+xml,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Param file path string true "File ID" format(uuid)
// @Success 200
// @Router /api/v2/chats/files/{file} [get]
func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileIDStr := chi.URLParam(r, "file")
fileID, err := uuid.Parse(fileIDStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file ID.",
})
return
}
chatFile, err := api.Database.GetChatFileByID(ctx, fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
Detail: err.Error(),
})
return
}
rw.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
api.serveChatFile(r.Context(), rw, chatFile)
}
// serveChatFile writes the file body and content headers; callers set
// Cache-Control because signed and session-authenticated downloads have
// different caching requirements.
func (api *API) serveChatFile(ctx context.Context, rw http.ResponseWriter, chatFile database.ChatFile) {
rw.Header().Set("Content-Type", chatFile.Mimetype)
disposition := "attachment"
if chatfiles.IsInlineRenderableStoredMediaType(chatFile.Mimetype) {
disposition = "inline"
}
if chatFile.Name != "" {
rw.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": chatFile.Name}))
} else {
rw.Header().Set("Content-Disposition", disposition)
}
rw.Header().Set("Content-Length", strconv.Itoa(len(chatFile.Data)))
rw.WriteHeader(http.StatusOK)
if _, err := rw.Write(chatFile.Data); err != nil {
api.Logger.Debug(ctx, "failed to write chat file response", slog.Error(err))
}
}
const (
chatWorkspaceUploadNoWorkspaceMessage = "Chat has no workspace to upload to."
chatWorkspaceUploadWorkspaceNotFoundMessage = "Chat workspace not found."
chatWorkspaceUploadWorkspaceDeletedMessage = "Chat workspace was deleted."
chatWorkspaceUploadNoAgentsMessage = "Chat workspace has no agents."
chatWorkspaceUploadArchivedMessage = "Cannot upload files to an archived chat."
chatWorkspaceUploadOwnerOnlyMessage = "Only the chat owner may upload files to a chat's workspace."
chatWorkspaceUploadMissingFilenameMessage = "Filename is required."
chatWorkspaceUploadNoChatAgentMessage = "No chat-compatible workspace agent found."
chatWorkspaceUploadAgentDialTimeout = 30 * time.Second
// Transport errors embed the agent's tailnet URL, so they are logged
// rather than returned to the client.
chatWorkspaceUploadAgentUnreachableDetail = "The workspace agent could not be reached. Check that the workspace agent is connected and retry."
)
func (api *API) chatWorkspaceUploadMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: chatWorkspaceUploadOwnerOnlyMessage,
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: chatWorkspaceUploadArchivedMessage,
})
return
}
next.ServeHTTP(rw, r)
})
}
// @Summary Upload a file to a chat's workspace
// @ID upload-a-file-to-a-chats-workspace
// @Security CoderSessionToken
// @Accept */*
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param Content-Disposition header string true "Filename of the file (attachment; filename=...)"
// @Param request body string true "Raw file binary data"
// @x-apidocgen {"rawBodyFile": "archive.zip"}
// @Success 201 {object} codersdk.UploadChatWorkspaceFileResponse
// @Failure 400 {object} codersdk.Response
// @Failure 403 {object} codersdk.Response
// @Failure 409 {object} codersdk.Response
// @Failure 429 {object} codersdk.Response
// @Failure 500 {object} codersdk.Response
// @Failure 502 {object} codersdk.Response
// @Router /api/v2/chats/{chat}/workspace-files [post]
// @Description Streams the request body into the chat workspace's
// @Description upload directory. The request Content-Type header is
// @Description recorded as the file's media type. There is no
// @Description server-imposed size cap; client cancellation aborts the
// @Description stream and the agent leaves no partial target file behind.
func (api *API) postChatWorkspaceFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
start := api.Clock.Now()
sw := &tracing.StatusWriter{ResponseWriter: rw}
rw = sw
var (
workspaceID uuid.UUID
agentID uuid.UUID
name string
body *workspaceUsageReader
failure error
)
defer func() {
api.chatWorkspaceUploadsTotal.WithLabelValues(strconv.Itoa(sw.Status)).Inc()
if sw.Status < http.StatusBadRequest {
return
}
var bytesRead int64
if body != nil {
bytesRead = body.bytesRead.Load()
}
fields := []slog.Field{
slog.F("chat_id", chat.ID),
slog.F("workspace_id", workspaceID),
slog.F("agent_id", agentID),
slog.F("owner_id", chat.OwnerID),
slog.F("file_name", name),
slog.F("bytes_read", bytesRead),
slog.F("duration", api.Clock.Since(start)),
slog.F("status", sw.Status),
}
if failure != nil {
fields = append(fields, slog.Error(failure))
}
api.Logger.Warn(ctx, "chat workspace file upload failed", fields...)
}()
workspace, ok := api.authorizeChatWorkspaceExecWithStatus(
rw,
r,
chat,
http.StatusConflict,
chatWorkspaceUploadNoWorkspaceMessage,
http.StatusConflict,
chatWorkspaceUploadWorkspaceNotFoundMessage,
)
if !ok {
return
}
workspaceID = workspace.ID
// Writing files into the workspace filesystem is an SSH-grade
// capability. The exec helper also admits app-connect-only
// callers (it serves the read-oriented stream endpoints), so
// require SSH explicitly, matching validateChatWorkspaceSelection.
if !api.Authorize(r, policy.ActionSSH, workspace) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: chatWorkspaceUploadWorkspaceNotFoundMessage,
})
return
}
filename := chatFilenameFromContentDisposition(r.Header.Get("Content-Disposition"))
sanitizedName, err := chatfiles.SanitizeWorkspaceUploadName(filename)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: chatWorkspaceUploadMissingFilenameMessage,
Detail: "Provide a filename via the Content-Disposition header.",
})
return
}
name = sanitizedName
contentType := chatfiles.BaseMediaType(r.Header.Get("Content-Type"))
if contentType == "" {
contentType = "application/octet-stream"
}
if workspace.Deleted {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: chatWorkspaceUploadWorkspaceDeletedMessage,
})
return
}
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, latestBuild.JobID)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build job.",
Detail: err.Error(),
})
return
}
workspaceStatus := codersdk.ConvertWorkspaceStatus(
codersdk.ProvisionerJobStatus(job.JobStatus),
codersdk.WorkspaceTransition(latestBuild.Transition),
)
if workspaceStatus != codersdk.WorkspaceStatusRunning {
action := "Start the workspace"
if latestBuild.Transition == database.WorkspaceTransitionStart &&
(workspaceStatus == codersdk.WorkspaceStatusPending || workspaceStatus == codersdk.WorkspaceStatusStarting) {
action = "Wait for the workspace to start"
}
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("Workspace is %s. %s before uploading files.", workspaceStatus, action),
})
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
if len(agents) == 0 {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: chatWorkspaceUploadNoAgentsMessage,
})
return
}
selectedAgent, err := chatWorkspaceUploadAgent(chat, agents)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: chatWorkspaceUploadNoChatAgentMessage,
Detail: err.Error(),
})
return
}
agentID = selectedAgent.ID
agentStatus := selectedAgent.Status(dbtime.Now(), api.AgentInactiveDisconnectTimeout)
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf(
"Agent status is %q. Start the workspace agent before uploading files.",
agentStatus.Status,
),
})
return
}
defer r.Body.Close()
dialCtx, dialCancel := context.WithTimeout(ctx, chatWorkspaceUploadAgentDialTimeout)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, selectedAgent.ID)
if err != nil {
failure = err
// A dial failure is a coderd-to-agent transport problem, the
// same class writeWorkspaceAgentUploadError maps to 502.
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{
Message: "Failed to dial workspace agent.",
Detail: chatWorkspaceUploadAgentUnreachableDetail,
})
return
}
defer release()
// Mirror chatd's connection headers so agent-side chat state (e.g.
// the git-watch PathStore) attributes this request to the chat.
var ancestorIDs []string
if chat.ParentChatID.Valid {
ancestorIDs = append(ancestorIDs, chat.ParentChatID.UUID.String())
}
ancestorJSON, err := json.Marshal(ancestorIDs)
if err != nil {
ancestorJSON = []byte("[]")
}
agentConn.SetExtraHeaders(http.Header{
workspacesdk.CoderChatIDHeader: {chat.ID.String()},
workspacesdk.CoderAncestorChatIDsHeader: {string(ancestorJSON)},
})
// Uploads are neither SSH nor app sessions, so nothing else records
// them as workspace usage; without this an uncapped upload started
// near the autostop deadline could see its workspace stop mid-stream.
// The bump runs as chatd like the heartbeat bump: the upload itself
// is already authorized, but the requester may hold only SSH rights
// on a shared workspace while the bump needs workspace update.
//nolint:gocritic // Activity bump for an upload the requester is already authorized to make.
bumpCtx := dbauthz.AsChatd(ctx)
body = &workspaceUsageReader{
r: r.Body,
clock: api.Clock,
report: func() {
api.WorkspaceUsageTracker.Add(workspace.ID)
workspacestats.ActivityBumpWorkspace(bumpCtx, api.Logger.Named("activity_bump"), api.Database, workspace.ID, time.Time{}, workspacestats.ActivityBumpReasonChatWorkspaceUpload)
},
}
body.reportNow()
resp, err := agentConn.UploadChatFile(ctx, workspacesdk.UploadChatFileRequest{
ChatID: chat.ID.String(),
Name: name,
Body: body,
})
if err != nil {
failure = err
writeWorkspaceAgentUploadError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UploadChatWorkspaceFileResponse{
Path: resp.Path,
Name: resp.Name,
Size: resp.Size,
MediaType: contentType,
WorkspaceID: workspace.ID,
})
}
// chatWorkspaceUploadAgent prefers the chat's bound agent when it is
// still part of the latest build so uploads land on the same agent the
// generation path uses. Otherwise it falls back to the deterministic
// chat agent selection without persisting a binding; binding remains a
// generation-path concern, and determinism makes both converge.
func chatWorkspaceUploadAgent(chat database.Chat, agents []database.WorkspaceAgent) (database.WorkspaceAgent, error) {
if chat.AgentID.Valid {
for _, agent := range agents {
if agent.ID == chat.AgentID.UUID {
return agent, nil
}
}
}
return agentselect.FindChatAgent(agents)
}
// chatWorkspaceUploadUsageInterval bounds how often a streaming upload
// records workspace usage; chatd heartbeats at the same cadence.
const chatWorkspaceUploadUsageInterval = 30 * time.Second
// workspaceUsageReader reports workspace usage while an upload body
// streams so long uploads keep extending the autostop deadline.
type workspaceUsageReader struct {
r io.Reader
clock quartz.Clock
report func()
lastReport time.Time
// bytesRead is atomic because the HTTP transport may still be
// reading the body when the handler logs a failed upload.
bytesRead atomic.Int64
}
func (w *workspaceUsageReader) reportNow() {
w.lastReport = w.clock.Now()
w.report()
}
func (w *workspaceUsageReader) Read(p []byte) (int, error) {
n, err := w.r.Read(p)
w.bytesRead.Add(int64(n))
if n > 0 && w.clock.Since(w.lastReport) >= chatWorkspaceUploadUsageInterval {
w.reportNow()
}
return n, err
}
func writeWorkspaceAgentUploadError(ctx context.Context, rw http.ResponseWriter, err error) {
var sdkErr *codersdk.Error
if errors.As(err, &sdkErr) && sdkErr.StatusCode() != 0 {
// The upload handler never answers 404 itself, so a 404 means
// the workspace runs an agent from before this endpoint existed
// (coderd upgraded while the workspace kept running).
if sdkErr.StatusCode() == http.StatusNotFound {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "The workspace agent does not support chat file uploads. Restart the workspace to update its agent, then retry.",
})
return
}
httpapi.Write(ctx, rw, sdkErr.StatusCode(), sdkErr.Response)
return
}
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{
Message: "Failed to upload file to workspace agent.",
Detail: chatWorkspaceUploadAgentUnreachableDetail,
})
}
func chatFilenameFromContentDisposition(contentDisposition string) string {
if contentDisposition == "" {
return ""
}
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return ""
}
return params["filename"]
}
// maxWorkspaceFileReferencePathBytes bounds persisted workspace file
// reference paths.
const maxWorkspaceFileReferencePathBytes = 4096
// normalizeWorkspaceFileReference reports whether a client-supplied
// workspace file reference plausibly points at a file uploaded for
// this chat and returns the cleaned path to persist. The agent home
// directory is not known to coderd, so validation is limited to an
// absolute path tail scoped by chat ID and a matching basename.
func normalizeWorkspaceFileReference(chatID uuid.UUID, filePath, name string) (string, bool) {
if chatID == uuid.Nil || len(filePath) > maxWorkspaceFileReferencePathBytes {
return "", false
}
// Control characters (including NUL, which cannot be persisted)
// never appear in agent-produced paths or names.
if strings.ContainsFunc(filePath, unicode.IsControl) || strings.ContainsFunc(name, unicode.IsControl) {
return "", false
}
normalizedPath := path.Clean(strings.ReplaceAll(strings.TrimSpace(filePath), `\`, "/"))
normalizedName := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")
if normalizedPath == "." || normalizedName == "." || normalizedName == "" {
return "", false
}
if path.Base(normalizedName) != normalizedName || path.Base(normalizedPath) != normalizedName {
return "", false
}
windowsPath := isWindowsAbsPath(normalizedPath)
if !path.IsAbs(normalizedPath) && !windowsPath {
return "", false
}
marker := "/" + strings.Trim(chatfiles.WorkspaceChatsDir, "/") + "/" +
chatID.String() + "/" + chatfiles.WorkspaceUploadFilesSubdir + "/"
if !strings.HasSuffix(normalizedPath, marker+normalizedName) {
return "", false
}
if windowsPath {
return strings.ReplaceAll(normalizedPath, "/", `\`), true
}
return normalizedPath, true
}
func isWindowsAbsPath(p string) bool {
return len(p) >= 3 && p[1] == ':' && p[2] == '/' &&
(('a' <= p[0] && p[0] <= 'z') || ('A' <= p[0] && p[0] <= 'Z'))
}
func createChatInputFromRequest(ctx context.Context, db database.Store, req codersdk.CreateChatRequest) (
[]codersdk.ChatMessagePart,
string,
*codersdk.Response,
) {
// Empty content creates an idle chat with no initial user
// message. Generation starts with the first message POSTed to
// /chats/{chat}/messages. This lets clients sequence work that
// needs the chat ID before the first turn, such as workspace
// file uploads.
if len(req.Content) == 0 {
return nil, "", nil
}
// Chat creation has no chat ID yet, so workspace-file-reference
// parts are rejected (they require an existing chat's uploads).
content, pasteData, inputError := createChatInputFromParts(ctx, db, uuid.Nil, uuid.NullUUID{}, req.Content, "content")
if inputError != nil {
return nil, "", inputError
}
// Derive titleSource through the same chatprompt.TitleText used at
// generation time; auto-titling gates on that equality. Paste blobs
// are copied only when text and file-reference parts yield nothing.
titleSource := chatprompt.TitleText(content, nil)
if titleSource == "" && len(pasteData) > 0 {
pasteText := make(map[uuid.UUID]string, len(pasteData))
for id, data := range pasteData {
pasteText[id] = chatprompt.TitlePasteText(data)
}
titleSource = chatprompt.TitleText(content, pasteText)
}
return content, titleSource, nil
}
// createChatInputFromParts validates input parts and converts them to
// message content. The returned map holds pasted-text blob references
// by file ID; the create path derives a title from it, message send
// and edit discard it without copying blob data.
func createChatInputFromParts(
ctx context.Context,
db database.Store,
chatID uuid.UUID,
chatWorkspaceID uuid.NullUUID,
parts []codersdk.ChatInputPart,
fieldName string,
) ([]codersdk.ChatMessagePart, map[uuid.UUID][]byte, *codersdk.Response) {
if len(parts) == 0 {
return nil, nil, &codersdk.Response{
Message: "Content is required.",
Detail: "Content cannot be empty.",
}
}
content := make([]codersdk.ChatMessagePart, 0, len(parts))
var pasteData map[uuid.UUID][]byte
for i, part := range parts {
switch strings.ToLower(strings.TrimSpace(string(part.Type))) {
case string(codersdk.ChatInputPartTypeText):
text := strings.TrimSpace(part.Text)
if text == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageText(text))
case string(codersdk.ChatInputPartTypeFile):
if part.FileID == uuid.Nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id is required for file parts.", fieldName, i),
}
}
// Validate that the file exists and get its media type.
// The loaded file data is only retained for synthetic
// pastes below; LLM dispatch re-resolves file content via
// chatFileResolver.
chatFile, err := db.GetChatFileByID(ctx, part.FileID)
if err != nil {
if httpapi.Is404Error(err) {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file that does not exist.", fieldName, i),
}
}
return nil, nil, &codersdk.Response{
Message: "Internal error.",
Detail: fmt.Sprintf("Failed to retrieve file for %s[%d].", fieldName, i),
}
}
if !chatfiles.IsAllowedPromptInputMediaType(chatFile.Mimetype) {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file type that cannot be used as prompt input. Allowed types: %s.", fieldName, i, chatfiles.AllowedPromptInputMediaTypesString()),
}
}
content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name))
// Retain blob references for create-time title derivation;
// send and edit paths discard the map.
if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) {
if pasteData == nil {
pasteData = make(map[uuid.UUID][]byte)
}
pasteData[part.FileID] = chatFile.Data
}
// file-reference parts carry inline code snippets, not uploaded
// files. They have no FileID and are excluded from file tracking.
case string(codersdk.ChatInputPartTypeFileReference):
if part.FileName == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageFileReference(part.FileName, part.StartLine, part.EndLine, part.Content))
case string(codersdk.ChatInputPartTypeWorkspaceFileReference):
if strings.TrimSpace(part.WorkspaceFilePath) == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_path is required for workspace-file-reference.", fieldName, i),
}
}
if strings.TrimSpace(part.WorkspaceFileName) == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_name is required for workspace-file-reference.", fieldName, i),
}
}
if part.WorkspaceFileSize < 0 {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_size must be non-negative.", fieldName, i),
}
}
if chatID == uuid.Nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace-file-reference requires an existing chat.", fieldName, i),
}
}
// The sanitizer is idempotent, so every name returned by the
// upload endpoint passes unchanged.
if sanitized, err := chatfiles.SanitizeWorkspaceUploadName(part.WorkspaceFileName); err != nil || sanitized != part.WorkspaceFileName {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_name must be a name returned by the workspace file upload endpoint.", fieldName, i),
}
}
workspaceFilePath, ok := normalizeWorkspaceFileReference(chatID, part.WorkspaceFilePath, part.WorkspaceFileName)
if !ok {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_path must reference a file uploaded to this chat.", fieldName, i),
}
}
if strings.ContainsRune(part.WorkspaceFileMediaType, 0) {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_media_type must not contain NUL bytes.", fieldName, i),
}
}
// The referenced bytes live in one specific workspace's
// filesystem. Reject references from a workspace other
// than the chat's current binding: after a rebind the
// path is unreadable for the bound agent.
if part.WorkspaceFileWorkspaceID == uuid.Nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_workspace_id is required for workspace-file-reference.", fieldName, i),
}
}
if !chatWorkspaceID.Valid || chatWorkspaceID.UUID != part.WorkspaceFileWorkspaceID {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_workspace_id must match the chat's bound workspace. Re-upload the file to the current workspace.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageWorkspaceFileReference(
part.WorkspaceFileWorkspaceID,
workspaceFilePath,
part.WorkspaceFileName,
part.WorkspaceFileSize,
chatfiles.BaseMediaType(part.WorkspaceFileMediaType),
))
default:
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf(
"%s[%d].type %q is not supported.",
fieldName,
i,
part.Type,
),
}
}
}
if len(content) == 0 {
return nil, nil, &codersdk.Response{
Message: "Content is required.",
Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName),
}
}
return content, pasteData, nil
}
// maxPromptRequestBodyBytes allows twice the prompt limit for JSON escaping
// and characters that sanitization removes, but never less than
// maxChatRequestBodyBytes, so small limits still return the 400 length error.
func (api *API) maxPromptRequestBodyBytes() int64 {
return max(maxChatRequestBodyBytes, 2*int64(api.chatLimits.MaxPromptBytes))
}
func (api *API) writeChatFileError(ctx context.Context, rw http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, chatstate.ErrChatFileCapExceeded):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat attachment limit reached.",
Detail: fmt.Sprintf("A message can include at most %d attachments. Remove some attachments and retry.", api.chatLimits.MaxAttachmentsPerChat),
})
case errors.Is(err, chatstate.ErrChatFileUnavailable):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat attachment unavailable.",
Detail: "An attachment is no longer available. Upload it again and retry.",
})
default:
return false
}
return true
}
// fetchChatFileMetadata returns metadata for all files linked to
// the given chat. Errors are logged and result in a nil return
// (callers treat file metadata as best-effort).
func (api *API) fetchChatFileMetadata(ctx context.Context, chatID uuid.UUID) []database.GetChatFileMetadataByChatIDRow {
rows, err := api.Database.GetChatFileMetadataByChatID(ctx, chatID)
if err != nil {
api.Logger.Error(ctx, "failed to fetch chat file metadata",
slog.F("chat_id", chatID),
slog.Error(err),
)
return nil
}
return rows
}
func convertChatQueuedMessage(m database.ChatQueuedMessage) codersdk.ChatQueuedMessage {
return db2sdk.ChatQueuedMessage(m)
}
func convertChatQueuedMessagePtr(m database.ChatQueuedMessage) *codersdk.ChatQueuedMessage {
qm := convertChatQueuedMessage(m)
return &qm
}
func convertChatQueuedMessages(msgs []database.ChatQueuedMessage) []codersdk.ChatQueuedMessage {
result := make([]codersdk.ChatQueuedMessage, 0, len(msgs))
for _, m := range msgs {
result = append(result, convertChatQueuedMessage(m))
}
return result
}
func convertChatMessage(m database.ChatMessage) codersdk.ChatMessage {
return db2sdk.ChatMessage(m)
}
func convertChatMessages(messages []database.ChatMessage) []codersdk.ChatMessage {
result := make([]codersdk.ChatMessage, 0, len(messages))
for _, m := range messages {
result = append(result, convertChatMessage(m))
}
return result
}
func parseUserAIProviderID(r *http.Request) (uuid.UUID, error) {
return uuid.Parse(chi.URLParam(r, "aiProvider"))
}
func convertAIProviderSummary(provider database.AIProvider) codersdk.AIProviderSummary {
displayName := provider.Name
if provider.DisplayName.Valid && provider.DisplayName.String != "" {
displayName = provider.DisplayName.String
}
return codersdk.AIProviderSummary{
ID: provider.ID,
Type: codersdk.AIProviderType(provider.Type),
Name: provider.Name,
DisplayName: displayName,
Icon: provider.Icon,
Enabled: provider.Enabled,
Deleted: provider.Deleted,
}
}
// @Summary List user AI provider key configurations
// @ID list-user-ai-provider-key-configurations
// @Security CoderSessionToken
// @Tags Chats
// @Param user path string true "User ID, username, or me"
// @Produce json
// @Success 200 {array} codersdk.UserAIProviderKeyConfig
// @Router /api/v2/users/{user}/ai-provider-keys [get]
func (api *API) listUserAIProviderKeyConfigs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetUser := httpmw.UserParam(r)
//nolint:gocritic // Users can list limited provider metadata to manage their own AI provider keys.
metadataCtx := dbauthz.AsAIProviderMetadataReader(ctx)
providers, err := api.Database.GetAIProviders(metadataCtx, database.GetAIProvidersParams{IncludeDisabled: true})
if err != nil {
api.Logger.Error(ctx, "failed to list user AI provider configs", slog.Error(err), slog.F("user_id", targetUser.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list AI providers."})
return
}
keys, err := api.Database.GetUserAIProviderKeysByUserID(ctx, targetUser.ID)
if err != nil {
api.Logger.Error(ctx, "failed to list user AI provider keys", slog.Error(err), slog.F("user_id", targetUser.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list user AI provider keys."})
return
}
keysByProviderID := make(map[uuid.UUID]struct{}, len(keys))
for _, key := range keys {
keysByProviderID[key.AIProviderID] = struct{}{}
}
visibleProviders := make([]database.AIProvider, 0, len(providers))
visibleProviderIDs := make([]uuid.UUID, 0, len(providers))
for _, provider := range providers {
_, hasUserKey := keysByProviderID[provider.ID]
if !provider.Enabled && !hasUserKey {
continue
}
visibleProviders = append(visibleProviders, provider)
visibleProviderIDs = append(visibleProviderIDs, provider.ID)
}
providerKeysByProviderID := make(map[uuid.UUID]struct{}, len(visibleProviderIDs))
if len(visibleProviderIDs) > 0 {
providerKeyIDs, err := api.Database.GetAIProviderKeyPresence(metadataCtx, visibleProviderIDs)
if err != nil {
api.Logger.Error(ctx, "failed to list AI provider key presence", slog.Error(err), slog.F("user_id", targetUser.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list AI provider keys."})
return
}
for _, providerID := range providerKeyIDs {
providerKeysByProviderID[providerID] = struct{}{}
}
}
byokEnabled := api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value()
configs := make([]codersdk.UserAIProviderKeyConfig, 0, len(visibleProviders))
for _, provider := range visibleProviders {
_, hasUserKey := keysByProviderID[provider.ID]
_, hasProviderKey := providerKeysByProviderID[provider.ID]
configs = append(configs, codersdk.UserAIProviderKeyConfig{
Provider: convertAIProviderSummary(provider),
HasUserAPIKey: hasUserKey,
HasProviderAPIKey: hasProviderKey,
BYOKEnabled: byokEnabled,
})
}
httpapi.Write(ctx, rw, http.StatusOK, configs)
}
// @Summary Update user AI provider key
// @ID update-user-ai-provider-key
// @Security CoderSessionToken
// @Tags Chats
// @Param user path string true "User ID, username, or me"
// @Param aiProvider path string true "AI provider ID" format(uuid)
// @Accept json
// @Param request body codersdk.CreateUserAIProviderKeyRequest true "Request body"
// @Produce json
// @Success 200 {object} codersdk.UserAIProviderKeyConfig
// @Router /api/v2/users/{user}/ai-provider-keys/{aiProvider} [put]
func (api *API) upsertUserAIProviderKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: "BYOK is disabled."})
return
}
targetUser := httpmw.UserParam(r)
providerID, err := parseUserAIProviderID(r)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid AI provider ID."})
return
}
//nolint:gocritic // Users can attach their own key to an enabled provider without AI provider admin permissions.
metadataCtx := dbauthz.AsAIProviderMetadataReader(ctx)
provider, err := api.Database.GetAIProviderByID(metadataCtx, providerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{Message: "AI provider not found."})
return
}
api.Logger.Error(ctx, "failed to get AI provider", slog.Error(err), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to get AI provider."})
return
}
if !provider.Enabled {
writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled)
return
}
var req codersdk.CreateUserAIProviderKeyRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := validateChatProviderAPIKeySize(req.APIKey); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "API key too large.",
Detail: err.Error(),
})
return
}
if req.APIKey == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "API key is required."})
return
}
if strings.TrimSpace(req.APIKey) != req.APIKey {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "API key must not contain leading or trailing whitespace."})
return
}
providerKeys, err := api.Database.GetAIProviderKeyPresence(metadataCtx, []uuid.UUID{providerID})
if err != nil {
api.Logger.Error(ctx, "failed to list AI provider key presence", slog.Error(err), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list AI provider keys."})
return
}
now := api.Clock.Now()
_, err = api.Database.UpsertUserAIProviderKey(ctx, database.UpsertUserAIProviderKeyParams{
ID: uuid.New(),
UserID: targetUser.ID,
AIProviderID: providerID,
APIKey: req.APIKey,
ApiKeyKeyID: sql.NullString{},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
api.Logger.Error(ctx, "failed to update user AI provider key", slog.Error(err), slog.F("user_id", targetUser.ID), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to update user AI provider key."})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserAIProviderKeyConfig{
Provider: convertAIProviderSummary(provider),
HasUserAPIKey: true,
HasProviderAPIKey: len(providerKeys) > 0,
BYOKEnabled: true,
})
}
// @Summary Delete user AI provider key
// @ID delete-user-ai-provider-key
// @Security CoderSessionToken
// @Tags Chats
// @Param user path string true "User ID, username, or me"
// @Param aiProvider path string true "AI provider ID" format(uuid)
// @Success 204
// @Router /api/v2/users/{user}/ai-provider-keys/{aiProvider} [delete]
func (api *API) deleteUserAIProviderKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetUser := httpmw.UserParam(r)
providerID, err := parseUserAIProviderID(r)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid AI provider ID."})
return
}
if err := api.Database.DeleteUserAIProviderKey(ctx, database.DeleteUserAIProviderKeyParams{UserID: targetUser.ID, AIProviderID: providerID}); err != nil {
api.Logger.Error(ctx, "failed to delete user AI provider key", slog.Error(err), slog.F("user_id", targetUser.ID), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to delete user AI provider key."})
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
// userAIProviderKeyStatusByProviderID returns only credential presence for the
// requesting user. The privileged lookup is contained here so callers never
// receive key material or require user:read_personal.
func (api *API) userAIProviderKeyStatusByProviderID(ctx context.Context, userID uuid.UUID) (map[uuid.UUID]bool, error) {
//nolint:gocritic // The result is reduced to credential presence for exactly the requesting user.
rows, err := api.Database.GetUserAIProviderKeysByUserID(dbauthz.AsChatd(ctx), userID)
if err != nil {
return nil, err
}
statusByProviderID := make(map[uuid.UUID]bool, len(rows))
for _, row := range rows {
statusByProviderID[row.AIProviderID] = strings.TrimSpace(row.APIKey) != ""
}
return statusByProviderID, nil
}
func (api *API) configuredProvidersFromAIProviders(ctx context.Context, providers []database.AIProvider) ([]chatprovider.ConfiguredProvider, error) {
if len(providers) == 0 {
return nil, nil
}
providerIDs := make([]uuid.UUID, 0, len(providers))
for _, provider := range providers {
providerIDs = append(providerIDs, provider.ID)
}
keys, err := api.Database.GetAIProviderKeysByProviderIDs(ctx, providerIDs)
if err != nil {
return nil, xerrors.Errorf("get AI provider keys: %w", err)
}
keysByProviderID := make(map[uuid.UUID][]database.AIProviderKey, len(providers))
for _, key := range keys {
keysByProviderID[key.ProviderID] = append(keysByProviderID[key.ProviderID], key)
}
configuredProviders := make([]chatprovider.ConfiguredProvider, 0, len(providers))
for _, provider := range providers {
configuredProviders = append(configuredProviders, api.configuredProviderFromAIProviderKeys(provider, keysByProviderID[provider.ID]))
}
return configuredProviders, nil
}
func (api *API) configuredProviderFromAIProviderKeys(provider database.AIProvider, keys []database.AIProviderKey) chatprovider.ConfiguredProvider {
apiKey := ""
for _, key := range keys {
if key.APIKey != "" {
apiKey = key.APIKey
break
}
}
supportsAmbientCredentials := false
// A corrupt settings blob only costs the ambient-credential capability hint;
// the gateway remains responsible for actual authentication.
if settings, err := db2sdk.AIProviderSettings(provider.Settings); err == nil {
supportsAmbientCredentials = aiProviderSupportsAmbientCredentials(settings)
}
return chatprovider.ConfiguredProvider{
ProviderID: provider.ID,
Provider: string(provider.Type),
APIKey: apiKey,
BaseURL: provider.BaseUrl,
CentralAPIKeyEnabled: true,
AllowUserAPIKey: api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value(),
AllowCentralAPIKeyFallback: true,
SupportsAmbientCredentials: supportsAmbientCredentials,
}
}
// @Summary List AI models and provider descriptors in an organization
// @ID list-ai-models-and-provider-descriptors-in-an-organization
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Success 200 {object} codersdk.OrganizationChatModelsResponse
// @Router /api/v2/organizations/{organization}/chats/models [get]
func (api *API) listChatModelConfigsByOrganization(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
apiKey := httpmw.APIKey(r)
// Keep the token scope gate separate from the authorized database filter.
// The filter applies each config ACL and the organization predicate.
if !chatModelConfigReadScope(apiKey.Scopes) {
httpapi.Forbidden(rw)
return
}
configs, visible, err := api.readableChatModelsInOrganization(ctx, r, organization)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if !visible {
httpapi.ResourceNotFound(rw)
return
}
availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to load chat model availability.",
Detail: err.Error(),
})
return
}
providers, err := api.chatModelProviderDescriptors(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list AI providers.",
Detail: err.Error(),
})
return
}
for i := range providers {
if status, ok := availability.providerStatusByID[providers[i].ID]; ok {
providers[i].Available = status.Available
if !status.Available {
providers[i].UnavailableReason = status.UnavailableReason
}
}
}
resp := codersdk.OrganizationChatModelsResponse{
Models: make([]codersdk.ChatModel, 0, len(configs)),
Providers: providers,
UnsupportedProviders: chatprovider.UnsupportedProviders(availability.configuredProviders),
}
for _, config := range configs {
resp.Models = append(resp.Models, convertChatModelConfig(config))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func (api *API) readableChatModelsInOrganization(
ctx context.Context,
r *http.Request,
organization database.Organization,
) ([]database.ChatModelConfig, bool, error) {
configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID)
if err != nil {
return nil, false, err
}
if len(configs) > 0 {
return configs, true, nil
}
return configs, api.Authorize(r, policy.ActionRead, organization.RBACObject()), nil
}
func chatModelConfigReadScope(scopes database.APIKeyScopes) bool {
return scopes.Has(database.ApiKeyScopeCoderAll) ||
scopes.Has(database.ApiKeyScopeChatModelConfigRead)
}
// chatModelProviderDescriptors assembles the redacted provider descriptors
// for the org model collection. The caller already passed the org's model
// read gate; providers are deployment-scoped and an org admin cannot read
// them directly, so the fetch runs under a narrow AsChatd context scoped to
// exactly these two reads and the result is projected to the fixed redacted
// fields, which exclude key material, base URLs, and headers.
func (api *API) chatModelProviderDescriptors(
ctx context.Context,
userID uuid.UUID,
) ([]codersdk.ChatModelProviderDescriptor, error) {
//nolint:gocritic // Fixed redacted projection under the model read gate; see function doc.
providers, err := api.Database.GetAIProviders(dbauthz.AsChatd(ctx), database.GetAIProvidersParams{IncludeDisabled: true})
if err != nil {
return nil, err
}
//nolint:gocritic // Key presence is boolean metadata, not key material; same redaction as the deployment endpoint.
keysByProvider, err := loadAIProviderKeysByProvider(dbauthz.AsChatd(ctx), api.Database)
if err != nil {
return nil, err
}
userKeyStatus := make(map[uuid.UUID]bool)
if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
userKeyStatus, err = api.userAIProviderKeyStatusByProviderID(ctx, userID)
if err != nil {
return nil, err
}
}
out := make([]codersdk.ChatModelProviderDescriptor, 0, len(providers))
for _, provider := range providers {
display := provider.Name
if provider.DisplayName.Valid && provider.DisplayName.String != "" {
display = provider.DisplayName.String
}
hasKey := false
for _, key := range keysByProvider[provider.ID] {
if key.APIKey != "" {
hasKey = true
break
}
}
hasUserKey := userKeyStatus[provider.ID]
// Some provider configurations support ambient credentials rather than a
// stored key. Actual credential availability is checked by the gateway.
supportsAmbientCredentials := provider.Type == database.AIProviderTypeBedrock
if !supportsAmbientCredentials {
settings, err := db2sdk.AIProviderSettings(provider.Settings)
if err != nil {
return nil, xerrors.Errorf("decode AI provider settings: %w", err)
}
supportsAmbientCredentials = aiProviderSupportsAmbientCredentials(settings)
}
out = append(out, codersdk.ChatModelProviderDescriptor{
ID: provider.ID,
Type: string(provider.Type),
DisplayName: display,
Icon: provider.Icon,
Enabled: provider.Enabled,
HasAPIKey: hasKey,
HasUserAPIKey: hasUserKey,
HasEffectiveAPIKey: hasKey || hasUserKey || supportsAmbientCredentials,
AllowUserAPIKey: api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value(),
})
}
return out, nil
}
// aiProviderSupportsAmbientCredentials reports whether the provider's settings
// support ambient credentials. It does not verify that credentials are available.
func aiProviderSupportsAmbientCredentials(settings codersdk.AIProviderSettings) bool {
return settings.Bedrock != nil || settings.ClaudePlatformAWS != nil
}
func chatModelConfigRBACObject(config database.ChatModelConfig) rbac.Object {
return rbac.ResourceChatModelConfig.
WithID(config.ID).
InOrg(config.OrganizationID).
WithACLUserList(config.UserACL.RBACACL()).
WithGroupACL(config.GroupACL.RBACACL())
}
// getChatModelConfig returns one chat model config after the organization and
// model identities have been resolved by route middleware.
//
// @Summary Get an AI model
// @ID get-an-ai-model
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param model path string true "Model ID" format(uuid)
// @Success 200 {object} codersdk.ChatModel
// @Router /api/v2/organizations/{organization}/chats/models/{model} [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
config := httpmw.ChatModelConfigParam(r)
if !api.Authorize(r, policy.ActionRead, chatModelConfigRBACObject(config)) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(config))
}
type chatModelConfigProviderModelError struct {
Response codersdk.Response
}
func (e *chatModelConfigProviderModelError) Error() string {
return e.Response.Message
}
func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model string, config *codersdk.ChatModelCallConfig) *chatModelConfigProviderModelError {
if err := chatopenai.ValidateReasoningMode(string(aiProvider.Type), config); err != nil {
return &chatModelConfigProviderModelError{
Response: codersdk.Response{Message: "Invalid model config.", Detail: err.Error()},
}
}
if err := chatd.ValidateAIGatewayProviderModel(aiProvider, model); err != nil {
return &chatModelConfigProviderModelError{
Response: codersdk.Response{
Message: "OpenRouter-like provider configured as type openai does not support slash-namespaced models.",
Detail: "Change the AI provider type to openrouter or openai-compat. Slash-namespaced model IDs on OpenRouter-like gateways require one of those provider types.",
},
}
}
return nil
}
// inChatModelConfigWriteTx runs fn in a transaction that holds the advisory
// lock serializing chat model config writes for one organization. All writes
// to the table must go through this helper so concurrent writers cannot act
// on stale reads, e.g. two creates on an empty organization both
// self-promoting to default and violating the
// idx_chat_model_configs_single_default unique index.
//
// The transaction must run at ReadCommitted. A snapshot isolation level takes
// the snapshot at the lock statement, so a re-read inside fn would observe
// pre-lock state and reintroduce the lost update this helper prevents.
func (api *API) inChatModelConfigWriteTx(
ctx context.Context,
organizationID uuid.UUID,
fn func(tx database.Store) error,
) error {
return api.Database.InTx(func(tx database.Store) error {
// organization_id is immutable. UpdateChatModelConfig cannot change it,
// so a lock key from a pre-lock read stays correct.
lockID := database.GenLockID("chat_model_config_writes:" + organizationID.String())
if err := tx.AcquireLock(ctx, lockID); err != nil {
return xerrors.Errorf("acquire chat model config write lock: %w", err)
}
return fn(tx)
}, &database.TxOptions{Isolation: sql.LevelReadCommitted})
}
type chatModelConfigAuditTransition struct {
Old database.ChatModelConfig
New database.ChatModelConfig
}
func (api *API) auditChatModelConfigTransitions(
ctx context.Context,
r *http.Request,
userID uuid.UUID,
status int,
transitions []chatModelConfigAuditTransition,
) {
if len(transitions) == 0 {
return
}
auditor := api.Auditor.Load()
auditCtx := context.WithoutCancel(ctx)
requestID := httpmw.RequestID(r)
for _, transition := range transitions {
audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.ChatModelConfig]{
Audit: *auditor,
Log: api.Logger,
UserID: userID,
RequestID: requestID,
Status: status,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
Action: database.AuditActionWrite,
OrganizationID: transition.New.OrganizationID,
Old: transition.Old,
New: transition.New,
})
}
}
// @Summary Create an AI model in an organization
// @ID create-an-ai-model-in-an-organization
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param request body codersdk.CreateChatModelRequest true "Model"
// @Success 201 {object} codersdk.ChatModel
// @Router /api/v2/organizations/{organization}/chats/models [post]
func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
OrganizationID: organization.ID,
})
defer commitAudit()
var req struct {
codersdk.CreateChatModelRequest
GroupACL json.RawMessage `json:"group_acl"`
UserACL json.RawMessage `json:"user_acl"`
}
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.GroupACL != nil || req.UserACL != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Model ACLs cannot be set here. Use the nested /acl endpoint after creating the model.",
})
return
}
if req.AIProviderID == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."})
return
}
aiProviderID := uuid.NullUUID{UUID: *req.AIProviderID, Valid: true}
model := strings.TrimSpace(req.Model)
if model == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Model is required.",
})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
isDefault := false
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
if req.ContextLimit == nil || *req.ContextLimit <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Context limit is required.",
Detail: "context_limit must be greater than zero.",
})
return
}
contextLimit := *req.ContextLimit
compressionThreshold, thresholdErr := normalizeChatCompressionThreshold(
req.CompressionThreshold,
defaultCompressionThresholdForContextLimit(contextLimit),
)
if thresholdErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid compression threshold.",
Detail: thresholdErr.Error(),
})
return
}
modelConfigRaw, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig)
if modelConfigErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config.",
Detail: modelConfigErr.Error(),
})
return
}
// Seed the everyone-in-org read entry so the config stays visible to
// members once ACLs are enforced; the Everyone group shares the
// organization's ID.
everyoneReadACL := database.ChatACL{
organization.ID.String(): {Permissions: []policy.Action{policy.ActionRead}},
}
insertParams := database.InsertChatModelConfigParams{
Model: model,
DisplayName: strings.TrimSpace(req.DisplayName),
Enabled: enabled,
IsDefault: isDefault,
ContextLimit: contextLimit,
CompressionThreshold: compressionThreshold,
Options: modelConfigRaw,
AIProviderID: aiProviderID,
CreatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil},
UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil},
OrganizationID: organization.ID,
GroupACL: everyoneReadACL,
UserACL: database.ChatACL{},
}
var (
inserted database.ChatModelConfig
auditTransitions []chatModelConfigAuditTransition
)
err := api.inChatModelConfigWriteTx(ctx, insertParams.OrganizationID, func(tx database.Store) error {
currentDefault, err := tx.GetDefaultChatModelConfig(ctx, insertParams.OrganizationID)
defaultExists := err == nil
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get default model config: %w", err)
}
insertAsDefault := isDefault || !defaultExists
if insertAsDefault && defaultExists {
if err := tx.UnsetDefaultChatModelConfigs(ctx, insertParams.OrganizationID); err != nil {
return xerrors.Errorf("unset default model configs: %w", err)
}
//nolint:gocritic // The create write owns authorization for this transition.
demoted, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), currentDefault.ID)
if err != nil {
return xerrors.Errorf("refresh demoted default chat model config: %w", err)
}
auditTransitions = append(auditTransitions, chatModelConfigAuditTransition{Old: currentDefault, New: demoted})
}
insertParams.IsDefault = insertAsDefault
config, err := tx.InsertChatModelConfig(ctx, insertParams)
if err != nil {
if database.IsForeignKeyViolation(err, database.ForeignKeyChatModelConfigsAIProviderID) {
return errChatProviderMissing
}
return err
}
inserted = config
//nolint:gocritic // The provider fetch only reads the redacted descriptor fields.
lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatProviderMissing
}
return xerrors.Errorf("get AI provider for create: %w", err)
}
if !lockedAIProvider.Enabled {
return errChatProviderDisabled
}
if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model, req.ModelConfig); err != nil {
return err
}
transition, err := ensureDefaultChatModelConfig(ctx, tx, insertParams.OrganizationID)
if err != nil {
return err
}
if transition != nil {
auditTransitions = append(auditTransitions, *transition)
}
refreshedConfig, err := tx.GetChatModelConfigByID(ctx, inserted.ID)
if err != nil {
return xerrors.Errorf("refresh inserted chat model config: %w", err)
}
inserted = refreshedConfig
return nil
})
if err != nil {
if writeChatProviderPreconditionError(ctx, rw, err) {
return
}
var providerModelErr *chatModelConfigProviderModelError
switch {
case errors.As(err, &providerModelErr):
httpapi.Write(ctx, rw, http.StatusBadRequest, providerModelErr.Response)
return
case database.IsUniqueViolation(err):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat model config already exists.",
Detail: err.Error(),
})
return
case dbauthz.IsNotAuthorizedError(err):
// The dbauthz create-in-org check is the write access boundary;
// surface its denial as 403, not a concealed 404 or a 500.
httpapi.Forbidden(rw)
return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat model config.",
Detail: err.Error(),
})
return
}
}
aReq.New = inserted
api.auditChatModelConfigTransitions(ctx, r, apiKey.UserID, http.StatusCreated, auditTransitions)
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, inserted.ID)
httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted))
}
// @Summary Update an AI model
// @ID update-an-ai-model
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param model path string true "Model ID" format(uuid)
// @Param request body codersdk.UpdateChatModelRequest true "Model updates"
// @Success 200 {object} codersdk.ChatModel
// @Router /api/v2/organizations/{organization}/chats/models/{model} [patch]
// @x-apidocgen {"skip": true}
func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
existing := httpmw.ChatModelConfigParam(r)
if !api.Authorize(r, policy.ActionUpdate, chatModelConfigRBACObject(existing)) {
httpapi.ResourceNotFound(rw)
return
}
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: existing.OrganizationID,
})
defer commitAudit()
aReq.Old = existing
var req struct {
codersdk.UpdateChatModelRequest
GroupACL json.RawMessage `json:"group_acl"`
UserACL json.RawMessage `json:"user_acl"`
}
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.GroupACL != nil || req.UserACL != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Model ACLs cannot be updated here. Use the nested /acl endpoint.",
})
return
}
if req.ContextLimit != nil && *req.ContextLimit <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Context limit must be greater than zero.",
})
return
}
// A PATCH that omits the field keeps the stored value, which the
// chat_model_configs CHECK constraint already bounds to 0..100.
var requestedCompressionThreshold *int32
if req.CompressionThreshold != nil {
if thresholdErr := validateChatCompressionThreshold(*req.CompressionThreshold); thresholdErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid compression threshold.",
Detail: thresholdErr.Error(),
})
return
}
requestedCompressionThreshold = req.CompressionThreshold
}
var requestedModelConfig json.RawMessage
if req.ModelConfig != nil {
encodedModelConfig, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig)
if modelConfigErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config.",
Detail: modelConfigErr.Error(),
})
return
}
requestedModelConfig = encodedModelConfig
}
var (
updated database.ChatModelConfig
auditTransitions []chatModelConfigAuditTransition
)
err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error {
// The middleware lookup above only rejects unknown IDs; a concurrent
// writer can change or delete the row between the two reads, so merge
// against this copy.
//nolint:gocritic // The model update below reauthorizes the locked row for update.
lockedExisting, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), existing.ID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
}
return xerrors.Errorf("get chat model config for update: %w", err)
}
aReq.Old = lockedExisting
model := lockedExisting.Model
if trimmed := strings.TrimSpace(req.Model); trimmed != "" {
model = trimmed
}
displayName := lockedExisting.DisplayName
if trimmed := strings.TrimSpace(req.DisplayName); trimmed != "" {
displayName = trimmed
}
enabled := lockedExisting.Enabled
if req.Enabled != nil {
enabled = *req.Enabled
}
isDefault := lockedExisting.IsDefault
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
contextLimit := lockedExisting.ContextLimit
if req.ContextLimit != nil {
contextLimit = *req.ContextLimit
}
compressionThreshold := lockedExisting.CompressionThreshold
if requestedCompressionThreshold != nil {
compressionThreshold = *requestedCompressionThreshold
}
modelConfigRaw := lockedExisting.Options
if requestedModelConfig != nil {
modelConfigRaw = requestedModelConfig
}
aiProviderID := lockedExisting.AIProviderID
if req.AIProviderID != nil {
aiProviderID = uuid.NullUUID{UUID: *req.AIProviderID, Valid: true}
}
updateParams := database.UpdateChatModelConfigParams{
Model: model,
DisplayName: displayName,
Enabled: enabled,
IsDefault: isDefault,
ContextLimit: contextLimit,
CompressionThreshold: compressionThreshold,
Options: modelConfigRaw,
AIProviderID: aiProviderID,
UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil},
ID: lockedExisting.ID,
}
// An update that touches neither the provider nor the model cannot
// invalidate the stored provider/model pair.
revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "")
if revalidateProviderModel || chatopenai.ReasoningMode(req.ModelConfig) != nil {
//nolint:gocritic // The provider fetch only reads the redacted descriptor fields.
aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatProviderMissing
}
return xerrors.Errorf("get AI provider for update: %w", err)
}
if revalidateProviderModel && !aiProvider.Enabled {
return errChatProviderDisabled
}
if err := validateChatModelConfigProviderModel(aiProvider, updateParams.Model, unmarshalChatModelCallConfig(modelConfigRaw)); err != nil {
return err
}
}
setAsDefault := updateParams.IsDefault && !lockedExisting.IsDefault
if setAsDefault {
//nolint:gocritic // The target update owns authorization for the sibling transition.
currentDefault, err := tx.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), lockedExisting.OrganizationID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get default model config before update: %w", err)
}
if err := tx.UnsetDefaultChatModelConfigs(ctx, lockedExisting.OrganizationID); err != nil {
return xerrors.Errorf("unset default model configs: %w", err)
}
if err == nil {
//nolint:gocritic // The target update owns authorization for the sibling transition.
demoted, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), currentDefault.ID)
if err != nil {
return xerrors.Errorf("refresh demoted default chat model config: %w", err)
}
auditTransitions = append(auditTransitions, chatModelConfigAuditTransition{Old: currentDefault, New: demoted})
}
}
updated, err = tx.UpdateChatModelConfig(ctx, updateParams)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
}
return err
}
excludeConfigID := uuid.Nil
if lockedExisting.IsDefault && req.IsDefault != nil && !*req.IsDefault {
excludeConfigID = lockedExisting.ID
}
transition, err := ensureDefaultChatModelConfig(
ctx,
tx,
lockedExisting.OrganizationID,
excludeConfigID,
)
if err != nil {
return err
}
if transition != nil {
if transition.New.ID == lockedExisting.ID {
updated = transition.New
} else {
auditTransitions = append(auditTransitions, *transition)
}
}
return nil
})
if err != nil {
if writeChatProviderPreconditionError(ctx, rw, err) {
return
}
var providerModelErr *chatModelConfigProviderModelError
switch {
case errors.As(err, &providerModelErr):
httpapi.Write(ctx, rw, http.StatusBadRequest, providerModelErr.Response)
return
case database.IsUniqueViolation(err):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat model config already exists.",
Detail: err.Error(),
})
return
case dbauthz.IsNotAuthorizedError(err):
// The dbauthz object update check is the write access boundary;
// surface its denial as 403, not a concealed 404 or a 500.
httpapi.Forbidden(rw)
return
case xerrors.Is(err, errChatModelConfigNotFound), httpapi.Is404Error(err):
httpapi.ResourceNotFound(rw)
return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat model config.",
Detail: err.Error(),
})
return
}
}
aReq.New = updated
api.auditChatModelConfigTransitions(ctx, r, apiKey.UserID, http.StatusOK, auditTransitions)
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, updated.ID)
httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated))
}
// @Summary Delete an AI model
// @ID delete-an-ai-model
// @Security CoderSessionToken
// @Tags Chats
// @Param organization path string true "Organization name or ID"
// @Param model path string true "Model ID" format(uuid)
// @Success 204
// @Router /api/v2/organizations/{organization}/chats/models/{model} [delete]
// @x-apidocgen {"skip": true}
func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
existing := httpmw.ChatModelConfigParam(r)
if !api.Authorize(r, policy.ActionDelete, chatModelConfigRBACObject(existing)) {
httpapi.ResourceNotFound(rw)
return
}
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.ChatModelConfig](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
OrganizationID: existing.OrganizationID,
})
defer commitAudit()
aReq.Old = existing
var auditTransitions []chatModelConfigAuditTransition
if err := api.inChatModelConfigWriteTx(ctx, existing.OrganizationID, func(tx database.Store) error {
//nolint:gocritic // The delete write below reauthorizes the locked row.
current, err := tx.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), existing.ID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
}
return xerrors.Errorf("get chat model config for delete: %w", err)
}
aReq.Old = current
if _, err := tx.DeleteChatModelConfigByID(ctx, current.ID); err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
}
return err
}
transition, err := ensureDefaultChatModelConfig(ctx, tx, current.OrganizationID)
if err != nil {
return err
}
if transition != nil {
auditTransitions = append(auditTransitions, *transition)
}
return nil
}); err != nil {
if dbauthz.IsNotAuthorizedError(err) {
// The dbauthz object delete check is the write access boundary;
// surface its denial as 403, not a concealed 404 or a 500.
httpapi.Forbidden(rw)
return
}
if xerrors.Is(err, errChatModelConfigNotFound) || httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete chat model config.",
Detail: err.Error(),
})
return
}
api.auditChatModelConfigTransitions(ctx, r, httpmw.APIKey(r).UserID, http.StatusNoContent, auditTransitions)
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, existing.ID)
rw.WriteHeader(http.StatusNoContent)
}
// ensureDefaultChatModelConfig preserves one default for every organization
// that owns at least one chat model config.
func ensureDefaultChatModelConfig(
ctx context.Context,
tx database.Store,
organizationID uuid.UUID,
excludedConfigIDs ...uuid.UUID,
) (*chatModelConfigAuditTransition, error) {
//nolint:gocritic // Default election is contained by action-authorized writes.
_, err := tx.GetDefaultChatModelConfig(dbauthz.AsSystemRestricted(ctx), organizationID)
switch {
case err == nil:
return nil, nil //nolint:nilnil // A nil transition means no default changed.
case !xerrors.Is(err, sql.ErrNoRows):
return nil, xerrors.Errorf("get default model config: %w", err)
}
orgModelConfigs, err := tx.GetChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return nil, xerrors.Errorf("list default chat model config candidates: %w", err)
}
if len(orgModelConfigs) == 0 {
return nil, nil //nolint:nilnil // No model remains to promote.
}
// Prefer a config that can actually serve requests (enabled, under an
// enabled provider) so the promoted default does not reject
// omitted-model chat creation. Fall back to any non-excluded config
// when no usable candidate exists.
enabledRows, err := tx.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return nil, xerrors.Errorf("list enabled chat model configs: %w", err)
}
usable := make(map[uuid.UUID]struct{}, len(enabledRows))
for _, row := range enabledRows {
usable[row.ChatModelConfig.ID] = struct{}{}
}
excluded := make(map[uuid.UUID]struct{}, len(excludedConfigIDs))
for _, configID := range excludedConfigIDs {
if configID == uuid.Nil {
continue
}
excluded[configID] = struct{}{}
}
var selected *database.ChatModelConfig
for i := range orgModelConfigs {
config := &orgModelConfigs[i]
if _, skip := excluded[config.ID]; skip {
continue
}
if selected == nil {
selected = config
}
if _, ok := usable[config.ID]; ok {
selected = config
break
}
}
if selected == nil {
// Re-promoting the excluded sole candidate preserves the required default.
selected = &orgModelConfigs[0]
}
candidateConfig := *selected
if err := tx.UnsetDefaultChatModelConfigs(ctx, organizationID); err != nil {
return nil, xerrors.Errorf("unset default model configs: %w", err)
}
params := chatModelConfigToUpdateParams(candidateConfig)
params.IsDefault = true
promoted, err := tx.UpdateChatModelConfig(ctx, params)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
// Do not wrap with %w. Callers map target misses to 404, but a
// default-candidate race is an internal retryable failure.
return nil, xerrors.Errorf("set default model config: %v", err)
}
return nil, xerrors.Errorf("set default model config: %w", err)
}
return &chatModelConfigAuditTransition{Old: candidateConfig, New: promoted}, nil
}
func chatModelConfigToUpdateParams(
config database.ChatModelConfig,
) database.UpdateChatModelConfigParams {
return database.UpdateChatModelConfigParams{
Model: config.Model,
DisplayName: config.DisplayName,
Enabled: config.Enabled,
IsDefault: config.IsDefault,
ContextLimit: config.ContextLimit,
CompressionThreshold: config.CompressionThreshold,
Options: config.Options,
AIProviderID: config.AIProviderID,
UpdatedBy: uuid.NullUUID{},
ID: config.ID,
}
}
func parseChatModelConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
modelConfigID, err := uuid.Parse(chi.URLParam(r, "modelConfig"))
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat model config ID.",
Detail: err.Error(),
})
return uuid.Nil, false
}
return modelConfigID, true
}
func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModel {
modelConfig := unmarshalChatModelCallConfig(config.Options)
var reasoningEffortConfig *codersdk.ChatModelReasoningEffortConfig
if modelConfig != nil {
reasoningEffortConfig = modelConfig.ReasoningEffort
}
// Active configs always carry a non-null ai_provider_id (CHECK
// chat_model_configs_ai_provider_required_when_active).
return codersdk.ChatModel{
ID: config.ID,
OrganizationID: config.OrganizationID,
AIProviderID: config.AIProviderID.UUID,
Model: config.Model,
DisplayName: config.DisplayName,
Enabled: config.Enabled,
IsDefault: config.IsDefault,
ContextLimit: config.ContextLimit,
CompressionThreshold: config.CompressionThreshold,
ModelConfig: modelConfig,
ReasoningEfforts: chatprovider.SelectableReasoningEfforts(reasoningEffortConfig),
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
}
}
func marshalChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) (json.RawMessage, error) {
if modelConfig == nil {
return json.RawMessage("{}"), nil
}
if err := validateChatModelCallConfig(modelConfig); err != nil {
return nil, err
}
encoded, err := json.Marshal(modelConfig)
if err != nil {
return nil, xerrors.Errorf("encode model config: %w", err)
}
return encoded, nil
}
func invalidReasoningEffortResponse(value string) codersdk.Response {
return codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: fmt.Sprintf("Invalid value %q, must be one of %s", value, allowedReasoningEffortValues),
}
}
func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) error {
if modelConfig == nil {
return nil
}
if err := validateChatModelReasoningEffortConfig(modelConfig); err != nil {
return err
}
return validateChatModelProviderOptions(modelConfig.ProviderOptions)
}
// validateChatModelReasoningEffortConfig validates the reasoning_effort
// config. Values must exactly match the global effort scale, and default
// must not exceed max.
func validateChatModelReasoningEffortConfig(modelConfig *codersdk.ChatModelCallConfig) error {
config := modelConfig.ReasoningEffort
if config == nil {
return nil
}
if config.Default == nil || config.Max == nil {
return xerrors.New("reasoning_effort.default and reasoning_effort.max must both be set")
}
if !chatprovider.IsValidReasoningEffort(*config.Default) {
return xerrors.Errorf("reasoning_effort.default %q must be one of %s", *config.Default, allowedReasoningEffortValues)
}
if !chatprovider.IsValidReasoningEffort(*config.Max) {
return xerrors.Errorf("reasoning_effort.max %q must be one of %s", *config.Max, allowedReasoningEffortValues)
}
if !chatprovider.ReasoningEffortLessOrEqual(*config.Default, *config.Max) {
return xerrors.New("reasoning_effort.default must not exceed reasoning_effort.max")
}
return nil
}
func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) error {
if options == nil {
return nil
}
if options.Anthropic != nil && options.Anthropic.ThinkingDisplay != nil &&
strings.TrimSpace(*options.Anthropic.ThinkingDisplay) != "" &&
chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) == nil {
return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted")
}
if options.Google != nil && options.Google.ThinkingConfig != nil &&
options.Google.ThinkingConfig.ThinkingLevel != nil &&
strings.TrimSpace(*options.Google.ThinkingConfig.ThinkingLevel) != "" {
if chatprovider.GoogleThinkingLevelFromChat(options.Google.ThinkingConfig.ThinkingLevel) == nil {
return xerrors.Errorf("provider_options.google.thinking_config.thinking_level must be one of minimal, low, medium, high")
}
if options.Google.ThinkingConfig.ThinkingBudget != nil {
return xerrors.Errorf("provider_options.google.thinking_config.thinking_level cannot be combined with thinking_budget")
}
}
return nil
}
func unmarshalChatModelCallConfig(
raw json.RawMessage,
) *codersdk.ChatModelCallConfig {
if len(raw) == 0 {
return nil
}
decoded := &codersdk.ChatModelCallConfig{}
if err := json.Unmarshal(raw, decoded); err != nil {
return nil
}
if isZeroChatModelCallConfig(decoded) {
return nil
}
return decoded
}
func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool {
if config == nil {
return true
}
return config.MaxOutputTokens == nil &&
config.Temperature == nil &&
config.TopP == nil &&
config.TopK == nil &&
config.PresencePenalty == nil &&
config.FrequencyPenalty == nil &&
config.ReasoningEffort == nil &&
isZeroChatModelOpenAIConfig(config.OpenAIConfig) &&
isZeroChatModelProviderOptions(config.ProviderOptions)
}
func isZeroChatModelOpenAIConfig(config *codersdk.ChatModelOpenAIConfig) bool {
return config == nil || (config.UseResponsesAPI == nil && config.ReasoningModel == nil)
}
func isZeroChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) bool {
if options == nil {
return true
}
return options.OpenAI == nil &&
options.Anthropic == nil &&
options.Google == nil &&
options.OpenAICompat == nil &&
options.OpenRouter == nil &&
options.Vercel == nil
}
const maxChatProviderAPIKeySize = 10240 // 10 KB
func validateChatProviderAPIKeySize(apiKey string) error {
if len(apiKey) > maxChatProviderAPIKeySize {
return xerrors.Errorf("API key exceeds maximum size of 10 KB (%d bytes)", maxChatProviderAPIKeySize)
}
return nil
}
func writeChatProviderPreconditionError(ctx context.Context, rw http.ResponseWriter, err error) bool {
var message string
switch {
case xerrors.Is(err, errChatProviderMissing):
message = "AI provider is not configured."
case xerrors.Is(err, errChatProviderDisabled):
message = "AI provider is disabled."
default:
return false
}
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: message})
return true
}
var (
errChatProviderDisabled = xerrors.New("AI provider is disabled")
errChatProviderMissing = xerrors.New("AI provider is not configured")
errChatModelConfigNotFound = xerrors.New("chat model config not found")
)
// @Summary Submit chat tool results
// @ID submit-chat-tool-results
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Accept json
// @Param request body codersdk.SubmitToolResultsRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/{chat}/tool-results [post]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
apiKey := httpmw.APIKey(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
// Submitting tool results resumes LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may submit tool results. See
// postChatMessages for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may submit tool results.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot submit tool results to an archived chat.",
})
return
}
if api.DeploymentValues.DisableChatCallerSuppliedTools.Value() {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Caller-supplied tools are disabled on this deployment.",
Detail: "The server runs with --disable-chat-caller-supplied-tools. Interrupt the chat to cancel the pending tool calls.",
})
return
}
// Cap the raw request body to prevent excessive memory use.
var req codersdk.SubmitToolResultsRequest
if !httpapi.ReadLimit(ctx, rw, r, maxChatRequestBodyBytes, &req) {
return
}
if len(req.Results) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "At least one tool result is required.",
})
return
}
// The authoritative status check happens inside SubmitToolResults
// under the row lock; that path also surfaces the shared
// invalid-state response for chats that are not in a valid
// execution state at all.
err := api.chatDaemon.SubmitToolResults(ctx, chatd.SubmitToolResultsOptions{
ChatID: chat.ID,
UserID: apiKey.UserID,
ModelConfigID: chat.LastModelConfigID,
Results: req.Results,
})
if err != nil {
if hookErr, ok := errors.AsType[*dispatch.Error](err); ok {
writeChatHookDispatchFailed(ctx, rw, hookErr)
return
}
var validationErr *chatd.ToolResultValidationError
var conflictErr *chatd.ToolResultStatusConflictError
switch {
case xerrors.Is(err, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot submit tool results to an archived chat.",
})
case errors.As(err, &conflictErr):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not waiting for tool results.",
Detail: err.Error(),
})
case errors.As(err, &validationErr):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: validationErr.Message,
Detail: validationErr.Detail,
})
case errors.Is(err, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, err):
// response already written
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not waiting for tool results.",
Detail: err.Error(),
})
default:
api.Logger.Error(ctx, "tool results submission failed",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error submitting tool results.",
})
}
return
}
rw.WriteHeader(http.StatusNoContent)
}
// getChatDebugRuns returns a list of debug run summaries for a chat.
// EXPERIMENTAL
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatDebugRuns(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
const maxDebugRuns = 100
runs, err := api.Database.GetChatDebugRunsByChatID(ctx, database.GetChatDebugRunsByChatIDParams{
ChatID: chat.ID,
LimitVal: maxDebugRuns,
})
if err != nil {
// The chat may have been deleted or access revoked between
// middleware extraction and this query (dbauthz re-authorizes
// on read). Surface those races as 404 to match the rest of
// this API and avoid leaking backend details.
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching debug runs.",
Detail: err.Error(),
})
return
}
summaries := make([]codersdk.ChatDebugRunSummary, 0, len(runs))
for _, run := range runs {
summaries = append(summaries, db2sdk.ChatDebugRunSummary(run))
}
httpapi.Write(ctx, rw, http.StatusOK, summaries)
}
// getChatDebugRun returns a single debug run with its steps.
// EXPERIMENTAL
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatDebugRun(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
runIDStr := chi.URLParam(r, "debugRun")
runID, err := uuid.Parse(runIDStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid debug run ID.",
Detail: err.Error(),
})
return
}
run, err := api.Database.GetChatDebugRunByID(ctx, runID)
if err != nil {
// Treat both not-found and authorization failures as 404 to
// avoid leaking the existence of runs the caller cannot access.
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching debug run.",
Detail: err.Error(),
})
return
}
// Verify the run belongs to this chat.
if run.ChatID != chat.ID {
httpapi.ResourceNotFound(rw)
return
}
steps, err := api.Database.GetChatDebugStepsByRunID(ctx, run.ID)
if err != nil {
// The run may have been deleted or access may have changed
// between the two queries. Treat not-found/authz errors as
// 404 for consistency with the run lookup above.
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching debug steps.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.ChatDebugRunDetail(run, steps))
}
// @Summary Stream chat parts via WebSockets
// @ID stream-chat-parts-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {array} codersdk.ChatStreamEvent
// @Router /api/v2/chats/{chat}/stream/parts [get]
// @x-apidocgen {"skip": true}
func (api *API) streamChatParts(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
if err := api.chatDaemon.ServeStreamPartsAuthorized(rw, r, chat); err != nil {
api.Logger.Named("chat_stream_parts").Debug(ctx, "chat stream parts closed", slog.Error(err))
}
}