From 268e8a35c5b3eae4a38f21f11260fdc09b417912 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 10:06:24 +0000 Subject: [PATCH 1/3] feat(coderd/x/chatd): add summary model override Add a deployment-wide summary generation model override as a follow-up to persisted chat summaries. Co-authored-by: Cursor --- coderd/database/dbauthz/dbauthz.go | 14 ++ coderd/database/dbauthz/dbauthz_test.go | 8 ++ coderd/database/dbmetrics/querymetrics.go | 16 +++ coderd/database/dbmock/dbmock.go | 29 +++++ coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 22 ++++ coderd/database/queries/siteconfig.sql | 8 ++ coderd/exp_chats.go | 6 + coderd/exp_chats_test.go | 14 +- coderd/x/chatd/chatd.go | 28 +++- coderd/x/chatd/summary_override.go | 47 +++++++ .../x/chatd/summary_override_internal_test.go | 120 ++++++++++++++++++ codersdk/chats.go | 11 +- site/src/api/typesGenerated.ts | 2 + .../AgentsPage/AgentSettingsAgentsPage.tsx | 20 +++ .../AgentSettingsAgentsPageView.stories.tsx | 49 +++++++ .../AgentSettingsAgentsPageView.tsx | 33 +++++ .../AgentsPage/AgentsPageView.stories.tsx | 8 ++ 18 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 coderd/x/chatd/summary_override.go create mode 100644 coderd/x/chatd/summary_override_internal_test.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d6c145fa46432..2cccef22fc9a6 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3451,6 +3451,13 @@ func (q *querier) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([ return q.db.GetChatStreamSyncRows(ctx, ids) } +func (q *querier) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { + return "", err + } + return q.db.GetChatSummaryGenerationModelOverride(ctx) +} + func (q *querier) GetChatSystemPrompt(ctx context.Context) (string, error) { // The system prompt is a deployment-wide setting read during chat // creation by every authenticated user, so no RBAC policy check @@ -8724,6 +8731,13 @@ func (q *querier) UpsertChatRetentionDays(ctx context.Context, retentionDays int return q.db.UpsertChatRetentionDays(ctx, retentionDays) } +func (q *querier) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatSummaryGenerationModelOverride(ctx, value) +} + func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1e417fed73e3e..2b0bd7d5c485e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1201,6 +1201,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) })) + s.Run("GetChatSummaryGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() + check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) + })) s.Run("GetChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) @@ -1672,6 +1676,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatTitleGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("UpsertChatSummaryGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatSummaryGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() + check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpsertChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c38660e33d64d..6df76e6befa51 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1770,6 +1770,14 @@ func (m queryMetricsStore) GetChatStreamSyncRows(ctx context.Context, ids []uuid return r0, r1 } +func (m queryMetricsStore) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetChatSummaryGenerationModelOverride(ctx) + m.queryLatencies.WithLabelValues("GetChatSummaryGenerationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatSummaryGenerationModelOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatSystemPrompt(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetChatSystemPrompt(ctx) @@ -6234,6 +6242,14 @@ func (m queryMetricsStore) UpsertChatRetentionDays(ctx context.Context, retentio return r0 } +func (m queryMetricsStore) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + start := time.Now() + r0 := m.s.UpsertChatSummaryGenerationModelOverride(ctx, value) + m.queryLatencies.WithLabelValues("UpsertChatSummaryGenerationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatSummaryGenerationModelOverride").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertChatSystemPrompt(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 56c75cc3bc0ce..dcc3a58541c0f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3267,6 +3267,21 @@ func (mr *MockStoreMockRecorder) GetChatStreamSyncRows(ctx, ids any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatStreamSyncRows", reflect.TypeOf((*MockStore)(nil).GetChatStreamSyncRows), ctx, ids) } +// GetChatSummaryGenerationModelOverride mocks base method. +func (m *MockStore) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatSummaryGenerationModelOverride", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatSummaryGenerationModelOverride indicates an expected call of GetChatSummaryGenerationModelOverride. +func (mr *MockStoreMockRecorder) GetChatSummaryGenerationModelOverride(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSummaryGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatSummaryGenerationModelOverride), ctx) +} + // GetChatSystemPrompt mocks base method. func (m *MockStore) GetChatSystemPrompt(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -11667,6 +11682,20 @@ func (mr *MockStoreMockRecorder) UpsertChatRetentionDays(ctx, retentionDays any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatRetentionDays", reflect.TypeOf((*MockStore)(nil).UpsertChatRetentionDays), ctx, retentionDays) } +// UpsertChatSummaryGenerationModelOverride mocks base method. +func (m *MockStore) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatSummaryGenerationModelOverride", ctx, value) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatSummaryGenerationModelOverride indicates an expected call of UpsertChatSummaryGenerationModelOverride. +func (mr *MockStoreMockRecorder) UpsertChatSummaryGenerationModelOverride(ctx, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSummaryGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatSummaryGenerationModelOverride), ctx, value) +} + // UpsertChatSystemPrompt mocks base method. func (m *MockStore) UpsertChatSystemPrompt(ctx context.Context, value string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a2840d3ad2ecd..063b2aa001ff4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -472,6 +472,7 @@ type sqlcQuerier interface { // A value of 0 disables chat purging entirely. GetChatRetentionDays(ctx context.Context) (int32, error) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID) ([]GetChatStreamSyncRowsRow, error) + GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) GetChatSystemPrompt(ctx context.Context) (string, error) // GetChatSystemPromptConfig returns both chat system prompt settings in a // single read to avoid torn reads between separate site-config lookups. @@ -1567,6 +1568,7 @@ type sqlcQuerier interface { UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error UpsertChatPlanModeInstructions(ctx context.Context, value string) error UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error + UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9ecef375c92a6..aca0c7d08fe0f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -24761,6 +24761,18 @@ func (q *sqlQuerier) GetChatRetentionDays(ctx context.Context) (int32, error) { return retention_days, err } +const getChatSummaryGenerationModelOverride = `-- name: GetChatSummaryGenerationModelOverride :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_summary_generation_model_override'), '') :: text AS model_config_id +` + +func (q *sqlQuerier) GetChatSummaryGenerationModelOverride(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatSummaryGenerationModelOverride) + var model_config_id string + err := row.Scan(&model_config_id) + return model_config_id, err +} + const getChatSystemPrompt = `-- name: GetChatSystemPrompt :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_system_prompt'), '') :: text AS chat_system_prompt @@ -25210,6 +25222,16 @@ func (q *sqlQuerier) UpsertChatRetentionDays(ctx context.Context, retentionDays return err } +const upsertChatSummaryGenerationModelOverride = `-- name: UpsertChatSummaryGenerationModelOverride :exec +INSERT INTO site_configs (key, value) VALUES ('agents_chat_summary_generation_model_override', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_summary_generation_model_override' +` + +func (q *sqlQuerier) UpsertChatSummaryGenerationModelOverride(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatSummaryGenerationModelOverride, value) + return err +} + const upsertChatSystemPrompt = `-- name: UpsertChatSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 709cd287ca610..93877b9b51408 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -191,6 +191,14 @@ SELECT INSERT INTO site_configs (key, value) VALUES ('agents_chat_title_generation_model_override', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_title_generation_model_override'; +-- name: GetChatSummaryGenerationModelOverride :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_summary_generation_model_override'), '') :: text AS model_config_id; + +-- name: UpsertChatSummaryGenerationModelOverride :exec +INSERT INTO site_configs (key, value) VALUES ('agents_chat_summary_generation_model_override', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_summary_generation_model_override'; + -- name: GetChatDesktopEnabled :one SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8635fd023a025..5a4ada4153962 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -621,6 +621,12 @@ func (api *API) chatModelOverrideSiteConfig( getter: api.Database.GetChatTitleGenerationModelOverride, upsert: api.Database.UpsertChatTitleGenerationModelOverride, }, nil + case codersdk.ChatModelOverrideContextSummaryGeneration: + return chatModelOverrideSiteConfig{ + label: "summary generation", + getter: api.Database.GetChatSummaryGenerationModelOverride, + upsert: api.Database.UpsertChatSummaryGenerationModelOverride, + }, nil default: return chatModelOverrideSiteConfig{}, xerrors.Errorf( "unknown chat model override context %q", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index fd7b6cda4cc41..26e5d00db7e36 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -11608,6 +11608,16 @@ func TestChatModelOverrides(t *testing.T) { return db.UpsertChatTitleGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) }, }, + { + name: "SummaryGeneration", + context: codersdk.ChatModelOverrideContextSummaryGeneration, + dbGet: func(ctx context.Context, db database.Store) (string, error) { + return db.GetChatSummaryGenerationModelOverride(dbauthz.AsSystemRestricted(ctx)) + }, + dbUpsert: func(ctx context.Context, db database.Store, value string) error { + return db.UpsertChatSummaryGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) + }, + }, } for _, setting := range settings { @@ -11753,7 +11763,7 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid chat model override context.", sdkErr.Message) require.Equal( t, - `Expected one of general, explore, title_generation. Got "not-a-context".`, + `Expected one of general, explore, title_generation, summary_generation. Got "not-a-context".`, sdkErr.Detail, ) @@ -11762,7 +11772,7 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid chat model override context.", sdkErr.Message) require.Equal( t, - `Expected one of general, explore, title_generation. Got "not-a-context".`, + `Expected one of general, explore, title_generation, summary_generation. Got "not-a-context".`, sdkErr.Detail, ) }) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9a7726b31fdc8..21a068007f61e 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5034,14 +5034,38 @@ func (p *Server) generateAndStoreChatSummary( p.updateChatSummary(ctx, chat, chat.HistoryVersion, summary, logger) } -// resolveChatSummaryModel resolves the chat's configured model for summary -// generation. +// resolveChatSummaryModel resolves the model for summary generation. It prefers +// the deployment summary-generation override when set; a configured-but-unusable +// override is a hard failure that skips generation (preserving any existing +// summary). Otherwise it falls back to the chat's configured model. func (p *Server) resolveChatSummaryModel( ctx context.Context, chat database.Chat, runResult runChatResult, logger slog.Logger, ) (fantasy.LanguageModel, database.ChatModelConfig, bool) { + overrideConfig, overrideModel, _, _, overrideSet, overrideErr := p.resolveSummaryGenerationModelOverride( + ctx, chat, runResult.ProviderKeys, runResult.ModelBuildOptions, + ) + if overrideErr != nil { + if overrideSet { + logger.Warn(ctx, "summary generation model override unavailable, skipping summary generation", + slog.F("chat_id", chat.ID), + slog.F("override_context", summaryGenerationOverrideContext), + slog.Error(overrideErr), + ) + return nil, database.ChatModelConfig{}, false + } + logger.Debug(ctx, "failed to resolve summary generation model override", + slog.F("chat_id", chat.ID), + slog.F("override_context", summaryGenerationOverrideContext), + slog.Error(overrideErr), + ) + } + if overrideSet { + return overrideModel, overrideConfig, true + } + //nolint:dogsled // resolveChatModel returns rich routing metadata; summary generation only needs the model and its config. model, dbConfig, _, _, _, _, _, err := p.resolveChatModel(ctx, chat, runResult.ModelBuildOptions) if err != nil { diff --git a/coderd/x/chatd/summary_override.go b/coderd/x/chatd/summary_override.go new file mode 100644 index 0000000000000..81da7ae167d50 --- /dev/null +++ b/coderd/x/chatd/summary_override.go @@ -0,0 +1,47 @@ +package chatd + +import ( + "context" + + "charm.land/fantasy" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" +) + +const summaryGenerationOverrideContext = "summary_generation" + +func readSummaryGenerationModelOverride( + ctx context.Context, + db database.Store, +) (string, error) { + //nolint:gocritic // Chatd is internal, not a user, so this read uses AsChatd. + chatdCtx := dbauthz.AsChatd(ctx) + raw, err := db.GetChatSummaryGenerationModelOverride(chatdCtx) + if err != nil { + return "", xerrors.Errorf( + "get chat summary generation model override: %w", + err, + ) + } + return raw, nil +} + +// resolveSummaryGenerationModelOverride resolves the deployment-wide summary +// generation model override. overrideSet is true when an override was +// configured; in that case any returned error is a hard failure and the caller +// should skip summary generation. When overrideSet is false, callers fall back +// to the chat's configured model. +func (p *Server) resolveSummaryGenerationModelOverride( + ctx context.Context, + chat database.Chat, + keys chatprovider.ProviderAPIKeys, + modelOpts modelBuildOptions, +) (database.ChatModelConfig, fantasy.LanguageModel, chatprovider.ProviderAPIKeys, resolvedModelRoute, bool, error) { + return p.resolveGenerationModelOverride( + ctx, chat, keys, modelOpts, + summaryGenerationOverrideContext, readSummaryGenerationModelOverride, + ) +} diff --git a/coderd/x/chatd/summary_override_internal_test.go b/coderd/x/chatd/summary_override_internal_test.go new file mode 100644 index 0000000000000..557b538c95767 --- /dev/null +++ b/coderd/x/chatd/summary_override_internal_test.go @@ -0,0 +1,120 @@ +package chatd + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/testutil" +) + +func TestResolveSummaryGenerationModelOverride_Unset(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("", nil) + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + require.NoError(t, err) + require.False(t, overrideSet) + require.Nil(t, model) + require.Equal(t, database.ChatModelConfig{}, config) +} + +func TestResolveSummaryGenerationModelOverride_MalformedFallsThrough(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return("not-a-uuid", nil) + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + require.NoError(t, err) + require.False(t, overrideSet) + require.Nil(t, model) + require.Equal(t, database.ChatModelConfig{}, config) +} + +func TestResolveSummaryGenerationModelOverride_SetUsable(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + overrideConfig := titleOverrideModelConfig("gpt-4.1", true) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{Type: database.AIProviderTypeOpenai, Enabled: true}}, nil) + db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + require.NoError(t, err) + require.True(t, overrideSet) + require.NotNil(t, model) + require.Equal(t, overrideConfig, config) +} + +func TestResolveSummaryGenerationModelOverride_SetUnusableHardFails(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + chat, _ := titleOverrideTestChatAndMessages(t) + // A disabled config is treated as unavailable. + overrideConfig := titleOverrideModelConfig("gpt-4.1", false) + + db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) + + server := titleOverrideTestServer(db, logger) + config, model, _, _, overrideSet, err := server.resolveSummaryGenerationModelOverride( + ctx, + chat, + chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, + modelBuildOptions{}, + ) + // overrideSet is true even on a hard failure so the caller skips generation + // instead of falling back to the chat model. + require.Error(t, err) + require.True(t, overrideSet) + require.ErrorContains(t, err, "summary generation model override is unavailable") + require.Nil(t, model) + require.Equal(t, database.ChatModelConfig{}, config) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 24e949dfc17dc..cf2f9a8cea5e3 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -738,9 +738,10 @@ type UpdateChatPlanModeInstructionsRequest struct { type ChatModelOverrideContext string const ( - ChatModelOverrideContextGeneral ChatModelOverrideContext = "general" - ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" - ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" + ChatModelOverrideContextGeneral ChatModelOverrideContext = "general" + ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" + ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" + ChatModelOverrideContextSummaryGeneration ChatModelOverrideContext = "summary_generation" ) // Valid reports whether the override context is one of the supported values. @@ -748,7 +749,8 @@ func (c ChatModelOverrideContext) Valid() bool { switch c { case ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, - ChatModelOverrideContextTitleGeneration: + ChatModelOverrideContextTitleGeneration, + ChatModelOverrideContextSummaryGeneration: return true default: return false @@ -761,6 +763,7 @@ func AllChatModelOverrideContexts() []ChatModelOverrideContext { ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, ChatModelOverrideContextTitleGeneration, + ChatModelOverrideContextSummaryGeneration, } } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a48a6cb6802ca..3c107fb7bc57e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2623,11 +2623,13 @@ export interface ChatModelOpenRouterProviderOptions { export type ChatModelOverrideContext = | "explore" | "general" + | "summary_generation" | "title_generation"; export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [ "explore", "general", + "summary_generation", "title_generation", ]; diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx index 5f664afb15027..ffe681299d825 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPage.tsx @@ -20,6 +20,8 @@ const generalOverrideContext: TypesGen.ChatModelOverrideContext = "general"; const exploreOverrideContext: TypesGen.ChatModelOverrideContext = "explore"; const titleGenerationOverrideContext: TypesGen.ChatModelOverrideContext = "title_generation"; +const summaryGenerationOverrideContext: TypesGen.ChatModelOverrideContext = + "summary_generation"; const chatModelOverrideKey = (context: TypesGen.ChatModelOverrideContext) => ["chat-model-override", context] as const; @@ -66,6 +68,10 @@ const AgentSettingsAgentsPage: FC = () => { ...chatModelOverrideQuery(titleGenerationOverrideContext), enabled: canEditDeploymentConfig, }); + const summaryGenerationModelQuery = useQuery({ + ...chatModelOverrideQuery(summaryGenerationOverrideContext), + enabled: canEditDeploymentConfig, + }); const modelConfigsQuery = useQuery(chatModelConfigs()); const savePersonalModelOverridesAdminSettingsMutation = useMutation( updateChatPersonalModelOverridesAdminSettings(queryClient), @@ -79,6 +85,12 @@ const AgentSettingsAgentsPage: FC = () => { titleGenerationOverrideContext, ), ); + const saveSummaryGenerationModelMutation = useMutation( + updateChatModelOverrideMutation( + queryClient, + summaryGenerationOverrideContext, + ), + ); const saveExploreModelOverrideMutation = useMutation( updateChatModelOverrideMutation(queryClient, exploreOverrideContext), ); @@ -123,6 +135,14 @@ const AgentSettingsAgentsPage: FC = () => { isSaveTitleGenerationModelError={ saveTitleGenerationModelMutation.isError } + summaryGenerationModelOverrideData={summaryGenerationModelQuery.data} + onSaveSummaryGenerationModel={saveSummaryGenerationModelMutation.mutate} + isSavingSummaryGenerationModel={ + saveSummaryGenerationModelMutation.isPending + } + isSaveSummaryGenerationModelError={ + saveSummaryGenerationModelMutation.isError + } onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate} isSavingExploreModelOverride={ saveExploreModelOverrideMutation.isPending diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx index 03bd43ef8f8dd..c26c7111dea8d 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx @@ -42,6 +42,11 @@ const buildTitleGenerationModelOverrideData = ( ): TypesGen.ChatModelOverrideResponse => buildOverrideData("title_generation", overrides); +const buildSummaryGenerationModelOverrideData = ( + overrides: Partial = {}, +): TypesGen.ChatModelOverrideResponse => + buildOverrideData("summary_generation", overrides); + const generalModelConfig = buildModelConfig({ id: "model-general-gpt-4.1-mini", display_name: "GPT 4.1 Mini", @@ -116,6 +121,7 @@ const buildArgs = ( isSaveAdminOverridesError: false, generalModelOverrideData: buildOverrideData("general"), titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData(), + summaryGenerationModelOverrideData: buildSummaryGenerationModelOverrideData(), exploreModelOverrideData: buildOverrideData("explore"), modelConfigsData: allModelConfigs, modelConfigsError: undefined, @@ -126,6 +132,9 @@ const buildArgs = ( onSaveTitleGenerationModel: fn(), isSavingTitleGenerationModel: false, isSaveTitleGenerationModelError: false, + onSaveSummaryGenerationModel: fn(), + isSavingSummaryGenerationModel: false, + isSaveSummaryGenerationModelError: false, onSaveExploreModelOverride: fn(), isSavingExploreModelOverride: false, isSaveExploreModelOverrideError: false, @@ -350,6 +359,46 @@ export const EachOverrideSetToEnabledModel: Story = { }, }; +export const SummaryGenerationModelSetToEnabledModel: Story = { + args: buildArgs({ + summaryGenerationModelOverrideData: buildSummaryGenerationModelOverrideData( + { model_config_id: titleModelConfig.id }, + ), + }), + play: async ({ canvasElement, args }) => { + const summarySection = await getSection( + canvasElement, + "Summary generation model", + ); + + expect( + within(summarySection).getByRole("combobox", { + name: /gpt 4o mini/i, + }), + ).toHaveTextContent("GPT 4o Mini"); + + await selectModelInSection( + summarySection, + canvasElement, + /gpt 4o mini/i, + "Claude Sonnet 4", + ); + const summarySaveButton = within(summarySection).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(summarySaveButton).toBeEnabled(); + }); + await userEvent.click(summarySaveButton); + await waitFor(() => { + expect(args.onSaveSummaryGenerationModel).toHaveBeenCalledWith( + { model_config_id: claudeSonnetModelConfig.id }, + expect.anything(), + ); + }); + }, +}; + export const MalformedOverridesRemainClearableAndSaveable: Story = { args: buildArgs({ generalModelOverrideData: buildOverrideData("general", { diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx index 46e2300975e8b..4a69927ce5f19 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.tsx @@ -25,6 +25,7 @@ export interface AgentSettingsAgentsPageViewProps { isSaveAdminOverridesError: boolean; generalModelOverrideData?: TypesGen.ChatModelOverrideResponse; titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; + summaryGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse; modelConfigsData: TypesGen.ChatModelConfig[] | undefined; modelConfigsError: unknown; @@ -35,6 +36,9 @@ export interface AgentSettingsAgentsPageViewProps { onSaveTitleGenerationModel: SaveModelOverride; isSavingTitleGenerationModel: boolean; isSaveTitleGenerationModelError: boolean; + onSaveSummaryGenerationModel: SaveModelOverride; + isSavingSummaryGenerationModel: boolean; + isSaveSummaryGenerationModelError: boolean; onSaveExploreModelOverride: SaveModelOverride; isSavingExploreModelOverride: boolean; isSaveExploreModelOverrideError: boolean; @@ -52,6 +56,7 @@ export const AgentSettingsAgentsPageView: FC< isSaveAdminOverridesError, generalModelOverrideData, titleGenerationModelOverrideData, + summaryGenerationModelOverrideData, exploreModelOverrideData, modelConfigsData, modelConfigsError, @@ -62,6 +67,9 @@ export const AgentSettingsAgentsPageView: FC< onSaveTitleGenerationModel, isSavingTitleGenerationModel, isSaveTitleGenerationModelError, + onSaveSummaryGenerationModel, + isSavingSummaryGenerationModel, + isSaveSummaryGenerationModelError, onSaveExploreModelOverride, isSavingExploreModelOverride, isSaveExploreModelOverrideError, @@ -137,6 +145,31 @@ export const AgentSettingsAgentsPageView: FC< showHeader={false} /> +
+ + +
( onSaveTitleGenerationModel={fn()} isSavingTitleGenerationModel={false} isSaveTitleGenerationModelError={false} + summaryGenerationModelOverrideData={{ + context: "summary_generation", + model_config_id: "", + is_malformed: false, + }} + onSaveSummaryGenerationModel={fn()} + isSavingSummaryGenerationModel={false} + isSaveSummaryGenerationModelError={false} onSaveExploreModelOverride={fn()} isSavingExploreModelOverride={false} isSaveExploreModelOverrideError={false} From 3d555d327b6eff125aeda3d7d126312d82912104 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 29 Jun 2026 15:15:25 +0000 Subject: [PATCH 2/3] test(site/src/pages/AgentsPage): cover summary model section in unset story The view now renders a 'Summary generation model' section between the title and explore sections. Update the AllOverridesUnset story so its exact level-3 heading assertion and per-section unset checks include the new section, fixing the failing Storybook interaction test. --- .../pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx index c26c7111dea8d..cee22d7d15534 100644 --- a/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsAgentsPageView.stories.tsx @@ -190,6 +190,7 @@ export const AllOverridesUnset: Story = { "Enable users to define their personal overrides", "General model", "Title generation model", + "Summary generation model", "Explore subagent model", ]); await canvas.findByText( @@ -202,6 +203,10 @@ export const AllOverridesUnset: Story = { headingName: "Title generation model", placeholder: "Use title default", }, + { + headingName: "Summary generation model", + placeholder: "Use chat model", + }, { headingName: "Explore subagent model", placeholder: "Use chat default", From 1d1891dd37785d3d426be46678df82b3c58c98fd Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 30 Jun 2026 05:03:56 +0000 Subject: [PATCH 3/3] docs(coderd/x/chatd): trim summary override comments Shorten the resolveChatSummaryModel and resolveSummaryGenerationModelOverride doc comments, condense the overrideSet test note to one line, and drop the redundant disabled-config note that the test name and argument already convey. --- coderd/x/chatd/chatd.go | 6 ++---- coderd/x/chatd/summary_override.go | 6 ++---- coderd/x/chatd/summary_override_internal_test.go | 4 +--- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 7eb4ddfd3e588..3b95ad1337da3 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5014,10 +5014,8 @@ func (p *Server) generateAndStoreChatSummary( p.updateChatSummary(ctx, chat, chat.HistoryVersion, summary, logger) } -// resolveChatSummaryModel resolves the model for summary generation. It prefers -// the deployment summary-generation override when set; a configured-but-unusable -// override is a hard failure that skips generation (preserving any existing -// summary). Otherwise it falls back to the chat's configured model. +// resolveChatSummaryModel prefers a usable deployment override, skips generation +// on a set-but-unusable one, and otherwise uses the chat's configured model. func (p *Server) resolveChatSummaryModel( ctx context.Context, chat database.Chat, diff --git a/coderd/x/chatd/summary_override.go b/coderd/x/chatd/summary_override.go index 81da7ae167d50..91e35d5db5ed7 100644 --- a/coderd/x/chatd/summary_override.go +++ b/coderd/x/chatd/summary_override.go @@ -30,10 +30,8 @@ func readSummaryGenerationModelOverride( } // resolveSummaryGenerationModelOverride resolves the deployment-wide summary -// generation model override. overrideSet is true when an override was -// configured; in that case any returned error is a hard failure and the caller -// should skip summary generation. When overrideSet is false, callers fall back -// to the chat's configured model. +// override. overrideSet reports whether one was configured; if true, any error is +// a hard failure (skip generation), and if false the caller uses the chat's model. func (p *Server) resolveSummaryGenerationModelOverride( ctx context.Context, chat database.Chat, diff --git a/coderd/x/chatd/summary_override_internal_test.go b/coderd/x/chatd/summary_override_internal_test.go index 557b538c95767..deacc55bfaeea 100644 --- a/coderd/x/chatd/summary_override_internal_test.go +++ b/coderd/x/chatd/summary_override_internal_test.go @@ -97,7 +97,6 @@ func TestResolveSummaryGenerationModelOverride_SetUnusableHardFails(t *testing.T db := dbmock.NewMockStore(ctrl) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) chat, _ := titleOverrideTestChatAndMessages(t) - // A disabled config is treated as unavailable. overrideConfig := titleOverrideModelConfig("gpt-4.1", false) db.EXPECT().GetChatSummaryGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) @@ -110,8 +109,7 @@ func TestResolveSummaryGenerationModelOverride_SetUnusableHardFails(t *testing.T chatprovider.ProviderAPIKeys{ByProvider: map[string]string{"openai": "test-key"}}, modelBuildOptions{}, ) - // overrideSet is true even on a hard failure so the caller skips generation - // instead of falling back to the chat model. + // overrideSet stays true on a hard failure so the caller skips generation. require.Error(t, err) require.True(t, overrideSet) require.ErrorContains(t, err, "summary generation model override is unavailable")