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