coderd/exp_chats.go
Rama main · réplica de lectura del repositorio original.
package coderd
import (
"cmp"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"path"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpapi/httperror"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/coderd/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/coderd/x/agenthooks/dispatch"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/agentselect"
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
"github.com/coder/coder/v2/coderd/x/chatd/chathooks"
"github.com/coder/coder/v2/coderd/x/chatd/chatopenai"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatfiles"
"github.com/coder/coder/v2/coderd/x/gitsync"
"github.com/coder/coder/v2/coderd/x/skills"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/quartz"
"github.com/coder/websocket"
)
const (
chatStreamBatchSize = 256
// maxChatRequestBodyBytes is the maximum JSON body size for chat
// creation and tool result requests.
maxChatRequestBodyBytes = 256 * 1024
defaultChatContextCompressionThreshold = int32(70)
// Large-context models (1M) compact earlier; 70% of a 1M window
// carries too much stale context.
largeContextDefaultCompressionThreshold = int32(30)
largeContextLimitTokens = int64(500_000)
minChatContextCompressionThreshold = int32(0)
maxChatContextCompressionThreshold = int32(100)
)
// defaultCompressionThresholdForContextLimit returns the compaction
// threshold used when compression_threshold is omitted at creation.
func defaultCompressionThresholdForContextLimit(contextLimit int64) int32 {
if contextLimit >= largeContextLimitTokens {
return largeContextDefaultCompressionThreshold
}
return defaultChatContextCompressionThreshold
}
var allowedReasoningEffortValues = strings.Join(codersdk.ChatModelReasoningEffortValues(), ", ")
// chatGitRef holds the branch, remote origin, and optional chat
// ID reported by the workspace agent during a git operation.
type chatGitRef struct {
Branch string
RemoteOrigin string
ChatID uuid.UUID
}
type chatRepositoryRef struct {
Provider string
RemoteOrigin string
Branch string
Owner string
Repo string
}
type chatDiffReference struct {
PullRequestURL string
RepositoryRef *chatRepositoryRef
}
func writeChatError(ctx context.Context, rw http.ResponseWriter, err error) {
responseErr, ok := httperror.IsResponder(err)
if !ok {
httperror.WriteResponseError(ctx, rw, err)
return
}
status, resp := responseErr.Response()
var (
denied *chathooks.UserPromptDeniedError
dispatchErr *dispatch.Error
)
switch {
case errors.As(err, &denied):
httpapi.Write(ctx, rw, status, codersdk.ChatHookDeniedResponse{
Response: resp,
Kind: codersdk.ChatErrorKindHookDenied,
})
case errors.As(err, &dispatchErr):
httpapi.Write(ctx, rw, status, codersdk.ChatHookDispatchFailedResponse{
Response: resp,
Kind: codersdk.ChatErrorKindHookDispatchFailed,
})
default:
httperror.WriteResponseError(ctx, rw, err)
}
}
func maybeWriteChatError(ctx context.Context, rw http.ResponseWriter, err error) bool {
if err == nil {
return false
}
writeChatError(ctx, rw, err)
return true
}
// Avoid returning raw dispatch errors, which may expose deployment internals.
func chatHookDispatchFailedError(err error) error {
hookErr, ok := errors.AsType[*dispatch.Error](err)
if !ok {
return nil
}
return httperror.WrapResponseError(http.StatusBadGateway, codersdk.Response{
Message: "Chat lifecycle hook dispatch failed.",
Detail: fmt.Sprintf("Lifecycle hook dispatch %s failed (%s).", hookErr.DispatchID, hookErr.Class),
}, err)
}
func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, hookErr *dispatch.Error) {
writeChatError(ctx, rw, chatHookDispatchFailedError(hookErr))
}
func chatHookError(err error, deniedFallback string) error {
if denied, ok := errors.AsType[*chathooks.UserPromptDeniedError](err); ok {
return httperror.WrapResponseError(http.StatusForbidden, codersdk.Response{
Message: cmp.Or(denied.UserMessage, deniedFallback),
}, err)
}
return chatHookDispatchFailedError(err)
}
func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, deniedFallback string) bool {
return maybeWriteChatError(ctx, rw, chatHookError(err, deniedFallback))
}
// AI Gateway budget rejections and provider quota failures classify as usage
// limits; synchronous generation reports them as conflicts instead of 500s.
func maybeWriteChatUsageLimitError(ctx context.Context, rw http.ResponseWriter, err error) bool {
classified := chaterror.Classify(err)
if classified.Kind != codersdk.ChatErrorKindUsageLimit {
return false
}
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: classified.Message,
})
return true
}
// statusClientClosedRequest is nginx's non-standard 499 status code,
// used here to distinguish a client-initiated cancel from a server-
// side failure when the manual title generation context is canceled.
const statusClientClosedRequest = 499
// maybeWriteManualTitleTimeoutErr translates context-cancel or
// title-timeout errors from the manual title pipeline into friendly
// 499/504 responses instead of a raw 500 that leaks the wrapped error
// chain. The errors bubble up wrapped, so match with errors.Is. Returns
// true when a response was written.
//
// The 499 branch additionally requires the request context itself to be
// canceled. A provider error can wrap context.Canceled (for example an
// upstream 401) while the caller context is still active; without the
// ctx.Err() guard such a provider failure would be misreported as a
// client-closed request instead of surfacing through the 500 path.
//
// The 504 branch keys off chatd.ErrManualTitleTimedOut, which chatd
// attaches only when the title-generation deadline actually expired. A
// provider failure whose chain merely contains an unrelated transport
// deadline is not tagged and keeps its provider-failure surface.
func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled):
httpapi.Write(ctx, rw, statusClientClosedRequest, codersdk.Response{
Message: "Title generation was canceled.",
})
return true
case errors.Is(err, chatd.ErrManualTitleTimedOut):
httpapi.Write(ctx, rw, http.StatusGatewayTimeout, codersdk.Response{
Message: "Title generation timed out. Try again or rename manually.",
})
return true
}
return false
}
var chatDaemonUnavailableResponse = 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.",
}
// 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 {
return !maybeWriteChatError(ctx, rw, api.chatDaemonError())
}
func (api *API) chatDaemonError() error {
if api.chatDaemon != nil {
return nil
}
return httperror.NewResponseError(http.StatusServiceUnavailable, chatDaemonUnavailableResponse)
}
// ChatDaemon returns the configured chat daemon, or nil if none is configured.
func (api *API) ChatDaemon() *chatd.Server {
return api.chatDaemon
}
func publishChatConfigEvent(logger slog.Logger, ps dbpubsub.Pubsub, kind pubsub.ChatConfigEventKind, entityID uuid.UUID) {
payload, err := json.Marshal(pubsub.ChatConfigEvent{
Kind: kind,
EntityID: entityID,
})
if err != nil {
logger.Error(context.Background(), "failed to marshal chat config event",
slog.F("kind", kind),
slog.F("entity_id", entityID),
slog.Error(err),
)
return
}
if err := ps.Publish(pubsub.ChatConfigEventChannel, payload); err != nil {
logger.Error(context.Background(), "failed to publish chat config event",
slog.F("kind", kind),
slog.F("entity_id", entityID),
slog.Error(err),
)
}
}
// @Summary Watch chat events for a user via WebSockets
// @ID watch-chat-events-for-a-user-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatWatchEvent
// @Router /api/v2/chats/watch [get]
func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
logger := api.Logger.Named("chat_watcher")
// Subscribe before accepting the websocket so the subscription
// is active when the client's Dial returns.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var (
encoder *json.Encoder
encoderReady = make(chan struct{})
// Capture before WebsocketNetConn reassigns ctx (data race).
ctxDone = ctx.Done()
)
cancelSubscribe, err := api.Pubsub.SubscribeWithErr(pubsub.ChatWatchEventChannel(apiKey.UserID),
pubsub.HandleChatWatchEvent(
func(cbCtx context.Context, payload codersdk.ChatWatchEvent, err error) {
if err != nil {
logger.Error(cbCtx, "chat watch event subscription error", slog.Error(err))
return
}
select {
case <-encoderReady:
case <-ctxDone:
return
case <-cbCtx.Done():
return
}
// encoderReady may close with encoder still nil on error paths.
if encoder == nil {
return
}
// The encoder is only written from the pubsub delivery
// goroutine, which processes messages serially. Do not
// add a second write path without synchronization.
if err := encoder.Encode(payload); err != nil {
logger.Debug(cbCtx, "failed to send chat watch event", slog.Error(err))
cancel()
return
}
},
))
if err != nil {
close(encoderReady)
logger.Error(ctx, "failed to subscribe to chat watch events", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to subscribe to chat events.",
Detail: err.Error(),
})
return
}
defer cancelSubscribe()
conn, err := websocket.Accept(rw, r, nil)
if err != nil {
close(encoderReady)
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to open chat watch stream.",
Detail: err.Error(),
})
return
}
_ = conn.CloseRead(context.Background())
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
defer wsNetConn.Close()
ctx = api.wsWatcher.Watch(ctx, logger, conn)
encoder = json.NewEncoder(wsNetConn)
close(encoderReady)
<-ctx.Done()
}
// the latest non-archived chat ID for each requested workspace.
// The query returns all matching chats and RBAC post-filters them;
// the handler then picks the latest per workspace in Go. This avoids
// the DISTINCT ON + post-filter bug where the sole candidate is
// silently dropped when the caller can't read it.
//
// TODO: move aggregation to a SQL view with proper in-query authz so the
// handler can return a single row per workspace without this two-pass approach.
type chatsByWorkspaceResponse map[uuid.UUID]uuid.UUID
// @Summary List chats by workspace
// @ID list-chats-by-workspace
// @Security CoderSessionToken
// @Tags Chats
// @Param workspace_ids query string false "Comma-separated workspace IDs"
// @Produce json
// @Success 200 {object} chatsByWorkspaceResponse
// @Router /api/v2/chats/by-workspace [get]
func (api *API) chatsByWorkspace(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
idsParam := r.URL.Query().Get("workspace_ids")
if idsParam == "" {
httpapi.Write(ctx, rw, http.StatusOK, chatsByWorkspaceResponse{})
return
}
raw := strings.Split(idsParam, ",")
// maxWorkspaceIDs is coupled to DEFAULT_RECORDS_PER_PAGE (25) in
// site/src/components/PaginationWidget/utils.ts.
// If the page size changes, this limit should too.
const maxWorkspaceIDs = 25
if len(raw) > maxWorkspaceIDs {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Too many workspace IDs, maximum is %d.", maxWorkspaceIDs),
})
return
}
workspaceIDs := make([]uuid.UUID, 0, len(raw))
for _, s := range raw {
id, err := uuid.Parse(strings.TrimSpace(s))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid workspace ID %q: %s", s, err),
})
return
}
workspaceIDs = append(workspaceIDs, id)
}
chats, err := api.Database.GetChatsByWorkspaceIDs(ctx, workspaceIDs)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chats by workspace.",
Detail: err.Error(),
})
return
}
// The SQL orders by (workspace_id, updated_at DESC), so the first
// chat seen per workspace after RBAC filtering is the latest
// readable one.
result := make(chatsByWorkspaceResponse, len(chats))
for _, chat := range chats {
if chat.WorkspaceID.Valid {
if _, exists := result[chat.WorkspaceID.UUID]; !exists {
result[chat.WorkspaceID.UUID] = chat.ID
}
}
}
httpapi.Write(ctx, rw, http.StatusOK, result)
}
// @Summary List chats
// @ID list-chats
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param q query string false "Search query. Supports `title:<substring>` (case-insensitive, quote multi-word values), `archived:bool`, `has_unread:bool`, `status:<waiting\|running\|error\|requires_action\|interrupting\|paused>` (chat status, repeated or comma-separated), `pr_status:<draft\|open\|merged\|closed\|none>` (none matches chats with no pull request) as repeated or comma-separated values, `source:<created_by_me\|shared_with_me>`, `diff_url:<url>` (quote values containing colons), `pr:<number>` (exact PR number match), `repo:<owner/repo>` (case-insensitive substring match against git remote origin or URL), `pr_title:<text>` (case-insensitive PR title substring), `search:<text>` (full-text search across chat titles, PR titles, PR numbers, and message bodies; message bodies match English word stems, e.g. `refactor` matches `refactoring`, and ignore English stopwords; titles and PR titles match whole words case-insensitively without stemming; quote multi-word values; cannot be combined with title, pr_title, or pr; a value that tokenizes to no searchable words, e.g. punctuation only, returns an empty list). Bare terms are not supported; use `title:<value>` or `search:<value>`."
// @Param label query []string false "Filter by label as key:value. Repeat for multiple (AND logic)." collectionFormat(multi)
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Param project_id query string false "Only chats in this project." format(uuid)
// @Success 200 {array} codersdk.Chat
// @Router /api/v2/chats [get]
func (api *API) listChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
paginationParams, ok := ParsePagination(rw, r)
if !ok {
return
}
queryStr := r.URL.Query().Get("q")
searchParams, errs := searchquery.Chats(queryStr)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat search query.",
Validations: errs,
})
return
}
var labelFilter pqtype.NullRawMessage
if labelParams := r.URL.Query()["label"]; len(labelParams) > 0 {
labelMap := make(map[string]string, len(labelParams))
for _, lp := range labelParams {
key, value, ok := strings.Cut(lp, ":")
if !ok || key == "" || value == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid label filter: %q (expected format key:value, both must be non-empty)", lp),
})
return
}
labelMap[key] = value
}
labelsJSON, err := json.Marshal(labelMap)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal label filter.",
Detail: err.Error(),
})
return
}
labelFilter = pqtype.NullRawMessage{
RawMessage: labelsJSON,
Valid: true,
}
}
var sharedWithGroupIDs []string
if searchParams.SharedOnly {
groups, err := api.Database.GetGroups(ctx, database.GetGroupsParams{HasMemberID: apiKey.UserID})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list chats.",
Detail: err.Error(),
})
return
}
sharedWithGroupIDs = make([]string, 0, len(groups))
for _, group := range groups {
sharedWithGroupIDs = append(sharedWithGroupIDs, group.Group.ID.String())
}
}
projectID := uuid.NullUUID{}
if rawProjectID := r.URL.Query().Get("project_id"); rawProjectID != "" {
parsedProjectID, err := uuid.Parse(rawProjectID)
if err != nil || parsedProjectID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid project_id query parameter."})
return
}
projectID = uuid.NullUUID{UUID: parsedProjectID, Valid: true}
}
params := database.GetChatsParams{
OwnedOnly: searchParams.OwnedOnly,
ViewerID: apiKey.UserID,
SharedOnly: searchParams.SharedOnly,
SharedWithUserID: apiKey.UserID,
SharedWithGroupIds: sharedWithGroupIDs,
Archived: searchParams.Archived,
AfterID: paginationParams.AfterID,
LabelFilter: labelFilter,
DiffURL: searchParams.DiffURL,
TitleQuery: searchParams.TitleQuery,
HasUnread: searchParams.HasUnread,
ChatStatuses: searchParams.ChatStatuses,
PullRequestStatuses: searchParams.PullRequestStatuses,
PrNumber: searchParams.PrNumber,
RepoQuery: searchParams.RepoQuery,
PrTitleQuery: searchParams.PrTitleQuery,
Search: searchParams.Search,
ProjectID: projectID,
// #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) {
result := make(map[uuid.UUID][]database.ChatDiffStatus, len(chats))
if len(chats) == 0 {
return result, 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)
}
for _, status := range statuses {
result[status.ChatID] = append(result[status.ChatID], status)
}
return result, nil
}
func planModeToNullChatPlanMode(mode codersdk.ChatPlanMode) database.NullChatPlanMode {
if mode == "" {
return database.NullChatPlanMode{}
}
return database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanMode(mode),
Valid: true,
}
}
func validateChatPlanMode(mode codersdk.ChatPlanMode) bool {
switch mode {
case "", codersdk.ChatPlanModePlan:
return true
default:
return false
}
}
func parseChatModelCallConfig(options json.RawMessage) (*codersdk.ChatModelCallConfig, error) {
callConfig := &codersdk.ChatModelCallConfig{}
if len(options) == 0 {
return callConfig, nil
}
if err := json.Unmarshal(options, callConfig); err != nil {
return nil, err
}
return callConfig, nil
}
func validateChatModelOverrideEffort(
modelConfig database.ChatModelConfig,
effort *string,
) (int, *codersdk.Response) {
if effort == nil {
return 0, nil
}
if !chatprovider.IsValidReasoningEffort(*effort) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: "Must be one of none, minimal, low, medium, high, xhigh, max.",
}
}
callConfig, err := parseChatModelCallConfig(modelConfig.Options)
if err != nil {
return http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating reasoning effort.",
Detail: err.Error(),
}
}
selectableEfforts := chatprovider.SelectableReasoningEfforts(callConfig.ReasoningEffort)
if len(selectableEfforts) == 0 {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: "This model does not support reasoning effort.",
}
}
if !slices.Contains(selectableEfforts, *effort) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid reasoning_effort value.",
Detail: "Must be one of " + strings.Join(selectableEfforts, ", ") + ".",
}
}
return 0, nil
}
func validateChatModelOverride(
ctx context.Context,
db database.Store,
organizationID uuid.UUID,
overrideContext codersdk.ChatModelOverrideContext,
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 := db.GetEnabledChatModelConfigByID(ctx, *id)
if err == nil && modelConfig.OrganizationID != organizationID {
err = sql.ErrNoRows
}
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
return http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
}
}
if overrideContext == codersdk.ChatModelOverrideContextComputerUse {
if status, resp := validateComputerUseModelProvider(ctx, db, modelConfig); resp != nil {
return status, resp
}
}
return validateChatModelOverrideEffort(modelConfig, effort)
}
// validateComputerUseModelProvider rejects models that cannot run computer
// use. Chatd derives the tool flavor from the provider type and the model.
func validateComputerUseModelProvider(
ctx context.Context,
db database.Store,
modelConfig database.ChatModelConfig,
) (int, *codersdk.Response) {
unsupported := &codersdk.Response{
Message: "Computer use requires " + chatprovider.ComputerUseModelRequirement + ".",
}
if !modelConfig.AIProviderID.Valid {
unsupported.Detail = fmt.Sprintf("Model %q has no AI provider.", modelConfig.Model)
return http.StatusBadRequest, unsupported
}
provider, err := db.GetAIProviderByID(ctx, modelConfig.AIProviderID.UUID)
if err != nil {
return http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating computer use model provider.",
Detail: err.Error(),
}
}
if _, ok := chatprovider.ComputerUseProvider(provider.Type, modelConfig.Model); ok {
return 0, nil
}
unsupported.Detail = fmt.Sprintf(
"Model %q uses AI provider %q of type %q.",
modelConfig.Model, provider.Name, provider.Type,
)
return http.StatusBadRequest, unsupported
}
func parseChatModelOverrideContext(raw string) (codersdk.ChatModelOverrideContext, error) {
overrideContext := codersdk.ChatModelOverrideContext(raw)
if overrideContext.Valid() {
return overrideContext, nil
}
return "", xerrors.Errorf("unknown chat model override context %q", raw)
}
var chatPersonalModelOverrideContexts = []codersdk.ChatPersonalModelOverrideContext{
codersdk.ChatPersonalModelOverrideContextRoot,
codersdk.ChatPersonalModelOverrideContextGeneral,
codersdk.ChatPersonalModelOverrideContextExplore,
}
func parseChatPersonalModelOverrideContext(raw string) (codersdk.ChatPersonalModelOverrideContext, bool) {
c := codersdk.ChatPersonalModelOverrideContext(raw)
return c, slices.Contains(chatPersonalModelOverrideContexts, c)
}
func chatPersonalModelOverrideContextsJoined() string {
values := make([]string, 0, len(chatPersonalModelOverrideContexts))
for _, overrideContext := range chatPersonalModelOverrideContexts {
values = append(values, string(overrideContext))
}
return strings.Join(values, ", ")
}
func defaultChatPersonalModelOverrideMode(
overrideContext codersdk.ChatPersonalModelOverrideContext,
) codersdk.ChatPersonalModelOverrideMode {
if overrideContext == codersdk.ChatPersonalModelOverrideContextRoot {
return codersdk.ChatPersonalModelOverrideModeChatDefault
}
return codersdk.ChatPersonalModelOverrideModeDeploymentDefault
}
func chatPersonalModelOverrideResponse(
overrideContext codersdk.ChatPersonalModelOverrideContext,
row *database.ChatUserModelOverride,
) codersdk.ChatPersonalModelOverride {
response := codersdk.ChatPersonalModelOverride{
Context: overrideContext,
Mode: defaultChatPersonalModelOverrideMode(overrideContext),
}
if row == nil {
return response
}
response.Mode = codersdk.ChatPersonalModelOverrideMode(row.Mode)
response.IsSet = true
if row.ModelConfigID.Valid {
response.ModelConfigID = row.ModelConfigID.UUID.String()
}
if row.ReasoningEffort.Valid {
response.ReasoningEffort = &row.ReasoningEffort.String
}
return response
}
func derefOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}
func chatOrganizationModelOverrideResponse(
row database.ChatOrganizationModelOverride,
) codersdk.ChatModelOverrideResponse {
response := codersdk.ChatModelOverrideResponse{
Context: codersdk.ChatModelOverrideContext(row.Context),
ModelConfigID: row.ModelConfigID.String(),
}
if row.ReasoningEffort.Valid {
response.ReasoningEffort = &row.ReasoningEffort.String
}
return response
}
func (api *API) chatPersonalModelOverrideDeploymentDefaultResponse(
ctx context.Context,
organizationID uuid.UUID,
overrideContext codersdk.ChatModelOverrideContext,
) (codersdk.ChatModelOverrideResponse, error) {
row, err := api.Database.GetChatOrganizationModelOverride(ctx, database.GetChatOrganizationModelOverrideParams{
OrganizationID: organizationID,
Context: string(overrideContext),
})
if errors.Is(err, sql.ErrNoRows) {
return codersdk.ChatModelOverrideResponse{Context: overrideContext}, nil
}
if err != nil {
return codersdk.ChatModelOverrideResponse{}, err
}
return chatOrganizationModelOverrideResponse(row), nil
}
func (api *API) chatPersonalModelOverrideDeploymentDefaults(
ctx context.Context,
organizationID uuid.UUID,
) (codersdk.ChatPersonalModelOverrideDeploymentDefaults, error) {
general, err := api.chatPersonalModelOverrideDeploymentDefaultResponse(
ctx,
organizationID,
codersdk.ChatModelOverrideContextGeneral,
)
if err != nil {
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{}, err
}
explore, err := api.chatPersonalModelOverrideDeploymentDefaultResponse(
ctx,
organizationID,
codersdk.ChatModelOverrideContextExplore,
)
if err != nil {
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{}, err
}
return codersdk.ChatPersonalModelOverrideDeploymentDefaults{
General: general,
Explore: explore,
}, nil
}
type userChatModelAvailability struct {
configuredProviders []chatprovider.ConfiguredProvider
enabledModels []database.ChatModelConfig
providerStatusByID map[uuid.UUID]chatprovider.ProviderAvailability
enabledProviderIDs map[uuid.UUID]struct{}
}
// chatModelConfigUnavailableReason reports why a model config cannot be used.
// The empty value means the model config is available. Callers must check the
// error returned by userCanUseChatModelConfig before interpreting this value.
type chatModelConfigUnavailableReason string
const (
chatModelConfigAvailable chatModelConfigUnavailableReason = ""
chatModelConfigUnavailableModelNotFoundOrDisabled chatModelConfigUnavailableReason = "model_not_found_or_disabled"
chatModelConfigUnavailableProviderDisabled chatModelConfigUnavailableReason = "provider_disabled"
chatModelConfigUnavailableCredentialsMissing chatModelConfigUnavailableReason = "credentials_missing"
chatModelConfigUnavailableOutsideOrganization chatModelConfigUnavailableReason = "outside_organization"
)
// getUserChatProviderAvailability returns the enabled chat providers and models
// the user can access in one organization. Provider configuration uses Chatd
// access. Model configs and user keys use the caller's authorization context.
func (api *API) getUserChatProviderAvailability(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
) (userChatModelAvailability, error) {
//nolint:gocritic // Chatd context is required to read enabled chat providers.
chatdCtx := dbauthz.AsChatd(ctx)
enabledProviders, err := api.Database.GetAIProviders(chatdCtx, database.GetAIProvidersParams{})
if err != nil {
return userChatModelAvailability{}, err
}
modelRows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return userChatModelAvailability{}, err
}
effectiveModels := database.DeriveEffectiveChatModelConfigs(modelRows)
enabledModels := make([]database.ChatModelConfig, 0, len(effectiveModels.Configs))
for _, row := range effectiveModels.Configs {
enabledModels = append(enabledModels, row.ChatModelConfig)
}
configuredProviders, err := api.configuredProvidersFromAIProviders(chatdCtx, enabledProviders)
if err != nil {
return userChatModelAvailability{}, err
}
availability := userChatModelAvailability{
configuredProviders: configuredProviders,
enabledModels: enabledModels,
enabledProviderIDs: make(map[uuid.UUID]struct{}, len(enabledProviders)),
providerStatusByID: make(map[uuid.UUID]chatprovider.ProviderAvailability, len(enabledProviders)),
}
for _, configuredProvider := range configuredProviders {
if configuredProvider.ProviderID != uuid.Nil {
availability.enabledProviderIDs[configuredProvider.ProviderID] = struct{}{}
}
}
userKeys := []chatprovider.UserProviderKey{}
if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
userKeyStatus, err := api.userAIProviderKeyStatusByProviderID(ctx, userID)
if err != nil {
return userChatModelAvailability{}, err
}
userKeys = make([]chatprovider.UserProviderKey, 0, len(userKeyStatus))
for providerID, configured := range userKeyStatus {
if configured {
userKeys = append(userKeys, chatprovider.UserProviderKey{ChatProviderID: providerID, APIKey: "configured"})
}
}
}
for _, configuredProvider := range configuredProviders {
normalizedProvider := chatprovider.NormalizeProvider(configuredProvider.Provider)
if normalizedProvider == "" {
continue
}
_, providerStatus := chatprovider.ResolveUserProviderKeys(
chatprovider.ProviderAPIKeys{},
[]chatprovider.ConfiguredProvider{configuredProvider},
userKeys,
)
status, ok := providerStatus[normalizedProvider]
if ok && configuredProvider.ProviderID != uuid.Nil {
availability.providerStatusByID[configuredProvider.ProviderID] = status
}
}
return availability, nil
}
// userCanUseChatModelConfig returns chatModelConfigAvailable when the user can
// use the model config. If err is non-nil, callers must ignore the returned
// reason because it may be the zero-value availability sentinel.
func (api *API) userCanUseChatModelConfig(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, chatModelConfigUnavailableReason, error) {
if modelConfigID == uuid.Nil {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
model, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
return database.ChatModelConfig{}, chatModelConfigAvailable, err
}
if model.OrganizationID != organizationID {
return database.ChatModelConfig{}, chatModelConfigUnavailableOutsideOrganization, nil
}
if !model.Enabled {
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
availability, err := api.getUserChatProviderAvailability(ctx, userID, organizationID)
if err != nil {
return database.ChatModelConfig{}, chatModelConfigAvailable, err
}
if model.AIProviderID.Valid {
providerID := model.AIProviderID.UUID
if _, ok := availability.enabledProviderIDs[providerID]; !ok {
return database.ChatModelConfig{}, chatModelConfigUnavailableProviderDisabled, nil
}
providerStatus, ok := availability.providerStatusByID[providerID]
if !ok {
return database.ChatModelConfig{}, chatModelConfigUnavailableProviderDisabled, nil
}
if !providerStatus.Available {
return database.ChatModelConfig{}, chatModelConfigUnavailableCredentialsMissing, nil
}
return model, chatModelConfigAvailable, nil
}
// Active configs always carry a provider FK (CHECK
// chat_model_configs_ai_provider_required_when_active), so an unset FK
// means the config is not usable.
return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil
}
func validateUserChatModelConfigAvailability(
modelConfig database.ChatModelConfig,
reason chatModelConfigUnavailableReason,
) (database.ChatModelConfig, int, *codersdk.Response) {
switch reason {
case chatModelConfigAvailable:
return modelConfig, 0, nil
case chatModelConfigUnavailableModelNotFoundOrDisabled,
chatModelConfigUnavailableOutsideOrganization:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id: model config not found or disabled.",
}
case chatModelConfigUnavailableCredentialsMissing:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id: provider credentials unavailable for this model.",
}
case chatModelConfigUnavailableProviderDisabled:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id: provider is not enabled for this model.",
}
default:
return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model_config_id.",
}
}
}
func (api *API) validateUserChatModelConfigAvailable(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (database.ChatModelConfig, int, *codersdk.Response) {
modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, organizationID, modelConfigID)
if err != nil {
return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
}
}
if reason != chatModelConfigAvailable &&
reason != chatModelConfigUnavailableModelNotFoundOrDisabled &&
reason != chatModelConfigUnavailableCredentialsMissing &&
reason != chatModelConfigUnavailableProviderDisabled &&
reason != chatModelConfigUnavailableOutsideOrganization {
api.Logger.Warn(ctx,
"unknown chat model config availability reason",
slog.F("user_id", userID),
slog.F("model_config_id", modelConfigID),
slog.F("reason", reason),
)
}
return validateUserChatModelConfigAvailability(modelConfig, reason)
}
// validateExplicitChatModelConfigAvailable validates a caller-supplied
// model config ID. A nil ID keeps the chat's current model and is
// validated by the daemon's fallback resolution instead.
func (api *API) validateExplicitChatModelConfigAvailable(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
modelConfigID uuid.UUID,
) (int, *codersdk.Response) {
if modelConfigID == uuid.Nil {
return 0, nil
}
_, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, organizationID, modelConfigID)
return status, resp
}
func validateChatMCPServerIDs(
ctx context.Context,
db database.Store,
organizationID uuid.UUID,
ids []uuid.UUID,
) (normalized []uuid.UUID, invalid []uuid.UUID, err error) {
unique := make([]uuid.UUID, 0, len(ids))
seen := make(map[uuid.UUID]struct{}, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return unique, nil, nil
}
configs, err := db.GetEnabledMCPServerConfigsByOrganizationAndIDs(ctx, database.GetEnabledMCPServerConfigsByOrganizationAndIDsParams{
OrganizationID: organizationID,
IDs: unique,
})
if err != nil {
return nil, nil, xerrors.Errorf("get enabled MCP server configs for organization: %w", err)
}
valid := make(map[uuid.UUID]struct{}, len(configs))
for _, config := range configs {
valid[config.ID] = struct{}{}
}
invalid = make([]uuid.UUID, 0, len(unique)-len(valid))
for _, id := range unique {
if _, ok := valid[id]; !ok {
invalid = append(invalid, id)
}
}
return unique, invalid, nil
}
// normalizeRequestedChatMCPServerIDs validates a request's MCP server
// selection for an existing chat. When requested is nil there is no
// change to make. IDs already persisted on the chat are exempt from the
// enabled-in-organization check: a server that is disabled or revoked
// after selection must not block sends. The generation path skips
// servers the chat can no longer use, and keeping the ID preserves the
// selection if the server is re-enabled. A non-nil response indicates
// the caller must write it with the returned status and stop.
func (api *API) normalizeRequestedChatMCPServerIDs(ctx context.Context, chat database.Chat, requested *[]uuid.UUID) (*[]uuid.UUID, int, *codersdk.Response) {
if requested == nil {
return nil, 0, nil
}
normalized, invalid, err := validateChatMCPServerIDs(ctx, api.Database, chat.OrganizationID, *requested)
if err != nil {
return nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to validate MCP server IDs.",
Detail: err.Error(),
}
}
persisted := make(map[uuid.UUID]struct{}, len(chat.MCPServerIDs))
for _, id := range chat.MCPServerIDs {
persisted[id] = struct{}{}
}
newlyInvalid := make([]uuid.UUID, 0, len(invalid))
for _, id := range invalid {
if _, ok := persisted[id]; !ok {
newlyInvalid = append(newlyInvalid, id)
}
}
if len(newlyInvalid) > 0 {
resp := invalidChatMCPServerIDsResponse(newlyInvalid)
return nil, http.StatusBadRequest, &resp
}
return &normalized, 0, nil
}
func invalidChatMCPServerIDsResponse(ids []uuid.UUID) codersdk.Response {
invalid := make([]string, 0, len(ids))
for _, id := range ids {
invalid = append(invalid, id.String())
}
return codersdk.Response{
Message: "One or more MCP server IDs are invalid or disabled.",
Detail: fmt.Sprintf("Invalid IDs: %s", strings.Join(invalid, ", ")),
}
}
// @Summary Create chat
// @ID create-chat
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param request body codersdk.CreateChatRequest true "Create chat request"
// @Success 201 {object} codersdk.Chat
// @Failure 400 {object} codersdk.Response
// @Failure 413 {object} codersdk.Response "Request body exceeds 256 KiB"
// @Router /api/v2/chats [post]
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
// Limit memory used to decode dynamic tool schemas.
var req codersdk.CreateChatRequest
if !httpapi.ReadLimit(ctx, rw, r, maxChatRequestBodyBytes, &req) {
return
}
aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
OrganizationID: req.OrganizationID,
})
defer commitAudit()
chat, sdkChat, err := api.createChat(ctx, apiKey.UserID, req)
aReq.New = chat
if err != nil {
writeChatError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusCreated, sdkChat)
}
// ctx must carry callerID's RBAC actor. Use writeChatError for returned errors.
// The returned row supports auditing even when a later step fails.
func (api *API) createChat(
ctx context.Context,
callerID uuid.UUID,
req codersdk.CreateChatRequest,
) (database.Chat, codersdk.Chat, error) {
if req.OrganizationID == uuid.Nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "organization_id is required.",
})
}
ownerID := callerID
if req.OwnerID != nil {
if *req.OwnerID == uuid.Nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid owner_id: must be a user ID or omitted.",
})
}
ownerID = *req.OwnerID
}
if ownerID == callerID {
// Validate organization membership.
actor, ok := dbauthz.ActorFromContext(ctx)
if !ok {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate organization membership.",
Detail: "No authorization actor in context.",
})
}
isMember, err := actor.HasOrganizationMembership(req.OrganizationID)
if err != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate organization membership.",
Detail: xerrors.Errorf("check organization membership: %w", err).Error(),
})
}
if !isMember {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "You are not a member of the specified organization.",
})
}
}
// Authorization needs the parsed organization ID. postChats bounds HTTP
// request parsing with ReadLimit before this check.
chatObject := rbac.ResourceChat.WithOwner(ownerID.String()).InOrg(req.OrganizationID)
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate, chatObject) {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusForbidden, httpapi.ResourceForbiddenResponse)
}
// Chat processing runs with the owner's credentials (workspace access,
// OIDC and provider tokens), so creating a chat for another user is
// acting as that user. Org-scoped chat permissions are not enough:
// require the same authority the token endpoint demands to mint a
// session for that user.
ownerCtx := ctx
if ownerID != callerID {
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate, rbac.ResourceApiKey.WithOwner(ownerID.String())) {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusForbidden, httpapi.ResourceForbiddenResponse)
}
//nolint:gocritic // The caller may hold this authority without being able to read the owner's membership.
memberships, err := api.Database.OrganizationMembers(dbauthz.AsSystemRestricted(ctx), database.OrganizationMembersParams{
OrganizationID: req.OrganizationID,
UserID: ownerID,
IncludeSystem: false,
GithubUserID: 0,
})
if err != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "An internal server error occurred.",
Detail: err.Error(),
})
}
// AI Bridge refuses to authorize inactive and system users, so a
// chat owned by one could never run. The query already omits
// system and deleted users.
if len(memberships) == 0 || memberships[0].Status != database.UserStatusActive {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Chat owner must be an active member of the organization.",
})
}
owner, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "An internal server error occurred.",
Detail: err.Error(),
})
}
// From here on the request proceeds as the owner. The chat is the
// owner's and chatd runs it under the owner's ACLs, so the
// workspace, model config, MCP servers, and the creation itself
// must succeed for the owner, not merely for the caller.
ownerCtx = dbauthz.As(ctx, owner)
// Service accounts and custom roles may lack chat permissions
// entirely or hold only some of them. Creation inserts the first
// message and reads the chat back under the owner, so a partial
// grant would fail inside the transaction with a generic 403.
for _, action := range []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate} {
if !api.HTTPAuth.AuthorizeContext(ownerCtx, action, chatObject) {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Chat owner does not have permission to use chats.",
})
}
}
}
projectID := uuid.NullUUID{}
if req.ProjectID != nil {
var status int
var resp *codersdk.Response
projectID, status, resp = api.validateChatProjectSelection(ctx, *req.ProjectID, ownerID, req.OrganizationID)
if resp != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(status, *resp)
}
}
contentBlocks, titleText, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, *inputError)
}
workspaceSelection, validationStatus, validationError := api.validateCreateChatWorkspaceSelection(ownerCtx, req)
if validationError != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(validationStatus, *validationError)
}
title := chatprompt.FallbackTitle(titleText)
titleSource := database.ChatTitleSourceFallback
if req.Title != nil {
userTitle, titleError := normalizeChatTitle(*req.Title)
if titleError != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, *titleError)
}
title = userTitle
titleSource = database.ChatTitleSourceUser
}
modelConfigID, personalOverrideEffort, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ownerCtx, ownerID, req)
if modelConfigError != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(modelConfigStatus, *modelConfigError)
}
if !validateChatPlanMode(req.PlanMode) {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
}
normalizedMCPServerIDs, invalidMCPServerIDs, err := validateChatMCPServerIDs(ownerCtx, api.Database, req.OrganizationID, req.MCPServerIDs)
if err != nil {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate MCP server IDs.",
Detail: err.Error(),
})
}
req.MCPServerIDs = normalizedMCPServerIDs
if len(invalidMCPServerIDs) > 0 {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, invalidChatMCPServerIDsResponse(invalidMCPServerIDs))
}
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 {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid labels.",
Validations: errs,
})
}
if (len(req.UnsafeDynamicTools) > 0 || len(req.InlineMCPServers) > 0) && api.DeploymentValues.DisableChatCallerSuppliedTools.Value() {
return database.Chat{}, codersdk.Chat{}, chatCallerSuppliedToolsDisabledError()
}
if len(req.InlineMCPServers) > 0 {
if !api.Experiments.Enabled(codersdk.ExperimentChatInlineMCPServers) {
return database.Chat{}, codersdk.Chat{}, inlineMCPServersExperimentRequiredError()
}
if validations := validateInlineMCPServers(req.InlineMCPServers, api.MCPAllowedPrivateCIDRs); len(validations) > 0 {
return database.Chat{}, codersdk.Chat{}, inlineMCPServersInvalidError(validations)
}
}
if len(req.UnsafeDynamicTools) > 250 {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Too many dynamic tools.",
Detail: "Maximum 250 dynamic tools per chat.",
})
}
// 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 == "" {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Dynamic tool name must not be empty.",
})
}
if _, exists := seenNames[dt.Name]; exists {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Duplicate dynamic tool name.",
Detail: fmt.Sprintf("Tool %q appears more than once.", dt.Name),
})
}
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 {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal dynamic tools.",
Detail: err.Error(),
})
}
}
clientType := database.ChatClientTypeApi
if req.ClientType != "" {
clientType = database.ChatClientType(req.ClientType)
if !clientType.Valid() {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid client_type.",
Detail: fmt.Sprintf("got %q, want one of %v", req.ClientType, database.AllChatClientTypeValues()),
})
}
}
reasoningEffort := req.ReasoningEffort
if reasoningEffort == nil {
reasoningEffort = personalOverrideEffort
}
if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) {
return database.Chat{}, codersdk.Chat{}, httperror.NewResponseError(http.StatusBadRequest, invalidReasoningEffortResponse(*reasoningEffort))
}
chat, err := api.chatDaemon.CreateChat(ownerCtx, chatd.CreateOptions{
OrganizationID: req.OrganizationID,
OwnerID: ownerID,
CreatedBy: callerID,
ProjectID: projectID,
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
TitleSource: titleSource,
ModelConfigID: modelConfigID,
ReasoningEffort: reasoningEffort,
PlanMode: planModeToNullChatPlanMode(req.PlanMode),
ClientType: clientType,
SystemPrompt: req.SystemPrompt,
InitialUserContent: contentBlocks,
MCPServerIDs: mcpServerIDs,
InlineMCPServers: req.InlineMCPServers,
Labels: labels,
DynamicTools: dynamicToolsJSON,
// IMPORTANT: users can only create root chats at the time of writing.
ParentChatID: uuid.NullUUID{},
})
if err != nil {
return database.Chat{}, codersdk.Chat{}, api.createChatError(err)
}
if chat.ParentChatID.Valid {
// Should not be possible. If we get here, something is very wrong. Bail.
return chat, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Developer error: ParentChatID got set somehow in api.createChat. This should never happen.",
})
}
readBack, err := api.Database.GetChatByID(ownerCtx, chat.ID)
if err != nil {
return chat, codersdk.Chat{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to read back chat after creation.",
Detail: err.Error(),
})
}
chat = readBack
// Empty creates title on their first send.
if len(contentBlocks) > 0 && chat.TitleSource == database.ChatTitleSourceFallback {
api.chatDaemon.GenerateChatTitleAsync(ownerCtx, chat)
}
chatFiles := api.fetchChatFileMetadata(ownerCtx, chat.ID)
response := db2sdk.Chat(chat, nil, chatFiles)
return chat, response, nil
}
// CreateChatAsUser creates and audits a root chat as userID without HTTP.
// ctx does not need an RBAC actor. Errors implement httperror.Responder;
// lifecycle hook errors also unwrap to their denial or dispatch error.
func (api *API) CreateChatAsUser(ctx context.Context, userID uuid.UUID, req codersdk.CreateChatRequest) (codersdk.Chat, error) {
if err := api.chatDaemonError(); err != nil {
return codersdk.Chat{}, err
}
ctx, err := api.actAsUser(ctx, userID)
if err != nil {
return codersdk.Chat{}, err
}
chat, sdkChat, err := api.createChat(ctx, userID, req)
api.auditChatAsUser(ctx, userID, database.AuditActionCreate, database.Chat{}, chat, http.StatusCreated, err)
return sdkChat, err
}
// SendChatMessageAsUser sends a message to a chat as userID with the checks
// and the errors of POST /api/v2/chats/{chat}/messages. See CreateChatAsUser.
func (api *API) SendChatMessageAsUser(ctx context.Context, userID, chatID uuid.UUID, req codersdk.CreateChatMessageRequest) (codersdk.CreateChatMessageResponse, error) {
ctx, chat, err := api.actAsUserOnChat(ctx, userID, chatID)
if err != nil {
return codersdk.CreateChatMessageResponse{}, err
}
if err := api.checkSendChatMessage(ctx, userID, chat); err != nil {
return codersdk.CreateChatMessageResponse{}, err
}
return api.sendChatMessage(ctx, userID, chat, req)
}
// InterruptChatAsUser interrupts a chat as userID with the checks and the
// errors of POST /api/v2/chats/{chat}/interrupt. See CreateChatAsUser.
func (api *API) InterruptChatAsUser(ctx context.Context, userID, chatID uuid.UUID) (codersdk.Chat, error) {
ctx, chat, err := api.actAsUserOnChat(ctx, userID, chatID)
if err != nil {
return codersdk.Chat{}, err
}
return api.interruptChat(ctx, chat)
}
// UpdateChatAsUser updates a chat as userID with PATCH /api/v2/chats/{chat}
// validation, errors, and auditing. See CreateChatAsUser.
func (api *API) UpdateChatAsUser(ctx context.Context, userID, chatID uuid.UUID, req codersdk.UpdateChatRequest) error {
ctx, chat, err := api.actAsUserOnChat(ctx, userID, chatID)
if err != nil {
return err
}
if err := api.checkUpdateChat(ctx, chat); err != nil {
return err
}
updated, err := api.updateChat(ctx, userID, chat, req)
api.auditChatAsUser(ctx, userID, database.AuditActionWrite, chat, updated, http.StatusNoContent, err)
return err
}
// Errors implement httperror.Responder.
func (api *API) actAsUserOnChat(ctx context.Context, userID, chatID uuid.UUID) (context.Context, database.Chat, error) {
ctx, err := api.actAsUser(ctx, userID)
if err != nil {
return nil, database.Chat{}, err
}
chat, err := httpmw.FetchChat(ctx, api.Database, chatID)
if err != nil {
return nil, database.Chat{}, err
}
return ctx, chat, nil
}
// Match API key authentication so in-process activity counts toward
// dormancy checks and dormant users can reactivate.
func (api *API) actAsUser(ctx context.Context, userID uuid.UUID) (context.Context, error) {
//nolint:gocritic // The API key middleware also updates the user as the system.
systemCtx := dbauthz.AsSystemRestricted(ctx)
user, err := api.Database.GetUserByID(systemCtx, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, httperror.WrapResponseError(http.StatusForbidden, codersdk.Response{
Message: "User does not exist.",
}, err)
}
if err != nil {
return nil, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
Detail: err.Error(),
}, err)
}
if user.Deleted {
return nil, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "User is deleted.",
})
}
switch user.Status {
case database.UserStatusDormant:
_, err = ActivateDormantUser(api.Logger, &api.Auditor, api.Database)(ctx, user)
if err != nil {
return nil, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error activating dormant user.",
Detail: err.Error(),
}, err)
}
case database.UserStatusActive:
if now := dbtime.Now(); now.Sub(user.LastSeenAt) > time.Hour {
_, err = api.Database.UpdateUserLastSeenAt(systemCtx, database.UpdateUserLastSeenAtParams{
ID: userID,
LastSeenAt: now,
UpdatedAt: now,
})
if err != nil {
return nil, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user last seen time.",
Detail: err.Error(),
}, err)
}
}
}
actor, status, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll)
if err != nil {
return nil, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user's roles.",
Detail: err.Error(),
}, err)
}
if status != database.UserStatusActive {
return nil, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate the account.", status),
})
}
return dbauthz.As(ctx, actor), nil
}
// Match HTTP audit behavior: omit entries without a chat and diffs on failure.
func (api *API) auditChatAsUser(
ctx context.Context,
userID uuid.UUID,
action database.AuditAction,
oldChat, newChat database.Chat,
status int,
err error,
) {
if oldChat.ID == uuid.Nil && newChat.ID == uuid.Nil {
return
}
if err != nil {
status = httperror.StatusCode(err)
}
if status >= http.StatusBadRequest {
if newChat.ID == uuid.Nil {
newChat = oldChat
}
oldChat = newChat
}
audit.BackgroundAudit(context.WithoutCancel(ctx), &audit.BackgroundAuditParams[database.Chat]{
Audit: *api.Auditor.Load(),
Log: api.Logger,
UserID: userID,
OrganizationID: newChat.OrganizationID,
Action: action,
Old: oldChat,
New: newChat,
Status: status,
RequestID: uuid.New(),
})
}
func (api *API) createChatError(err error) error {
if hookErr := chatHookError(err, "Chat creation denied by lifecycle hook."); hookErr != nil {
return hookErr
}
if fileErr := api.chatFileError(err); fileErr != nil {
return fileErr
}
if skillErr := chatSkillInvocationError(err); skillErr != nil {
return skillErr
}
if xerrors.Is(err, chatd.ErrInvalidModelConfigID) || database.IsForeignKeyViolation(
err,
database.ForeignKeyChatsLastModelConfigID,
database.ForeignKeyChatMessagesModelConfigID,
) {
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
Detail: err.Error(),
}, err)
}
// The project can be deleted between the access check and the insert.
if database.IsForeignKeyViolation(err, database.ForeignKeyChatsProjectID) {
return httperror.WrapResponseError(http.StatusNotFound, codersdk.Response{Message: "Chat project not found."}, err)
}
if dbauthz.IsNotAuthorizedError(err) {
return httperror.WrapResponseError(http.StatusForbidden, httpapi.ResourceForbiddenResponse, err)
}
return httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat.",
Detail: err.Error(),
}, err)
}
// @Summary Get chat by ID
// @ID get-chat-by-id
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat} [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
// Use the cached diff statuses from the database rather than
// resolving them 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 statuses fresh.
diffStatuses, err := api.Database.GetChatDiffStatusesByChatID(ctx, chat.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
api.Logger.Error(ctx, "failed to get cached chat diff status",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
}
// Hydrate file metadata for all files linked to this chat.
chatFiles := api.fetchChatFileMetadata(ctx, chat.ID)
sdkChat := db2sdk.ChatWithDiffStatuses(chat, db2sdk.PrimaryChatDiffStatus(diffStatuses), diffStatuses, chatFiles)
if api.chatDaemon != nil {
queued, err := api.chatDaemon.ChatQueuedForCapacity(ctx, chat)
if err != nil {
api.Logger.Error(ctx, "failed to derive chat queued-for-capacity state",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
sdkChat.QueuedForCapacity = queued
}
}
// Enrich the lightweight context summary with the chat's pinned
// resources (metadata only). This detail is computed on read and only
// attached on the single-chat GET; list and watch payloads stay
// lightweight. A failure here is non-fatal: the chat is still usable
// without the detail, so we log and return the rest of the response.
if sdkChat.Context != nil && api.chatDaemon != nil {
resources, err := api.chatDaemon.ContextResources(ctx, chat)
if err != nil {
api.Logger.Error(ctx, "failed to compute chat context resources",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
sdkChat.Context.Resources = resources
}
}
// For root chats, embed children so callers get a complete
// tree in a single response.
if !chat.ParentChatID.Valid {
// Embed children matching the parent's archive state.
childRows, err := api.Database.GetChildChatsByParentIDs(ctx, database.GetChildChatsByParentIDsParams{
ParentIds: []uuid.UUID{chat.ID},
Archived: sql.NullBool{Bool: chat.Archived, Valid: true},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch child chats.",
Detail: err.Error(),
})
return
}
// Look up diff statuses for children.
childChats := make([]database.Chat, len(childRows))
for i, row := range childRows {
childChats[i] = row.Chat
}
childDiffStatuses, err := api.getChatDiffStatusesByChatID(ctx, childChats)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch child chat diff statuses.",
Detail: err.Error(),
})
return
}
sdkChat.Children = db2sdk.ChildChatRows(childRows, childDiffStatuses)
}
if api.Experiments.Enabled(codersdk.ExperimentChatInlineMCPServers) {
servers, err := api.chatInlineMCPServers(ctx, chat.ID)
if err != nil {
api.Logger.Error(ctx, "failed to get inline MCP servers",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
} else {
if chat.OwnerID != httpmw.APIKey(r).UserID {
for i := range servers {
servers[i].URL = ""
}
}
sdkChat.InlineMCPServers = servers
}
}
enriched := []codersdk.Chat{sdkChat}
api.repairChatAgentIDs(ctx, enriched)
sdkChat = enriched[0]
httpapi.Write(ctx, rw, http.StatusOK, sdkChat)
}
// @Summary List chat messages
// @ID list-chat-messages
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param before_id query int false "Return messages with id < before_id"
// @Param after_id query int false "Return messages with id > after_id"
// @Param limit query int false "Page size, 1 to 200. Defaults to 50."
// @Success 200 {object} codersdk.ChatMessagesResponse
// @Router /api/v2/chats/{chat}/messages [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatMessages(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
// Parse optional cursor-based pagination parameters.
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
beforeID := parser.PositiveInt64(queryParams, 0, "before_id")
afterID := parser.PositiveInt64(queryParams, 0, "after_id")
limit := parser.PositiveInt32(queryParams, 50, "limit")
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
if limit < 1 || limit > 200 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid limit parameter (1-200).",
})
return
}
// Reject transposed or equal cursors so an empty open range is loud,
// not silently indistinguishable from "no messages in this range."
if beforeID > 0 && afterID > 0 && afterID >= beforeID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "after_id must be less than before_id.",
})
return
}
// Polling with only after_id uses ASC so the cursor advances
// monotonically; a DESC limit would drop rows when a burst larger
// than `limit` lands between polls. Fetch limit+1 in both paths to
// detect whether more pages exist.
ascending := afterID > 0 && beforeID == 0
var messages []database.ChatMessage
var err error
switch {
case ascending:
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]
}
var turnStartID *int64
if !ascending && len(messages) > 0 {
id, err := api.Database.GetChatTurnStartID(ctx, database.GetChatTurnStartIDParams{
ChatID: chatID,
MessageID: messages[len(messages)-1].ID,
})
switch {
case err == nil:
turnStartID = &id
case !errors.Is(err, sql.ErrNoRows):
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat turn start.",
Detail: err.Error(),
})
return
}
}
// 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: slice.List(messages, db2sdk.ChatMessage),
QueuedMessages: db2sdk.ChatQueuedMessages(queuedMessages),
HasMore: hasMore,
TurnStartID: turnStartID,
})
}
// @Summary Get chat cost
// @ID get-chat-cost
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ChatCost
// @Router /api/v2/chats/{chat}/cost [get]
// @Description
// @Description Cost covers the whole chat tree: the root chat plus every
// @Description subagent chat beneath it. Requesting cost for a subagent chat
// @Description returns that same total.
// @Description
// @Description Cost is derived from AI Gateway data, which is subject to its
// @Description own retention period, 60 days by default, configured
// @Description independently of chat retention. Spend for requests older than
// @Description that period is no longer reported, so a chat whose requests
// @Description have all been purged reports zero cost.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatCost(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
// AI Gateway attributes a subagent's requests to the chat that spawned
// it, so cost is only meaningful for a whole chat tree. Resolve the root
// chat and report the tree total, including for subagent chats. Fall back
// to the parent when root_chat_id is NULL, matching the
// COALESCE(root_chat_id, parent_chat_id) resolution the chat queries use:
// both columns are ON DELETE SET NULL, so deleting a root leaves
// descendants with only a parent.
rootChatID := chat.ID
switch {
case chat.RootChatID.Valid:
rootChatID = chat.RootChatID.UUID
case chat.ParentChatID.Valid:
rootChatID = chat.ParentChatID.UUID
}
row, err := api.Database.GetAIBridgeChatCost(ctx, rootChatID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat cost.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatCost{
ChatID: chat.ID,
TotalCostMicros: row.TotalCostMicros,
RequestCount: row.RequestCount,
UnpricedRequestCount: row.UnpricedRequestCount,
})
}
// @Summary List chat user prompts
// @ID list-chat-user-prompts
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param limit query int false "Page size, 0 to 2000. 0 (the default) means the server-side default of 500."
// @Success 200 {object} codersdk.ChatPromptsResponse
// @Router /api/v2/chats/{chat}/prompts [get]
// @Description
// @Description Returns the user-authored prompts in a chat, newest first,
// @Description with each prompt's text parts concatenated in the order they
// @Description were authored. Used by the composer to power the up/down
// @Description arrow prompt-history cycle without paging through every
// @Description message in the chat.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatUserPrompts(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
// Default 0 sentinel; the SQL query treats 0 as "use the built-in
// default of 500" via COALESCE(NULLIF(@limit_val, 0), 500). The
// SDK guards opts.Limit > 0 so callers using the typed client only
// reach here with an explicit value; raw HTTP callers can omit the
// parameter (or pass 0) to opt into the default.
limit := parser.PositiveInt32(queryParams, 0, "limit")
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
// PositiveInt32 already rejects negatives via parser.Errors above,
// so we only need to cap the upper bound here.
if limit > 2000 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid limit parameter (0-2000).",
})
return
}
rows, err := api.Database.GetChatUserPromptsByChatID(ctx, database.GetChatUserPromptsByChatIDParams{
ChatID: chatID,
LimitVal: limit,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat user prompts.",
Detail: err.Error(),
})
return
}
prompts := make([]codersdk.ChatPrompt, 0, len(rows))
for _, row := range rows {
prompts = append(prompts, codersdk.ChatPrompt{
ID: row.ID,
Text: row.Text,
})
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPromptsResponse{
Prompts: prompts,
})
}
// @Summary List chat attachments
// @ID list-chat-attachments
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param before_id query int false "Return attachments from messages with id < before_id"
// @Success 200 {object} codersdk.ChatAttachmentsResponse
// @Router /api/v2/chats/{chat}/attachments [get]
// @Description Returns every file part in the user-visible user and assistant messages of a chat, newest first, so a client can list a chat's attachments without paging through every message in the chat.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatAttachments(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
parser := httpapi.NewQueryParamParser()
beforeID := parser.PositiveInt64(r.URL.Query(), 0, "before_id")
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
messages, err := api.Database.GetChatMessagesWithFilePartsByChatID(ctx, database.GetChatMessagesWithFilePartsByChatIDParams{
ChatID: chat.ID,
BeforeID: beforeID,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat attachments.",
Detail: err.Error(),
})
return
}
attachments := make([]codersdk.ChatAttachment, 0, len(messages))
for _, m := range messages {
// Use the messages endpoint's conversion so part_index and the
// part match what GET /messages returns for the same message.
msg := db2sdk.ChatMessage(m)
for i := len(msg.Content) - 1; i >= 0; i-- {
part := msg.Content[i]
if part.Type != codersdk.ChatMessagePartTypeFile {
continue
}
attachments = append(attachments, codersdk.ChatAttachment{
MessageID: msg.ID,
PartIndex: i,
Role: codersdk.ChatAttachmentRole(msg.Role),
File: codersdk.ChatAttachmentFile{
Type: codersdk.ChatAttachmentFileTypeFile,
MediaType: part.MediaType,
Name: part.Name,
Data: part.Data,
FileID: part.FileID,
},
})
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatAttachmentsResponse{
Attachments: attachments,
})
}
// authorizeChatWorkspaceExec enforces the workspace-level permissions
// shared by the chat stream endpoints that proxy a live websocket into
// the workspace agent (currently /stream/git and /stream/desktop).
//
// The chat row only authorizes the chat owner, so callers also need
// exec-level access (ApplicationConnect or SSH) to the bound workspace.
// The chat owner's workspace permissions may have been revoked after
// the chat was bound; skipping this check enabled CODAGT-184.
//
// On any failure the response is written and ok=false is returned.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) authorizeChatWorkspaceExec(
rw http.ResponseWriter,
r *http.Request,
chat database.Chat,
noWorkspaceMessage string,
) (database.Workspace, bool) {
return api.authorizeChatWorkspaceExecWithStatus(
rw,
r,
chat,
http.StatusBadRequest,
noWorkspaceMessage,
http.StatusBadRequest,
codersdk.ChatGitWatchWorkspaceNotFoundMessage,
)
}
// authorizeChatWorkspaceExecWithStatus is authorizeChatWorkspaceExec
// with caller-chosen statuses and messages for the no-workspace and
// workspace-not-found failures. The workspace upload endpoint reports
// these as 409 conflicts while the stream endpoints use 400.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) authorizeChatWorkspaceExecWithStatus(
rw http.ResponseWriter,
r *http.Request,
chat database.Chat,
noWorkspaceStatus int,
noWorkspaceMessage string,
notFoundStatus int,
notFoundMessage string,
) (database.Workspace, bool) {
ctx := r.Context()
if !chat.WorkspaceID.Valid {
httpapi.Write(ctx, rw, noWorkspaceStatus, codersdk.Response{
Message: noWorkspaceMessage,
})
return database.Workspace{}, false
}
workspace, err := api.Database.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID)
if httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, notFoundStatus, codersdk.Response{
Message: notFoundMessage,
})
return database.Workspace{}, false
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat workspace.",
Detail: err.Error(),
})
return database.Workspace{}, false
}
if !api.Authorize(r, policy.ActionApplicationConnect, workspace) &&
!api.Authorize(r, policy.ActionSSH, workspace) {
httpapi.Forbidden(rw)
return database.Workspace{}, false
}
return workspace, true
}
// @Summary Watch chat workspace git state via WebSockets
// @ID watch-chat-workspace-git-state-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.WorkspaceAgentGitServerMessage
// @Router /api/v2/chats/{chat}/stream/git [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) watchChatGit(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
chat = httpmw.ChatParam(r)
logger = api.Logger.Named("chat_git_watcher").With(slog.F("chat_id", chat.ID))
)
if _, ok := api.authorizeChatWorkspaceExec(rw, r, chat, codersdk.ChatGitWatchNoWorkspaceMessage); !ok {
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, chat.WorkspaceID.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
agent, err := agentselect.FindChatAgent(agents)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchNoEligibleAgentMessage,
Detail: err.Error(),
})
return
}
apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(),
*api.TailnetCoordinator.Load(),
agent,
nil,
nil,
nil,
api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchAgentStateMessage(apiAgent.Status),
})
return
}
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error dialing workspace agent.",
Detail: err.Error(),
})
return
}
defer release()
agentStream, err := agentConn.WatchGit(ctx, logger, chat.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error watching agent's git state.",
Detail: err.Error(),
})
return
}
defer agentStream.Close(websocket.StatusGoingAway)
clientConn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err != nil {
logger.Error(ctx, "failed to accept websocket", slog.Error(err))
return
}
clientStream := wsjson.NewStream[
codersdk.WorkspaceAgentGitClientMessage,
codersdk.WorkspaceAgentGitServerMessage,
](clientConn, websocket.MessageText, websocket.MessageText, logger)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
ctx = api.wsWatcher.Watch(ctx, logger, clientConn)
// Proxy agent → client.
agentCh := agentStream.Chan()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-api.ctx.Done():
return
case <-ctx.Done():
return
case msg, ok := <-agentCh:
if !ok {
cancel()
return
}
if err := clientStream.Send(msg); err != nil {
logger.Debug(ctx, "failed to forward agent message to client", slog.Error(err))
cancel()
return
}
}
}
}()
// Proxy client → agent.
clientCh := clientStream.Chan()
proxyLoop:
for {
select {
case <-api.ctx.Done():
break proxyLoop
case <-ctx.Done():
break proxyLoop
case msg, ok := <-clientCh:
if !ok {
break proxyLoop
}
if err := agentStream.Send(msg); err != nil {
logger.Debug(ctx, "failed to forward client message to agent", slog.Error(err))
break proxyLoop
}
}
}
cancel()
wg.Wait()
_ = clientStream.Close(websocket.StatusGoingAway)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Connect to chat workspace desktop via WebSockets
// @ID connect-to-chat-workspace-desktop-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce application/octet-stream
// @Param chat path string true "Chat ID" format(uuid)
// @Success 101
// @Router /api/experimental/chats/{chat}/stream/desktop [get]
// @Description Raw binary WebSocket stream of the chat workspace desktop.
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
chat = httpmw.ChatParam(r)
logger = api.Logger.Named("chat_desktop").With(slog.F("chat_id", chat.ID))
)
if _, ok := api.authorizeChatWorkspaceExec(rw, r, chat, "Chat has no workspace."); !ok {
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, chat.WorkspaceID.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
agent, err := agentselect.FindChatAgent(agents)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: codersdk.ChatGitWatchNoEligibleAgentMessage,
Detail: err.Error(),
})
return
}
apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(),
*api.TailnetCoordinator.Load(),
agent,
nil,
nil,
nil,
api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Agent state is %q, must be connected.", apiAgent.Status),
})
return
}
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to dial workspace agent.",
Detail: err.Error(),
})
return
}
defer release()
desktopConn, err := agentConn.ConnectDesktopVNC(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to connect to agent desktop.",
Detail: err.Error(),
})
return
}
defer desktopConn.Close()
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
logger.Error(ctx, "failed to accept websocket", slog.Error(err))
return
}
// No read limit because RFB framebuffer updates can be large.
conn.SetReadLimit(-1)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx, wsNetConn := workspaceapps.WebsocketNetConn(ctx, conn, websocket.MessageBinary)
defer wsNetConn.Close()
ctx = api.wsWatcher.Watch(ctx, logger, conn)
agentssh.Bicopy(ctx, wsNetConn, desktopConn)
logger.Debug(ctx, "desktop Bicopy finished")
}
// normalizeChatTitle trims and validates a user-supplied title. The
// response is non-nil when the title is rejected.
func normalizeChatTitle(rawTitle string) (string, *codersdk.Response) {
title := strings.TrimSpace(rawTitle)
if title == "" {
return "", &codersdk.Response{Message: "Title cannot be empty."}
}
if utf8.RuneCountInString(title) > codersdk.MaxChatTitleRunes {
return "", &codersdk.Response{
Message: fmt.Sprintf("Title must be at most %d characters.", codersdk.MaxChatTitleRunes),
}
}
return title, nil
}
func (api *API) applyChatTitleUpdate(
ctx context.Context,
chat database.Chat,
title string,
) (database.Chat, error) {
updatedChat, wrote, err := api.chatDaemon.RenameChatTitle(ctx, chat.ID, title)
if err != nil {
return database.Chat{}, chatUpdateError(err, "Failed to update chat title.")
}
if wrote {
api.chatDaemon.PublishTitleChange(updatedChat)
}
return updatedChat, nil
}
// refreshChatContext re-pins a chat to its agent's latest context snapshot
// and clears the dirty marker.
//
// @Summary Refresh chat context
// @ID refresh-chat-context
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/context [put]
func (api *API) refreshChatContext(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if !api.requireChatDaemon(ctx, rw) {
return
}
updated, err := api.chatDaemon.RefreshChatContext(ctx, chat)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error refreshing chat context.",
Detail: err.Error(),
})
return
}
sdkChat := db2sdk.Chat(updated, nil, nil)
// Enrich the context summary with the freshly pinned resources so the
// client reflects the refresh immediately, without a full reload. This
// mirrors getChat; we pass the re-pinned chat so the detail reflects the
// post-refresh state. A failure here is non-fatal: the refresh already
// succeeded, so we log and return the rest of the response.
if sdkChat.Context != nil && api.chatDaemon != nil {
resources, err := api.chatDaemon.ContextResources(ctx, updated)
if err != nil {
api.Logger.Error(ctx, "failed to compute chat context resources after refresh",
slog.F("chat_id", updated.ID),
slog.Error(err),
)
} else {
sdkChat.Context.Resources = resources
}
}
httpapi.Write(ctx, rw, http.StatusOK, sdkChat)
}
// patchChat updates a chat resource. Supports updating labels,
// workspace binding, project, archiving, pinning, pinned-chat ordering,
// and the owner's read state.
//
// @Summary Update chat
// @ID update-chat
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param chat path string true "Chat ID" format(uuid)
// @Param request body codersdk.UpdateChatRequest true "Update chat request"
// @Success 204
// @Failure 400 {object} codersdk.Response
// @Router /api/v2/chats/{chat} [patch]
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if maybeWriteChatError(ctx, rw, api.checkUpdateChat(ctx, chat)) {
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
}
updated, err := api.updateChat(ctx, httpmw.APIKey(r).UserID, chat, req)
if err != nil {
writeChatError(ctx, rw, err)
return
}
aReq.New = updated
rw.WriteHeader(http.StatusNoContent)
}
// ctx must carry the caller's RBAC actor. Errors implement httperror.Responder.
func (api *API) checkUpdateChat(ctx context.Context, chat database.Chat) error {
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUpdate, chat.RBACObject()) {
return httperror.ErrResourceNotFound
}
return api.chatDaemonError()
}
// The caller must call checkUpdateChat first and supply callerID's RBAC actor
// in ctx. Errors implement httperror.Responder.
func (api *API) updateChat(
ctx context.Context,
callerID uuid.UUID,
chat database.Chat,
req codersdk.UpdateChatRequest,
) (database.Chat, error) {
// Every field is validated before the first write, so an invalid
// request changes nothing.
var planModeUpdate *database.NullChatPlanMode
if req.PlanMode != nil {
if !validateChatPlanMode(*req.PlanMode) {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
}
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
planModeUpdate = &resolvedPlanMode
}
// The read cursor is owner-scoped, so an admin with update
// permission must not move another user's unread state.
if req.Read != nil && chat.OwnerID != callerID {
return database.Chat{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner can change its read state.",
})
}
// The binding decides where the owner's later prompts and tool calls
// run. Checking the owner's SSH access to the target is not enough,
// because a non-owner can first share their own workspace with them.
if req.WorkspaceID != nil && chat.OwnerID != callerID {
return database.Chat{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner can change its workspace.",
})
}
var title string
if req.Title != nil {
normalized, resp := normalizeChatTitle(*req.Title)
if resp != nil {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, *resp)
}
title = normalized
}
var labelsJSON []byte
if req.Labels != nil {
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid labels.",
Validations: errs,
})
}
var err error
labelsJSON, err = json.Marshal(*req.Labels)
if err != nil {
return database.Chat{}, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal labels.",
Detail: err.Error(),
}, err)
}
}
archived := chat.Archived
if req.Archived != nil {
// 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 {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Chat archive state can only be changed on the root chat.",
})
}
if *req.Archived == chat.Archived {
state := "archived"
if !chat.Archived {
state = "not archived"
}
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Chat is already %s.", state),
})
}
archived = *req.Archived
}
if req.PinOrder != nil {
if *req.PinOrder < 0 {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Pin order must be non-negative.",
})
}
if *req.PinOrder > 0 && archived {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin an archived chat.",
})
}
if *req.PinOrder > 0 && chat.ParentChatID.Valid {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin a child chat.",
})
}
}
workspaceID := uuid.NullUUID{}
if req.WorkspaceID != nil && *req.WorkspaceID != uuid.Nil {
var workspace database.Workspace
var status int
var resp *codersdk.Response
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
return database.Chat{}, httperror.NewResponseError(status, *resp)
}
if workspace.OrganizationID != chat.OrganizationID {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Workspace does not belong to this chat's organization.",
})
}
}
var projectUpdate *uuid.NullUUID
if req.ProjectID != nil {
var refusal string
switch {
case chat.ParentChatID.Valid:
refusal = "Cannot move a child chat."
case archived:
refusal = "Cannot move an archived chat."
}
if refusal != "" {
return database.Chat{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{Message: refusal})
}
projectID := uuid.NullUUID{}
if *req.ProjectID != uuid.Nil {
var status int
var resp *codersdk.Response
projectID, status, resp = api.validateChatProjectSelection(ctx, *req.ProjectID, chat.OwnerID, chat.OrganizationID)
if resp != nil {
return database.Chat{}, httperror.NewResponseError(status, *resp)
}
}
projectUpdate = &projectID
}
if req.Title != nil {
updatedChat, err := api.applyChatTitleUpdate(ctx, chat, title)
if err != nil {
return database.Chat{}, err
}
chat = updatedChat
}
if req.Labels != nil {
updatedChat, err := api.Database.UpdateChatLabelsByID(ctx, database.UpdateChatLabelsByIDParams{
ID: chat.ID,
Labels: labelsJSON,
})
if err != nil {
return database.Chat{}, chatUpdateError(err, "Failed to update chat labels.")
}
chat = updatedChat
}
if req.Archived != nil {
var err error
if *req.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) {
return database.Chat{}, httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Chat archive state can only be changed on the root chat.",
}, err)
}
if invalidStateErr := chatInvalidStateError(err); invalidStateErr != nil {
return database.Chat{}, invalidStateErr
}
if errors.Is(err, chatstate.ErrTransitionNotAllowed) {
message := "Cannot archive an active chat. Interrupt or wait for the chat to finish first."
// The refusal can come from any member of the family, so
// the message follows the state that refused.
var transitionErr *chatstate.TransitionError
if errors.As(err, &transitionErr) && transitionErr.From == chatstate.StateP {
message = "Cannot archive: a chat in this family is paused at a queued message under edit. Finish editing, send, or remove that message first."
}
return database.Chat{}, httperror.WrapResponseError(http.StatusConflict, codersdk.Response{
Message: message,
Detail: err.Error(),
}, err)
}
action := "archive"
if !*req.Archived {
action = "unarchive"
}
return database.Chat{}, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Failed to %s chat.", action),
Detail: err.Error(),
}, err)
}
}
if req.PinOrder != nil {
pinOrder := *req.PinOrder
// 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):
return database.Chat{}, httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin a child chat.",
}, err)
case database.IsCheckViolation(err, database.CheckChatsPinOrderArchivedCheck):
return database.Chat{}, httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Cannot pin an archived chat.",
}, err)
default:
return database.Chat{}, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: errMsg,
Detail: err.Error(),
}, err)
}
}
}
if req.Read != nil {
markRead := *req.Read
var err error
if markRead {
err = api.advanceChatReadCursor(ctx, chat.ID)
} else {
err = api.clearChatReadCursor(ctx, chat.ID)
}
if err != nil {
action := "read"
if !markRead {
action = "unread"
}
return database.Chat{}, chatUpdateError(err, fmt.Sprintf("Failed to mark chat as %s.", action))
}
}
if req.WorkspaceID != nil {
updatedChat, err := api.Database.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
ID: chat.ID,
WorkspaceID: workspaceID,
BuildID: uuid.NullUUID{},
AgentID: uuid.NullUUID{},
})
if err != nil {
return database.Chat{}, chatUpdateError(err, "Failed to update chat workspace binding.")
}
chat = updatedChat
}
if planModeUpdate != nil {
updatedChat, err := api.Database.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
PlanMode: *planModeUpdate,
ID: chat.ID,
})
if err != nil {
return database.Chat{}, chatUpdateError(err, "Failed to update chat plan mode.")
}
chat = updatedChat
}
// The move is the last write, so a refused move keeps the earlier
// writes of the request, as a refused archive does.
if projectUpdate != nil {
err := api.chatDaemon.MoveChatToProject(ctx, chat, *projectUpdate)
if err != nil {
if invalidStateErr := chatInvalidStateError(err); invalidStateErr != nil {
return database.Chat{}, invalidStateErr
}
if errors.Is(err, chatstate.ErrTransitionNotAllowed) {
return database.Chat{}, httperror.WrapResponseError(http.StatusConflict, codersdk.Response{
Message: "Cannot move an active chat. Interrupt or wait for the chat to finish first.",
Detail: err.Error(),
}, err)
}
// The project can be deleted between the check and the move.
if database.IsForeignKeyViolation(err, database.ForeignKeyChatsProjectID) {
return database.Chat{}, httperror.WrapResponseError(http.StatusNotFound, codersdk.Response{
Message: "Chat project not found.",
}, err)
}
return database.Chat{}, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to move chat.",
Detail: err.Error(),
}, err)
}
}
refreshed, err := api.Database.GetChatByID(ctx, chat.ID)
if err != nil {
api.Logger.Error(ctx, "failed to refresh chat for audit", slog.F("chat_id", chat.ID), slog.Error(err))
return chat, nil
}
return refreshed, nil
}
func chatUpdateError(err error, message string) error {
if errors.Is(err, sql.ErrNoRows) {
return httperror.WrapResponseError(http.StatusNotFound, httpapi.ResourceNotFoundResponse, err)
}
return httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: message,
Detail: err.Error(),
}, err)
}
func chatInvalidStateError(err error) error {
if !errors.Is(err, chatstate.ErrInvalidState) {
return nil
}
return httperror.WrapResponseError(http.StatusConflict, codersdk.Response{
Message: "Chat is in an invalid state.",
}, err)
}
// 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 {
return maybeWriteChatError(ctx, rw, chatInvalidStateError(err))
}
func noLocalChatModelResponse() *codersdk.Response {
return &codersdk.Response{
Message: "No chat model is available in this organization.",
Detail: "Ask an organization administrator to configure and enable a chat model.",
}
}
func noLocalChatModelError(err error) error {
if !errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
return nil
}
return httperror.WrapResponseError(http.StatusBadRequest, *noLocalChatModelResponse(), err)
}
func writeNoLocalChatModelResponse(ctx context.Context, rw http.ResponseWriter) {
writeChatError(ctx, rw, noLocalChatModelError(chatd.ErrNoDefaultChatModelConfig))
}
func commonChatMutationError(err error, archivedMessage string) error {
if xerrors.Is(err, chatd.ErrChatArchived) {
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: cmp.Or(archivedMessage, "Cannot mutate an archived chat."),
}, err)
}
if invalidStateErr := chatInvalidStateError(err); invalidStateErr != nil {
return invalidStateErr
}
if errors.Is(err, chatstate.ErrChatNotFound) || httpapi.Is404Error(err) {
return httperror.WrapResponseError(http.StatusNotFound, httpapi.ResourceNotFoundResponse, err)
}
return nil
}
// 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 {
return maybeWriteChatError(ctx, rw, commonChatMutationError(err, archivedMessage))
}
// @Summary Send chat message
// @ID send-chat-message
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param request body codersdk.CreateChatMessageRequest true "Create chat message request"
// @Success 200 {object} codersdk.CreateChatMessageResponse
// @Router /api/v2/chats/{chat}/messages [post]
func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if maybeWriteChatError(ctx, rw, api.checkSendChatMessage(ctx, apiKey.UserID, chat)) {
return
}
var req codersdk.CreateChatMessageRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
response, err := api.sendChatMessage(ctx, apiKey.UserID, chat, req)
if err != nil {
writeChatError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// ctx must carry callerID's RBAC actor. Errors implement httperror.Responder.
func (api *API) checkSendChatMessage(ctx context.Context, callerID uuid.UUID, chat database.Chat) error {
if err := api.chatDaemonError(); err != nil {
return err
}
// Sending a message triggers LLM inference, requiring update
// permission on the org-scoped chat resource.
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUpdate, chat.RBACObject()) {
return httperror.ErrResourceNotFound
}
// 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 callerID != chat.OwnerID {
return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may send messages.",
})
}
if chat.Archived {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Cannot send messages to an archived chat.",
})
}
return nil
}
// The caller must first pass checkSendChatMessage. ctx must carry callerID's
// RBAC actor. Errors implement httperror.Responder.
func (api *API) sendChatMessage(
ctx context.Context,
callerID uuid.UUID,
chat database.Chat,
req codersdk.CreateChatMessageRequest,
) (codersdk.CreateChatMessageResponse, error) {
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, chat.ID, chat.WorkspaceID, req.Content, "content")
if inputError != nil {
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
Detail: inputError.Detail,
})
}
normalizedMCPServerIDs, status, mcpResp := api.normalizeRequestedChatMCPServerIDs(ctx, chat, req.MCPServerIDs)
if mcpResp != nil {
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(status, *mcpResp)
}
req.MCPServerIDs = normalizedMCPServerIDs
if req.InlineMCPServers != nil {
// The field belongs to root chats, so a child rejects every value,
// including []. The kill switch and experiment gates apply only to
// non-empty declarations: [] is the detach path and must keep
// working when the feature is turned off.
if chat.ParentChatID.Valid {
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "inline_mcp_servers can only be declared on a root chat.",
})
}
if len(*req.InlineMCPServers) > 0 {
if api.DeploymentValues.DisableChatCallerSuppliedTools.Value() {
return codersdk.CreateChatMessageResponse{}, chatCallerSuppliedToolsDisabledError()
}
if !api.Experiments.Enabled(codersdk.ExperimentChatInlineMCPServers) {
return codersdk.CreateChatMessageResponse{}, inlineMCPServersExperimentRequiredError()
}
if validations := validateInlineMCPServers(*req.InlineMCPServers, api.MCPAllowedPrivateCIDRs); len(validations) > 0 {
return codersdk.CreateChatMessageResponse{}, inlineMCPServersInvalidError(validations)
}
}
}
if req.PlanMode != nil {
if !validateChatPlanMode(*req.PlanMode) {
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
}
}
var sendPlanMode *database.NullChatPlanMode
if req.PlanMode != nil {
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
sendPlanMode = &resolvedPlanMode
}
var busyBehavior chatd.SendMessageBusyBehavior
switch req.BusyBehavior {
case "", codersdk.ChatBusyBehaviorSteer:
busyBehavior = chatd.SendMessageBusyBehaviorSteer
case codersdk.ChatBusyBehaviorQueue:
busyBehavior = chatd.SendMessageBusyBehaviorQueue
default:
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid busy_behavior value.",
Detail: `Must be "steer" or "queue".`,
})
}
modelConfigID := uuid.Nil
if req.ModelConfigID != nil {
modelConfigID = *req.ModelConfigID
}
if status, resp := api.validateExplicitChatModelConfigAvailable(ctx, callerID, chat.OrganizationID, modelConfigID); resp != nil {
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(status, *resp)
}
reasoningEffort := req.ReasoningEffort
if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) {
return codersdk.CreateChatMessageResponse{}, httperror.NewResponseError(http.StatusBadRequest, invalidReasoningEffortResponse(*reasoningEffort))
}
// Chats created without an initial message have no stored effort,
// so apply the personal override postChats would have applied.
if reasoningEffort == nil && !chat.LastReasoningEffort.Valid {
overrideModelConfigID, overrideEffort, err := api.personalRootModelOverride(ctx, chat.OwnerID, chat.OrganizationID)
if err != nil {
return codersdk.CreateChatMessageResponse{}, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}, err)
}
if overrideEffort != nil &&
overrideModelConfigID == cmp.Or(modelConfigID, chat.LastModelConfigID) &&
chatprovider.IsValidReasoningEffort(*overrideEffort) {
reasoningEffort = overrideEffort
}
}
sendResult, sendErr := api.chatDaemon.SendMessage(
ctx,
chatd.SendMessageOptions{
ChatID: chat.ID,
CreatedBy: callerID,
Content: contentBlocks,
ModelConfigID: modelConfigID,
ReasoningEffort: reasoningEffort,
BusyBehavior: busyBehavior,
PlanMode: sendPlanMode,
MCPServerIDs: req.MCPServerIDs,
InlineMCPServers: req.InlineMCPServers,
},
)
if sendErr != nil {
return codersdk.CreateChatMessageResponse{}, api.sendChatMessageError(sendErr)
}
// Pass the inserted message as the snapshot: SendMessage published
// the ownership hint, so a fresh history read could already contain
// the assistant reply and disqualify title generation.
if sendResult.FirstUserTurn {
api.chatDaemon.GenerateChatTitleForMessagesAsync(
ctx,
sendResult.Chat,
[]database.ChatMessage{sendResult.Message},
)
}
response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued}
if sendResult.Queued {
if sendResult.QueuedMessage != nil {
response.QueuedMessage = new(db2sdk.ChatQueuedMessage(*sendResult.QueuedMessage))
}
} else {
response.Message = new(db2sdk.ChatMessage(sendResult.Message))
}
// Return the full user-visible inserted batch, which clients must cache.
// A send to an errored or paused chat can also insert queued messages
// (see chatd.SendMessageResult.InsertedMessages).
for _, inserted := range sendResult.InsertedMessages {
if inserted.Visibility == database.ChatMessageVisibilityModel {
continue
}
response.Messages = append(response.Messages, db2sdk.ChatMessage(inserted))
}
return response, nil
}
func (api *API) sendChatMessageError(err error) error {
if hookErr := chatHookError(err, "Chat message denied by lifecycle hook."); hookErr != nil {
return hookErr
}
if fileErr := api.chatFileError(err); fileErr != nil {
return fileErr
}
if skillErr := chatSkillInvocationError(err); skillErr != nil {
return skillErr
}
if xerrors.Is(err, chatd.ErrChatArchived) {
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Cannot send messages to an archived chat.",
}, err)
}
if xerrors.Is(err, chatstate.ErrMessageQueueFull) {
var queueFull *chatstate.MessageQueueFullError
detail := ""
if errors.As(err, &queueFull) {
detail = fmt.Sprintf("Maximum %d messages can be queued.", queueFull.Max)
}
return httperror.WrapResponseError(http.StatusTooManyRequests, codersdk.Response{
Message: "Message queue is full.",
Detail: detail,
}, err)
}
if xerrors.Is(err, chatd.ErrInvalidModelConfigID) {
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
}, err)
}
if noModelErr := noLocalChatModelError(err); noModelErr != nil {
return noModelErr
}
if errors.Is(err, chatstate.ErrChatNotFound) {
return httperror.WrapResponseError(http.StatusNotFound, httpapi.ResourceNotFoundResponse, err)
}
if invalidStateErr := chatInvalidStateError(err); invalidStateErr != nil {
return invalidStateErr
}
if errors.Is(err, chatstate.ErrTransitionNotAllowed) {
return httperror.WrapResponseError(http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts new messages.",
Detail: err.Error(),
}, err)
}
return httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat message.",
Detail: chaterror.FormatDiagnosticDetail(err),
}, err)
}
// @Summary Edit chat message
// @ID edit-chat-message
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param message path int true "Message ID"
// @Param request body codersdk.EditChatMessageRequest true "Edit chat message request"
// @Success 200 {object} codersdk.EditChatMessageResponse
// @Router /api/v2/chats/{chat}/messages/{message} [patch]
func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may edit messages. See postChatMessages
// for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may edit messages.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot edit messages in an archived chat.",
})
return
}
messageIDStr := chi.URLParam(r, "message")
messageID, err := strconv.ParseInt(messageIDStr, 10, 64)
if err != nil || messageID <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat message ID.",
Detail: "Message ID must be a positive integer.",
})
return
}
var req codersdk.EditChatMessageRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, chat.ID, chat.WorkspaceID, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
Detail: inputError.Detail,
})
return
}
editModelConfigID := uuid.Nil
if req.ModelConfigID != nil {
editModelConfigID = *req.ModelConfigID
}
if status, resp := api.validateExplicitChatModelConfigAvailable(ctx, apiKey.UserID, chat.OrganizationID, editModelConfigID); resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
editReasoningEffort := req.ReasoningEffort
if editReasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*editReasoningEffort) {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*editReasoningEffort))
return
}
editMCPServerIDs, status, mcpResp := api.normalizeRequestedChatMCPServerIDs(ctx, chat, req.MCPServerIDs)
if mcpResp != nil {
httpapi.Write(ctx, rw, status, *mcpResp)
return
}
editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{
ChatID: chat.ID,
CreatedBy: apiKey.UserID,
EditedMessageID: messageID,
Content: contentBlocks,
ModelConfigID: editModelConfigID,
ReasoningEffort: editReasoningEffort,
MCPServerIDs: editMCPServerIDs,
})
if editErr != nil {
if writeChatHookErr(ctx, rw, editErr, "Chat message denied by lifecycle hook.") {
return
}
if api.writeChatFileError(ctx, rw, editErr) {
return
}
if writeChatSkillInvocationError(ctx, rw, editErr) {
return
}
switch {
case xerrors.Is(editErr, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot edit messages in an archived chat.",
})
case xerrors.Is(editErr, chatd.ErrEditedMessageNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Chat message not found.",
Detail: "Message does not belong to this chat.",
})
case xerrors.Is(editErr, chatd.ErrEditedMessageNotUser):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Only user messages can be edited.",
})
case xerrors.Is(editErr, chatd.ErrInvalidModelConfigID):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
})
case xerrors.Is(editErr, chatd.ErrNoDefaultChatModelConfig):
writeNoLocalChatModelResponse(ctx, rw)
case errors.Is(editErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, editErr):
// response already written
case errors.Is(editErr, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts message edits.",
Detail: editErr.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to edit chat message.",
Detail: editErr.Error(),
})
}
return
}
response := codersdk.EditChatMessageResponse{Message: db2sdk.ChatMessage(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, db2sdk.ChatMessage(inserted))
}
response.DeletedMessageIDs = editResult.DeletedMessageIDs
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Delete chat queued message
// @ID delete-chat-queued-message
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Param queuedMessage path int true "Queued message ID"
// @Success 204
// @Router /api/v2/chats/{chat}/queue/{queuedMessage} [delete]
func (api *API) deleteChatQueuedMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
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
}
// Only the chat owner may delete queued 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 delete queued messages.",
})
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, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot delete queued messages in an archived chat.",
})
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 xerrors.Is(err, chatd.ErrNoDefaultChatModelConfig):
writeNoLocalChatModelResponse(ctx, rw)
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: "Cannot delete this queued message in the chat's current state.",
Detail: err.Error(),
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete queued message.",
Detail: err.Error(),
})
}
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Promote chat queued message
// @ID promote-chat-queued-message
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Param queuedMessage path int true "Queued message ID"
// @Produce json
// @Success 202 {object} codersdk.Response
// @Router /api/v2/chats/{chat}/queue/{queuedMessage}/promote [post]
func (api *API) promoteChatQueuedMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
if !api.requireChatDaemon(ctx, rw) {
return
}
// Promoting a queued message triggers LLM inference,
// requiring update permission on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may promote messages. See
// postChatMessages for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may promote queued messages.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot promote queued messages in an archived chat.",
})
return
}
queuedMessageIDStr := chi.URLParam(r, "queuedMessage")
queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid queued message ID.",
Detail: err.Error(),
})
return
}
txErr := api.chatDaemon.PromoteQueued(ctx, chatd.PromoteQueuedOptions{
ChatID: chatID,
QueuedMessageID: queuedMessageID,
})
if txErr != nil {
switch {
case xerrors.Is(txErr, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot promote queued messages in an archived chat.",
})
case xerrors.Is(txErr, chatstate.ErrQueuedMessageNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
case xerrors.Is(txErr, chatd.ErrNoDefaultChatModelConfig):
writeNoLocalChatModelResponse(ctx, rw)
case errors.Is(txErr, chatstate.ErrChatNotFound):
httpapi.ResourceNotFound(rw)
case writeChatInvalidState(ctx, rw, txErr):
// response already written
case errors.Is(txErr, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot promote this queued message in the chat's current state.",
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.",
})
}
// @Summary Edit chat queued message
// @ID edit-chat-queued-message
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param chat path string true "Chat ID" format(uuid)
// @Param queuedMessage path int true "Queued message ID"
// @Param request body codersdk.EditChatQueuedMessageRequest true "Edit chat queued message request"
// @Success 204
// @Router /api/v2/chats/{chat}/queue/{queuedMessage} [patch]
func (api *API) patchChatQueuedMessage(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
// Ending the edit of a paused chat's head 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 edit queued 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 queued messages.",
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot edit queued messages in an archived chat.",
})
return
}
queuedMessageIDStr := chi.URLParam(r, "queuedMessage")
queuedMessageID, err := strconv.ParseInt(queuedMessageIDStr, 10, 64)
if err != nil || queuedMessageID <= 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid queued message ID.",
Detail: "Queued message ID must be a positive integer.",
})
return
}
var req codersdk.EditChatQueuedMessageRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.Content == nil && req.Editing == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Nothing to edit.",
Detail: "Provide content, editing, or both.",
})
return
}
if req.Content == nil && (req.ModelConfigID != nil || req.ReasoningEffort != nil) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "model_config_id and reasoning_effort require content.",
Detail: "The overrides apply to the content they are sent with.",
})
return
}
opts := chatd.EditQueuedMessageOptions{
ChatID: chat.ID,
QueuedMessageID: queuedMessageID,
Editing: req.Editing,
}
if req.Content != nil {
contentBlocks, _, inputError := createChatInputFromParts(ctx, api.Database, chat.ID, chat.WorkspaceID, req.Content, "content")
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: inputError.Message,
Detail: inputError.Detail,
})
return
}
opts.Content = contentBlocks
if req.ModelConfigID != nil {
opts.ModelConfigID = *req.ModelConfigID
}
if status, resp := api.validateExplicitChatModelConfigAvailable(ctx, apiKey.UserID, chat.OrganizationID, opts.ModelConfigID); resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
if req.ReasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*req.ReasoningEffort) {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*req.ReasoningEffort))
return
}
opts.ReasoningEffort = req.ReasoningEffort
}
err = api.chatDaemon.EditQueuedMessage(ctx, opts)
if err != nil {
if writeChatSkillInvocationError(ctx, rw, err) {
return
}
if writeChatHookErr(ctx, rw, err, "Chat message denied by lifecycle hook.") {
return
}
if api.writeChatFileError(ctx, rw, err) {
return
}
switch {
case xerrors.Is(err, chatd.ErrChatArchived):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot edit queued messages in an archived chat.",
})
case errors.Is(err, chatstate.ErrQueuedMessageNotFound):
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Queued message not found.",
})
case xerrors.Is(err, chatd.ErrInvalidModelConfigID):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model config ID.",
})
case xerrors.Is(err, chatd.ErrNoDefaultChatModelConfig):
writeNoLocalChatModelResponse(ctx, rw)
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: "Cannot edit this queued message in the chat's current state.",
Detail: err.Error(),
})
case errors.Is(err, chatstate.ErrPausedQueuedHeadUnderEdit):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "The chat is paused at a queued message under edit. Finish editing, send, or remove it before editing another.",
})
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to edit queued message.",
Detail: err.Error(),
})
}
return
}
rw.WriteHeader(http.StatusNoContent)
}
// markChatAsRead updates the last read message ID for a chat to the
// latest message, so subsequent unread checks treat all current
// messages as seen. This is called on stream connect and disconnect
// to avoid per-message API calls during active streaming.
func (api *API) markChatAsRead(ctx context.Context, chatID uuid.UUID) {
if err := api.advanceChatReadCursor(ctx, chatID); err != nil {
api.Logger.Warn(ctx, "failed to mark chat as read",
slog.F("chat_id", chatID),
slog.Error(err),
)
}
}
// advanceChatReadCursor moves the chat's owner-scoped read cursor to the
// latest assistant message, so nothing in the chat is unread.
func (api *API) advanceChatReadCursor(ctx context.Context, chatID uuid.UUID) error {
lastMsg, err := api.Database.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{
ChatID: chatID,
Role: database.ChatMessageRoleAssistant,
})
if errors.Is(err, sql.ErrNoRows) {
// No assistant messages yet, so nothing can be unread.
return nil
}
if err != nil {
return xerrors.Errorf("get last assistant message: %w", err)
}
if err := api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chatID,
LastReadMessageID: sql.NullInt64{Int64: lastMsg.ID, Valid: true},
}); err != nil {
return xerrors.Errorf("update chat last read message id: %w", err)
}
return nil
}
// clearChatReadCursor clears the chat's owner-scoped read cursor, so every
// assistant message in the chat counts as unread again.
func (api *API) clearChatReadCursor(ctx context.Context, chatID uuid.UUID) error {
if err := api.Database.UpdateChatLastReadMessageID(ctx, database.UpdateChatLastReadMessageIDParams{
ID: chatID,
LastReadMessageID: sql.NullInt64{},
}); err != nil {
return xerrors.Errorf("clear chat last read message id: %w", err)
}
return nil
}
// @Summary Stream chat events via WebSockets
// @ID stream-chat-events-via-websockets
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param after_id query int false "Skip snapshot messages with id at or before this cursor"
// @Success 200 {array} codersdk.ChatStreamEvent
// @Router /api/v2/chats/{chat}/stream [get]
func (api *API) streamChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_streamer").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
var afterMessageID int64
if v := r.URL.Query().Get("after_id"); v != "" {
var err error
afterMessageID, err = strconv.ParseInt(v, 10, 64)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid after_id parameter.",
Detail: err.Error(),
})
return
}
}
// Subscribe before accepting the WebSocket so that failures
// can still be reported as normal HTTP errors.
snapshot, events, cancelSub, ok := api.chatDaemon.SubscribeAuthorized(ctx, chat, r.Header, afterMessageID)
// Defensive against future SubscribeAuthorized failure modes.
if !ok {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat streaming is not available.",
Detail: "Chat stream state is not configured.",
})
return
}
defer cancelSub()
conn, err := websocket.Accept(rw, r, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to open chat stream.",
Detail: err.Error(),
})
return
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
_ = conn.CloseRead(context.Background())
ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText)
defer wsNetConn.Close()
ctx = api.wsWatcher.Watch(ctx, logger, conn)
// The last_read_message_id field is owner-scoped. Shared readers
// intentionally lack chat update permission, so their streams must not
// update it.
if chat.OwnerID == httpmw.APIKey(r).UserID {
api.markChatAsRead(ctx, chatID)
defer api.markChatAsRead(context.WithoutCancel(ctx), chatID)
}
encoder := json.NewEncoder(wsNetConn)
sendChatStreamBatch := func(batch []codersdk.ChatStreamEvent) error {
if len(batch) == 0 {
return nil
}
return encoder.Encode(batch)
}
drainChatStreamBatch := func(
first codersdk.ChatStreamEvent,
maxBatchSize int,
) ([]codersdk.ChatStreamEvent, bool) {
batch := []codersdk.ChatStreamEvent{first}
if maxBatchSize <= 1 {
return batch, false
}
for len(batch) < maxBatchSize {
select {
case event, ok := <-events:
if !ok {
return batch, true
}
batch = append(batch, event)
default:
return batch, false
}
}
return batch, false
}
for start := 0; start < len(snapshot); start += chatStreamBatchSize {
end := start + chatStreamBatchSize
if end > len(snapshot) {
end = len(snapshot)
}
if err := sendChatStreamBatch(snapshot[start:end]); err != nil {
logger.Debug(ctx, "failed to send chat stream snapshot", slog.Error(err))
return
}
}
for {
select {
case <-ctx.Done():
return
case firstEvent, ok := <-events:
if !ok {
return
}
batch, streamClosed := drainChatStreamBatch(
firstEvent,
chatStreamBatchSize,
)
if err := sendChatStreamBatch(batch); err != nil {
logger.Debug(ctx, "failed to send chat stream event", slog.Error(err))
return
}
if streamClosed {
return
}
}
}
}
// @Summary Interrupt chat
// @ID interrupt-chat
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Produce json
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/interrupt [post]
// @Description Interrupts the active turn of a chat.
// @Description If the chat has nothing to interrupt, for example because its turn already ended, the endpoint returns the chat unchanged.
func (api *API) postChatInterrupt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat, err := api.interruptChat(ctx, httpmw.ChatParam(r))
if err != nil {
writeChatError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, chat)
}
// ctx must carry the caller's RBAC actor. Errors implement httperror.Responder.
func (api *API) interruptChat(ctx context.Context, chat database.Chat) (codersdk.Chat, error) {
if err := api.chatDaemonError(); err != nil {
return codersdk.Chat{}, err
}
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUpdate, chat.RBACObject()) {
return codersdk.Chat{}, httperror.ErrResourceNotFound
}
updated, _, err := api.chatDaemon.InterruptChat(ctx, chat)
if err != nil {
if mutationErr := commonChatMutationError(err, "Cannot interrupt an archived chat."); mutationErr != nil {
return codersdk.Chat{}, mutationErr
}
if errors.Is(err, chatstate.ErrTransitionNotAllowed) {
return codersdk.Chat{}, httperror.WrapResponseError(http.StatusConflict, codersdk.Response{
Message: "Chat is not in an interruptible state.",
Detail: err.Error(),
}, err)
}
api.Logger.Named("chat_interrupt").Error(ctx, "failed to interrupt chat", slog.F("chat_id", chat.ID), slog.Error(err))
return codersdk.Chat{}, httperror.WrapResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to interrupt chat.",
Detail: err.Error(),
}, err)
}
return db2sdk.Chat(updated, nil, nil), nil
}
// @Summary Compact chat
// @ID compact-chat
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Produce json
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/compact [post]
// @x-apidocgen {"skip": true}
// @Description Requests a manual context compaction on an idle or errored
// @Description chat, clearing any stored error. The compaction runs
// @Description asynchronously through the chat worker and bypasses the
// @Description automatic usage threshold.
func (api *API) compactChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_compact").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
// Compaction triggers LLM inference, requiring update permission
// on the org-scoped chat resource.
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may trigger compaction. Org admins pass the
// RBAC check above (org-level ActionUpdate), but compaction runs
// inference with the owner's delegated credentials.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may compact the chat.",
})
return
}
updated, err := api.chatDaemon.CompactChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "Cannot compact an archived chat.") {
return
}
switch {
case errors.Is(err, chatd.ErrNothingToCompact):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Nothing to compact.",
Detail: "The chat has no conversation to summarize after the latest compaction.",
})
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot compact the chat in its current state.",
Detail: "Compaction is available only while the chat is waiting or errored.",
})
default:
logger.Error(ctx, "failed to compact chat", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to compact chat.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// @Summary Clear chat context
// @ID clear-chat-context
// @Security CoderSessionToken
// @Tags Chats
// @Param chat path string true "Chat ID" format(uuid)
// @Produce json
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/clear [post]
// @x-apidocgen {"skip": true}
// @Description Resets the model context of an idle or errored chat,
// @Description clearing any stored error. The reset commits
// @Description synchronously with no model call: the transcript is
// @Description preserved and the next prompt starts from a fresh
// @Description context.
func (api *API) clearChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_clear").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may clear the context, matching the
// compaction endpoint so the two context operations share
// authorization semantics.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may clear the chat context.",
})
return
}
updated, err := api.chatDaemon.ClearChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "Cannot clear an archived chat.") {
return
}
switch {
case errors.Is(err, chatd.ErrNothingToClear):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Nothing to clear.",
Detail: "The chat has no conversation to clear after the latest context boundary.",
})
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot clear the chat in its current state.",
Detail: "Clearing is not available while the chat is generating or has queued messages.",
})
default:
logger.Error(ctx, "failed to clear chat context", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to clear chat context.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// @Summary Reconcile invalid chat state
// @ID reconcile-invalid-chat-state
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.Chat
// @Router /api/v2/chats/{chat}/reconcile-invalid [post]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) reconcileInvalidChatState(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
chatID := chat.ID
logger := api.Logger.Named("chat_reconcile_invalid").With(slog.F("chat_id", chatID))
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
updated, err := api.chatDaemon.ReconcileInvalidStateChat(ctx, chat)
if err != nil {
if writeCommonChatMutationError(ctx, rw, err, "") {
return
}
switch {
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in an invalid state.",
Detail: err.Error(),
})
default:
logger.Error(ctx, "failed to reconcile invalid chat state", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to reconcile chat state.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updated, nil, nil))
}
// @Summary Propose chat title
// @ID propose-chat-title
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ProposeChatTitleResponse
// @Router /api/v2/chats/{chat}/title/propose [post]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.requireChatDaemon(ctx, rw) {
return
}
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
// Only the chat owner may propose titles. See
// postChatMessages for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may propose a title.",
})
return
}
title, err := api.chatDaemon.ProposeChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
writeNoLocalChatModelResponse(ctx, rw)
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if maybeWriteChatUsageLimitError(ctx, rw, err) {
return
}
if maybeWriteManualTitleTimeoutErr(ctx, rw, err) {
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate chat title.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ProposeChatTitleResponse{Title: title})
}
// @Summary Get chat diff contents
// @ID get-chat-diff-contents
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param origin query string false "Remote origin selecting the ref to diff"
// @Param branch query string false "Git branch selecting the ref to diff"
// @Success 200 {object} codersdk.ChatDiffContents
// @Router /api/v2/chats/{chat}/diff [get]
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatDiffContents(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
selector := codersdk.DiffStatusRef{
RemoteOrigin: strings.TrimSpace(r.URL.Query().Get("origin")),
GitBranch: strings.TrimSpace(r.URL.Query().Get("branch")),
}
if (selector.RemoteOrigin == "") != (selector.GitBranch == "") {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Set the origin and branch query parameters together, or omit both.",
})
return
}
diff, err := api.resolveChatDiffContents(ctx, chat, selector)
if err != nil {
if xerrors.Is(err, errNoDiffStatusForRef) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "The chat does not track that ref.",
Detail: fmt.Sprintf("origin %q, branch %q", selector.RemoteOrigin, selector.GitBranch),
})
return
}
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)
}
// chatRenderTemplateParameters evaluates a template version's parameters
// for the chat owner with no inputs, which is the form state the owner would
// see on the workspace creation page. It authorizes as the owner itself, like
// the other chat hooks. Prepare selects the static renderer for versions that
// predate dynamic parameters and returns
// dynamicparameters.ErrTemplateVersionNotReady while the import job runs.
func (api *API) chatRenderTemplateParameters(
ctx context.Context,
ownerID uuid.UUID,
templateVersionID uuid.UUID,
) ([]codersdk.PreviewParameter, []codersdk.FriendlyDiagnostic, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
return nil, nil, xerrors.Errorf("load user authorization: %w", err)
}
ctx = dbauthz.As(ctx, actor)
renderer, err := dynamicparameters.Prepare(ctx, api.Database, api.FileCache, templateVersionID,
dynamicparameters.WithPreviewOptions(dynamicparameters.PreviewOptions(api.DeploymentValues)...),
)
if err != nil {
return nil, nil, xerrors.Errorf("prepare template version renderer: %w", err)
}
defer renderer.Close()
output, diags := renderer.Render(ctx, ownerID, map[string]string{})
var params []codersdk.PreviewParameter
if output != nil {
params = slice.List(output.Parameters, db2sdk.PreviewParameter)
}
return params, db2sdk.HCLDiagnostics(diags), nil
}
// 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,
chatID uuid.UUID,
req codersdk.CreateWorkspaceRequest,
) (codersdk.Workspace, database.Chat, error) {
actor, _, err := httpmw.UserRBACSubject(ctx, api.Database, ownerID, rbac.ScopeAll)
if err != nil {
return codersdk.Workspace{}, database.Chat{}, 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{}, database.Chat{}, 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{}, database.Chat{}, 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{}, database.Chat{}, 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()
var chat database.Chat
workspace, err := createWorkspace(ctx, aReq, ownerID, api, owner, req, &createWorkspaceOptions{
postBuildInTX: func(ctx context.Context, tx database.Store, workspace database.Workspace, build database.WorkspaceBuild) error {
bound, err := tx.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
ID: chatID,
WorkspaceID: uuid.NullUUID{UUID: workspace.ID, Valid: true},
BuildID: uuid.NullUUID{UUID: build.ID, Valid: true},
AgentID: uuid.NullUUID{},
})
if err != nil {
return xerrors.Errorf("bind chat to workspace: %w", err)
}
chat = bound
return nil
},
})
if err != nil {
sw.WriteHeader(chatWorkspaceAuditStatus(err))
return codersdk.Workspace{}, database.Chat{}, err
}
sw.WriteHeader(http.StatusCreated)
return workspace, chat, 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,
selector codersdk.DiffStatusRef,
) (codersdk.ChatDiffContents, error) {
result := codersdk.ChatDiffContents{ChatID: chat.ID}
statuses, err := api.Database.GetChatDiffStatusesByChatID(ctx, chat.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return result, err
}
status, found := selectChatDiffStatus(statuses, selector)
if !found && selector.RemoteOrigin != "" {
return result, errNoDiffStatusForRef
}
reference, err := api.resolveChatDiffReference(ctx, chat, found, status)
if err != nil {
return result, err
}
if reference.RepositoryRef != nil {
provider := strings.TrimSpace(reference.RepositoryRef.Provider)
if provider != "" {
result.Provider = &provider
}
origin := strings.TrimSpace(reference.RepositoryRef.RemoteOrigin)
if origin != "" {
result.RemoteOrigin = &origin
}
branch := strings.TrimSpace(reference.RepositoryRef.Branch)
if branch != "" {
result.Branch = &branch
}
}
if reference.PullRequestURL != "" {
pullRequestURL := strings.TrimSpace(reference.PullRequestURL)
result.PullRequestURL = &pullRequestURL
// The agent's report creates the row. Discovery only fills in
// the URL, so skip until the ref was reported.
if found && (!strings.EqualFold(strings.TrimSpace(status.Url.String), pullRequestURL)) {
err := api.Database.UpdateChatDiffStatusReferenceURL(
ctx,
database.UpdateChatDiffStatusReferenceURLParams{
ChatID: status.ChatID,
GitBranch: status.GitBranch,
GitRemoteOrigin: status.GitRemoteOrigin,
StaleAt: time.Now().UTC().Add(-time.Second),
Url: pullRequestURL,
},
)
if err != nil {
return result, xerrors.Errorf("update chat diff status reference url: %w", err)
}
}
}
if reference.RepositoryRef == nil {
return result, nil
}
gp := api.resolveGitProvider(ctx, reference.RepositoryRef.RemoteOrigin)
if gp == nil {
return result, nil
}
token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin)
if errors.Is(err, gitsync.ErrNoTokenAvailable) || token == nil {
// No token available; return metadata without fetching diff.
return result, nil
} else if err != nil {
return result, xerrors.Errorf("resolve git access token: %w", err)
}
if reference.PullRequestURL != "" {
ref, ok := gp.ParsePullRequestURL(reference.PullRequestURL)
if !ok {
return result, xerrors.Errorf("invalid pull request URL %q", reference.PullRequestURL)
}
diff, err := gp.FetchPullRequestDiff(ctx, *token, ref)
if err != nil {
return result, err
}
result.Diff = diff
return result, nil
}
diff, err := gp.FetchBranchDiff(ctx, *token, gitprovider.BranchRef{
Owner: reference.RepositoryRef.Owner,
Repo: reference.RepositoryRef.Repo,
Branch: reference.RepositoryRef.Branch,
})
if err != nil {
return result, err
}
result.Diff = diff
return result, nil
}
// resolveChatDiffReference builds the diff reference from the cached
// status stored in the database. The git branch and remote origin are
// populated by the workspace agent during git operations (via the
// gitaskpass flow), so no SSH into the workspace is needed here.
//
//nolint:revive // Boolean indicates whether diff status was found.
func (api *API) resolveChatDiffReference(
ctx context.Context,
chat database.Chat,
found bool,
status database.ChatDiffStatus,
) (chatDiffReference, error) {
reference := chatDiffReference{}
if !found {
return reference, nil
}
reference.PullRequestURL = strings.TrimSpace(status.Url.String)
// Build the repository ref from the stored git branch/origin
// that the agent reported.
reference.RepositoryRef = api.buildChatRepositoryRefFromStatus(ctx, status)
// If we have a repo ref with a branch, try to resolve the
// current open PR. This picks up new PRs after the previous
// one was closed.
if reference.RepositoryRef != nil && reference.RepositoryRef.Owner != "" {
gp := api.resolveGitProvider(ctx, reference.RepositoryRef.RemoteOrigin)
if gp != nil {
token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin)
if token == nil || errors.Is(err, gitsync.ErrNoTokenAvailable) {
// No token available yet.
return reference, nil
} else if err != nil {
return chatDiffReference{}, xerrors.Errorf("resolve git access token: %w", err)
}
prRef, lookupErr := gp.ResolveBranchPullRequest(ctx, *token, gitprovider.BranchRef{
Owner: reference.RepositoryRef.Owner,
Repo: reference.RepositoryRef.Repo,
Branch: reference.RepositoryRef.Branch,
})
if lookupErr != nil {
api.Logger.Debug(ctx, "failed to resolve pull request from repository reference",
slog.F("chat_id", chat.ID),
slog.F("provider", reference.RepositoryRef.Provider),
slog.F("remote_origin", reference.RepositoryRef.RemoteOrigin),
slog.F("branch", reference.RepositoryRef.Branch),
slog.Error(lookupErr),
)
} else if prRef != nil {
reference.PullRequestURL = gp.BuildPullRequestURL(*prRef)
}
reference.PullRequestURL = gp.NormalizePullRequestURL(reference.PullRequestURL)
}
}
// If we have a PR URL but no repo ref (e.g. the agent hasn't
// reported branch/origin yet), derive a partial ref from the
// PR URL so the caller can still show provider/owner/repo.
if reference.RepositoryRef == nil && reference.PullRequestURL != "" {
for _, extAuth := range api.ExternalAuthConfigs {
gp, err := extAuth.Git()
if err != nil || gp == nil {
continue
}
if parsed, ok := gp.ParsePullRequestURL(reference.PullRequestURL); ok {
reference.RepositoryRef = &chatRepositoryRef{
Provider: strings.ToLower(extAuth.Type),
Owner: parsed.Owner,
Repo: parsed.Repo,
RemoteOrigin: gp.BuildRepositoryURL(parsed.Owner, parsed.Repo),
}
break
}
}
}
return reference, nil
}
// buildChatRepositoryRefFromStatus constructs a chatRepositoryRef
// from the git branch and remote origin stored in the cached status.
// Returns nil if no ref data is available.
func (api *API) buildChatRepositoryRefFromStatus(ctx context.Context, status database.ChatDiffStatus) *chatRepositoryRef {
branch := strings.TrimSpace(status.GitBranch)
origin := strings.TrimSpace(status.GitRemoteOrigin)
if branch == "" || origin == "" {
return nil
}
providerType, gp := api.resolveExternalAuth(ctx, origin)
repoRef := &chatRepositoryRef{
Provider: providerType,
RemoteOrigin: origin,
Branch: branch,
}
if gp != nil {
if owner, repo, normalizedOrigin, ok := gp.ParseRepositoryOrigin(repoRef.RemoteOrigin); ok {
repoRef.RemoteOrigin = normalizedOrigin
repoRef.Owner = owner
repoRef.Repo = repo
}
}
if repoRef.Provider == "" {
return nil
}
return repoRef
}
// selectChatDiffStatus returns the status row a diff request targets.
// A non-empty selector must match a stored ref exactly.
func selectChatDiffStatus(
statuses []database.ChatDiffStatus,
selector codersdk.DiffStatusRef,
) (database.ChatDiffStatus, bool) {
if selector.RemoteOrigin == "" && selector.GitBranch == "" {
primary := db2sdk.PrimaryChatDiffStatus(statuses)
if primary == nil {
return database.ChatDiffStatus{}, false
}
return *primary, true
}
for i := range statuses {
if statuses[i].GitRemoteOrigin == selector.RemoteOrigin && statuses[i].GitBranch == selector.GitBranch {
return statuses[i], true
}
}
return database.ChatDiffStatus{}, false
}
// resolveExternalAuth finds the external auth config matching the
// given remote origin URL and returns both the provider type string
// (e.g. "github") and the gitprovider.Provider. Returns ("", nil)
// if no matching config is found or no provider could be constructed.
func (api *API) resolveExternalAuth(ctx context.Context, origin string) (providerType string, gp gitprovider.Provider) {
origin = strings.TrimSpace(origin)
if origin == "" {
return "", nil
}
for _, extAuth := range api.ExternalAuthConfigs {
if extAuth.Regex == nil || !extAuth.Regex.MatchString(origin) {
continue
}
p, err := extAuth.Git()
if err != nil {
api.Logger.Warn(ctx, "failed to construct git provider",
slog.F("provider_id", extAuth.ID),
slog.F("provider_type", extAuth.Type),
slog.Error(err),
)
continue
}
if p == nil {
continue
}
return strings.ToLower(strings.TrimSpace(extAuth.Type)), p
}
return "", nil
}
// resolveGitProvider finds the external auth config matching the
// given remote origin URL and returns its git provider. Returns
// nil if no matching git provider is configured.
func (api *API) resolveGitProvider(ctx context.Context, origin string) gitprovider.Provider {
_, gp := api.resolveExternalAuth(ctx, origin)
return gp
}
func (api *API) resolveChatGitAccessToken(
ctx context.Context,
userID uuid.UUID,
origin string,
) (*string, error) {
origin = strings.TrimSpace(origin)
// If we have an origin, find the specific matching config first.
// This ensures multi-provider setups (github.com + GHE) get the
// correct token.
if origin != "" {
for _, config := range api.ExternalAuthConfigs {
if config.Regex == nil || !config.Regex.MatchString(origin) {
continue
}
//nolint:gocritic // System access needed to read external auth
// links when called from the gitsync worker (chatd context).
link, err := api.Database.GetExternalAuthLink(dbauthz.AsSystemRestricted(ctx),
database.GetExternalAuthLinkParams{
ProviderID: config.ID,
UserID: userID,
},
)
if err != nil {
continue
}
//nolint:gocritic // System context carried through for token refresh.
refreshed, refreshErr := config.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
if refreshErr == nil {
link = refreshed
}
token := strings.TrimSpace(link.OAuthAccessToken)
if token != "" {
return new(token), nil
}
}
}
// Fallback: iterate all external auth configs.
// Used when origin is empty (inline refresh from HTTP handler)
// or when the origin-specific lookup above failed.
configs := make(map[string]*externalauth.Config)
providerIDs := []string{}
for _, config := range api.ExternalAuthConfigs {
providerIDs = append(providerIDs, config.ID)
configs[config.ID] = config
}
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
if _, ok := seen[providerID]; ok {
continue
}
seen[providerID] = struct{}{}
//nolint:gocritic // System access needed to read external auth
// links when called from the gitsync worker (chatd context).
link, err := api.Database.GetExternalAuthLink(
dbauthz.AsSystemRestricted(ctx),
database.GetExternalAuthLinkParams{
ProviderID: providerID,
UserID: userID,
},
)
if err != nil {
continue
}
// Refresh the token if there is a matching config, mirroring
// the same code path used by provisionerdserver when handing
// tokens to provisioners.
if cfg, ok := configs[providerID]; ok {
//nolint:gocritic // System context carried through for token refresh.
refreshed, refreshErr := cfg.RefreshToken(dbauthz.AsSystemRestricted(ctx), api.Database, link)
if refreshErr != nil {
api.Logger.Debug(ctx, "failed to refresh external auth token for chat diff",
slog.F("provider_id", providerID),
slog.F("user_id", userID),
slog.Error(refreshErr),
)
// Fall through because the existing token may still work.
// (e.g. GitHub tokens with no expiry).
} else {
link = refreshed
}
}
token := strings.TrimSpace(link.OAuthAccessToken)
if token != "" {
return new(token), nil
}
}
return nil, gitsync.ErrNoTokenAvailable
}
type createChatWorkspaceSelection struct {
WorkspaceID uuid.NullUUID
}
// validateChatProjectSelection returns the project for a chat of ownerID in
// organizationID. A chat in a project reads and writes its memory, so the
// chat's owner must be able to use the project. A project the owner cannot
// use gets the unreadable-project response so callers acting for other
// users cannot probe for project IDs.
func (api *API) validateChatProjectSelection(
ctx context.Context,
projectID, ownerID, organizationID uuid.UUID,
) (uuid.NullUUID, int, *codersdk.Response) {
notFound := &codersdk.Response{Message: "Chat project not found."}
project, err := api.Database.GetChatProjectByID(ctx, projectID)
if err != nil {
if httpapi.Is404Error(err) {
return uuid.NullUUID{}, http.StatusNotFound, notFound
}
return uuid.NullUUID{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error fetching chat project.",
Detail: err.Error(),
}
}
usable, err := chatd.ChatProjectUsableBy(ctx, api.Database, project, ownerID)
if err != nil {
return uuid.NullUUID{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Internal error checking chat project access.",
Detail: err.Error(),
}
}
if !usable {
return uuid.NullUUID{}, http.StatusNotFound, notFound
}
if project.OrganizationID != organizationID {
return uuid.NullUUID{}, http.StatusBadRequest, &codersdk.Response{
Message: "Chat project does not belong to this chat's organization.",
}
}
return uuid.NullUUID{UUID: project.ID, Valid: true}, 0, nil
}
func (api *API) validateChatWorkspaceSelection(
ctx context.Context,
workspaceID *uuid.UUID,
) (
uuid.NullUUID,
database.Workspace,
int,
*codersdk.Response,
) {
if workspaceID == nil {
return uuid.NullUUID{}, database.Workspace{}, 0, nil
}
workspace, err := api.Database.GetWorkspaceByID(ctx, *workspaceID)
if err != nil {
if httpapi.Is404Error(err) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return uuid.NullUUID{}, database.Workspace{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to get workspace.",
Detail: err.Error(),
}
}
selection := uuid.NullUUID{
UUID: workspace.ID,
Valid: true,
}
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionSSH, workspace) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return selection, workspace, 0, nil
}
func (api *API) validateCreateChatWorkspaceSelection(
ctx context.Context,
req codersdk.CreateChatRequest,
) (
createChatWorkspaceSelection,
int,
*codersdk.Response,
) {
selection := createChatWorkspaceSelection{}
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, req.WorkspaceID)
if resp != nil {
return selection, status, resp
}
selection.WorkspaceID = workspaceID
if !workspaceID.Valid {
return selection, 0, nil
}
if workspace.OrganizationID != req.OrganizationID {
return selection, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace does not belong to the specified organization.",
}
}
return selection, 0, nil
}
func (api *API) resolveCreateChatModelConfigID(
ctx context.Context,
userID uuid.UUID,
req codersdk.CreateChatRequest,
) (uuid.UUID, *string, int, *codersdk.Response) {
if req.ModelConfigID != nil {
if *req.ModelConfigID == uuid.Nil {
return uuid.Nil, nil, http.StatusBadRequest, &codersdk.Response{
Message: "Invalid model config ID.",
}
}
if _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, req.OrganizationID, *req.ModelConfigID); resp != nil {
return uuid.Nil, nil, status, resp
}
return *req.ModelConfigID, nil, 0, nil
}
overrideModelConfigID, overrideEffort, err := api.personalRootModelOverride(ctx, userID, req.OrganizationID)
if err != nil {
return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
if overrideModelConfigID != uuid.Nil {
return overrideModelConfigID, overrideEffort, 0, nil
}
id, status, resp := api.defaultCreateChatModelConfigID(ctx, req.OrganizationID)
return id, nil, status, resp
}
// personalRootModelOverride returns the user's available personal root
// model override and its reasoning effort, or uuid.Nil when personal
// overrides are disabled or none applies.
func (api *API) personalRootModelOverride(
ctx context.Context,
userID uuid.UUID,
organizationID uuid.UUID,
) (uuid.UUID, *string, error) {
personalOverridesEnabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
return uuid.Nil, nil, err
}
if !personalOverridesEnabled {
return uuid.Nil, nil, nil
}
override, err := api.Database.GetChatUserModelOverride(ctx, database.GetChatUserModelOverrideParams{
UserID: userID,
OrganizationID: organizationID,
Context: string(codersdk.ChatPersonalModelOverrideContextRoot),
})
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil, nil
}
if err != nil {
return uuid.Nil, nil, err
}
switch codersdk.ChatPersonalModelOverrideMode(override.Mode) {
case codersdk.ChatPersonalModelOverrideModeChatDefault:
case codersdk.ChatPersonalModelOverrideModeModel:
if override.ModelConfigID.Valid {
_, reason, err := api.userCanUseChatModelConfig(
ctx,
userID,
organizationID,
override.ModelConfigID.UUID,
)
if err != nil {
return uuid.Nil, nil, err
}
if reason == chatModelConfigAvailable {
var effort *string
if override.ReasoningEffort.Valid {
effort = &override.ReasoningEffort.String
}
return override.ModelConfigID.UUID, effort, nil
}
api.Logger.Debug(
ctx,
"personal root model override is unavailable, using default model",
slog.F("user_id", userID),
slog.F("model_config_id", override.ModelConfigID.UUID),
slog.F("reason", reason),
)
}
default:
api.Logger.Warn(
ctx,
"unsupported personal root model override mode, using default model",
slog.F("user_id", userID),
slog.F("mode", override.Mode),
)
}
return uuid.Nil, nil, nil
}
func (api *API) defaultCreateChatModelConfigID(
ctx context.Context,
organizationID uuid.UUID,
) (uuid.UUID, int, *codersdk.Response) {
rows, err := api.Database.GetEnabledChatModelConfigsByOrganization(ctx, organizationID)
if err != nil {
return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to resolve chat model config.",
Detail: err.Error(),
}
}
effective := database.DeriveEffectiveChatModelConfigs(rows)
if effective.DefaultConfig.ID != uuid.Nil {
return effective.DefaultConfig.ID, 0, nil
}
return uuid.Nil, http.StatusBadRequest, noLocalChatModelResponse()
}
// validateChatCompressionThreshold enforces the chat_model_configs CHECK
// constraint range.
func validateChatCompressionThreshold(threshold int32) error {
if threshold < minChatContextCompressionThreshold ||
threshold > maxChatContextCompressionThreshold {
return xerrors.Errorf(
"context_compression_threshold must be between %d and %d",
minChatContextCompressionThreshold,
maxChatContextCompressionThreshold,
)
}
return nil
}
func normalizeChatCompressionThreshold(
requested *int32,
fallback int32,
) (int32, error) {
threshold := fallback
if requested != nil {
threshold = *requested
}
if err := validateChatCompressionThreshold(threshold); err != nil {
return 0, err
}
return threshold, nil
}
func parseCompactionThresholdKey(key string) (uuid.UUID, error) {
if !strings.HasPrefix(key, codersdk.ChatCompactionThresholdKeyPrefix) {
return uuid.Nil, xerrors.Errorf("invalid compaction threshold key: %q", key)
}
id, err := uuid.Parse(key[len(codersdk.ChatCompactionThresholdKeyPrefix):])
if err != nil {
return uuid.Nil, xerrors.Errorf("invalid model config ID in key %q: %w", key, err)
}
return id, nil
}
// @Summary Get chat system prompt
// @ID get-chat-system-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatSystemPromptResponse
// @Router /api/v2/chats/config/system-prompt [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
config, err := api.Database.GetChatSystemPromptConfig(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat system prompt configuration.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatSystemPromptResponse{
SystemPrompt: config.ChatSystemPrompt,
IncludeDefaultSystemPrompt: config.IncludeDefaultSystemPrompt,
DefaultSystemPrompt: chatd.DefaultSystemPrompt,
})
}
// chatInstructionSettingsLockTimeout bounds how long a request waits for the
// per-setting advisory lock. The only other holders are sibling requests
// holding it for a single upsert.
const chatInstructionSettingsLockTimeout = 5 * time.Second
// @Summary Update chat system prompt
// @ID update-chat-system-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatSystemPromptRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/system-prompt [put]
func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Identity is assigned before the authorization check so a denied PUT
// records the attempt with status 403 and an empty diff. The body is
// never read before authorization, so no request content reaches that
// row.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.Old = database.ChatInstructionSettings{
ID: audit.ChatInstructionSystemPromptID,
Name: audit.ChatInstructionSystemPromptName,
}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
var req codersdk.UpdateChatSystemPromptRequest
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &req) {
return
}
sanitizedPrompt := codersdk.SanitizePromptText(req.SystemPrompt)
if api.writePromptTooLongError(ctx, rw, sanitizedPrompt, "System prompt exceeds maximum length.") {
return
}
var noChange bool
// The per-setting advisory lock serializes the audit change-detection
// with the write: two concurrent identical PUTs both still succeed,
// but the second transaction's comparison sees the first's committed
// state and reports no change. The lock wait is bounded so a waiter
// cannot hang past the client's deadline.
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionSystemPrompt); err != nil {
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
oldConfig, err := tx.GetChatSystemPromptConfig(ctx)
if err != nil {
return err
}
aReq.Old.SystemPrompt = oldConfig.ChatSystemPrompt
aReq.Old.IncludeDefaultSystemPromptSet = oldConfig.IncludeDefaultSystemPromptSet
aReq.Old.IncludeDefaultSystemPrompt = oldConfig.IncludeDefaultSystemPrompt
if err := tx.UpsertChatSystemPrompt(ctx, sanitizedPrompt); err != nil {
return err
}
// Only update the include-default flag when the caller explicitly
// provides it. Omitting the field preserves whatever is currently
// stored (or the schema-level default for new deployments),
// avoiding a backward-compatibility regression for older clients
// that only send system_prompt.
if req.IncludeDefaultSystemPrompt != nil {
if err := tx.UpsertChatIncludeDefaultSystemPrompt(ctx, *req.IncludeDefaultSystemPrompt); err != nil {
return err
}
}
// Derive New from what was written rather than re-reading: the
// upserts store $1 verbatim, so the stored prompt is the request
// value, and the effective include-default flag follows
// GetChatSystemPromptConfig's rule from the written toggle row
// and the written prompt. A post-write read would fail on
// query-local context cancellation even after a successful
// commit, silently discarding a change the client made.
newIncludeSet := oldConfig.IncludeDefaultSystemPromptSet
newIncludeValue := oldConfig.IncludeDefaultSystemPrompt
if req.IncludeDefaultSystemPrompt != nil {
newIncludeSet = true
newIncludeValue = *req.IncludeDefaultSystemPrompt
}
if !newIncludeSet {
// Legacy fallback: a non-empty custom prompt implies opting
// out; otherwise the setting defaults to true.
newIncludeValue = sanitizedPrompt == ""
}
aReq.New.SystemPrompt = sanitizedPrompt
aReq.New.IncludeDefaultSystemPromptSet = newIncludeSet
aReq.New.IncludeDefaultSystemPrompt = newIncludeValue
noChange = aReq.New.SystemPrompt == aReq.Old.SystemPrompt &&
aReq.New.IncludeDefaultSystemPromptSet == aReq.Old.IncludeDefaultSystemPromptSet &&
aReq.New.IncludeDefaultSystemPrompt == aReq.Old.IncludeDefaultSystemPrompt
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating chat system prompt configuration.",
Detail: err.Error(),
})
return
}
if noChange {
// Stage the no-op decision until after the transaction commits,
// so a commit failure cannot suppress an attempt row.
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get organization chat system prompt
// @ID get-organization-chat-system-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Success 200 {object} codersdk.OrganizationChatSystemPromptResponse
// @Router /api/v2/organizations/{organization}/chats/config/system-prompt [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getOrganizationChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
if !api.Authorize(r, policy.ActionRead, rbac.ResourceChatModelConfig.InOrg(organization.ID)) {
httpapi.ResourceNotFound(rw)
return
}
row, err := api.Database.GetChatOrganizationSystemPrompt(ctx, organization.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching organization chat system prompt.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OrganizationChatSystemPromptResponse{
SystemPrompt: row.SystemPrompt,
})
}
// @Summary Update organization chat system prompt
// @ID update-organization-chat-system-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param organization path string true "Organization ID" format(uuid)
// @Param request body codersdk.UpdateOrganizationChatSystemPromptRequest true "Request body"
// @Success 204
// @Router /api/v2/organizations/{organization}/chats/config/system-prompt [put]
func (api *API) putOrganizationChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
// As with the deployment prompt, the audit request is initialized before
// authorization so denied attempts are recorded without request content.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOrganizationSystemPrompt](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: organization.ID,
})
defer commitAudit(true)
aReq.Old = database.ChatOrganizationSystemPrompt{OrganizationID: organization.ID}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organization.ID)) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateOrganizationChatSystemPromptRequest
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &req) {
return
}
sanitizedPrompt := codersdk.SanitizePromptText(req.SystemPrompt)
if api.writePromptTooLongError(ctx, rw, sanitizedPrompt, "System prompt exceeds maximum length.") {
return
}
var noChange bool
// The per-organization lock serializes audit change detection with the
// write, like the deployment prompt.
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatOrganizationSystemPrompt(organization.ID)); err != nil {
return xerrors.Errorf("acquire organization chat system prompt write lock: %w", err)
}
// The ActionUpdate check above gates this endpoint. Read the previous
// value for the audit diff under a system context so a custom role
// that grants update without read can still write.
//nolint:gocritic // See above.
old, err := tx.GetChatOrganizationSystemPrompt(dbauthz.AsSystemRestricted(ctx), organization.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil {
aReq.Old = old
}
updated, err := tx.UpsertChatOrganizationSystemPrompt(ctx, database.UpsertChatOrganizationSystemPromptParams{
OrganizationID: organization.ID,
SystemPrompt: sanitizedPrompt,
})
if err != nil {
return err
}
aReq.New = updated
noChange = aReq.New.SystemPrompt == aReq.Old.SystemPrompt
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating organization chat system prompt.",
Detail: err.Error(),
})
return
}
if noChange {
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat plan mode instructions
// @ID get-chat-plan-mode-instructions
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatPlanModeInstructionsResponse
// @Router /api/v2/chats/config/plan-mode-instructions [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
instructions, err := api.Database.GetChatPlanModeInstructions(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching plan mode instructions.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPlanModeInstructionsResponse{
PlanModeInstructions: instructions,
})
}
// @Summary Update chat plan mode instructions
// @ID update-chat-plan-mode-instructions
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatPlanModeInstructionsRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/plan-mode-instructions [put]
func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Identity is assigned before the authorization check so a denied PUT
// records the attempt with status 403 and an empty diff. The body is
// never read before authorization, so no request content reaches that
// row.
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatInstructionSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.Old = database.ChatInstructionSettings{
ID: audit.ChatInstructionPlanModeID,
Name: audit.ChatInstructionPlanModeName,
}
aReq.New = aReq.Old
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
var req codersdk.UpdateChatPlanModeInstructionsRequest
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), &req) {
return
}
sanitizedInstructions := codersdk.SanitizePromptText(req.PlanModeInstructions)
if api.writePromptTooLongError(ctx, rw, sanitizedInstructions, "Plan mode instructions exceed maximum length.") {
return
}
var noChange bool
lockCtx, lockCancel := context.WithTimeout(ctx, chatInstructionSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.LockIDChatInstructionPlanMode); err != nil {
return xerrors.Errorf("acquire chat instruction setting write lock: %w", err)
}
oldInstructions, err := tx.GetChatPlanModeInstructions(ctx)
if err != nil {
return err
}
aReq.Old.PlanModeInstructions = oldInstructions
if err := tx.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
return err
}
aReq.New.PlanModeInstructions = sanitizedInstructions
noChange = aReq.New.PlanModeInstructions == aReq.Old.PlanModeInstructions
return nil
}, database.DefaultTXOptions().WithID("chat_plan_mode_instructions_write"))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: err.Error(),
})
return
}
if noChange {
// Stage the no-op decision until after the transaction commits,
// so a commit failure cannot suppress an attempt row.
commitAudit(false)
}
rw.WriteHeader(http.StatusNoContent)
}
func readChatModelOverrideContext(
rw http.ResponseWriter,
r *http.Request,
) (codersdk.ChatModelOverrideContext, bool) {
ctx := r.Context()
rawContext := chi.URLParam(r, "context")
overrideContext, err := parseChatModelOverrideContext(rawContext)
if err == nil {
return overrideContext, true
}
validContextValues := make(
[]string,
0,
len(codersdk.AllChatModelOverrideContexts()),
)
for _, overrideContext := range codersdk.AllChatModelOverrideContexts() {
validContextValues = append(validContextValues, string(overrideContext))
}
validContexts := strings.Join(validContextValues, ", ")
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat model override context.",
Detail: fmt.Sprintf(
"Expected one of %s. Got %q.",
validContexts,
rawContext,
),
})
return "", false
}
// @Summary List organization chat model overrides
// @ID list-organization-chat-model-overrides
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Success 200 {object} codersdk.ChatModelOverridesResponse
// @Router /api/v2/organizations/{organization}/chats/model-overrides [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getOrganizationChatModelOverrides(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
rows, err := api.Database.GetChatOrganizationModelOverrides(ctx, organization.ID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching model overrides.",
Detail: err.Error(),
})
return
}
response := codersdk.ChatModelOverridesResponse{
Overrides: make([]codersdk.ChatModelOverrideResponse, 0, len(rows)),
}
for _, row := range rows {
response.Overrides = append(response.Overrides, chatOrganizationModelOverrideResponse(row))
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Update organization chat model override
// @ID update-organization-chat-model-override
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param context path string true "Override context" Enums(general,explore,title_generation,compaction,advisor,computer_use)
// @Param request body codersdk.UpdateChatModelOverrideRequest true "Model override"
// @Success 200 {object} codersdk.ChatModelOverrideResponse
// @Router /api/v2/organizations/{organization}/chats/model-overrides/{context} [put]
// @x-apidocgen {"skip": true}
func (api *API) putOrganizationChatModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(organization.ID)) {
httpapi.ResourceNotFound(rw)
return
}
overrideContext, ok := readChatModelOverrideContext(rw, r)
if !ok {
return
}
if overrideContext == codersdk.ChatModelOverrideContextComputerUse &&
!api.Experiments.Enabled(codersdk.ExperimentChatVirtualDesktop) {
httpapi.ResourceNotFound(rw)
return
}
var req codersdk.UpdateChatModelOverrideRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
response := codersdk.ChatModelOverrideResponse{Context: overrideContext}
trimmedModelConfigID := strings.TrimSpace(req.ModelConfigID)
if trimmedModelConfigID == "" {
if req.ReasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "reasoning_effort requires model_config_id.",
})
return
}
err := api.Database.DeleteChatOrganizationModelOverride(ctx, database.DeleteChatOrganizationModelOverrideParams{
OrganizationID: organization.ID,
Context: string(overrideContext),
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Internal error clearing %s model override.", overrideContext),
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, response)
return
}
modelConfigID, err := uuid.Parse(trimmedModelConfigID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id.",
Detail: fmt.Sprintf("Value %q is not a valid UUID.", req.ModelConfigID),
})
return
}
// The explicit ActionUpdate authorization above gates this endpoint. Look
// the referenced model up under a system context so a custom role that
// grants update without read can still use the endpoint; organization
// ownership is enforced inside the validation.
//nolint:gocritic // See above.
status, validationResponse := validateChatModelOverride(
dbauthz.AsSystemRestricted(ctx),
api.Database,
organization.ID,
overrideContext,
&modelConfigID,
req.ReasoningEffort,
)
if validationResponse != nil {
httpapi.Write(ctx, rw, status, *validationResponse)
return
}
row := database.ChatOrganizationModelOverride{
OrganizationID: organization.ID,
Context: string(overrideContext),
ModelConfigID: modelConfigID,
ReasoningEffort: sql.NullString{String: derefOrEmpty(req.ReasoningEffort), Valid: req.ReasoningEffort != nil},
}
err = api.Database.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{
OrganizationID: row.OrganizationID,
Context: row.Context,
ModelConfigID: row.ModelConfigID,
ReasoningEffort: row.ReasoningEffort,
})
if database.IsForeignKeyViolation(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid model_config_id."})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Internal error updating %s model override.", overrideContext),
Detail: err.Error(),
})
return
}
response = chatOrganizationModelOverrideResponse(row)
httpapi.Write(ctx, rw, http.StatusOK, response)
}
func readChatPersonalModelOverrideContext(
rw http.ResponseWriter,
r *http.Request,
) (codersdk.ChatPersonalModelOverrideContext, bool) {
ctx := r.Context()
rawContext := chi.URLParam(r, "context")
overrideContext, ok := parseChatPersonalModelOverrideContext(rawContext)
if ok {
return overrideContext, true
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid chat personal model override context.",
Detail: fmt.Sprintf(
"Expected one of %s. Got %q.",
chatPersonalModelOverrideContextsJoined(),
rawContext,
),
})
return "", false
}
// @Summary Get chat personal model override settings
// @ID get-chat-personal-model-override-settings
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatPersonalModelOverridesAdminSettings
// @Router /api/v2/chats/config/personal-model-overrides [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
enabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal model override setting.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPersonalModelOverridesAdminSettings{
AllowUsers: enabled,
})
}
const chatOperationalSettingsLockTimeout = 5 * time.Second
type chatOperationalSetting string
const (
chatOperationalSettingChatRetentionDays chatOperationalSetting = "agents_chat_retention_days"
chatOperationalSettingChatDebugRetentionDays chatOperationalSetting = "agents_chat_debug_retention_days"
chatOperationalSettingChatAutoArchiveDays chatOperationalSetting = "agents_chat_auto_archive_days"
chatOperationalSettingWorkspaceTTL chatOperationalSetting = "agents_workspace_ttl"
chatOperationalSettingDebugLoggingAllowUsers chatOperationalSetting = "agents_chat_debug_logging_allow_users"
chatOperationalSettingPersonalModelOverridesEnabled chatOperationalSetting = "agents_chat_personal_model_overrides_enabled"
)
func (s chatOperationalSetting) defaultValue() string {
switch s {
case chatOperationalSettingChatRetentionDays:
return "30"
case chatOperationalSettingChatDebugRetentionDays:
return strconv.FormatInt(int64(codersdk.DefaultChatDebugRetentionDays), 10)
case chatOperationalSettingChatAutoArchiveDays:
return strconv.FormatInt(int64(codersdk.DefaultChatAutoArchiveDays), 10)
case chatOperationalSettingWorkspaceTTL:
return "0s"
case chatOperationalSettingDebugLoggingAllowUsers,
chatOperationalSettingPersonalModelOverridesEnabled:
return "false"
default:
panic(fmt.Sprintf("unknown chat operational setting %q", s))
}
}
func (s chatOperationalSetting) auditValue(value string, id uuid.UUID) database.ChatOperationalSettings {
settings := database.ChatOperationalSettings{ID: id}
switch s {
case chatOperationalSettingChatRetentionDays:
settings.ChatRetentionDays = value
case chatOperationalSettingChatDebugRetentionDays:
settings.ChatDebugRetentionDays = value
case chatOperationalSettingChatAutoArchiveDays:
settings.ChatAutoArchiveDays = value
case chatOperationalSettingWorkspaceTTL:
settings.WorkspaceTTL = value
case chatOperationalSettingDebugLoggingAllowUsers:
settings.DebugLoggingAllowUsers = value
case chatOperationalSettingPersonalModelOverridesEnabled:
settings.PersonalModelOverridesEnabled = value
default:
panic(fmt.Sprintf("unknown chat operational setting %q", s))
}
return settings
}
// auditedChatOperationalSettingWrite captures the effective old value and
// performs the write in one transaction. It suppresses the audit entry when
// the effective value does not change.
func (api *API) auditedChatOperationalSettingWrite(
ctx context.Context,
aReq *audit.Request[database.ChatOperationalSettings],
commitAudit func(bool),
setting chatOperationalSetting,
newValue string,
write func(database.Store) error,
) error {
var noChange bool
lockCtx, lockCancel := context.WithTimeout(ctx, chatOperationalSettingsLockTimeout)
defer lockCancel()
err := api.Database.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.GenLockID(string(setting))); err != nil {
return xerrors.Errorf("acquire chat operational setting write lock: %w", err)
}
old, err := tx.GetChatSiteConfigValue(ctx, string(setting))
if err != nil {
return err
}
oldValue := old.Value
if !old.Exists {
oldValue = setting.defaultValue()
}
if oldValue == newValue {
noChange = true
return nil
}
if err := write(tx); err != nil {
return err
}
aReq.Old = setting.auditValue(oldValue, uuid.Nil)
aReq.New = setting.auditValue(newValue, aReq.New.ID)
return nil
}, nil)
if err != nil {
return err
}
if noChange {
commitAudit(false)
}
return nil
}
func putChatOperationalSetting[Req any](
api *API,
rw http.ResponseWriter,
r *http.Request,
setting chatOperationalSetting,
failMessage string,
prepare func(ctx context.Context, req Req) (value string, write func(database.Store) error, invalid *codersdk.Response),
) {
ctx := r.Context()
aReq, commitAudit := audit.InitRequestWithCancel[database.ChatOperationalSettings](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(), Log: api.Logger, Request: r, Action: database.AuditActionWrite,
})
aReq.New.ID = uuid.New()
defer commitAudit(true)
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
var req Req
if !httpapi.Read(ctx, rw, r, &req) {
return
}
value, write, invalid := prepare(ctx, req)
if invalid != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, invalid)
return
}
if err := api.auditedChatOperationalSettingWrite(ctx, aReq, commitAudit, setting, value, write); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: failMessage,
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
func validateChatSettingDays(label string, days, maximum int32) *codersdk.Response {
if days >= 0 && days <= maximum {
return nil
}
return &codersdk.Response{Message: fmt.Sprintf("%s must be between 0 and %d.", label, maximum)}
}
// @Summary Update chat personal model override settings
// @ID update-chat-personal-model-override-settings
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/personal-model-overrides [put]
func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWriter, r *http.Request) {
putChatOperationalSetting(api, rw, r, chatOperationalSettingPersonalModelOverridesEnabled, "Internal error updating personal model override setting.",
func(ctx context.Context, req codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest) (string, func(database.Store) error, *codersdk.Response) {
return strconv.FormatBool(req.AllowUsers), func(tx database.Store) error {
return tx.UpsertChatPersonalModelOverridesEnabled(ctx, req.AllowUsers)
}, nil
})
}
// @Summary Get organization member chat model overrides
// @ID get-organization-member-chat-model-overrides
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Param user path string true "User name, ID, or me"
// @Success 200 {object} codersdk.UserChatPersonalModelOverridesResponse
// @Router /api/v2/organizations/{organization}/members/{user}/chats/model-overrides [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatPersonalModelOverrides(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
member := httpmw.OrganizationMemberParam(r)
enabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal model override setting.",
Detail: err.Error(),
})
return
}
rows, err := api.Database.GetChatUserModelOverrides(ctx, database.GetChatUserModelOverridesParams{
UserID: member.UserID,
OrganizationID: organization.ID,
})
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user personal model overrides.",
Detail: err.Error(),
})
return
}
byContext := make(map[string]database.ChatUserModelOverride, len(rows))
for _, row := range rows {
byContext[row.Context] = row
}
deploymentDefaults, err := api.chatPersonalModelOverrideDeploymentDefaults(ctx, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching organization model defaults.",
Detail: err.Error(),
})
return
}
response := codersdk.UserChatPersonalModelOverridesResponse{
Enabled: enabled,
DeploymentDefaults: deploymentDefaults,
}
for _, overrideContext := range chatPersonalModelOverrideContexts {
var row *database.ChatUserModelOverride
if contextRow, ok := byContext[string(overrideContext)]; ok {
row = &contextRow
}
override := chatPersonalModelOverrideResponse(overrideContext, row)
switch overrideContext {
case codersdk.ChatPersonalModelOverrideContextRoot:
response.Root = override
case codersdk.ChatPersonalModelOverrideContextGeneral:
response.General = override
case codersdk.ChatPersonalModelOverrideContextExplore:
response.Explore = override
}
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
// @Summary Update organization member chat model override
// @ID update-organization-member-chat-model-override
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param organization path string true "Organization name or ID"
// @Param user path string true "User name, ID, or me"
// @Param context path string true "Override context" Enums(root,general,explore)
// @Param request body codersdk.UpdateUserChatPersonalModelOverrideRequest true "Personal model override"
// @Success 204
// @Router /api/v2/organizations/{organization}/members/{user}/chats/model-overrides/{context} [put]
// @x-apidocgen {"skip": true}
func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
organization := httpmw.OrganizationParam(r)
member := httpmw.OrganizationMemberParam(r)
enabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching personal model override setting.",
Detail: err.Error(),
})
return
}
if !enabled && apiKey.UserID == member.UserID &&
!api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "An administrator has not enabled user personal model overrides.",
})
return
}
overrideContext, ok := readChatPersonalModelOverrideContext(rw, r)
if !ok {
return
}
var req codersdk.UpdateUserChatPersonalModelOverrideRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
modelConfigID := uuid.NullUUID{}
reasoningEffort := req.ReasoningEffort
rawModelConfigID := strings.TrimSpace(req.ModelConfigID)
switch req.Mode {
case codersdk.ChatPersonalModelOverrideModeChatDefault:
if rawModelConfigID != "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "model_config_id must be empty unless mode is model."})
return
}
if reasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "reasoning_effort requires mode model."})
return
}
case codersdk.ChatPersonalModelOverrideModeDeploymentDefault:
if overrideContext == codersdk.ChatPersonalModelOverrideContextRoot {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "deployment_default is not supported for root personal model overrides."})
return
}
if rawModelConfigID != "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "model_config_id must be empty unless mode is model."})
return
}
if reasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "reasoning_effort requires mode model."})
return
}
case codersdk.ChatPersonalModelOverrideModeModel:
if rawModelConfigID == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "model_config_id is required when mode is model."})
return
}
parsedModelConfigID, err := uuid.Parse(rawModelConfigID)
if err != nil || parsedModelConfigID == uuid.Nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id.",
Detail: fmt.Sprintf("Value %q is not a valid UUID.", req.ModelConfigID),
})
return
}
// Validate with the target member's ACLs so a model visible only to
// an administrator caller is rejected instead of persisted; chatd
// resolves the override under the member's actor at runtime.
validateCtx := ctx
if apiKey.UserID != member.UserID {
memberSubject, _, err := httpmw.UserRBACSubject(ctx, api.Database, member.UserID, rbac.ScopeAll)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error validating model config override.",
Detail: err.Error(),
})
return
}
validateCtx = dbauthz.As(ctx, memberSubject)
}
modelConfig, status, validationResponse := api.validateUserChatModelConfigAvailable(
validateCtx, member.UserID, organization.ID, parsedModelConfigID,
)
if validationResponse != nil {
httpapi.Write(ctx, rw, status, *validationResponse)
return
}
status, validationResponse = validateChatModelOverrideEffort(modelConfig, reasoningEffort)
if validationResponse != nil {
httpapi.Write(ctx, rw, status, *validationResponse)
return
}
modelConfigID = uuid.NullUUID{UUID: parsedModelConfigID, Valid: true}
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid personal model override mode."})
return
}
err = api.Database.UpsertChatUserModelOverride(ctx, database.UpsertChatUserModelOverrideParams{
UserID: member.UserID,
OrganizationID: organization.ID,
Context: string(overrideContext),
Mode: string(req.Mode),
ModelConfigID: modelConfigID,
ReasoningEffort: sql.NullString{String: derefOrEmpty(reasoningEffort), Valid: reasoningEffort != nil},
})
if database.IsForeignKeyViolation(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid model_config_id: model config not found or disabled.",
})
return
}
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user personal model override.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) deploymentChatDebugLoggingEnabled() bool {
return api.DeploymentValues != nil && api.DeploymentValues.AI.Chat.DebugLoggingEnabled.Value()
}
// @Summary Get chat debug logging setting
// @ID get-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatDebugLoggingAdminSettings
// @Router /api/v2/chats/config/debug-logging [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
allowUsers, err := api.Database.GetChatDebugLoggingAllowUsers(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat debug logging setting.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugLoggingAdminSettings{
AllowUsers: err == nil && allowUsers,
ForcedByDeployment: api.deploymentChatDebugLoggingEnabled(),
})
}
// @Summary Update chat debug logging setting
// @ID update-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatDebugLoggingAllowUsersRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/debug-logging [put]
func (api *API) putChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
putChatOperationalSetting(api, rw, r, chatOperationalSettingDebugLoggingAllowUsers, "Internal error updating chat debug logging setting.",
func(ctx context.Context, req codersdk.UpdateChatDebugLoggingAllowUsersRequest) (string, func(database.Store) error, *codersdk.Response) {
return strconv.FormatBool(req.AllowUsers), func(tx database.Store) error {
return tx.UpsertChatDebugLoggingAllowUsers(ctx, req.AllowUsers)
}, nil
})
}
// @Summary Get user chat debug logging setting
// @ID get-user-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.UserChatDebugLoggingSettings
// @Router /api/v2/chats/config/user-debug-logging [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
forcedByDeployment := api.deploymentChatDebugLoggingEnabled()
allowUsers := false
if !forcedByDeployment {
enabled, err := api.Database.GetChatDebugLoggingAllowUsers(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat debug logging setting.",
Detail: err.Error(),
})
return
}
allowUsers = err == nil && enabled
}
debugEnabled := forcedByDeployment
if allowUsers {
enabled, err := api.Database.GetUserChatDebugLoggingEnabled(ctx, apiKey.UserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user chat debug logging setting.",
Detail: err.Error(),
})
return
}
debugEnabled = err == nil && enabled
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatDebugLoggingSettings{
DebugLoggingEnabled: debugEnabled,
UserToggleAllowed: !forcedByDeployment && allowUsers,
ForcedByDeployment: forcedByDeployment,
})
}
// @Summary Update user chat debug logging setting
// @ID update-user-chat-debug-logging-setting
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateUserChatDebugLoggingRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/user-debug-logging [put]
func (api *API) putUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
if api.deploymentChatDebugLoggingEnabled() {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat debug logging is already forced on by deployment configuration.",
})
return
}
allowUsers, err := api.Database.GetChatDebugLoggingAllowUsers(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching chat debug logging setting.",
Detail: err.Error(),
})
return
}
if err != nil || !allowUsers {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "An administrator has not enabled user-controlled chat debug logging.",
})
return
}
var req codersdk.UpdateUserChatDebugLoggingRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := api.Database.UpsertUserChatDebugLoggingEnabled(ctx, database.UpsertUserChatDebugLoggingEnabledParams{
UserID: apiKey.UserID,
DebugLoggingEnabled: req.DebugLoggingEnabled,
}); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating user chat debug logging setting.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat advisor config
// @ID get-chat-advisor-config
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.AdvisorConfig
// @Router /api/v2/chats/config/advisor [get]
//
//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.Enabled = true
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Update chat advisor config
// @ID update-chat-advisor-config
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateAdvisorConfigRequest true "Request body"
// @Success 204
// @Failure 400 {object} codersdk.Response
// @Failure 403 {object} codersdk.Response
// @Router /api/v2/chats/config/advisor [put]
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
}
runtimeConfig := codersdk.AdvisorConfig{
MaxUsesPerRun: req.MaxUsesPerRun,
MaxOutputTokens: req.MaxOutputTokens,
}
raw, err := json.Marshal(runtimeConfig)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error encoding advisor configuration.",
Detail: err.Error(),
})
return
}
if err := api.Database.UpsertChatAdvisorConfig(ctx, string(raw)); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating advisor configuration.",
Detail: err.Error(),
})
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventAdvisorConfig, uuid.Nil)
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get chat workspace time to live
// @ID get-chat-workspace-time-to-live
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatWorkspaceTTLResponse
// @Router /api/v2/chats/config/workspace-ttl [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
raw, err := api.Database.GetChatWorkspaceTTL(ctx)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace TTL setting.",
Detail: err.Error(),
})
return
}
// Validate/default the stored value so callers always receive a
// well-formed duration string.
d, err := codersdk.ParseChatWorkspaceTTL(raw)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Stored workspace TTL is invalid.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatWorkspaceTTLResponse{
WorkspaceTTLMillis: d.Milliseconds(),
})
}
// @Summary Update chat workspace time to live
// @ID update-chat-workspace-time-to-live
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatWorkspaceTTLRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/workspace-ttl [put]
func (api *API) putChatWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
putChatOperationalSetting(api, rw, r, chatOperationalSettingWorkspaceTTL, "Internal error updating workspace TTL setting.",
func(ctx context.Context, req codersdk.UpdateChatWorkspaceTTLRequest) (string, func(database.Store) error, *codersdk.Response) {
// Reject out-of-range input before conversion can wrap it
// into a valid TTL.
if req.WorkspaceTTLMillis < 0 {
return "", nil, &codersdk.Response{Message: "Workspace TTL must be non-negative."}
}
if req.WorkspaceTTLMillis > ttlMaximum.Milliseconds() {
return "", nil, &codersdk.Response{Message: "Workspace TTL must not exceed 30 days."}
}
d := time.Duration(req.WorkspaceTTLMillis) * time.Millisecond
// Keep zero as "disabled"; validWorkspaceTTLMillis substitutes
// a template default.
if d > 0 && d < ttlMinimum {
return "", nil, &codersdk.Response{Message: "Workspace TTL must not be less than 1 minute."}
}
value := d.String()
return value, func(tx database.Store) error { return tx.UpsertChatWorkspaceTTL(ctx, value) }, nil
})
}
// @Summary Get chat retention days
// @ID get-chat-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatRetentionDaysResponse
// @Router /api/v2/chats/config/retention-days [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
retentionDays, err := api.Database.GetChatRetentionDays(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat retention days.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatRetentionDaysResponse{
RetentionDays: retentionDays,
})
}
// Keep in sync with retentionDaysMaximum in
// site/src/pages/AgentsPage/AgentSettingsBehaviorPageView.tsx.
const retentionDaysMaximum = 3650 // ~10 years
// @Summary Update chat retention days
// @ID update-chat-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatRetentionDaysRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/retention-days [put]
// @x-apidocgen {"skip": true}
func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) {
putChatOperationalSetting(api, rw, r, chatOperationalSettingChatRetentionDays, "Failed to update chat retention days.",
func(ctx context.Context, req codersdk.UpdateChatRetentionDaysRequest) (string, func(database.Store) error, *codersdk.Response) {
if invalid := validateChatSettingDays("Retention days", req.RetentionDays, retentionDaysMaximum); invalid != nil {
return "", nil, invalid
}
return strconv.FormatInt(int64(req.RetentionDays), 10), func(tx database.Store) error {
return tx.UpsertChatRetentionDays(ctx, req.RetentionDays)
}, nil
})
}
// getChatDebugRetentionDays returns the deployment-wide chat debug run
// retention window. Any authenticated user can read it; writes require admin.
//
// @Summary Get chat debug retention days
// @ID get-chat-debug-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatDebugRetentionDaysResponse
// @Router /api/v2/chats/config/debug-retention-days [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
retentionDays, err := api.Database.GetChatDebugRetentionDays(ctx, codersdk.DefaultChatDebugRetentionDays)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat debug retention days.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugRetentionDaysResponse{
DebugRetentionDays: retentionDays,
})
}
// Keep in sync with the validation schema in
// site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx.
const chatDebugRetentionDaysMaximum = 3650 // ~10 years
// putChatDebugRetentionDays updates the deployment-wide chat debug run
// retention window. Admin-only.
//
// @Summary Update chat debug retention days
// @ID update-chat-debug-retention-days
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatDebugRetentionDaysRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/debug-retention-days [put]
func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) {
putChatOperationalSetting(api, rw, r, chatOperationalSettingChatDebugRetentionDays, "Failed to update chat debug retention days.",
func(ctx context.Context, req codersdk.UpdateChatDebugRetentionDaysRequest) (string, func(database.Store) error, *codersdk.Response) {
if invalid := validateChatSettingDays("Chat debug retention days", req.DebugRetentionDays, chatDebugRetentionDaysMaximum); invalid != nil {
return "", nil, invalid
}
return strconv.FormatInt(int64(req.DebugRetentionDays), 10), func(tx database.Store) error {
return tx.UpsertChatDebugRetentionDays(ctx, req.DebugRetentionDays)
}, nil
})
}
// getChatAutoArchiveDays returns the deployment-wide auto-archive
// window. Any authenticated user can read it (same as retention
// days); writes require admin.
//
// @Summary Get chat auto archive days
// @ID get-chat-auto-archive-days
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.ChatAutoArchiveDaysResponse
// @Router /api/v2/chats/config/auto-archive-days [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
autoArchiveDays, err := api.Database.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat auto-archive days.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatAutoArchiveDaysResponse{
AutoArchiveDays: autoArchiveDays,
})
}
// Upper bound for the auto-archive window. Keep in sync with
// the validation schema in site/src/pages/AgentsPage/components/AutoArchiveSettings.tsx.
const autoArchiveDaysMaximum = 3650 // ~10 years
// putChatAutoArchiveDays updates the deployment-wide auto-archive
// window. Admin-only; documented in docs/coder-agents/chats-api.md.
//
// @Summary Update chat auto archive days
// @ID update-chat-auto-archive-days
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UpdateChatAutoArchiveDaysRequest true "Request body"
// @Success 204
// @Router /api/v2/chats/config/auto-archive-days [put]
func (api *API) putChatAutoArchiveDays(rw http.ResponseWriter, r *http.Request) {
putChatOperationalSetting(api, rw, r, chatOperationalSettingChatAutoArchiveDays, "Failed to update chat auto-archive days.",
func(ctx context.Context, req codersdk.UpdateChatAutoArchiveDaysRequest) (string, func(database.Store) error, *codersdk.Response) {
if invalid := validateChatSettingDays("Auto-archive days", req.AutoArchiveDays, autoArchiveDaysMaximum); invalid != nil {
return "", nil, invalid
}
return strconv.FormatInt(int64(req.AutoArchiveDays), 10), func(tx database.Store) error {
return tx.UpsertChatAutoArchiveDays(ctx, req.AutoArchiveDays)
}, nil
})
}
// @Summary Get user chat custom prompt
// @ID get-user-chat-custom-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.UserChatCustomPrompt
// @Router /api/v2/chats/config/user-prompt [get]
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatCustomPrompt(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
customPrompt, err := api.Database.GetUserChatCustomPrompt(ctx, apiKey.UserID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error reading user chat custom prompt.",
Detail: err.Error(),
})
return
}
customPrompt = ""
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCustomPrompt{
CustomPrompt: customPrompt,
})
}
// @Summary Update user chat custom prompt
// @ID update-user-chat-custom-prompt
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param request body codersdk.UserChatCustomPrompt true "Request body"
// @Produce json
// @Success 200 {object} codersdk.UserChatCustomPrompt
// @Router /api/v2/chats/config/user-prompt [put]
func (api *API) putUserChatCustomPrompt(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
var params codersdk.UserChatCustomPrompt
if !httpapi.ReadLimit(ctx, rw, r, api.maxPromptRequestBodyBytes(), ¶ms) {
return
}
sanitizedPrompt := codersdk.SanitizePromptText(params.CustomPrompt)
if api.writePromptTooLongError(ctx, rw, sanitizedPrompt, "Custom prompt exceeds maximum length.") {
return
}
updatedConfig, err := api.Database.UpdateUserChatCustomPrompt(ctx, database.UpdateUserChatCustomPromptParams{
UserID: apiKey.UserID,
ChatCustomPrompt: sanitizedPrompt,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error updating user chat custom prompt.",
Detail: err.Error(),
})
return
}
publishChatConfigEvent(api.Logger, api.Pubsub, pubsub.ChatConfigEventUserPrompt, apiKey.UserID)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCustomPrompt{
CustomPrompt: updatedConfig.Value,
})
}
// @Summary Get user chat compaction thresholds
// @ID get-user-chat-compaction-thresholds
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Success 200 {object} codersdk.UserChatCompactionThresholds
// @Router /api/v2/chats/config/user-compaction-thresholds [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getUserChatCompactionThresholds(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
rows, err := api.Database.ListUserChatCompactionThresholds(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error listing user chat compaction thresholds.",
Detail: err.Error(),
})
return
}
resp := codersdk.UserChatCompactionThresholds{
Thresholds: make([]codersdk.UserChatCompactionThreshold, 0, len(rows)),
}
for _, row := range rows {
modelConfigID, err := parseCompactionThresholdKey(row.Key)
if err != nil {
api.Logger.Warn(ctx, "skipping malformed user chat compaction threshold key",
slog.F("key", row.Key),
slog.F("value", row.Value),
slog.Error(err),
)
continue
}
thresholdPercent, err := strconv.ParseInt(row.Value, 10, 32)
if err != nil {
api.Logger.Warn(ctx, "skipping malformed user chat compaction threshold value",
slog.F("key", row.Key),
slog.F("value", row.Value),
slog.Error(err),
)
continue
}
if thresholdPercent < int64(minChatContextCompressionThreshold) ||
thresholdPercent > int64(maxChatContextCompressionThreshold) {
api.Logger.Warn(ctx, "skipping out-of-range user chat compaction threshold",
slog.F("key", row.Key),
slog.F("value", row.Value),
)
continue
}
resp.Thresholds = append(resp.Thresholds, codersdk.UserChatCompactionThreshold{
ModelConfigID: modelConfigID,
ThresholdPercent: int32(thresholdPercent),
})
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// @Summary Update user chat compaction threshold
// @ID update-user-chat-compaction-threshold
// @Security CoderSessionToken
// @Tags Chats
// @Param modelConfig path string true "Model config ID" format(uuid)
// @Accept json
// @Param request body codersdk.UpdateUserChatCompactionThresholdRequest true "Request body"
// @Produce json
// @Success 200 {object} codersdk.UserChatCompactionThreshold
// @Router /api/v2/chats/config/user-compaction-thresholds/{modelConfig} [put]
// @x-apidocgen {"skip": true}
func (api *API) putUserChatCompactionThreshold(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
modelConfigID, ok := parseChatModelConfigID(rw, r)
if !ok {
return
}
var req codersdk.UpdateUserChatCompactionThresholdRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.ThresholdPercent < minChatContextCompressionThreshold ||
req.ThresholdPercent > maxChatContextCompressionThreshold {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "threshold_percent is out of range.",
Detail: fmt.Sprintf(
"threshold_percent must be between %d and %d, got %d.",
minChatContextCompressionThreshold,
maxChatContextCompressionThreshold,
req.ThresholdPercent,
),
})
return
}
// The preference is personal, so model existence checks must not depend on
// whether the user can read the organization model configuration.
//nolint:gocritic // Non-admin users need this lookup to save their own setting.
modelConfig, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), modelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat model config.",
Detail: err.Error(),
})
return
}
if !modelConfig.Enabled {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Model config is disabled.",
})
return
}
_, err = api.Database.UpdateUserChatCompactionThreshold(ctx, database.UpdateUserChatCompactionThresholdParams{
UserID: apiKey.UserID,
Key: codersdk.CompactionThresholdKey(modelConfigID),
ThresholdPercent: req.ThresholdPercent,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error updating user chat compaction threshold.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserChatCompactionThreshold{
ModelConfigID: modelConfigID,
ThresholdPercent: req.ThresholdPercent,
})
}
// @Summary Delete user chat compaction threshold
// @ID delete-user-chat-compaction-threshold
// @Security CoderSessionToken
// @Tags Chats
// @Param modelConfig path string true "Model config ID" format(uuid)
// @Success 204
// @Router /api/v2/chats/config/user-compaction-thresholds/{modelConfig} [delete]
// @x-apidocgen {"skip": true}
func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apiKey = httpmw.APIKey(r)
)
modelConfigID, ok := parseChatModelConfigID(rw, r)
if !ok {
return
}
if err := api.Database.DeleteUserChatCompactionThreshold(ctx, database.DeleteUserChatCompactionThresholdParams{
UserID: apiKey.UserID,
Key: codersdk.CompactionThresholdKey(modelConfigID),
}); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error deleting user chat compaction threshold.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Upload chat file
// @ID upload-chat-file
// @Security CoderSessionToken
// @Tags Chats
// @Accept image/png,image/jpeg,image/gif,image/webp,image/svg+xml,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Produce json
// @Param organization query string true "Organization ID" format(uuid)
// @Param Content-Disposition header string true "Attachment disposition carrying the filename as a quoted string." extensions(x-example=attachment; filename="image.png")
// @Param request body string true "Raw file binary data"
// @x-apidocgen {"rawBodyFile": "image.png"}
// @Success 201 {object} codersdk.UploadChatFileResponse
// @Failure 413 {object} codersdk.Response "Request body exceeds 10 MiB"
// @Router /api/v2/chats/files [post]
func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
orgIDStr := r.URL.Query().Get("organization")
if orgIDStr == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing organization query parameter.",
})
return
}
orgID, err := uuid.Parse(orgIDStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid organization ID.",
})
return
}
// NOTE: This authorize check is intentionally placed after query
// parameter parsing because we need orgID to scope the RBAC check
// to the correct org.
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceChat.WithOwner(apiKey.UserID.String()).InOrg(orgID)) {
httpapi.Forbidden(rw)
return
}
contentType := r.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
// Strip parameters (e.g. "image/png; charset=utf-8" → "image/png")
// so the allowlist check matches the base media type.
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil {
contentType = mediaType
}
// application/octet-stream means the client could not classify the file
// ahead of time, so we defer to byte classification below.
if contentType != "application/octet-stream" && !chatfiles.IsAllowedPromptInputMediaType(contentType) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unsupported file type.",
Detail: fmt.Sprintf("Allowed types: %s.", chatfiles.AllowedPromptInputMediaTypesString()),
})
return
}
// Extract filename from Content-Disposition header if provided.
filename := chatFilenameFromContentDisposition(r.Header.Get("Content-Disposition"))
r.Body = http.MaxBytesReader(rw, r.Body, codersdk.MaxChatFileSizeBytes)
data, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
httpapi.RecordRequestBodyLimit(ctx, codersdk.MaxChatFileSizeBytes)
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "File too large.",
Detail: fmt.Sprintf("Maximum file size is %d bytes.", codersdk.MaxChatFileSizeBytes),
})
return
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to read file from request.",
Detail: err.Error(),
})
return
}
// Classify the actual content before applying the upload policy so
// a client cannot spoof Content-Type to serve active content.
filename, detected, err := chatfiles.PrepareStoredFile(filename, filename, data)
if err != nil {
switch {
case errors.Is(err, chatfiles.ErrStoredFileNameRequired):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Filename is required.",
Detail: "Provide a filename in the Content-Disposition header.",
})
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file.",
Detail: err.Error(),
})
}
return
}
if !chatfiles.IsAllowedPromptInputMediaType(detected) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unsupported file type.",
Detail: fmt.Sprintf("Allowed types: %s.", chatfiles.AllowedPromptInputMediaTypesString()),
})
return
}
// The compatibility check below is security-critical: it keeps exact
// media-type matching by default while allowing application/
// octet-stream uploads to defer to byte classification, and letting
// text/plain refine to safe text subtypes such as JSON, CSV, and
// Markdown. Combined with the X-Content-Type-Options: nosniff header
// applied globally, this still prevents clients from smuggling binary
// or active content under a safer declared Content-Type.
if !chatfiles.IsCompatibleUploadMediaType(contentType, detected) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "File content type does not match Content-Type header.",
Detail: fmt.Sprintf("Header declared %q but file content was detected as %q.", contentType, detected),
})
return
}
chatFile, err := api.Database.InsertChatFile(ctx, database.InsertChatFileParams{
OwnerID: apiKey.UserID,
OrganizationID: orgID,
Name: filename,
Mimetype: detected,
Data: data,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to save chat file.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UploadChatFileResponse{
ID: chatFile.ID,
})
}
// ChatFileDownloadClaims are the signed claims embedded in a chat file
// download URL token.
type ChatFileDownloadClaims struct {
jwtutils.RegisteredClaims
FileID uuid.UUID `json:"file_id"`
UserID uuid.UUID `json:"user_id"`
}
func (c ChatFileDownloadClaims) Validate(expected jwt.Expected) error {
if c.FileID == uuid.Nil {
return xerrors.New("file ID is required")
}
if c.UserID == uuid.Nil {
return xerrors.New("user ID is required")
}
return c.RegisteredClaims.Validate(expected)
}
// @Summary Create chat file download URL
// @ID create-chat-file-download-url
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param file path string true "File ID" format(uuid)
// @Success 200 {object} codersdk.ChatFileDownloadURLResponse
// @Router /api/v2/chats/files/{file}/download-url [post]
// @x-apidocgen {"skip": true}
func (api *API) postChatFileDownloadURL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file ID.",
})
return
}
chatFile, err := api.Database.GetChatFileByID(ctx, fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
Detail: err.Error(),
})
return
}
now := time.Now()
// Truncate to whole seconds so the advertised expiry matches the JWT
// exp claim, which jwt.NewNumericDate stores at second precision.
expiresAt := now.Add(cryptokeys.ChatFilesTokenDuration).Truncate(time.Second)
claims := ChatFileDownloadClaims{
RegisteredClaims: jwtutils.RegisteredClaims{
Expiry: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(now),
},
FileID: fileID,
UserID: httpmw.APIKey(r).UserID,
}
token, err := jwtutils.Sign(ctx, api.ChatFileTokenKeyCache, claims)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create chat file download URL.",
Detail: err.Error(),
})
return
}
downloadURL := api.AccessURL.JoinPath("api", "v2", "chats", "files", fileID.String(), "download")
downloadURL.RawQuery = url.Values{"token": {token}}.Encode()
digest := sha256.Sum256(chatFile.Data)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatFileDownloadURLResponse{
URL: downloadURL.String(),
ExpiresAt: expiresAt,
SHA256: hex.EncodeToString(digest[:]),
SizeBytes: int64(len(chatFile.Data)),
Name: chatFile.Name,
MimeType: chatFile.Mimetype,
})
}
// @Summary Download chat file with signed token
// @ID download-chat-file-with-signed-token
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,image/svg+xml,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Param file path string true "File ID" format(uuid)
// @Param token query string true "Signed download token"
// @Success 200
// @Router /api/v2/chats/files/{file}/download [get]
// @x-apidocgen {"skip": true}
func (api *API) downloadChatFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
if err != nil {
httpapi.ResourceNotFound(rw)
return
}
var claims ChatFileDownloadClaims
if err := jwtutils.Verify(ctx, api.ChatFileTokenKeyCache, r.URL.Query().Get("token"), &claims); err != nil || claims.FileID != fileID {
httpapi.ResourceNotFound(rw)
return
}
subject, status, err := httpmw.UserRBACSubject(ctx, api.Database, claims.UserID, rbac.ScopeAll)
if err != nil || status != database.UserStatusActive {
httpapi.ResourceNotFound(rw)
return
}
chatFile, err := api.Database.GetChatFileByID(dbauthz.As(ctx, subject), fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
// This endpoint is reachable without a session token, so internal
// error details stay in the logs rather than the response body.
api.Logger.Error(ctx, "failed to get chat file for signed download", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
})
return
}
// Never let private caches replay a signed URL past its expiry;
// revocation is re-checked only when the request reaches coderd.
rw.Header().Set("Cache-Control", "no-store")
api.serveChatFile(ctx, rw, chatFile)
}
// @Summary Get chat file
// @ID get-chat-file
// @Security CoderSessionToken
// @Tags Chats
// @Produce image/png,image/jpeg,image/gif,image/webp,image/svg+xml,text/plain,text/markdown,text/csv,application/json,application/pdf
// @Param file path string true "File ID" format(uuid)
// @Success 200
// @Router /api/v2/chats/files/{file} [get]
func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
fileIDStr := chi.URLParam(r, "file")
fileID, err := uuid.Parse(fileIDStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid file ID.",
})
return
}
chatFile, err := api.Database.GetChatFileByID(ctx, fileID)
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get chat file.",
Detail: err.Error(),
})
return
}
rw.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
api.serveChatFile(r.Context(), rw, chatFile)
}
// serveChatFile writes the file body and content headers; callers set
// Cache-Control because signed and session-authenticated downloads have
// different caching requirements.
func (api *API) serveChatFile(ctx context.Context, rw http.ResponseWriter, chatFile database.ChatFile) {
rw.Header().Set("Content-Type", chatFile.Mimetype)
disposition := "attachment"
if chatfiles.IsInlineRenderableStoredMediaType(chatFile.Mimetype) {
disposition = "inline"
}
if chatFile.Name != "" {
rw.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": chatFile.Name}))
} else {
rw.Header().Set("Content-Disposition", disposition)
}
rw.Header().Set("Content-Length", strconv.Itoa(len(chatFile.Data)))
rw.WriteHeader(http.StatusOK)
if _, err := rw.Write(chatFile.Data); err != nil {
api.Logger.Debug(ctx, "failed to write chat file response", slog.Error(err))
}
}
const (
chatWorkspaceUploadNoWorkspaceMessage = "Chat has no workspace to upload to."
chatWorkspaceUploadWorkspaceNotFoundMessage = "Chat workspace not found."
chatWorkspaceUploadWorkspaceDeletedMessage = "Chat workspace was deleted."
chatWorkspaceUploadNoAgentsMessage = "Chat workspace has no agents."
chatWorkspaceUploadArchivedMessage = "Cannot upload files to an archived chat."
chatWorkspaceUploadOwnerOnlyMessage = "Only the chat owner may upload files to a chat's workspace."
chatWorkspaceUploadMissingFilenameMessage = "Filename is required."
chatWorkspaceUploadUnusableFilenameMessage = "Filename is unusable."
chatWorkspaceUploadNoChatAgentMessage = "No chat-compatible workspace agent found."
chatWorkspaceUploadAgentDialTimeout = 30 * time.Second
// Transport errors embed the agent's tailnet URL, so they are logged
// rather than returned to the client.
chatWorkspaceUploadAgentUnreachableDetail = "The workspace agent could not be reached. Check that the workspace agent is connected and retry."
)
func (api *API) chatWorkspaceUploadMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)
if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: chatWorkspaceUploadOwnerOnlyMessage,
})
return
}
if chat.Archived {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: chatWorkspaceUploadArchivedMessage,
})
return
}
next.ServeHTTP(rw, r)
})
}
// @Summary Upload a file to a chat's workspace
// @ID upload-a-file-to-a-chats-workspace
// @Security CoderSessionToken
// @Accept */*
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Param Content-Disposition header string true "Filename of the file (attachment; filename=...)"
// @Param request body string true "Raw file binary data"
// @x-apidocgen {"rawBodyFile": "archive.zip"}
// @Success 201 {object} codersdk.UploadChatWorkspaceFileResponse
// @Failure 400 {object} codersdk.Response
// @Failure 403 {object} codersdk.Response
// @Failure 409 {object} codersdk.Response
// @Failure 429 {object} codersdk.Response
// @Failure 500 {object} codersdk.Response
// @Failure 502 {object} codersdk.Response
// @Router /api/v2/chats/{chat}/workspace-files [post]
// @Description Streams the request body into the chat workspace's
// @Description upload directory. The request Content-Type header is
// @Description recorded as the file's media type. There is no
// @Description server-imposed size cap; client cancellation aborts the
// @Description stream and the agent leaves no partial target file behind.
func (api *API) postChatWorkspaceFile(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
start := api.Clock.Now()
sw := &tracing.StatusWriter{ResponseWriter: rw}
rw = sw
var (
workspaceID uuid.UUID
agentID uuid.UUID
name string
body *workspaceUsageReader
failure error
)
defer func() {
api.chatWorkspaceUploadsTotal.WithLabelValues(strconv.Itoa(sw.Status)).Inc()
if sw.Status < http.StatusBadRequest {
return
}
var bytesRead int64
if body != nil {
bytesRead = body.bytesRead.Load()
}
fields := []slog.Field{
slog.F("chat_id", chat.ID),
slog.F("workspace_id", workspaceID),
slog.F("agent_id", agentID),
slog.F("owner_id", chat.OwnerID),
slog.F("file_name", name),
slog.F("bytes_read", bytesRead),
slog.F("duration", api.Clock.Since(start)),
slog.F("status", sw.Status),
}
if failure != nil {
fields = append(fields, slog.Error(failure))
}
api.Logger.Warn(ctx, "chat workspace file upload failed", fields...)
}()
workspace, ok := api.authorizeChatWorkspaceExecWithStatus(
rw,
r,
chat,
http.StatusConflict,
chatWorkspaceUploadNoWorkspaceMessage,
http.StatusConflict,
chatWorkspaceUploadWorkspaceNotFoundMessage,
)
if !ok {
return
}
workspaceID = workspace.ID
// Writing files into the workspace filesystem is an SSH-grade
// capability. The exec helper also admits app-connect-only
// callers (it serves the read-oriented stream endpoints), so
// require SSH explicitly, matching validateChatWorkspaceSelection.
if !api.Authorize(r, policy.ActionSSH, workspace) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: chatWorkspaceUploadWorkspaceNotFoundMessage,
})
return
}
filename := chatFilenameFromContentDisposition(r.Header.Get("Content-Disposition"))
sanitizedName, err := chatfiles.SanitizeWorkspaceUploadName(filename)
if err != nil {
if strings.TrimSpace(filename) == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: chatWorkspaceUploadMissingFilenameMessage,
Detail: "Provide a filename via the Content-Disposition header.",
})
return
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: chatWorkspaceUploadUnusableFilenameMessage,
Detail: "The provided filename has no safe characters left after sanitization or is a reserved device name.",
})
return
}
name = sanitizedName
// The client echoes media_type back as workspace_file_media_type, so
// it must pass the same strict parse the message endpoint applies.
contentType, err := chatfiles.ParseBaseMediaType(r.Header.Get("Content-Type"))
if err != nil {
contentType = "application/octet-stream"
}
if workspace.Deleted {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: chatWorkspaceUploadWorkspaceDeletedMessage,
})
return
}
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, latestBuild.JobID)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build job.",
Detail: err.Error(),
})
return
}
workspaceStatus := codersdk.ConvertWorkspaceStatus(
codersdk.ProvisionerJobStatus(job.JobStatus),
codersdk.WorkspaceTransition(latestBuild.Transition),
)
if workspaceStatus != codersdk.WorkspaceStatusRunning {
action := "Start the workspace"
if latestBuild.Transition == database.WorkspaceTransitionStart &&
(workspaceStatus == codersdk.WorkspaceStatusPending || workspaceStatus == codersdk.WorkspaceStatusStarting) {
action = "Wait for the workspace to start"
}
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("Workspace is %s. %s before uploading files.", workspaceStatus, action),
})
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
if len(agents) == 0 {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: chatWorkspaceUploadNoAgentsMessage,
})
return
}
selectedAgent, err := chatWorkspaceUploadAgent(chat, agents)
if err != nil {
failure = err
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: chatWorkspaceUploadNoChatAgentMessage,
Detail: err.Error(),
})
return
}
agentID = selectedAgent.ID
agentStatus := selectedAgent.Status(dbtime.Now(), api.AgentInactiveDisconnectTimeout)
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf(
"Agent status is %q. Start the workspace agent before uploading files.",
agentStatus.Status,
),
})
return
}
defer r.Body.Close()
dialCtx, dialCancel := context.WithTimeout(ctx, chatWorkspaceUploadAgentDialTimeout)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, selectedAgent.ID)
if err != nil {
failure = err
// A dial failure is a coderd-to-agent transport problem, the
// same class writeWorkspaceAgentUploadError maps to 502.
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{
Message: "Failed to dial workspace agent.",
Detail: chatWorkspaceUploadAgentUnreachableDetail,
})
return
}
defer release()
// Mirror chatd's connection headers so agent-side chat state (e.g.
// the git-watch PathStore) attributes this request to the chat.
var ancestorIDs []string
if chat.ParentChatID.Valid {
ancestorIDs = append(ancestorIDs, chat.ParentChatID.UUID.String())
}
ancestorJSON, err := json.Marshal(ancestorIDs)
if err != nil {
ancestorJSON = []byte("[]")
}
agentConn.SetExtraHeaders(http.Header{
workspacesdk.CoderChatIDHeader: {chat.ID.String()},
workspacesdk.CoderAncestorChatIDsHeader: {string(ancestorJSON)},
})
// Uploads are neither SSH nor app sessions, so nothing else records
// them as workspace usage; without this an uncapped upload started
// near the autostop deadline could see its workspace stop mid-stream.
// The bump runs as chatd like the heartbeat bump: the upload itself
// is already authorized, but the requester may hold only SSH rights
// on a shared workspace while the bump needs workspace update.
//nolint:gocritic // Activity bump for an upload the requester is already authorized to make.
bumpCtx := dbauthz.AsChatd(ctx)
body = &workspaceUsageReader{
r: r.Body,
clock: api.Clock,
report: func() {
api.WorkspaceUsageTracker.Add(workspace.ID)
workspacestats.ActivityBumpWorkspace(bumpCtx, api.Logger.Named("activity_bump"), api.Database, workspace.ID, time.Time{}, workspacestats.ActivityBumpReasonChatWorkspaceUpload)
},
}
body.reportNow()
resp, err := agentConn.UploadChatFile(ctx, workspacesdk.UploadChatFileRequest{
ChatID: chat.ID.String(),
Name: name,
Body: body,
})
if err != nil {
failure = err
writeWorkspaceAgentUploadError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.UploadChatWorkspaceFileResponse{
Path: resp.Path,
Name: resp.Name,
Size: resp.Size,
MediaType: contentType,
WorkspaceID: workspace.ID,
})
}
// chatWorkspaceUploadAgent prefers the chat's bound agent when it is
// still part of the latest build so uploads land on the same agent the
// generation path uses. Otherwise it falls back to the deterministic
// chat agent selection without persisting a binding; binding remains a
// generation-path concern, and determinism makes both converge.
func chatWorkspaceUploadAgent(chat database.Chat, agents []database.WorkspaceAgent) (database.WorkspaceAgent, error) {
if chat.AgentID.Valid {
for _, agent := range agents {
if agent.ID == chat.AgentID.UUID {
return agent, nil
}
}
}
return agentselect.FindChatAgent(agents)
}
// chatWorkspaceUploadUsageInterval bounds how often a streaming upload
// records workspace usage; chatd heartbeats at the same cadence.
const chatWorkspaceUploadUsageInterval = 30 * time.Second
// workspaceUsageReader reports workspace usage while an upload body
// streams so long uploads keep extending the autostop deadline.
type workspaceUsageReader struct {
r io.Reader
clock quartz.Clock
report func()
lastReport time.Time
// bytesRead is atomic because the HTTP transport may still be
// reading the body when the handler logs a failed upload.
bytesRead atomic.Int64
}
func (w *workspaceUsageReader) reportNow() {
w.lastReport = w.clock.Now()
w.report()
}
func (w *workspaceUsageReader) Read(p []byte) (int, error) {
n, err := w.r.Read(p)
w.bytesRead.Add(int64(n))
if n > 0 && w.clock.Since(w.lastReport) >= chatWorkspaceUploadUsageInterval {
w.reportNow()
}
return n, err
}
func writeWorkspaceAgentUploadError(ctx context.Context, rw http.ResponseWriter, err error) {
// The upload handler never answers 404 itself, so a 404 means the
// workspace runs an agent from before this endpoint existed (coderd
// upgraded while the workspace kept running).
var agentErr *codersdk.Error
if errors.As(err, &agentErr) && agentErr.StatusCode() == http.StatusNotFound {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "The workspace agent does not support chat file uploads. Restart the workspace to update its agent, then retry.",
})
return
}
if writeAgentError(ctx, rw, err, "Failed to upload file to workspace agent.") {
return
}
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{
Message: "Failed to upload file to workspace agent.",
Detail: chatWorkspaceUploadAgentUnreachableDetail,
})
}
func chatFilenameFromContentDisposition(contentDisposition string) string {
if contentDisposition == "" {
return ""
}
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return ""
}
return params["filename"]
}
// maxWorkspaceFileReferencePathBytes bounds persisted workspace file
// reference paths.
const maxWorkspaceFileReferencePathBytes = 4096
// normalizeWorkspaceFileReference reports whether a client-supplied
// workspace file reference plausibly points at a file uploaded for
// this chat and returns the cleaned path to persist. The agent home
// directory is not known to coderd, so validation is limited to an
// absolute path tail scoped by chat ID and a matching basename.
func normalizeWorkspaceFileReference(chatID uuid.UUID, filePath, name string) (string, bool) {
if chatID == uuid.Nil || len(filePath) > maxWorkspaceFileReferencePathBytes {
return "", false
}
// Control characters (including NUL, which cannot be persisted)
// never appear in agent-produced paths or names.
if strings.ContainsFunc(filePath, unicode.IsControl) || strings.ContainsFunc(name, unicode.IsControl) {
return "", false
}
normalizedPath := path.Clean(strings.ReplaceAll(strings.TrimSpace(filePath), `\`, "/"))
normalizedName := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")
if normalizedPath == "." || normalizedName == "." || normalizedName == "" {
return "", false
}
if path.Base(normalizedName) != normalizedName || path.Base(normalizedPath) != normalizedName {
return "", false
}
windowsPath := isWindowsAbsPath(normalizedPath)
if !path.IsAbs(normalizedPath) && !windowsPath {
return "", false
}
marker := "/" + strings.Trim(chatfiles.WorkspaceChatsDir, "/") + "/" +
chatID.String() + "/" + chatfiles.WorkspaceUploadFilesSubdir + "/"
if !strings.HasSuffix(normalizedPath, marker+normalizedName) {
return "", false
}
if windowsPath {
return strings.ReplaceAll(normalizedPath, "/", `\`), true
}
return normalizedPath, true
}
func isWindowsAbsPath(p string) bool {
return len(p) >= 3 && p[1] == ':' && p[2] == '/' &&
(('a' <= p[0] && p[0] <= 'z') || ('A' <= p[0] && p[0] <= 'Z'))
}
func createChatInputFromRequest(ctx context.Context, db database.Store, req codersdk.CreateChatRequest) (
[]codersdk.ChatMessagePart,
string,
*codersdk.Response,
) {
// Empty content creates an idle chat with no initial user
// message. Generation starts with the first message POSTed to
// /chats/{chat}/messages. This lets clients sequence work that
// needs the chat ID before the first turn, such as workspace
// file uploads.
if len(req.Content) == 0 {
return nil, "", nil
}
// Chat creation has no chat ID yet, so workspace-file-reference
// parts are rejected (they require an existing chat's uploads).
content, pasteData, inputError := createChatInputFromParts(ctx, db, uuid.Nil, uuid.NullUUID{}, req.Content, "content")
if inputError != nil {
return nil, "", inputError
}
titleText := chatprompt.TitleText(content, nil)
if titleText == "" && len(pasteData) > 0 {
pasteText := make(map[uuid.UUID]string, len(pasteData))
for id, data := range pasteData {
pasteText[id] = chatprompt.TitlePasteText(data)
}
titleText = chatprompt.TitleText(content, pasteText)
}
return content, titleText, nil
}
// createChatInputFromParts validates input parts and converts them to
// message content. The returned map holds pasted-text blob references
// by file ID; the create path derives a title from it, message send
// and edit discard it without copying blob data.
func createChatInputFromParts(
ctx context.Context,
db database.Store,
chatID uuid.UUID,
chatWorkspaceID uuid.NullUUID,
parts []codersdk.ChatInputPart,
fieldName string,
) ([]codersdk.ChatMessagePart, map[uuid.UUID][]byte, *codersdk.Response) {
if len(parts) == 0 {
return nil, nil, &codersdk.Response{
Message: "Content is required.",
Detail: "Content cannot be empty.",
}
}
content := make([]codersdk.ChatMessagePart, 0, len(parts))
var pasteData map[uuid.UUID][]byte
skillInvocations := 0
for i, part := range parts {
switch strings.ToLower(strings.TrimSpace(string(part.Type))) {
case string(codersdk.ChatInputPartTypeText):
text := strings.TrimSpace(part.Text)
if text == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].text cannot be empty.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageText(text))
case string(codersdk.ChatInputPartTypeFile):
if part.FileID == uuid.Nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id is required for file parts.", fieldName, i),
}
}
// Validate that the file exists and get its media type.
// The loaded file data is only retained for synthetic
// pastes below; LLM dispatch re-resolves file content via
// chatFileResolver.
chatFile, err := db.GetChatFileByID(ctx, part.FileID)
if err != nil {
if httpapi.Is404Error(err) {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file that does not exist.", fieldName, i),
}
}
return nil, nil, &codersdk.Response{
Message: "Internal error.",
Detail: fmt.Sprintf("Failed to retrieve file for %s[%d].", fieldName, i),
}
}
if !chatfiles.IsAllowedPromptInputMediaType(chatFile.Mimetype) {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_id references a file type that cannot be used as prompt input. Allowed types: %s.", fieldName, i, chatfiles.AllowedPromptInputMediaTypesString()),
}
}
content = append(content, codersdk.ChatMessageFile(part.FileID, chatFile.Mimetype, chatFile.Name))
// Retain blob references for create-time title derivation;
// send and edit paths discard the map.
if chatprompt.IsSyntheticPaste(chatFile.Name, chatFile.Mimetype) {
if pasteData == nil {
pasteData = make(map[uuid.UUID][]byte)
}
pasteData[part.FileID] = chatFile.Data
}
// file-reference parts carry inline code snippets, not uploaded
// files. They have no FileID and are excluded from file tracking.
case string(codersdk.ChatInputPartTypeFileReference):
if part.FileName == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageFileReference(part.FileName, part.StartLine, part.EndLine, part.Content))
case string(codersdk.ChatInputPartTypeWorkspaceFileReference):
if strings.TrimSpace(part.WorkspaceFilePath) == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_path is required for workspace-file-reference.", fieldName, i),
}
}
if strings.TrimSpace(part.WorkspaceFileName) == "" {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_name is required for workspace-file-reference.", fieldName, i),
}
}
if part.WorkspaceFileSize < 0 {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_size must be non-negative.", fieldName, i),
}
}
if chatID == uuid.Nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace-file-reference requires an existing chat.", fieldName, i),
}
}
// The sanitizer is idempotent, so every name returned by the
// upload endpoint passes unchanged.
if sanitized, err := chatfiles.SanitizeWorkspaceUploadName(part.WorkspaceFileName); err != nil || sanitized != part.WorkspaceFileName {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_name must be a name returned by the workspace file upload endpoint.", fieldName, i),
}
}
workspaceFilePath, ok := normalizeWorkspaceFileReference(chatID, part.WorkspaceFilePath, part.WorkspaceFileName)
if !ok {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_path must reference a file uploaded to this chat.", fieldName, i),
}
}
var workspaceFileMediaType string
if part.WorkspaceFileMediaType != "" {
parsed, err := chatfiles.ParseBaseMediaType(part.WorkspaceFileMediaType)
if err != nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_media_type must be a valid media type of at most %d bytes without control characters.", fieldName, i, chatfiles.MaxMediaTypeBytes),
}
}
workspaceFileMediaType = parsed
}
// The referenced bytes live in one specific workspace's
// filesystem. Reject references from a workspace other
// than the chat's current binding: after a rebind the
// path is unreadable for the bound agent.
if part.WorkspaceFileWorkspaceID == uuid.Nil {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_workspace_id is required for workspace-file-reference.", fieldName, i),
}
}
if !chatWorkspaceID.Valid || chatWorkspaceID.UUID != part.WorkspaceFileWorkspaceID {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].workspace_file_workspace_id must match the chat's bound workspace. Re-upload the file to the current workspace.", fieldName, i),
}
}
content = append(content, codersdk.ChatMessageWorkspaceFileReference(
part.WorkspaceFileWorkspaceID,
workspaceFilePath,
part.WorkspaceFileName,
part.WorkspaceFileSize,
workspaceFileMediaType,
))
// chatd loads the skill body as the chat owner when the message
// is sent; only the shape is checked here.
case string(codersdk.ChatInputPartTypeSkillInvocation):
switch part.SkillSource {
case codersdk.ChatSkillSourcePersonal, codersdk.ChatSkillSourceOrganization,
codersdk.ChatSkillSourceProject, codersdk.ChatSkillSourceWorkspace:
default:
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].skill_source %q is not supported.", fieldName, i, part.SkillSource),
}
}
if !workspacesdk.SkillNamePattern.MatchString(part.SkillName) || len(part.SkillName) > skills.MaxPersonalSkillNameBytes {
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf("%s[%d].skill_name must be a kebab-case skill name.", fieldName, i),
}
}
skillInvocations++
if skillInvocations > chatd.MaxSkillInvocationsPerMessage {
return nil, nil, &codersdk.Response{
Message: "Too many skill invocations.",
Detail: fmt.Sprintf("%s can invoke at most %d skills.", fieldName, chatd.MaxSkillInvocationsPerMessage),
}
}
content = append(content, codersdk.ChatMessageSkillInvocation(part.SkillSource, part.SkillName))
default:
return nil, nil, &codersdk.Response{
Message: "Invalid input part.",
Detail: fmt.Sprintf(
"%s[%d].type %q is not supported.",
fieldName,
i,
part.Type,
),
}
}
}
if len(content) == 0 {
return nil, nil, &codersdk.Response{
Message: "Content is required.",
Detail: fmt.Sprintf("%s must include at least one text or file part.", fieldName),
}
}
return content, pasteData, nil
}
// maxPromptRequestBodyBytes allows twice the prompt limit for JSON escaping
// and characters that sanitization removes, but never less than
// maxChatRequestBodyBytes, so small limits still return the 400 length error.
func (api *API) maxPromptRequestBodyBytes() int64 {
return max(maxChatRequestBodyBytes, 2*int64(api.chatLimits.MaxPromptBytes))
}
func (api *API) writePromptTooLongError(ctx context.Context, rw http.ResponseWriter, sanitized, message string) bool {
if len(sanitized) <= api.chatLimits.MaxPromptBytes {
return false
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: message,
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", api.chatLimits.MaxPromptBytes, len(sanitized)),
})
return true
}
func (api *API) chatFileError(err error) error {
switch {
case errors.Is(err, chatstate.ErrChatFileCapExceeded):
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Chat attachment limit reached.",
Detail: fmt.Sprintf("A message can include at most %d attachments. Remove some attachments and retry.", api.chatLimits.MaxAttachmentsPerChat),
}, err)
case errors.Is(err, chatstate.ErrChatFileUnavailable):
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Chat attachment unavailable.",
Detail: "An attachment is no longer available. Upload it again and retry.",
}, err)
default:
return nil
}
}
func (api *API) writeChatFileError(ctx context.Context, rw http.ResponseWriter, err error) bool {
return maybeWriteChatError(ctx, rw, api.chatFileError(err))
}
// chatSkillInvocationError returns a 400 response error for a message that
// invokes too many skills or a skill the chat owner cannot use, or nil for
// any other error.
func chatSkillInvocationError(err error) error {
if errors.Is(err, chatd.ErrTooManySkillInvocations) {
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Too many skill invocations.",
Detail: err.Error(),
}, err)
}
var unavailable *chatd.SkillInvocationUnavailableError
if !errors.As(err, &unavailable) {
return nil
}
return httperror.WrapResponseError(http.StatusBadRequest, codersdk.Response{
Message: unavailable.Error(),
Detail: "The skill is missing, disabled, or not shared with the chat owner.",
}, err)
}
// writeChatSkillInvocationError writes the chatSkillInvocationError response
// and reports whether it wrote one.
func writeChatSkillInvocationError(ctx context.Context, rw http.ResponseWriter, err error) bool {
return maybeWriteChatError(ctx, rw, chatSkillInvocationError(err))
}
// 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 parseUserAIProviderID(r *http.Request) (uuid.UUID, error) {
return uuid.Parse(chi.URLParam(r, "aiProvider"))
}
func convertAIProviderSummary(provider database.AIProvider) codersdk.AIProviderSummary {
displayName := provider.Name
if provider.DisplayName.Valid && provider.DisplayName.String != "" {
displayName = provider.DisplayName.String
}
return codersdk.AIProviderSummary{
ID: provider.ID,
Type: codersdk.AIProviderType(provider.Type),
Name: provider.Name,
DisplayName: displayName,
Icon: provider.Icon,
Enabled: provider.Enabled,
Deleted: provider.Deleted,
}
}
// @Summary List user AI provider key configurations
// @ID list-user-ai-provider-key-configurations
// @Security CoderSessionToken
// @Tags Chats
// @Param user path string true "User ID, username, or me"
// @Produce json
// @Success 200 {array} codersdk.UserAIProviderKeyConfig
// @Router /api/v2/users/{user}/ai-provider-keys [get]
func (api *API) listUserAIProviderKeyConfigs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetUser := httpmw.UserParam(r)
//nolint:gocritic // Users can list limited provider metadata to manage their own AI provider keys.
metadataCtx := dbauthz.AsAIProviderMetadataReader(ctx)
providers, err := api.Database.GetAIProviders(metadataCtx, database.GetAIProvidersParams{IncludeDisabled: true})
if err != nil {
api.Logger.Error(ctx, "failed to list user AI provider configs", slog.Error(err), slog.F("user_id", targetUser.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list AI providers."})
return
}
keys, err := api.Database.GetUserAIProviderKeysByUserID(ctx, targetUser.ID)
if err != nil {
api.Logger.Error(ctx, "failed to list user AI provider keys", slog.Error(err), slog.F("user_id", targetUser.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list user AI provider keys."})
return
}
keysByProviderID := make(map[uuid.UUID]struct{}, len(keys))
for _, key := range keys {
keysByProviderID[key.AIProviderID] = struct{}{}
}
visibleProviders := make([]database.AIProvider, 0, len(providers))
visibleProviderIDs := make([]uuid.UUID, 0, len(providers))
for _, provider := range providers {
_, hasUserKey := keysByProviderID[provider.ID]
if !provider.Enabled && !hasUserKey {
continue
}
visibleProviders = append(visibleProviders, provider)
visibleProviderIDs = append(visibleProviderIDs, provider.ID)
}
providerKeysByProviderID := make(map[uuid.UUID]struct{}, len(visibleProviderIDs))
if len(visibleProviderIDs) > 0 {
providerKeyIDs, err := api.Database.GetAIProviderKeyPresence(metadataCtx, visibleProviderIDs)
if err != nil {
api.Logger.Error(ctx, "failed to list AI provider key presence", slog.Error(err), slog.F("user_id", targetUser.ID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list AI provider keys."})
return
}
for _, providerID := range providerKeyIDs {
providerKeysByProviderID[providerID] = struct{}{}
}
}
byokEnabled := api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value()
configs := make([]codersdk.UserAIProviderKeyConfig, 0, len(visibleProviders))
for _, provider := range visibleProviders {
_, hasUserKey := keysByProviderID[provider.ID]
_, hasProviderKey := providerKeysByProviderID[provider.ID]
configs = append(configs, codersdk.UserAIProviderKeyConfig{
Provider: convertAIProviderSummary(provider),
HasUserAPIKey: hasUserKey,
HasProviderAPIKey: hasProviderKey,
BYOKEnabled: byokEnabled,
})
}
httpapi.Write(ctx, rw, http.StatusOK, configs)
}
// @Summary Update user AI provider key
// @ID update-user-ai-provider-key
// @Security CoderSessionToken
// @Tags Chats
// @Param user path string true "User ID, username, or me"
// @Param aiProvider path string true "AI provider ID" format(uuid)
// @Accept json
// @Param request body codersdk.CreateUserAIProviderKeyRequest true "Request body"
// @Produce json
// @Success 200 {object} codersdk.UserAIProviderKeyConfig
// @Router /api/v2/users/{user}/ai-provider-keys/{aiProvider} [put]
func (api *API) upsertUserAIProviderKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: "BYOK is disabled."})
return
}
targetUser := httpmw.UserParam(r)
providerID, err := parseUserAIProviderID(r)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid AI provider ID."})
return
}
//nolint:gocritic // Users can attach their own key to an enabled provider without AI provider admin permissions.
metadataCtx := dbauthz.AsAIProviderMetadataReader(ctx)
provider, err := api.Database.GetAIProviderByID(metadataCtx, providerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{Message: "AI provider not found."})
return
}
api.Logger.Error(ctx, "failed to get AI provider", slog.Error(err), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to get AI provider."})
return
}
if !provider.Enabled {
writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled)
return
}
var req codersdk.CreateUserAIProviderKeyRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if err := validateChatProviderAPIKeySize(req.APIKey); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "API key too large.",
Detail: err.Error(),
})
return
}
if req.APIKey == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "API key is required."})
return
}
if strings.TrimSpace(req.APIKey) != req.APIKey {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "API key must not contain leading or trailing whitespace."})
return
}
providerKeys, err := api.Database.GetAIProviderKeyPresence(metadataCtx, []uuid.UUID{providerID})
if err != nil {
api.Logger.Error(ctx, "failed to list AI provider key presence", slog.Error(err), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to list AI provider keys."})
return
}
now := api.Clock.Now()
_, err = api.Database.UpsertUserAIProviderKey(ctx, database.UpsertUserAIProviderKeyParams{
ID: uuid.New(),
UserID: targetUser.ID,
AIProviderID: providerID,
APIKey: req.APIKey,
ApiKeyKeyID: sql.NullString{},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
api.Logger.Error(ctx, "failed to update user AI provider key", slog.Error(err), slog.F("user_id", targetUser.ID), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to update user AI provider key."})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserAIProviderKeyConfig{
Provider: convertAIProviderSummary(provider),
HasUserAPIKey: true,
HasProviderAPIKey: len(providerKeys) > 0,
BYOKEnabled: true,
})
}
// @Summary Delete user AI provider key
// @ID delete-user-ai-provider-key
// @Security CoderSessionToken
// @Tags Chats
// @Param user path string true "User ID, username, or me"
// @Param aiProvider path string true "AI provider ID" format(uuid)
// @Success 204
// @Router /api/v2/users/{user}/ai-provider-keys/{aiProvider} [delete]
func (api *API) deleteUserAIProviderKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetUser := httpmw.UserParam(r)
providerID, err := parseUserAIProviderID(r)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "Invalid AI provider ID."})
return
}
if err := api.Database.DeleteUserAIProviderKey(ctx, database.DeleteUserAIProviderKeyParams{UserID: targetUser.ID, AIProviderID: providerID}); err != nil {
api.Logger.Error(ctx, "failed to delete user AI provider key", slog.Error(err), slog.F("user_id", targetUser.ID), slog.F("ai_provider_id", providerID))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{Message: "Failed to delete user AI provider key."})
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
// userAIProviderKeyStatusByProviderID returns only credential presence for the
// requesting user. The privileged lookup is contained here so callers never
// receive key material or require user:read_personal.
func (api *API) userAIProviderKeyStatusByProviderID(ctx context.Context, userID uuid.UUID) (map[uuid.UUID]bool, error) {
//nolint:gocritic // The result is reduced to credential presence for exactly the requesting user.
rows, err := api.Database.GetUserAIProviderKeysByUserID(dbauthz.AsChatd(ctx), userID)
if err != nil {
return nil, err
}
statusByProviderID := make(map[uuid.UUID]bool, len(rows))
for _, row := range rows {
statusByProviderID[row.AIProviderID] = strings.TrimSpace(row.APIKey) != ""
}
return statusByProviderID, nil
}
func (api *API) configuredProvidersFromAIProviders(ctx context.Context, providers []database.AIProvider) ([]chatprovider.ConfiguredProvider, error) {
if len(providers) == 0 {
return nil, nil
}
providerIDs := make([]uuid.UUID, 0, len(providers))
for _, provider := range providers {
providerIDs = append(providerIDs, provider.ID)
}
keys, err := api.Database.GetAIProviderKeysByProviderIDs(ctx, providerIDs)
if err != nil {
return nil, xerrors.Errorf("get AI provider keys: %w", err)
}
keysByProviderID := make(map[uuid.UUID][]database.AIProviderKey, len(providers))
for _, key := range keys {
keysByProviderID[key.ProviderID] = append(keysByProviderID[key.ProviderID], key)
}
configuredProviders := make([]chatprovider.ConfiguredProvider, 0, len(providers))
for _, provider := range providers {
configuredProviders = append(configuredProviders, api.configuredProviderFromAIProviderKeys(provider, keysByProviderID[provider.ID]))
}
return configuredProviders, nil
}
func (api *API) configuredProviderFromAIProviderKeys(provider database.AIProvider, keys []database.AIProviderKey) chatprovider.ConfiguredProvider {
apiKey := ""
for _, key := range keys {
if key.APIKey != "" {
apiKey = key.APIKey
break
}
}
supportsAmbientCredentials := false
// A corrupt settings blob only costs the ambient-credential capability hint;
// the gateway remains responsible for actual authentication.
if settings, err := db2sdk.AIProviderSettings(provider.Settings); err == nil {
supportsAmbientCredentials = aiProviderSupportsAmbientCredentials(settings)
}
return chatprovider.ConfiguredProvider{
ProviderID: provider.ID,
Provider: string(provider.Type),
APIKey: apiKey,
BaseURL: provider.BaseUrl,
CentralAPIKeyEnabled: true,
AllowUserAPIKey: api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value(),
AllowCentralAPIKeyFallback: true,
SupportsAmbientCredentials: supportsAmbientCredentials,
}
}
// @Summary List AI models and provider descriptors in an organization
// @ID list-ai-models-and-provider-descriptors-in-an-organization
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization name or ID"
// @Success 200 {object} codersdk.OrganizationChatModelsResponse
// @Router /api/v2/organizations/{organization}/chats/models [get]
func (api *API) listChatModelConfigsByOrganization(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
organization := httpmw.OrganizationParam(r)
apiKey := httpmw.APIKey(r)
// Keep the token scope gate separate from the authorized database filter.
// The filter applies each config ACL and the organization predicate.
if !chatModelConfigReadScope(apiKey.Scopes) {
httpapi.Forbidden(rw)
return
}
configs, visible, err := api.readableChatModelsInOrganization(ctx, r, organization)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if !visible {
httpapi.ResourceNotFound(rw)
return
}
availability, err := api.getUserChatProviderAvailability(ctx, apiKey.UserID, organization.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to load chat model availability.",
Detail: err.Error(),
})
return
}
providers, err := api.chatModelProviderDescriptors(ctx, apiKey.UserID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to list AI providers.",
Detail: err.Error(),
})
return
}
for i := range providers {
if status, ok := availability.providerStatusByID[providers[i].ID]; ok {
providers[i].Available = status.Available
if !status.Available {
providers[i].UnavailableReason = status.UnavailableReason
}
}
}
resp := codersdk.OrganizationChatModelsResponse{
Models: make([]codersdk.ChatModel, 0, len(configs)),
Providers: providers,
UnsupportedProviders: chatprovider.UnsupportedProviders(availability.configuredProviders),
}
providerTypes := make(map[uuid.UUID]database.AIProviderType, len(providers))
for _, provider := range providers {
providerTypes[provider.ID] = database.AIProviderType(provider.Type)
}
for _, config := range configs {
resp.Models = append(resp.Models, convertChatModelConfig(config, providerTypes[config.AIProviderID.UUID]))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
func (api *API) readableChatModelsInOrganization(
ctx context.Context,
r *http.Request,
organization database.Organization,
) ([]database.ChatModelConfig, bool, error) {
configs, err := api.Database.GetChatModelConfigs(ctx, organization.ID)
if err != nil {
return nil, false, err
}
if len(configs) > 0 {
return configs, true, nil
}
return configs, api.Authorize(r, policy.ActionRead, organization.RBACObject()), nil
}
func chatModelConfigReadScope(scopes database.APIKeyScopes) bool {
return scopes.Has(database.ApiKeyScopeCoderAll) ||
scopes.Has(database.ApiKeyScopeChatModelConfigRead)
}
// chatModelProviderDescriptors assembles the redacted provider descriptors
// for the org model collection. The caller already passed the org's model
// read gate; providers are deployment-scoped and an org admin cannot read
// them directly, so the fetch runs under a narrow AsChatd context scoped to
// exactly these two reads and the result is projected to the fixed redacted
// fields, which exclude key material, base URLs, and headers.
func (api *API) chatModelProviderDescriptors(
ctx context.Context,
userID uuid.UUID,
) ([]codersdk.ChatModelProviderDescriptor, error) {
//nolint:gocritic // Fixed redacted projection under the model read gate; see function doc.
providers, err := api.Database.GetAIProviders(dbauthz.AsChatd(ctx), database.GetAIProvidersParams{IncludeDisabled: true})
if err != nil {
return nil, err
}
//nolint:gocritic // Key presence is boolean metadata, not key material; same redaction as the deployment endpoint.
keysByProvider, err := loadAIProviderKeysByProvider(dbauthz.AsChatd(ctx), api.Database)
if err != nil {
return nil, err
}
userKeyStatus := make(map[uuid.UUID]bool)
if api.DeploymentValues.AI.BridgeConfig.AllowBYOK.Value() {
userKeyStatus, err = api.userAIProviderKeyStatusByProviderID(ctx, userID)
if err != nil {
return nil, err
}
}
out := make([]codersdk.ChatModelProviderDescriptor, 0, len(providers))
for _, provider := range providers {
display := provider.Name
if provider.DisplayName.Valid && provider.DisplayName.String != "" {
display = provider.DisplayName.String
}
hasKey := false
for _, key := range keysByProvider[provider.ID] {
if key.APIKey != "" {
hasKey = true
break
}
}
hasUserKey := userKeyStatus[provider.ID]
// Some provider configurations support ambient credentials rather than a
// stored key. Actual credential availability is checked by the gateway.
supportsAmbientCredentials := provider.Type == database.AIProviderTypeBedrock
if !supportsAmbientCredentials {
settings, err := db2sdk.AIProviderSettings(provider.Settings)
if err != nil {
return nil, xerrors.Errorf("decode AI provider settings: %w", err)
}
supportsA