Files
coder/coderd/exp_chats.go
T
Bobby Ho 166d92ba73 fix: bound request body size on JSON API endpoints (#28168)
## Summary

`httpapi.Read` decoded request bodies with no size limit, so a single
request could allocate memory without bound. This adds a 4 MiB default
ceiling, leaves the endpoints that legitimately need more explicitly
exempted, and counts the rejections so a limit set too tight is visible.

This is the first of three PRs split out of #28048, covering the
endpoints that answer in `codersdk.Response` shape. The OAuth2 decode
paths (RFC 6749, RFC 7591) and the SCIM ones (RFC 7644) answer in their
own error shapes and follow in separate PRs, along with the lint rule
that pins the invariant.

Closes PLAT-463. Remediates SEC-416 (CWE-770, CVSS 7.5) and SEC-392.

## Problem

`httpapi.Read` calls `json.NewDecoder(r.Body).Decode(value)` with no
ceiling, and no middleware in the chain bounds body size. The exposure
is pre-authentication: login, OTP, and first-user creation all read a
body before any authorization decision is reached. The existing rate
limiter bounds request *rate*, which is orthogonal to the memory a
single admitted request may consume.

## Fix

`Read` is split into `Read` and `ReadLimit`. `ReadLimit` wraps `r.Body`
in an `http.MaxBytesReader` and keeps the existing decode and validate
logic; `Read` delegates to it with a new `DefaultMaxRequestBodyBytes` of
4 MiB, which covers the 124 remaining non-test callers at a single site.

`http.MaxBytesReader` composes as tightest-wins, so the handlers that
pre-wrapped their own bodies pass their limit to `ReadLimit` rather than
wrapping, and each keeps its previous ceiling byte for byte. That
matters most for the bulk secrets import at `8 * MaxSecretsFileBytes`:
an unconditional wrap inside `Read` would have silently halved it to the
default. `TestImportUserSecretsBodyLargerThanDefaultLimit` is the
regression guard for that specific failure, and
`TestMaxBytesReaderNesting` pins the composition behavior the whole
requirement rests on.

Every rejection site calls `httpapi.RecordRequestBodyLimit`, which names
the limit that tripped on the request's existing log line and marks the
request so `coderd_api_requests_too_large_total{reason="request_body"}`
counts body rejections apart from the 413s coderd answers for other
causes, such as agent log storage overflow. A limit set too tight for a
legitimate payload therefore surfaces without waiting for a user report.

The limit is a constant rather than a deployment option: an operator
raising it to unblock something would reopen the vulnerability as
configuration, where a security scan will not find it. A legitimate 413
is answered with a targeted `ReadLimit` on that endpoint.

## Behavior change

`POST /api/v2/files` now answers 413 rather than 400 when a request body
exceeds `HTTPFileMaxBytes`. It installed that bound already but reported
the rejection as a read failure, which leaked the stdlib `http: request
body too large` string through `Detail` and kept the largest limit in
the tree off the metric. The separate 413 for an oversized expanded
archive is unchanged.

The task log snapshot endpoint now answers 413 rather than 400 when its
64 KiB cap is exceeded. Routing it through `ReadLimit` also changes its
decode-failure message from "Failed to decode request payload." to
"Request body must be valid JSON.", which is what every other endpoint
answers. Its tests are updated to match both.

`coderd_api_requests_too_large_total` is new, so there is no existing
query to migrate. It counts the 413s coderd answers, labeled `method`,
`path`, and `reason`. `reason="request_body"` is a rejection by one of
the limits above; `reason="other"` is a 413 that has nothing to do with
body size, such as agent log storage overflow.

## Reading this

The commits are ordered to be read in sequence. Commits 1 and 2 are the
security fix; commits 3 to 5 are the observability consequences, and
commit 3 is the one that touches dashboards. Commit 7 documents the
limit on the REST API reference index. Commits 6 and 8 add and revert an
exhaustive `@Failure 413` annotation pass, which buried the fix under
its regenerated swagger, and cancel out.
2026-08-18 12:54:45 -07:00

7831 lines
253 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"
"slices"
"strconv"
"strings"
"sync"
"time"
"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"
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/util/ptr"
"github.com/coder/coder/v2/coderd/workspaceapps"
"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/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/wsjson"
"github.com/coder/websocket"
)
const (
chatStreamBatchSize = 256
chatContextLimitModelConfigKey = "context_limit"
chatContextCompressionThresholdModelConfigKey = "context_compression_threshold"
defaultChatContextCompressionThreshold = int32(70)
minChatContextCompressionThreshold = int32(0)
maxChatContextCompressionThreshold = int32(100)
maxSystemPromptLenBytes = 131072 // 128 KiB
)
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
}
// 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),
)
}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/watch [get]
// @Description Experimental: this endpoint is subject to change.
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()
}
// EXPERIMENTAL: chatsByWorkspace returns a mapping of workspace ID to
// 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:
// 1. move aggregation to a SQL view with proper in-query authz so we
// can return a single row per workspace without this two-pass approach.
// 2. Restore the below router annotation and un-skip docs gen
// <at>Router /api/experimental/chats/by-workspace [post]
//
// @Summary Get latest chats by workspace IDs
// @ID get-latest-chats-by-workspace-ids
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Success 200
// @x-apidocgen {"skip": true}
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, map[uuid.UUID]uuid.UUID{})
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(map[uuid.UUID]uuid.UUID, 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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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`, `pr_status:<draft\|open\|merged\|closed>` 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; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words 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)."
// @Success 200 {array} codersdk.Chat
// @Router /api/experimental/chats [get]
// @Description Experimental: this endpoint is subject to change.
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,
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 {
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
}
}
type parsedChatModelOverride struct {
modelConfigID *uuid.UUID
reasoningEffort *string
}
func parseChatModelOverride(raw string) (parsedChatModelOverride, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return parsedChatModelOverride{}, nil
}
rawID, rawEffort, hasEffort := strings.Cut(trimmed, ":")
modelConfigID, err := uuid.Parse(rawID)
if err != nil {
return parsedChatModelOverride{}, xerrors.Errorf("parse chat model override: %w", err)
}
if hasEffort && rawEffort == "" {
return parsedChatModelOverride{}, xerrors.New("parse chat model override: reasoning effort is empty")
}
parsed := parsedChatModelOverride{modelConfigID: &modelConfigID}
if hasEffort {
parsed.reasoningEffort = &rawEffort
}
return parsed, nil
}
func formatChatModelOverride(id *uuid.UUID, effort *string) string {
if id == nil {
return ""
}
formatted := id.String()
if effort != nil {
formatted += ":" + *effort
}
return formatted
}
func lookupEnabledChatModelConfigByID(
ctx context.Context,
db database.Store,
id uuid.UUID,
) (database.ChatModelConfig, error) {
//nolint:gocritic // Validation lookup uses AsChatd to check model
// availability independently of the caller's read permissions.
return db.GetEnabledChatModelConfigByID(dbauthz.AsChatd(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,
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 {
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 (api *API) getChatModelOverrideConfig(
ctx context.Context,
settingName string,
getter func(context.Context) (string, error),
) (*uuid.UUID, *string, bool, error) {
raw, err := getter(ctx)
if err != nil {
return nil, nil, false, xerrors.Errorf("get %s model override: %w", settingName, err)
}
parsed, err := parseChatModelOverride(raw)
if err != nil {
// Degrade malformed values to unset so the admin settings page
// remains accessible and the bad value can be cleared.
api.Logger.Warn(
ctx,
"malformed model override in site config, treating as unset",
slog.F("setting", settingName),
slog.F("raw_value", raw),
slog.Error(err),
)
return nil, nil, true, nil
}
return parsed.modelConfigID, parsed.reasoningEffort, false, nil
}
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)
}
type chatModelOverrideSiteConfig struct {
label string
getter func(context.Context) (string, error)
upsert func(context.Context, string) error
}
func (api *API) chatModelOverrideSiteConfig(
overrideContext codersdk.ChatModelOverrideContext,
) (chatModelOverrideSiteConfig, error) {
switch overrideContext {
case codersdk.ChatModelOverrideContextGeneral:
return chatModelOverrideSiteConfig{
label: "general",
getter: api.Database.GetChatGeneralModelOverride,
upsert: api.Database.UpsertChatGeneralModelOverride,
}, nil
case codersdk.ChatModelOverrideContextExplore:
return chatModelOverrideSiteConfig{
label: "explore",
getter: api.Database.GetChatExploreModelOverride,
upsert: api.Database.UpsertChatExploreModelOverride,
}, nil
case codersdk.ChatModelOverrideContextTitleGeneration:
return chatModelOverrideSiteConfig{
label: "title generation",
getter: api.Database.GetChatTitleGenerationModelOverride,
upsert: api.Database.UpsertChatTitleGenerationModelOverride,
}, nil
case codersdk.ChatModelOverrideContextCompaction:
return chatModelOverrideSiteConfig{
label: "compaction",
getter: api.Database.GetChatCompactionModelOverride,
upsert: api.Database.UpsertChatCompactionModelOverride,
}, nil
default:
return chatModelOverrideSiteConfig{}, xerrors.Errorf(
"unknown chat model override context %q",
overrideContext,
)
}
}
func (api *API) readChatModelOverrideConfig(
ctx context.Context,
overrideContext codersdk.ChatModelOverrideContext,
) (*uuid.UUID, *string, bool, string, error) {
siteConfig, err := api.chatModelOverrideSiteConfig(overrideContext)
if err != nil {
return nil, nil, false, "", err
}
id, effort, isMalformed, err := api.getChatModelOverrideConfig(ctx, siteConfig.label, siteConfig.getter)
return id, effort, isMalformed, siteConfig.label, err
}
func (api *API) upsertChatModelOverrideConfig(
ctx context.Context,
overrideContext codersdk.ChatModelOverrideContext,
modelConfigID *uuid.UUID,
reasoningEffort *string,
) (string, error) {
siteConfig, err := api.chatModelOverrideSiteConfig(overrideContext)
if err != nil {
return "", err
}
return siteConfig.label, siteConfig.upsert(ctx, formatChatModelOverride(modelConfigID, reasoningEffort))
}
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 parseChatPersonalModelOverrideValue(
raw string,
overrideContext codersdk.ChatPersonalModelOverrideContext,
) chatd.ParsedChatPersonalModelOverride {
defaultMode := defaultChatPersonalModelOverrideMode(overrideContext)
parsed := chatd.ParseChatPersonalModelOverride(raw, defaultMode)
if overrideContext == codersdk.ChatPersonalModelOverrideContextRoot &&
parsed.Mode == codersdk.ChatPersonalModelOverrideModeDeploymentDefault {
return chatd.ParsedChatPersonalModelOverride{
Mode: defaultMode,
Malformed: true,
}
}
return parsed
}
func formatChatPersonalModelOverrideValue(
mode codersdk.ChatPersonalModelOverrideMode,
modelConfigID string,
reasoningEffort *string,
) string {
if mode == codersdk.ChatPersonalModelOverrideModeModel {
value := string(mode) + ":" + strings.TrimSpace(modelConfigID)
if reasoningEffort != nil {
value += ":" + *reasoningEffort
}
return value
}
return string(mode)
}
func chatPersonalModelOverrideResponse(
overrideContext codersdk.ChatPersonalModelOverrideContext,
raw string,
isSet bool,
) codersdk.ChatPersonalModelOverride {
parsed := parseChatPersonalModelOverrideValue(raw, overrideContext)
modelConfigID := ""
var reasoningEffort *string
if parsed.Mode == codersdk.ChatPersonalModelOverrideModeModel {
modelConfigID = parsed.ModelConfigID.String()
reasoningEffort = parsed.ReasoningEffort
}
return codersdk.ChatPersonalModelOverride{
Context: overrideContext,
Mode: parsed.Mode,
ModelConfigID: modelConfigID,
ReasoningEffort: reasoningEffort,
IsSet: isSet,
IsMalformed: parsed.Malformed,
}
}
func (api *API) chatPersonalModelOverrideDeploymentDefaultResponse(
ctx context.Context,
overrideContext codersdk.ChatModelOverrideContext,
) (codersdk.ChatModelOverrideResponse, error) {
// The deployment defaults are global chat configuration, not user-owned
// resources. Users may read these values here because the personal settings
// UI must explain what deployment_default resolves to.
//nolint:gocritic // System context is required to read deployment config.
modelConfigID, reasoningEffort, isMalformed, _, err := api.readChatModelOverrideConfig(
dbauthz.AsSystemRestricted(ctx),
overrideContext,
)
if err != nil {
return codersdk.ChatModelOverrideResponse{}, err
}
return codersdk.ChatModelOverrideResponse{
Context: overrideContext,
ModelConfigID: formatChatModelOverride(modelConfigID, nil),
ReasoningEffort: reasoningEffort,
IsMalformed: isMalformed,
}, nil
}
func (api *API) chatPersonalModelOverrideDeploymentDefaults(
ctx context.Context,
) (codersdk.ChatPersonalModelOverrideDeploymentDefaults, error) {
general, err := api.chatPersonalModelOverrideDeploymentDefaultResponse(
ctx,
codersdk.ChatModelOverrideContextGeneral,
)
if err != nil {
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{}, err
}
explore, err := api.chatPersonalModelOverrideDeploymentDefaultResponse(
ctx,
codersdk.ChatModelOverrideContextExplore,
)
if err != nil {
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{}, err
}
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{
General: general,
Explore: explore,
}, nil
}
type userChatModelAvailability struct {
configuredProviders []chatprovider.ConfiguredProvider
configuredModels []chatprovider.ConfiguredModel
enabledModels []database.GetEnabledChatModelConfigsRow
providerStatus map[string]chatprovider.ProviderAvailability
providerStatusByID map[uuid.UUID]chatprovider.ProviderAvailability
enabledProviderNames map[string]struct{}
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"
)
// getUserChatProviderAvailability returns the enabled chat providers and models
// the user can access. Deployment-level configuration is read as chatd, while
// user key lookups still use the caller's authorization context.
func (api *API) getUserChatProviderAvailability(
ctx context.Context,
userID uuid.UUID,
) (userChatModelAvailability, error) {
//nolint:gocritic // Chatd context is required to read enabled chat config.
chatdCtx := dbauthz.AsChatd(ctx)
enabledProviders, err := api.Database.GetAIProviders(chatdCtx, database.GetAIProvidersParams{})
if err != nil {
return userChatModelAvailability{}, err
}
enabledModels, err := api.Database.GetEnabledChatModelConfigs(chatdCtx)
if err != nil {
return userChatModelAvailability{}, err
}
configuredProviders, err := api.configuredProvidersFromAIProviders(chatdCtx, enabledProviders)
if err != nil {
return userChatModelAvailability{}, err
}
availability := userChatModelAvailability{
configuredProviders: configuredProviders,
configuredModels: make([]chatprovider.ConfiguredModel, 0, len(enabledModels)),
enabledModels: enabledModels,
enabledProviderNames: make(map[string]struct{}, len(enabledProviders)),
enabledProviderIDs: make(map[uuid.UUID]struct{}, len(enabledProviders)),
providerStatusByID: make(map[uuid.UUID]chatprovider.ProviderAvailability, len(enabledProviders)),
}
for _, configuredProvider := range configuredProviders {
normalizedProvider := chatprovider.NormalizeProvider(configuredProvider.Provider)
if normalizedProvider != "" {
availability.enabledProviderNames[normalizedProvider] = struct{}{}
}
if configuredProvider.ProviderID != uuid.Nil {
availability.enabledProviderIDs[configuredProvider.ProviderID] = struct{}{}
}
}
userKeys := []chatprovider.UserProviderKey{}
if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
userKeyRows, err := api.Database.GetUserAIProviderKeysByUserID(ctx, userID)
if err != nil {
return userChatModelAvailability{}, err
}
userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyRows))
for _, userKey := range userKeyRows {
userKeys = append(userKeys, chatprovider.UserProviderKey{
ChatProviderID: userKey.AIProviderID,
APIKey: userKey.APIKey,
})
}
}
fallbackKeys := ChatProviderAPIKeysFromDeploymentValues(api.DeploymentValues)
mergeProviderStatus := func(
statuses map[string]chatprovider.ProviderAvailability,
normalizedProvider string,
status chatprovider.ProviderAvailability,
) {
current, ok := statuses[normalizedProvider]
if !ok || (!current.Available && status.Available) {
statuses[normalizedProvider] = status
}
}
providerStatusByType := make(map[string]chatprovider.ProviderAvailability, len(availability.configuredProviders))
for _, configuredProvider := range availability.configuredProviders {
normalizedProvider := chatprovider.NormalizeProvider(configuredProvider.Provider)
if normalizedProvider == "" {
continue
}
_, providerStatus := chatprovider.ResolveUserProviderKeys(
fallbackKeys,
[]chatprovider.ConfiguredProvider{configuredProvider},
userKeys,
)
status, ok := providerStatus[normalizedProvider]
if !ok {
continue
}
if configuredProvider.ProviderID != uuid.Nil {
availability.providerStatusByID[configuredProvider.ProviderID] = status
}
mergeProviderStatus(providerStatusByType, normalizedProvider, status)
}
modelStatusByType := make(map[string]chatprovider.ProviderAvailability, len(enabledModels))
for _, model := range enabledModels {
normalizedProvider := chatprovider.NormalizeProvider(model.Provider)
if normalizedProvider == "" {
continue
}
if model.ChatModelConfig.AIProviderID.Valid {
status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID]
if ok {
mergeProviderStatus(modelStatusByType, normalizedProvider, status)
}
continue
}
if status, ok := providerStatusByType[normalizedProvider]; ok {
mergeProviderStatus(modelStatusByType, normalizedProvider, status)
}
}
availability.providerStatus = providerStatusByType
for provider, status := range modelStatusByType {
availability.providerStatus[provider] = status
}
for _, model := range enabledModels {
normalizedProvider := chatprovider.NormalizeProvider(model.Provider)
if model.ChatModelConfig.AIProviderID.Valid {
status, ok := availability.providerStatusByID[model.ChatModelConfig.AIProviderID.UUID]
if !ok {
continue
}
if aggregateStatus, ok := availability.providerStatus[normalizedProvider]; ok && aggregateStatus.Available && !status.Available {
continue
}
}
availability.configuredModels = append(availability.configuredModels, chatprovider.ConfiguredModel{
Provider: model.Provider,
Model: model.ChatModelConfig.Model,
DisplayName: model.ChatModelConfig.DisplayName,
})
}
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,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, chatModelConfigUnavailableReason, error) {
if modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
//nolint:gocritic // Non-admin users need deployment config validation.
model, err := api.Database.GetChatModelConfigByID(
dbauthz.AsSystemRestricted(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.Enabled {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
availability, err := api.getUserChatProviderAvailability(ctx, userID)
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 (api *API) validateUserChatModelConfigAvailable(
ctx context.Context,
userID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, int, *codersdk.Response) {
modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, modelConfigID)
if err != nil {
return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
}
}
switch reason {
case chatModelConfigAvailable:
return modelConfig, 0, nil
case chatModelConfigUnavailableModelNotFoundOrDisabled:
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:
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 database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
}
// 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,
modelConfigID uuid.UUID,
) (int, *codersdk.Response) {
if modelConfigID == uuid.Nil {
return 0, nil
}
_, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, modelConfigID)
return status, resp
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats [post]
// @Description Experimental: this endpoint is subject to change.
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, int64(2*maxSystemPromptLenBytes), &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()
// Validate organization membership.
if req.OrganizationID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "organization_id is required.",
})
return
}
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.
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).InOrg(req.OrganizationID)) {
httpapi.Forbidden(rw)
return
}
// Validate per-chat system prompt length.
const maxSystemPromptLen = 10000
if len(req.SystemPrompt) > maxSystemPromptLen {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "System prompt exceeds maximum length.",
Detail: fmt.Sprintf("System prompt must be at most %d characters, got %d.", maxSystemPromptLen, len(req.SystemPrompt)),
})
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(ctx, r, req)
if validationError != nil {
httpapi.Write(ctx, rw, validationStatus, *validationError)
return
}
title := chatprompt.FallbackTitle(titleSource)
modelConfigID, personalOverrideEffort, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, apiKey.UserID, 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
}
// Validate MCP server IDs exist.
if len(req.MCPServerIDs) > 0 {
//nolint:gocritic // Need to validate MCP server IDs exist.
existingConfigs, err := api.Database.GetMCPServerConfigsByIDs(dbauthz.AsSystemRestricted(ctx), req.MCPServerIDs)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate MCP server IDs.",
Detail: err.Error(),
})
return
}
if len(existingConfigs) != len(req.MCPServerIDs) {
found := make(map[uuid.UUID]struct{}, len(existingConfigs))
for _, c := range existingConfigs {
found[c.ID] = struct{}{}
}
var missing []string
for _, id := range req.MCPServerIDs {
if _, ok := found[id]; !ok {
missing = append(missing, id.String())
}
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "One or more MCP server IDs are invalid.",
Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(missing, ", ")),
})
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) > 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(ctx, chatd.CreateOptions{
OrganizationID: req.OrganizationID,
OwnerID: 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,
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 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(ctx, 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.
api.chatDaemon.GenerateChatTitleAsync(ctx, chat)
chatFiles := api.fetchChatFileMetadata(ctx, chat.ID)
response := db2sdk.Chat(chat, nil, chatFiles)
httpapi.Write(ctx, rw, http.StatusCreated, response)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary List chat models
// @ID list-chat-models
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatModelsResponse
// @Router /api/experimental/chats/models [get]
// @Description Experimental: this endpoint is subject to change.
func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to load chat model configuration.",
Detail: err.Error(),
})
return
}
catalog := chatprovider.NewModelCatalog()
var response codersdk.ChatModelsResponse
if configured, ok := catalog.ListConfiguredModels(
availability.configuredProviders,
availability.configuredModels,
availability.providerStatus,
availability.enabledProviderNames,
); ok {
response = configured
} else {
response = catalog.ListConfiguredProviderAvailability(
availability.providerStatus,
availability.enabledProviderNames,
)
}
// Both catalog branches drop providers the harness cannot use, so
// attach them here for the empty state.
response.UnsupportedProviders = chatprovider.UnsupportedProviders(availability.configuredProviders)
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat} [get]
// @Description Experimental: this endpoint is subject to change.
//
//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
status, err := api.Database.GetChatDiffStatusByChatID(ctx, chat.ID)
switch {
case err == nil:
diffStatus = &status
case !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),
)
}
// 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)
}
enriched := []codersdk.Chat{sdkChat}
api.repairChatAgentIDs(ctx, enriched)
sdkChat = enriched[0]
httpapi.Write(ctx, rw, http.StatusOK, sdkChat)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/messages [get]
// @Description Experimental: this endpoint is subject to change.
//
//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,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/cost [get]
// @Description Experimental: this endpoint is subject to change.
// @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/experimental/chats/{chat}/prompts [get]
// @Description Experimental: this endpoint is subject to change.
// @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) {
ctx := r.Context()
if !chat.WorkspaceID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, 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, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchWorkspaceNotFoundMessage,
})
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
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/stream/git [get]
// @Description Experimental: this endpoint is subject to change.
//
//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 — 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/experimental/chats/{chat}/context [put]
// @Description Experimental: this endpoint is subject to change.
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, and pinned-chat ordering.
//
// @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/experimental/chats/{chat} [patch]
// @Description Experimental: this endpoint is subject to change.
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
}
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.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, r, 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
}
// 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
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/messages [post]
// @Description Experimental: this endpoint is subject to change.
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, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
Detail: inputError.Detail,
})
return
}
// Validate MCP server IDs exist.
if req.MCPServerIDs != nil && len(*req.MCPServerIDs) > 0 {
//nolint:gocritic // Need to validate MCP server IDs exist.
existingConfigs, err := api.Database.GetMCPServerConfigsByIDs(dbauthz.AsSystemRestricted(ctx), *req.MCPServerIDs)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate MCP server IDs.",
Detail: err.Error(),
})
return
}
if len(existingConfigs) != len(*req.MCPServerIDs) {
found := make(map[uuid.UUID]struct{}, len(existingConfigs))
for _, c := range existingConfigs {
found[c.ID] = struct{}{}
}
var missing []string
for _, id := range *req.MCPServerIDs {
if _, ok := found[id]; !ok {
missing = append(missing, id.String())
}
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "One or more MCP server IDs are invalid.",
Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(missing, ", ")),
})
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, 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
}
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,
},
)
if sendErr != nil {
if writeChatHookErr(ctx, rw, sendErr, "Chat message denied by lifecycle hook.") {
return
}
if 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) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No default chat model config is configured.",
})
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
}
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/messages/{message} [patch]
// @Description Experimental: this endpoint is subject to change.
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, 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, 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
}
editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
CreatedBy: apiKey.UserID,
EditedMessageID: messageID,
Content: contentBlocks,
ModelConfigID: editModelConfigID,
ReasoningEffort: editReasoningEffort,
})
if editErr != nil {
if writeChatHookErr(ctx, rw, editErr, "Chat message denied by lifecycle hook.") {
return
}
if 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):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No default chat model config is configured.",
})
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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,
CreatedBy: apiKey.UserID,
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 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) {
lastMsg, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chatID,
Role: database.ChatMessageRoleAssistant,
})
if errors.Is(err, sql.ErrNoRows) {
// No assistant messages yet, nothing to mark as read.
return
}
if err != nil {
api.Logger.Warn(ctx, "failed to get last assistant message for read marker",
slog.F("chat_id", chatID),
slog.Error(err),
)
return
}
err = api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chatID,
LastReadMessageID: lastMsg.ID,
})
if err != nil {
api.Logger.Warn(ctx, "failed to update chat last read message ID",
slog.F("chat_id", chatID),
slog.Error(err),
)
}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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)
// @Success 200 {object} codersdk.ChatStreamEvent
// @Router /api/experimental/chats/{chat}/stream [get]
// @Description Experimental: this endpoint is subject to change.
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
}
}
}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/interrupt [post]
// @Description Experimental: this endpoint is subject to change.
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))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/compact [post]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
// @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))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/reconcile-invalid [post]
// @Description Experimental: this endpoint is subject to change.
//
//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))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Regenerate chat title
// @ID regenerate-chat-title
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/experimental/chats/{chat}/title/regenerate [post]
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) regenerateChatTitle(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 regenerate 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 regenerate the title.",
})
return
}
updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No default chat model config is configured.",
})
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if maybeWriteChatUsageLimitError(ctx, rw, err) {
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to regenerate chat title.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil))
}
//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) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No default chat model config is configured.",
})
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if maybeWriteChatUsageLimitError(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})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/{chat}/diff [get]
// @Description Experimental: this endpoint is subject to change.
//
//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
if !found || !strings.EqualFold(strings.TrimSpace(status.Url.String), pullRequestURL) {
_, err := api.upsertChatDiffStatusReference(ctx, chat.ID, pullRequestURL, time.Now().UTC().Add(-time.Second))
if err != nil {
return result, 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) upsertChatDiffStatusReference(
ctx context.Context,
chatID uuid.UUID,
pullRequestURL string,
staleAt time.Time,
) (database.ChatDiffStatus, error) {
status, err := api.Database.UpsertChatDiffStatusReference(
ctx,
database.UpsertChatDiffStatusReferenceParams{
ChatID: chatID,
Url: sql.NullString{
String: pullRequestURL,
Valid: strings.TrimSpace(pullRequestURL) != "",
},
// Empty strings preserve existing values via the
// CASE expression in the SQL query.
GitBranch: "",
GitRemoteOrigin: "",
StaleAt: staleAt,
},
)
if err != nil {
return database.ChatDiffStatus{}, xerrors.Errorf("upsert chat diff status reference: %w", err)
}
return status, nil
}
func (api *API) getCachedChatDiffStatus(
ctx context.Context,
chatID uuid.UUID,
) (database.ChatDiffStatus, bool, error) {
status, err := api.Database.GetChatDiffStatusByChatID(ctx, chatID)
if err == nil {
return status, true, nil
}
if xerrors.Is(err, sql.ErrNoRows) {
return database.ChatDiffStatus{}, false, nil
}
return database.ChatDiffStatus{}, false, xerrors.Errorf(
"get chat diff status: %w",
err,
)
}
// 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 ptr.Ref(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 — the existing token may still work
// (e.g. GitHub tokens with no expiry).
} else {
link = refreshed
}
}
token := strings.TrimSpace(link.OAuthAccessToken)
if token != "" {
return ptr.Ref(token), nil
}
}
return nil, gitsync.ErrNoTokenAvailable
}
type createChatWorkspaceSelection struct {
WorkspaceID uuid.NullUUID
}
func (api *API) validateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
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.Authorize(r, 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,
r *http.Request,
req codersdk.CreateChatRequest,
) (
createChatWorkspaceSelection,
int,
*codersdk.Response,
) {
selection := createChatWorkspaceSelection{}
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, r, 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.ModelConfigID); resp != nil {
return uuid.Nil, nil, status, resp
}
return *req.ModelConfigID, nil, 0, nil
}
personalOverridesEnabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
if !personalOverridesEnabled {
id, status, resp := api.defaultCreateChatModelConfigID(ctx)
return id, nil, status, resp
}
raw, err := api.Database.GetUserChatPersonalModelOverride(ctx, database.GetUserChatPersonalModelOverrideParams{
UserID: userID,
Key: chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot),
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
if err == nil {
parsed := parseChatPersonalModelOverrideValue(
raw,
codersdk.ChatPersonalModelOverrideContextRoot,
)
if parsed.Malformed {
api.Logger.Debug(
ctx,
"unsupported personal root model override mode, using default model",
slog.F("user_id", userID),
slog.F("raw_value", raw),
)
}
switch parsed.Mode {
case codersdk.ChatPersonalModelOverrideModeChatDefault:
// For root context, chat_default and the defensive default
// case both fall through to the deployment default model below.
case codersdk.ChatPersonalModelOverrideModeModel:
_, reason, err := api.userCanUseChatModelConfig(
ctx,
userID,
parsed.ModelConfigID,
)
if err != nil {
return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
if reason == chatModelConfigAvailable {
return parsed.ModelConfigID, parsed.ReasoningEffort, 0, nil
}
api.Logger.Debug(
ctx,
"personal root model override is unavailable, using default model",
slog.F("user_id", userID),
slog.F("model_config_id", parsed.ModelConfigID),
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", parsed.Mode),
)
}
}
id, status, resp := api.defaultCreateChatModelConfigID(ctx)
return id, nil, status, resp
}
func (api *API) defaultCreateChatModelConfigID(
ctx context.Context,
) (uuid.UUID, int, *codersdk.Response) {
defaultModelConfig, err := api.Database.GetDefaultChatModelConfig(ctx)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return uuid.Nil, http.StatusBadRequest, &codersdk.Response{
Message: "No default chat model config is configured.",
}
}
return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
// The resolved default may itself be disabled or under a disabled
// provider.
if _, err := lookupEnabledChatModelConfigByID(ctx, api.Database, defaultModelConfig.ID); err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return uuid.Nil, http.StatusBadRequest, &codersdk.Response{
Message: "No default chat model config is configured.",
Detail: "The default chat model or its provider is disabled.",
}
}
return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
return defaultModelConfig.ID, 0, nil
}
func normalizeChatCompressionThreshold(
requested *int32,
fallback int32,
) (int32, error) {
threshold := fallback
if requested != nil {
threshold = *requested
}
if threshold < minChatContextCompressionThreshold ||
threshold > maxChatContextCompressionThreshold {
return 0, xerrors.Errorf(
"context_compression_threshold must be between %d and %d",
minChatContextCompressionThreshold,
maxChatContextCompressionThreshold,
)
}
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
}
//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, so a short bound cannot strand a waiter:
// on expiry the request falls back to the unaudited write path instead of
// hanging until the client deadline.
const chatInstructionSettingsLockTimeout = 5 * time.Second
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, int64(2*maxSystemPromptLenBytes), &req) {
return
}
sanitizedPrompt := chatd.SanitizePromptText(req.SystemPrompt)
// 128 KiB is generous for a system prompt while still
// preventing abuse or accidental pastes of large content.
if len(sanitizedPrompt) > maxSystemPromptLenBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "System prompt exceeds maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", maxSystemPromptLenBytes, len(sanitizedPrompt)),
})
return
}
var (
noChange bool
oldCaptured bool
oldReadErr error
lockErr error
)
// 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 {
lockErr = err
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
// Old capture is best effort: it runs after the lock but before
// the write, so abandoning the transaction on its error costs
// nothing and the handler writes directly below with no entry.
// The lock keeps the captured baseline serialized with the write.
oldConfig, oldErr := tx.GetChatSystemPromptConfig(ctx)
if oldErr != nil {
oldReadErr = oldErr
return oldErr
}
oldCaptured = true
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 {
auditSetupErr := lockErr
if auditSetupErr == nil {
auditSetupErr = oldReadErr
}
if auditSetupErr != nil {
// The audit-added lock wait or Old capture failed before
// anything was written, so the transaction was abandoned at
// no cost: write directly exactly as main does, emit no
// entry, and warn.
api.Logger.Warn(ctx, "audit change detection failed, writing chat system prompt without an audit entry",
slog.Error(auditSetupErr))
commitAudit(false)
if mainErr := api.Database.InTx(func(tx database.Store) error {
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
if req.IncludeDefaultSystemPrompt != nil {
return tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt)
}
return nil
}, nil); mainErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: mainErr.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
return
}
// This endpoint was transactional on main, so begin, write,
// commit and rollback errors all surface exactly as main's. The
// advisory lock is audit-added, so its failure falls back above
// instead of surfacing.
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: err.Error(),
})
return
}
if oldCaptured && 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)
}
// 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) 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,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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, int64(2*maxSystemPromptLenBytes), &req) {
return
}
sanitizedInstructions := chatd.SanitizePromptText(req.PlanModeInstructions)
if len(sanitizedInstructions) > maxSystemPromptLenBytes {
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.", maxSystemPromptLenBytes, len(sanitizedInstructions)),
})
return
}
// This endpoint was not transactional on main, so the audited
// transaction's lock, begin, commit and rollback failures run the
// direct write instead, matching main's non-transactional behavior.
// Only a failure of the write itself surfaces the transaction error
// (the raw write error, as main produced).
var (
noChange bool
oldCaptured bool
oldReadErr error
)
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
var writeErr error
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)
}
// Old capture is best effort: it runs after the lock but before
// the write, so abandoning the transaction on its error costs
// nothing and the handler writes directly below with no entry;
// see putChatSystemPrompt for why New is derived from the write.
oldInstructions, oldErr := tx.GetChatPlanModeInstructions(ctx)
if oldErr != nil {
oldReadErr = oldErr
return oldErr
}
oldCaptured = true
aReq.Old.PlanModeInstructions = oldInstructions
if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
writeErr = err
return err
}
aReq.New.PlanModeInstructions = sanitizedInstructions
noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions
return nil
}, nil)
if err != nil {
// Log the full InTx error first: the response below derives from
// the write error alone, so a rollback failure layered on it
// would otherwise vanish from response, audit row and logs
// simultaneously.
api.Logger.Warn(ctx, "plan mode instructions update transaction failed",
slog.Error(err))
if oldReadErr != nil {
// The Old capture failed before anything was written: fall
// through to the direct write below with no entry.
oldCaptured = false
} else if writeErr != nil {
// The write itself failed: respond with the raw error
// exactly as the endpoint did before the audit wiring
// existed.
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: writeErr.Error(),
})
return
}
// Only the audit-added transaction machinery failed (lock,
// begin, commit, rollback, or the Old read), not the write.
// Main's non-transactional behavior is authoritative: run the
// direct upsert and derive the response from that.
if mainErr := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); mainErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: mainErr.Error(),
})
return
}
if !oldCaptured {
// The lock wait or Old capture failed, so the write landed
// through the direct path with no baseline: no entry, warn,
// and finish with the success response. A commit failure
// keeps oldCaptured true and deliberately falls through so
// the attempt row with its real diff survives.
api.Logger.Warn(ctx, "audit change detection failed, writing plan mode instructions without an audit entry",
slog.Error(err))
commitAudit(false)
rw.WriteHeader(http.StatusNoContent)
return
}
}
if oldCaptured && 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
}
// 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) getChatModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
overrideContext, ok := readChatModelOverrideContext(rw, r)
if !ok {
return
}
modelConfigID, reasoningEffort, isMalformed, label, err := api.readChatModelOverrideConfig(ctx, overrideContext)
if err != nil {
if label == "" {
label = string(overrideContext)
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Internal error fetching %s model override.", label),
Detail: err.Error(),
})
return
}
resp := codersdk.ChatModelOverrideResponse{
Context: overrideContext,
ModelConfigID: formatChatModelOverride(modelConfigID, nil),
ReasoningEffort: reasoningEffort,
IsMalformed: isMalformed,
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
overrideContext, ok := readChatModelOverrideContext(rw, r)
if !ok {
return
}
var req codersdk.UpdateChatModelOverrideRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
var modelConfigID *uuid.UUID
trimmedModelConfigID := strings.TrimSpace(req.ModelConfigID)
if trimmedModelConfigID != "" {
if strings.Contains(trimmedModelConfigID, ":") {
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
}
parsedModelConfigID, 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
}
modelConfigID = &parsedModelConfigID
}
status, resp := validateChatModelOverride(ctx, api.Database, modelConfigID, req.ReasoningEffort)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
label, err := api.upsertChatModelOverrideConfig(ctx, overrideContext, modelConfigID, req.ReasoningEffort)
if err != nil {
if label == "" {
label = string(overrideContext)
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Internal error updating %s model override.", label),
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
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
}
// 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) 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,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := api.Database.UpsertChatPersonalModelOverridesEnabled(ctx, req.AllowUsers); 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)
}
// 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) getUserChatPersonalModelOverrides(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(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.ListUserChatPersonalModelOverrides(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user personal model overrides.",
Detail: err.Error(),
})
return
}
values := make(map[codersdk.ChatPersonalModelOverrideContext]string, len(rows))
for _, row := range rows {
rawContext, ok := strings.CutPrefix(row.Key, chatd.ChatPersonalModelOverrideKeyPrefix)
if !ok {
continue
}
overrideContext, ok := parseChatPersonalModelOverrideContext(rawContext)
if !ok {
continue
}
values[overrideContext] = row.Value
}
deploymentDefaults, err := api.chatPersonalModelOverrideDeploymentDefaults(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching deployment model defaults.",
Detail: err.Error(),
})
return
}
response := codersdk.UserChatPersonalModelOverridesResponse{
Enabled: enabled,
DeploymentDefaults: deploymentDefaults,
}
for _, overrideContext := range chatPersonalModelOverrideContexts {
raw, isSet := values[overrideContext]
override := chatPersonalModelOverrideResponse(overrideContext, raw, isSet)
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(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 {
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 := ""
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 {
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
}
if parsedModelConfigID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id.",
})
return
}
modelConfig, status, resp := api.validateUserChatModelConfigAvailable(ctx, apiKey.UserID, parsedModelConfigID)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
status, resp = validateChatModelOverrideEffort(modelConfig, reasoningEffort)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
modelConfigID = parsedModelConfigID.String()
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid personal model override mode.",
})
return
}
if err := api.Database.UpsertUserChatPersonalModelOverride(ctx, database.UpsertUserChatPersonalModelOverrideParams{
UserID: apiKey.UserID,
Key: chatd.ChatPersonalModelOverrideKey(overrideContext),
Value: formatChatPersonalModelOverrideValue(req.Mode, modelConfigID, reasoningEffort),
}); err != nil {
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()
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
}
if err := api.Database.UpsertChatComputerUseProvider(ctx, string(req.Provider)); 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()
}
// 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) 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(),
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatDebugLoggingAllowUsersRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := api.Database.UpsertChatDebugLoggingAllowUsers(ctx, req.AllowUsers); 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)
}
// 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) 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,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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)
if resp.ModelConfigID == uuid.Nil {
resp.ReasoningEffort = nil
}
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.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
}
if req.ModelConfigID == uuid.Nil {
if req.ReasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "reasoning_effort requires model_config_id.",
})
return
}
} else {
modelConfig, err := lookupEnabledChatModelConfigByID(ctx, api.Database, req.ModelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("model_config_id %q does not match any enabled model config.", req.ModelConfigID),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error validating advisor model config.",
Detail: err.Error(),
})
return
}
if status, response := validateChatModelOverrideEffort(modelConfig, req.ReasoningEffort); response != nil {
httpapi.Write(ctx, rw, status, *response)
return
}
}
raw, err := json.Marshal(req)
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)
}
// 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) 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(),
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
// Store the canonicalized duration string.
if err := api.Database.UpsertChatWorkspaceTTL(ctx, d.String()); 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/experimental/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/experimental/chats/config/retention-days [put]
// @x-apidocgen {"skip": true}
func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
if err := api.Database.UpsertChatRetentionDays(ctx, req.RetentionDays); 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.
//
//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.
func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
if err := api.Database.UpsertChatDebugRetentionDays(ctx, req.DebugRetentionDays); 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.
//
//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.
func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
if err := api.Database.UpsertChatAutoArchiveDays(ctx, req.AutoArchiveDays); 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)
}
// 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) 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,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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, int64(2*maxSystemPromptLenBytes), &params) {
return
}
sanitizedPrompt := chatd.SanitizePromptText(params.CustomPrompt)
// Apply the same 128 KiB limit as the deployment system prompt.
if len(sanitizedPrompt) > maxSystemPromptLenBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Custom prompt exceeds maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", maxSystemPromptLenBytes, 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
// @x-apidocgen {"skip": true}
// 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) 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 Set user chat compaction threshold for a model config
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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
}
// Use system context because GetChatModelConfigByID requires
// deployment-config read access, which non-admin users lack.
// The user is only checking if the model exists and is enabled
// before writing their own personal preference.
//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 for a model config
// @x-apidocgen {"skip": true}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Upload chat file
// @ID upload-chat-file
// @Security CoderSessionToken
// @Tags Chats
// @Accept image/png,image/jpeg,image/gif,image/webp,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Produce json
// @Param organization query string true "Organization ID" format(uuid)
// @Success 201 {object} codersdk.UploadChatFileResponse
// @Failure 413 {object} codersdk.Response "Request body exceeds 10 MiB"
// @Router /api/experimental/chats/files [post]
// @Description Experimental: this endpoint is subject to change.
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.
var filename string
if cd := r.Header.Get("Content-Disposition"); cd != "" {
if _, params, err := mime.ParseMediaType(cd); err == nil {
filename = params["filename"]
}
}
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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/experimental/chats/files/{file}/download-url [post]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
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", "experimental", "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,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Download chat file with signed token
// @ID download-chat-file
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,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/experimental/chats/files/{file}/download [get]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
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)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Get chat file
// @ID get-chat-file
// @Security CoderSessionToken
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Param file path string true "File ID" format(uuid)
// @Success 200
// @Router /api/experimental/chats/files/{file} [get]
// @Description Experimental: this endpoint is subject to change.
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))
}
}
func createChatInputFromRequest(ctx context.Context, db database.Store, req codersdk.CreateChatRequest) (
[]codersdk.ChatMessagePart,
string,
*codersdk.Response,
) {
content, pasteData, inputError := createChatInputFromParts(ctx, db, 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,
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))
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
}
func 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 chat can reference at most %d attachments. Remove some attachments or start a new chat.", codersdk.MaxChatFileIDs),
})
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,
}
}
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)
}
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 {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is disabled."})
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,
})
}
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)
}
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
}
}
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,
}
}
func writeLegacyChatProviderGone(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusGone, codersdk.Response{
Message: "Legacy chat provider APIs were removed. Use AI provider APIs instead.",
Detail: "See https://coder.com/docs/ai-coder/agents/models#providers for AI provider configuration.",
})
}
func (*API) listChatProviders(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) createChatProvider(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) updateChatProvider(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) deleteChatProvider(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) listUserChatProviderConfigs(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) upsertUserChatProviderKey(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (*API) deleteUserChatProviderKey(rw http.ResponseWriter, r *http.Request) {
writeLegacyChatProviderGone(rw, r)
}
func (api *API) listChatModelConfigs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Admin users can see all model configs (including disabled ones)
// for management purposes. Non-admin users see only enabled
// configs, which is sufficient for using the chat feature.
isAdmin := api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig)
var configs []database.ChatModelConfig
var err error
if isAdmin {
configs, err = api.Database.GetChatModelConfigs(ctx)
} else {
//nolint:gocritic // All authenticated users need to read enabled model configs to use the chat feature.
rows, rowsErr := api.Database.GetEnabledChatModelConfigs(dbauthz.AsChatd(ctx))
err = rowsErr
configs = make([]database.ChatModelConfig, 0, len(rows))
for _, row := range rows {
configs = append(configs, row.ChatModelConfig)
}
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chat model configs.",
Detail: err.Error(),
})
return
}
resp := make([]codersdk.ChatModelConfig, 0, len(configs))
for _, config := range configs {
resp = append(resp, convertChatModelConfig(config))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
type chatModelConfigProviderModelError struct {
Response codersdk.Response
}
func (e *chatModelConfigProviderModelError) Error() string {
return e.Response.Message
}
func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model string) *chatModelConfigProviderModelError {
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. 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 deployment both self-promoting to default and
// violating the idx_chat_model_configs_single_default unique index.
func (api *API) inChatModelConfigWriteTx(ctx context.Context, fn func(tx database.Store) error) error {
return api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(ctx, database.LockIDChatModelConfigWrites); err != nil {
return xerrors.Errorf("acquire chat model config write lock: %w", err)
}
return fn(tx)
}, nil)
}
func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req codersdk.CreateChatModelConfigRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.AIProviderID == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "AI provider ID is required."})
return
}
//nolint:gocritic // The route already authorized chat model config updates.
aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is not configured."})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get AI provider.",
Detail: err.Error(),
})
return
}
if !aiProvider.Enabled {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is disabled."})
return
}
aiProviderID := uuid.NullUUID{UUID: aiProvider.ID, Valid: true}
model := strings.TrimSpace(req.Model)
if model == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Model is required.",
})
return
}
if validationErr := validateChatModelConfigProviderModel(aiProvider, model); validationErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, validationErr.Response)
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,
defaultChatContextCompressionThreshold,
)
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
}
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},
}
var inserted database.ChatModelConfig
err = api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error {
//nolint:gocritic // The route already authorized chat model config updates.
lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatProviderNotConfigured
}
return xerrors.Errorf("get AI provider for update: %w", err)
}
if !lockedAIProvider.Enabled {
return errChatProviderNotConfigured
}
if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil {
return err
}
insertAsDefault := isDefault
if !insertAsDefault {
_, err := tx.GetDefaultChatModelConfig(ctx)
switch {
case err == nil:
// A default already exists.
case xerrors.Is(err, sql.ErrNoRows):
insertAsDefault = true
default:
return xerrors.Errorf("get default model config: %w", err)
}
}
if insertAsDefault {
if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil {
return xerrors.Errorf("unset default model configs: %w", err)
}
}
insertParams.IsDefault = insertAsDefault
config, err := tx.InsertChatModelConfig(ctx, insertParams)
if err != nil {
return err
}
inserted = config
if err := ensureDefaultChatModelConfig(ctx, tx); err != nil {
return err
}
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 {
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 xerrors.Is(err, errChatProviderNotConfigured):
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Chat provider is not configured.",
Detail: err.Error(),
})
return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat model config.",
Detail: err.Error(),
})
return
}
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, inserted.ID)
httpapi.Write(ctx, rw, http.StatusCreated, convertChatModelConfig(inserted))
}
func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
modelConfigID, ok := parseChatModelConfigID(rw, r)
if !ok {
return
}
existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID)
if err != nil {
if 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
}
var req codersdk.UpdateChatModelConfigRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
aiProviderID := existing.AIProviderID
if req.AIProviderID != nil {
//nolint:gocritic // The route already authorized chat model config updates.
aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is not configured."})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get AI provider.",
Detail: err.Error(),
})
return
}
if !aiProvider.Enabled {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is disabled."})
return
}
aiProviderID = uuid.NullUUID{UUID: aiProvider.ID, Valid: true}
}
model := existing.Model
if trimmed := strings.TrimSpace(req.Model); trimmed != "" {
model = trimmed
}
displayName := existing.DisplayName
if trimmed := strings.TrimSpace(req.DisplayName); trimmed != "" {
displayName = trimmed
}
enabled := existing.Enabled
if req.Enabled != nil {
enabled = *req.Enabled
}
isDefault := existing.IsDefault
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
contextLimit := existing.ContextLimit
if req.ContextLimit != nil {
if *req.ContextLimit <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Context limit must be greater than zero.",
})
return
}
contextLimit = *req.ContextLimit
}
compressionThreshold, thresholdErr := normalizeChatCompressionThreshold(
req.CompressionThreshold,
existing.CompressionThreshold,
)
if thresholdErr != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid compression threshold.",
Detail: thresholdErr.Error(),
})
return
}
modelConfigRaw := existing.Options
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
}
modelConfigRaw = encodedModelConfig
}
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: existing.ID,
}
// Re-derive the provider type under lock when the model or provider changes.
revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "")
var updated database.ChatModelConfig
err = api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error {
if revalidateProviderModel {
//nolint:gocritic // The route already authorized chat model config updates.
aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatProviderNotConfigured
}
return xerrors.Errorf("get AI provider for update: %w", err)
}
if !aiProvider.Enabled {
return errChatProviderNotConfigured
}
if err := validateChatModelConfigProviderModel(aiProvider, updateParams.Model); err != nil {
return err
}
}
setAsDefault := updateParams.IsDefault && !existing.IsDefault
if setAsDefault {
if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil {
return xerrors.Errorf("unset default model configs: %w", err)
}
}
_, err := tx.UpdateChatModelConfig(ctx, updateParams)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return errChatModelConfigNotFound
}
return err
}
excludeConfigID := uuid.Nil
if existing.IsDefault && req.IsDefault != nil && !*req.IsDefault {
excludeConfigID = existing.ID
}
if err := ensureDefaultChatModelConfig(
ctx,
tx,
excludeConfigID,
); err != nil {
return err
}
refreshedConfig, err := tx.GetChatModelConfigByID(ctx, existing.ID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
// Do not wrap with %w. The outer handler maps target misses to 404.
return xerrors.Errorf("refresh updated chat model config: %v", err)
}
return xerrors.Errorf("refresh updated chat model config: %w", err)
}
updated = refreshedConfig
return nil
})
if err != nil {
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 xerrors.Is(err, errChatProviderNotConfigured):
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Chat provider is not configured.",
Detail: err.Error(),
})
return
case xerrors.Is(err, errChatModelConfigNotFound):
httpapi.ResourceNotFound(rw)
return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat model config.",
Detail: err.Error(),
})
return
}
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, updated.ID)
httpapi.Write(ctx, rw, http.StatusOK, convertChatModelConfig(updated))
}
func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
modelConfigID, ok := parseChatModelConfigID(rw, r)
if !ok {
return
}
if _, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID); err != nil {
if 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 err := api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error {
if err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil {
return err
}
return ensureDefaultChatModelConfig(ctx, tx)
}); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete chat model config.",
Detail: err.Error(),
})
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventModelConfig, modelConfigID)
rw.WriteHeader(http.StatusNoContent)
}
func ensureDefaultChatModelConfig(
ctx context.Context,
tx database.Store,
excludedConfigIDs ...uuid.UUID,
) error {
_, err := tx.GetDefaultChatModelConfig(ctx)
switch {
case err == nil:
return nil
case !xerrors.Is(err, sql.ErrNoRows):
return xerrors.Errorf("get default model config: %w", err)
}
modelConfigs, err := tx.GetChatModelConfigs(ctx)
if err != nil {
return xerrors.Errorf("list chat model configs: %w", err)
}
if len(modelConfigs) == 0 {
return nil
}
// 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.
//nolint:gocritic // Candidate usability depends on deployment-wide provider state, not the caller's permissions.
enabledRows, err := tx.GetEnabledChatModelConfigs(dbauthz.AsChatd(ctx))
if err != nil {
return 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{}{}
}
candidateConfig := modelConfigs[0]
var selected *database.ChatModelConfig
for i := range modelConfigs {
config := &modelConfigs[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 {
candidateConfig = *selected
}
if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil {
return xerrors.Errorf("unset default model configs: %w", err)
}
params := chatModelConfigToUpdateParams(candidateConfig)
params.IsDefault = true
if _, err := tx.UpdateChatModelConfig(ctx, params); 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 xerrors.Errorf("set default model config: %v", err)
}
return xerrors.Errorf("set default model config: %w", err)
}
return 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.ChatModelConfig {
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.ChatModelConfig{
ID: config.ID,
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 || options.Anthropic == nil || options.Anthropic.ThinkingDisplay == nil {
return nil
}
if strings.TrimSpace(*options.Anthropic.ThinkingDisplay) == "" ||
chatprovider.AnthropicThinkingDisplayFromChat(options.Anthropic.ThinkingDisplay) != nil {
return nil
}
return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted")
}
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
}
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
}
var (
errChatModelConfigNotFound = xerrors.New("chat model config not found")
errChatProviderNotConfigured = xerrors.New("chat provider is not configured")
)
// ChatProviderAPIKeysFromDeploymentValues returns deployment-backed chat
// provider API keys.
func ChatProviderAPIKeysFromDeploymentValues(
_ *codersdk.DeploymentValues,
) chatprovider.ProviderAPIKeys {
// AI bridge deployment config is intentionally not reused for chat
// provider credentials. Bridge keys serve the AI task subsystem and
// should not silently broaden into chat execution paths.
return chatprovider.ProviderAPIKeys{}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//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
}
// Cap the raw request body to prevent excessive memory use.
var req codersdk.SubmitToolResultsRequest
if !httpapi.ReadLimit(ctx, rw, r, int64(2*maxSystemPromptLenBytes), &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.
var dynamicTools json.RawMessage
if chat.DynamicTools.Valid {
dynamicTools = chat.DynamicTools.RawMessage
}
err := api.chatDaemon.SubmitToolResults(ctx, chatd.SubmitToolResultsOptions{
ChatID: chat.ID,
UserID: apiKey.UserID,
ModelConfigID: chat.LastModelConfigID,
Results: req.Results,
DynamicTools: dynamicTools,
})
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))
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @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 {object} codersdk.ChatStreamEvent
// @Router /api/experimental/chats/{chat}/stream/parts [get]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
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))
}
}