Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 12 additions & 4 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1559,7 +1559,6 @@ func New(options *Options) *API {
r.Post("/compact", api.compactChat)
r.Post("/reconcile-invalid", api.reconcileInvalidChatState)
r.Post("/tool-results", api.postChatToolResults)
r.Post("/title/regenerate", api.regenerateChatTitle)
r.Post("/title/propose", api.proposeChatTitle)
r.Get("/diff", api.getChatDiffContents)
r.Put("/context", api.refreshChatContext)
Expand Down
95 changes: 44 additions & 51 deletions coderd/exp_chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ func maybeWriteChatUsageLimitError(ctx context.Context, rw http.ResponseWriter,
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
Expand Down Expand Up @@ -3381,63 +3418,16 @@ func (api *API) reconcileInvalidChatState(rw http.ResponseWriter, r *http.Reques

// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Regenerate chat title
// @ID regenerate-chat-title
// @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.Chat
// @Router /api/experimental/chats/{chat}/title/regenerate [post]
// @Success 200 {object} codersdk.ProposeChatTitleResponse
// @Router /api/experimental/chats/{chat}/title/propose [post]
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
chat := httpmw.ChatParam(r)

if !api.requireChatDaemon(ctx, rw) {
return
}

if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) {
httpapi.ResourceNotFound(rw)
return
}

// Only the chat owner may regenerate titles. See
// postChatMessages for the security rationale.
if apiKey.UserID != chat.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only the chat owner may regenerate the title.",
})
return
}

updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat)
if err != nil {
if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) {
writeNoLocalChatModelResponse(ctx, rw)
return
}
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if maybeWriteChatUsageLimitError(ctx, rw, err) {
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to regenerate chat title.",
Detail: err.Error(),
})
return
}

httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil))
}

//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
Expand Down Expand Up @@ -3475,6 +3465,9 @@ func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) {
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(),
Expand Down
97 changes: 97 additions & 0 deletions coderd/exp_chats_internal_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package coderd

import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
Expand All @@ -21,6 +23,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
Expand Down Expand Up @@ -564,3 +567,97 @@ func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) {
"isZeroChatModelCallConfig ignores field %s", field.Name)
}
}

func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) {
t.Parallel()

// canceledCtx returns a context whose Err reports context.Canceled,
// mirroring a request whose caller disconnected.
canceledCtx := func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}

tests := []struct {
name string
ctx context.Context
err error
wantWrote bool
wantStatus int
wantMessage string
}{
{
// A genuine title timeout is tagged with the chatd sentinel
// and wrapped several layers deep, so the handler must match
// with errors.Is.
name: "TitleTimeoutSentinelMapsTo504",
ctx: context.Background(),
err: xerrors.Errorf(
"generate manual title: %w",
errors.Join(chatd.ErrManualTitleTimedOut, context.DeadlineExceeded),
),
wantWrote: true,
wantStatus: http.StatusGatewayTimeout,
wantMessage: "Title generation timed out. Try again or rename manually.",
},
{
// A provider failure can wrap an unrelated transport deadline
// while the title deadline never expired. Without the chatd
// sentinel this must keep the 500 path instead of a
// misleading 504.
name: "BareDeadlineWithoutSentinelFallsThrough",
ctx: context.Background(),
err: xerrors.Errorf("provider call failed: %w", context.DeadlineExceeded),
wantWrote: false,
},
{
// The caller disconnected, so ctx.Err() confirms the cancel
// and the handler reports a client-closed request.
name: "CanceledWithCanceledCtxMapsTo499",
ctx: canceledCtx(),
err: xerrors.Errorf("generate manual title: %w", context.Canceled),
wantWrote: true,
wantStatus: statusClientClosedRequest,
wantMessage: "Title generation was canceled.",
},
{
// A provider error can wrap context.Canceled (e.g. an
// upstream 401) while the request context is still active.
// Without a live cancel this must fall through to the 500
// path instead of a misleading 499.
name: "CanceledWithLiveCtxFallsThrough",
ctx: context.Background(),
err: xerrors.Errorf("provider auth failed: %w", context.Canceled),
wantWrote: false,
},
{
// Unrelated errors must fall through so the handler keeps
// its existing 500 surface for genuine failures.
name: "UnrelatedErrorFallsThrough",
ctx: context.Background(),
err: xerrors.New("something else"),
wantWrote: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

rw := httptest.NewRecorder()
wrote := maybeWriteManualTitleTimeoutErr(tt.ctx, rw, tt.err)
require.Equal(t, tt.wantWrote, wrote)
if !tt.wantWrote {
require.Equal(t, http.StatusOK, rw.Code, "must not write a response when err is unrelated")
return
}
require.Equal(t, tt.wantStatus, rw.Code)

var resp codersdk.Response
require.NoError(t, json.NewDecoder(rw.Body).Decode(&resp))
require.Equal(t, tt.wantMessage, resp.Message)
require.Empty(t, resp.Detail, "translated copy must not leak the raw error detail")
})
}
}
Loading
Loading