diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 98624f6729..cac56b2ebd 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -62,8 +62,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { } modelConfig := database.ChatModelConfig{ ID: modelConfigID, - Provider: "anthropic", - Model: "claude-haiku-4-5", + Provider: "openai", + Model: "gpt-4o-mini", ContextLimit: 8192, } updatedChat := chat @@ -85,9 +85,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { require.NoError(t, err) defer cancelSub() - serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { - require.Equal(t, "claude-haiku-4-5", req.Model) - return chattest.AnthropicNonStreamingResponse(wantTitle) + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + require.Equal(t, "gpt-4o-mini", req.Model) + return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}") }) server := &Server{ @@ -99,7 +99,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{ - Provider: "anthropic", + Provider: "openai", APIKey: "test-key", BaseUrl: serverURL, }}, nil) @@ -221,8 +221,8 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t lockedChat.StartedAt = sql.NullTime{Time: time.Now(), Valid: true} modelConfig := database.ChatModelConfig{ ID: modelConfigID, - Provider: "anthropic", - Model: "claude-haiku-4-5", + Provider: "openai", + Model: "gpt-4o-mini", ContextLimit: 8192, } updatedChat := lockedChat @@ -247,9 +247,9 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t require.NoError(t, err) defer cancelSub() - serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse { - require.Equal(t, "claude-haiku-4-5", req.Model) - return chattest.AnthropicNonStreamingResponse(wantTitle) + serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + require.Equal(t, "gpt-4o-mini", req.Model) + return chattest.OpenAINonStreamingResponse("{\"title\":\"" + wantTitle + "\"}") }) server := &Server{ @@ -261,7 +261,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) db.EXPECT().GetEnabledChatProviders(gomock.Any()).Return([]database.ChatProvider{{ - Provider: "anthropic", + Provider: "openai", APIKey: "test-key", BaseUrl: serverURL, }}, nil) diff --git a/coderd/x/chatd/chattest/openai.go b/coderd/x/chatd/chattest/openai.go index 1e99628dda..0e89417724 100644 --- a/coderd/x/chatd/chattest/openai.go +++ b/coderd/x/chatd/chattest/openai.go @@ -670,7 +670,11 @@ func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp "created": resp.Created, "model": resp.Model, "output": outputs, - "usage": resp.Usage, + "usage": map[string]interface{}{ + "input_tokens": resp.Usage.PromptTokens, + "output_tokens": resp.Usage.CompletionTokens, + "total_tokens": resp.Usage.TotalTokens, + }, } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 68f3a33f8f..78b3531706 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -2,12 +2,14 @@ package chatd import ( "context" + "errors" "fmt" "slices" "strings" "time" "charm.land/fantasy" + "charm.land/fantasy/object" fantasyanthropic "charm.land/fantasy/providers/anthropic" fantasyazure "charm.land/fantasy/providers/azure" fantasybedrock "charm.land/fantasy/providers/bedrock" @@ -27,6 +29,7 @@ import ( ) const titleGenerationPrompt = "Write a short title for the user's message. " + + "Populate the title field with the result. " + "Return only the title text in 2-8 words. " + "Do not answer the user or describe the title-writing task. " + "Preserve specific identifiers such as PR numbers, repo names, file paths, function names, and error messages. " + @@ -89,6 +92,10 @@ func normalizeShortTextOutput(text string) string { return strings.Join(strings.Fields(text), " ") } +type generatedTitle struct { + Title string `json:"title" description:"Short descriptive chat title"` +} + // maybeGenerateChatTitle generates an AI title for the chat when // appropriate (first user message, no assistant reply yet, and the // current title is either empty or still the fallback truncation). @@ -173,17 +180,79 @@ func generateTitle( model fantasy.LanguageModel, input string, ) (string, error) { - title, _, err := generateShortText(ctx, model, titleGenerationPrompt, input) + title, _, err := generateStructuredTitle(ctx, model, titleGenerationPrompt, input) if err != nil { return "", err } - title = normalizeTitleOutput(title) - if title == "" { - return "", xerrors.New("generated title was empty") - } return title, nil } +func generateStructuredTitle( + ctx context.Context, + model fantasy.LanguageModel, + systemPrompt string, + userInput string, +) (string, fantasy.Usage, error) { + userInput = strings.TrimSpace(userInput) + if userInput == "" { + return "", fantasy.Usage{}, xerrors.New("title input was empty") + } + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleSystem, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: systemPrompt}, + }, + }, + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: userInput}, + }, + }, + } + + var maxOutputTokens int64 = 256 + var result *fantasy.ObjectResult[generatedTitle] + err := chatretry.Retry(ctx, func(retryCtx context.Context) error { + var genErr error + result, genErr = object.Generate[generatedTitle](retryCtx, model, fantasy.ObjectCall{ + Prompt: prompt, + SchemaName: "propose_title", + SchemaDescription: "Propose a short chat title.", + MaxOutputTokens: &maxOutputTokens, + }) + return genErr + }, nil) + if err != nil { + // Extract usage from the error when available so that + // failed attempts are still accounted for in usage tracking. + var usage fantasy.Usage + var noObjErr *fantasy.NoObjectGeneratedError + if errors.As(err, &noObjErr) { + usage = noObjErr.Usage + } + return "", usage, xerrors.Errorf("generate structured title: %w", err) + } + + title := normalizeTitleOutput(result.Object.Title) + if err := validateGeneratedTitle(title); err != nil { + return "", result.Usage, err + } + return title, result.Usage, nil +} + +func validateGeneratedTitle(title string) error { + if title == "" { + return xerrors.New("generated title was empty") + } + if len(strings.Fields(title)) > 8 { + return xerrors.New("generated title exceeded 8 words") + } + return nil +} + // titleInput returns the first user message text and whether title // generation should proceed. It returns false when the chat already // has assistant/tool replies, has more than one visible user message, @@ -400,7 +469,8 @@ func renderManualTitlePrompt( _, _ = prompt.WriteString(value) } - write("Write a short title for this AI coding conversation.\n\n") + write("Write a short title for this AI coding conversation.\n") + write("Populate the title field with the result.\n\n") write("Primary user objective:\n\n") write(firstUserText) write("\n") @@ -420,6 +490,7 @@ func renderManualTitlePrompt( write("\n\nRequirements:\n") write("- Return only the title text in 2-8 words.\n") + write("- Populate the title field only.\n") write("- Do not answer the user or describe the title-writing task.\n") write("- Preserve specific identifiers (PR numbers, repo names, file paths, function names, error messages).\n") write("- If the conversation is short or vague, stay close to the user's wording.\n") @@ -458,19 +529,14 @@ func generateManualTitle( userInput = strings.TrimSpace(firstUserText) } - title, usage, err := generateShortText( + title, usage, err := generateStructuredTitle( titleCtx, fallbackModel, systemPrompt, userInput, ) if err != nil { - return "", fantasy.Usage{}, err - } - - title = normalizeTitleOutput(title) - if title == "" { - return "", usage, xerrors.New("generated title was empty") + return "", usage, err } return title, usage, nil diff --git a/coderd/x/chatd/quickgen_test.go b/coderd/x/chatd/quickgen_test.go index f9936ad9d0..a904aa1e32 100644 --- a/coderd/x/chatd/quickgen_test.go +++ b/coderd/x/chatd/quickgen_test.go @@ -376,7 +376,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) { } model := &stubModel{ - generateFn: func(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + generateObjectFn: func(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { deadline, ok := ctx.Deadline() require.True(t, ok, "manual title generation should set a deadline") require.WithinDuration( @@ -386,11 +386,8 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) { 2*time.Second, ) require.Len(t, call.Prompt, 2) - return &fantasy.Response{ - Content: fantasy.ResponseContent{ - fantasy.TextContent{Text: "Refresh title"}, - }, - }, nil + require.Equal(t, "propose_title", call.SchemaName) + return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil }, } @@ -417,7 +414,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) { } model := &stubModel{ - generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) { + generateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { require.Len(t, call.Prompt, 2) systemText, ok := call.Prompt[0].Content[0].(fantasy.TextPart) require.True(t, ok) @@ -426,11 +423,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) { userText, ok := call.Prompt[1].Content[0].(fantasy.TextPart) require.True(t, ok) require.Equal(t, truncateRunes(longFirstUserText, 1000), userText.Text) - return &fantasy.Response{ - Content: fantasy.ResponseContent{ - fantasy.TextContent{Text: "Refresh title"}, - }, - }, nil + return &fantasy.ObjectResponse{Object: map[string]any{"title": "Refresh title"}}, nil }, } @@ -455,11 +448,9 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T) } model := &stubModel{ - generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) { - return &fantasy.Response{ - Content: fantasy.ResponseContent{ - fantasy.TextContent{Text: "\"\""}, - }, + generateObjectFn: func(_ context.Context, _ fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + return &fantasy.ObjectResponse{ + Object: map[string]any{"title": "\"\""}, Usage: fantasy.Usage{ InputTokens: 11, OutputTokens: 7, @@ -533,13 +524,17 @@ func Test_generateShortText_NormalizesQuotedOutput(t *testing.T) { } type stubModel struct { - generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error) + generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error) + generateObjectFn func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) } func (m *stubModel) Generate( ctx context.Context, call fantasy.Call, ) (*fantasy.Response, error) { + if m.generateFn == nil { + return nil, xerrors.New("generate not implemented") + } return m.generateFn(ctx, call) } @@ -550,11 +545,14 @@ func (*stubModel) Stream( return nil, xerrors.New("stream not implemented") } -func (*stubModel) GenerateObject( - context.Context, - fantasy.ObjectCall, +func (m *stubModel) GenerateObject( + ctx context.Context, + call fantasy.ObjectCall, ) (*fantasy.ObjectResponse, error) { - return nil, xerrors.New("generate object not implemented") + if m.generateObjectFn == nil { + return nil, xerrors.New("generate object not implemented") + } + return m.generateObjectFn(ctx, call) } func (*stubModel) StreamObject(