Ctrl K
ComunidadGuías…

coderd/exp_chats.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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(), &params) {
		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