From abb4cdc3e20ac1918d91c834be8c4f05a6190529 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:16:17 +0000 Subject: [PATCH 01/16] feat(coderd/database): add organization-scoped model overrides --- coderd/database/check_constraint.go | 4 + coderd/database/dbauthz/dbauthz.go | 67 ++ coderd/database/dbauthz/dbauthz_test.go | 59 ++ coderd/database/dbmetrics/querymetrics.go | 56 ++ coderd/database/dbmock/dbmock.go | 102 +++ coderd/database/dump.sql | 52 ++ coderd/database/foreign_key_constraint.go | 317 +++++---- ...583_chat_model_override_org_scope.down.sql | 78 ++ ...00583_chat_model_override_org_scope.up.sql | 183 +++++ coderd/database/migrations/migrate_test.go | 150 ++++ ...00583_chat_model_override_org_scope.up.sql | 30 + coderd/database/models.go | 18 + coderd/database/querier.go | 9 + coderd/database/queries.sql.go | 664 +++++++++++++----- .../database/queries/chatmodeloverrides.sql | 235 +++++++ coderd/database/queries/siteconfig.sql | 32 - coderd/database/queries/users.sql | 17 - coderd/database/unique_constraint.go | 353 +++++----- 18 files changed, 1888 insertions(+), 538 deletions(-) create mode 100644 coderd/database/migrations/000583_chat_model_override_org_scope.down.sql create mode 100644 coderd/database/migrations/000583_chat_model_override_org_scope.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000583_chat_model_override_org_scope.up.sql create mode 100644 coderd/database/queries/chatmodeloverrides.sql diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 139ff171c97..99cc39159d2 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -27,9 +27,13 @@ const ( CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs CheckChatModelConfigsGroupAclIsObject CheckConstraint = "chat_model_configs_group_acl_is_object" // chat_model_configs CheckChatModelConfigsUserAclIsObject CheckConstraint = "chat_model_configs_user_acl_is_object" // chat_model_configs + CheckChatOrganizationModelOverridesContextCheck CheckConstraint = "chat_organization_model_overrides_context_check" // chat_organization_model_overrides CheckChatUsageLimitConfigDefaultLimitMicrosCheck CheckConstraint = "chat_usage_limit_config_default_limit_micros_check" // chat_usage_limit_config CheckChatUsageLimitConfigPeriodCheck CheckConstraint = "chat_usage_limit_config_period_check" // chat_usage_limit_config CheckChatUsageLimitConfigSingletonCheck CheckConstraint = "chat_usage_limit_config_singleton_check" // chat_usage_limit_config + CheckChatUserModelOverridesContextCheck CheckConstraint = "chat_user_model_overrides_context_check" // chat_user_model_overrides + CheckChatUserModelOverridesModeCheck CheckConstraint = "chat_user_model_overrides_mode_check" // chat_user_model_overrides + CheckChatUserModelOverridesModelRequiresConfigCheck CheckConstraint = "chat_user_model_overrides_model_requires_config_check" // chat_user_model_overrides CheckChatAclOnlyOnRootChats CheckConstraint = "chat_acl_only_on_root_chats" // chats CheckChatGroupAclNotNullJsonb CheckConstraint = "chat_group_acl_not_null_jsonb" // chats CheckChatUserAclNotNullJsonb CheckConstraint = "chat_user_acl_not_null_jsonb" // chats diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b243230bcfb..c486a796438 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2238,6 +2238,13 @@ func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) ( return q.db.DeleteChatModelConfigByID(ctx, id) } +func (q *querier) DeleteChatOrganizationModelOverride(ctx context.Context, arg database.DeleteChatOrganizationModelOverrideParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID)); err != nil { + return err + } + return q.db.DeleteChatOrganizationModelOverride(ctx, arg) +} + func (q *querier) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { @@ -3565,6 +3572,26 @@ func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]databa return q.db.GetChatModelConfigsForTelemetry(ctx) } +func (q *querier) GetChatOrganizationModelOverride(ctx context.Context, arg database.GetChatOrganizationModelOverrideParams) (database.ChatOrganizationModelOverride, error) { + object := rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID).WithGroupACL(map[string][]policy.Action{ + arg.OrganizationID.String(): {policy.ActionRead}, + }) + if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { + return database.ChatOrganizationModelOverride{}, err + } + return q.db.GetChatOrganizationModelOverride(ctx, arg) +} + +func (q *querier) GetChatOrganizationModelOverrides(ctx context.Context, organizationID uuid.UUID) ([]database.ChatOrganizationModelOverride, error) { + object := rbac.ResourceChatModelConfig.InOrg(organizationID).WithGroupACL(map[string][]policy.Action{ + organizationID.String(): {policy.ActionRead}, + }) + if err := q.authorizeContext(ctx, policy.ActionRead, object); err != nil { + return nil, err + } + return q.db.GetChatOrganizationModelOverrides(ctx, organizationID) +} + func (q *querier) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { // The personal model overrides flag is a deployment-wide setting read by // authenticated chat users. We only require that an explicit actor is @@ -3670,6 +3697,28 @@ func (q *querier) GetChatTitleGenerationModelOverride(ctx context.Context) (stri return q.db.GetChatTitleGenerationModelOverride(ctx) } +func (q *querier) GetChatUserModelOverride(ctx context.Context, arg database.GetChatUserModelOverrideParams) (database.ChatUserModelOverride, error) { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return database.ChatUserModelOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { + return database.ChatUserModelOverride{}, err + } + return q.db.GetChatUserModelOverride(ctx, arg) +} + +func (q *querier) GetChatUserModelOverrides(ctx context.Context, arg database.GetChatUserModelOverridesParams) ([]database.ChatUserModelOverride, error) { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { + return nil, err + } + return q.db.GetChatUserModelOverrides(ctx, arg) +} + func (q *querier) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) { // Authorize read on the parent chat. _, err := q.GetChatByID(ctx, arg.ChatID) @@ -9080,6 +9129,13 @@ func (q *querier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, incl return q.db.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt) } +func (q *querier) UpsertChatOrganizationModelOverride(ctx context.Context, arg database.UpsertChatOrganizationModelOverrideParams) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChatModelConfig.InOrg(arg.OrganizationID)); err != nil { + return err + } + return q.db.UpsertChatOrganizationModelOverride(ctx, arg) +} + func (q *querier) UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err @@ -9115,6 +9171,17 @@ func (q *querier) UpsertChatTitleGenerationModelOverride(ctx context.Context, va return q.db.UpsertChatTitleGenerationModelOverride(ctx, value) } +func (q *querier) UpsertChatUserModelOverride(ctx context.Context, arg database.UpsertChatUserModelOverrideParams) error { + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { + return err + } + return q.db.UpsertChatUserModelOverride(ctx, arg) +} + //nolint:revive,staticcheck // Parameter name matches the generated querier interface. func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 469a9180772..b2cff852472 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1308,6 +1308,29 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return("", nil).AnyTimes() check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) })) + s.Run("GetChatOrganizationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + arg := database.GetChatOrganizationModelOverrideParams{OrganizationID: orgID, Context: "general"} + dbm.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), arg).Return(database.ChatOrganizationModelOverride{}, nil).AnyTimes() + object := rbac.ResourceChatModelConfig.InOrg(orgID).WithGroupACL(map[string][]policy.Action{ + orgID.String(): {policy.ActionRead}, + }) + check.Args(arg).Asserts(object, policy.ActionRead) + })) + s.Run("GetChatOrganizationModelOverrides", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + dbm.EXPECT().GetChatOrganizationModelOverrides(gomock.Any(), orgID).Return([]database.ChatOrganizationModelOverride{}, nil).AnyTimes() + object := rbac.ResourceChatModelConfig.InOrg(orgID).WithGroupACL(map[string][]policy.Action{ + orgID.String(): {policy.ActionRead}, + }) + check.Args(orgID).Asserts(object, policy.ActionRead) + })) + s.Run("DeleteChatOrganizationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + arg := database.DeleteChatOrganizationModelOverrideParams{OrganizationID: orgID, Context: "general"} + dbm.EXPECT().DeleteChatOrganizationModelOverride(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), policy.ActionUpdate) + })) 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) @@ -1790,6 +1813,16 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatCompactionModelOverride(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("UpsertChatOrganizationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + orgID := uuid.New() + arg := database.UpsertChatOrganizationModelOverrideParams{ + OrganizationID: orgID, + Context: "general", + ModelConfigID: uuid.New(), + } + dbm.EXPECT().UpsertChatOrganizationModelOverride(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceChatModelConfig.InOrg(orgID), 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) @@ -3290,6 +3323,32 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpsertUserChatPersonalModelOverride(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) })) + s.Run("GetChatUserModelOverrides", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.GetChatUserModelOverridesParams{UserID: u.ID, OrganizationID: uuid.New()} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetChatUserModelOverrides(gomock.Any(), arg).Return([]database.ChatUserModelOverride{}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionReadPersonal).Returns([]database.ChatUserModelOverride{}) + })) + s.Run("GetChatUserModelOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.GetChatUserModelOverrideParams{UserID: u.ID, OrganizationID: uuid.New(), Context: "root"} + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().GetChatUserModelOverride(gomock.Any(), arg).Return(database.ChatUserModelOverride{}, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionReadPersonal).Returns(database.ChatUserModelOverride{}) + })) + s.Run("UpsertChatUserModelOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpsertChatUserModelOverrideParams{ + UserID: u.ID, + OrganizationID: uuid.New(), + Context: "root", + Mode: "chat_default", + } + dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() + dbm.EXPECT().UpsertChatUserModelOverride(gomock.Any(), arg).Return(nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) + })) s.Run("UpdateUserChatCustomPrompt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) uc := database.UserConfig{UserID: u.ID, Key: "chat_custom_prompt", Value: "my custom prompt"} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 1f728f1693f..364bcd0e645 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -528,6 +528,14 @@ func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uui return r0, r1 } +func (m queryMetricsStore) DeleteChatOrganizationModelOverride(ctx context.Context, arg database.DeleteChatOrganizationModelOverrideParams) error { + start := time.Now() + r0 := m.s.DeleteChatOrganizationModelOverride(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteChatOrganizationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatOrganizationModelOverride").Inc() + return r0 +} + func (m queryMetricsStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { start := time.Now() r0 := m.s.DeleteChatQueuedMessage(ctx, arg) @@ -1720,6 +1728,22 @@ func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context) return r0, r1 } +func (m queryMetricsStore) GetChatOrganizationModelOverride(ctx context.Context, arg database.GetChatOrganizationModelOverrideParams) (database.ChatOrganizationModelOverride, error) { + start := time.Now() + r0, r1 := m.s.GetChatOrganizationModelOverride(ctx, arg) + m.queryLatencies.WithLabelValues("GetChatOrganizationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatOrganizationModelOverride").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatOrganizationModelOverrides(ctx context.Context, organizationID uuid.UUID) ([]database.ChatOrganizationModelOverride, error) { + start := time.Now() + r0, r1 := m.s.GetChatOrganizationModelOverrides(ctx, organizationID) + m.queryLatencies.WithLabelValues("GetChatOrganizationModelOverrides").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatOrganizationModelOverrides").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { start := time.Now() r0, r1 := m.s.GetChatPersonalModelOverridesEnabled(ctx) @@ -1816,6 +1840,22 @@ func (m queryMetricsStore) GetChatTitleGenerationModelOverride(ctx context.Conte return r0, r1 } +func (m queryMetricsStore) GetChatUserModelOverride(ctx context.Context, arg database.GetChatUserModelOverrideParams) (database.ChatUserModelOverride, error) { + start := time.Now() + r0, r1 := m.s.GetChatUserModelOverride(ctx, arg) + m.queryLatencies.WithLabelValues("GetChatUserModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatUserModelOverride").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetChatUserModelOverrides(ctx context.Context, arg database.GetChatUserModelOverridesParams) ([]database.ChatUserModelOverride, error) { + start := time.Now() + r0, r1 := m.s.GetChatUserModelOverrides(ctx, arg) + m.queryLatencies.WithLabelValues("GetChatUserModelOverrides").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatUserModelOverrides").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) { start := time.Now() r0, r1 := m.s.GetChatUserPromptsByChatID(ctx, arg) @@ -6376,6 +6416,14 @@ func (m queryMetricsStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Cont return r0 } +func (m queryMetricsStore) UpsertChatOrganizationModelOverride(ctx context.Context, arg database.UpsertChatOrganizationModelOverrideParams) error { + start := time.Now() + r0 := m.s.UpsertChatOrganizationModelOverride(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertChatOrganizationModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatOrganizationModelOverride").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error { start := time.Now() r0 := m.s.UpsertChatPersonalModelOverridesEnabled(ctx, enabled) @@ -6416,6 +6464,14 @@ func (m queryMetricsStore) UpsertChatTitleGenerationModelOverride(ctx context.Co return r0 } +func (m queryMetricsStore) UpsertChatUserModelOverride(ctx context.Context, arg database.UpsertChatUserModelOverrideParams) error { + start := time.Now() + r0 := m.s.UpsertChatUserModelOverride(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertChatUserModelOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatUserModelOverride").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { start := time.Now() r0 := m.s.UpsertChatWorkspaceTTL(ctx, workspaceTtl) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b2f091c868c..bfd3ac4ed97 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -863,6 +863,20 @@ func (mr *MockStoreMockRecorder) DeleteChatModelConfigByID(ctx, id any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatModelConfigByID", reflect.TypeOf((*MockStore)(nil).DeleteChatModelConfigByID), ctx, id) } +// DeleteChatOrganizationModelOverride mocks base method. +func (m *MockStore) DeleteChatOrganizationModelOverride(ctx context.Context, arg database.DeleteChatOrganizationModelOverrideParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChatOrganizationModelOverride", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChatOrganizationModelOverride indicates an expected call of DeleteChatOrganizationModelOverride. +func (mr *MockStoreMockRecorder) DeleteChatOrganizationModelOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatOrganizationModelOverride", reflect.TypeOf((*MockStore)(nil).DeleteChatOrganizationModelOverride), ctx, arg) +} + // DeleteChatQueuedMessage mocks base method. func (m *MockStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { m.ctrl.T.Helper() @@ -3225,6 +3239,36 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigsForTelemetry(ctx any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsForTelemetry", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsForTelemetry), ctx) } +// GetChatOrganizationModelOverride mocks base method. +func (m *MockStore) GetChatOrganizationModelOverride(ctx context.Context, arg database.GetChatOrganizationModelOverrideParams) (database.ChatOrganizationModelOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatOrganizationModelOverride", ctx, arg) + ret0, _ := ret[0].(database.ChatOrganizationModelOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatOrganizationModelOverride indicates an expected call of GetChatOrganizationModelOverride. +func (mr *MockStoreMockRecorder) GetChatOrganizationModelOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatOrganizationModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatOrganizationModelOverride), ctx, arg) +} + +// GetChatOrganizationModelOverrides mocks base method. +func (m *MockStore) GetChatOrganizationModelOverrides(ctx context.Context, organizationID uuid.UUID) ([]database.ChatOrganizationModelOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatOrganizationModelOverrides", ctx, organizationID) + ret0, _ := ret[0].([]database.ChatOrganizationModelOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatOrganizationModelOverrides indicates an expected call of GetChatOrganizationModelOverrides. +func (mr *MockStoreMockRecorder) GetChatOrganizationModelOverrides(ctx, organizationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatOrganizationModelOverrides", reflect.TypeOf((*MockStore)(nil).GetChatOrganizationModelOverrides), ctx, organizationID) +} + // GetChatPersonalModelOverridesEnabled mocks base method. func (m *MockStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { m.ctrl.T.Helper() @@ -3405,6 +3449,36 @@ func (mr *MockStoreMockRecorder) GetChatTitleGenerationModelOverride(ctx any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatTitleGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatTitleGenerationModelOverride), ctx) } +// GetChatUserModelOverride mocks base method. +func (m *MockStore) GetChatUserModelOverride(ctx context.Context, arg database.GetChatUserModelOverrideParams) (database.ChatUserModelOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatUserModelOverride", ctx, arg) + ret0, _ := ret[0].(database.ChatUserModelOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatUserModelOverride indicates an expected call of GetChatUserModelOverride. +func (mr *MockStoreMockRecorder) GetChatUserModelOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatUserModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatUserModelOverride), ctx, arg) +} + +// GetChatUserModelOverrides mocks base method. +func (m *MockStore) GetChatUserModelOverrides(ctx context.Context, arg database.GetChatUserModelOverridesParams) ([]database.ChatUserModelOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatUserModelOverrides", ctx, arg) + ret0, _ := ret[0].([]database.ChatUserModelOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatUserModelOverrides indicates an expected call of GetChatUserModelOverrides. +func (mr *MockStoreMockRecorder) GetChatUserModelOverrides(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatUserModelOverrides", reflect.TypeOf((*MockStore)(nil).GetChatUserModelOverrides), ctx, arg) +} + // GetChatUserPromptsByChatID mocks base method. func (m *MockStore) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) { m.ctrl.T.Helper() @@ -12002,6 +12076,20 @@ func (mr *MockStoreMockRecorder) UpsertChatIncludeDefaultSystemPrompt(ctx, inclu return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatIncludeDefaultSystemPrompt), ctx, includeDefaultSystemPrompt) } +// UpsertChatOrganizationModelOverride mocks base method. +func (m *MockStore) UpsertChatOrganizationModelOverride(ctx context.Context, arg database.UpsertChatOrganizationModelOverrideParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatOrganizationModelOverride", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatOrganizationModelOverride indicates an expected call of UpsertChatOrganizationModelOverride. +func (mr *MockStoreMockRecorder) UpsertChatOrganizationModelOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatOrganizationModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatOrganizationModelOverride), ctx, arg) +} + // UpsertChatPersonalModelOverridesEnabled mocks base method. func (m *MockStore) UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error { m.ctrl.T.Helper() @@ -12072,6 +12160,20 @@ func (mr *MockStoreMockRecorder) UpsertChatTitleGenerationModelOverride(ctx, val return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatTitleGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatTitleGenerationModelOverride), ctx, value) } +// UpsertChatUserModelOverride mocks base method. +func (m *MockStore) UpsertChatUserModelOverride(ctx context.Context, arg database.UpsertChatUserModelOverrideParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatUserModelOverride", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatUserModelOverride indicates an expected call of UpsertChatUserModelOverride. +func (mr *MockStoreMockRecorder) UpsertChatUserModelOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatUserModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatUserModelOverride), ctx, arg) +} + // UpsertChatWorkspaceTTL mocks base method. func (m *MockStore) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ebb9508f58d..9bd7833dad7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2094,6 +2094,15 @@ CREATE TABLE chat_model_configs ( CONSTRAINT chat_model_configs_user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text)) ); +CREATE TABLE chat_organization_model_overrides ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + context text NOT NULL, + model_config_id uuid NOT NULL, + reasoning_effort text, + CONSTRAINT chat_organization_model_overrides_context_check CHECK ((context = ANY (ARRAY['general'::text, 'explore'::text, 'title_generation'::text, 'compaction'::text, 'advisor'::text]))) +); + CREATE SEQUENCE chat_queued_messages_position_seq START WITH 1 INCREMENT BY 1 @@ -2145,6 +2154,19 @@ CREATE SEQUENCE chat_usage_limit_config_id_seq ALTER SEQUENCE chat_usage_limit_config_id_seq OWNED BY chat_usage_limit_config.id; +CREATE TABLE chat_user_model_overrides ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + organization_id uuid NOT NULL, + context text NOT NULL, + mode text NOT NULL, + model_config_id uuid, + reasoning_effort text, + CONSTRAINT chat_user_model_overrides_context_check CHECK ((context = ANY (ARRAY['root'::text, 'general'::text, 'explore'::text]))), + CONSTRAINT chat_user_model_overrides_mode_check CHECK ((mode = ANY (ARRAY['model'::text, 'chat_default'::text, 'deployment_default'::text]))), + CONSTRAINT chat_user_model_overrides_model_requires_config_check CHECK (((mode = 'model'::text) = (model_config_id IS NOT NULL))) +); + CREATE TABLE chats ( id uuid DEFAULT gen_random_uuid() NOT NULL, owner_id uuid NOT NULL, @@ -4400,9 +4422,18 @@ ALTER TABLE ONLY chat_heartbeats ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chat_model_configs + ADD CONSTRAINT chat_model_configs_organization_id_id_key UNIQUE (organization_id, id); + ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chat_organization_model_overrides + ADD CONSTRAINT chat_organization_model_overrides_organization_id_context_key UNIQUE (organization_id, context); + +ALTER TABLE ONLY chat_organization_model_overrides + ADD CONSTRAINT chat_organization_model_overrides_pkey PRIMARY KEY (id); + ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); @@ -4412,6 +4443,12 @@ ALTER TABLE ONLY chat_usage_limit_config ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_singleton_key UNIQUE (singleton); +ALTER TABLE ONLY chat_user_model_overrides + ADD CONSTRAINT chat_user_model_overrides_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY chat_user_model_overrides + ADD CONSTRAINT chat_user_model_overrides_user_organization_context_key UNIQUE (user_id, organization_id, context); + ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); @@ -5276,9 +5313,24 @@ ALTER TABLE ONLY chat_model_configs ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id); +ALTER TABLE ONLY chat_organization_model_overrides + ADD CONSTRAINT chat_organization_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chat_organization_model_overrides + ADD CONSTRAINT chat_organization_model_overrides_organization_model_config_fke FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); + ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; +ALTER TABLE ONLY chat_user_model_overrides + ADD CONSTRAINT chat_user_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE ONLY chat_user_model_overrides + ADD CONSTRAINT chat_user_model_overrides_organization_model_config_fkey FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); + +ALTER TABLE ONLY chat_user_model_overrides + ADD CONSTRAINT chat_user_model_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY chats ADD CONSTRAINT chats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index aa3cab22ad5..251ce1aec5c 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -6,160 +6,165 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( - ForeignKeyAIProviderKeysAPIKeyKeyID ForeignKeyConstraint = "ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyAIProviderKeysProviderID ForeignKeyConstraint = "ai_provider_keys_provider_id_fkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; - ForeignKeyAIProvidersSettingsKeyID ForeignKeyConstraint = "ai_providers_settings_key_id_fkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_settings_key_id_fkey FOREIGN KEY (settings_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyAISeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); - ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyBoundaryLogsOwnerID ForeignKeyConstraint = "boundary_logs_owner_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; - ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; - ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); - ForeignKeyChatContextResourcesChatID ForeignKeyConstraint = "chat_context_resources_chat_id_fkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatFileLinksChatID ForeignKeyConstraint = "chat_file_links_chat_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatFileLinksFileID ForeignKeyConstraint = "chat_file_links_file_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE; - ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyChatHeartbeatsChatID ForeignKeyConstraint = "chat_heartbeats_chat_id_fkey" // ALTER TABLE ONLY chat_heartbeats ADD CONSTRAINT chat_heartbeats_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatMessagesModelConfigID ForeignKeyConstraint = "chat_messages_model_config_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id); - ForeignKeyChatModelConfigsAIProviderID ForeignKeyConstraint = "chat_model_configs_ai_provider_id_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id); - ForeignKeyChatModelConfigsCreatedBy ForeignKeyConstraint = "chat_model_configs_created_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id); - ForeignKeyChatModelConfigsOrganizationID ForeignKeyConstraint = "chat_model_configs_organization_id_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyChatModelConfigsUpdatedBy ForeignKeyConstraint = "chat_model_configs_updated_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id); - ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; - ForeignKeyChatsAgentID ForeignKeyConstraint = "chats_agent_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL; - ForeignKeyChatsBuildID ForeignKeyConstraint = "chats_build_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_build_id_fkey FOREIGN KEY (build_id) REFERENCES workspace_builds(id) ON DELETE SET NULL; - ForeignKeyChatsLastModelConfigID ForeignKeyConstraint = "chats_last_model_config_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_last_model_config_id_fkey FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id); - ForeignKeyChatsOrganizationID ForeignKeyConstraint = "chats_organization_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyChatsParentChatID ForeignKeyConstraint = "chats_parent_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_parent_chat_id_fkey FOREIGN KEY (parent_chat_id) REFERENCES chats(id) ON DELETE SET NULL; - ForeignKeyChatsRootChatID ForeignKeyConstraint = "chats_root_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_root_chat_id_fkey FOREIGN KEY (root_chat_id) REFERENCES chats(id) ON DELETE SET NULL; - ForeignKeyChatsWorkspaceID ForeignKeyConstraint = "chats_workspace_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; - ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; - ForeignKeyConnectionLogsWorkspaceOwnerID ForeignKeyConstraint = "connection_logs_workspace_owner_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyFkChatDebugStepsRunChat ForeignKeyConstraint = "fk_chat_debug_steps_run_chat" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT fk_chat_debug_steps_run_chat FOREIGN KEY (run_id, chat_id) REFERENCES chat_debug_runs(id, chat_id) ON DELETE CASCADE; - ForeignKeyFkOauth2ProviderAppTokensUserID ForeignKeyConstraint = "fk_oauth2_provider_app_tokens_user_id" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT fk_oauth2_provider_app_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyGitSSHKeysPrivateKeyKeyID ForeignKeyConstraint = "gitsshkeys_private_key_key_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_private_key_key_id_fkey FOREIGN KEY (private_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyGitSSHKeysUserID ForeignKeyConstraint = "gitsshkeys_user_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); - ForeignKeyGroupAIBudgetsGroupID ForeignKeyConstraint = "group_ai_budgets_group_id_fkey" // ALTER TABLE ONLY group_ai_budgets ADD CONSTRAINT group_ai_budgets_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; - ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; - ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyInboxNotificationsTemplateID ForeignKeyConstraint = "inbox_notifications_template_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; - ForeignKeyInboxNotificationsUserID ForeignKeyConstraint = "inbox_notifications_user_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyJfrogXrayScansAgentID ForeignKeyConstraint = "jfrog_xray_scans_agent_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; - ForeignKeyMcpServerConfigsAPIKeyValueKeyID ForeignKeyConstraint = "mcp_server_configs_api_key_value_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_api_key_value_key_id_fkey FOREIGN KEY (api_key_value_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyMcpServerConfigsCreatedBy ForeignKeyConstraint = "mcp_server_configs_created_by_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; - ForeignKeyMcpServerConfigsCustomHeadersKeyID ForeignKeyConstraint = "mcp_server_configs_custom_headers_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_custom_headers_key_id_fkey FOREIGN KEY (custom_headers_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyMcpServerConfigsOauth2ClientSecretKeyID ForeignKeyConstraint = "mcp_server_configs_oauth2_client_secret_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_oauth2_client_secret_key_id_fkey FOREIGN KEY (oauth2_client_secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyMcpServerConfigsOrganizationID ForeignKeyConstraint = "mcp_server_configs_organization_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyMcpServerConfigsUpdatedBy ForeignKeyConstraint = "mcp_server_configs_updated_by_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL; - ForeignKeyMcpServerUserTokensAccessTokenKeyID ForeignKeyConstraint = "mcp_server_user_tokens_access_token_key_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_access_token_key_id_fkey FOREIGN KEY (access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyMcpServerUserTokensMcpServerConfigID ForeignKeyConstraint = "mcp_server_user_tokens_mcp_server_config_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_fkey FOREIGN KEY (mcp_server_config_id) REFERENCES mcp_server_configs(id) ON DELETE CASCADE; - ForeignKeyMcpServerUserTokensRefreshTokenKeyID ForeignKeyConstraint = "mcp_server_user_tokens_refresh_token_key_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_refresh_token_key_id_fkey FOREIGN KEY (refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyMcpServerUserTokensUserID ForeignKeyConstraint = "mcp_server_user_tokens_user_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; - ForeignKeyNotificationMessagesUserID ForeignKeyConstraint = "notification_messages_user_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyNotificationPreferencesNotificationTemplateID ForeignKeyConstraint = "notification_preferences_notification_template_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; - ForeignKeyNotificationPreferencesUserID ForeignKeyConstraint = "notification_preferences_user_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyOauth2ProviderAppCodesAppID ForeignKeyConstraint = "oauth2_provider_app_codes_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; - ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; - ForeignKeyOauth2ProviderAppTokensAPIKeyID ForeignKeyConstraint = "oauth2_provider_app_tokens_api_key_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE; - ForeignKeyOauth2ProviderAppTokensAppID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; - ForeignKeyOauth2ProviderAppTokensAppSecretID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_secret_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_secret_id_fkey FOREIGN KEY (app_secret_id) REFERENCES oauth2_provider_app_secrets(id) ON DELETE CASCADE; - ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyParameterSchemasJobID ForeignKeyConstraint = "parameter_schemas_job_id_fkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ForeignKeyProvisionerDaemonsKeyID ForeignKeyConstraint = "provisioner_daemons_key_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_key_id_fkey FOREIGN KEY (key_id) REFERENCES provisioner_keys(id) ON DELETE CASCADE; - ForeignKeyProvisionerDaemonsOrganizationID ForeignKeyConstraint = "provisioner_daemons_organization_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyProvisionerJobLogsJobID ForeignKeyConstraint = "provisioner_job_logs_job_id_fkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ForeignKeyProvisionerJobTimingsJobID ForeignKeyConstraint = "provisioner_job_timings_job_id_fkey" // ALTER TABLE ONLY provisioner_job_timings ADD CONSTRAINT provisioner_job_timings_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ForeignKeyProvisionerJobsOrganizationID ForeignKeyConstraint = "provisioner_jobs_organization_id_fkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyProvisionerKeysOrganizationID ForeignKeyConstraint = "provisioner_keys_organization_id_fkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyTailnetPeersCoordinatorID ForeignKeyConstraint = "tailnet_peers_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; - ForeignKeyTailnetTunnelsCoordinatorID ForeignKeyConstraint = "tailnet_tunnels_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; - ForeignKeyTaskSnapshotsTaskID ForeignKeyConstraint = "task_snapshots_task_id_fkey" // ALTER TABLE ONLY task_snapshots ADD CONSTRAINT task_snapshots_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; - ForeignKeyTaskWorkspaceAppsTaskID ForeignKeyConstraint = "task_workspace_apps_task_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; - ForeignKeyTaskWorkspaceAppsWorkspaceAgentID ForeignKeyConstraint = "task_workspace_apps_workspace_agent_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyTaskWorkspaceAppsWorkspaceAppID ForeignKeyConstraint = "task_workspace_apps_workspace_app_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_app_id_fkey FOREIGN KEY (workspace_app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; - ForeignKeyTasksOrganizationID ForeignKeyConstraint = "tasks_organization_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyTasksOwnerID ForeignKeyConstraint = "tasks_owner_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyTasksTemplateVersionID ForeignKeyConstraint = "tasks_template_version_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyTasksWorkspaceID ForeignKeyConstraint = "tasks_workspace_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionParametersTemplateVersionID ForeignKeyConstraint = "template_version_parameters_template_version_id_fkey" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionPresetParametTemplateVersionPresetID ForeignKeyConstraint = "template_version_preset_paramet_template_version_preset_id_fkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_paramet_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionPresetPrebuildSchedulesPresetID ForeignKeyConstraint = "template_version_preset_prebuild_schedules_preset_id_fkey" // ALTER TABLE ONLY template_version_preset_prebuild_schedules ADD CONSTRAINT template_version_preset_prebuild_schedules_preset_id_fkey FOREIGN KEY (preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionPresetsTemplateVersionID ForeignKeyConstraint = "template_version_presets_template_version_id_fkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionTerraformValuesCachedModuleFiles ForeignKeyConstraint = "template_version_terraform_values_cached_module_files_fkey" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_cached_module_files_fkey FOREIGN KEY (cached_module_files) REFERENCES files(id); - ForeignKeyTemplateVersionTerraformValuesTemplateVersionID ForeignKeyConstraint = "template_version_terraform_values_template_version_id_fkey" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionVariablesTemplateVersionID ForeignKeyConstraint = "template_version_variables_template_version_id_fkey" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionWorkspaceTagsTemplateVersionID ForeignKeyConstraint = "template_version_workspace_tags_template_version_id_fkey" // ALTER TABLE ONLY template_version_workspace_tags ADD CONSTRAINT template_version_workspace_tags_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionsCreatedBy ForeignKeyConstraint = "template_versions_created_by_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; - ForeignKeyTemplateVersionsOrganizationID ForeignKeyConstraint = "template_versions_organization_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE; - ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; - ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; - ForeignKeyUserAIBudgetOverridesGroupID ForeignKeyConstraint = "user_ai_budget_overrides_group_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; - ForeignKeyUserAIBudgetOverridesUserID ForeignKeyConstraint = "user_ai_budget_overrides_user_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyUserAIProviderKeysAIProviderID ForeignKeyConstraint = "user_ai_provider_keys_ai_provider_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; - ForeignKeyUserAIProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyUserAIProviderKeysUserID ForeignKeyConstraint = "user_ai_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyUserConfigsUserID ForeignKeyConstraint = "user_configs_user_id_fkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyUserDeletedUserID ForeignKeyConstraint = "user_deleted_user_id_fkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); - ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest); - ForeignKeyUserSkillsUserID ForeignKeyConstraint = "user_skills_user_id_fkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); - ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentContextResourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_resources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentContextSnapshotsWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_snapshots_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_snapshots ADD CONSTRAINT workspace_agent_context_snapshots_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentMemoryResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_memory_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentMetadataWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_metadata_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentPortShareWorkspaceID ForeignKeyConstraint = "workspace_agent_port_share_workspace_id_fkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentScriptTimingsScriptID ForeignKeyConstraint = "workspace_agent_script_timings_script_id_fkey" // ALTER TABLE ONLY workspace_agent_script_timings ADD CONSTRAINT workspace_agent_script_timings_script_id_fkey FOREIGN KEY (script_id) REFERENCES workspace_agent_scripts(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentScriptsWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_scripts_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentStartupLogsAgentID ForeignKeyConstraint = "workspace_agent_startup_logs_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentVolumeResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_volume_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentsParentID ForeignKeyConstraint = "workspace_agents_parent_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAgentsResourceID ForeignKeyConstraint = "workspace_agents_resource_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAppAuditSessionsAgentID ForeignKeyConstraint = "workspace_app_audit_sessions_agent_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); - ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); - ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); - ForeignKeyWorkspaceAppStatusesAgentID ForeignKeyConstraint = "workspace_app_statuses_agent_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); - ForeignKeyWorkspaceAppStatusesAppID ForeignKeyConstraint = "workspace_app_statuses_app_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id); - ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); - ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildOrchestrationsChildBuildWorkspaceID ForeignKeyConstraint = "workspace_build_orchestrations_child_build_workspace_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_build_workspace_id_fkey FOREIGN KEY (child_build_id, workspace_id) REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildOrchestrationsChildPresetID ForeignKeyConstraint = "workspace_build_orchestrations_child_preset_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_preset_id_fkey FOREIGN KEY (child_template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL; - ForeignKeyWorkspaceBuildOrchestrationsChildPresetVersion ForeignKeyConstraint = "workspace_build_orchestrations_child_preset_version_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_preset_version_fkey FOREIGN KEY (child_template_version_preset_id, child_template_version_id) REFERENCES template_version_presets(id, template_version_id); - ForeignKeyWorkspaceBuildOrchestrationsChildTemplateVersionID ForeignKeyConstraint = "workspace_build_orchestrations_child_template_version_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_template_version_id_fkey FOREIGN KEY (child_template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildOrchestrationsParentBuildWorkspaceID ForeignKeyConstraint = "workspace_build_orchestrations_parent_build_workspace_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_parent_build_workspace_id_fkey FOREIGN KEY (parent_build_id, workspace_id) REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildsTemplateVersionID ForeignKeyConstraint = "workspace_builds_template_version_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; - ForeignKeyWorkspaceBuildsTemplateVersionPresetID ForeignKeyConstraint = "workspace_builds_template_version_preset_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL; - ForeignKeyWorkspaceBuildsWorkspaceID ForeignKeyConstraint = "workspace_builds_workspace_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; - ForeignKeyWorkspaceModulesJobID ForeignKeyConstraint = "workspace_modules_job_id_fkey" // ALTER TABLE ONLY workspace_modules ADD CONSTRAINT workspace_modules_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ForeignKeyWorkspaceResourceMetadataWorkspaceResourceID ForeignKeyConstraint = "workspace_resource_metadata_workspace_resource_id_fkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_workspace_resource_id_fkey FOREIGN KEY (workspace_resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; - ForeignKeyWorkspaceResourcesJobID ForeignKeyConstraint = "workspace_resources_job_id_fkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; - ForeignKeyWorkspacesOrganizationID ForeignKeyConstraint = "workspaces_organization_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT; - ForeignKeyWorkspacesOwnerID ForeignKeyConstraint = "workspaces_owner_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; - ForeignKeyWorkspacesTemplateID ForeignKeyConstraint = "workspaces_template_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT; + ForeignKeyAIProviderKeysAPIKeyKeyID ForeignKeyConstraint = "ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyAIProviderKeysProviderID ForeignKeyConstraint = "ai_provider_keys_provider_id_fkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; + ForeignKeyAIProvidersSettingsKeyID ForeignKeyConstraint = "ai_providers_settings_key_id_fkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_settings_key_id_fkey FOREIGN KEY (settings_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyAISeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); + ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyBoundaryLogsOwnerID ForeignKeyConstraint = "boundary_logs_owner_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; + ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; + ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); + ForeignKeyChatContextResourcesChatID ForeignKeyConstraint = "chat_context_resources_chat_id_fkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatFileLinksChatID ForeignKeyConstraint = "chat_file_links_chat_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatFileLinksFileID ForeignKeyConstraint = "chat_file_links_file_id_fkey" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_file_id_fkey FOREIGN KEY (file_id) REFERENCES chat_files(id) ON DELETE CASCADE; + ForeignKeyChatFilesOrganizationID ForeignKeyConstraint = "chat_files_organization_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyChatFilesOwnerID ForeignKeyConstraint = "chat_files_owner_id_fkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatHeartbeatsChatID ForeignKeyConstraint = "chat_heartbeats_chat_id_fkey" // ALTER TABLE ONLY chat_heartbeats ADD CONSTRAINT chat_heartbeats_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatMessagesChatID ForeignKeyConstraint = "chat_messages_chat_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatMessagesModelConfigID ForeignKeyConstraint = "chat_messages_model_config_id_fkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_model_config_id_fkey FOREIGN KEY (model_config_id) REFERENCES chat_model_configs(id); + ForeignKeyChatModelConfigsAIProviderID ForeignKeyConstraint = "chat_model_configs_ai_provider_id_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id); + ForeignKeyChatModelConfigsCreatedBy ForeignKeyConstraint = "chat_model_configs_created_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id); + ForeignKeyChatModelConfigsOrganizationID ForeignKeyConstraint = "chat_model_configs_organization_id_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyChatModelConfigsUpdatedBy ForeignKeyConstraint = "chat_model_configs_updated_by_fkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id); + ForeignKeyChatOrganizationModelOverridesOrganizationID ForeignKeyConstraint = "chat_organization_model_overrides_organization_id_fkey" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyChatOrganizationModelOverridesOrganizationModelConfigFke ForeignKeyConstraint = "chat_organization_model_overrides_organization_model_config_fke" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_model_config_fke FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); + ForeignKeyChatQueuedMessagesChatID ForeignKeyConstraint = "chat_queued_messages_chat_id_fkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; + ForeignKeyChatUserModelOverridesOrganizationID ForeignKeyConstraint = "chat_user_model_overrides_organization_id_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyChatUserModelOverridesOrganizationModelConfig ForeignKeyConstraint = "chat_user_model_overrides_organization_model_config_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_organization_model_config_fkey FOREIGN KEY (organization_id, model_config_id) REFERENCES chat_model_configs(organization_id, id); + ForeignKeyChatUserModelOverridesUserID ForeignKeyConstraint = "chat_user_model_overrides_user_id_fkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatsAgentID ForeignKeyConstraint = "chats_agent_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE SET NULL; + ForeignKeyChatsBuildID ForeignKeyConstraint = "chats_build_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_build_id_fkey FOREIGN KEY (build_id) REFERENCES workspace_builds(id) ON DELETE SET NULL; + ForeignKeyChatsLastModelConfigID ForeignKeyConstraint = "chats_last_model_config_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_last_model_config_id_fkey FOREIGN KEY (last_model_config_id) REFERENCES chat_model_configs(id); + ForeignKeyChatsOrganizationID ForeignKeyConstraint = "chats_organization_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyChatsOwnerID ForeignKeyConstraint = "chats_owner_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyChatsParentChatID ForeignKeyConstraint = "chats_parent_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_parent_chat_id_fkey FOREIGN KEY (parent_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + ForeignKeyChatsRootChatID ForeignKeyConstraint = "chats_root_chat_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_root_chat_id_fkey FOREIGN KEY (root_chat_id) REFERENCES chats(id) ON DELETE SET NULL; + ForeignKeyChatsWorkspaceID ForeignKeyConstraint = "chats_workspace_id_fkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL; + ForeignKeyConnectionLogsOrganizationID ForeignKeyConstraint = "connection_logs_organization_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyConnectionLogsWorkspaceID ForeignKeyConstraint = "connection_logs_workspace_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ForeignKeyConnectionLogsWorkspaceOwnerID ForeignKeyConstraint = "connection_logs_workspace_owner_id_fkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_workspace_owner_id_fkey FOREIGN KEY (workspace_owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyCryptoKeysSecretKeyID ForeignKeyConstraint = "crypto_keys_secret_key_id_fkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_secret_key_id_fkey FOREIGN KEY (secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyFkChatDebugStepsRunChat ForeignKeyConstraint = "fk_chat_debug_steps_run_chat" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT fk_chat_debug_steps_run_chat FOREIGN KEY (run_id, chat_id) REFERENCES chat_debug_runs(id, chat_id) ON DELETE CASCADE; + ForeignKeyFkOauth2ProviderAppTokensUserID ForeignKeyConstraint = "fk_oauth2_provider_app_tokens_user_id" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT fk_oauth2_provider_app_tokens_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyGitAuthLinksOauthAccessTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyGitAuthLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "git_auth_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyGitSSHKeysPrivateKeyKeyID ForeignKeyConstraint = "gitsshkeys_private_key_key_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_private_key_key_id_fkey FOREIGN KEY (private_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyGitSSHKeysUserID ForeignKeyConstraint = "gitsshkeys_user_id_fkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ForeignKeyGroupAIBudgetsGroupID ForeignKeyConstraint = "group_ai_budgets_group_id_fkey" // ALTER TABLE ONLY group_ai_budgets ADD CONSTRAINT group_ai_budgets_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + ForeignKeyGroupMembersGroupID ForeignKeyConstraint = "group_members_group_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + ForeignKeyGroupMembersUserID ForeignKeyConstraint = "group_members_user_id_fkey" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyGroupsOrganizationID ForeignKeyConstraint = "groups_organization_id_fkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyInboxNotificationsTemplateID ForeignKeyConstraint = "inbox_notifications_template_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_template_id_fkey FOREIGN KEY (template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + ForeignKeyInboxNotificationsUserID ForeignKeyConstraint = "inbox_notifications_user_id_fkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyJfrogXrayScansAgentID ForeignKeyConstraint = "jfrog_xray_scans_agent_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ForeignKeyMcpServerConfigsAPIKeyValueKeyID ForeignKeyConstraint = "mcp_server_configs_api_key_value_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_api_key_value_key_id_fkey FOREIGN KEY (api_key_value_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyMcpServerConfigsCreatedBy ForeignKeyConstraint = "mcp_server_configs_created_by_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; + ForeignKeyMcpServerConfigsCustomHeadersKeyID ForeignKeyConstraint = "mcp_server_configs_custom_headers_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_custom_headers_key_id_fkey FOREIGN KEY (custom_headers_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyMcpServerConfigsOauth2ClientSecretKeyID ForeignKeyConstraint = "mcp_server_configs_oauth2_client_secret_key_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_oauth2_client_secret_key_id_fkey FOREIGN KEY (oauth2_client_secret_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyMcpServerConfigsOrganizationID ForeignKeyConstraint = "mcp_server_configs_organization_id_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyMcpServerConfigsUpdatedBy ForeignKeyConstraint = "mcp_server_configs_updated_by_fkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_updated_by_fkey FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL; + ForeignKeyMcpServerUserTokensAccessTokenKeyID ForeignKeyConstraint = "mcp_server_user_tokens_access_token_key_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_access_token_key_id_fkey FOREIGN KEY (access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyMcpServerUserTokensMcpServerConfigID ForeignKeyConstraint = "mcp_server_user_tokens_mcp_server_config_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_fkey FOREIGN KEY (mcp_server_config_id) REFERENCES mcp_server_configs(id) ON DELETE CASCADE; + ForeignKeyMcpServerUserTokensRefreshTokenKeyID ForeignKeyConstraint = "mcp_server_user_tokens_refresh_token_key_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_refresh_token_key_id_fkey FOREIGN KEY (refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyMcpServerUserTokensUserID ForeignKeyConstraint = "mcp_server_user_tokens_user_id_fkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + ForeignKeyNotificationMessagesUserID ForeignKeyConstraint = "notification_messages_user_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyNotificationPreferencesNotificationTemplateID ForeignKeyConstraint = "notification_preferences_notification_template_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + ForeignKeyNotificationPreferencesUserID ForeignKeyConstraint = "notification_preferences_user_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppCodesAppID ForeignKeyConstraint = "oauth2_provider_app_codes_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppTokensAPIKeyID ForeignKeyConstraint = "oauth2_provider_app_tokens_api_key_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppTokensAppID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; + ForeignKeyOauth2ProviderAppTokensAppSecretID ForeignKeyConstraint = "oauth2_provider_app_tokens_app_secret_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_app_secret_id_fkey FOREIGN KEY (app_secret_id) REFERENCES oauth2_provider_app_secrets(id) ON DELETE CASCADE; + ForeignKeyOrganizationMembersOrganizationIDUUID ForeignKeyConstraint = "organization_members_organization_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyOrganizationMembersUserIDUUID ForeignKeyConstraint = "organization_members_user_id_uuid_fkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyParameterSchemasJobID ForeignKeyConstraint = "parameter_schemas_job_id_fkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyProvisionerDaemonsKeyID ForeignKeyConstraint = "provisioner_daemons_key_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_key_id_fkey FOREIGN KEY (key_id) REFERENCES provisioner_keys(id) ON DELETE CASCADE; + ForeignKeyProvisionerDaemonsOrganizationID ForeignKeyConstraint = "provisioner_daemons_organization_id_fkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyProvisionerJobLogsJobID ForeignKeyConstraint = "provisioner_job_logs_job_id_fkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyProvisionerJobTimingsJobID ForeignKeyConstraint = "provisioner_job_timings_job_id_fkey" // ALTER TABLE ONLY provisioner_job_timings ADD CONSTRAINT provisioner_job_timings_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyProvisionerJobsOrganizationID ForeignKeyConstraint = "provisioner_jobs_organization_id_fkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyProvisionerKeysOrganizationID ForeignKeyConstraint = "provisioner_keys_organization_id_fkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyTailnetPeersCoordinatorID ForeignKeyConstraint = "tailnet_peers_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; + ForeignKeyTailnetTunnelsCoordinatorID ForeignKeyConstraint = "tailnet_tunnels_coordinator_id_fkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_coordinator_id_fkey FOREIGN KEY (coordinator_id) REFERENCES tailnet_coordinators(id) ON DELETE CASCADE; + ForeignKeyTaskSnapshotsTaskID ForeignKeyConstraint = "task_snapshots_task_id_fkey" // ALTER TABLE ONLY task_snapshots ADD CONSTRAINT task_snapshots_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + ForeignKeyTaskWorkspaceAppsTaskID ForeignKeyConstraint = "task_workspace_apps_task_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_task_id_fkey FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE; + ForeignKeyTaskWorkspaceAppsWorkspaceAgentID ForeignKeyConstraint = "task_workspace_apps_workspace_agent_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyTaskWorkspaceAppsWorkspaceAppID ForeignKeyConstraint = "task_workspace_apps_workspace_app_id_fkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_workspace_app_id_fkey FOREIGN KEY (workspace_app_id) REFERENCES workspace_apps(id) ON DELETE CASCADE; + ForeignKeyTasksOrganizationID ForeignKeyConstraint = "tasks_organization_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyTasksOwnerID ForeignKeyConstraint = "tasks_owner_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyTasksTemplateVersionID ForeignKeyConstraint = "tasks_template_version_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyTasksWorkspaceID ForeignKeyConstraint = "tasks_workspace_id_fkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionParametersTemplateVersionID ForeignKeyConstraint = "template_version_parameters_template_version_id_fkey" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionPresetParametTemplateVersionPresetID ForeignKeyConstraint = "template_version_preset_paramet_template_version_preset_id_fkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_paramet_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionPresetPrebuildSchedulesPresetID ForeignKeyConstraint = "template_version_preset_prebuild_schedules_preset_id_fkey" // ALTER TABLE ONLY template_version_preset_prebuild_schedules ADD CONSTRAINT template_version_preset_prebuild_schedules_preset_id_fkey FOREIGN KEY (preset_id) REFERENCES template_version_presets(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionPresetsTemplateVersionID ForeignKeyConstraint = "template_version_presets_template_version_id_fkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionTerraformValuesCachedModuleFiles ForeignKeyConstraint = "template_version_terraform_values_cached_module_files_fkey" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_cached_module_files_fkey FOREIGN KEY (cached_module_files) REFERENCES files(id); + ForeignKeyTemplateVersionTerraformValuesTemplateVersionID ForeignKeyConstraint = "template_version_terraform_values_template_version_id_fkey" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionVariablesTemplateVersionID ForeignKeyConstraint = "template_version_variables_template_version_id_fkey" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionWorkspaceTagsTemplateVersionID ForeignKeyConstraint = "template_version_workspace_tags_template_version_id_fkey" // ALTER TABLE ONLY template_version_workspace_tags ADD CONSTRAINT template_version_workspace_tags_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionsCreatedBy ForeignKeyConstraint = "template_versions_created_by_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; + ForeignKeyTemplateVersionsOrganizationID ForeignKeyConstraint = "template_versions_organization_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE; + ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; + ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyUserAIBudgetOverridesGroupID ForeignKeyConstraint = "user_ai_budget_overrides_group_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + ForeignKeyUserAIBudgetOverridesUserID ForeignKeyConstraint = "user_ai_budget_overrides_user_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserAIProviderKeysAIProviderID ForeignKeyConstraint = "user_ai_provider_keys_ai_provider_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; + ForeignKeyUserAIProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyUserAIProviderKeysUserID ForeignKeyConstraint = "user_ai_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserConfigsUserID ForeignKeyConstraint = "user_configs_user_id_fkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserDeletedUserID ForeignKeyConstraint = "user_deleted_user_id_fkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ForeignKeyUserLinksOauthAccessTokenKeyID ForeignKeyConstraint = "user_links_oauth_access_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_access_token_key_id_fkey FOREIGN KEY (oauth_access_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyUserLinksOauthRefreshTokenKeyID ForeignKeyConstraint = "user_links_oauth_refresh_token_key_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_oauth_refresh_token_key_id_fkey FOREIGN KEY (oauth_refresh_token_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyUserLinksUserID ForeignKeyConstraint = "user_links_user_id_fkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserSecretsValueKeyID ForeignKeyConstraint = "user_secrets_value_key_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_value_key_id_fkey FOREIGN KEY (value_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyUserSkillsUserID ForeignKeyConstraint = "user_skills_user_id_fkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentContextResourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_resources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentContextSnapshotsWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_context_snapshots_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_context_snapshots ADD CONSTRAINT workspace_agent_context_snapshots_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentMemoryResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_memory_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentMetadataWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_metadata_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentPortShareWorkspaceID ForeignKeyConstraint = "workspace_agent_port_share_workspace_id_fkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentScriptTimingsScriptID ForeignKeyConstraint = "workspace_agent_script_timings_script_id_fkey" // ALTER TABLE ONLY workspace_agent_script_timings ADD CONSTRAINT workspace_agent_script_timings_script_id_fkey FOREIGN KEY (script_id) REFERENCES workspace_agent_scripts(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentScriptsWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_scripts_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentStartupLogsAgentID ForeignKeyConstraint = "workspace_agent_startup_logs_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentVolumeResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_volume_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentsParentID ForeignKeyConstraint = "workspace_agents_parent_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAgentsResourceID ForeignKeyConstraint = "workspace_agents_resource_id_fkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_resource_id_fkey FOREIGN KEY (resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAppAuditSessionsAgentID ForeignKeyConstraint = "workspace_app_audit_sessions_agent_id_fkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); + ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ForeignKeyWorkspaceAppStatusesAgentID ForeignKeyConstraint = "workspace_app_statuses_agent_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + ForeignKeyWorkspaceAppStatusesAppID ForeignKeyConstraint = "workspace_app_statuses_app_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id); + ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildOrchestrationsChildBuildWorkspaceID ForeignKeyConstraint = "workspace_build_orchestrations_child_build_workspace_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_build_workspace_id_fkey FOREIGN KEY (child_build_id, workspace_id) REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildOrchestrationsChildPresetID ForeignKeyConstraint = "workspace_build_orchestrations_child_preset_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_preset_id_fkey FOREIGN KEY (child_template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL; + ForeignKeyWorkspaceBuildOrchestrationsChildPresetVersion ForeignKeyConstraint = "workspace_build_orchestrations_child_preset_version_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_preset_version_fkey FOREIGN KEY (child_template_version_preset_id, child_template_version_id) REFERENCES template_version_presets(id, template_version_id); + ForeignKeyWorkspaceBuildOrchestrationsChildTemplateVersionID ForeignKeyConstraint = "workspace_build_orchestrations_child_template_version_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_template_version_id_fkey FOREIGN KEY (child_template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildOrchestrationsParentBuildWorkspaceID ForeignKeyConstraint = "workspace_build_orchestrations_parent_build_workspace_id_fkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_parent_build_workspace_id_fkey FOREIGN KEY (parent_build_id, workspace_id) REFERENCES workspace_builds(id, workspace_id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildsTemplateVersionID ForeignKeyConstraint = "workspace_builds_template_version_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_id_fkey FOREIGN KEY (template_version_id) REFERENCES template_versions(id) ON DELETE CASCADE; + ForeignKeyWorkspaceBuildsTemplateVersionPresetID ForeignKeyConstraint = "workspace_builds_template_version_preset_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_template_version_preset_id_fkey FOREIGN KEY (template_version_preset_id) REFERENCES template_version_presets(id) ON DELETE SET NULL; + ForeignKeyWorkspaceBuildsWorkspaceID ForeignKeyConstraint = "workspace_builds_workspace_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; + ForeignKeyWorkspaceModulesJobID ForeignKeyConstraint = "workspace_modules_job_id_fkey" // ALTER TABLE ONLY workspace_modules ADD CONSTRAINT workspace_modules_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyWorkspaceResourceMetadataWorkspaceResourceID ForeignKeyConstraint = "workspace_resource_metadata_workspace_resource_id_fkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_workspace_resource_id_fkey FOREIGN KEY (workspace_resource_id) REFERENCES workspace_resources(id) ON DELETE CASCADE; + ForeignKeyWorkspaceResourcesJobID ForeignKeyConstraint = "workspace_resources_job_id_fkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; + ForeignKeyWorkspacesOrganizationID ForeignKeyConstraint = "workspaces_organization_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE RESTRICT; + ForeignKeyWorkspacesOwnerID ForeignKeyConstraint = "workspaces_owner_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; + ForeignKeyWorkspacesTemplateID ForeignKeyConstraint = "workspaces_template_id_fkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE RESTRICT; ) diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql new file mode 100644 index 00000000000..c7c89715c57 --- /dev/null +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql @@ -0,0 +1,78 @@ +INSERT INTO site_configs (key, value) +SELECT + 'agents_chat_' || omo.context || '_model_override', + omo.model_config_id::text || CASE + WHEN omo.reasoning_effort IS NOT NULL AND omo.reasoning_effort != '' + THEN ':' || omo.reasoning_effort + ELSE '' + END +FROM chat_organization_model_overrides omo +JOIN organizations o ON o.id = omo.organization_id +WHERE o.is_default + AND omo.context IN ('general', 'explore', 'title_generation', 'compaction') + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = omo.model_config_id + AND cmc.organization_id = omo.organization_id + AND NOT cmc.deleted + ) +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + +INSERT INTO site_configs (key, value) +SELECT + 'agents_advisor_config', + jsonb_strip_nulls(jsonb_build_object( + 'model_config_id', omo.model_config_id, + 'reasoning_effort', omo.reasoning_effort + ))::text +FROM chat_organization_model_overrides omo +JOIN organizations o ON o.id = omo.organization_id +WHERE o.is_default + AND omo.context = 'advisor' + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = omo.model_config_id + AND cmc.organization_id = omo.organization_id + AND NOT cmc.deleted + ) +ON CONFLICT (key) DO UPDATE +SET value = ( + COALESCE(NULLIF(site_configs.value, ''), '{}')::jsonb + || EXCLUDED.value::jsonb +)::text; + +INSERT INTO user_configs (user_id, key, value) +SELECT + umo.user_id, + 'chat_personal_model_override:' || umo.context, + CASE + WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode + ELSE 'model:' || umo.model_config_id::text || CASE + WHEN umo.reasoning_effort IS NOT NULL AND umo.reasoning_effort != '' + THEN ':' || umo.reasoning_effort + ELSE '' + END + END +FROM chat_user_model_overrides umo +JOIN organizations o ON o.id = umo.organization_id +WHERE o.is_default + AND ( + umo.mode IN ('chat_default', 'deployment_default') + OR EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = umo.model_config_id + AND cmc.organization_id = umo.organization_id + AND NOT cmc.deleted + ) + ) +ON CONFLICT ON CONSTRAINT user_configs_pkey +DO UPDATE SET value = EXCLUDED.value; + +DROP TABLE chat_user_model_overrides; +DROP TABLE chat_organization_model_overrides; + +ALTER TABLE chat_model_configs + DROP CONSTRAINT chat_model_configs_organization_id_id_key; diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql new file mode 100644 index 00000000000..2b0bea50c56 --- /dev/null +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql @@ -0,0 +1,183 @@ +ALTER TABLE chat_model_configs + ADD CONSTRAINT chat_model_configs_organization_id_id_key + UNIQUE (organization_id, id); + +CREATE TABLE chat_organization_model_overrides ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + context text NOT NULL, + model_config_id uuid NOT NULL, + reasoning_effort text, + CONSTRAINT chat_organization_model_overrides_context_check + CHECK (context IN ('general', 'explore', 'title_generation', 'compaction', 'advisor')), + CONSTRAINT chat_organization_model_overrides_organization_id_context_key + UNIQUE (organization_id, context), + CONSTRAINT chat_organization_model_overrides_organization_model_config_fkey + FOREIGN KEY (organization_id, model_config_id) + REFERENCES chat_model_configs (organization_id, id) +); + +CREATE TABLE chat_user_model_overrides ( + id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + context text NOT NULL, + mode text NOT NULL, + model_config_id uuid, + reasoning_effort text, + CONSTRAINT chat_user_model_overrides_context_check + CHECK (context IN ('root', 'general', 'explore')), + CONSTRAINT chat_user_model_overrides_mode_check + CHECK (mode IN ('model', 'chat_default', 'deployment_default')), + CONSTRAINT chat_user_model_overrides_model_requires_config_check + CHECK ((mode = 'model') = (model_config_id IS NOT NULL)), + CONSTRAINT chat_user_model_overrides_user_organization_context_key + UNIQUE (user_id, organization_id, context), + CONSTRAINT chat_user_model_overrides_organization_model_config_fkey + FOREIGN KEY (organization_id, model_config_id) + REFERENCES chat_model_configs (organization_id, id) +); + +WITH parsed AS ( + SELECT + substring(sc.key FROM '^agents_chat_(.*)_model_override$') AS context, + sc.value, + ( + SELECT split_part(trim(sc.value), ':', 1)::uuid + WHERE split_part(trim(sc.value), ':', 1) + ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + ) AS config_id + FROM site_configs sc + WHERE sc.key IN ( + 'agents_chat_general_model_override', + 'agents_chat_explore_model_override', + 'agents_chat_title_generation_model_override', + 'agents_chat_compaction_model_override' + ) + AND trim(sc.value) != '' +) +INSERT INTO chat_organization_model_overrides + (organization_id, context, model_config_id, reasoning_effort) +SELECT + o.id, + p.context, + p.config_id, + NULLIF(split_part(trim(p.value), ':', 2), '') +FROM parsed p +CROSS JOIN organizations o +WHERE o.is_default + AND p.config_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = p.config_id + AND cmc.organization_id = o.id + AND NOT cmc.deleted + ) +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO NOTHING; + +DELETE FROM site_configs +WHERE key IN ( + 'agents_chat_general_model_override', + 'agents_chat_explore_model_override', + 'agents_chat_title_generation_model_override', + 'agents_chat_compaction_model_override' +); + +WITH advisor AS ( + SELECT + sc.value::jsonb AS blob, + ( + SELECT (sc.value::jsonb ->> 'model_config_id')::uuid + WHERE sc.value::jsonb ->> 'model_config_id' + ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + ) AS config_id + FROM site_configs sc + WHERE sc.key = 'agents_advisor_config' + AND trim(sc.value) != '' + AND sc.value::jsonb ? 'model_config_id' +) +INSERT INTO chat_organization_model_overrides + (organization_id, context, model_config_id, reasoning_effort) +SELECT + o.id, + 'advisor', + a.config_id, + NULLIF(trim(a.blob ->> 'reasoning_effort'), '') +FROM advisor a +CROSS JOIN organizations o +WHERE o.is_default + AND a.config_id IS NOT NULL + AND a.config_id != '00000000-0000-0000-0000-000000000000'::uuid + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = a.config_id + AND cmc.organization_id = o.id + AND NOT cmc.deleted + ) +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO NOTHING; + +UPDATE site_configs +SET value = (value::jsonb - 'model_config_id' - 'reasoning_effort')::text +WHERE key = 'agents_advisor_config'; + +WITH parsed AS ( + SELECT + uc.user_id, + substring(uc.key FROM 'chat_personal_model_override:(.*)') AS context, + uc.value, + ( + SELECT split_part(trim(uc.value), ':', 2)::uuid + WHERE split_part(trim(uc.value), ':', 1) = 'model' + AND split_part(trim(uc.value), ':', 2) + ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + ) AS config_id + FROM user_configs uc + WHERE uc.key LIKE 'chat\_personal\_model\_override:%' + AND substring(uc.key FROM 'chat_personal_model_override:(.*)') IN ('root', 'general', 'explore') +) +INSERT INTO chat_user_model_overrides + (user_id, organization_id, context, mode, model_config_id, reasoning_effort) +SELECT + p.user_id, + o.id, + p.context, + CASE + WHEN trim(p.value) IN ('chat_default', 'deployment_default') THEN trim(p.value) + ELSE 'model' + END, + CASE + WHEN trim(p.value) IN ('chat_default', 'deployment_default') THEN NULL + ELSE p.config_id + END, + CASE + WHEN split_part(trim(p.value), ':', 1) = 'model' + THEN NULLIF(split_part(trim(p.value), ':', 3), '') + ELSE NULL + END +FROM parsed p +CROSS JOIN organizations o +WHERE o.is_default + AND ( + trim(p.value) IN ('chat_default', 'deployment_default') + OR ( + split_part(trim(p.value), ':', 1) = 'model' + AND p.config_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = p.config_id + AND cmc.organization_id = o.id + AND NOT cmc.deleted + ) + ) + ) +ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key +DO NOTHING; + +DELETE FROM user_configs +WHERE key LIKE 'chat\_personal\_model\_override:%' + AND substring(key FROM 'chat_personal_model_override:(.*)') IN ('root', 'general', 'explore'); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 3827e58e078..d26d65f6d21 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3255,3 +3255,153 @@ func mustJSON(t *testing.T, v any) []byte { require.NoError(t, err) return raw } + +func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { + t.Parallel() + + const migrationVersion = 583 + + db := testSQLDB(t) + next, err := migrations.Stepper(db) + require.NoError(t, err) + last := uint(0) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + break + } + last = version + } + require.GreaterOrEqual(t, last, uint(migrationVersion)) + + ctx := testutil.Context(t, testutil.WaitSuperLong) + upSQL, err := os.ReadFile("000583_chat_model_override_org_scope.up.sql") + require.NoError(t, err) + downSQL, err := os.ReadFile("000583_chat_model_override_org_scope.down.sql") + require.NoError(t, err) + + _, err = db.ExecContext(ctx, string(downSQL)) + require.NoError(t, err) + + now := time.Now().UTC().Truncate(time.Microsecond) + userID := uuid.New() + providerID := uuid.New() + modelID := uuid.New() + + var orgID uuid.UUID + require.NoError(t, db.QueryRowContext(ctx, + "SELECT id FROM organizations WHERE is_default = true").Scan(&orgID)) + + _, err = db.ExecContext(ctx, ` + INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type) + VALUES ($1, $2, $3, $4, $5, $5, 'active', '{}', 'password')`, + userID, "model-override-"+userID.String(), userID.String()+"@example.com", []byte{}, now) + require.NoError(t, err) + _, err = db.ExecContext(ctx, ` + INSERT INTO ai_providers (id, type, name, enabled, base_url, created_at, updated_at) + VALUES ($1, 'openai', $2, true, 'https://example.com', $3, $3)`, + providerID, "model-override-"+providerID.String(), now) + require.NoError(t, err) + _, err = db.ExecContext(ctx, ` + INSERT INTO chat_model_configs ( + id, model, display_name, enabled, is_default, deleted, + context_limit, compression_threshold, ai_provider_id, organization_id, + group_acl, user_acl, created_at, updated_at + ) VALUES ($1, $2, $2, true, false, false, 128000, 70, $3, $4, '{}', '{}', $5, $5)`, + modelID, "model-override-"+modelID.String(), providerID, orgID, now) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, ` + INSERT INTO site_configs (key, value) VALUES + ('agents_chat_general_model_override', $1), + ('agents_advisor_config', $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + modelID.String()+":high", + fmt.Sprintf(`{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, modelID)) + require.NoError(t, err) + _, err = db.ExecContext(ctx, ` + INSERT INTO user_configs (user_id, key, value) VALUES + ($1, 'chat_personal_model_override:root', $2), + ($1, 'chat_personal_model_override:general', 'chat_default')`, + userID, "model:"+modelID.String()+":max") + require.NoError(t, err) + + _, err = db.ExecContext(ctx, string(upSQL)) + require.NoError(t, err) + + var orgModelID uuid.UUID + var orgEffort sql.NullString + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT model_config_id, reasoning_effort + FROM chat_organization_model_overrides + WHERE organization_id = $1 AND context = 'general'`, orgID).Scan(&orgModelID, &orgEffort)) + require.Equal(t, modelID, orgModelID) + require.Equal(t, sql.NullString{String: "high", Valid: true}, orgEffort) + + var advisorModelID uuid.UUID + var advisorEffort sql.NullString + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT model_config_id, reasoning_effort + FROM chat_organization_model_overrides + WHERE organization_id = $1 AND context = 'advisor'`, orgID).Scan(&advisorModelID, &advisorEffort)) + require.Equal(t, modelID, advisorModelID) + require.Equal(t, sql.NullString{String: "low", Valid: true}, advisorEffort) + + var mode string + var userModelID uuid.UUID + var userEffort sql.NullString + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT mode, model_config_id, reasoning_effort + FROM chat_user_model_overrides + WHERE user_id = $1 AND organization_id = $2 AND context = 'root'`, userID, orgID). + Scan(&mode, &userModelID, &userEffort)) + require.Equal(t, "model", mode) + require.Equal(t, modelID, userModelID) + require.Equal(t, sql.NullString{String: "max", Valid: true}, userEffort) + + var advisorConfig string + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&advisorConfig)) + require.JSONEq(t, `{"enabled":true,"max_uses_per_run":2}`, advisorConfig) + + var legacyCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM site_configs + WHERE key = 'agents_chat_general_model_override'`).Scan(&legacyCount)) + require.Zero(t, legacyCount) + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM user_configs + WHERE user_id = $1 AND key LIKE 'chat\_personal\_model\_override:%'`, userID).Scan(&legacyCount)) + require.Zero(t, legacyCount) + + otherOrgID := uuid.New() + _, err = db.ExecContext(ctx, ` + INSERT INTO organizations ( + id, name, display_name, description, created_at, updated_at, + is_default, default_org_member_roles + ) VALUES ($1, $2, $2, '', $3, $3, false, '{}')`, + otherOrgID, "model-override-"+otherOrgID.String(), now) + require.NoError(t, err) + _, err = db.ExecContext(ctx, ` + INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id) + VALUES ($1, 'explore', $2)`, otherOrgID, modelID) + require.Error(t, err) + require.True(t, database.IsForeignKeyViolation(err)) + + _, err = db.ExecContext(ctx, string(downSQL)) + require.NoError(t, err) + + var value string + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM site_configs WHERE key = 'agents_chat_general_model_override'").Scan(&value)) + require.Equal(t, modelID.String()+":high", value) + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM user_configs WHERE user_id = $1 AND key = 'chat_personal_model_override:root'", userID).Scan(&value)) + require.Equal(t, "model:"+modelID.String()+":max", value) + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) + require.JSONEq(t, fmt.Sprintf( + `{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, + modelID), value) +} diff --git a/coderd/database/migrations/testdata/fixtures/000583_chat_model_override_org_scope.up.sql b/coderd/database/migrations/testdata/fixtures/000583_chat_model_override_org_scope.up.sql new file mode 100644 index 00000000000..bfab7bc7d2a --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000583_chat_model_override_org_scope.up.sql @@ -0,0 +1,30 @@ +INSERT INTO chat_organization_model_overrides ( + organization_id, + context, + model_config_id, + reasoning_effort +) +SELECT + organization_id, + 'general', + id, + 'high' +FROM chat_model_configs +WHERE id = '580c0001-0000-4000-8000-000000000001'; + +INSERT INTO chat_user_model_overrides ( + user_id, + organization_id, + context, + mode +) +SELECT + u.id, + o.id, + 'root', + 'chat_default' +FROM users u +CROSS JOIN organizations o +WHERE o.is_default +ORDER BY u.id +LIMIT 1; diff --git a/coderd/database/models.go b/coderd/database/models.go index 2f379be3091..a3d1ddaade3 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5264,6 +5264,14 @@ type ChatModelConfig struct { UserACL ChatACL `db:"user_acl" json:"user_acl"` } +type ChatOrganizationModelOverride struct { + ID uuid.UUID `db:"id" json:"id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` + ModelConfigID uuid.UUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` +} + type ChatQueuedMessage struct { ID int64 `db:"id" json:"id"` ChatID uuid.UUID `db:"chat_id" json:"chat_id"` @@ -5343,6 +5351,16 @@ type ChatUsageLimitConfig struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +type ChatUserModelOverride struct { + ID uuid.UUID `db:"id" json:"id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` + Mode string `db:"mode" json:"mode"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` +} + type ConnectionLog struct { ID uuid.UUID `db:"id" json:"id"` ConnectTime time.Time `db:"connect_time" json:"connect_time"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 167f9210f76..06ee7e1cabe 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -147,6 +147,7 @@ type sqlcQuerier interface { // archive-cleanup retry). DeleteChatDebugDataByChatID(ctx context.Context, arg DeleteChatDebugDataByChatIDParams) (int64, error) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) + DeleteChatOrganizationModelOverride(ctx context.Context, arg DeleteChatOrganizationModelOverrideParams) error DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error // Deletes a queued message, scoped to the parent chat. Returns the // number of affected rows so callers can detect missing rows without @@ -458,6 +459,8 @@ type sqlcQuerier interface { GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]GetChatFileMetadataByChatIDRow, error) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error) GetChatGatewayAPIKey(ctx context.Context, arg GetChatGatewayAPIKeyParams) (APIKey, error) + // Compatibility methods keep the current runtime compiling until it adopts + // the organization-aware query surface. They read and write the default org. GetChatGeneralModelOverride(ctx context.Context) (string, error) GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatParams) (ChatHeartbeat, error) // GetChatIncludeDefaultSystemPrompt preserves the legacy default @@ -490,6 +493,8 @@ type sqlcQuerier interface { GetChatModelConfigsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]ChatModelConfig, error) // deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe. GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error) + GetChatOrganizationModelOverride(ctx context.Context, arg GetChatOrganizationModelOverrideParams) (ChatOrganizationModelOverride, error) + GetChatOrganizationModelOverrides(ctx context.Context, organizationID uuid.UUID) ([]ChatOrganizationModelOverride, error) // GetChatPersonalModelOverridesEnabled returns whether users may configure // personal chat model overrides. It defaults to false when unset. GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) @@ -516,6 +521,8 @@ type sqlcQuerier interface { // existed. GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) + GetChatUserModelOverride(ctx context.Context, arg GetChatUserModelOverrideParams) (ChatUserModelOverride, error) + GetChatUserModelOverrides(ctx context.Context, arg GetChatUserModelOverridesParams) ([]ChatUserModelOverride, error) // Returns the concatenated text of each user-visible user prompt in a // chat, newest first. Used by the composer to populate the up/down // arrow prompt-history cycle. Non-text parts (tool calls, files, @@ -1664,6 +1671,7 @@ type sqlcQuerier interface { // database time so callers do not depend on a local clock. UpsertChatHeartbeat(ctx context.Context, arg UpsertChatHeartbeatParams) error UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error + UpsertChatOrganizationModelOverride(ctx context.Context, arg UpsertChatOrganizationModelOverrideParams) error // UpsertChatPersonalModelOverridesEnabled updates whether users may configure // personal chat model overrides. UpsertChatPersonalModelOverridesEnabled(ctx context.Context, enabled bool) error @@ -1671,6 +1679,7 @@ type sqlcQuerier interface { UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error + UpsertChatUserModelOverride(ctx context.Context, arg UpsertChatUserModelOverrideParams) error UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index bc76ac7179a..568d366dada 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6692,6 +6692,511 @@ func (q *sqlQuerier) UpdateChatModelConfigACLByID(ctx context.Context, arg Updat return i, err } +const deleteChatOrganizationModelOverride = `-- name: DeleteChatOrganizationModelOverride :exec +DELETE FROM chat_organization_model_overrides +WHERE organization_id = $1 + AND context = $2 +` + +type DeleteChatOrganizationModelOverrideParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` +} + +func (q *sqlQuerier) DeleteChatOrganizationModelOverride(ctx context.Context, arg DeleteChatOrganizationModelOverrideParams) error { + _, err := q.db.ExecContext(ctx, deleteChatOrganizationModelOverride, arg.OrganizationID, arg.Context) + return err +} + +const getChatCompactionModelOverride = `-- name: GetChatCompactionModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'compaction' +), '')::text AS model_config_id +` + +func (q *sqlQuerier) GetChatCompactionModelOverride(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatCompactionModelOverride) + var model_config_id string + err := row.Scan(&model_config_id) + return model_config_id, err +} + +const getChatExploreModelOverride = `-- name: GetChatExploreModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'explore' +), '')::text AS model_config_id +` + +func (q *sqlQuerier) GetChatExploreModelOverride(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatExploreModelOverride) + var model_config_id string + err := row.Scan(&model_config_id) + return model_config_id, err +} + +const getChatGeneralModelOverride = `-- name: GetChatGeneralModelOverride :one + +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'general' +), '')::text AS model_config_id +` + +// Compatibility methods keep the current runtime compiling until it adopts +// the organization-aware query surface. They read and write the default org. +func (q *sqlQuerier) GetChatGeneralModelOverride(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatGeneralModelOverride) + var model_config_id string + err := row.Scan(&model_config_id) + return model_config_id, err +} + +const getChatOrganizationModelOverride = `-- name: GetChatOrganizationModelOverride :one +SELECT id, organization_id, context, model_config_id, reasoning_effort +FROM chat_organization_model_overrides +WHERE organization_id = $1 + AND context = $2 +` + +type GetChatOrganizationModelOverrideParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` +} + +func (q *sqlQuerier) GetChatOrganizationModelOverride(ctx context.Context, arg GetChatOrganizationModelOverrideParams) (ChatOrganizationModelOverride, error) { + row := q.db.QueryRowContext(ctx, getChatOrganizationModelOverride, arg.OrganizationID, arg.Context) + var i ChatOrganizationModelOverride + err := row.Scan( + &i.ID, + &i.OrganizationID, + &i.Context, + &i.ModelConfigID, + &i.ReasoningEffort, + ) + return i, err +} + +const getChatOrganizationModelOverrides = `-- name: GetChatOrganizationModelOverrides :many +SELECT id, organization_id, context, model_config_id, reasoning_effort +FROM chat_organization_model_overrides +WHERE organization_id = $1 +ORDER BY context +` + +func (q *sqlQuerier) GetChatOrganizationModelOverrides(ctx context.Context, organizationID uuid.UUID) ([]ChatOrganizationModelOverride, error) { + rows, err := q.db.QueryContext(ctx, getChatOrganizationModelOverrides, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatOrganizationModelOverride + for rows.Next() { + var i ChatOrganizationModelOverride + if err := rows.Scan( + &i.ID, + &i.OrganizationID, + &i.Context, + &i.ModelConfigID, + &i.ReasoningEffort, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChatTitleGenerationModelOverride = `-- name: GetChatTitleGenerationModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'title_generation' +), '')::text AS model_config_id +` + +func (q *sqlQuerier) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatTitleGenerationModelOverride) + var model_config_id string + err := row.Scan(&model_config_id) + return model_config_id, err +} + +const getChatUserModelOverride = `-- name: GetChatUserModelOverride :one +SELECT id, user_id, organization_id, context, mode, model_config_id, reasoning_effort +FROM chat_user_model_overrides +WHERE user_id = $1 + AND organization_id = $2 + AND context = $3 +` + +type GetChatUserModelOverrideParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` +} + +func (q *sqlQuerier) GetChatUserModelOverride(ctx context.Context, arg GetChatUserModelOverrideParams) (ChatUserModelOverride, error) { + row := q.db.QueryRowContext(ctx, getChatUserModelOverride, arg.UserID, arg.OrganizationID, arg.Context) + var i ChatUserModelOverride + err := row.Scan( + &i.ID, + &i.UserID, + &i.OrganizationID, + &i.Context, + &i.Mode, + &i.ModelConfigID, + &i.ReasoningEffort, + ) + return i, err +} + +const getChatUserModelOverrides = `-- name: GetChatUserModelOverrides :many +SELECT id, user_id, organization_id, context, mode, model_config_id, reasoning_effort +FROM chat_user_model_overrides +WHERE user_id = $1 + AND organization_id = $2 +ORDER BY context +` + +type GetChatUserModelOverridesParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (q *sqlQuerier) GetChatUserModelOverrides(ctx context.Context, arg GetChatUserModelOverridesParams) ([]ChatUserModelOverride, error) { + rows, err := q.db.QueryContext(ctx, getChatUserModelOverrides, arg.UserID, arg.OrganizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChatUserModelOverride + for rows.Next() { + var i ChatUserModelOverride + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.OrganizationID, + &i.Context, + &i.Mode, + &i.ModelConfigID, + &i.ReasoningEffort, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserChatPersonalModelOverride = `-- name: GetUserChatPersonalModelOverride :one +SELECT (CASE + WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode + ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') +END)::text AS personal_model_override +FROM chat_user_model_overrides umo +JOIN organizations o ON o.id = umo.organization_id +WHERE umo.user_id = $1 + AND o.is_default + AND umo.context = substring($2::text FROM 'chat_personal_model_override:(.*)') +` + +type GetUserChatPersonalModelOverrideParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Key string `db:"key" json:"key"` +} + +func (q *sqlQuerier) GetUserChatPersonalModelOverride(ctx context.Context, arg GetUserChatPersonalModelOverrideParams) (string, error) { + row := q.db.QueryRowContext(ctx, getUserChatPersonalModelOverride, arg.UserID, arg.Key) + var personal_model_override string + err := row.Scan(&personal_model_override) + return personal_model_override, err +} + +const listUserChatPersonalModelOverrides = `-- name: ListUserChatPersonalModelOverrides :many +SELECT + ('chat_personal_model_override:' || umo.context)::text AS key, + (CASE + WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode + ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') + END)::text AS value +FROM chat_user_model_overrides umo +JOIN organizations o ON o.id = umo.organization_id +WHERE umo.user_id = $1 + AND o.is_default +ORDER BY key +` + +type ListUserChatPersonalModelOverridesRow struct { + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` +} + +func (q *sqlQuerier) ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]ListUserChatPersonalModelOverridesRow, error) { + rows, err := q.db.QueryContext(ctx, listUserChatPersonalModelOverrides, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUserChatPersonalModelOverridesRow + for rows.Next() { + var i ListUserChatPersonalModelOverridesRow + if err := rows.Scan(&i.Key, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertChatCompactionModelOverride = `-- name: UpsertChatCompactionModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'compaction' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'compaction', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort +` + +func (q *sqlQuerier) UpsertChatCompactionModelOverride(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatCompactionModelOverride, value) + return err +} + +const upsertChatExploreModelOverride = `-- name: UpsertChatExploreModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'explore' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'explore', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort +` + +func (q *sqlQuerier) UpsertChatExploreModelOverride(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatExploreModelOverride, value) + return err +} + +const upsertChatGeneralModelOverride = `-- name: UpsertChatGeneralModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'general' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'general', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort +` + +func (q *sqlQuerier) UpsertChatGeneralModelOverride(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatGeneralModelOverride, value) + return err +} + +const upsertChatOrganizationModelOverride = `-- name: UpsertChatOrganizationModelOverride :exec +INSERT INTO chat_organization_model_overrides + (organization_id, context, model_config_id, reasoning_effort) +VALUES ($1, $2, $3, $4) +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET + model_config_id = EXCLUDED.model_config_id, + reasoning_effort = EXCLUDED.reasoning_effort +` + +type UpsertChatOrganizationModelOverrideParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` + ModelConfigID uuid.UUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` +} + +func (q *sqlQuerier) UpsertChatOrganizationModelOverride(ctx context.Context, arg UpsertChatOrganizationModelOverrideParams) error { + _, err := q.db.ExecContext(ctx, upsertChatOrganizationModelOverride, + arg.OrganizationID, + arg.Context, + arg.ModelConfigID, + arg.ReasoningEffort, + ) + return err +} + +const upsertChatTitleGenerationModelOverride = `-- name: UpsertChatTitleGenerationModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'title_generation' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'title_generation', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort +` + +func (q *sqlQuerier) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatTitleGenerationModelOverride, value) + return err +} + +const upsertChatUserModelOverride = `-- name: UpsertChatUserModelOverride :exec +INSERT INTO chat_user_model_overrides + (user_id, organization_id, context, mode, model_config_id, reasoning_effort) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key +DO UPDATE SET + mode = EXCLUDED.mode, + model_config_id = EXCLUDED.model_config_id, + reasoning_effort = EXCLUDED.reasoning_effort +` + +type UpsertChatUserModelOverrideParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Context string `db:"context" json:"context"` + Mode string `db:"mode" json:"mode"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` +} + +func (q *sqlQuerier) UpsertChatUserModelOverride(ctx context.Context, arg UpsertChatUserModelOverrideParams) error { + _, err := q.db.ExecContext(ctx, upsertChatUserModelOverride, + arg.UserID, + arg.OrganizationID, + arg.Context, + arg.Mode, + arg.ModelConfigID, + arg.ReasoningEffort, + ) + return err +} + +const upsertUserChatPersonalModelOverride = `-- name: UpsertUserChatPersonalModelOverride :exec +WITH input AS ( + SELECT + $1::uuid AS user_id, + o.id AS organization_id, + substring($2::text FROM 'chat_personal_model_override:(.*)') AS context, + trim($3::text) AS value, + CASE + WHEN split_part(trim($3::text), ':', 1) = 'model' + THEN split_part(trim($3::text), ':', 2)::uuid + ELSE NULL + END AS model_config_id + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_user_model_overrides umo + USING input i + WHERE umo.user_id = i.user_id + AND umo.organization_id = i.organization_id + AND umo.context = i.context + AND i.value = '' +) +INSERT INTO chat_user_model_overrides + (user_id, organization_id, context, mode, model_config_id, reasoning_effort) +SELECT + user_id, + organization_id, + context, + CASE WHEN value IN ('chat_default', 'deployment_default') THEN value ELSE 'model' END, + model_config_id, + CASE WHEN split_part(value, ':', 1) = 'model' THEN NULLIF(split_part(value, ':', 3), '') ELSE NULL END +FROM input +WHERE value != '' +ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key +DO UPDATE SET + mode = EXCLUDED.mode, + model_config_id = EXCLUDED.model_config_id, + reasoning_effort = EXCLUDED.reasoning_effort +` + +type UpsertUserChatPersonalModelOverrideParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` +} + +func (q *sqlQuerier) UpsertUserChatPersonalModelOverride(ctx context.Context, arg UpsertUserChatPersonalModelOverrideParams) error { + _, err := q.db.ExecContext(ctx, upsertUserChatPersonalModelOverride, arg.UserID, arg.Key, arg.Value) + return err +} + const acquireStaleChatDiffStatuses = `-- name: AcquireStaleChatDiffStatuses :many WITH acquired AS ( UPDATE @@ -25286,18 +25791,6 @@ func (q *sqlQuerier) GetChatAutoArchiveDays(ctx context.Context, defaultAutoArch return auto_archive_days, err } -const getChatCompactionModelOverride = `-- name: GetChatCompactionModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_compaction_model_override'), '') :: text AS model_config_id -` - -func (q *sqlQuerier) GetChatCompactionModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatCompactionModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - const getChatComputerUseProvider = `-- name: GetChatComputerUseProvider :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'agents_computer_use_provider'), '') :: text AS provider @@ -25353,30 +25846,6 @@ func (q *sqlQuerier) GetChatDesktopEnabled(ctx context.Context) (bool, error) { return enable_desktop, err } -const getChatExploreModelOverride = `-- name: GetChatExploreModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_explore_model_override'), '') :: text AS model_config_id -` - -func (q *sqlQuerier) GetChatExploreModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatExploreModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - -const getChatGeneralModelOverride = `-- name: GetChatGeneralModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_general_model_override'), '') :: text AS model_config_id -` - -func (q *sqlQuerier) GetChatGeneralModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatGeneralModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - const getChatIncludeDefaultSystemPrompt = `-- name: GetChatIncludeDefaultSystemPrompt :one SELECT COALESCE( @@ -25495,18 +25964,6 @@ func (q *sqlQuerier) GetChatSystemPromptConfig(ctx context.Context) (GetChatSyst return i, err } -const getChatTitleGenerationModelOverride = `-- name: GetChatTitleGenerationModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_title_generation_model_override'), '') :: text AS model_config_id -` - -func (q *sqlQuerier) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatTitleGenerationModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - const getChatWorkspaceTTL = `-- name: GetChatWorkspaceTTL :one SELECT COALESCE( @@ -25745,16 +26202,6 @@ func (q *sqlQuerier) UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveD return err } -const upsertChatCompactionModelOverride = `-- name: UpsertChatCompactionModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_compaction_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_compaction_model_override' -` - -func (q *sqlQuerier) UpsertChatCompactionModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatCompactionModelOverride, value) - return err -} - const upsertChatComputerUseProvider = `-- name: UpsertChatComputerUseProvider :exec INSERT INTO site_configs (key, value) VALUES ('agents_computer_use_provider', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_computer_use_provider' @@ -25823,26 +26270,6 @@ func (q *sqlQuerier) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop return err } -const upsertChatExploreModelOverride = `-- name: UpsertChatExploreModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_explore_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_explore_model_override' -` - -func (q *sqlQuerier) UpsertChatExploreModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatExploreModelOverride, value) - return err -} - -const upsertChatGeneralModelOverride = `-- name: UpsertChatGeneralModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_general_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_general_model_override' -` - -func (q *sqlQuerier) UpsertChatGeneralModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatGeneralModelOverride, value) - return err -} - const upsertChatIncludeDefaultSystemPrompt = `-- name: UpsertChatIncludeDefaultSystemPrompt :exec INSERT INTO site_configs (key, value) VALUES ( @@ -25921,16 +26348,6 @@ func (q *sqlQuerier) UpsertChatSystemPrompt(ctx context.Context, value string) e return err } -const upsertChatTitleGenerationModelOverride = `-- name: UpsertChatTitleGenerationModelOverride :exec -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' -` - -func (q *sqlQuerier) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatTitleGenerationModelOverride, value) - return err -} - const upsertChatWorkspaceTTL = `-- name: UpsertChatWorkspaceTTL :exec INSERT INTO site_configs (key, value) VALUES ('agents_workspace_ttl', $1::text) @@ -31066,24 +31483,6 @@ func (q *sqlQuerier) GetUserChatDebugLoggingEnabled(ctx context.Context, userID return debug_logging_enabled, err } -const getUserChatPersonalModelOverride = `-- name: GetUserChatPersonalModelOverride :one -SELECT value AS personal_model_override FROM user_configs -WHERE user_id = $1 - AND key = $2 -` - -type GetUserChatPersonalModelOverrideParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - Key string `db:"key" json:"key"` -} - -func (q *sqlQuerier) GetUserChatPersonalModelOverride(ctx context.Context, arg GetUserChatPersonalModelOverrideParams) (string, error) { - row := q.db.QueryRowContext(ctx, getUserChatPersonalModelOverride, arg.UserID, arg.Key) - var personal_model_override string - err := row.Scan(&personal_model_override) - return personal_model_override, err -} - const getUserCodeDiffDisplayMode = `-- name: GetUserCodeDiffDisplayMode :one SELECT value AS code_diff_display_mode @@ -31597,41 +31996,6 @@ func (q *sqlQuerier) ListUserChatCompactionThresholds(ctx context.Context, userI return items, nil } -const listUserChatPersonalModelOverrides = `-- name: ListUserChatPersonalModelOverrides :many -SELECT key, value FROM user_configs -WHERE user_id = $1 - AND key LIKE 'chat\_personal\_model\_override:%' -ORDER BY key -` - -type ListUserChatPersonalModelOverridesRow struct { - Key string `db:"key" json:"key"` - Value string `db:"value" json:"value"` -} - -func (q *sqlQuerier) ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]ListUserChatPersonalModelOverridesRow, error) { - rows, err := q.db.QueryContext(ctx, listUserChatPersonalModelOverrides, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListUserChatPersonalModelOverridesRow - for rows.Next() { - var i ListUserChatPersonalModelOverridesRow - if err := rows.Scan(&i.Key, &i.Value); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const updateInactiveUsersToDormant = `-- name: UpdateInactiveUsersToDormant :many UPDATE users @@ -32399,24 +32763,6 @@ func (q *sqlQuerier) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg return err } -const upsertUserChatPersonalModelOverride = `-- name: UpsertUserChatPersonalModelOverride :exec -INSERT INTO user_configs (user_id, key, value) -VALUES ($1::uuid, $2::text, $3::text) -ON CONFLICT ON CONSTRAINT user_configs_pkey -DO UPDATE SET value = $3::text -` - -type UpsertUserChatPersonalModelOverrideParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - Key string `db:"key" json:"key"` - Value string `db:"value" json:"value"` -} - -func (q *sqlQuerier) UpsertUserChatPersonalModelOverride(ctx context.Context, arg UpsertUserChatPersonalModelOverrideParams) error { - _, err := q.db.ExecContext(ctx, upsertUserChatPersonalModelOverride, arg.UserID, arg.Key, arg.Value) - return err -} - const validateUserIDs = `-- name: ValidateUserIDs :one WITH input AS ( SELECT diff --git a/coderd/database/queries/chatmodeloverrides.sql b/coderd/database/queries/chatmodeloverrides.sql new file mode 100644 index 00000000000..69c459f3a78 --- /dev/null +++ b/coderd/database/queries/chatmodeloverrides.sql @@ -0,0 +1,235 @@ +-- name: GetChatOrganizationModelOverrides :many +SELECT id, organization_id, context, model_config_id, reasoning_effort +FROM chat_organization_model_overrides +WHERE organization_id = @organization_id +ORDER BY context; + +-- name: GetChatOrganizationModelOverride :one +SELECT id, organization_id, context, model_config_id, reasoning_effort +FROM chat_organization_model_overrides +WHERE organization_id = @organization_id + AND context = @context; + +-- name: UpsertChatOrganizationModelOverride :exec +INSERT INTO chat_organization_model_overrides + (organization_id, context, model_config_id, reasoning_effort) +VALUES (@organization_id, @context, @model_config_id, @reasoning_effort) +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET + model_config_id = EXCLUDED.model_config_id, + reasoning_effort = EXCLUDED.reasoning_effort; + +-- name: DeleteChatOrganizationModelOverride :exec +DELETE FROM chat_organization_model_overrides +WHERE organization_id = @organization_id + AND context = @context; + +-- name: GetChatUserModelOverrides :many +SELECT id, user_id, organization_id, context, mode, model_config_id, reasoning_effort +FROM chat_user_model_overrides +WHERE user_id = @user_id + AND organization_id = @organization_id +ORDER BY context; + +-- name: GetChatUserModelOverride :one +SELECT id, user_id, organization_id, context, mode, model_config_id, reasoning_effort +FROM chat_user_model_overrides +WHERE user_id = @user_id + AND organization_id = @organization_id + AND context = @context; + +-- name: UpsertChatUserModelOverride :exec +INSERT INTO chat_user_model_overrides + (user_id, organization_id, context, mode, model_config_id, reasoning_effort) +VALUES (@user_id, @organization_id, @context, @mode, @model_config_id, @reasoning_effort) +ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key +DO UPDATE SET + mode = EXCLUDED.mode, + model_config_id = EXCLUDED.model_config_id, + reasoning_effort = EXCLUDED.reasoning_effort; + +-- Compatibility methods keep the current runtime compiling until it adopts +-- the organization-aware query surface. They read and write the default org. + +-- name: GetChatGeneralModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'general' +), '')::text AS model_config_id; + +-- name: GetChatExploreModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'explore' +), '')::text AS model_config_id; + +-- name: GetChatTitleGenerationModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'title_generation' +), '')::text AS model_config_id; + +-- name: GetChatCompactionModelOverride :one +SELECT COALESCE(( + SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default AND omo.context = 'compaction' +), '')::text AS model_config_id; + +-- name: UpsertChatGeneralModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'general' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'general', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; + +-- name: UpsertChatExploreModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'explore' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'explore', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; + +-- name: UpsertChatTitleGenerationModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'title_generation' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'title_generation', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; + +-- name: UpsertChatCompactionModelOverride :exec +WITH input AS ( + SELECT + o.id AS organization_id, + CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, + NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_organization_model_overrides omo + USING input i + WHERE omo.organization_id = i.organization_id + AND omo.context = 'compaction' + AND i.model_config_id IS NULL +) +INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) +SELECT organization_id, 'compaction', model_config_id, reasoning_effort +FROM input +WHERE model_config_id IS NOT NULL +ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key +DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; + +-- name: ListUserChatPersonalModelOverrides :many +SELECT + ('chat_personal_model_override:' || umo.context)::text AS key, + (CASE + WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode + ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') + END)::text AS value +FROM chat_user_model_overrides umo +JOIN organizations o ON o.id = umo.organization_id +WHERE umo.user_id = @user_id + AND o.is_default +ORDER BY key; + +-- name: GetUserChatPersonalModelOverride :one +SELECT (CASE + WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode + ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') +END)::text AS personal_model_override +FROM chat_user_model_overrides umo +JOIN organizations o ON o.id = umo.organization_id +WHERE umo.user_id = @user_id + AND o.is_default + AND umo.context = substring(@key::text FROM 'chat_personal_model_override:(.*)'); + +-- name: UpsertUserChatPersonalModelOverride :exec +WITH input AS ( + SELECT + @user_id::uuid AS user_id, + o.id AS organization_id, + substring(@key::text FROM 'chat_personal_model_override:(.*)') AS context, + trim(@value::text) AS value, + CASE + WHEN split_part(trim(@value::text), ':', 1) = 'model' + THEN split_part(trim(@value::text), ':', 2)::uuid + ELSE NULL + END AS model_config_id + FROM organizations o + WHERE o.is_default +), deleted AS ( + DELETE FROM chat_user_model_overrides umo + USING input i + WHERE umo.user_id = i.user_id + AND umo.organization_id = i.organization_id + AND umo.context = i.context + AND i.value = '' +) +INSERT INTO chat_user_model_overrides + (user_id, organization_id, context, mode, model_config_id, reasoning_effort) +SELECT + user_id, + organization_id, + context, + CASE WHEN value IN ('chat_default', 'deployment_default') THEN value ELSE 'model' END, + model_config_id, + CASE WHEN split_part(value, ':', 1) = 'model' THEN NULLIF(split_part(value, ':', 3), '') ELSE NULL END +FROM input +WHERE value != '' +ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key +DO UPDATE SET + mode = EXCLUDED.mode, + model_config_id = EXCLUDED.model_config_id, + reasoning_effort = EXCLUDED.reasoning_effort; diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index a4d9589f7da..ef499baf6d2 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -194,38 +194,6 @@ SELECT INSERT INTO site_configs (key, value) VALUES ('agents_chat_plan_mode_instructions', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_plan_mode_instructions'; --- name: GetChatExploreModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_explore_model_override'), '') :: text AS model_config_id; - --- name: UpsertChatExploreModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_explore_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_explore_model_override'; - --- name: GetChatGeneralModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_general_model_override'), '') :: text AS model_config_id; - --- name: UpsertChatGeneralModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_general_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_general_model_override'; - --- name: GetChatTitleGenerationModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_title_generation_model_override'), '') :: text AS model_config_id; - --- name: UpsertChatTitleGenerationModelOverride :exec -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: GetChatCompactionModelOverride :one -SELECT - COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_compaction_model_override'), '') :: text AS model_config_id; - --- name: UpsertChatCompactionModelOverride :exec -INSERT INTO site_configs (key, value) VALUES ('agents_chat_compaction_model_override', $1) -ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_compaction_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/database/queries/users.sql b/coderd/database/queries/users.sql index 47940485efd..fe6bbd291e6 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -284,23 +284,6 @@ END WHERE user_configs.user_id = @user_id AND user_configs.key = 'chat_debug_logging_enabled'; --- name: ListUserChatPersonalModelOverrides :many -SELECT key, value FROM user_configs -WHERE user_id = @user_id - AND key LIKE 'chat\_personal\_model\_override:%' -ORDER BY key; - --- name: GetUserChatPersonalModelOverride :one -SELECT value AS personal_model_override FROM user_configs -WHERE user_id = @user_id - AND key = @key; - --- name: UpsertUserChatPersonalModelOverride :exec -INSERT INTO user_configs (user_id, key, value) -VALUES (@user_id::uuid, @key::text, @value::text) -ON CONFLICT ON CONSTRAINT user_configs_pkey -DO UPDATE SET value = @value::text; - -- name: GetUserTaskNotificationAlertDismissed :one SELECT value::boolean as task_notification_alert_dismissed diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index faa400addbb..9d2390a6468 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,178 +6,183 @@ type UniqueConstraint string // UniqueConstraint enums. const ( - UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); - UniqueAIGatewayKeysPkey UniqueConstraint = "ai_gateway_keys_pkey" // ALTER TABLE ONLY ai_gateway_keys ADD CONSTRAINT ai_gateway_keys_pkey PRIMARY KEY (id); - UniqueAIModelPricesPkey UniqueConstraint = "ai_model_prices_pkey" // ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model, source); - UniqueAIProviderKeysPkey UniqueConstraint = "ai_provider_keys_pkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_pkey PRIMARY KEY (id); - UniqueAIProvidersPkey UniqueConstraint = "ai_providers_pkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_pkey PRIMARY KEY (id); - UniqueAISeatStatePkey UniqueConstraint = "ai_seat_state_pkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); - UniqueAIUserDailySpendPkey UniqueConstraint = "ai_user_daily_spend_pkey" // ALTER TABLE ONLY ai_user_daily_spend ADD CONSTRAINT ai_user_daily_spend_pkey PRIMARY KEY (user_id, effective_group_id, day); - UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id); - UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id); - UniqueAibridgeToolUsagesPkey UniqueConstraint = "aibridge_tool_usages_pkey" // ALTER TABLE ONLY aibridge_tool_usages ADD CONSTRAINT aibridge_tool_usages_pkey PRIMARY KEY (id); - UniqueAibridgeUserPromptsPkey UniqueConstraint = "aibridge_user_prompts_pkey" // ALTER TABLE ONLY aibridge_user_prompts ADD CONSTRAINT aibridge_user_prompts_pkey PRIMARY KEY (id); - UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); - UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); - UniqueBoundaryLogsPkey UniqueConstraint = "boundary_logs_pkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_pkey PRIMARY KEY (id); - UniqueBoundarySessionsPkey UniqueConstraint = "boundary_sessions_pkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_pkey PRIMARY KEY (id); - UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); - UniqueChatContextResourcesPkey UniqueConstraint = "chat_context_resources_pkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_pkey PRIMARY KEY (chat_id, source); - UniqueChatDebugRunsPkey UniqueConstraint = "chat_debug_runs_pkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); - UniqueChatDebugStepsPkey UniqueConstraint = "chat_debug_steps_pkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id); - UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); - UniqueChatFileLinksChatIDFileIDKey UniqueConstraint = "chat_file_links_chat_id_file_id_key" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id); - UniqueChatFilesPkey UniqueConstraint = "chat_files_pkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id); - UniqueChatHeartbeatsPkey UniqueConstraint = "chat_heartbeats_pkey" // ALTER TABLE ONLY chat_heartbeats ADD CONSTRAINT chat_heartbeats_pkey PRIMARY KEY (chat_id, runner_id); - UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); - UniqueChatModelConfigsPkey UniqueConstraint = "chat_model_configs_pkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id); - UniqueChatQueuedMessagesPkey UniqueConstraint = "chat_queued_messages_pkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); - UniqueChatUsageLimitConfigPkey UniqueConstraint = "chat_usage_limit_config_pkey" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_pkey PRIMARY KEY (id); - UniqueChatUsageLimitConfigSingletonKey UniqueConstraint = "chat_usage_limit_config_singleton_key" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_singleton_key UNIQUE (singleton); - UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); - UniqueConnectionLogsPkey UniqueConstraint = "connection_logs_pkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); - UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); - UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); - UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); - UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); - UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); - UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); - UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); - UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); - UniqueGitSSHKeysPkey UniqueConstraint = "gitsshkeys_pkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); - UniqueGroupAIBudgetsPkey UniqueConstraint = "group_ai_budgets_pkey" // ALTER TABLE ONLY group_ai_budgets ADD CONSTRAINT group_ai_budgets_pkey PRIMARY KEY (group_id); - UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); - UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); - UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); - UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id); - UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); - UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); - UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); - UniqueMcpServerConfigsOrganizationIDSlugKey UniqueConstraint = "mcp_server_configs_organization_id_slug_key" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); - UniqueMcpServerConfigsPkey UniqueConstraint = "mcp_server_configs_pkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_pkey PRIMARY KEY (id); - UniqueMcpServerUserTokensMcpServerConfigIDUserIDKey UniqueConstraint = "mcp_server_user_tokens_mcp_server_config_id_user_id_key" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_user_id_key UNIQUE (mcp_server_config_id, user_id); - UniqueMcpServerUserTokensPkey UniqueConstraint = "mcp_server_user_tokens_pkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_pkey PRIMARY KEY (id); - UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); - UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); - UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); - UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); - UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppCodesSecretPrefixKey UniqueConstraint = "oauth2_provider_app_codes_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_secret_prefix_key UNIQUE (secret_prefix); - UniqueOauth2ProviderAppSecretsPkey UniqueConstraint = "oauth2_provider_app_secrets_pkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppSecretsSecretPrefixKey UniqueConstraint = "oauth2_provider_app_secrets_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_secret_prefix_key UNIQUE (secret_prefix); - UniqueOauth2ProviderAppTokensHashPrefixKey UniqueConstraint = "oauth2_provider_app_tokens_hash_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_hash_prefix_key UNIQUE (hash_prefix); - UniqueOauth2ProviderAppTokensPkey UniqueConstraint = "oauth2_provider_app_tokens_pkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_pkey PRIMARY KEY (id); - UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); - UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); - UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); - UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); - UniqueParameterSchemasPkey UniqueConstraint = "parameter_schemas_pkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_pkey PRIMARY KEY (id); - UniqueParameterValuesPkey UniqueConstraint = "parameter_values_pkey" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_pkey PRIMARY KEY (id); - UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); - UniqueProvisionerDaemonsPkey UniqueConstraint = "provisioner_daemons_pkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_pkey PRIMARY KEY (id); - UniqueProvisionerJobLogsPkey UniqueConstraint = "provisioner_job_logs_pkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_pkey PRIMARY KEY (id); - UniqueProvisionerJobsPkey UniqueConstraint = "provisioner_jobs_pkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_pkey PRIMARY KEY (id); - UniqueProvisionerKeysPkey UniqueConstraint = "provisioner_keys_pkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_pkey PRIMARY KEY (id); - UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); - UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id); - UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id); - UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); - UniqueTaskSnapshotsPkey UniqueConstraint = "task_snapshots_pkey" // ALTER TABLE ONLY task_snapshots ADD CONSTRAINT task_snapshots_pkey PRIMARY KEY (task_id); - UniqueTaskWorkspaceAppsPkey UniqueConstraint = "task_workspace_apps_pkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); - UniqueTasksPkey UniqueConstraint = "tasks_pkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); - UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key); - UniqueTelemetryLocksPkey UniqueConstraint = "telemetry_locks_pkey" // ALTER TABLE ONLY telemetry_locks ADD CONSTRAINT telemetry_locks_pkey PRIMARY KEY (event_type, period_ending_at); - UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id); - UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); - UniqueTemplateVersionPresetParametersPkey UniqueConstraint = "template_version_preset_parameters_pkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_parameters_pkey PRIMARY KEY (id); - UniqueTemplateVersionPresetPrebuildSchedulesPkey UniqueConstraint = "template_version_preset_prebuild_schedules_pkey" // ALTER TABLE ONLY template_version_preset_prebuild_schedules ADD CONSTRAINT template_version_preset_prebuild_schedules_pkey PRIMARY KEY (id); - UniqueTemplateVersionPresetsIDTemplateVersionIDKey UniqueConstraint = "template_version_presets_id_template_version_id_key" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_id_template_version_id_key UNIQUE (id, template_version_id); - UniqueTemplateVersionPresetsPkey UniqueConstraint = "template_version_presets_pkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_pkey PRIMARY KEY (id); - UniqueTemplateVersionTerraformValuesTemplateVersionIDKey UniqueConstraint = "template_version_terraform_values_template_version_id_key" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_template_version_id_key UNIQUE (template_version_id); - UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); - UniqueTemplateVersionWorkspaceTagsTemplateVersionIDKeyKey UniqueConstraint = "template_version_workspace_tags_template_version_id_key_key" // ALTER TABLE ONLY template_version_workspace_tags ADD CONSTRAINT template_version_workspace_tags_template_version_id_key_key UNIQUE (template_version_id, key); - UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); - UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); - UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); - UniqueUsageEventsDailyPkey UniqueConstraint = "usage_events_daily_pkey" // ALTER TABLE ONLY usage_events_daily ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type); - UniqueUsageEventsPkey UniqueConstraint = "usage_events_pkey" // ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); - UniqueUserAIBudgetOverridesPkey UniqueConstraint = "user_ai_budget_overrides_pkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id); - UniqueUserAIProviderKeysPkey UniqueConstraint = "user_ai_provider_keys_pkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id); - UniqueUserAIProviderKeysUserIDAIProviderIDKey UniqueConstraint = "user_ai_provider_keys_user_id_ai_provider_id_key" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_ai_provider_id_key UNIQUE (user_id, ai_provider_id); - UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); - UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); - UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); - UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); - UniqueUserSkillsPkey UniqueConstraint = "user_skills_pkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id); - UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); - UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); - UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); - UniqueWorkspaceAgentContextResourcesPkey UniqueConstraint = "workspace_agent_context_resources_pkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_pkey PRIMARY KEY (workspace_agent_id, source); - UniqueWorkspaceAgentContextSnapshotsPkey UniqueConstraint = "workspace_agent_context_snapshots_pkey" // ALTER TABLE ONLY workspace_agent_context_snapshots ADD CONSTRAINT workspace_agent_context_snapshots_pkey PRIMARY KEY (workspace_agent_id); - UniqueWorkspaceAgentDevcontainersPkey UniqueConstraint = "workspace_agent_devcontainers_pkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id); - UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); - UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id); - UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); - UniqueWorkspaceAgentPortSharePkey UniqueConstraint = "workspace_agent_port_share_pkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_pkey PRIMARY KEY (workspace_id, agent_name, port); - UniqueWorkspaceAgentScriptTimingsScriptIDStartedAtKey UniqueConstraint = "workspace_agent_script_timings_script_id_started_at_key" // ALTER TABLE ONLY workspace_agent_script_timings ADD CONSTRAINT workspace_agent_script_timings_script_id_started_at_key UNIQUE (script_id, started_at); - UniqueWorkspaceAgentScriptsIDKey UniqueConstraint = "workspace_agent_scripts_id_key" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_id_key UNIQUE (id); - UniqueWorkspaceAgentStartupLogsPkey UniqueConstraint = "workspace_agent_startup_logs_pkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); - UniqueWorkspaceAgentVolumeResourceMonitorsPkey UniqueConstraint = "workspace_agent_volume_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_pkey PRIMARY KEY (agent_id, path); - UniqueWorkspaceAgentsPkey UniqueConstraint = "workspace_agents_pkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); - UniqueWorkspaceAppAuditSessionsAgentIDAppIDUserIDIpUseKey UniqueConstraint = "workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key UNIQUE (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); - UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); - UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); - UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); - UniqueWorkspaceAppStatusesPkey UniqueConstraint = "workspace_app_statuses_pkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id); - UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); - UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id); - UniqueWorkspaceBuildOrchestrationsChildBuildIDKey UniqueConstraint = "workspace_build_orchestrations_child_build_id_key" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_build_id_key UNIQUE (child_build_id); - UniqueWorkspaceBuildOrchestrationsParentBuildIDKey UniqueConstraint = "workspace_build_orchestrations_parent_build_id_key" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_parent_build_id_key UNIQUE (parent_build_id); - UniqueWorkspaceBuildOrchestrationsPkey UniqueConstraint = "workspace_build_orchestrations_pkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_pkey PRIMARY KEY (id); - UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); - UniqueWorkspaceBuildsIDWorkspaceIDKey UniqueConstraint = "workspace_builds_id_workspace_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_id_workspace_id_key UNIQUE (id, workspace_id); - UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); - UniqueWorkspaceBuildsPkey UniqueConstraint = "workspace_builds_pkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id); - UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); - UniqueWorkspaceProxiesPkey UniqueConstraint = "workspace_proxies_pkey" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id); - UniqueWorkspaceProxiesRegionIDUnique UniqueConstraint = "workspace_proxies_region_id_unique" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_region_id_unique UNIQUE (region_id); - UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key); - UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id); - UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); - UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); - UniqueAIGatewayKeysHashedSecretIndex UniqueConstraint = "ai_gateway_keys_hashed_secret_idx" // CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret); - UniqueAIGatewayKeysNameIndex UniqueConstraint = "ai_gateway_keys_name_idx" // CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name)); - UniqueAIGatewayKeysSecretPrefixIndex UniqueConstraint = "ai_gateway_keys_secret_prefix_idx" // CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix); - UniqueAIProvidersNameUnique UniqueConstraint = "ai_providers_name_unique" // CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false); - UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); - UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); - UniqueIndexChatDebugStepsRunStep UniqueConstraint = "idx_chat_debug_steps_run_step" // CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number); - UniqueIndexChatModelConfigsSingleDefault UniqueConstraint = "idx_chat_model_configs_single_default" // CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree (organization_id) WHERE ((is_default = true) AND (deleted = false)); - UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); - UniqueIndexCustomRolesNameLowerOrganizationID UniqueConstraint = "idx_custom_roles_name_lower_organization_id" // CREATE UNIQUE INDEX idx_custom_roles_name_lower_organization_id ON custom_roles USING btree (lower(name), COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)); - UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); - UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); - UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); - UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); - UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text); - UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text)); - UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); - UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); - UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); - UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text)); - UniqueTasksOwnerIDNameUniqueIndex UniqueConstraint = "tasks_owner_id_name_unique_idx" // CREATE UNIQUE INDEX tasks_owner_id_name_unique_idx ON tasks USING btree (owner_id, lower(name)) WHERE (deleted_at IS NULL); - UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); - UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); - UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); - UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); - UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); - UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); - UniqueUserSkillsUserIDNameIndex UniqueConstraint = "user_skills_user_id_name_idx" // CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name); - UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text)); - UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); - UniqueWebpushSubscriptionsUserIDEndpointIndex UniqueConstraint = "webpush_subscriptions_user_id_endpoint_idx" // CREATE UNIQUE INDEX webpush_subscriptions_user_id_endpoint_idx ON webpush_subscriptions USING btree (user_id, endpoint); - UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); - UniqueWorkspaceProxiesLowerNameIndex UniqueConstraint = "workspace_proxies_lower_name_idx" // CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false); - UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false); + UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); + UniqueAIGatewayKeysPkey UniqueConstraint = "ai_gateway_keys_pkey" // ALTER TABLE ONLY ai_gateway_keys ADD CONSTRAINT ai_gateway_keys_pkey PRIMARY KEY (id); + UniqueAIModelPricesPkey UniqueConstraint = "ai_model_prices_pkey" // ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model, source); + UniqueAIProviderKeysPkey UniqueConstraint = "ai_provider_keys_pkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_pkey PRIMARY KEY (id); + UniqueAIProvidersPkey UniqueConstraint = "ai_providers_pkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_pkey PRIMARY KEY (id); + UniqueAISeatStatePkey UniqueConstraint = "ai_seat_state_pkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); + UniqueAIUserDailySpendPkey UniqueConstraint = "ai_user_daily_spend_pkey" // ALTER TABLE ONLY ai_user_daily_spend ADD CONSTRAINT ai_user_daily_spend_pkey PRIMARY KEY (user_id, effective_group_id, day); + UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id); + UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id); + UniqueAibridgeToolUsagesPkey UniqueConstraint = "aibridge_tool_usages_pkey" // ALTER TABLE ONLY aibridge_tool_usages ADD CONSTRAINT aibridge_tool_usages_pkey PRIMARY KEY (id); + UniqueAibridgeUserPromptsPkey UniqueConstraint = "aibridge_user_prompts_pkey" // ALTER TABLE ONLY aibridge_user_prompts ADD CONSTRAINT aibridge_user_prompts_pkey PRIMARY KEY (id); + UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); + UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueBoundaryLogsPkey UniqueConstraint = "boundary_logs_pkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_pkey PRIMARY KEY (id); + UniqueBoundarySessionsPkey UniqueConstraint = "boundary_sessions_pkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_pkey PRIMARY KEY (id); + UniqueBoundaryUsageStatsPkey UniqueConstraint = "boundary_usage_stats_pkey" // ALTER TABLE ONLY boundary_usage_stats ADD CONSTRAINT boundary_usage_stats_pkey PRIMARY KEY (replica_id); + UniqueChatContextResourcesPkey UniqueConstraint = "chat_context_resources_pkey" // ALTER TABLE ONLY chat_context_resources ADD CONSTRAINT chat_context_resources_pkey PRIMARY KEY (chat_id, source); + UniqueChatDebugRunsPkey UniqueConstraint = "chat_debug_runs_pkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_pkey PRIMARY KEY (id); + UniqueChatDebugStepsPkey UniqueConstraint = "chat_debug_steps_pkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_pkey PRIMARY KEY (id); + UniqueChatDiffStatusesPkey UniqueConstraint = "chat_diff_statuses_pkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_pkey PRIMARY KEY (chat_id); + UniqueChatFileLinksChatIDFileIDKey UniqueConstraint = "chat_file_links_chat_id_file_id_key" // ALTER TABLE ONLY chat_file_links ADD CONSTRAINT chat_file_links_chat_id_file_id_key UNIQUE (chat_id, file_id); + UniqueChatFilesPkey UniqueConstraint = "chat_files_pkey" // ALTER TABLE ONLY chat_files ADD CONSTRAINT chat_files_pkey PRIMARY KEY (id); + UniqueChatHeartbeatsPkey UniqueConstraint = "chat_heartbeats_pkey" // ALTER TABLE ONLY chat_heartbeats ADD CONSTRAINT chat_heartbeats_pkey PRIMARY KEY (chat_id, runner_id); + UniqueChatMessagesPkey UniqueConstraint = "chat_messages_pkey" // ALTER TABLE ONLY chat_messages ADD CONSTRAINT chat_messages_pkey PRIMARY KEY (id); + UniqueChatModelConfigsOrganizationIDIDKey UniqueConstraint = "chat_model_configs_organization_id_id_key" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_organization_id_id_key UNIQUE (organization_id, id); + UniqueChatModelConfigsPkey UniqueConstraint = "chat_model_configs_pkey" // ALTER TABLE ONLY chat_model_configs ADD CONSTRAINT chat_model_configs_pkey PRIMARY KEY (id); + UniqueChatOrganizationModelOverridesOrganizationIDContextKey UniqueConstraint = "chat_organization_model_overrides_organization_id_context_key" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_organization_id_context_key UNIQUE (organization_id, context); + UniqueChatOrganizationModelOverridesPkey UniqueConstraint = "chat_organization_model_overrides_pkey" // ALTER TABLE ONLY chat_organization_model_overrides ADD CONSTRAINT chat_organization_model_overrides_pkey PRIMARY KEY (id); + UniqueChatQueuedMessagesPkey UniqueConstraint = "chat_queued_messages_pkey" // ALTER TABLE ONLY chat_queued_messages ADD CONSTRAINT chat_queued_messages_pkey PRIMARY KEY (id); + UniqueChatUsageLimitConfigPkey UniqueConstraint = "chat_usage_limit_config_pkey" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_pkey PRIMARY KEY (id); + UniqueChatUsageLimitConfigSingletonKey UniqueConstraint = "chat_usage_limit_config_singleton_key" // ALTER TABLE ONLY chat_usage_limit_config ADD CONSTRAINT chat_usage_limit_config_singleton_key UNIQUE (singleton); + UniqueChatUserModelOverridesPkey UniqueConstraint = "chat_user_model_overrides_pkey" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_pkey PRIMARY KEY (id); + UniqueChatUserModelOverridesUserOrganizationContextKey UniqueConstraint = "chat_user_model_overrides_user_organization_context_key" // ALTER TABLE ONLY chat_user_model_overrides ADD CONSTRAINT chat_user_model_overrides_user_organization_context_key UNIQUE (user_id, organization_id, context); + UniqueChatsPkey UniqueConstraint = "chats_pkey" // ALTER TABLE ONLY chats ADD CONSTRAINT chats_pkey PRIMARY KEY (id); + UniqueConnectionLogsPkey UniqueConstraint = "connection_logs_pkey" // ALTER TABLE ONLY connection_logs ADD CONSTRAINT connection_logs_pkey PRIMARY KEY (id); + UniqueCryptoKeysPkey UniqueConstraint = "crypto_keys_pkey" // ALTER TABLE ONLY crypto_keys ADD CONSTRAINT crypto_keys_pkey PRIMARY KEY (feature, sequence); + UniqueCustomRolesUniqueKey UniqueConstraint = "custom_roles_unique_key" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_unique_key UNIQUE (name, organization_id); + UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); + UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); + UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); + UniqueFilesHashCreatedByKey UniqueConstraint = "files_hash_created_by_key" // ALTER TABLE ONLY files ADD CONSTRAINT files_hash_created_by_key UNIQUE (hash, created_by); + UniqueFilesPkey UniqueConstraint = "files_pkey" // ALTER TABLE ONLY files ADD CONSTRAINT files_pkey PRIMARY KEY (id); + UniqueGitAuthLinksProviderIDUserIDKey UniqueConstraint = "git_auth_links_provider_id_user_id_key" // ALTER TABLE ONLY external_auth_links ADD CONSTRAINT git_auth_links_provider_id_user_id_key UNIQUE (provider_id, user_id); + UniqueGitSSHKeysPkey UniqueConstraint = "gitsshkeys_pkey" // ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); + UniqueGroupAIBudgetsPkey UniqueConstraint = "group_ai_budgets_pkey" // ALTER TABLE ONLY group_ai_budgets ADD CONSTRAINT group_ai_budgets_pkey PRIMARY KEY (group_id); + UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); + UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); + UniqueGroupsPkey UniqueConstraint = "groups_pkey" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + UniqueInboxNotificationsPkey UniqueConstraint = "inbox_notifications_pkey" // ALTER TABLE ONLY inbox_notifications ADD CONSTRAINT inbox_notifications_pkey PRIMARY KEY (id); + UniqueJfrogXrayScansPkey UniqueConstraint = "jfrog_xray_scans_pkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_pkey PRIMARY KEY (agent_id, workspace_id); + UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); + UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); + UniqueMcpServerConfigsOrganizationIDSlugKey UniqueConstraint = "mcp_server_configs_organization_id_slug_key" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_organization_id_slug_key UNIQUE (organization_id, slug); + UniqueMcpServerConfigsPkey UniqueConstraint = "mcp_server_configs_pkey" // ALTER TABLE ONLY mcp_server_configs ADD CONSTRAINT mcp_server_configs_pkey PRIMARY KEY (id); + UniqueMcpServerUserTokensMcpServerConfigIDUserIDKey UniqueConstraint = "mcp_server_user_tokens_mcp_server_config_id_user_id_key" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_mcp_server_config_id_user_id_key UNIQUE (mcp_server_config_id, user_id); + UniqueMcpServerUserTokensPkey UniqueConstraint = "mcp_server_user_tokens_pkey" // ALTER TABLE ONLY mcp_server_user_tokens ADD CONSTRAINT mcp_server_user_tokens_pkey PRIMARY KEY (id); + UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); + UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); + UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); + UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); + UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppCodesSecretPrefixKey UniqueConstraint = "oauth2_provider_app_codes_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_secret_prefix_key UNIQUE (secret_prefix); + UniqueOauth2ProviderAppSecretsPkey UniqueConstraint = "oauth2_provider_app_secrets_pkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppSecretsSecretPrefixKey UniqueConstraint = "oauth2_provider_app_secrets_secret_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_secret_prefix_key UNIQUE (secret_prefix); + UniqueOauth2ProviderAppTokensHashPrefixKey UniqueConstraint = "oauth2_provider_app_tokens_hash_prefix_key" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_hash_prefix_key UNIQUE (hash_prefix); + UniqueOauth2ProviderAppTokensPkey UniqueConstraint = "oauth2_provider_app_tokens_pkey" // ALTER TABLE ONLY oauth2_provider_app_tokens ADD CONSTRAINT oauth2_provider_app_tokens_pkey PRIMARY KEY (id); + UniqueOauth2ProviderAppsPkey UniqueConstraint = "oauth2_provider_apps_pkey" // ALTER TABLE ONLY oauth2_provider_apps ADD CONSTRAINT oauth2_provider_apps_pkey PRIMARY KEY (id); + UniqueOrganizationMembersPkey UniqueConstraint = "organization_members_pkey" // ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_pkey PRIMARY KEY (organization_id, user_id); + UniqueOrganizationsPkey UniqueConstraint = "organizations_pkey" // ALTER TABLE ONLY organizations ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); + UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); + UniqueParameterSchemasPkey UniqueConstraint = "parameter_schemas_pkey" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_pkey PRIMARY KEY (id); + UniqueParameterValuesPkey UniqueConstraint = "parameter_values_pkey" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_pkey PRIMARY KEY (id); + UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); + UniqueProvisionerDaemonsPkey UniqueConstraint = "provisioner_daemons_pkey" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_pkey PRIMARY KEY (id); + UniqueProvisionerJobLogsPkey UniqueConstraint = "provisioner_job_logs_pkey" // ALTER TABLE ONLY provisioner_job_logs ADD CONSTRAINT provisioner_job_logs_pkey PRIMARY KEY (id); + UniqueProvisionerJobsPkey UniqueConstraint = "provisioner_jobs_pkey" // ALTER TABLE ONLY provisioner_jobs ADD CONSTRAINT provisioner_jobs_pkey PRIMARY KEY (id); + UniqueProvisionerKeysPkey UniqueConstraint = "provisioner_keys_pkey" // ALTER TABLE ONLY provisioner_keys ADD CONSTRAINT provisioner_keys_pkey PRIMARY KEY (id); + UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key); + UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id); + UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id); + UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id); + UniqueTaskSnapshotsPkey UniqueConstraint = "task_snapshots_pkey" // ALTER TABLE ONLY task_snapshots ADD CONSTRAINT task_snapshots_pkey PRIMARY KEY (task_id); + UniqueTaskWorkspaceAppsPkey UniqueConstraint = "task_workspace_apps_pkey" // ALTER TABLE ONLY task_workspace_apps ADD CONSTRAINT task_workspace_apps_pkey PRIMARY KEY (task_id, workspace_build_number); + UniqueTasksPkey UniqueConstraint = "tasks_pkey" // ALTER TABLE ONLY tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); + UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key); + UniqueTelemetryLocksPkey UniqueConstraint = "telemetry_locks_pkey" // ALTER TABLE ONLY telemetry_locks ADD CONSTRAINT telemetry_locks_pkey PRIMARY KEY (event_type, period_ending_at); + UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id); + UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name); + UniqueTemplateVersionPresetParametersPkey UniqueConstraint = "template_version_preset_parameters_pkey" // ALTER TABLE ONLY template_version_preset_parameters ADD CONSTRAINT template_version_preset_parameters_pkey PRIMARY KEY (id); + UniqueTemplateVersionPresetPrebuildSchedulesPkey UniqueConstraint = "template_version_preset_prebuild_schedules_pkey" // ALTER TABLE ONLY template_version_preset_prebuild_schedules ADD CONSTRAINT template_version_preset_prebuild_schedules_pkey PRIMARY KEY (id); + UniqueTemplateVersionPresetsIDTemplateVersionIDKey UniqueConstraint = "template_version_presets_id_template_version_id_key" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_id_template_version_id_key UNIQUE (id, template_version_id); + UniqueTemplateVersionPresetsPkey UniqueConstraint = "template_version_presets_pkey" // ALTER TABLE ONLY template_version_presets ADD CONSTRAINT template_version_presets_pkey PRIMARY KEY (id); + UniqueTemplateVersionTerraformValuesTemplateVersionIDKey UniqueConstraint = "template_version_terraform_values_template_version_id_key" // ALTER TABLE ONLY template_version_terraform_values ADD CONSTRAINT template_version_terraform_values_template_version_id_key UNIQUE (template_version_id); + UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name); + UniqueTemplateVersionWorkspaceTagsTemplateVersionIDKeyKey UniqueConstraint = "template_version_workspace_tags_template_version_id_key_key" // ALTER TABLE ONLY template_version_workspace_tags ADD CONSTRAINT template_version_workspace_tags_template_version_id_key_key UNIQUE (template_version_id, key); + UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); + UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); + UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); + UniqueUsageEventsDailyPkey UniqueConstraint = "usage_events_daily_pkey" // ALTER TABLE ONLY usage_events_daily ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type); + UniqueUsageEventsPkey UniqueConstraint = "usage_events_pkey" // ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); + UniqueUserAIBudgetOverridesPkey UniqueConstraint = "user_ai_budget_overrides_pkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id); + UniqueUserAIProviderKeysPkey UniqueConstraint = "user_ai_provider_keys_pkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id); + UniqueUserAIProviderKeysUserIDAIProviderIDKey UniqueConstraint = "user_ai_provider_keys_user_id_ai_provider_id_key" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_ai_provider_id_key UNIQUE (user_id, ai_provider_id); + UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); + UniqueUserDeletedPkey UniqueConstraint = "user_deleted_pkey" // ALTER TABLE ONLY user_deleted ADD CONSTRAINT user_deleted_pkey PRIMARY KEY (id); + UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); + UniqueUserSecretsPkey UniqueConstraint = "user_secrets_pkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_pkey PRIMARY KEY (id); + UniqueUserSkillsPkey UniqueConstraint = "user_skills_pkey" // ALTER TABLE ONLY user_skills ADD CONSTRAINT user_skills_pkey PRIMARY KEY (id); + UniqueUserStatusChangesPkey UniqueConstraint = "user_status_changes_pkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_pkey PRIMARY KEY (id); + UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); + UniqueWebpushSubscriptionsPkey UniqueConstraint = "webpush_subscriptions_pkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_pkey PRIMARY KEY (id); + UniqueWorkspaceAgentContextResourcesPkey UniqueConstraint = "workspace_agent_context_resources_pkey" // ALTER TABLE ONLY workspace_agent_context_resources ADD CONSTRAINT workspace_agent_context_resources_pkey PRIMARY KEY (workspace_agent_id, source); + UniqueWorkspaceAgentContextSnapshotsPkey UniqueConstraint = "workspace_agent_context_snapshots_pkey" // ALTER TABLE ONLY workspace_agent_context_snapshots ADD CONSTRAINT workspace_agent_context_snapshots_pkey PRIMARY KEY (workspace_agent_id); + UniqueWorkspaceAgentDevcontainersPkey UniqueConstraint = "workspace_agent_devcontainers_pkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_pkey PRIMARY KEY (id); + UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); + UniqueWorkspaceAgentMemoryResourceMonitorsPkey UniqueConstraint = "workspace_agent_memory_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_pkey PRIMARY KEY (agent_id); + UniqueWorkspaceAgentMetadataPkey UniqueConstraint = "workspace_agent_metadata_pkey" // ALTER TABLE ONLY workspace_agent_metadata ADD CONSTRAINT workspace_agent_metadata_pkey PRIMARY KEY (workspace_agent_id, key); + UniqueWorkspaceAgentPortSharePkey UniqueConstraint = "workspace_agent_port_share_pkey" // ALTER TABLE ONLY workspace_agent_port_share ADD CONSTRAINT workspace_agent_port_share_pkey PRIMARY KEY (workspace_id, agent_name, port); + UniqueWorkspaceAgentScriptTimingsScriptIDStartedAtKey UniqueConstraint = "workspace_agent_script_timings_script_id_started_at_key" // ALTER TABLE ONLY workspace_agent_script_timings ADD CONSTRAINT workspace_agent_script_timings_script_id_started_at_key UNIQUE (script_id, started_at); + UniqueWorkspaceAgentScriptsIDKey UniqueConstraint = "workspace_agent_scripts_id_key" // ALTER TABLE ONLY workspace_agent_scripts ADD CONSTRAINT workspace_agent_scripts_id_key UNIQUE (id); + UniqueWorkspaceAgentStartupLogsPkey UniqueConstraint = "workspace_agent_startup_logs_pkey" // ALTER TABLE ONLY workspace_agent_logs ADD CONSTRAINT workspace_agent_startup_logs_pkey PRIMARY KEY (id); + UniqueWorkspaceAgentVolumeResourceMonitorsPkey UniqueConstraint = "workspace_agent_volume_resource_monitors_pkey" // ALTER TABLE ONLY workspace_agent_volume_resource_monitors ADD CONSTRAINT workspace_agent_volume_resource_monitors_pkey PRIMARY KEY (agent_id, path); + UniqueWorkspaceAgentsPkey UniqueConstraint = "workspace_agents_pkey" // ALTER TABLE ONLY workspace_agents ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id); + UniqueWorkspaceAppAuditSessionsAgentIDAppIDUserIDIpUseKey UniqueConstraint = "workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_agent_id_app_id_user_id_ip_use_key UNIQUE (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); + UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); + UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); + UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); + UniqueWorkspaceAppStatusesPkey UniqueConstraint = "workspace_app_statuses_pkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id); + UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); + UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id); + UniqueWorkspaceBuildOrchestrationsChildBuildIDKey UniqueConstraint = "workspace_build_orchestrations_child_build_id_key" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_child_build_id_key UNIQUE (child_build_id); + UniqueWorkspaceBuildOrchestrationsParentBuildIDKey UniqueConstraint = "workspace_build_orchestrations_parent_build_id_key" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_parent_build_id_key UNIQUE (parent_build_id); + UniqueWorkspaceBuildOrchestrationsPkey UniqueConstraint = "workspace_build_orchestrations_pkey" // ALTER TABLE ONLY workspace_build_orchestrations ADD CONSTRAINT workspace_build_orchestrations_pkey PRIMARY KEY (id); + UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); + UniqueWorkspaceBuildsIDWorkspaceIDKey UniqueConstraint = "workspace_builds_id_workspace_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_id_workspace_id_key UNIQUE (id, workspace_id); + UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id); + UniqueWorkspaceBuildsPkey UniqueConstraint = "workspace_builds_pkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_pkey PRIMARY KEY (id); + UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number); + UniqueWorkspaceProxiesPkey UniqueConstraint = "workspace_proxies_pkey" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_pkey PRIMARY KEY (id); + UniqueWorkspaceProxiesRegionIDUnique UniqueConstraint = "workspace_proxies_region_id_unique" // ALTER TABLE ONLY workspace_proxies ADD CONSTRAINT workspace_proxies_region_id_unique UNIQUE (region_id); + UniqueWorkspaceResourceMetadataName UniqueConstraint = "workspace_resource_metadata_name" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_name UNIQUE (workspace_resource_id, key); + UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id); + UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); + UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); + UniqueAIGatewayKeysHashedSecretIndex UniqueConstraint = "ai_gateway_keys_hashed_secret_idx" // CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret); + UniqueAIGatewayKeysNameIndex UniqueConstraint = "ai_gateway_keys_name_idx" // CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name)); + UniqueAIGatewayKeysSecretPrefixIndex UniqueConstraint = "ai_gateway_keys_secret_prefix_idx" // CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix); + UniqueAIProvidersNameUnique UniqueConstraint = "ai_providers_name_unique" // CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false); + UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); + UniqueIndexChatDebugStepsRunStep UniqueConstraint = "idx_chat_debug_steps_run_step" // CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number); + UniqueIndexChatModelConfigsSingleDefault UniqueConstraint = "idx_chat_model_configs_single_default" // CREATE UNIQUE INDEX idx_chat_model_configs_single_default ON chat_model_configs USING btree (organization_id) WHERE ((is_default = true) AND (deleted = false)); + UniqueIndexConnectionLogsConnectionIDWorkspaceIDAgentName UniqueConstraint = "idx_connection_logs_connection_id_workspace_id_agent_name" // CREATE UNIQUE INDEX idx_connection_logs_connection_id_workspace_id_agent_name ON connection_logs USING btree (connection_id, workspace_id, agent_name); + UniqueIndexCustomRolesNameLowerOrganizationID UniqueConstraint = "idx_custom_roles_name_lower_organization_id" // CREATE UNIQUE INDEX idx_custom_roles_name_lower_organization_id ON custom_roles USING btree (lower(name), COALESCE(organization_id, '00000000-0000-0000-0000-000000000000'::uuid)); + UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)) WHERE (deleted = false); + UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); + UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); + UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); + UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text); + UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text)); + UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); + UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); + UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); + UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text)); + UniqueTasksOwnerIDNameUniqueIndex UniqueConstraint = "tasks_owner_id_name_unique_idx" // CREATE UNIQUE INDEX tasks_owner_id_name_unique_idx ON tasks USING btree (owner_id, lower(name)) WHERE (deleted_at IS NULL); + UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id); + UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false); + UniqueUserLinksLinkedIDLoginTypeIndex UniqueConstraint = "user_links_linked_id_login_type_idx" // CREATE UNIQUE INDEX user_links_linked_id_login_type_idx ON user_links USING btree (linked_id, login_type) WHERE (linked_id <> ''::text); + UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); + UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); + UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); + UniqueUserSkillsUserIDNameIndex UniqueConstraint = "user_skills_user_id_name_idx" // CREATE UNIQUE INDEX user_skills_user_id_name_idx ON user_skills USING btree (user_id, name); + UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text)); + UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); + UniqueWebpushSubscriptionsUserIDEndpointIndex UniqueConstraint = "webpush_subscriptions_user_id_endpoint_idx" // CREATE UNIQUE INDEX webpush_subscriptions_user_id_endpoint_idx ON webpush_subscriptions USING btree (user_id, endpoint); + UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); + UniqueWorkspaceProxiesLowerNameIndex UniqueConstraint = "workspace_proxies_lower_name_idx" // CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false); + UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false); ) From 38c261b52f14f0b65a23d88e31fd0201c55d9534 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:48:08 +0000 Subject: [PATCH 02/16] feat(coderd/x/chatd): scope model overrides by organization --- coderd/x/chatd/advisor_internal_test.go | 100 +++++--- coderd/x/chatd/chatd.go | 58 +++-- coderd/x/chatd/compaction_override.go | 28 ++- .../compaction_override_internal_test.go | 70 ++---- coderd/x/chatd/configcache.go | 31 ++- coderd/x/chatd/configcache_internal_test.go | 5 +- coderd/x/chatd/subagent.go | 224 +++++++++++++----- coderd/x/chatd/subagent_internal_test.go | 153 ++++++------ coderd/x/chatd/title_override.go | 63 ++--- .../x/chatd/title_override_internal_test.go | 161 +++---------- 10 files changed, 444 insertions(+), 449 deletions(-) diff --git a/coderd/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go index 92050bf45a8..5b95831dd68 100644 --- a/coderd/x/chatd/advisor_internal_test.go +++ b/coderd/x/chatd/advisor_internal_test.go @@ -31,12 +31,36 @@ import ( type advisorOverrideStubStore struct { database.Store - getEnabledChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error) - getChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error) - getAIProviderByID func(context.Context, uuid.UUID) (database.AIProvider, error) - getAIProviders func(context.Context, database.GetAIProvidersParams) ([]database.AIProvider, error) - getAIProviderKeysByProviderID func(context.Context, uuid.UUID) ([]database.AIProviderKey, error) - getAIProviderKeysByProviderIDs func(context.Context, []uuid.UUID) ([]database.AIProviderKey, error) + getChatOrganizationModelOverride func(context.Context, database.GetChatOrganizationModelOverrideParams) (database.ChatOrganizationModelOverride, error) + advisorModelConfigID uuid.UUID + advisorReasoningEffort *string + getEnabledChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error) + getChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error) + getAIProviderByID func(context.Context, uuid.UUID) (database.AIProvider, error) + getAIProviders func(context.Context, database.GetAIProvidersParams) ([]database.AIProvider, error) + getAIProviderKeysByProviderID func(context.Context, uuid.UUID) ([]database.AIProviderKey, error) + getAIProviderKeysByProviderIDs func(context.Context, []uuid.UUID) ([]database.AIProviderKey, error) +} + +func (s *advisorOverrideStubStore) GetChatOrganizationModelOverride( + ctx context.Context, + params database.GetChatOrganizationModelOverrideParams, +) (database.ChatOrganizationModelOverride, error) { + if s.getChatOrganizationModelOverride != nil { + return s.getChatOrganizationModelOverride(ctx, params) + } + if s.advisorModelConfigID == uuid.Nil { + return database.ChatOrganizationModelOverride{}, sql.ErrNoRows + } + override := database.ChatOrganizationModelOverride{ + OrganizationID: params.OrganizationID, + Context: params.Context, + ModelConfigID: s.advisorModelConfigID, + } + if s.advisorReasoningEffort != nil { + override.ReasoningEffort = sql.NullString{String: *s.advisorReasoningEffort, Valid: true} + } + return override, nil } func (s *advisorOverrideStubStore) GetEnabledChatModelConfigByID( @@ -114,6 +138,22 @@ func newAdvisorTestServer( const advisorTestMaxOutputTokens = int64(16384) +func resolveAdvisorModelOverrideForTest( + ctx context.Context, + p *Server, + chat database.Chat, + cfg codersdk.AdvisorConfig, + maxOutputTokens int64, + modelOpts modelBuildOptions, + logger slog.Logger, +) (resolvedModelCall, bool, error) { + if store, ok := p.db.(*advisorOverrideStubStore); ok { + store.advisorModelConfigID = cfg.ModelConfigID + store.advisorReasoningEffort = cfg.ReasoningEffort + } + return p.resolveAdvisorModelOverride(ctx, chat, maxOutputTokens, modelOpts, logger) +} + // advisorChatModelFixture wires a chat whose LastModelConfigID resolves // through the config cache to an enabled, provider-linked model config, so // the advisor chat-model path can resolve without an override. @@ -121,22 +161,24 @@ func advisorChatModelFixture(t *testing.T, options json.RawMessage) (database.Ch t.Helper() configID := uuid.New() providerID := uuid.New() + organizationID := uuid.New() store := &advisorOverrideStubStore{ getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) { return database.ChatModelConfig{ - ID: configID, - Model: "gpt-5.2", - Enabled: true, - Options: options, - DisplayName: "gpt-5.2", - AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, + ID: configID, + Model: "gpt-5.2", + Enabled: true, + Options: options, + DisplayName: "gpt-5.2", + AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, + OrganizationID: organizationID, }, nil }, getAIProviderByID: func(context.Context, uuid.UUID) (database.AIProvider, error) { return aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil }, } - return database.Chat{LastModelConfigID: configID}, store + return database.Chat{LastModelConfigID: configID, OrganizationID: organizationID}, store } func advisorTestTransportFactory() *aibridgeTestFactory { @@ -155,8 +197,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { requireChatModel := func(t *testing.T, p *Server, advisorCfg codersdk.AdvisorConfig) { t.Helper() ctx := testutil.Context(t, testutil.WaitShort) - resolved, ok, err := p.resolveAdvisorModelOverride( - ctx, + resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, + p, database.Chat{}, advisorCfg, advisorTestMaxOutputTokens, @@ -227,8 +269,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { } p := newAdvisorTestServer(ctx, t, store) - resolved, ok, err := p.resolveAdvisorModelOverride( - ctx, + resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, + p, database.Chat{OrganizationID: chatOrgID}, codersdk.AdvisorConfig{ModelConfigID: configID}, advisorTestMaxOutputTokens, @@ -348,8 +390,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { p := newAdvisorTestServer(ctx, t, store) p.aibridgeTransportFactory = aibridgeTestFactoryPointer(advisorTestTransportFactory()) - resolved, ok, err := p.resolveAdvisorModelOverride( - ctx, + resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, + p, database.Chat{}, codersdk.AdvisorConfig{ ModelConfigID: configID, @@ -408,8 +450,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { p := newAdvisorTestServer(ctx, t, store) p.aibridgeTransportFactory = aibridgeTestFactoryPointer(advisorTestTransportFactory()) - resolved, ok, err := p.resolveAdvisorModelOverride( - ctx, + resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, + p, database.Chat{}, codersdk.AdvisorConfig{ModelConfigID: configID}, advisorTestMaxOutputTokens, @@ -453,8 +495,8 @@ func TestResolveAdvisorModelOverridePromotesAIBridgeErrors(t *testing.T) { p := newAdvisorTestServer(ctx, t, store) ctx = aibridge.WithDelegatedAPIKeyID(ctx, uuid.NewString()) - resolved, ok, err := p.resolveAdvisorModelOverride( - ctx, + resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, + p, database.Chat{ID: uuid.New()}, codersdk.AdvisorConfig{ModelConfigID: configID}, advisorTestMaxOutputTokens, @@ -557,7 +599,7 @@ func TestNewAdvisorRuntime(t *testing.T) { logger := slog.Make() - newChatModelRuntime := func(t *testing.T, advisorCfg codersdk.AdvisorConfig, options json.RawMessage) *chatadvisor.Runtime { + newChatModelRuntime := func(t *testing.T, advisorCfg advisorRuntimeConfig, options json.RawMessage) *chatadvisor.Runtime { t.Helper() ctx := testutil.Context(t, testutil.WaitShort) chat, store := advisorChatModelFixture(t, options) @@ -578,7 +620,7 @@ func TestNewAdvisorRuntime(t *testing.T) { t.Run("ZeroMaxUsesDefaultsToMaxChatSteps", func(t *testing.T) { t.Parallel() - rt := newChatModelRuntime(t, codersdk.AdvisorConfig{ + rt := newChatModelRuntime(t, advisorRuntimeConfig{ Enabled: true, MaxUsesPerRun: 0, MaxOutputTokens: 16384, @@ -599,7 +641,7 @@ func TestNewAdvisorRuntime(t *testing.T) { rt, err := p.newAdvisorRuntime( ctx, database.Chat{}, - codersdk.AdvisorConfig{ + advisorRuntimeConfig{ Enabled: true, MaxUsesPerRun: -1, MaxOutputTokens: 16384, @@ -614,7 +656,7 @@ func TestNewAdvisorRuntime(t *testing.T) { t.Run("ZeroMaxOutputTokensDefaults", func(t *testing.T) { t.Parallel() - rt := newChatModelRuntime(t, codersdk.AdvisorConfig{ + rt := newChatModelRuntime(t, advisorRuntimeConfig{ Enabled: true, MaxUsesPerRun: 3, MaxOutputTokens: 0, @@ -639,7 +681,7 @@ func TestNewAdvisorRuntime(t *testing.T) { rt, err := p.newAdvisorRuntime( ctx, database.Chat{LastModelConfigID: uuid.New()}, - codersdk.AdvisorConfig{ + advisorRuntimeConfig{ Enabled: true, MaxUsesPerRun: 3, MaxOutputTokens: 16384, @@ -667,7 +709,7 @@ func TestNewAdvisorRuntime(t *testing.T) { }) require.NoError(t, err) - rt := newChatModelRuntime(t, codersdk.AdvisorConfig{ + rt := newChatModelRuntime(t, advisorRuntimeConfig{ Enabled: true, MaxUsesPerRun: 3, MaxOutputTokens: 16384, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index d0214d41554..6d945c431b2 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -210,11 +210,11 @@ type Server struct { chatHeartbeatInterval time.Duration } -func (p *Server) loadAdvisorConfig(ctx context.Context, logger slog.Logger) codersdk.AdvisorConfig { +func (p *Server) loadAdvisorConfig(ctx context.Context, logger slog.Logger) advisorRuntimeConfig { cfg, err := p.configCache.AdvisorConfig(ctx) if err != nil { logger.Warn(ctx, "failed to load advisor config", slog.Error(err)) - return codersdk.AdvisorConfig{} + return advisorRuntimeConfig{} } return cfg } @@ -245,19 +245,36 @@ func isAdvisorGuidanceMessage(msg fantasy.Message) bool { return strings.TrimSpace(text.Text) == strings.TrimSpace(chatadvisor.ParentGuidanceBlock) } -// resolveAdvisorModelOverride resolves the configured advisor override -// model. ok is false when no override is configured or the override is -// unavailable, in which case the advisor uses the chat model. An error is -// a hard failure: the override has a linked provider and failed to build. +const advisorOverrideContext = "advisor" + +// resolveAdvisorModelOverride resolves the advisor model override for the +// chat's organization. Missing or unusable overrides fall back to the chat +// model. Linked-provider route and client failures remain hard failures. func (p *Server) resolveAdvisorModelOverride( ctx context.Context, chat database.Chat, - advisorCfg codersdk.AdvisorConfig, maxOutputTokens int64, modelOpts modelBuildOptions, logger slog.Logger, ) (resolvedModelCall, bool, error) { - if advisorCfg.ModelConfigID == uuid.Nil { + //nolint:gocritic // Chatd reads organization-scoped runtime configuration. + override, err := p.db.GetChatOrganizationModelOverride( + dbauthz.AsChatd(ctx), + database.GetChatOrganizationModelOverrideParams{ + OrganizationID: chat.OrganizationID, + Context: advisorOverrideContext, + }, + ) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return resolvedModelCall{}, false, nil + } + logger.Warn( + ctx, + "failed to load advisor model override, continuing with chat model", + slog.F("organization_id", chat.OrganizationID), + slog.Error(err), + ) return resolvedModelCall{}, false, nil } @@ -266,17 +283,14 @@ func (p *Server) resolveAdvisorModelOverride( logger.Warn( ctx, "failed to load advisor model authorization, continuing with chat model", - slog.F("model_config_id", advisorCfg.ModelConfigID), + slog.F("model_config_id", override.ModelConfigID), slog.Error(modelCtxErr), ) return resolvedModelCall{}, false, nil } - // Re-read the override instead of using the cache so disabled models - // or providers stop routing advisor prompts immediately. - overrideConfig, err := p.db.GetEnabledChatModelConfigByID( - modelCtx, - advisorCfg.ModelConfigID, - ) + // Re-read the model row for every runtime so disabled models or providers + // stop routing advisor prompts immediately. + overrideConfig, err := p.db.GetEnabledChatModelConfigByID(modelCtx, override.ModelConfigID) if err == nil && overrideConfig.OrganizationID != chat.OrganizationID { err = sql.ErrNoRows } @@ -285,14 +299,14 @@ func (p *Server) resolveAdvisorModelOverride( logger.Warn( ctx, "advisor model config is disabled or unavailable, continuing with chat model", - slog.F("model_config_id", advisorCfg.ModelConfigID), + slog.F("model_config_id", override.ModelConfigID), ) return resolvedModelCall{}, false, nil } logger.Warn( ctx, "failed to resolve advisor model config, continuing with chat model", - slog.F("model_config_id", advisorCfg.ModelConfigID), + slog.F("model_config_id", override.ModelConfigID), slog.Error(err), ) return resolvedModelCall{}, false, nil @@ -302,13 +316,11 @@ func (p *Server) resolveAdvisorModelOverride( purpose: "advisor", chat: chat, explicitConfig: &overrideConfig, - requestedEffort: advisorCfg.ReasoningEffort, + requestedEffort: nullStringPtr(override.ReasoningEffort), maxOutputTokens: ptr.Ref(maxOutputTokens), buildOptions: modelOpts, }) if err != nil { - // Malformed options always fall back; route and client errors are - // hard failures only when the config has a linked provider. var parseErr modelCallConfigParseError if overrideConfig.AIProviderID.Valid && !xerrors.As(err, &parseErr) { return resolvedModelCall{}, false, xerrors.Errorf("resolve advisor override model: %w", err) @@ -316,19 +328,18 @@ func (p *Server) resolveAdvisorModelOverride( logger.Warn( ctx, "failed to resolve advisor override model, continuing with chat model", - slog.F("model_config_id", advisorCfg.ModelConfigID), + slog.F("model_config_id", override.ModelConfigID), slog.Error(err), ) return resolvedModelCall{}, false, nil } - return resolved, true, nil } func (p *Server) newAdvisorRuntime( ctx context.Context, chat database.Chat, - advisorCfg codersdk.AdvisorConfig, + advisorCfg advisorRuntimeConfig, modelOpts modelBuildOptions, logger slog.Logger, ) (*chatadvisor.Runtime, error) { @@ -357,7 +368,6 @@ func (p *Server) newAdvisorRuntime( advisor, ok, err := p.resolveAdvisorModelOverride( ctx, chat, - advisorCfg, maxOutputTokens, modelOpts, logger, diff --git a/coderd/x/chatd/compaction_override.go b/coderd/x/chatd/compaction_override.go index 0aa1335c152..31e015c061b 100644 --- a/coderd/x/chatd/compaction_override.go +++ b/coderd/x/chatd/compaction_override.go @@ -2,6 +2,7 @@ package chatd import ( "context" + "database/sql" "github.com/google/uuid" "golang.org/x/xerrors" @@ -16,17 +17,21 @@ const compactionOverrideContext = "compaction" func readCompactionModelOverride( ctx context.Context, db database.Store, -) (string, error) { + organizationID uuid.UUID, +) (database.ChatOrganizationModelOverride, error) { //nolint:gocritic // Chatd is internal, not a user, so this read uses AsChatd. chatdCtx := dbauthz.AsChatd(ctx) - raw, err := db.GetChatCompactionModelOverride(chatdCtx) + override, err := db.GetChatOrganizationModelOverride(chatdCtx, database.GetChatOrganizationModelOverrideParams{ + OrganizationID: organizationID, + Context: compactionOverrideContext, + }) if err != nil { - return "", xerrors.Errorf( + return database.ChatOrganizationModelOverride{}, xerrors.Errorf( "get chat compaction model override: %w", err, ) } - return raw, nil + return override, nil } // resolvedCompactionOverride is the compaction override resolved at @@ -42,8 +47,8 @@ type resolvedCompactionOverride struct { ResolvedModel string } -// resolveCompactionOverrideConfig resolves the stored deployment-wide -// compaction model override. Unset, malformed, stale, and credential-less +// resolveCompactionOverrideConfig resolves the stored organization-scoped +// compaction model override. Unset, stale, and credential-less // overrides fall back to the chat model (nil override). This runs on every // generation prepare because the override's context limit feeds the // compaction trigger; the model client is built only when compaction runs. @@ -51,21 +56,24 @@ func (p *Server) resolveCompactionOverrideConfig( ctx context.Context, chat database.Chat, ) (*resolvedCompactionOverride, error) { - raw, err := readCompactionModelOverride(ctx, p.db) + override, err := readCompactionModelOverride(ctx, p.db, chat.OrganizationID) if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // No row means use the chat model. + } return nil, xerrors.Errorf( "read compaction model override: %w", err, ) } - modelConfig, providerName, overrideEffort, overrideSet, err := p.resolveConfiguredModelOverride( + modelConfig, providerName, overrideEffort, overrideSet, err := p.resolveOrganizationModelOverride( ctx, compactionOverrideContext, - raw, + override, chat.OwnerID, func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) { - return p.resolveModelConfigForOrganization(ctx, chat.OwnerID, chat.OrganizationID, modelConfigID) + return p.resolveModelConfigAndNormalizedProvider(ctx, chat.OwnerID, modelConfigID) }, func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) diff --git a/coderd/x/chatd/compaction_override_internal_test.go b/coderd/x/chatd/compaction_override_internal_test.go index c01d8973b19..459b18801cf 100644 --- a/coderd/x/chatd/compaction_override_internal_test.go +++ b/coderd/x/chatd/compaction_override_internal_test.go @@ -17,32 +17,23 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestResolveCompactionOverrideConfig_ForeignConfigFallsBack(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) - chat.OrganizationID = uuid.New() - overrideConfig := titleOverrideModelConfig("gpt-4.1", true) - overrideConfig.OrganizationID = uuid.New() - overrideProviderID := uuid.New() - overrideConfig.AIProviderID = uuid.NullUUID{UUID: overrideProviderID, Valid: true} - - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) - db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), overrideProviderID).Return(database.AIProvider{ - ID: overrideProviderID, - Type: database.AIProviderTypeOpenai, - Enabled: true, - }, nil) +func compactionOverrideParams(chat database.Chat) database.GetChatOrganizationModelOverrideParams { + return database.GetChatOrganizationModelOverrideParams{ + OrganizationID: chat.OrganizationID, + Context: compactionOverrideContext, + } +} - server := titleOverrideTestServer(db, logger) - override, err := server.resolveCompactionOverrideConfig(ctx, chat) - require.NoError(t, err) - require.Nil(t, override) +func orgModelOverride(chat database.Chat, context string, modelConfigID uuid.UUID, effort string) database.ChatOrganizationModelOverride { + override := database.ChatOrganizationModelOverride{ + OrganizationID: chat.OrganizationID, + Context: context, + ModelConfigID: modelConfigID, + } + if effort != "" { + override.ReasoningEffort = sql.NullString{String: effort, Valid: true} + } + return override } func TestResolveCompactionOverrideConfig_Unset(t *testing.T) { @@ -54,7 +45,7 @@ func TestResolveCompactionOverrideConfig_Unset(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) chat, _ := titleOverrideTestChatAndMessages(t) - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) server := titleOverrideTestServer(db, logger) override, err := server.resolveCompactionOverrideConfig(ctx, chat) @@ -71,7 +62,7 @@ func TestResolveCompactionOverrideConfig_ReadDBError(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) chat, _ := titleOverrideTestChatAndMessages(t) - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return("", sql.ErrConnDone) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrConnDone) server := titleOverrideTestServer(db, logger) override, err := server.resolveCompactionOverrideConfig(ctx, chat) @@ -80,23 +71,6 @@ func TestResolveCompactionOverrideConfig_ReadDBError(t *testing.T) { require.Nil(t, override) } -func TestResolveCompactionOverrideConfig_MalformedFallsBack(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().GetChatCompactionModelOverride(gomock.Any()).Return("not-a-uuid", nil) - - server := titleOverrideTestServer(db, logger) - override, err := server.resolveCompactionOverrideConfig(ctx, chat) - require.NoError(t, err) - require.Nil(t, override) -} - func TestResolveCompactionOverrideConfig_DeletedConfigFallsBack(t *testing.T) { t.Parallel() @@ -107,7 +81,7 @@ func TestResolveCompactionOverrideConfig_DeletedConfigFallsBack(t *testing.T) { chat, _ := titleOverrideTestChatAndMessages(t) missingID := uuid.New() - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(missingID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, missingID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), missingID).Return(database.ChatModelConfig{}, sql.ErrNoRows) server := titleOverrideTestServer(db, logger) @@ -126,7 +100,7 @@ func TestResolveCompactionOverrideConfig_DisabledConfigFallsBack(t *testing.T) { chat, _ := titleOverrideTestChatAndMessages(t) overrideConfig := titleOverrideModelConfig("gpt-4.1", false) - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) server := titleOverrideTestServer(db, logger) @@ -147,7 +121,7 @@ func TestResolveCompactionOverrideConfig_MissingCredentialsFallsBack(t *testing. providerID := uuid.New() overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true} - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{ ID: providerID, @@ -182,7 +156,7 @@ func TestCompactionOverride_SetUsable(t *testing.T) { require.NoError(t, err) overrideConfig.Options = options - db.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return(overrideConfig.ID.String()+":high", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, overrideConfig.ID, "high"), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ diff --git a/coderd/x/chatd/configcache.go b/coderd/x/chatd/configcache.go index 017aff1de9e..73e31abd5cd 100644 --- a/coderd/x/chatd/configcache.go +++ b/coderd/x/chatd/configcache.go @@ -15,7 +15,6 @@ import ( "tailscale.com/util/singleflight" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/codersdk" "github.com/coder/quartz" ) @@ -33,8 +32,14 @@ type cachedProviders struct { expiresAt time.Time } +type advisorRuntimeConfig struct { + Enabled bool `json:"enabled"` + MaxUsesPerRun int `json:"max_uses_per_run"` + MaxOutputTokens int64 `json:"max_output_tokens"` +} + type cachedAdvisorConfig struct { - config codersdk.AdvisorConfig + config advisorRuntimeConfig expiresAt time.Time } @@ -66,7 +71,7 @@ type chatConfigCache struct { // Advisor configuration (singleton). advisorConfig *cachedAdvisorConfig advisorConfigGeneration uint64 - advisorConfigFetches singleflight.Group[string, codersdk.AdvisorConfig] + advisorConfigFetches singleflight.Group[string, advisorRuntimeConfig] } func newChatConfigCache(ctx context.Context, db database.Store, clock quartz.Clock) *chatConfigCache { @@ -262,7 +267,7 @@ func (c *chatConfigCache) InvalidateAdvisorConfig() { // this cache saves a per-turn DB round trip on chats that reference the // advisor. Parse errors and lookup errors are surfaced to the caller; // callers that prefer silent fallback handle that at the call site. -func (c *chatConfigCache) AdvisorConfig(ctx context.Context) (codersdk.AdvisorConfig, error) { +func (c *chatConfigCache) AdvisorConfig(ctx context.Context) (advisorRuntimeConfig, error) { if config, ok := c.cachedAdvisorConfig(); ok { return config, nil } @@ -272,35 +277,35 @@ func (c *chatConfigCache) AdvisorConfig(ctx context.Context) (codersdk.AdvisorCo ctx, &c.advisorConfigFetches, fmt.Sprintf("%d:advisor", generation), - func() (codersdk.AdvisorConfig, error) { + func() (advisorRuntimeConfig, error) { if cached, ok := c.cachedAdvisorConfig(); ok { return cached, nil } raw, err := c.db.GetChatAdvisorConfig(c.ctx) if err != nil { - return codersdk.AdvisorConfig{}, err + return advisorRuntimeConfig{}, err } - var cfg codersdk.AdvisorConfig + var cfg advisorRuntimeConfig if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - return codersdk.AdvisorConfig{}, err + return advisorRuntimeConfig{}, err } c.storeAdvisorConfig(generation, cfg) return cfg, nil }, ) if err != nil { - return codersdk.AdvisorConfig{}, err + return advisorRuntimeConfig{}, err } return config, nil } -func (c *chatConfigCache) cachedAdvisorConfig() (codersdk.AdvisorConfig, bool) { +func (c *chatConfigCache) cachedAdvisorConfig() (advisorRuntimeConfig, bool) { c.mu.RLock() entry := c.advisorConfig c.mu.RUnlock() if entry == nil { - return codersdk.AdvisorConfig{}, false + return advisorRuntimeConfig{}, false } if c.clock.Now().Before(entry.expiresAt) { return entry.config, true @@ -312,7 +317,7 @@ func (c *chatConfigCache) cachedAdvisorConfig() (codersdk.AdvisorConfig, bool) { } c.mu.Unlock() - return codersdk.AdvisorConfig{}, false + return advisorRuntimeConfig{}, false } func (c *chatConfigCache) advisorConfigGenerationSnapshot() uint64 { @@ -322,7 +327,7 @@ func (c *chatConfigCache) advisorConfigGenerationSnapshot() uint64 { return generation } -func (c *chatConfigCache) storeAdvisorConfig(generation uint64, config codersdk.AdvisorConfig) { +func (c *chatConfigCache) storeAdvisorConfig(generation uint64, config advisorRuntimeConfig) { c.mu.Lock() defer c.mu.Unlock() diff --git a/coderd/x/chatd/configcache_internal_test.go b/coderd/x/chatd/configcache_internal_test.go index 93eda31be8a..3f4e670e88f 100644 --- a/coderd/x/chatd/configcache_internal_test.go +++ b/coderd/x/chatd/configcache_internal_test.go @@ -17,7 +17,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" - "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" ) @@ -777,7 +776,7 @@ func TestConfigCache_AdvisorConfig_EmptyJSONYieldsZeroValue(t *testing.T) { cfg, err := cache.AdvisorConfig(ctx) require.NoError(t, err) - require.Equal(t, codersdk.AdvisorConfig{}, cfg) + require.Equal(t, advisorRuntimeConfig{}, cfg) } // Guards the pubsub-driven invalidation path. Without this, an admin @@ -842,7 +841,7 @@ func TestConfigCache_InvalidateAdvisorConfig_BlocksStaleInFlight(t *testing.T) { cache := newChatConfigCache(ctx, store, clock) type result struct { - config codersdk.AdvisorConfig + config advisorRuntimeConfig err error } diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 960defa4d6c..dc2efc11f44 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -72,6 +72,28 @@ type modelOverrideProviderKeysResolver func( uuid.UUID, ) (chatprovider.ProviderAPIKeys, error) +type parsedModelOverride struct { + modelConfigID uuid.UUID + reasoningEffort *string +} + +func parseModelOverride(raw string) (parsedModelOverride, bool) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return parsedModelOverride{}, true + } + rawID, rawEffort, hasEffort := strings.Cut(trimmed, ":") + modelConfigID, err := uuid.Parse(rawID) + if err != nil || (hasEffort && rawEffort == "") { + return parsedModelOverride{}, false + } + parsed := parsedModelOverride{modelConfigID: modelConfigID} + if hasEffort { + parsed.reasoningEffort = &rawEffort + } + return parsed, true +} + const ( subagentAwaitPollInterval = 200 * time.Millisecond subagentAwaitFallbackPoll = 5 * time.Second @@ -136,19 +158,22 @@ func subagentModelOverrideLogLabel( func readSubagentModelOverride( ctx context.Context, db database.Store, + organizationID uuid.UUID, overrideContext codersdk.ChatModelOverrideContext, -) (string, error) { +) (database.ChatOrganizationModelOverride, error) { switch overrideContext { - case codersdk.ChatModelOverrideContextGeneral: - return db.GetChatGeneralModelOverride(ctx) - case codersdk.ChatModelOverrideContextExplore: - return db.GetChatExploreModelOverride(ctx) + case codersdk.ChatModelOverrideContextGeneral, + codersdk.ChatModelOverrideContextExplore: default: - return "", xerrors.Errorf( + return database.ChatOrganizationModelOverride{}, xerrors.Errorf( "unsupported subagent model override context %q", overrideContext, ) } + return db.GetChatOrganizationModelOverride(ctx, database.GetChatOrganizationModelOverrideParams{ + OrganizationID: organizationID, + Context: string(overrideContext), + }) } func personalModelOverrideContextForSubagent( @@ -303,6 +328,103 @@ func (p *Server) resolveConfiguredModelOverride( return modelConfig, providerName, parsed.reasoningEffort, true, nil } +func nullStringPtr(v sql.NullString) *string { + if !v.Valid { + return nil + } + return &v.String +} + +// resolveOrganizationModelOverride resolves an override row whose composite +// foreign key already binds the model config to the same organization. +func (p *Server) resolveOrganizationModelOverride( + ctx context.Context, + overrideContext string, + override database.ChatOrganizationModelOverride, + ownerID uuid.UUID, + resolveModelConfig modelOverrideConfigResolver, + resolveProviderKeys modelOverrideProviderKeysResolver, + failureMode modelOverrideFailureMode, +) (database.ChatModelConfig, string, *string, bool, error) { + modelConfig, providerName, err := resolveModelConfig(ctx, override.ModelConfigID) + if err != nil { + if failureMode == modelOverrideFailureModeHard { + label := modelOverrideErrorLabel(overrideContext) + switch { + case errors.Is(err, sql.ErrNoRows): + return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + "%s model override is unavailable: %s", + label, + override.ModelConfigID, + ) + case errors.Is(err, errInvalidModelOverrideMetadata): + return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + "%s model override metadata is invalid for %s: %w", + label, + override.ModelConfigID, + err, + ) + default: + return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + "resolve %s model override %s: %w", + label, + override.ModelConfigID, + err, + ) + } + } + + switch { + case errors.Is(err, sql.ErrNoRows): + p.logger.Info(ctx, + "model override is unavailable, ignoring", + slog.F("override_context", overrideContext), + slog.F("model_config_id", override.ModelConfigID), + ) + case errors.Is(err, errInvalidModelOverrideMetadata): + p.logger.Info(ctx, + "model override metadata is invalid, ignoring", + slog.F("override_context", overrideContext), + slog.F("model_config_id", override.ModelConfigID), + slog.Error(err), + ) + default: + p.logger.Warn(ctx, + "failed to resolve model override, ignoring", + slog.F("override_context", overrideContext), + slog.F("model_config_id", override.ModelConfigID), + slog.Error(err), + ) + } + return database.ChatModelConfig{}, "", nil, false, nil + } + + providerKeys, err := resolveProviderKeys(ctx, ownerID, modelConfigAIProviderID(modelConfig)) + if err != nil { + return database.ChatModelConfig{}, "", nil, false, xerrors.Errorf( + "resolve provider API keys: %w", + err, + ) + } + if !userCanUseProviderKeys(providerKeys, providerName) { + if failureMode == modelOverrideFailureModeHard { + return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + "%s model override credentials are unavailable for provider %q", + modelOverrideErrorLabel(overrideContext), + providerName, + ) + } + p.logger.Info(ctx, + "model override credentials are unavailable, ignoring", + slog.F("override_context", overrideContext), + slog.F("model_config_id", override.ModelConfigID), + slog.F("provider", providerName), + ) + return database.ChatModelConfig{}, "", nil, false, nil + } + return modelConfig, providerName, nullStringPtr(override.ReasoningEffort), true, nil +} + func (p *Server) resolvePersonalSubagentModelConfigID( ctx context.Context, ownerID uuid.UUID, @@ -313,13 +435,11 @@ func (p *Server) resolvePersonalSubagentModelConfigID( if err != nil { return uuid.Nil, nil, false, err } - raw, err := p.db.GetUserChatPersonalModelOverride( - ctx, - database.GetUserChatPersonalModelOverrideParams{ - UserID: ownerID, - Key: ChatPersonalModelOverrideKey(personalContext), - }, - ) + override, err := p.db.GetChatUserModelOverride(ctx, database.GetChatUserModelOverrideParams{ + UserID: ownerID, + OrganizationID: organizationID, + Context: string(personalContext), + }) if err != nil { if !xerrors.Is(err, sql.ErrNoRows) { return uuid.Nil, nil, false, xerrors.Errorf( @@ -328,48 +448,42 @@ func (p *Server) resolvePersonalSubagentModelConfigID( err, ) } - raw = "" + return uuid.Nil, nil, false, nil } - parsed := ParseChatPersonalModelOverride( - raw, - codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - ) - if parsed.Malformed { - p.logger.Debug(ctx, - "personal model override is malformed, using chat organization default", - slog.F("override_context", overrideContext), - slog.F("owner_id", ownerID), - slog.F("raw_model_config_id", strings.TrimSpace(raw)), - ) - } - switch parsed.Mode { + switch codersdk.ChatPersonalModelOverrideMode(override.Mode) { case codersdk.ChatPersonalModelOverrideModeChatDefault: return uuid.Nil, nil, true, nil case codersdk.ChatPersonalModelOverrideModeDeploymentDefault: case codersdk.ChatPersonalModelOverrideModeModel: + if !override.ModelConfigID.Valid { + p.logger.Warn(ctx, + "personal model override has no model config, using deployment default", + slog.F("override_context", overrideContext), + slog.F("owner_id", ownerID), + ) + break + } modelConfig, ok, err := p.resolvePersonalModelOverride( ctx, overrideContext, ownerID, - organizationID, - parsed.ModelConfigID, + override.ModelConfigID.UUID, ) if err != nil { return uuid.Nil, nil, false, err } if ok { - return modelConfig.ID, parsed.ReasoningEffort, true, nil + return modelConfig.ID, nullStringPtr(override.ReasoningEffort), true, nil } default: p.logger.Warn(ctx, - "unsupported personal model override mode, using chat organization default", + "unsupported personal model override mode, using deployment default", slog.F("override_context", overrideContext), slog.F("owner_id", ownerID), - slog.F("mode", parsed.Mode), + slog.F("mode", override.Mode), ) } - return uuid.Nil, nil, false, nil } @@ -377,34 +491,21 @@ func (p *Server) resolvePersonalModelOverride( ctx context.Context, overrideContext codersdk.ChatModelOverrideContext, ownerID uuid.UUID, - organizationID uuid.UUID, modelConfigID uuid.UUID, ) (database.ChatModelConfig, bool, error) { - modelConfig, providerName, err := p.resolveModelConfigForOrganization( - ctx, - ownerID, - organizationID, - modelConfigID, - ) + modelConfig, providerName, err := p.resolveModelConfigAndNormalizedProvider(ctx, ownerID, modelConfigID) if err != nil { switch { - case errors.Is(err, errModelConfigOutsideOrganization): - p.logger.Debug(ctx, - "personal model override belongs to another organization, using chat organization default", - slog.F("override_context", overrideContext), - slog.F("owner_id", ownerID), - slog.F("model_config_id", modelConfigID), - ) case xerrors.Is(err, sql.ErrNoRows): p.logger.Debug(ctx, - "personal model override is unavailable, using chat organization default", + "personal model override is unavailable, using deployment default", slog.F("override_context", overrideContext), slog.F("owner_id", ownerID), slog.F("model_config_id", modelConfigID), ) case errors.Is(err, errInvalidModelOverrideMetadata): p.logger.Debug(ctx, - "personal model override metadata is invalid, using chat organization default", + "personal model override metadata is invalid, using deployment default", slog.F("override_context", overrideContext), slog.F("owner_id", ownerID), slog.F("model_config_id", modelConfigID), @@ -412,7 +513,7 @@ func (p *Server) resolvePersonalModelOverride( ) default: p.logger.Warn(ctx, - "failed to resolve personal model override, using chat organization default", + "failed to resolve personal model override, using deployment default", slog.F("override_context", overrideContext), slog.F("owner_id", ownerID), slog.F("model_config_id", modelConfigID), @@ -423,14 +524,11 @@ func (p *Server) resolvePersonalModelOverride( } providerKeys, err := p.resolveUserProviderAPIKeys(ctx, ownerID, modelConfigAIProviderID(modelConfig)) if err != nil { - return database.ChatModelConfig{}, false, xerrors.Errorf( - "resolve provider API keys: %w", - err, - ) + return database.ChatModelConfig{}, false, xerrors.Errorf("resolve provider API keys: %w", err) } if !userCanUseProviderKeys(providerKeys, providerName) { p.logger.Debug(ctx, - "personal model override credentials are unavailable, using chat organization default", + "personal model override credentials are unavailable, using deployment default", slog.F("override_context", overrideContext), slog.F("owner_id", ownerID), slog.F("model_config_id", modelConfigID), @@ -451,10 +549,7 @@ func (p *Server) resolveSubagentModelConfigID( chatdCtx := dbauthz.AsChatd(ctx) personalOverridesEnabled, err := p.db.GetChatPersonalModelOverridesEnabled(chatdCtx) if err != nil { - return uuid.Nil, nil, xerrors.Errorf( - "get chat personal model overrides enabled: %w", - err, - ) + return uuid.Nil, nil, xerrors.Errorf("get chat personal model overrides enabled: %w", err) } if personalOverridesEnabled { modelConfigID, reasoningEffort, resolved, err := p.resolvePersonalSubagentModelConfigID( @@ -471,21 +566,24 @@ func (p *Server) resolveSubagentModelConfigID( } } - raw, err := readSubagentModelOverride(chatdCtx, p.db, overrideContext) + override, err := readSubagentModelOverride(chatdCtx, p.db, organizationID, overrideContext) if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return uuid.Nil, nil, nil + } return uuid.Nil, nil, xerrors.Errorf( "get %s model override: %w", subagentModelOverrideLogLabel(overrideContext), err, ) } - modelConfig, _, reasoningEffort, ok, err := p.resolveConfiguredModelOverride( + modelConfig, _, reasoningEffort, ok, err := p.resolveOrganizationModelOverride( chatdCtx, string(overrideContext), - raw, + override, ownerID, func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) { - return p.resolveModelConfigForOrganization(ctx, ownerID, organizationID, modelConfigID) + return p.resolveModelConfigAndNormalizedProvider(ctx, ownerID, modelConfigID) }, p.resolveUserProviderAPIKeys, modelOverrideFailureModeSoft, diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 4bd7a34a038..c51ae159cf4 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "slices" + "strings" "sync" "sync/atomic" "testing" @@ -789,25 +790,57 @@ func enableInternalChatPersonalModelOverrides( ) } +func upsertInternalChatOrganizationModelOverride( + t *testing.T, + db database.Store, + organizationID uuid.UUID, + overrideContext codersdk.ChatModelOverrideContext, + raw string, +) { + t.Helper() + rawModelConfigID, effort, hasEffort := strings.Cut(raw, ":") + modelConfigID, err := uuid.Parse(rawModelConfigID) + require.NoError(t, err) + params := database.UpsertChatOrganizationModelOverrideParams{ + OrganizationID: organizationID, + Context: string(overrideContext), + ModelConfigID: modelConfigID, + } + if hasEffort { + params.ReasoningEffort = sql.NullString{String: effort, Valid: true} + } + require.NoError(t, db.UpsertChatOrganizationModelOverride(systemRestrictedTestContext(t), params)) +} + func upsertInternalUserChatPersonalModelOverride( t *testing.T, db database.Store, userID uuid.UUID, + organizationID uuid.UUID, overrideContext codersdk.ChatPersonalModelOverrideContext, raw string, ) { t.Helper() - require.NoError( - t, - db.UpsertUserChatPersonalModelOverride( - systemRestrictedTestContext(t), - database.UpsertUserChatPersonalModelOverrideParams{ - UserID: userID, - Key: ChatPersonalModelOverrideKey(overrideContext), - Value: raw, - }, - ), - ) + mode, modelConfigID, reasoningEffort := raw, uuid.NullUUID{}, sql.NullString{} + if rawMode, remainder, ok := strings.Cut(raw, ":"); ok { + mode = rawMode + rawModelConfigID, rawEffort, hasEffort := strings.Cut(remainder, ":") + modelConfigID = uuid.NullUUID{UUID: uuid.MustParse(rawModelConfigID), Valid: true} + if hasEffort { + reasoningEffort = sql.NullString{String: rawEffort, Valid: true} + } + } + require.NoError(t, db.UpsertChatUserModelOverride( + systemRestrictedTestContext(t), + database.UpsertChatUserModelOverrideParams{ + UserID: userID, + OrganizationID: organizationID, + Context: string(overrideContext), + Mode: mode, + ModelConfigID: modelConfigID, + ReasoningEffort: reasoningEffort, + }, + )) } func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) { @@ -1052,7 +1085,7 @@ func TestSpawnAgent_GeneralUsesConfiguredModelOverride(t *testing.T) { overrideModel := insertInternalChatModelConfig( t, db, org.ID, "general-override-"+uuid.NewString(), true, ) - require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, overrideModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextGeneral, overrideModel.ID.String()) parentChat := createInternalParentChat( ctx, t, server, db, org.ID, user.ID, model.ID, "parent-general-override", ) @@ -1193,16 +1226,6 @@ func TestSpawnAgent_GeneralHonorsPersonalModelOverrides(t *testing.T) { return deploymentModel.ID }, }, - { - name: "MalformedValueUsesDeploymentOverride", - enablePersonalOverride: true, - personalRaw: func(database.ChatModelConfig) string { - return "model:not-a-uuid" - }, - wantModelID: func(_, deploymentModel, _ database.ChatModelConfig) uuid.UUID { - return deploymentModel.ID - }, - }, } for _, tt := range tests { @@ -1221,7 +1244,7 @@ func TestSpawnAgent_GeneralHonorsPersonalModelOverrides(t *testing.T) { "general-deployment-"+uuid.NewString(), true, ) - require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, deploymentModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextGeneral, deploymentModel.ID.String()) personalModel := insertInternalChatModelConfig( t, db, @@ -1240,6 +1263,7 @@ func TestSpawnAgent_GeneralHonorsPersonalModelOverrides(t *testing.T) { t, db, user.ID, + org.ID, codersdk.ChatPersonalModelOverrideContextGeneral, tt.personalRaw(personalModel), ) @@ -1302,7 +1326,7 @@ func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenCredentialsUnavailable(t "gpt-4o-mini", true, ) - require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, overrideModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextGeneral, overrideModel.ID.String()) parent, err := server.CreateChat(ctx, CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, @@ -1372,7 +1396,7 @@ func TestSpawnAgent_GeneralOverrideLogsAndFallsBackWhenProviderDisabled(t *testi "gpt-4o-mini", true, ) - require.NoError(t, db.UpsertChatGeneralModelOverride(ctx, overrideModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextGeneral, overrideModel.ID.String()) parent, err := server.CreateChat(ctx, CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, @@ -1613,11 +1637,11 @@ type personalOverrideErrorStore struct { database.Store } -func (*personalOverrideErrorStore) GetUserChatPersonalModelOverride( +func (*personalOverrideErrorStore) GetChatUserModelOverride( context.Context, - database.GetUserChatPersonalModelOverrideParams, -) (string, error) { - return "", xerrors.New("unexpected personal override lookup") + database.GetChatUserModelOverrideParams, +) (database.ChatUserModelOverride, error) { + return database.ChatUserModelOverride{}, xerrors.New("unexpected personal override lookup") } func TestSpawnAgent_ExplicitModelSkipsConfiguredOverrides(t *testing.T) { @@ -1670,14 +1694,15 @@ func TestSpawnAgent_ExplicitModelBeatsConfiguredOverrides(t *testing.T) { selectedModel := insertInternalChatModelConfig( t, db, org.ID, "general-selected-"+uuid.NewString(), true, ) - require.NoError(t, db.UpsertChatGeneralModelOverride( - ctx, deploymentModel.ID.String()+":medium", - )) + upsertInternalChatOrganizationModelOverride( + t, db, org.ID, codersdk.ChatModelOverrideContextGeneral, deploymentModel.ID.String()+":medium", + ) enableInternalChatPersonalModelOverrides(t, db) upsertInternalUserChatPersonalModelOverride( t, db, user.ID, + org.ID, codersdk.ChatPersonalModelOverrideContextGeneral, string(codersdk.ChatPersonalModelOverrideModeModel)+":"+personalModel.ID.String()+":low", ) @@ -1781,9 +1806,9 @@ func TestSpawnAgent_ExplicitReasoningEffort(t *testing.T) { deploymentModel := insertInternalChatModelConfig( t, db, org.ID, "general-deployment-"+uuid.NewString(), true, ) - require.NoError(t, db.UpsertChatGeneralModelOverride( - ctx, deploymentModel.ID.String()+":low", - )) + upsertInternalChatOrganizationModelOverride( + t, db, org.ID, codersdk.ChatModelOverrideContextGeneral, deploymentModel.ID.String()+":low", + ) parentChat := createInternalParentChat( ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-effort-beats-override", ) @@ -2104,7 +2129,7 @@ func TestSpawnAgent_ExploreUsesConfiguredModelOverride(t *testing.T) { overrideModel := insertInternalChatModelConfig( t, db, org.ID, "explore-override-"+uuid.NewString(), true, ) - require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextExplore, overrideModel.ID.String()) parentChat := createInternalParentChat( ctx, t, server, db, org.ID, user.ID, model.ID, "parent-explore-override", ) @@ -2288,16 +2313,6 @@ func TestSpawnAgent_ExploreHonorsPersonalModelOverrides(t *testing.T) { return deploymentModel.ID }, }, - { - name: "MalformedValueUsesDeploymentOverride", - enablePersonalOverride: true, - personalRaw: func(database.ChatModelConfig) string { - return "not-a-mode" - }, - wantModelID: func(_, _, deploymentModel, _ database.ChatModelConfig) uuid.UUID { - return deploymentModel.ID - }, - }, } for _, tt := range tests { @@ -2323,7 +2338,7 @@ func TestSpawnAgent_ExploreHonorsPersonalModelOverrides(t *testing.T) { "explore-deployment-"+uuid.NewString(), true, ) - require.NoError(t, db.UpsertChatExploreModelOverride(ctx, deploymentModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextExplore, deploymentModel.ID.String()) personalModel := insertInternalChatModelConfig( t, db, @@ -2342,6 +2357,7 @@ func TestSpawnAgent_ExploreHonorsPersonalModelOverrides(t *testing.T) { t, db, user.ID, + org.ID, codersdk.ChatPersonalModelOverrideContextExplore, tt.personalRaw(personalModel), ) @@ -2615,38 +2631,6 @@ func TestSpawnAgent_ExploreSnapshotsTurnStateParentState(t *testing.T) { "Explore child should keep the turn-start MCP snapshot after parent mutations") } -func TestSpawnAgent_ExploreFallsBackOnInvalidUUID(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) - - ctx := chatdTestContext(t) - user, org, parentModel := seedInternalChatDeps(t, db) - currentTurnModel := insertInternalChatModelConfig( - t, db, org.ID, "explore-invalid-override-"+uuid.NewString(), true, - ) - require.NoError(t, db.UpsertChatExploreModelOverride(ctx, "not-a-uuid")) - parentChat := createInternalParentChat( - ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-invalid-override", - ) - - resp := runSubagentTool( - ctx, - t, - server, - parentChat, - currentTurnModel.ID, - spawnAgentToolName, - spawnAgentArgs{Type: subagentTypeExplore, Prompt: "inspect the handler flow"}, - ) - childID := requireSpawnAgentChildChatID(t, resp) - - childChat, err := db.GetChatByID(ctx, childID) - require.NoError(t, err) - require.Equal(t, currentTurnModel.ID, childChat.LastModelConfigID) -} - func TestSpawnAgent_ExploreFallsBackWhenOverrideIsUnavailable(t *testing.T) { t.Parallel() @@ -2661,7 +2645,7 @@ func TestSpawnAgent_ExploreFallsBackWhenOverrideIsUnavailable(t *testing.T) { disabledModel := insertInternalChatModelConfig( t, db, org.ID, "explore-disabled-"+uuid.NewString(), false, ) - require.NoError(t, db.UpsertChatExploreModelOverride(ctx, disabledModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextExplore, disabledModel.ID.String()) parentChat := createInternalParentChat( ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-disabled", ) @@ -2704,11 +2688,12 @@ func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *tes }) overrideModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - AIProviderID: uuid.NullUUID{UUID: overrideProvider.ID, Valid: true}, - Model: "gpt-4o-mini", - DisplayName: "Explore Override Missing User Key", + AIProviderID: uuid.NullUUID{UUID: overrideProvider.ID, Valid: true}, + OrganizationID: org.ID, + Model: "gpt-4o-mini", + DisplayName: "Explore Override Missing User Key", }) - require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String())) + upsertInternalChatOrganizationModelOverride(t, db, org.ID, codersdk.ChatModelOverrideContextExplore, overrideModel.ID.String()) parentChat := createInternalParentChat( ctx, t, server, db, org.ID, user.ID, parentModel.ID, "parent-explore-missing-user-key", ) diff --git a/coderd/x/chatd/title_override.go b/coderd/x/chatd/title_override.go index ef9c1c469cd..d467a54d348 100644 --- a/coderd/x/chatd/title_override.go +++ b/coderd/x/chatd/title_override.go @@ -2,8 +2,7 @@ package chatd import ( "context" - "errors" - "strings" + "database/sql" "github.com/google/uuid" "golang.org/x/xerrors" @@ -15,81 +14,59 @@ import ( const titleGenerationOverrideContext = "title_generation" -type parsedModelOverride struct { - modelConfigID uuid.UUID - reasoningEffort *string -} - -func parseModelOverride(raw string) (parsedModelOverride, bool) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return parsedModelOverride{}, true - } - rawID, rawEffort, hasEffort := strings.Cut(trimmed, ":") - modelConfigID, err := uuid.Parse(rawID) - if err != nil || (hasEffort && rawEffort == "") { - return parsedModelOverride{}, false - } - parsed := parsedModelOverride{modelConfigID: modelConfigID} - if hasEffort { - parsed.reasoningEffort = &rawEffort - } - return parsed, true -} - func readTitleGenerationModelOverride( ctx context.Context, db database.Store, -) (string, error) { + organizationID uuid.UUID, +) (database.ChatOrganizationModelOverride, error) { //nolint:gocritic // Chatd is internal, not a user, so this read uses AsChatd. chatdCtx := dbauthz.AsChatd(ctx) - raw, err := db.GetChatTitleGenerationModelOverride(chatdCtx) + override, err := db.GetChatOrganizationModelOverride(chatdCtx, database.GetChatOrganizationModelOverrideParams{ + OrganizationID: organizationID, + Context: titleGenerationOverrideContext, + }) if err != nil { - return "", xerrors.Errorf( + return database.ChatOrganizationModelOverride{}, xerrors.Errorf( "get chat title generation model override: %w", err, ) } - return raw, nil + return override, nil } -// resolveTitleGenerationModelOverride resolves the deployment-wide title -// generation model override. overrideSet is true when an override was -// configured and resolved; a configured override that does not resolve -// because it is unknown or disabled is ignored. Model construction failures -// after a successful resolution stay hard failures. -// When overrideSet is false, callers may fall back to the default title -// model. +// resolveTitleGenerationModelOverride resolves the chat organization's title +// generation model override. A configured but unusable override is a hard +// failure. When no row is configured, callers may use the default title model. func (p *Server) resolveTitleGenerationModelOverride( ctx context.Context, chat database.Chat, modelOpts modelBuildOptions, ) (resolvedModelCall, bool, error) { - raw, err := readTitleGenerationModelOverride(ctx, p.db) + override, err := readTitleGenerationModelOverride(ctx, p.db, chat.OrganizationID) if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return resolvedModelCall{}, false, nil + } return resolvedModelCall{}, false, xerrors.Errorf( "read title generation model override: %w", err, ) } - modelConfig, _, overrideEffort, overrideSet, err := p.resolveConfiguredModelOverride( + modelConfig, _, overrideEffort, overrideSet, err := p.resolveOrganizationModelOverride( ctx, titleGenerationOverrideContext, - raw, + override, chat.OwnerID, func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) { - return p.resolveModelConfigForOrganization(ctx, chat.OwnerID, chat.OrganizationID, modelConfigID) + return p.resolveModelConfigAndNormalizedProvider(ctx, chat.OwnerID, modelConfigID) }, func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) { return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID) }, - modelOverrideFailureModeSoft, + modelOverrideFailureModeHard, ) if err != nil { - if errors.Is(err, errModelConfigOutsideOrganization) { - return resolvedModelCall{}, false, err - } return resolvedModelCall{}, overrideSet, err } if !overrideSet { diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 8c8ee99741d..d483ac7d3b3 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -34,6 +34,13 @@ import ( "github.com/coder/quartz" ) +func titleGenerationOverrideParams(chat database.Chat) database.GetChatOrganizationModelOverrideParams { + return database.GetChatOrganizationModelOverrideParams{ + OrganizationID: chat.OrganizationID, + Context: titleGenerationOverrideContext, + } +} + func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) { t.Parallel() @@ -57,7 +64,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) { }, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, @@ -107,56 +114,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideReadDBError(t *testing.T) }, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", sql.ErrConnDone) - db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ - ID: chat.ID, - Title: wantTitle, - }).Return(chatWithTitle(chat, wantTitle), nil) - - generated := &generatedChatTitle{} - server := titleOverrideTestServer(db, logger) - server.maybeGenerateChatTitle( - ctx, - chat, - messages, - nil, - resolvedModelCall{ - model: chatprovider.NewModel(fallbackModel, nil), - dbConfig: database.ChatModelConfig{Model: "fallback-chat-model"}, - }, - modelBuildOptions{}, - generated, - logger, - nil, - ) - - require.Equal(t, int32(1), fallbackCalls.Load()) - gotTitle, ok := generated.Load() - require.True(t, ok) - require.Equal(t, wantTitle, gotTitle) -} - -func TestMaybeGenerateChatTitle_TitleGenerationOverrideMalformedFallsThrough(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, messages := titleOverrideTestChatAndMessages(t) - wantTitle := "Fallback title" - - var fallbackCalls atomic.Int32 - fallbackModel := &chattest.FakeModel{ - GenerateObjectFn: func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { - fallbackCalls.Add(1) - return &fantasy.ObjectResponse{ - Object: map[string]any{"title": wantTitle}, - }, nil - }, - } - - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("not-a-uuid", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrConnDone) db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: wantTitle, @@ -236,7 +194,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) { }, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String()+":xhigh", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, "xhigh"), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ @@ -272,7 +230,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) { require.Equal(t, wantTitle, gotTitle) } -func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testing.T) { +func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableHardFails(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -290,12 +248,8 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi }, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) - db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ - ID: chat.ID, - Title: wantTitle, - }).Return(chatWithTitle(chat, wantTitle), nil) generated := &generatedChatTitle{} server := titleOverrideTestServer(db, logger) @@ -314,9 +268,8 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi nil, ) - gotTitle, ok := generated.Load() - require.True(t, ok) - require.Equal(t, wantTitle, gotTitle) + _, ok := generated.Load() + require.False(t, ok, "a configured but unusable title override must not fall back") } func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(t *testing.T) { @@ -343,7 +296,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback( }, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ @@ -374,56 +327,6 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback( require.False(t, ok) } -func TestResolveManualTitleModel_TitleGenerationOverrideIgnoresForeignConfig(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) - chat.OrganizationID = uuid.New() - overrideConfig := titleOverrideModelConfig("gpt-4.1", true) - overrideConfig.OrganizationID = uuid.New() - overrideProviderID := uuid.New() - overrideConfig.AIProviderID = uuid.NullUUID{UUID: overrideProviderID, Valid: true} - localProviderID := uuid.New() - localPreferred := database.ChatModelConfig{ - ID: uuid.New(), - Model: preferredTitleModels[0].model, - Enabled: true, - OrganizationID: chat.OrganizationID, - } - localDefault := database.ChatModelConfig{ - ID: uuid.New(), - AIProviderID: uuid.NullUUID{UUID: localProviderID, Valid: true}, - Model: "local-default-model", - Enabled: true, - IsDefault: true, - OrganizationID: chat.OrganizationID, - } - - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) - db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), overrideProviderID).Return(aibridgeTestAIProvider(overrideProviderID, "foreign-openai", database.AIProviderTypeOpenai), nil) - db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ - {ChatModelConfig: localPreferred, Provider: preferredTitleModels[0].provider}, - {ChatModelConfig: localDefault, Provider: "openai"}, - }, nil).AnyTimes() - db.EXPECT().GetAIProviderByID(gomock.Any(), localProviderID).Return(aibridgeTestAIProvider(localProviderID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() - - server := titleOverrideTestServer(db, logger) - resolved, err := server.resolveManualTitleModel( - ctx, - db, - chat, - modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, - ) - require.NoError(t, err) - require.True(t, resolved.model.Valid()) - require.Equal(t, localDefault, resolved.dbConfig) -} - func TestResolveManualTitleModel_TitleGenerationOverrideUnset(t *testing.T) { t.Parallel() @@ -440,7 +343,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnset(t *testing.T) { Enabled: true, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ {ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1", Enabled: true}, Provider: "openai"}, {ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider}, @@ -469,7 +372,7 @@ func TestResolveManualTitleModel_NonDefaultOrgDoesNotUseDefaultOrgConfigs(t *tes chat, _ := titleOverrideTestChatAndMessages(t) chat.OrganizationID = uuid.New() - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).AnyTimes() server := titleOverrideTestServer(db, logger) @@ -510,7 +413,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnsetAIProvider(t *testi BaseUrl: serverURL, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ {ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider}, }, nil) @@ -548,7 +451,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideReadDBError(t *testing.T Enabled: true, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", sql.ErrConnDone) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrConnDone) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ {ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1", Enabled: true}, Provider: "openai"}, {ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider}, @@ -579,7 +482,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUsable(t *testing.T) providerID := uuid.New() overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true} - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ @@ -616,13 +519,11 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te Enabled: true, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return(nil, nil).AnyTimes() - // Missing override credentials soft-fail the override. The local - // organization has no usable model, so fallback resolution fails. - db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).Times(2) + // Missing override credentials hard-fail the configured override. server := titleOverrideTestServer(db, logger) resolved, err := server.resolveManualTitleModel( @@ -631,9 +532,8 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te chat, modelBuildOptions{}, ) - // The chat has no model config and its org has no default, so - // fallback resolution errors with ErrNoDefaultChatModelConfig. - require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) + require.ErrorContains(t, err, "resolve manual title generation model override") + require.ErrorContains(t, err, "credentials are unavailable") require.Equal(t, resolvedModelCall{}, resolved) } @@ -695,7 +595,7 @@ func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) { UserID: chat.OwnerID, ExpiresAt: time.Now().Add(48 * time.Hour), }, nil) - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ @@ -726,11 +626,9 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T chat, _ := titleOverrideTestChatAndMessages(t) overrideConfig := titleOverrideModelConfig("gpt-4.1", false) - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(orgModelOverride(chat, titleGenerationOverrideContext, overrideConfig.ID, ""), nil) db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) - // A disabled override config soft-fails the override. The local - // organization has no usable model, so fallback resolution fails. - db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).Times(2) + // A disabled configured override is a hard failure. server := titleOverrideTestServer(db, logger) resolved, err := server.resolveManualTitleModel( @@ -739,9 +637,8 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T chat, modelBuildOptions{}, ) - // The chat has no model config and its org has no default, so - // fallback resolution errors with ErrNoDefaultChatModelConfig. - require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) + require.ErrorContains(t, err, "resolve manual title generation model override") + require.ErrorContains(t, err, "model override is unavailable") require.Equal(t, resolvedModelCall{}, resolved) } From 261a8d1433b9d1c8ff0ed96e5bd0943edd5aedf2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:29:39 +0000 Subject: [PATCH 03/16] feat: move chat model override settings to organization scope --- coderd/apidoc/docs.go | 350 +++++ coderd/apidoc/swagger.json | 320 ++++ coderd/coderd.go | 21 +- coderd/database/dbauthz/dbauthz.go | 89 -- coderd/database/dbauthz/dbauthz_test.go | 57 - coderd/database/dbmetrics/querymetrics.go | 88 -- coderd/database/dbmock/dbmock.go | 160 -- coderd/database/querier.go | 13 - coderd/database/queries.sql.go | 296 ---- .../database/queries/chatmodeloverrides.sql | 186 --- coderd/exp_chats.go | 660 ++++---- coderd/exp_chats_test.go | 1323 ++++------------- coderd/telemetry/telemetry.go | 56 +- coderd/telemetry/telemetry_test.go | 174 +-- coderd/x/chatd/advisor_internal_test.go | 39 +- coderd/x/chatd/chatd_internal_test.go | 4 +- coderd/x/chatd/chatd_test.go | 6 +- coderd/x/chatd/quickgen_internal_test.go | 2 +- codersdk/chats.go | 95 +- docs/reference/api/schemas.md | 211 +++ site/src/api/api.ts | 42 +- site/src/api/queries/chats.test.ts | 29 - site/src/api/queries/chats.ts | 63 +- site/src/api/typesGenerated.ts | 57 +- .../CoderAgentsPage/CoderAgentsPage.tsx | 116 +- .../CoderAgentsPageView.stories.tsx | 809 +--------- .../CoderAgentsPage/CoderAgentsPageView.tsx | 234 +-- .../SubagentModelOverrideSettings.tsx | 9 +- .../ModelsPage/DefaultsPage/DefaultsPage.tsx | 123 ++ .../DefaultsPage/DefaultsPageView.stories.tsx | 82 + .../DefaultsPage/DefaultsPageView.tsx | 135 ++ .../ModelsPage/OrganizationModelsLayout.tsx | 26 + .../ModelsPage/organizationModels.tsx | 8 +- site/src/pages/AgentsPage/AgentCreatePage.tsx | 15 +- .../AgentSettingsUserAgentsPage.tsx | 45 +- ...gentSettingsUserAgentsPageView.stories.tsx | 41 +- .../AgentSettingsUserAgentsPageView.tsx | 44 +- .../AgentsPage/AgentsPageLayout.stories.tsx | 7 - .../src/pages/AgentsPage/AgentsPageLayout.tsx | 4 +- .../AgentsPage/components/AdvisorSettings.tsx | 212 +-- .../components/AgentCreateForm.stories.tsx | 28 - .../AgentsPage/components/AgentCreateForm.tsx | 33 +- .../components/PersonalModelOverrideRow.tsx | 21 +- site/src/router.tsx | 7 + 44 files changed, 2326 insertions(+), 4014 deletions(-) create mode 100644 site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fc57754bc17..81bb7709f9e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1359,6 +1359,106 @@ const docTemplate = `{ } } }, + "/api/experimental/organizations/{organization}/chats/model-overrides": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "List organization chat model overrides", + "operationId": "list-organization-chat-model-overrides", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelOverridesResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/chats/model-overrides/{context}": { + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Update organization chat model override", + "operationId": "update-organization-chat-model-override", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "enum": [ + "general", + "explore", + "title_generation", + "compaction", + "advisor" + ], + "type": "string", + "description": "Override context", + "name": "context", + "in": "path", + "required": true + }, + { + "description": "Model override", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateChatModelOverrideRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/chats/models": { "get": { "produces": [ @@ -2036,6 +2136,112 @@ const docTemplate = `{ } } }, + "/api/experimental/organizations/{organization}/members/{user}/chats/model-overrides": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Get organization member chat model overrides", + "operationId": "get-organization-member-chat-model-overrides", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User name, ID, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserChatPersonalModelOverridesResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/members/{user}/chats/model-overrides/{context}": { + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Update organization member chat model override", + "operationId": "update-organization-member-chat-model-override", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User name, ID, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "enum": [ + "root", + "general", + "explore" + ], + "type": "string", + "description": "Override context", + "name": "context", + "in": "path", + "required": true + }, + { + "description": "Personal model override", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserChatPersonalModelOverrideRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/users/{user}/skills": { "get": { "produces": [ @@ -19280,6 +19486,48 @@ const docTemplate = `{ } } }, + "codersdk.ChatModelOverrideContext": { + "type": "string", + "enum": [ + "general", + "explore", + "title_generation", + "compaction", + "advisor" + ], + "x-enum-varnames": [ + "ChatModelOverrideContextGeneral", + "ChatModelOverrideContextExplore", + "ChatModelOverrideContextTitleGeneration", + "ChatModelOverrideContextCompaction", + "ChatModelOverrideContextAdvisor" + ] + }, + "codersdk.ChatModelOverrideResponse": { + "type": "object", + "properties": { + "context": { + "$ref": "#/definitions/codersdk.ChatModelOverrideContext" + }, + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, + "codersdk.ChatModelOverridesResponse": { + "type": "object", + "properties": { + "overrides": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + } + } + } + }, "codersdk.ChatModelProviderDescriptor": { "type": "object", "properties": { @@ -19431,6 +19679,63 @@ const docTemplate = `{ } } }, + "codersdk.ChatPersonalModelOverride": { + "type": "object", + "properties": { + "context": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideContext" + }, + "is_set": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideMode" + }, + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, + "codersdk.ChatPersonalModelOverrideContext": { + "type": "string", + "enum": [ + "root", + "general", + "explore" + ], + "x-enum-varnames": [ + "ChatPersonalModelOverrideContextRoot", + "ChatPersonalModelOverrideContextGeneral", + "ChatPersonalModelOverrideContextExplore" + ] + }, + "codersdk.ChatPersonalModelOverrideDeploymentDefaults": { + "type": "object", + "properties": { + "explore": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + }, + "general": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + } + } + }, + "codersdk.ChatPersonalModelOverrideMode": { + "type": "string", + "enum": [ + "deployment_default", + "chat_default", + "model" + ], + "x-enum-varnames": [ + "ChatPersonalModelOverrideModeDeploymentDefault", + "ChatPersonalModelOverrideModeChatDefault", + "ChatPersonalModelOverrideModeModel" + ] + }, "codersdk.ChatPlanMode": { "type": "string", "enum": [ @@ -27458,6 +27763,17 @@ const docTemplate = `{ } } }, + "codersdk.UpdateChatModelOverrideRequest": { + "type": "object", + "properties": { + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, "codersdk.UpdateChatModelRequest": { "type": "object", "properties": { @@ -27872,6 +28188,20 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserChatPersonalModelOverrideRequest": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideMode" + }, + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { @@ -28425,6 +28755,26 @@ const docTemplate = `{ } } }, + "codersdk.UserChatPersonalModelOverridesResponse": { + "type": "object", + "properties": { + "deployment_defaults": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideDeploymentDefaults" + }, + "enabled": { + "type": "boolean" + }, + "explore": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverride" + }, + "general": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverride" + }, + "root": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverride" + } + } + }, "codersdk.UserLatency": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e7b814744ce..0d53012fdbf 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1210,6 +1210,96 @@ } } }, + "/api/experimental/organizations/{organization}/chats/model-overrides": { + "get": { + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "List organization chat model overrides", + "operationId": "list-organization-chat-model-overrides", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelOverridesResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/chats/model-overrides/{context}": { + "put": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Update organization chat model override", + "operationId": "update-organization-chat-model-override", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "enum": [ + "general", + "explore", + "title_generation", + "compaction", + "advisor" + ], + "type": "string", + "description": "Override context", + "name": "context", + "in": "path", + "required": true + }, + { + "description": "Model override", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateChatModelOverrideRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/organizations/{organization}/chats/models": { "get": { "produces": ["application/json"], @@ -1825,6 +1915,100 @@ } } }, + "/api/experimental/organizations/{organization}/members/{user}/chats/model-overrides": { + "get": { + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Get organization member chat model overrides", + "operationId": "get-organization-member-chat-model-overrides", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User name, ID, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserChatPersonalModelOverridesResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, + "/api/experimental/organizations/{organization}/members/{user}/chats/model-overrides/{context}": { + "put": { + "consumes": ["application/json"], + "tags": ["Chats"], + "summary": "Update organization member chat model override", + "operationId": "update-organization-member-chat-model-override", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User name, ID, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "enum": ["root", "general", "explore"], + "type": "string", + "description": "Override context", + "name": "context", + "in": "path", + "required": true + }, + { + "description": "Personal model override", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserChatPersonalModelOverrideRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/users/{user}/skills": { "get": { "produces": ["application/json"], @@ -17436,6 +17620,48 @@ } } }, + "codersdk.ChatModelOverrideContext": { + "type": "string", + "enum": [ + "general", + "explore", + "title_generation", + "compaction", + "advisor" + ], + "x-enum-varnames": [ + "ChatModelOverrideContextGeneral", + "ChatModelOverrideContextExplore", + "ChatModelOverrideContextTitleGeneration", + "ChatModelOverrideContextCompaction", + "ChatModelOverrideContextAdvisor" + ] + }, + "codersdk.ChatModelOverrideResponse": { + "type": "object", + "properties": { + "context": { + "$ref": "#/definitions/codersdk.ChatModelOverrideContext" + }, + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, + "codersdk.ChatModelOverridesResponse": { + "type": "object", + "properties": { + "overrides": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + } + } + } + }, "codersdk.ChatModelProviderDescriptor": { "type": "object", "properties": { @@ -17583,6 +17809,55 @@ } } }, + "codersdk.ChatPersonalModelOverride": { + "type": "object", + "properties": { + "context": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideContext" + }, + "is_set": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideMode" + }, + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, + "codersdk.ChatPersonalModelOverrideContext": { + "type": "string", + "enum": ["root", "general", "explore"], + "x-enum-varnames": [ + "ChatPersonalModelOverrideContextRoot", + "ChatPersonalModelOverrideContextGeneral", + "ChatPersonalModelOverrideContextExplore" + ] + }, + "codersdk.ChatPersonalModelOverrideDeploymentDefaults": { + "type": "object", + "properties": { + "explore": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + }, + "general": { + "$ref": "#/definitions/codersdk.ChatModelOverrideResponse" + } + } + }, + "codersdk.ChatPersonalModelOverrideMode": { + "type": "string", + "enum": ["deployment_default", "chat_default", "model"], + "x-enum-varnames": [ + "ChatPersonalModelOverrideModeDeploymentDefault", + "ChatPersonalModelOverrideModeChatDefault", + "ChatPersonalModelOverrideModeModel" + ] + }, "codersdk.ChatPlanMode": { "type": "string", "enum": ["plan"], @@ -25275,6 +25550,17 @@ } } }, + "codersdk.UpdateChatModelOverrideRequest": { + "type": "object", + "properties": { + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, "codersdk.UpdateChatModelRequest": { "type": "object", "properties": { @@ -25670,6 +25956,20 @@ } } }, + "codersdk.UpdateUserChatPersonalModelOverrideRequest": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideMode" + }, + "model_config_id": { + "type": "string" + }, + "reasoning_effort": { + "type": "string" + } + } + }, "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { @@ -26190,6 +26490,26 @@ } } }, + "codersdk.UserChatPersonalModelOverridesResponse": { + "type": "object", + "properties": { + "deployment_defaults": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverrideDeploymentDefaults" + }, + "enabled": { + "type": "boolean" + }, + "explore": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverride" + }, + "general": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverride" + }, + "root": { + "$ref": "#/definitions/codersdk.ChatPersonalModelOverride" + } + } + }, "codersdk.UserLatency": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index b065c0d8824..20b0a9bac14 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1436,6 +1436,23 @@ func New(options *Options) *API { httpmw.ExtractOrganizationParam(options.Database), ).Get("/chats/models", api.listChatModelConfigsByOrganization) + r.Route("/organizations/{organization}/chats/model-overrides", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractChatModelOrganizationParam(options.Database), + ) + r.Get("/", api.getOrganizationChatModelOverrides) + r.Put("/{context}", api.putOrganizationChatModelOverride) + }) + r.Route("/organizations/{organization}/members/{user}/chats/model-overrides", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractOrganizationParam(options.Database), + httpmw.ExtractOrganizationMemberParam(options.Database), + ) + r.Get("/", api.getUserChatPersonalModelOverrides) + r.Put("/{context}", api.putUserChatPersonalModelOverride) + }) r.Route("/organizations/{organization}/chats/models", func(r chi.Router) { r.Use(apiKeyMiddleware) r.With(httpmw.ExtractOrganizationParam(options.Database)).Get("/", api.listChatModelConfigsByOrganization) @@ -1473,12 +1490,8 @@ func New(options *Options) *API { r.Put("/system-prompt", api.putChatSystemPrompt) r.Get("/plan-mode-instructions", api.getChatPlanModeInstructions) r.Put("/plan-mode-instructions", api.putChatPlanModeInstructions) - r.Get("/model-override/{context}", api.getChatModelOverride) - r.Put("/model-override/{context}", api.putChatModelOverride) r.Get("/personal-model-overrides", api.getChatPersonalModelOverridesAdminSettings) r.Put("/personal-model-overrides", api.putChatPersonalModelOverridesAdminSettings) - r.Get("/user-personal-model-overrides", api.getUserChatPersonalModelOverrides) - r.Put("/user-personal-model-overrides/{context}", api.putUserChatPersonalModelOverride) r.Group(func(r chi.Router) { r.Use(httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentChatVirtualDesktop)) r.Get("/computer-use-provider", api.getChatComputerUseProvider) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index c486a796438..d18374a26d1 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3192,13 +3192,6 @@ func (q *querier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (datab return fetch(q.log, q.auth, q.db.GetChatByIDForUpdate)(ctx, id) } -func (q *querier) GetChatCompactionModelOverride(ctx context.Context) (string, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return "", err - } - return q.db.GetChatCompactionModelOverride(ctx) -} - func (q *querier) GetChatComputerUseProvider(ctx context.Context) (string, error) { // The computer-use provider is a deployment-wide runtime chat setting // read by authenticated chat users and chatd. Feature and experiment @@ -3324,13 +3317,6 @@ func (q *querier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIDs []uu return q.db.GetChatDiffStatusesByChatIDs(ctx, chatIDs) } -func (q *querier) GetChatExploreModelOverride(ctx context.Context) (string, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return "", err - } - return q.db.GetChatExploreModelOverride(ctx) -} - func (q *querier) GetChatFamilyIDsByRootID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { // This is a read-only query: it returns the chat IDs that belong // to a family. Authorize as Read against the root chat. The @@ -3434,13 +3420,6 @@ func (q *querier) GetChatGatewayAPIKey(ctx context.Context, arg database.GetChat return fetch(q.log, q.auth, q.db.GetChatGatewayAPIKey)(ctx, arg) } -func (q *querier) GetChatGeneralModelOverride(ctx context.Context) (string, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return "", err - } - return q.db.GetChatGeneralModelOverride(ctx) -} - func (q *querier) GetChatHeartbeat(ctx context.Context, arg database.GetChatHeartbeatParams) (database.ChatHeartbeat, error) { _, err := q.GetChatByID(ctx, arg.ChatID) if err != nil { @@ -3690,13 +3669,6 @@ func (q *querier) GetChatSystemPromptConfig(ctx context.Context) (database.GetCh return q.db.GetChatSystemPromptConfig(ctx) } -func (q *querier) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil { - return "", err - } - return q.db.GetChatTitleGenerationModelOverride(ctx) -} - func (q *querier) GetChatUserModelOverride(ctx context.Context, arg database.GetChatUserModelOverrideParams) (database.ChatUserModelOverride, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { @@ -5278,17 +5250,6 @@ func (q *querier) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uui return q.db.GetUserChatDebugLoggingEnabled(ctx, userID) } -func (q *querier) GetUserChatPersonalModelOverride(ctx context.Context, arg database.GetUserChatPersonalModelOverrideParams) (string, error) { - u, err := q.db.GetUserByID(ctx, arg.UserID) - if err != nil { - return "", err - } - if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { - return "", err - } - return q.db.GetUserChatPersonalModelOverride(ctx, arg) -} - func (q *querier) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { user, err := q.db.GetUserByID(ctx, userID) if err != nil { @@ -7028,17 +6989,6 @@ func (q *querier) ListUserChatCompactionThresholds(ctx context.Context, userID u return q.db.ListUserChatCompactionThresholds(ctx, userID) } -func (q *querier) ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]database.ListUserChatPersonalModelOverridesRow, error) { - u, err := q.db.GetUserByID(ctx, userID) - if err != nil { - return nil, err - } - if err := q.authorizeContext(ctx, policy.ActionReadPersonal, u); err != nil { - return nil, err - } - return q.db.ListUserChatPersonalModelOverrides(ctx, userID) -} - func (q *querier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) { obj := rbac.ResourceUserSecret.WithOwner(userID.String()) if err := q.authorizeContext(ctx, policy.ActionRead, obj); err != nil { @@ -9037,13 +8987,6 @@ func (q *querier) UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays return q.db.UpsertChatAutoArchiveDays(ctx, autoArchiveDays) } -func (q *querier) UpsertChatCompactionModelOverride(ctx context.Context, value string) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertChatCompactionModelOverride(ctx, value) -} - func (q *querier) UpsertChatComputerUseProvider(ctx context.Context, provider string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err @@ -9096,20 +9039,6 @@ func (q *querier) UpsertChatDiffStatusReference(ctx context.Context, arg databas return q.db.UpsertChatDiffStatusReference(ctx, arg) } -func (q *querier) UpsertChatExploreModelOverride(ctx context.Context, value string) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertChatExploreModelOverride(ctx, value) -} - -func (q *querier) UpsertChatGeneralModelOverride(ctx context.Context, value string) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertChatGeneralModelOverride(ctx, value) -} - func (q *querier) UpsertChatHeartbeat(ctx context.Context, arg database.UpsertChatHeartbeatParams) error { chat, err := q.db.GetChatByID(ctx, arg.ChatID) if err != nil { @@ -9164,13 +9093,6 @@ func (q *querier) UpsertChatSystemPrompt(ctx context.Context, value string) erro return q.db.UpsertChatSystemPrompt(ctx, value) } -func (q *querier) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error { - if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err - } - return q.db.UpsertChatTitleGenerationModelOverride(ctx, value) -} - func (q *querier) UpsertChatUserModelOverride(ctx context.Context, arg database.UpsertChatUserModelOverrideParams) error { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { @@ -9390,17 +9312,6 @@ func (q *querier) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg dat return q.db.UpsertUserChatDebugLoggingEnabled(ctx, arg) } -func (q *querier) UpsertUserChatPersonalModelOverride(ctx context.Context, arg database.UpsertUserChatPersonalModelOverrideParams) error { - u, err := q.db.GetUserByID(ctx, arg.UserID) - if err != nil { - return err - } - if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, u); err != nil { - return err - } - return q.db.UpsertUserChatPersonalModelOverride(ctx, arg) -} - func (q *querier) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) 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 b2cff852472..124bc5ae064 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -32,7 +32,6 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/util/slice" - "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/testutil" @@ -1292,22 +1291,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatComputerUseProvider(gomock.Any()).Return("anthropic", nil).AnyTimes() check.Args().Asserts() })) - s.Run("GetChatGeneralModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetChatGeneralModelOverride(gomock.Any()).Return("", nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) - s.Run("GetChatExploreModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetChatExploreModelOverride(gomock.Any()).Return("", nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) - s.Run("GetChatTitleGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) - s.Run("GetChatCompactionModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().GetChatCompactionModelOverride(gomock.Any()).Return("", nil).AnyTimes() - check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead) - })) s.Run("GetChatOrganizationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() arg := database.GetChatOrganizationModelOverrideParams{OrganizationID: orgID, Context: "general"} @@ -1797,22 +1780,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatComputerUseProvider(gomock.Any(), "anthropic").Return(nil).AnyTimes() check.Args("anthropic").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) - s.Run("UpsertChatGeneralModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().UpsertChatGeneralModelOverride(gomock.Any(), "").Return(nil).AnyTimes() - check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) - })) - s.Run("UpsertChatExploreModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().UpsertChatExploreModelOverride(gomock.Any(), "").Return(nil).AnyTimes() - check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) - })) - s.Run("UpsertChatTitleGenerationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().UpsertChatTitleGenerationModelOverride(gomock.Any(), "").Return(nil).AnyTimes() - check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) - })) - s.Run("UpsertChatCompactionModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - dbm.EXPECT().UpsertChatCompactionModelOverride(gomock.Any(), "").Return(nil).AnyTimes() - check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) - })) s.Run("UpsertChatOrganizationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() arg := database.UpsertChatOrganizationModelOverrideParams{ @@ -3299,30 +3266,6 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpsertUserChatDebugLoggingEnabled(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) })) - s.Run("ListUserChatPersonalModelOverrides", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - u := testutil.Fake(s.T(), faker, database.User{}) - key := chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot) - row := database.ListUserChatPersonalModelOverridesRow{Key: key, Value: "chat_default"} - dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() - dbm.EXPECT().ListUserChatPersonalModelOverrides(gomock.Any(), u.ID).Return([]database.ListUserChatPersonalModelOverridesRow{row}, nil).AnyTimes() - check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns([]database.ListUserChatPersonalModelOverridesRow{row}) - })) - s.Run("GetUserChatPersonalModelOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - u := testutil.Fake(s.T(), faker, database.User{}) - key := chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot) - arg := database.GetUserChatPersonalModelOverrideParams{UserID: u.ID, Key: key} - dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() - dbm.EXPECT().GetUserChatPersonalModelOverride(gomock.Any(), arg).Return("chat_default", nil).AnyTimes() - check.Args(arg).Asserts(u, policy.ActionReadPersonal).Returns("chat_default") - })) - s.Run("UpsertUserChatPersonalModelOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - u := testutil.Fake(s.T(), faker, database.User{}) - key := chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot) - arg := database.UpsertUserChatPersonalModelOverrideParams{UserID: u.ID, Key: key, Value: "chat_default"} - dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() - dbm.EXPECT().UpsertUserChatPersonalModelOverride(gomock.Any(), arg).Return(nil).AnyTimes() - check.Args(arg).Asserts(u, policy.ActionUpdatePersonal) - })) s.Run("GetChatUserModelOverrides", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) arg := database.GetChatUserModelOverridesParams{UserID: u.ID, OrganizationID: uuid.New()} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 364bcd0e645..548fecc09f3 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1472,14 +1472,6 @@ func (m queryMetricsStore) GetChatByIDForUpdate(ctx context.Context, id uuid.UUI return r0, r1 } -func (m queryMetricsStore) GetChatCompactionModelOverride(ctx context.Context) (string, error) { - start := time.Now() - r0, r1 := m.s.GetChatCompactionModelOverride(ctx) - m.queryLatencies.WithLabelValues("GetChatCompactionModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatCompactionModelOverride").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetChatComputerUseProvider(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetChatComputerUseProvider(ctx) @@ -1560,14 +1552,6 @@ func (m queryMetricsStore) GetChatDiffStatusesByChatIDs(ctx context.Context, cha return r0, r1 } -func (m queryMetricsStore) GetChatExploreModelOverride(ctx context.Context) (string, error) { - start := time.Now() - r0, r1 := m.s.GetChatExploreModelOverride(ctx) - m.queryLatencies.WithLabelValues("GetChatExploreModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatExploreModelOverride").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetChatFamilyIDsByRootID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { start := time.Now() r0, r1 := m.s.GetChatFamilyIDsByRootID(ctx, id) @@ -1616,14 +1600,6 @@ func (m queryMetricsStore) GetChatGatewayAPIKey(ctx context.Context, arg databas return r0, r1 } -func (m queryMetricsStore) GetChatGeneralModelOverride(ctx context.Context) (string, error) { - start := time.Now() - r0, r1 := m.s.GetChatGeneralModelOverride(ctx) - m.queryLatencies.WithLabelValues("GetChatGeneralModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatGeneralModelOverride").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetChatHeartbeat(ctx context.Context, arg database.GetChatHeartbeatParams) (database.ChatHeartbeat, error) { start := time.Now() r0, r1 := m.s.GetChatHeartbeat(ctx, arg) @@ -1832,14 +1808,6 @@ func (m queryMetricsStore) GetChatSystemPromptConfig(ctx context.Context) (datab return r0, r1 } -func (m queryMetricsStore) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) { - start := time.Now() - r0, r1 := m.s.GetChatTitleGenerationModelOverride(ctx) - m.queryLatencies.WithLabelValues("GetChatTitleGenerationModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatTitleGenerationModelOverride").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetChatUserModelOverride(ctx context.Context, arg database.GetChatUserModelOverrideParams) (database.ChatUserModelOverride, error) { start := time.Now() r0, r1 := m.s.GetChatUserModelOverride(ctx, arg) @@ -3352,14 +3320,6 @@ func (m queryMetricsStore) GetUserChatDebugLoggingEnabled(ctx context.Context, u return r0, r1 } -func (m queryMetricsStore) GetUserChatPersonalModelOverride(ctx context.Context, arg database.GetUserChatPersonalModelOverrideParams) (string, error) { - start := time.Now() - r0, r1 := m.s.GetUserChatPersonalModelOverride(ctx, arg) - m.queryLatencies.WithLabelValues("GetUserChatPersonalModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserChatPersonalModelOverride").Inc() - return r0, r1 -} - func (m queryMetricsStore) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { start := time.Now() r0, r1 := m.s.GetUserCodeDiffDisplayMode(ctx, userID) @@ -4896,14 +4856,6 @@ func (m queryMetricsStore) ListUserChatCompactionThresholds(ctx context.Context, return r0, r1 } -func (m queryMetricsStore) ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]database.ListUserChatPersonalModelOverridesRow, error) { - start := time.Now() - r0, r1 := m.s.ListUserChatPersonalModelOverrides(ctx, userID) - m.queryLatencies.WithLabelValues("ListUserChatPersonalModelOverrides").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListUserChatPersonalModelOverrides").Inc() - return r0, r1 -} - func (m queryMetricsStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) { start := time.Now() r0, r1 := m.s.ListUserSecrets(ctx, userID) @@ -6328,14 +6280,6 @@ func (m queryMetricsStore) UpsertChatAutoArchiveDays(ctx context.Context, autoAr return r0 } -func (m queryMetricsStore) UpsertChatCompactionModelOverride(ctx context.Context, value string) error { - start := time.Now() - r0 := m.s.UpsertChatCompactionModelOverride(ctx, value) - m.queryLatencies.WithLabelValues("UpsertChatCompactionModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatCompactionModelOverride").Inc() - return r0 -} - func (m queryMetricsStore) UpsertChatComputerUseProvider(ctx context.Context, provider string) error { start := time.Now() r0 := m.s.UpsertChatComputerUseProvider(ctx, provider) @@ -6384,22 +6328,6 @@ func (m queryMetricsStore) UpsertChatDiffStatusReference(ctx context.Context, ar return r0, r1 } -func (m queryMetricsStore) UpsertChatExploreModelOverride(ctx context.Context, value string) error { - start := time.Now() - r0 := m.s.UpsertChatExploreModelOverride(ctx, value) - m.queryLatencies.WithLabelValues("UpsertChatExploreModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatExploreModelOverride").Inc() - return r0 -} - -func (m queryMetricsStore) UpsertChatGeneralModelOverride(ctx context.Context, value string) error { - start := time.Now() - r0 := m.s.UpsertChatGeneralModelOverride(ctx, value) - m.queryLatencies.WithLabelValues("UpsertChatGeneralModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatGeneralModelOverride").Inc() - return r0 -} - func (m queryMetricsStore) UpsertChatHeartbeat(ctx context.Context, arg database.UpsertChatHeartbeatParams) error { start := time.Now() r0 := m.s.UpsertChatHeartbeat(ctx, arg) @@ -6456,14 +6384,6 @@ func (m queryMetricsStore) UpsertChatSystemPrompt(ctx context.Context, value str return r0 } -func (m queryMetricsStore) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error { - start := time.Now() - r0 := m.s.UpsertChatTitleGenerationModelOverride(ctx, value) - m.queryLatencies.WithLabelValues("UpsertChatTitleGenerationModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatTitleGenerationModelOverride").Inc() - return r0 -} - func (m queryMetricsStore) UpsertChatUserModelOverride(ctx context.Context, arg database.UpsertChatUserModelOverrideParams) error { start := time.Now() r0 := m.s.UpsertChatUserModelOverride(ctx, arg) @@ -6664,14 +6584,6 @@ func (m queryMetricsStore) UpsertUserChatDebugLoggingEnabled(ctx context.Context return r0 } -func (m queryMetricsStore) UpsertUserChatPersonalModelOverride(ctx context.Context, arg database.UpsertUserChatPersonalModelOverrideParams) error { - start := time.Now() - r0 := m.s.UpsertUserChatPersonalModelOverride(ctx, arg) - m.queryLatencies.WithLabelValues("UpsertUserChatPersonalModelOverride").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserChatPersonalModelOverride").Inc() - return r0 -} - func (m queryMetricsStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { start := time.Now() r0 := m.s.UpsertWebpushVAPIDKeys(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bfd3ac4ed97..7b0b06fdb36 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2759,21 +2759,6 @@ func (mr *MockStoreMockRecorder) GetChatByIDForUpdate(ctx, id any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatByIDForUpdate", reflect.TypeOf((*MockStore)(nil).GetChatByIDForUpdate), ctx, id) } -// GetChatCompactionModelOverride mocks base method. -func (m *MockStore) GetChatCompactionModelOverride(ctx context.Context) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatCompactionModelOverride", ctx) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatCompactionModelOverride indicates an expected call of GetChatCompactionModelOverride. -func (mr *MockStoreMockRecorder) GetChatCompactionModelOverride(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCompactionModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatCompactionModelOverride), ctx) -} - // GetChatComputerUseProvider mocks base method. func (m *MockStore) GetChatComputerUseProvider(ctx context.Context) (string, error) { m.ctrl.T.Helper() @@ -2924,21 +2909,6 @@ func (mr *MockStoreMockRecorder) GetChatDiffStatusesByChatIDs(ctx, chatIds any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDiffStatusesByChatIDs", reflect.TypeOf((*MockStore)(nil).GetChatDiffStatusesByChatIDs), ctx, chatIds) } -// GetChatExploreModelOverride mocks base method. -func (m *MockStore) GetChatExploreModelOverride(ctx context.Context) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatExploreModelOverride", ctx) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatExploreModelOverride indicates an expected call of GetChatExploreModelOverride. -func (mr *MockStoreMockRecorder) GetChatExploreModelOverride(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatExploreModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatExploreModelOverride), ctx) -} - // GetChatFamilyIDsByRootID mocks base method. func (m *MockStore) GetChatFamilyIDsByRootID(ctx context.Context, id uuid.UUID) ([]uuid.UUID, error) { m.ctrl.T.Helper() @@ -3029,21 +2999,6 @@ func (mr *MockStoreMockRecorder) GetChatGatewayAPIKey(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatGatewayAPIKey", reflect.TypeOf((*MockStore)(nil).GetChatGatewayAPIKey), ctx, arg) } -// GetChatGeneralModelOverride mocks base method. -func (m *MockStore) GetChatGeneralModelOverride(ctx context.Context) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatGeneralModelOverride", ctx) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatGeneralModelOverride indicates an expected call of GetChatGeneralModelOverride. -func (mr *MockStoreMockRecorder) GetChatGeneralModelOverride(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatGeneralModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatGeneralModelOverride), ctx) -} - // GetChatHeartbeat mocks base method. func (m *MockStore) GetChatHeartbeat(ctx context.Context, arg database.GetChatHeartbeatParams) (database.ChatHeartbeat, error) { m.ctrl.T.Helper() @@ -3434,21 +3389,6 @@ func (mr *MockStoreMockRecorder) GetChatSystemPromptConfig(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatSystemPromptConfig", reflect.TypeOf((*MockStore)(nil).GetChatSystemPromptConfig), ctx) } -// GetChatTitleGenerationModelOverride mocks base method. -func (m *MockStore) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChatTitleGenerationModelOverride", ctx) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetChatTitleGenerationModelOverride indicates an expected call of GetChatTitleGenerationModelOverride. -func (mr *MockStoreMockRecorder) GetChatTitleGenerationModelOverride(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatTitleGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).GetChatTitleGenerationModelOverride), ctx) -} - // GetChatUserModelOverride mocks base method. func (m *MockStore) GetChatUserModelOverride(ctx context.Context, arg database.GetChatUserModelOverrideParams) (database.ChatUserModelOverride, error) { m.ctrl.T.Helper() @@ -6314,21 +6254,6 @@ func (mr *MockStoreMockRecorder) GetUserChatDebugLoggingEnabled(ctx, userID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).GetUserChatDebugLoggingEnabled), ctx, userID) } -// GetUserChatPersonalModelOverride mocks base method. -func (m *MockStore) GetUserChatPersonalModelOverride(ctx context.Context, arg database.GetUserChatPersonalModelOverrideParams) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetUserChatPersonalModelOverride", ctx, arg) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetUserChatPersonalModelOverride indicates an expected call of GetUserChatPersonalModelOverride. -func (mr *MockStoreMockRecorder) GetUserChatPersonalModelOverride(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserChatPersonalModelOverride", reflect.TypeOf((*MockStore)(nil).GetUserChatPersonalModelOverride), ctx, arg) -} - // GetUserCodeDiffDisplayMode mocks base method. func (m *MockStore) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) { m.ctrl.T.Helper() @@ -9282,21 +9207,6 @@ func (mr *MockStoreMockRecorder) ListUserChatCompactionThresholds(ctx, userID an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserChatCompactionThresholds", reflect.TypeOf((*MockStore)(nil).ListUserChatCompactionThresholds), ctx, userID) } -// ListUserChatPersonalModelOverrides mocks base method. -func (m *MockStore) ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]database.ListUserChatPersonalModelOverridesRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListUserChatPersonalModelOverrides", ctx, userID) - ret0, _ := ret[0].([]database.ListUserChatPersonalModelOverridesRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListUserChatPersonalModelOverrides indicates an expected call of ListUserChatPersonalModelOverrides. -func (mr *MockStoreMockRecorder) ListUserChatPersonalModelOverrides(ctx, userID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserChatPersonalModelOverrides", reflect.TypeOf((*MockStore)(nil).ListUserChatPersonalModelOverrides), ctx, userID) -} - // ListUserSecrets mocks base method. func (m *MockStore) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]database.ListUserSecretsRow, error) { m.ctrl.T.Helper() @@ -11920,20 +11830,6 @@ func (mr *MockStoreMockRecorder) UpsertChatAutoArchiveDays(ctx, autoArchiveDays return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatAutoArchiveDays", reflect.TypeOf((*MockStore)(nil).UpsertChatAutoArchiveDays), ctx, autoArchiveDays) } -// UpsertChatCompactionModelOverride mocks base method. -func (m *MockStore) UpsertChatCompactionModelOverride(ctx context.Context, value string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertChatCompactionModelOverride", ctx, value) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertChatCompactionModelOverride indicates an expected call of UpsertChatCompactionModelOverride. -func (mr *MockStoreMockRecorder) UpsertChatCompactionModelOverride(ctx, value any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatCompactionModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatCompactionModelOverride), ctx, value) -} - // UpsertChatComputerUseProvider mocks base method. func (m *MockStore) UpsertChatComputerUseProvider(ctx context.Context, provider string) error { m.ctrl.T.Helper() @@ -12020,34 +11916,6 @@ func (mr *MockStoreMockRecorder) UpsertChatDiffStatusReference(ctx, arg any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDiffStatusReference", reflect.TypeOf((*MockStore)(nil).UpsertChatDiffStatusReference), ctx, arg) } -// UpsertChatExploreModelOverride mocks base method. -func (m *MockStore) UpsertChatExploreModelOverride(ctx context.Context, value string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertChatExploreModelOverride", ctx, value) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertChatExploreModelOverride indicates an expected call of UpsertChatExploreModelOverride. -func (mr *MockStoreMockRecorder) UpsertChatExploreModelOverride(ctx, value any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatExploreModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatExploreModelOverride), ctx, value) -} - -// UpsertChatGeneralModelOverride mocks base method. -func (m *MockStore) UpsertChatGeneralModelOverride(ctx context.Context, value string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertChatGeneralModelOverride", ctx, value) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertChatGeneralModelOverride indicates an expected call of UpsertChatGeneralModelOverride. -func (mr *MockStoreMockRecorder) UpsertChatGeneralModelOverride(ctx, value any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatGeneralModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatGeneralModelOverride), ctx, value) -} - // UpsertChatHeartbeat mocks base method. func (m *MockStore) UpsertChatHeartbeat(ctx context.Context, arg database.UpsertChatHeartbeatParams) error { m.ctrl.T.Helper() @@ -12146,20 +12014,6 @@ func (mr *MockStoreMockRecorder) UpsertChatSystemPrompt(ctx, value any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatSystemPrompt), ctx, value) } -// UpsertChatTitleGenerationModelOverride mocks base method. -func (m *MockStore) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertChatTitleGenerationModelOverride", ctx, value) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertChatTitleGenerationModelOverride indicates an expected call of UpsertChatTitleGenerationModelOverride. -func (mr *MockStoreMockRecorder) UpsertChatTitleGenerationModelOverride(ctx, value any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatTitleGenerationModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertChatTitleGenerationModelOverride), ctx, value) -} - // UpsertChatUserModelOverride mocks base method. func (m *MockStore) UpsertChatUserModelOverride(ctx context.Context, arg database.UpsertChatUserModelOverrideParams) error { m.ctrl.T.Helper() @@ -12519,20 +12373,6 @@ func (mr *MockStoreMockRecorder) UpsertUserChatDebugLoggingEnabled(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserChatDebugLoggingEnabled", reflect.TypeOf((*MockStore)(nil).UpsertUserChatDebugLoggingEnabled), ctx, arg) } -// UpsertUserChatPersonalModelOverride mocks base method. -func (m *MockStore) UpsertUserChatPersonalModelOverride(ctx context.Context, arg database.UpsertUserChatPersonalModelOverrideParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpsertUserChatPersonalModelOverride", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpsertUserChatPersonalModelOverride indicates an expected call of UpsertUserChatPersonalModelOverride. -func (mr *MockStoreMockRecorder) UpsertUserChatPersonalModelOverride(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserChatPersonalModelOverride", reflect.TypeOf((*MockStore)(nil).UpsertUserChatPersonalModelOverride), ctx, arg) -} - // UpsertWebpushVAPIDKeys mocks base method. func (m *MockStore) UpsertWebpushVAPIDKeys(ctx context.Context, arg database.UpsertWebpushVAPIDKeysParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 06ee7e1cabe..ea9208e5e00 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -420,7 +420,6 @@ type sqlcQuerier interface { GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Chat, error) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) - GetChatCompactionModelOverride(ctx context.Context) (string, error) GetChatComputerUseProvider(ctx context.Context) (string, error) // GetChatDebugLoggingAllowUsers returns the runtime admin setting that // allows users to opt into chat debug logging when the deployment does @@ -443,7 +442,6 @@ type sqlcQuerier interface { // intentionally excluded from these aggregates. GetChatDiffStatusSummary(ctx context.Context) (GetChatDiffStatusSummaryRow, error) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds []uuid.UUID) ([]ChatDiffStatus, error) - GetChatExploreModelOverride(ctx context.Context) (string, error) // Returns the chat IDs of every chat in a family (root + all children) // in deterministic order. The id parameter must be the root id; the // query does not walk up from a child. @@ -459,9 +457,6 @@ type sqlcQuerier interface { GetChatFileMetadataByChatID(ctx context.Context, chatID uuid.UUID) ([]GetChatFileMetadataByChatIDRow, error) GetChatFilesByIDs(ctx context.Context, ids []uuid.UUID) ([]ChatFile, error) GetChatGatewayAPIKey(ctx context.Context, arg GetChatGatewayAPIKeyParams) (APIKey, error) - // Compatibility methods keep the current runtime compiling until it adopts - // the organization-aware query surface. They read and write the default org. - GetChatGeneralModelOverride(ctx context.Context) (string, error) GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatParams) (ChatHeartbeat, error) // GetChatIncludeDefaultSystemPrompt preserves the legacy default // for deployments created before the explicit include-default toggle. @@ -520,7 +515,6 @@ type sqlcQuerier interface { // non-empty custom prompt implied opting out before the explicit toggle // existed. GetChatSystemPromptConfig(ctx context.Context) (GetChatSystemPromptConfigRow, error) - GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) GetChatUserModelOverride(ctx context.Context, arg GetChatUserModelOverrideParams) (ChatUserModelOverride, error) GetChatUserModelOverrides(ctx context.Context, arg GetChatUserModelOverridesParams) ([]ChatUserModelOverride, error) // Returns the concatenated text of each user-visible user prompt in a @@ -933,7 +927,6 @@ type sqlcQuerier interface { GetUserChatCompactionThreshold(ctx context.Context, arg GetUserChatCompactionThresholdParams) (string, error) GetUserChatCustomPrompt(ctx context.Context, userID uuid.UUID) (string, error) GetUserChatDebugLoggingEnabled(ctx context.Context, userID uuid.UUID) (bool, error) - GetUserChatPersonalModelOverride(ctx context.Context, arg GetUserChatPersonalModelOverrideParams) (string, error) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) GetUserCount(ctx context.Context, includeSystem bool) (int64, error) // Returns the "Everyone" group (id == organization_id) to attribute a user's @@ -1295,7 +1288,6 @@ type sqlcQuerier interface { // Used by the usage generator to find missing heartbeat buckets. ListUsageEventCreatedAtsByTypeSince(ctx context.Context, arg ListUsageEventCreatedAtsByTypeSinceParams) ([]time.Time, error) ListUserChatCompactionThresholds(ctx context.Context, userID uuid.UUID) ([]UserConfig, error) - ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]ListUserChatPersonalModelOverridesRow, error) // Returns metadata only (no value or value_key_id) for the // REST API list and get endpoints. ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]ListUserSecretsRow, error) @@ -1656,7 +1648,6 @@ type sqlcQuerier interface { // to JSON before invoking this query. UpsertChatAdvisorConfig(ctx context.Context, value string) error UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays int32) error - UpsertChatCompactionModelOverride(ctx context.Context, value string) error UpsertChatComputerUseProvider(ctx context.Context, provider string) error // UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that // allows users to opt into chat debug logging. @@ -1665,8 +1656,6 @@ type sqlcQuerier interface { UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error) UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error) - UpsertChatExploreModelOverride(ctx context.Context, value string) error - UpsertChatGeneralModelOverride(ctx context.Context, value string) error // Upserts a heartbeat row for the (chat_id, runner_id) lease. Uses // database time so callers do not depend on a local clock. UpsertChatHeartbeat(ctx context.Context, arg UpsertChatHeartbeatParams) error @@ -1678,7 +1667,6 @@ type sqlcQuerier interface { UpsertChatPlanModeInstructions(ctx context.Context, value string) error UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error UpsertChatSystemPrompt(ctx context.Context, value string) error - UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error UpsertChatUserModelOverride(ctx context.Context, arg UpsertChatUserModelOverrideParams) error UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error // The default proxy is implied and not actually stored in the database. @@ -1715,7 +1703,6 @@ type sqlcQuerier interface { // created_at for the insert path only. UpsertUserAIProviderKey(ctx context.Context, arg UpsertUserAIProviderKeyParams) (UserAIProviderKey, error) UpsertUserChatDebugLoggingEnabled(ctx context.Context, arg UpsertUserChatDebugLoggingEnabledParams) error - UpsertUserChatPersonalModelOverride(ctx context.Context, arg UpsertUserChatPersonalModelOverrideParams) error UpsertWebpushVAPIDKeys(ctx context.Context, arg UpsertWebpushVAPIDKeysParams) error UpsertWorkspaceAgentContextResource(ctx context.Context, arg UpsertWorkspaceAgentContextResourceParams) (WorkspaceAgentContextResource, error) UpsertWorkspaceAgentContextSnapshot(ctx context.Context, arg UpsertWorkspaceAgentContextSnapshotParams) (WorkspaceAgentContextSnapshot, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 568d366dada..571170eb17c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6708,57 +6708,6 @@ func (q *sqlQuerier) DeleteChatOrganizationModelOverride(ctx context.Context, ar return err } -const getChatCompactionModelOverride = `-- name: GetChatCompactionModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'compaction' -), '')::text AS model_config_id -` - -func (q *sqlQuerier) GetChatCompactionModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatCompactionModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - -const getChatExploreModelOverride = `-- name: GetChatExploreModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'explore' -), '')::text AS model_config_id -` - -func (q *sqlQuerier) GetChatExploreModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatExploreModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - -const getChatGeneralModelOverride = `-- name: GetChatGeneralModelOverride :one - -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'general' -), '')::text AS model_config_id -` - -// Compatibility methods keep the current runtime compiling until it adopts -// the organization-aware query surface. They read and write the default org. -func (q *sqlQuerier) GetChatGeneralModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatGeneralModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - const getChatOrganizationModelOverride = `-- name: GetChatOrganizationModelOverride :one SELECT id, organization_id, context, model_config_id, reasoning_effort FROM chat_organization_model_overrides @@ -6820,22 +6769,6 @@ func (q *sqlQuerier) GetChatOrganizationModelOverrides(ctx context.Context, orga return items, nil } -const getChatTitleGenerationModelOverride = `-- name: GetChatTitleGenerationModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'title_generation' -), '')::text AS model_config_id -` - -func (q *sqlQuerier) GetChatTitleGenerationModelOverride(ctx context.Context) (string, error) { - row := q.db.QueryRowContext(ctx, getChatTitleGenerationModelOverride) - var model_config_id string - err := row.Scan(&model_config_id) - return model_config_id, err -} - const getChatUserModelOverride = `-- name: GetChatUserModelOverride :one SELECT id, user_id, organization_id, context, mode, model_config_id, reasoning_effort FROM chat_user_model_overrides @@ -6909,156 +6842,6 @@ func (q *sqlQuerier) GetChatUserModelOverrides(ctx context.Context, arg GetChatU return items, nil } -const getUserChatPersonalModelOverride = `-- name: GetUserChatPersonalModelOverride :one -SELECT (CASE - WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode - ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') -END)::text AS personal_model_override -FROM chat_user_model_overrides umo -JOIN organizations o ON o.id = umo.organization_id -WHERE umo.user_id = $1 - AND o.is_default - AND umo.context = substring($2::text FROM 'chat_personal_model_override:(.*)') -` - -type GetUserChatPersonalModelOverrideParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - Key string `db:"key" json:"key"` -} - -func (q *sqlQuerier) GetUserChatPersonalModelOverride(ctx context.Context, arg GetUserChatPersonalModelOverrideParams) (string, error) { - row := q.db.QueryRowContext(ctx, getUserChatPersonalModelOverride, arg.UserID, arg.Key) - var personal_model_override string - err := row.Scan(&personal_model_override) - return personal_model_override, err -} - -const listUserChatPersonalModelOverrides = `-- name: ListUserChatPersonalModelOverrides :many -SELECT - ('chat_personal_model_override:' || umo.context)::text AS key, - (CASE - WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode - ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') - END)::text AS value -FROM chat_user_model_overrides umo -JOIN organizations o ON o.id = umo.organization_id -WHERE umo.user_id = $1 - AND o.is_default -ORDER BY key -` - -type ListUserChatPersonalModelOverridesRow struct { - Key string `db:"key" json:"key"` - Value string `db:"value" json:"value"` -} - -func (q *sqlQuerier) ListUserChatPersonalModelOverrides(ctx context.Context, userID uuid.UUID) ([]ListUserChatPersonalModelOverridesRow, error) { - rows, err := q.db.QueryContext(ctx, listUserChatPersonalModelOverrides, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []ListUserChatPersonalModelOverridesRow - for rows.Next() { - var i ListUserChatPersonalModelOverridesRow - if err := rows.Scan(&i.Key, &i.Value); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertChatCompactionModelOverride = `-- name: UpsertChatCompactionModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'compaction' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'compaction', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort -` - -func (q *sqlQuerier) UpsertChatCompactionModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatCompactionModelOverride, value) - return err -} - -const upsertChatExploreModelOverride = `-- name: UpsertChatExploreModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'explore' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'explore', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort -` - -func (q *sqlQuerier) UpsertChatExploreModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatExploreModelOverride, value) - return err -} - -const upsertChatGeneralModelOverride = `-- name: UpsertChatGeneralModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'general' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'general', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort -` - -func (q *sqlQuerier) UpsertChatGeneralModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatGeneralModelOverride, value) - return err -} - const upsertChatOrganizationModelOverride = `-- name: UpsertChatOrganizationModelOverride :exec INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) @@ -7086,34 +6869,6 @@ func (q *sqlQuerier) UpsertChatOrganizationModelOverride(ctx context.Context, ar return err } -const upsertChatTitleGenerationModelOverride = `-- name: UpsertChatTitleGenerationModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim($1::text) = '' THEN NULL ELSE split_part(trim($1::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim($1::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'title_generation' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'title_generation', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort -` - -func (q *sqlQuerier) UpsertChatTitleGenerationModelOverride(ctx context.Context, value string) error { - _, err := q.db.ExecContext(ctx, upsertChatTitleGenerationModelOverride, value) - return err -} - const upsertChatUserModelOverride = `-- name: UpsertChatUserModelOverride :exec INSERT INTO chat_user_model_overrides (user_id, organization_id, context, mode, model_config_id, reasoning_effort) @@ -7146,57 +6901,6 @@ func (q *sqlQuerier) UpsertChatUserModelOverride(ctx context.Context, arg Upsert return err } -const upsertUserChatPersonalModelOverride = `-- name: UpsertUserChatPersonalModelOverride :exec -WITH input AS ( - SELECT - $1::uuid AS user_id, - o.id AS organization_id, - substring($2::text FROM 'chat_personal_model_override:(.*)') AS context, - trim($3::text) AS value, - CASE - WHEN split_part(trim($3::text), ':', 1) = 'model' - THEN split_part(trim($3::text), ':', 2)::uuid - ELSE NULL - END AS model_config_id - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_user_model_overrides umo - USING input i - WHERE umo.user_id = i.user_id - AND umo.organization_id = i.organization_id - AND umo.context = i.context - AND i.value = '' -) -INSERT INTO chat_user_model_overrides - (user_id, organization_id, context, mode, model_config_id, reasoning_effort) -SELECT - user_id, - organization_id, - context, - CASE WHEN value IN ('chat_default', 'deployment_default') THEN value ELSE 'model' END, - model_config_id, - CASE WHEN split_part(value, ':', 1) = 'model' THEN NULLIF(split_part(value, ':', 3), '') ELSE NULL END -FROM input -WHERE value != '' -ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key -DO UPDATE SET - mode = EXCLUDED.mode, - model_config_id = EXCLUDED.model_config_id, - reasoning_effort = EXCLUDED.reasoning_effort -` - -type UpsertUserChatPersonalModelOverrideParams struct { - UserID uuid.UUID `db:"user_id" json:"user_id"` - Key string `db:"key" json:"key"` - Value string `db:"value" json:"value"` -} - -func (q *sqlQuerier) UpsertUserChatPersonalModelOverride(ctx context.Context, arg UpsertUserChatPersonalModelOverrideParams) error { - _, err := q.db.ExecContext(ctx, upsertUserChatPersonalModelOverride, arg.UserID, arg.Key, arg.Value) - return err -} - const acquireStaleChatDiffStatuses = `-- name: AcquireStaleChatDiffStatuses :many WITH acquired AS ( UPDATE diff --git a/coderd/database/queries/chatmodeloverrides.sql b/coderd/database/queries/chatmodeloverrides.sql index 69c459f3a78..992d2719291 100644 --- a/coderd/database/queries/chatmodeloverrides.sql +++ b/coderd/database/queries/chatmodeloverrides.sql @@ -47,189 +47,3 @@ DO UPDATE SET mode = EXCLUDED.mode, model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; - --- Compatibility methods keep the current runtime compiling until it adopts --- the organization-aware query surface. They read and write the default org. - --- name: GetChatGeneralModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'general' -), '')::text AS model_config_id; - --- name: GetChatExploreModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'explore' -), '')::text AS model_config_id; - --- name: GetChatTitleGenerationModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'title_generation' -), '')::text AS model_config_id; - --- name: GetChatCompactionModelOverride :one -SELECT COALESCE(( - SELECT model_config_id::text || COALESCE(':' || reasoning_effort, '') - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default AND omo.context = 'compaction' -), '')::text AS model_config_id; - --- name: UpsertChatGeneralModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'general' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'general', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; - --- name: UpsertChatExploreModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'explore' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'explore', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; - --- name: UpsertChatTitleGenerationModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'title_generation' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'title_generation', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; - --- name: UpsertChatCompactionModelOverride :exec -WITH input AS ( - SELECT - o.id AS organization_id, - CASE WHEN trim(@value::text) = '' THEN NULL ELSE split_part(trim(@value::text), ':', 1)::uuid END AS model_config_id, - NULLIF(split_part(trim(@value::text), ':', 2), '') AS reasoning_effort - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_organization_model_overrides omo - USING input i - WHERE omo.organization_id = i.organization_id - AND omo.context = 'compaction' - AND i.model_config_id IS NULL -) -INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) -SELECT organization_id, 'compaction', model_config_id, reasoning_effort -FROM input -WHERE model_config_id IS NOT NULL -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO UPDATE SET model_config_id = EXCLUDED.model_config_id, reasoning_effort = EXCLUDED.reasoning_effort; - --- name: ListUserChatPersonalModelOverrides :many -SELECT - ('chat_personal_model_override:' || umo.context)::text AS key, - (CASE - WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode - ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') - END)::text AS value -FROM chat_user_model_overrides umo -JOIN organizations o ON o.id = umo.organization_id -WHERE umo.user_id = @user_id - AND o.is_default -ORDER BY key; - --- name: GetUserChatPersonalModelOverride :one -SELECT (CASE - WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode - ELSE 'model:' || umo.model_config_id::text || COALESCE(':' || umo.reasoning_effort, '') -END)::text AS personal_model_override -FROM chat_user_model_overrides umo -JOIN organizations o ON o.id = umo.organization_id -WHERE umo.user_id = @user_id - AND o.is_default - AND umo.context = substring(@key::text FROM 'chat_personal_model_override:(.*)'); - --- name: UpsertUserChatPersonalModelOverride :exec -WITH input AS ( - SELECT - @user_id::uuid AS user_id, - o.id AS organization_id, - substring(@key::text FROM 'chat_personal_model_override:(.*)') AS context, - trim(@value::text) AS value, - CASE - WHEN split_part(trim(@value::text), ':', 1) = 'model' - THEN split_part(trim(@value::text), ':', 2)::uuid - ELSE NULL - END AS model_config_id - FROM organizations o - WHERE o.is_default -), deleted AS ( - DELETE FROM chat_user_model_overrides umo - USING input i - WHERE umo.user_id = i.user_id - AND umo.organization_id = i.organization_id - AND umo.context = i.context - AND i.value = '' -) -INSERT INTO chat_user_model_overrides - (user_id, organization_id, context, mode, model_config_id, reasoning_effort) -SELECT - user_id, - organization_id, - context, - CASE WHEN value IN ('chat_default', 'deployment_default') THEN value ELSE 'model' END, - model_config_id, - CASE WHEN split_part(value, ':', 1) = 'model' THEN NULLIF(split_part(value, ':', 3), '') ELSE NULL END -FROM input -WHERE value != '' -ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key -DO UPDATE SET - mode = EXCLUDED.mode, - model_config_id = EXCLUDED.model_config_id, - reasoning_effort = EXCLUDED.reasoning_effort; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 433ece3d461..f1a45aa5556 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -634,42 +634,6 @@ func validateChatPlanMode(mode codersdk.ChatPlanMode) bool { } } -type parsedChatModelOverride struct { - modelConfigID *uuid.UUID - reasoningEffort *string -} - -func parseChatModelOverride(raw string) (parsedChatModelOverride, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return parsedChatModelOverride{}, nil - } - rawID, rawEffort, hasEffort := strings.Cut(trimmed, ":") - modelConfigID, err := uuid.Parse(rawID) - if err != nil { - return parsedChatModelOverride{}, xerrors.Errorf("parse chat model override: %w", err) - } - if hasEffort && rawEffort == "" { - return parsedChatModelOverride{}, xerrors.New("parse chat model override: reasoning effort is empty") - } - parsed := parsedChatModelOverride{modelConfigID: &modelConfigID} - if hasEffort { - parsed.reasoningEffort = &rawEffort - } - return parsed, nil -} - -func formatChatModelOverride(id *uuid.UUID, effort *string) string { - if id == nil { - return "" - } - formatted := id.String() - if effort != nil { - formatted += ":" + *effort - } - return formatted -} - func lookupEnabledChatModelConfigByID( ctx context.Context, db database.Store, @@ -763,31 +727,6 @@ func validateChatModelOverride( return validateChatModelOverrideEffort(modelConfig, effort) } -func (api *API) getChatModelOverrideConfig( - ctx context.Context, - settingName string, - getter func(context.Context) (string, error), -) (*uuid.UUID, *string, bool, error) { - raw, err := getter(ctx) - if err != nil { - return nil, nil, false, xerrors.Errorf("get %s model override: %w", settingName, err) - } - parsed, err := parseChatModelOverride(raw) - if err != nil { - // Degrade malformed values to unset so the admin settings page - // remains accessible and the bad value can be cleared. - api.Logger.Warn( - ctx, - "malformed model override in site config, treating as unset", - slog.F("setting", settingName), - slog.F("raw_value", raw), - slog.Error(err), - ) - return nil, nil, true, nil - } - return parsed.modelConfigID, parsed.reasoningEffort, false, nil -} - func parseChatModelOverrideContext(raw string) (codersdk.ChatModelOverrideContext, error) { overrideContext := codersdk.ChatModelOverrideContext(raw) if overrideContext.Valid() { @@ -796,73 +735,6 @@ func parseChatModelOverrideContext(raw string) (codersdk.ChatModelOverrideContex return "", xerrors.Errorf("unknown chat model override context %q", raw) } -type chatModelOverrideSiteConfig struct { - label string - getter func(context.Context) (string, error) - upsert func(context.Context, string) error -} - -func (api *API) chatModelOverrideSiteConfig( - overrideContext codersdk.ChatModelOverrideContext, -) (chatModelOverrideSiteConfig, error) { - switch overrideContext { - case codersdk.ChatModelOverrideContextGeneral: - return chatModelOverrideSiteConfig{ - label: "general", - getter: api.Database.GetChatGeneralModelOverride, - upsert: api.Database.UpsertChatGeneralModelOverride, - }, nil - case codersdk.ChatModelOverrideContextExplore: - return chatModelOverrideSiteConfig{ - label: "explore", - getter: api.Database.GetChatExploreModelOverride, - upsert: api.Database.UpsertChatExploreModelOverride, - }, nil - case codersdk.ChatModelOverrideContextTitleGeneration: - return chatModelOverrideSiteConfig{ - label: "title generation", - getter: api.Database.GetChatTitleGenerationModelOverride, - upsert: api.Database.UpsertChatTitleGenerationModelOverride, - }, nil - case codersdk.ChatModelOverrideContextCompaction: - return chatModelOverrideSiteConfig{ - label: "compaction", - getter: api.Database.GetChatCompactionModelOverride, - upsert: api.Database.UpsertChatCompactionModelOverride, - }, nil - default: - return chatModelOverrideSiteConfig{}, xerrors.Errorf( - "unknown chat model override context %q", - overrideContext, - ) - } -} - -func (api *API) readChatModelOverrideConfig( - ctx context.Context, - overrideContext codersdk.ChatModelOverrideContext, -) (*uuid.UUID, *string, bool, string, error) { - siteConfig, err := api.chatModelOverrideSiteConfig(overrideContext) - if err != nil { - return nil, nil, false, "", err - } - id, effort, isMalformed, err := api.getChatModelOverrideConfig(ctx, siteConfig.label, siteConfig.getter) - return id, effort, isMalformed, siteConfig.label, err -} - -func (api *API) upsertChatModelOverrideConfig( - ctx context.Context, - overrideContext codersdk.ChatModelOverrideContext, - modelConfigID *uuid.UUID, - reasoningEffort *string, -) (string, error) { - siteConfig, err := api.chatModelOverrideSiteConfig(overrideContext) - if err != nil { - return "", err - } - return siteConfig.label, siteConfig.upsert(ctx, formatChatModelOverride(modelConfigID, reasoningEffort)) -} - var chatPersonalModelOverrideContexts = []codersdk.ChatPersonalModelOverrideContext{ codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideContextGeneral, @@ -891,87 +763,73 @@ func defaultChatPersonalModelOverrideMode( return codersdk.ChatPersonalModelOverrideModeDeploymentDefault } -func parseChatPersonalModelOverrideValue( - raw string, +func chatPersonalModelOverrideResponse( overrideContext codersdk.ChatPersonalModelOverrideContext, -) chatd.ParsedChatPersonalModelOverride { - defaultMode := defaultChatPersonalModelOverrideMode(overrideContext) - parsed := chatd.ParseChatPersonalModelOverride(raw, defaultMode) - if overrideContext == codersdk.ChatPersonalModelOverrideContextRoot && - parsed.Mode == codersdk.ChatPersonalModelOverrideModeDeploymentDefault { - return chatd.ParsedChatPersonalModelOverride{ - Mode: defaultMode, - Malformed: true, - } - } - return parsed -} - -func formatChatPersonalModelOverrideValue( - mode codersdk.ChatPersonalModelOverrideMode, - modelConfigID string, - reasoningEffort *string, -) string { - if mode == codersdk.ChatPersonalModelOverrideModeModel { - value := string(mode) + ":" + strings.TrimSpace(modelConfigID) - if reasoningEffort != nil { - value += ":" + *reasoningEffort - } - return value + 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() } - return string(mode) + if row.ReasoningEffort.Valid { + response.ReasoningEffort = &row.ReasoningEffort.String + } + return response } -func chatPersonalModelOverrideResponse( - overrideContext codersdk.ChatPersonalModelOverrideContext, - raw string, - isSet bool, -) codersdk.ChatPersonalModelOverride { - parsed := parseChatPersonalModelOverrideValue(raw, overrideContext) - modelConfigID := "" - var reasoningEffort *string - if parsed.Mode == codersdk.ChatPersonalModelOverrideModeModel { - modelConfigID = parsed.ModelConfigID.String() - reasoningEffort = parsed.ReasoningEffort - } - return codersdk.ChatPersonalModelOverride{ - Context: overrideContext, - Mode: parsed.Mode, - ModelConfigID: modelConfigID, - ReasoningEffort: reasoningEffort, - IsSet: isSet, - IsMalformed: parsed.Malformed, +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) { - // The deployment defaults are global chat configuration, not user-owned - // resources. Users may read these values here because the personal settings - // UI must explain what deployment_default resolves to. - //nolint:gocritic // System context is required to read deployment config. - modelConfigID, reasoningEffort, isMalformed, _, err := api.readChatModelOverrideConfig( - dbauthz.AsSystemRestricted(ctx), - overrideContext, - ) + 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 codersdk.ChatModelOverrideResponse{ - Context: overrideContext, - ModelConfigID: formatChatModelOverride(modelConfigID, nil), - ReasoningEffort: reasoningEffort, - IsMalformed: isMalformed, - }, nil + 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 { @@ -979,6 +837,7 @@ func (api *API) chatPersonalModelOverrideDeploymentDefaults( } explore, err := api.chatPersonalModelOverrideDeploymentDefaultResponse( ctx, + organizationID, codersdk.ChatModelOverrideContextExplore, ) if err != nil { @@ -1010,18 +869,6 @@ const ( chatModelConfigUnavailableOutsideOrganization chatModelConfigUnavailableReason = "outside_organization" ) -func defaultOrganizationID( - ctx context.Context, - db database.Store, -) (uuid.UUID, error) { - //nolint:gocritic // Runtime configuration must be independent of caller permissions. - organization, err := db.GetDefaultOrganization(dbauthz.AsChatd(ctx)) - if err != nil { - return uuid.Nil, err - } - return organization.ID, nil -} - // 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. @@ -4353,9 +4200,10 @@ func (api *API) resolveCreateChatModelConfigID( return id, nil, status, resp } - raw, err := api.Database.GetUserChatPersonalModelOverride(ctx, database.GetUserChatPersonalModelOverrideParams{ - UserID: userID, - Key: chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot), + override, err := api.Database.GetChatUserModelOverride(ctx, database.GetChatUserModelOverrideParams{ + UserID: userID, + OrganizationID: req.OrganizationID, + Context: string(codersdk.ChatPersonalModelOverrideContextRoot), }) if err != nil && !errors.Is(err, sql.ErrNoRows) { return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ @@ -4364,50 +4212,43 @@ func (api *API) resolveCreateChatModelConfigID( } } if err == nil { - parsed := parseChatPersonalModelOverrideValue( - raw, - codersdk.ChatPersonalModelOverrideContextRoot, - ) - if parsed.Malformed { - api.Logger.Debug( - ctx, - "unsupported personal root model override mode, using default model", - slog.F("user_id", userID), - slog.F("raw_value", raw), - ) - } - switch parsed.Mode { + switch codersdk.ChatPersonalModelOverrideMode(override.Mode) { case codersdk.ChatPersonalModelOverrideModeChatDefault: - // Root chat_default uses the chat organization default. case codersdk.ChatPersonalModelOverrideModeModel: - _, reason, err := api.userCanUseChatModelConfig( - ctx, - userID, - req.OrganizationID, - parsed.ModelConfigID, - ) - if err != nil { - return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ - Message: "Failed to resolve chat model config.", - Detail: err.Error(), + if override.ModelConfigID.Valid { + _, reason, err := api.userCanUseChatModelConfig( + ctx, + userID, + req.OrganizationID, + override.ModelConfigID.UUID, + ) + if err != nil { + return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ + Message: "Failed to resolve chat model config.", + Detail: err.Error(), + } } + if reason == chatModelConfigAvailable { + var effort *string + if override.ReasoningEffort.Valid { + effort = &override.ReasoningEffort.String + } + return override.ModelConfigID.UUID, effort, 0, 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), + ) } - if reason == chatModelConfigAvailable { - return parsed.ModelConfigID, parsed.ReasoningEffort, 0, nil - } - api.Logger.Debug( - ctx, - "personal root model override is unavailable, using default model", - slog.F("user_id", userID), - slog.F("model_config_id", parsed.ModelConfigID), - 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", parsed.Mode), + slog.F("mode", override.Mode), ) } } @@ -4856,107 +4697,148 @@ func readChatModelOverrideContext( 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/experimental/organizations/{organization}/chats/model-overrides [get] +// @x-apidocgen {"skip": true} +// // 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) getChatModelOverride(rw http.ResponseWriter, r *http.Request) { +func (api *API) getOrganizationChatModelOverrides(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) { - httpapi.ResourceNotFound(rw) - return - } - overrideContext, ok := readChatModelOverrideContext(rw, r) - if !ok { - return - } - - modelConfigID, reasoningEffort, isMalformed, label, err := api.readChatModelOverrideConfig(ctx, overrideContext) + organization := httpmw.OrganizationParam(r) + rows, err := api.Database.GetChatOrganizationModelOverrides(ctx, organization.ID) if err != nil { - if label == "" { - label = string(overrideContext) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Internal error fetching %s model override.", label), + Message: "Internal error fetching model overrides.", Detail: err.Error(), }) return } - - resp := codersdk.ChatModelOverrideResponse{ - Context: overrideContext, - ModelConfigID: formatChatModelOverride(modelConfigID, nil), - ReasoningEffort: reasoningEffort, - IsMalformed: isMalformed, + response := codersdk.ChatModelOverridesResponse{ + Overrides: make([]codersdk.ChatModelOverrideResponse, 0, len(rows)), } - - httpapi.Write(ctx, rw, http.StatusOK, resp) + 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/experimental/organizations/{organization}/chats/model-overrides/{context} [put] +// @x-apidocgen {"skip": true} +// // EXPERIMENTAL: this endpoint is experimental and is subject to change. -func (api *API) putChatModelOverride(rw http.ResponseWriter, r *http.Request) { +func (api *API) putOrganizationChatModelOverride(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Forbidden(rw) + 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 } - var modelConfigID *uuid.UUID + response := codersdk.ChatModelOverrideResponse{Context: overrideContext} trimmedModelConfigID := strings.TrimSpace(req.ModelConfigID) - if trimmedModelConfigID != "" { - if strings.Contains(trimmedModelConfigID, ":") { + if trimmedModelConfigID == "" { + if req.ReasoningEffort != 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), + Message: "reasoning_effort requires model_config_id.", }) return } - parsedModelConfigID, err := uuid.Parse(trimmedModelConfigID) + err := api.Database.DeleteChatOrganizationModelOverride(ctx, database.DeleteChatOrganizationModelOverrideParams{ + OrganizationID: organization.ID, + Context: string(overrideContext), + }) 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), + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: fmt.Sprintf("Internal error clearing %s model override.", overrideContext), + Detail: err.Error(), }) return } - modelConfigID = &parsedModelConfigID + httpapi.Write(ctx, rw, http.StatusOK, response) + return } - defaultOrgID, err := defaultOrganizationID(ctx, api.Database) + modelConfigID, err := uuid.Parse(trimmedModelConfigID) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error validating model config override.", - Detail: err.Error(), + 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 } - status, resp := validateChatModelOverride(ctx, api.Database, defaultOrgID, modelConfigID, req.ReasoningEffort) - if resp != nil { - httpapi.Write(ctx, rw, status, *resp) + status, validationResponse := validateChatModelOverride( + ctx, + api.Database, + organization.ID, + &modelConfigID, + req.ReasoningEffort, + ) + if validationResponse != nil { + httpapi.Write(ctx, rw, status, *validationResponse) return } - label, err := api.upsertChatModelOverrideConfig(ctx, overrideContext, modelConfigID, req.ReasoningEffort) + 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 { - if label == "" { - label = string(overrideContext) - } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Internal error updating %s model override.", label), + Message: fmt.Sprintf("Internal error updating %s model override.", overrideContext), Detail: err.Error(), }) return } - - rw.WriteHeader(http.StatusNoContent) + response = chatOrganizationModelOverrideResponse(row) + httpapi.Write(ctx, rw, http.StatusOK, response) } func readChatPersonalModelOverrideContext( @@ -5025,12 +4907,24 @@ func (api *API) putChatPersonalModelOverridesAdminSettings(rw http.ResponseWrite 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/experimental/organizations/{organization}/members/{user}/chats/model-overrides [get] +// @x-apidocgen {"skip": true} +// // 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) getUserChatPersonalModelOverrides(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 { @@ -5041,32 +4935,30 @@ func (api *API) getUserChatPersonalModelOverrides(rw http.ResponseWriter, r *htt return } - rows, err := api.Database.ListUserChatPersonalModelOverrides(ctx, apiKey.UserID) + 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 } - - values := make(map[codersdk.ChatPersonalModelOverrideContext]string, len(rows)) + byContext := make(map[string]database.ChatUserModelOverride, len(rows)) for _, row := range rows { - rawContext, ok := strings.CutPrefix(row.Key, chatd.ChatPersonalModelOverrideKeyPrefix) - if !ok { - continue - } - overrideContext, ok := parseChatPersonalModelOverrideContext(rawContext) - if !ok { - continue - } - values[overrideContext] = row.Value + byContext[row.Context] = row } - deploymentDefaults, err := api.chatPersonalModelOverrideDeploymentDefaults(ctx) + deploymentDefaults, err := api.chatPersonalModelOverrideDeploymentDefaults(ctx, organization.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching deployment model defaults.", + Message: "Internal error fetching organization model defaults.", Detail: err.Error(), }) return @@ -5077,8 +4969,11 @@ func (api *API) getUserChatPersonalModelOverrides(rw http.ResponseWriter, r *htt DeploymentDefaults: deploymentDefaults, } for _, overrideContext := range chatPersonalModelOverrideContexts { - raw, isSet := values[overrideContext] - override := chatPersonalModelOverrideResponse(overrideContext, raw, isSet) + 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 @@ -5091,10 +4986,25 @@ func (api *API) getUserChatPersonalModelOverrides(rw http.ResponseWriter, r *htt 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/experimental/organizations/{organization}/members/{user}/chats/model-overrides/{context} [put] +// @x-apidocgen {"skip": true} +// // EXPERIMENTAL: this endpoint is experimental and is subject to change. 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 { @@ -5104,7 +5014,8 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http }) return } - if !enabled { + 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.", }) @@ -5121,99 +5032,82 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http return } - modelConfigID := "" + 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.", - }) + 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.", - }) + 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.", - }) + 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.", - }) + 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.", - }) + 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.", - }) + 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 { + 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 } - if parsedModelConfigID == uuid.Nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid model_config_id.", - }) - return - } - defaultOrgID, err := defaultOrganizationID(ctx, api.Database) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve default organization.", - Detail: err.Error(), - }) - return - } - modelConfig, status, resp := api.validateUserChatModelConfigAvailable( - ctx, - apiKey.UserID, - defaultOrgID, - parsedModelConfigID, + modelConfig, status, validationResponse := api.validateUserChatModelConfigAvailable( + ctx, member.UserID, organization.ID, parsedModelConfigID, ) - if resp != nil { - httpapi.Write(ctx, rw, status, *resp) + if validationResponse != nil { + httpapi.Write(ctx, rw, status, *validationResponse) return } - status, resp = validateChatModelOverrideEffort(modelConfig, reasoningEffort) - if resp != nil { - httpapi.Write(ctx, rw, status, *resp) + status, validationResponse = validateChatModelOverrideEffort(modelConfig, reasoningEffort) + if validationResponse != nil { + httpapi.Write(ctx, rw, status, *validationResponse) return } - modelConfigID = parsedModelConfigID.String() + 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 personal model override mode.", + Message: "Invalid model_config_id: model config not found or disabled.", }) return } - - if err := api.Database.UpsertUserChatPersonalModelOverride(ctx, database.UpsertUserChatPersonalModelOverrideParams{ - UserID: apiKey.UserID, - Key: chatd.ChatPersonalModelOverrideKey(overrideContext), - Value: formatChatPersonalModelOverrideValue(req.Mode, modelConfigID, reasoningEffort), - }); err != nil { + 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(), @@ -5433,9 +5327,6 @@ func (api *API) getChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { } resp.MaxUsesPerRun = max(resp.MaxUsesPerRun, 0) resp.MaxOutputTokens = max(resp.MaxOutputTokens, 0) - if resp.ModelConfigID == uuid.Nil { - resp.ReasoningEffort = nil - } resp.Enabled = api.Experiments.Enabled(codersdk.ExperimentChatAdvisor) httpapi.Write(ctx, rw, http.StatusOK, resp) @@ -5453,6 +5344,12 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { 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/experimental/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), @@ -5465,46 +5362,13 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { }) return } - if req.ModelConfigID == uuid.Nil { - if req.ReasoningEffort != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "reasoning_effort requires model_config_id.", - }) - return - } - } else { - defaultOrgID, err := defaultOrganizationID(ctx, api.Database) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error validating advisor model config.", - Detail: err.Error(), - }) - return - } - modelConfig, err := lookupEnabledChatModelConfigByID(ctx, api.Database, req.ModelConfigID) - if err == nil && modelConfig.OrganizationID != defaultOrgID { - err = sql.ErrNoRows - } - if err != nil { - if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("model_config_id %q does not match any enabled model config.", req.ModelConfigID), - }) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error validating advisor model config.", - Detail: err.Error(), - }) - return - } - if status, response := validateChatModelOverrideEffort(modelConfig, req.ReasoningEffort); response != nil { - httpapi.Write(ctx, rw, status, *response) - return - } + + runtimeConfig := codersdk.AdvisorConfig{ + MaxUsesPerRun: req.MaxUsesPerRun, + MaxOutputTokens: req.MaxOutputTokens, } - raw, err := json.Marshal(req) + raw, err := json.Marshal(runtimeConfig) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error encoding advisor configuration.", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index a7363263f61..ffebdd28035 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -11586,9 +11586,10 @@ func TestManualTitleEndpointsPassOwnerSyntheticAPIKeyToAIGateway(t *testing.T) { } var factory aibridge.TransportFactory = stub api.AIBridgeTransportFactory.Store(&factory) - require.NoError(t, client.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextTitleGeneration, codersdk.UpdateChatModelOverrideRequest{ + _, err := client.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, codersdk.ChatModelOverrideContextTitleGeneration, codersdk.UpdateChatModelOverrideRequest{ ModelConfigID: modelConfig.ID.String(), - })) + }) + require.NoError(t, err) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -15255,377 +15256,126 @@ func TestChatPlanModeInstructions(t *testing.T) { }) } -//nolint:tparallel,paralleltest // Setting subtests share per-setting coderdtest instances. +//nolint:tparallel,paralleltest // Context subtests share per-context coderdtest instances. func TestChatModelOverrides(t *testing.T) { t.Parallel() - type overrideResponse struct { - context codersdk.ChatModelOverrideContext - modelConfigID string - reasoningEffort *string - isMalformed bool - } - - type settingTest struct { - name string - context codersdk.ChatModelOverrideContext - dbGet func(context.Context, database.Store) (string, error) - dbUpsert func(context.Context, database.Store, string) error - } - - settingPath := func(overrideContext codersdk.ChatModelOverrideContext) string { - return "/api/experimental/chats/config/model-override/" + string(overrideContext) - } - - getOverride := func( - ctx context.Context, - client *codersdk.ExperimentalClient, - overrideContext codersdk.ChatModelOverrideContext, - ) (overrideResponse, error) { - resp, err := client.GetChatModelOverride(ctx, overrideContext) - if err != nil { - return overrideResponse{}, err - } - return overrideResponse{ - context: resp.Context, - modelConfigID: resp.ModelConfigID, - reasoningEffort: resp.ReasoningEffort, - isMalformed: resp.IsMalformed, - }, nil - } - - putOverrideWithEffort := func( - ctx context.Context, - client *codersdk.ExperimentalClient, - overrideContext codersdk.ChatModelOverrideContext, - modelConfigID string, - reasoningEffort *string, - ) error { - return client.UpdateChatModelOverride( - ctx, - overrideContext, - codersdk.UpdateChatModelOverrideRequest{ - ModelConfigID: modelConfigID, - ReasoningEffort: reasoningEffort, - }, - ) - } - putOverride := func( - ctx context.Context, - client *codersdk.ExperimentalClient, - overrideContext codersdk.ChatModelOverrideContext, - modelConfigID string, - ) error { - return putOverrideWithEffort(ctx, client, overrideContext, modelConfigID, nil) - } - - settings := []settingTest{ - { - name: "General", - context: codersdk.ChatModelOverrideContextGeneral, - dbGet: func(ctx context.Context, db database.Store) (string, error) { - return db.GetChatGeneralModelOverride(dbauthz.AsSystemRestricted(ctx)) - }, - dbUpsert: func(ctx context.Context, db database.Store, value string) error { - return db.UpsertChatGeneralModelOverride(dbauthz.AsSystemRestricted(ctx), value) - }, - }, - { - name: "Explore", - context: codersdk.ChatModelOverrideContextExplore, - dbGet: func(ctx context.Context, db database.Store) (string, error) { - return db.GetChatExploreModelOverride(dbauthz.AsSystemRestricted(ctx)) - }, - dbUpsert: func(ctx context.Context, db database.Store, value string) error { - return db.UpsertChatExploreModelOverride(dbauthz.AsSystemRestricted(ctx), value) - }, - }, - { - name: "TitleGeneration", - context: codersdk.ChatModelOverrideContextTitleGeneration, - dbGet: func(ctx context.Context, db database.Store) (string, error) { - return db.GetChatTitleGenerationModelOverride(dbauthz.AsSystemRestricted(ctx)) - }, - dbUpsert: func(ctx context.Context, db database.Store, value string) error { - return db.UpsertChatTitleGenerationModelOverride(dbauthz.AsSystemRestricted(ctx), value) - }, - }, - { - name: "Compaction", - context: codersdk.ChatModelOverrideContextCompaction, - dbGet: func(ctx context.Context, db database.Store) (string, error) { - return db.GetChatCompactionModelOverride(dbauthz.AsSystemRestricted(ctx)) - }, - dbUpsert: func(ctx context.Context, db database.Store, value string) error { - return db.UpsertChatCompactionModelOverride(dbauthz.AsSystemRestricted(ctx), value) - }, - }, + contexts := []codersdk.ChatModelOverrideContext{ + codersdk.ChatModelOverrideContextGeneral, + codersdk.ChatModelOverrideContextExplore, + codersdk.ChatModelOverrideContextTitleGeneration, + codersdk.ChatModelOverrideContextCompaction, + codersdk.ChatModelOverrideContextAdvisor, } - - for _, setting := range settings { - t.Run(setting.name, func(t *testing.T) { + for _, overrideContext := range contexts { + t.Run(string(overrideContext), func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) defaultModel := createChatModel(t, adminClient) - openAIModel := createAdditionalChatModel( - t, - adminClient, - coderdtest.TestChatProviderOpenAICompat, - "gpt-4.1-mini-"+string(setting.context), - ) + model := createAdditionalChatModel(t, adminClient, coderdtest.TestChatProviderOpenAICompat, "override-"+string(overrideContext)) reasoningModel := createAdditionalChatModelWithReasoningEffort( - t, - adminClient, - coderdtest.TestChatProviderOpenAICompat, - "gpt-4.1-reasoning-"+string(setting.context), - "medium", - "high", - ) - disabledModel := createDisabledChatModel( - t, - adminClient, - coderdtest.TestChatProviderOpenAICompat, - "gpt-4.1-disabled-"+string(setting.context), + t, adminClient, coderdtest.TestChatProviderOpenAICompat, + "reasoning-"+string(overrideContext), "medium", "high", ) + disabledModel := createDisabledChatModel(t, adminClient, coderdtest.TestChatProviderOpenAICompat, "disabled-"+string(overrideContext)) otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - otherOrgConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + otherOrgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true}, - Model: "other-org-override-" + uuid.NewString(), + Model: "other-org-" + uuid.NewString(), Enabled: true, OrganizationID: otherOrg.ID, }) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - t.Run("DefaultGETReturnsEmpty", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - resp, err := getOverride(ctx, adminClient, setting.context) - require.NoError(t, err) - require.Equal(t, setting.context, resp.context) - require.Empty(t, resp.modelConfigID) - require.False(t, resp.isMalformed) - - raw, err := setting.dbGet(ctx, db) - require.NoError(t, err) - require.Empty(t, raw, "expected empty stored override for %s", settingPath(setting.context)) - }) - - t.Run("AdminCanSetAndClear", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverride(ctx, adminClient, setting.context, openAIModel.ID.String()) - require.NoError(t, err) - - raw, err := setting.dbGet(ctx, db) - require.NoError(t, err) - require.Equal(t, openAIModel.ID.String(), raw, "expected stored override for %s", settingPath(setting.context)) - - resp, err := getOverride(ctx, adminClient, setting.context) - require.NoError(t, err) - require.Equal(t, setting.context, resp.context) - require.Equal(t, openAIModel.ID.String(), resp.modelConfigID) - require.False(t, resp.isMalformed) - - err = putOverride(ctx, adminClient, setting.context, "") - require.NoError(t, err) - - raw, err = setting.dbGet(ctx, db) - require.NoError(t, err) - require.Empty(t, raw, "expected cleared override for %s", settingPath(setting.context)) - - resp, err = getOverride(ctx, adminClient, setting.context) - require.NoError(t, err) - require.Equal(t, setting.context, resp.context) - require.Empty(t, resp.modelConfigID) - require.False(t, resp.isMalformed) - }) - - t.Run("AdminCanSetReasoningEffort", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverrideWithEffort(ctx, adminClient, setting.context, reasoningModel.ID.String(), ptr.Ref("high")) - require.NoError(t, err) - - raw, err := setting.dbGet(ctx, db) - require.NoError(t, err) - require.Equal(t, reasoningModel.ID.String()+":high", raw) - - resp, err := getOverride(ctx, adminClient, setting.context) - require.NoError(t, err) - require.Equal(t, reasoningModel.ID.String(), resp.modelConfigID) - require.Equal(t, ptr.Ref("high"), resp.reasoningEffort) - require.False(t, resp.isMalformed) - - err = putOverride(ctx, adminClient, setting.context, "") - require.NoError(t, err) - }) - - t.Run("PUTRejectsEncodedModelConfigID", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - encodedModelConfigID := reasoningModel.ID.String() + ":high" - err := putOverride(ctx, adminClient, setting.context, encodedModelConfigID) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model_config_id.", sdkErr.Message) - require.Equal(t, "Value "+strconv.Quote(encodedModelConfigID)+" is not a valid UUID.", sdkErr.Detail) - }) - - t.Run("ReasoningEffortRequiresModel", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverrideWithEffort(ctx, adminClient, setting.context, "", ptr.Ref("high")) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "reasoning_effort requires model_config_id.", sdkErr.Message) - }) - - t.Run("ReasoningEffortMustBeSelectable", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverrideWithEffort(ctx, adminClient, setting.context, reasoningModel.ID.String(), ptr.Ref("xhigh")) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) - require.Equal(t, "Must be one of none, minimal, low, medium, high.", sdkErr.Detail) - }) - - t.Run("ReasoningEffortUnsupportedModel", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverrideWithEffort(ctx, adminClient, setting.context, openAIModel.ID.String(), ptr.Ref("high")) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) - require.Equal(t, "This model does not support reasoning effort.", sdkErr.Detail) - }) - - t.Run("MalformedStoredOverrideIsReportedAndCanBeCleared", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - require.NoError(t, setting.dbUpsert(ctx, db, "not-a-uuid")) - - resp, err := getOverride(ctx, adminClient, setting.context) - require.NoError(t, err) - require.Equal(t, setting.context, resp.context) - require.Empty(t, resp.modelConfigID) - require.True(t, resp.isMalformed) - - err = putOverride(ctx, adminClient, setting.context, "") - require.NoError(t, err) - - raw, err := setting.dbGet(ctx, db) - require.NoError(t, err) - require.Empty(t, raw, "expected malformed override to be cleared for %s", settingPath(setting.context)) - - resp, err = getOverride(ctx, adminClient, setting.context) - require.NoError(t, err) - require.Equal(t, setting.context, resp.context) - require.Empty(t, resp.modelConfigID) - require.False(t, resp.isMalformed) - }) - - t.Run("InvalidUUIDReturns400", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverride(ctx, adminClient, setting.context, "not-a-uuid") - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model_config_id.", sdkErr.Message) - require.Equal(t, "Value \"not-a-uuid\" is not a valid UUID.", sdkErr.Detail) - }) - - t.Run("DisabledModelReturns400", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - err := putOverride(ctx, adminClient, setting.context, disabledModel.ID.String()) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model_config_id.", sdkErr.Message) - }) - - t.Run("ProviderDisabledModelReturns400", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) + resp, err := adminClient.OrganizationChatModelOverrides(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Empty(t, resp.Overrides) - providerDisabledModel := createProviderDisabledChatModel( - t, - adminClient, - "openai", - "gpt-4.1-provider-disabled-"+string(setting.context), - ) - err := putOverride(ctx, adminClient, setting.context, providerDisabledModel.ID.String()) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model_config_id.", sdkErr.Message) + updated, err := adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, overrideContext, codersdk.UpdateChatModelOverrideRequest{ + ModelConfigID: model.ID.String(), }) + require.NoError(t, err) + require.Equal(t, overrideContext, updated.Context) + require.Equal(t, model.ID.String(), updated.ModelConfigID) + require.Nil(t, updated.ReasoningEffort) - t.Run("UnknownModelReturns400", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - unknownModelID := uuid.New() - - err := putOverride(ctx, adminClient, setting.context, unknownModelID.String()) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model_config_id.", sdkErr.Message) + stored, err := db.GetChatOrganizationModelOverride(dbauthz.AsSystemRestricted(ctx), database.GetChatOrganizationModelOverrideParams{ + OrganizationID: firstUser.OrganizationID, + Context: string(overrideContext), }) + require.NoError(t, err) + require.Equal(t, model.ID, stored.ModelConfigID) - t.Run("OtherOrganizationModelReturns400", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) + resp, err = adminClient.OrganizationChatModelOverrides(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Equal(t, []codersdk.ChatModelOverrideResponse{updated}, resp.Overrides) - err := putOverride(ctx, adminClient, setting.context, otherOrgConfig.ID.String()) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model_config_id.", sdkErr.Message) + updated, err = adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, overrideContext, codersdk.UpdateChatModelOverrideRequest{ + ModelConfigID: reasoningModel.ID.String(), + ReasoningEffort: ptr.Ref("high"), }) + require.NoError(t, err) + require.Equal(t, reasoningModel.ID.String(), updated.ModelConfigID) + require.Equal(t, ptr.Ref("high"), updated.ReasoningEffort) - t.Run("NonAdminGETReturns404", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) - - _, err := getOverride(ctx, memberClient, setting.context) - requireSDKError(t, err, http.StatusNotFound) - }) + for _, tc := range []struct { + name string + req codersdk.UpdateChatModelOverrideRequest + }{ + {name: "InvalidUUID", req: codersdk.UpdateChatModelOverrideRequest{ModelConfigID: "not-a-uuid"}}, + {name: "DisabledModel", req: codersdk.UpdateChatModelOverrideRequest{ModelConfigID: disabledModel.ID.String()}}, + {name: "UnknownModel", req: codersdk.UpdateChatModelOverrideRequest{ModelConfigID: uuid.NewString()}}, + {name: "OtherOrganization", req: codersdk.UpdateChatModelOverrideRequest{ModelConfigID: otherOrgModel.ID.String()}}, + {name: "EffortWithoutModel", req: codersdk.UpdateChatModelOverrideRequest{ReasoningEffort: ptr.Ref("high")}}, + {name: "UnsupportedEffort", req: codersdk.UpdateChatModelOverrideRequest{ModelConfigID: model.ID.String(), ReasoningEffort: ptr.Ref("high")}}, + {name: "UnselectableEffort", req: codersdk.UpdateChatModelOverrideRequest{ModelConfigID: reasoningModel.ID.String(), ReasoningEffort: ptr.Ref("xhigh")}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, overrideContext, tc.req) + requireSDKError(t, err, http.StatusBadRequest) + }) + } - t.Run("NonAdminPUTReturns403", func(t *testing.T) { - ctx := testutil.Context(t, testutil.WaitLong) + // Org members can read overrides (the personal settings page + // shows effective defaults) but cannot write them. + memberResp, err := memberClient.OrganizationChatModelOverrides(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Len(t, memberResp.Overrides, 1) + _, err = memberClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, overrideContext, codersdk.UpdateChatModelOverrideRequest{ModelConfigID: defaultModel.ID.String()}) + requireSDKError(t, err, http.StatusNotFound) - err := putOverride(ctx, memberClient, setting.context, defaultModel.ID.String()) - requireSDKError(t, err, http.StatusForbidden) + cleared, err := adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, overrideContext, codersdk.UpdateChatModelOverrideRequest{}) + require.NoError(t, err) + require.Equal(t, overrideContext, cleared.Context) + require.Empty(t, cleared.ModelConfigID) + _, err = db.GetChatOrganizationModelOverride(dbauthz.AsSystemRestricted(ctx), database.GetChatOrganizationModelOverrideParams{ + OrganizationID: firstUser.OrganizationID, + Context: string(overrideContext), }) + require.ErrorIs(t, err, sql.ErrNoRows) }) } - t.Run("UnknownContextReturns400", func(t *testing.T) { + t.Run("UnknownContext", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - unknownContext := codersdk.ChatModelOverrideContext("not-a-context") - - _, err := getOverride(ctx, adminClient, unknownContext) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + _, err := adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, "not-a-context", codersdk.UpdateChatModelOverrideRequest{}) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid chat model override context.", sdkErr.Message) - require.Equal( - t, - `Expected one of general, explore, title_generation, compaction. Got "not-a-context".`, - sdkErr.Detail, - ) - - err = putOverride(ctx, adminClient, unknownContext, "") - sdkErr = requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid chat model override context.", sdkErr.Message) - require.Equal( - t, - `Expected one of general, explore, title_generation, compaction. Got "not-a-context".`, - sdkErr.Detail, - ) + require.Contains(t, sdkErr.Detail, "general, explore, title_generation, compaction, advisor") }) - t.Run("NonAdminUnknownContextUsesAuthResponse", func(t *testing.T) { + t.Run("LegacyRoutesRemoved", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) - memberClient := codersdk.NewExperimentalClient(memberClientRaw) - unknownContext := codersdk.ChatModelOverrideContext("not-a-context") - - _, err := getOverride(ctx, memberClient, unknownContext) - requireSDKError(t, err, http.StatusNotFound) - - err = putOverride(ctx, memberClient, unknownContext, "") - requireSDKError(t, err, http.StatusForbidden) + coderdtest.CreateFirstUser(t, adminClient.Client) + res, err := adminClient.Request(ctx, http.MethodGet, "/api/experimental/chats/config/model-override/general", nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) }) } @@ -15677,74 +15427,48 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) memberClientRaw, member := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - noKeyClientRaw, noKeyUser := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) - noKeyClient := codersdk.NewExperimentalClient(noKeyClientRaw) + otherMemberClientRaw, otherMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + otherMemberClient := codersdk.NewExperimentalClient(otherMemberClientRaw) - defaultModelConfig := createChatModel(t, adminClient) - provider := enableUserChatProviderKey(t, adminClient, memberClient, coderdtest.TestChatProviderOpenAICompat) + defaultModel := createChatModel(t, adminClient) + _ = enableUserChatProviderKey(t, adminClient, memberClient, coderdtest.TestChatProviderOpenAICompat) modelProvider := createAIProviderForTest(t, adminClient, "anthropic", "") - _, err := memberClient.UpsertUserAIProviderKey(ctx, "me", modelProvider.ID, codersdk.CreateUserAIProviderKeyRequest{ + _, err := memberClient.UpsertUserAIProviderKey(ctx, codersdk.Me, modelProvider.ID, codersdk.CreateUserAIProviderKeyRequest{ APIKey: "test-user-api-key-" + uuid.NewString(), }) require.NoError(t, err) contextLimit := int64(4096) - modelConfigRequest := codersdk.CreateChatModelRequest{ + model, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ AIProviderID: &modelProvider.ID, Model: "claude-personal-" + uuid.NewString(), ContextLimit: &contextLimit, - } - modelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, modelConfigRequest) - require.NoError(t, err) - modelConfigRequest.Model = "claude-personal-reasoning-" + uuid.NewString() - modelConfigRequest.ModelConfig = &codersdk.ChatModelCallConfig{ - ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ - Default: ptr.Ref("medium"), - Max: ptr.Ref("high"), - }, - } - reasoningModelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, modelConfigRequest) - require.NoError(t, err) - err = adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{ - ModelConfigID: modelConfig.ID.String(), }) require.NoError(t, err) - err = adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextExplore, codersdk.UpdateChatModelOverrideRequest{ - ModelConfigID: defaultModelConfig.ID.String(), - }) - require.NoError(t, err) - - disabledModelConfig := createDisabledChatModel( - t, - adminClient, - coderdtest.TestChatProviderOpenAICompat, - "gpt-4o-personal-disabled-"+uuid.NewString(), - ) - disabledProvider := createAIProviderForTest(t, adminClient, "google", "test-api-key") - disabledProviderModelConfig, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ - AIProviderID: &disabledProvider.ID, - Model: "gemini-personal-disabled-provider-" + uuid.NewString(), + reasoningModel, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &modelProvider.ID, + Model: "claude-personal-reasoning-" + uuid.NewString(), ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("medium"), + Max: ptr.Ref("high"), + }}, }) require.NoError(t, err) + disabledModel := createDisabledChatModel(t, adminClient, coderdtest.TestChatProviderOpenAICompat, "disabled-personal-"+uuid.NewString()) otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - otherOrgModelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - AIProviderID: uuid.NullUUID{UUID: defaultModelConfig.AIProviderID, Valid: true}, - Model: "personal-other-org-" + uuid.NewString(), + otherOrgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true}, + Model: "other-org-personal-" + uuid.NewString(), Enabled: true, OrganizationID: otherOrg.ID, }) - enabled := false - disabledProvider, err = adminClient.UpdateAIProvider(ctx, disabledProvider.ID.String(), codersdk.UpdateAIProviderRequest{ - Enabled: &enabled, - }) + + _, err = adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{ModelConfigID: model.ID.String()}) + require.NoError(t, err) + _, err = adminClient.UpdateOrganizationChatModelOverride(ctx, firstUser.OrganizationID, codersdk.ChatModelOverrideContextExplore, codersdk.UpdateChatModelOverrideRequest{ModelConfigID: defaultModel.ID.String()}) require.NoError(t, err) - require.NotEqual(t, uuid.Nil, provider.ID) - require.NotEqual(t, uuid.Nil, disabledProvider.ID) - personalOverride := func( - resp codersdk.UserChatPersonalModelOverridesResponse, - overrideContext codersdk.ChatPersonalModelOverrideContext, - ) codersdk.ChatPersonalModelOverride { + personalOverride := func(resp codersdk.UserChatPersonalModelOverridesResponse, overrideContext codersdk.ChatPersonalModelOverrideContext) codersdk.ChatPersonalModelOverride { t.Helper() switch overrideContext { case codersdk.ChatPersonalModelOverrideContextRoot: @@ -15758,388 +15482,159 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { return codersdk.ChatPersonalModelOverride{} } } - assertOverrideWithEffort := func( - resp codersdk.UserChatPersonalModelOverridesResponse, - overrideContext codersdk.ChatPersonalModelOverrideContext, - mode codersdk.ChatPersonalModelOverrideMode, - modelConfigID string, - reasoningEffort *string, - isSet bool, - isMalformed bool, - ) { + assertOverride := func(resp codersdk.UserChatPersonalModelOverridesResponse, overrideContext codersdk.ChatPersonalModelOverrideContext, mode codersdk.ChatPersonalModelOverrideMode, modelConfigID string, effort *string, isSet bool) { t.Helper() override := personalOverride(resp, overrideContext) require.Equal(t, overrideContext, override.Context) require.Equal(t, mode, override.Mode) require.Equal(t, modelConfigID, override.ModelConfigID) - require.Equal(t, reasoningEffort, override.ReasoningEffort) + require.Equal(t, effort, override.ReasoningEffort) require.Equal(t, isSet, override.IsSet) - require.Equal(t, isMalformed, override.IsMalformed) - } - assertOverride := func( - resp codersdk.UserChatPersonalModelOverridesResponse, - overrideContext codersdk.ChatPersonalModelOverrideContext, - mode codersdk.ChatPersonalModelOverrideMode, - modelConfigID string, - isSet bool, - isMalformed bool, - ) { - t.Helper() - assertOverrideWithEffort(resp, overrideContext, mode, modelConfigID, nil, isSet, isMalformed) - } - assertDeploymentDefault := func( - resp codersdk.UserChatPersonalModelOverridesResponse, - overrideContext codersdk.ChatModelOverrideContext, - modelConfigID string, - reasoningEffort *string, - isMalformed bool, - ) { - t.Helper() - var override codersdk.ChatModelOverrideResponse - switch overrideContext { - case codersdk.ChatModelOverrideContextGeneral: - override = resp.DeploymentDefaults.General - case codersdk.ChatModelOverrideContextExplore: - override = resp.DeploymentDefaults.Explore - default: - t.Fatalf("unexpected deployment model override context %q", overrideContext) - } - require.Equal(t, overrideContext, override.Context) - require.Equal(t, modelConfigID, override.ModelConfigID) - require.Equal(t, reasoningEffort, override.ReasoningEffort) - require.Equal(t, isMalformed, override.IsMalformed) - } - upsertRaw := func( - overrideContext codersdk.ChatPersonalModelOverrideContext, - value string, - ) { - t.Helper() - err := db.UpsertUserChatPersonalModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertUserChatPersonalModelOverrideParams{ - UserID: member.ID, - Key: chatd.ChatPersonalModelOverrideKey(overrideContext), - Value: value, - }) - require.NoError(t, err) } - getRawFor := func(userID uuid.UUID, overrideContext codersdk.ChatPersonalModelOverrideContext) string { + getRow := func(userID uuid.UUID, organizationID uuid.UUID, overrideContext codersdk.ChatPersonalModelOverrideContext) (database.ChatUserModelOverride, error) { t.Helper() - raw, err := db.GetUserChatPersonalModelOverride(dbauthz.AsSystemRestricted(ctx), database.GetUserChatPersonalModelOverrideParams{ - UserID: userID, - Key: chatd.ChatPersonalModelOverrideKey(overrideContext), + return db.GetChatUserModelOverride(dbauthz.AsSystemRestricted(ctx), database.GetChatUserModelOverrideParams{ + UserID: userID, OrganizationID: organizationID, Context: string(overrideContext), }) - if stderrors.Is(err, sql.ErrNoRows) { - return "" - } - require.NoError(t, err) - return raw - } - getRaw := func(overrideContext codersdk.ChatPersonalModelOverrideContext) string { - t.Helper() - return getRawFor(member.ID, overrideContext) } - t.Run("GETDisabledReturnsMissingDefaults", func(t *testing.T) { - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) - require.NoError(t, err) - require.False(t, resp.Enabled) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideModeChatDefault, "", false, false) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", false, false) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextExplore, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", false, false) - }) - - upsertRaw(codersdk.ChatPersonalModelOverrideContextRoot, string(codersdk.ChatPersonalModelOverrideModeChatDefault)) - upsertRaw(codersdk.ChatPersonalModelOverrideContextGeneral, string(codersdk.ChatPersonalModelOverrideModeDeploymentDefault)) - upsertRaw(codersdk.ChatPersonalModelOverrideContextExplore, "model:"+modelConfig.ID.String()) - - t.Run("GETDisabledReturnsSavedValues", func(t *testing.T) { - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + t.Run("DisabledReturnsDefaultsAndRejectsSelfWrite", func(t *testing.T) { + resp, err := memberClient.UserChatPersonalModelOverrides(ctx, firstUser.OrganizationID, codersdk.Me) require.NoError(t, err) require.False(t, resp.Enabled) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideModeChatDefault, "", true, false) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", true, false) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextExplore, codersdk.ChatPersonalModelOverrideModeModel, modelConfig.ID.String(), true, false) - }) + assertOverride(resp, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideModeChatDefault, "", nil, false) + assertOverride(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", nil, false) + require.Equal(t, model.ID.String(), resp.DeploymentDefaults.General.ModelConfigID) + require.Equal(t, defaultModel.ID.String(), resp.DeploymentDefaults.Explore.ModelConfigID) - t.Run("GETIncludesDeploymentDefaults", func(t *testing.T) { - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) - require.NoError(t, err) - assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextGeneral, modelConfig.ID.String(), nil, false) - assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextExplore, defaultModelConfig.ID.String(), nil, false) + err = memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, + }) + requireSDKError(t, err, http.StatusForbidden) }) - t.Run("GETIncludesDeploymentDefaultReasoningEffort", func(t *testing.T) { - err := adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{ - ModelConfigID: reasoningModelConfig.ID.String(), - ReasoningEffort: ptr.Ref("high"), + t.Run("AdminCanWriteAndReadAnotherUserWhileDisabled", func(t *testing.T) { + err := adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, member.ID.String(), codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, }) require.NoError(t, err) - - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + resp, err := adminClient.UserChatPersonalModelOverrides(ctx, firstUser.OrganizationID, member.ID.String()) require.NoError(t, err) - assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextGeneral, reasoningModelConfig.ID.String(), ptr.Ref("high"), false) - }) - - t.Run("PUTDisabledReturns403AndPreservesRows", func(t *testing.T) { - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfig.ID.String(), - }) - requireSDKError(t, err, http.StatusForbidden) - require.Equal(t, string(codersdk.ChatPersonalModelOverrideModeChatDefault), getRaw(codersdk.ChatPersonalModelOverrideContextRoot)) + assertOverride(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", nil, true) }) - err = adminClient.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{ - AllowUsers: true, - }) + err = adminClient.UpdateChatPersonalModelOverridesAdminSettings(ctx, codersdk.UpdateChatPersonalModelOverridesAdminSettingsRequest{AllowUsers: true}) require.NoError(t, err) - contexts := []codersdk.ChatPersonalModelOverrideContext{ - codersdk.ChatPersonalModelOverrideContextRoot, - codersdk.ChatPersonalModelOverrideContextGeneral, - codersdk.ChatPersonalModelOverrideContextExplore, - } - - t.Run("PUTRejectsUnknownMode", func(t *testing.T) { - rawBefore := getRaw(codersdk.ChatPersonalModelOverrideContextGeneral) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideMode("banana"), + t.Run("NonAdminCannotWriteAnotherUser", func(t *testing.T) { + err := memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, otherMember.ID.String(), codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "Invalid personal model override mode.") - require.Equal(t, rawBefore, getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) + requireSDKError(t, err, http.StatusNotFound) + _, err = otherMemberClient.UserChatPersonalModelOverrides(ctx, firstUser.OrganizationID, codersdk.Me) + require.NoError(t, err) }) - t.Run("PUTChatDefaultRoundTrips", func(t *testing.T) { - for _, overrideContext := range contexts { - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, + t.Run("ModesRoundTrip", func(t *testing.T) { + for _, overrideContext := range []codersdk.ChatPersonalModelOverrideContext{ + codersdk.ChatPersonalModelOverrideContextRoot, + codersdk.ChatPersonalModelOverrideContextGeneral, + codersdk.ChatPersonalModelOverrideContextExplore, + } { + err := memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: model.ID.String(), }) require.NoError(t, err) } - - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + resp, err := memberClient.UserChatPersonalModelOverrides(ctx, firstUser.OrganizationID, codersdk.Me) require.NoError(t, err) require.True(t, resp.Enabled) - for _, overrideContext := range contexts { - assertOverride(resp, overrideContext, codersdk.ChatPersonalModelOverrideModeChatDefault, "", true, false) - } - }) - - t.Run("PUTChatDefaultRejectsNonEmptyModelConfigID", func(t *testing.T) { - rawBefore := getRaw(codersdk.ChatPersonalModelOverrideContextRoot) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, - ModelConfigID: modelConfig.ID.String(), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "model_config_id must be empty") - require.Equal(t, rawBefore, getRaw(codersdk.ChatPersonalModelOverrideContextRoot)) - }) - - t.Run("PUTDeploymentDefaultRoundTripsForAgentContexts", func(t *testing.T) { for _, overrideContext := range []codersdk.ChatPersonalModelOverrideContext{ + codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideContextExplore, } { - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - }) - require.NoError(t, err) + assertOverride(resp, overrideContext, codersdk.ChatPersonalModelOverrideModeModel, model.ID.String(), nil, true) } - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) - require.NoError(t, err) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", true, false) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextExplore, codersdk.ChatPersonalModelOverrideModeDeploymentDefault, "", true, false) - }) - - t.Run("PUTDeploymentDefaultRejectsNonEmptyModelConfigID", func(t *testing.T) { - rawBefore := getRaw(codersdk.ChatPersonalModelOverrideContextGeneral) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - ModelConfigID: modelConfig.ID.String(), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "model_config_id must be empty") - require.Equal(t, rawBefore, getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) - }) - - t.Run("PUTDeploymentDefaultRejectsRoot", func(t *testing.T) { - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, + err = memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: reasoningModel.ID.String(), ReasoningEffort: ptr.Ref("high"), }) - requireSDKError(t, err, http.StatusBadRequest) - }) - - t.Run("PUTModelRoundTrips", func(t *testing.T) { - for _, overrideContext := range contexts { - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfig.ID.String(), - }) - require.NoError(t, err) - } - - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) require.NoError(t, err) - for _, overrideContext := range contexts { - assertOverride(resp, overrideContext, codersdk.ChatPersonalModelOverrideModeModel, modelConfig.ID.String(), true, false) - } - }) - - t.Run("PUTModelRoundTripsReasoningEffort", func(t *testing.T) { - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: reasoningModelConfig.ID.String(), - ReasoningEffort: ptr.Ref("high"), - }) + resp, err = memberClient.UserChatPersonalModelOverrides(ctx, firstUser.OrganizationID, codersdk.Me) require.NoError(t, err) + assertOverride(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeModel, reasoningModel.ID.String(), ptr.Ref("high"), true) - require.Equal(t, "model:"+reasoningModelConfig.ID.String()+":high", getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + for _, overrideContext := range []codersdk.ChatPersonalModelOverrideContext{ + codersdk.ChatPersonalModelOverrideContextGeneral, + codersdk.ChatPersonalModelOverrideContextExplore, + } { + err = memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, overrideContext, codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault}) + require.NoError(t, err) + } + err = memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeChatDefault}) require.NoError(t, err) - assertOverrideWithEffort(resp, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeModel, reasoningModelConfig.ID.String(), ptr.Ref("high"), true, false) - }) - - t.Run("PUTReasoningEffortRejectsNonModelMode", func(t *testing.T) { - rawBefore := getRaw(codersdk.ChatPersonalModelOverrideContextGeneral) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - ReasoningEffort: ptr.Ref("high"), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "reasoning_effort requires mode model.", sdkErr.Message) - require.Equal(t, rawBefore, getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) - }) - - t.Run("PUTReasoningEffortMustBeSelectable", func(t *testing.T) { - rawBefore := getRaw(codersdk.ChatPersonalModelOverrideContextGeneral) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: reasoningModelConfig.ID.String(), - ReasoningEffort: ptr.Ref("xhigh"), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) - require.Equal(t, "Must be one of none, minimal, low, medium, high.", sdkErr.Detail) - require.Equal(t, rawBefore, getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) - }) - - t.Run("PUTReasoningEffortUnsupportedModel", func(t *testing.T) { - rawBefore := getRaw(codersdk.ChatPersonalModelOverrideContextGeneral) - err := memberClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfig.ID.String(), - ReasoningEffort: ptr.Ref("high"), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) - require.Equal(t, "This model does not support reasoning effort.", sdkErr.Detail) - require.Equal(t, rawBefore, getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) }) - t.Run("PUTModelRejectsInvalidModels", func(t *testing.T) { + t.Run("ModeValidation", func(t *testing.T) { cases := []struct { - name string - client *codersdk.ExperimentalClient - userID uuid.UUID - modelConfigID string - wantMessageSubstring string + name string + context codersdk.ChatPersonalModelOverrideContext + req codersdk.UpdateUserChatPersonalModelOverrideRequest }{ - { - name: "Nil", - client: memberClient, - userID: member.ID, - modelConfigID: uuid.Nil.String(), - wantMessageSubstring: "Invalid model_config_id", - }, - { - name: "Empty", - client: memberClient, - userID: member.ID, - modelConfigID: "", - wantMessageSubstring: "model_config_id is required", - }, - { - name: "Malformed", - client: memberClient, - userID: member.ID, - modelConfigID: "not-a-uuid", - wantMessageSubstring: "Invalid model_config_id", - }, - { - name: "Unknown", - client: memberClient, - userID: member.ID, - modelConfigID: uuid.NewString(), - wantMessageSubstring: "Invalid model_config_id: model config " + - "not found or disabled.", - }, - { - name: "Disabled", - client: memberClient, - userID: member.ID, - modelConfigID: disabledModelConfig.ID.String(), - wantMessageSubstring: "Invalid model_config_id: model config " + - "not found or disabled.", - }, - { - name: "OtherOrganization", - client: memberClient, - userID: member.ID, - modelConfigID: otherOrgModelConfig.ID.String(), - wantMessageSubstring: "Invalid model_config_id: model config " + - "not found or disabled.", - }, - { - name: "ProviderDisabled", - client: memberClient, - userID: member.ID, - modelConfigID: disabledProviderModelConfig.ID.String(), - wantMessageSubstring: "provider is not enabled", - }, - { - name: "CredentialUnavailable", - client: noKeyClient, - userID: noKeyUser.ID, - modelConfigID: modelConfig.ID.String(), - wantMessageSubstring: "Invalid model_config_id: provider " + - "credentials unavailable for this model.", - }, + {name: "UnknownMode", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: "banana"}}, + {name: "ModelRequiresID", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeModel}}, + {name: "ChatDefaultRejectsID", context: codersdk.ChatPersonalModelOverrideContextRoot, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, ModelConfigID: model.ID.String()}}, + {name: "ChatDefaultRejectsEffort", context: codersdk.ChatPersonalModelOverrideContextRoot, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, ReasoningEffort: ptr.Ref("high")}}, + {name: "DeploymentDefaultRejectsID", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, ModelConfigID: model.ID.String()}}, + {name: "DeploymentDefaultRejectsEffort", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, ReasoningEffort: ptr.Ref("high")}}, + {name: "RootRejectsDeploymentDefault", context: codersdk.ChatPersonalModelOverrideContextRoot, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault}}, + {name: "InvalidUUID", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: "not-a-uuid"}}, + {name: "UnsupportedEffort", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: model.ID.String(), ReasoningEffort: ptr.Ref("high")}}, + {name: "UnselectableEffort", context: codersdk.ChatPersonalModelOverrideContextGeneral, req: codersdk.UpdateUserChatPersonalModelOverrideRequest{Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: reasoningModel.ID.String(), ReasoningEffort: ptr.Ref("xhigh")}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - rawBefore := getRawFor(tc.userID, codersdk.ChatPersonalModelOverrideContextGeneral) - err := tc.client.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: tc.modelConfigID, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, tc.wantMessageSubstring) - rawAfter := getRawFor(tc.userID, codersdk.ChatPersonalModelOverrideContextGeneral) - require.Equal(t, rawBefore, rawAfter) + err := memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, tc.context, tc.req) + requireSDKError(t, err, http.StatusBadRequest) }) } }) - t.Run("GETMalformedStoredValueFallsBackToContextDefault", func(t *testing.T) { - upsertRaw(codersdk.ChatPersonalModelOverrideContextRoot, "model:not-a-uuid") + t.Run("RejectsUnavailableModels", func(t *testing.T) { + for _, modelConfigID := range []string{uuid.NewString(), disabledModel.ID.String(), otherOrgModel.ID.String()} { + err := memberClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: modelConfigID, + }) + requireSDKError(t, err, http.StatusBadRequest) + } + }) - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + t.Run("OrganizationIsolation", func(t *testing.T) { + dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: otherOrg.ID, UserID: member.ID}) + err := db.UpsertChatUserModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertChatUserModelOverrideParams{ + UserID: member.ID, OrganizationID: otherOrg.ID, Context: string(codersdk.ChatPersonalModelOverrideContextGeneral), + Mode: string(codersdk.ChatPersonalModelOverrideModeModel), ModelConfigID: uuid.NullUUID{UUID: otherOrgModel.ID, Valid: true}, + }) require.NoError(t, err) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideModeChatDefault, "", true, true) - }) - t.Run("GETRootDeploymentDefaultIsMalformed", func(t *testing.T) { - upsertRaw( - codersdk.ChatPersonalModelOverrideContextRoot, - string(codersdk.ChatPersonalModelOverrideModeDeploymentDefault), - ) + orgA, err := memberClient.UserChatPersonalModelOverrides(ctx, firstUser.OrganizationID, codersdk.Me) + require.NoError(t, err) + require.NotEqual(t, otherOrgModel.ID.String(), orgA.General.ModelConfigID) + orgB, err := memberClient.UserChatPersonalModelOverrides(ctx, otherOrg.ID, codersdk.Me) + require.NoError(t, err) + assertOverride(orgB, codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.ChatPersonalModelOverrideModeModel, otherOrgModel.ID.String(), nil, true) + }) - resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + t.Run("LegacyRouteRemoved", func(t *testing.T) { + res, err := memberClient.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-personal-model-overrides", nil) require.NoError(t, err) - assertOverride(resp, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.ChatPersonalModelOverrideModeChatDefault, "", true, true) + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) }) + + _, err = getRow(member.ID, firstUser.OrganizationID, codersdk.ChatPersonalModelOverrideContextRoot) + require.NoError(t, err) } //nolint:tparallel,paralleltest // Subtests share coderdtest instances. @@ -16197,12 +15692,11 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { require.Equal(t, chat.LastModelConfigID, storedChat.LastModelConfigID) return chat } - upsertRootRaw := func(userID uuid.UUID, value string) { + upsertRootModel := func(userID uuid.UUID, organizationID uuid.UUID, modelConfigID uuid.UUID) { t.Helper() - err := db.UpsertUserChatPersonalModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertUserChatPersonalModelOverrideParams{ - UserID: userID, - Key: chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot), - Value: value, + err := db.UpsertChatUserModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertChatUserModelOverrideParams{ + UserID: userID, OrganizationID: organizationID, Context: string(codersdk.ChatPersonalModelOverrideContextRoot), + Mode: string(codersdk.ChatPersonalModelOverrideModeModel), ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true}, }) require.NoError(t, err) } @@ -16211,7 +15705,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { AllowUsers: true, }) require.NoError(t, err) - err = adminClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + err = adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: overrideModel.ID.String(), }) @@ -16237,7 +15731,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { AllowUsers: true, }) require.NoError(t, err) - err = adminClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + err = adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, }) require.NoError(t, err) @@ -16246,14 +15740,14 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { require.Equal(t, defaultModel.ID, chat.LastModelConfigID) }) - t.Run("MalformedRootFallsBackToDefault", func(t *testing.T) { - upsertRootRaw(firstUser.UserID, "garbage") - chat := createChat(adminClient, "malformed root falls back", nil) + t.Run("UnavailableStoredRootFallsBackToDefault", func(t *testing.T) { + upsertRootModel(firstUser.UserID, firstUser.OrganizationID, disabledModel.ID) + chat := createChat(adminClient, "unavailable root falls back", nil) require.Equal(t, defaultModel.ID, chat.LastModelConfigID) }) t.Run("RootModelOverrideUsesSavedModel", func(t *testing.T) { - err := adminClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + err := adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: overrideModel.ID.String(), }) @@ -16276,7 +15770,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { }, }) require.NoError(t, err) - err = adminClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + err = adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: reasoningModel.ID.String(), ReasoningEffort: ptr.Ref("high"), @@ -16288,7 +15782,7 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { require.Equal(t, ptr.Ref("high"), chat.LastReasoningEffort) }) - t.Run("CrossOrgRootModelFallsBackToOrgDefault", func(t *testing.T) { + t.Run("CrossOrgRootModelIsUnrepresentable", func(t *testing.T) { org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) orgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true}, @@ -16304,7 +15798,18 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { rbac.ScopedRoleAgentsAccess(org.ID), ) otherClient := codersdk.NewExperimentalClient(otherClientRaw) - upsertRootRaw(otherUser.ID, "model:"+overrideModel.ID.String()) + + // The composite foreign key makes a root override pointing at + // another organization's model unrepresentable at the schema level. + err := db.UpsertChatUserModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertChatUserModelOverrideParams{ + UserID: otherUser.ID, + OrganizationID: org.ID, + Context: string(codersdk.ChatPersonalModelOverrideContextRoot), + Mode: string(codersdk.ChatPersonalModelOverrideModeModel), + ModelConfigID: uuid.NullUUID{UUID: overrideModel.ID, Valid: true}, + }) + require.Error(t, err) + require.True(t, database.IsForeignKeyViolation(err)) chat, err := otherClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: org.ID, @@ -16318,11 +15823,11 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { }) t.Run("UnavailableRootModelFallsBackToDefault", func(t *testing.T) { - upsertRootRaw(firstUser.UserID, "model:"+disabledModel.ID.String()) + upsertRootModel(firstUser.UserID, firstUser.OrganizationID, disabledModel.ID) chat := createChat(adminClient, "disabled root model falls back", nil) require.Equal(t, defaultModel.ID, chat.LastModelConfigID) - upsertRootRaw(member.ID, "model:"+overrideModel.ID.String()) + upsertRootModel(member.ID, firstUser.OrganizationID, overrideModel.ID) chat = createChat(memberClient, "missing user key falls back", nil) require.Equal(t, defaultModel.ID, chat.LastModelConfigID) }) @@ -16947,374 +16452,116 @@ func TestChatDebugRun(t *testing.T) { func TestChatAdvisorConfig_GetDefault(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - // Enabled reflects the experiment state, not the DB value. The test - // deployment enables chat-advisor, so Enabled is true. require.True(t, resp.Enabled) - require.Equal(t, 0, resp.MaxUsesPerRun) - require.Equal(t, int64(0), resp.MaxOutputTokens) - require.Equal(t, uuid.Nil, resp.ModelConfigID) + require.Zero(t, resp.MaxUsesPerRun) + require.Zero(t, resp.MaxOutputTokens) } func TestChatAdvisorConfig_Update(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - - want := codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 5, - MaxOutputTokens: 1024, - } - - err := adminClient.UpdateChatAdvisorConfig(ctx, want) - require.NoError(t, err) - + req := codersdk.UpdateAdvisorConfigRequest{MaxUsesPerRun: 5, MaxOutputTokens: 1024} + require.NoError(t, adminClient.UpdateChatAdvisorConfig(ctx, req)) resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - require.Equal(t, want, resp) + require.Equal(t, codersdk.AdvisorConfig{Enabled: true, MaxUsesPerRun: 5, MaxOutputTokens: 1024}, resp) } func TestChatAdvisorConfig_MemberCannotWriteButCanRead(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - - want := codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 2, - MaxOutputTokens: 256, - } - - err := adminClient.UpdateChatAdvisorConfig(ctx, want) + req := codersdk.UpdateAdvisorConfigRequest{MaxUsesPerRun: 2, MaxOutputTokens: 256} + require.NoError(t, adminClient.UpdateChatAdvisorConfig(ctx, req)) + requireSDKError(t, memberClient.UpdateChatAdvisorConfig(ctx, req), http.StatusForbidden) + resp, err := memberClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - - resp, err := adminClient.GetChatAdvisorConfig(ctx) - require.NoError(t, err) - require.Equal(t, want, resp) - - err = memberClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - Enabled: true, - }) - requireSDKError(t, err, http.StatusForbidden) - - // Members must still be able to read the advisor config: the dbauthz - // layer only requires an authenticated actor, and the GET handler has - // no RBAC check because the admin settings UI and chatd runtime are - // the planned consumers. This assertion pins that behavior so a - // future RBAC tightening is a deliberate change. - memberResp, err := memberClient.GetChatAdvisorConfig(ctx) - require.NoError(t, err) - require.Equal(t, want, memberResp) - - resp, err = adminClient.GetChatAdvisorConfig(ctx) - require.NoError(t, err) - require.Equal(t, want, resp) + require.Equal(t, codersdk.AdvisorConfig{Enabled: true, MaxUsesPerRun: 2, MaxOutputTokens: 256}, resp) } -func TestChatAdvisorConfig_NegativeMaxUsesPerRunRejected(t *testing.T) { +func TestChatAdvisorConfig_NegativeValuesRejected(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - MaxUsesPerRun: -1, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "max_uses_per_run") - require.Contains(t, sdkErr.Message, "-1") - require.Contains(t, sdkErr.Message, "non-negative") -} - -func TestChatAdvisorConfig_NegativeMaxOutputTokensRejected(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - MaxOutputTokens: -1, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "max_output_tokens") - require.Contains(t, sdkErr.Message, "-1") - require.Contains(t, sdkErr.Message, "non-negative") -} - -func TestChatAdvisorConfig_RoundTripModelConfigID(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - - modelConfig := createAdditionalChatModelWithReasoningEffort( - t, - adminClient, - "openai", - "gpt-5.2", - codersdk.ChatModelReasoningEffortMedium, - codersdk.ChatModelReasoningEffortXHigh, - ) - - want := codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 3, - MaxOutputTokens: 2048, - ModelConfigID: modelConfig.ID, - ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + for _, req := range []codersdk.UpdateAdvisorConfigRequest{{MaxUsesPerRun: -1}, {MaxOutputTokens: -1}} { + err := adminClient.UpdateChatAdvisorConfig(ctx, req) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "non-negative") } - - err := adminClient.UpdateChatAdvisorConfig(ctx, want) - require.NoError(t, err) - - resp, err := adminClient.GetChatAdvisorConfig(ctx) - require.NoError(t, err) - require.Equal(t, want, resp) -} - -func TestChatAdvisorConfig_InvalidModelConfigID(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - - unknownID := uuid.New() - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - ModelConfigID: unknownID, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, unknownID.String()) - require.Contains(t, sdkErr.Message, "does not match any enabled model config") -} - -func TestChatAdvisorConfig_DisabledModelConfigID(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - - disabledConfig := createDisabledChatModel( - t, - adminClient, - coderdtest.TestChatProviderOpenAICompat, - "gpt-4o-advisor-disabled-"+uuid.NewString(), - ) - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - ModelConfigID: disabledConfig.ID, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "does not match any enabled model config") -} - -func TestChatAdvisorConfig_ProviderDisabledModelConfigID(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - - providerDisabledConfig := createProviderDisabledChatModel( - t, - adminClient, - "openai", - "gpt-4o-advisor-provider-disabled-"+uuid.NewString(), - ) - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - ModelConfigID: providerDisabledConfig.ID, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "does not match any enabled model config") -} - -func TestChatAdvisorConfig_OtherOrganizationModelConfigID(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient, db := newChatClientWithDatabase(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - defaultConfig := createChatModel(t, adminClient) - otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - otherOrgConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, - Model: "advisor-other-org-" + uuid.NewString(), - Enabled: true, - OrganizationID: otherOrg.ID, - }) - - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - ModelConfigID: otherOrgConfig.ID, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "does not match any enabled model config") -} - -func TestChatAdvisorConfig_ReasoningEffortRequiresModelConfig(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) - coderdtest.CreateFirstUser(t, adminClient.Client) - - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "reasoning_effort requires model_config_id.", sdkErr.Message) } -func TestChatAdvisorConfig_ReasoningEffortMustBeSelectable(t *testing.T) { +func TestChatAdvisorConfig_StaleModelWriteRejected(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - modelConfig := createAdditionalChatModelWithReasoningEffort( - t, - adminClient, - "openai", - "gpt-5.2", - codersdk.ChatModelReasoningEffortLow, - codersdk.ChatModelReasoningEffortMedium, - ) - - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - ModelConfigID: modelConfig.ID, - ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) - require.Equal(t, "Must be one of none, minimal, low, medium.", sdkErr.Detail) + modelID := uuid.New() + for _, req := range []codersdk.UpdateAdvisorConfigRequest{ + {DeprecatedModelConfigID: &modelID}, + {DeprecatedReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh)}, + } { + err := adminClient.UpdateChatAdvisorConfig(ctx, req) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Advisor model settings moved to PUT /api/experimental/organizations/{organization}/chats/model-overrides/advisor.", sdkErr.Message) + } } func TestChatAdvisorConfig_RoundTripZeroValues(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - - want := codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 0, - MaxOutputTokens: 0, - } - - err := adminClient.UpdateChatAdvisorConfig(ctx, want) - require.NoError(t, err) - + require.NoError(t, adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{})) resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - require.Equal(t, want, resp) + require.Equal(t, codersdk.AdvisorConfig{Enabled: true}, resp) } -// TestChatAdvisorConfig_OverwriteClearsPreviousValues pins PUT to -// full-replace semantics. A second write with zero-valued fields must -// clear every field set by a prior non-zero write, so nothing leaks if -// someone later introduces merge/patch semantics. func TestChatAdvisorConfig_OverwriteClearsPreviousValues(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - - modelConfig := createAdditionalChatModelWithReasoningEffort( - t, - adminClient, - "openai", - "gpt-5.2", - codersdk.ChatModelReasoningEffortMedium, - codersdk.ChatModelReasoningEffortXHigh, - ) - - rich := codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 5, - MaxOutputTokens: 1024, - ModelConfigID: modelConfig.ID, - ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), - } - err := adminClient.UpdateChatAdvisorConfig(ctx, rich) - require.NoError(t, err) - - sparse := codersdk.AdvisorConfig{Enabled: true} - err = adminClient.UpdateChatAdvisorConfig(ctx, sparse) - require.NoError(t, err) - + require.NoError(t, adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{MaxUsesPerRun: 5, MaxOutputTokens: 1024})) + require.NoError(t, adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{})) resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - require.Equal(t, sparse, resp) + require.Equal(t, codersdk.AdvisorConfig{Enabled: true}, resp) } -// TestChatAdvisorConfig_EnabledReflectsExperiment pins that the Enabled -// field in the GET response reflects the experiment state, not the DB-stored -// value. Setting Enabled: false via PUT stores false in the DB, but the GET -// handler overrides it with the experiment check. func TestChatAdvisorConfig_EnabledReflectsExperiment(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) + values := coderdtest.DeploymentValues(t) + values.Experiments = serpent.StringArray{string(codersdk.ExperimentAgentLifecycleHooks)} + adminClient := newChatClientWithDeploymentValues(t, values) coderdtest.CreateFirstUser(t, adminClient.Client) - - err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 2, - }) - require.NoError(t, err) - - enabledResp, err := adminClient.GetChatAdvisorConfig(ctx) - require.NoError(t, err) - require.True(t, enabledResp.Enabled) - - err = adminClient.UpdateChatAdvisorConfig(ctx, codersdk.AdvisorConfig{ - Enabled: false, - }) - require.NoError(t, err) - - disabledResp, err := adminClient.GetChatAdvisorConfig(ctx) + resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - // Enabled reflects the experiment state (on), not the DB-stored false. - require.True(t, disabledResp.Enabled) + require.False(t, resp.Enabled) } func TestChatAdvisorConfig_ClampsNegativeStoredValues(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) coderdtest.CreateFirstUser(t, adminClient.Client) - stored := `{"enabled":true,"max_uses_per_run":-3,"max_output_tokens":-99}` - err := db.UpsertChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx), stored) - require.NoError(t, err) - + require.NoError(t, db.UpsertChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx), stored)) resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - require.Equal(t, codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 0, - MaxOutputTokens: 0, - }, resp) - + require.Equal(t, codersdk.AdvisorConfig{Enabled: true}, resp) raw, err := db.GetChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) require.JSONEq(t, stored, raw) @@ -17322,70 +16569,40 @@ func TestChatAdvisorConfig_ClampsNegativeStoredValues(t *testing.T) { func TestChatAdvisorConfig_IgnoresLegacyReasoningEffort(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) coderdtest.CreateFirstUser(t, adminClient.Client) - stored := `{"enabled":true,"max_uses_per_run":3,"max_output_tokens":2048,"reasoning_effort":"high"}` - err := db.UpsertChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx), stored) - require.NoError(t, err) - + require.NoError(t, db.UpsertChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx), stored)) resp, err := adminClient.GetChatAdvisorConfig(ctx) require.NoError(t, err) - require.Equal(t, codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 3, - MaxOutputTokens: 2048, - }, resp) - + require.Equal(t, codersdk.AdvisorConfig{Enabled: true, MaxUsesPerRun: 3, MaxOutputTokens: 2048}, resp) raw, err := db.GetChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx)) require.NoError(t, err) require.JSONEq(t, stored, raw) } -// TestChatAdvisorConfig_CorruptStoredJSONReturnsError pins that the GET -// handler surfaces a 500 when the stored site_configs row contains bytes -// that are not valid JSON. Unlike the neighboring chat config endpoints, -// this handler unmarshals the raw string server-side, so DB corruption -// must not present as a default-valued 200. func TestChatAdvisorConfig_CorruptStoredJSONReturnsError(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) coderdtest.CreateFirstUser(t, adminClient.Client) - - err := db.UpsertChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx), "not-json") - require.NoError(t, err) - - _, err = adminClient.GetChatAdvisorConfig(ctx) + require.NoError(t, db.UpsertChatAdvisorConfig(dbauthz.AsSystemRestricted(ctx), "not-json")) + _, err := adminClient.GetChatAdvisorConfig(ctx) sdkErr := requireSDKError(t, err, http.StatusInternalServerError) require.Contains(t, sdkErr.Message, "invalid") } -// TestChatAdvisorConfig_UnauthenticatedFails pins that the advisor config -// endpoints are gated by apiKeyMiddleware at the /chats route level. The -// handler itself has no auth check, so this test protects against a future -// route restructuring that would accidentally expose these settings. func TestChatAdvisorConfig_UnauthenticatedFails(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - adminClient := newChatClient(t) coderdtest.CreateFirstUser(t, adminClient.Client) - anonClient := codersdk.NewExperimentalClient(codersdk.New(adminClient.URL)) _, err := anonClient.GetChatAdvisorConfig(ctx) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) - - err = anonClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ - Enabled: true, - }) - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) + requireSDKError(t, err, http.StatusUnauthorized) + err = anonClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{}) + requireSDKError(t, err, http.StatusUnauthorized) } func TestChatWorkspaceTTL(t *testing.T) { diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 5a6ea82f2c2..e47bfc3460e 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -2378,14 +2378,20 @@ type AgentsComputerUseTelemetry struct { ProviderSource string `json:"provider_source"` } +// AgentsAdvisorOverrideTelemetry describes one organization's advisor model override. +type AgentsAdvisorOverrideTelemetry struct { + OrganizationID string `json:"organization_id"` + Provider string `json:"provider"` + Model string `json:"model"` +} + // AgentsAdvisorTelemetry is the value shape for the advisor entry in // Deployment.AgentsExperiments. type AgentsAdvisorTelemetry struct { - Enabled bool `json:"enabled"` - MaxUsesPerRun int `json:"max_uses_per_run"` - MaxOutputTokens int64 `json:"max_output_tokens"` - Provider string `json:"provider"` - Model string `json:"model"` + Enabled bool `json:"enabled"` + MaxUsesPerRun int `json:"max_uses_per_run"` + MaxOutputTokens int64 `json:"max_output_tokens"` + Overrides []AgentsAdvisorOverrideTelemetry `json:"overrides"` } // CollectAgentsVirtualDesktop collects the virtual_desktop entry in @@ -2421,9 +2427,8 @@ func CollectAgentsVirtualDesktop(ctx context.Context, opts Options) json.RawMess // Deployment.AgentsExperiments. func CollectAgentsAdvisor(ctx context.Context, opts Options) json.RawMessage { payload := AgentsAdvisorTelemetry{ - Enabled: opts.Experiments.Enabled(codersdk.ExperimentChatAdvisor), - Provider: AgentsExperimentUnknown, - Model: AgentsExperimentUnknown, + Enabled: opts.Experiments.Enabled(codersdk.ExperimentChatAdvisor), + Overrides: []AgentsAdvisorOverrideTelemetry{}, } var cfg codersdk.AdvisorConfig raw, err := opts.Database.GetChatAdvisorConfig(ctx) @@ -2434,8 +2439,37 @@ func CollectAgentsAdvisor(ctx context.Context, opts Options) json.RawMessage { } else { payload.MaxUsesPerRun = max(cfg.MaxUsesPerRun, 0) payload.MaxOutputTokens = max(cfg.MaxOutputTokens, 0) - payload.Provider, payload.Model = advisorModelTelemetry(ctx, opts.Database, opts.Logger, cfg.ModelConfigID) } + + organizations, err := opts.Database.GetOrganizations(ctx, database.GetOrganizationsParams{}) + if err != nil { + opts.Logger.Warn(ctx, "get organizations for advisor telemetry", slog.Error(err)) + } else { + for _, organization := range organizations { + if organization.Deleted { + continue + } + override, err := opts.Database.GetChatOrganizationModelOverride(ctx, database.GetChatOrganizationModelOverrideParams{ + OrganizationID: organization.ID, + Context: string(codersdk.ChatModelOverrideContextAdvisor), + }) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + opts.Logger.Warn(ctx, "get chat advisor model override for telemetry", + slog.F("organization_id", organization.ID), slog.Error(err)) + continue + } + provider, model := advisorModelTelemetry(ctx, opts.Database, opts.Logger, override.ModelConfigID) + payload.Overrides = append(payload.Overrides, AgentsAdvisorOverrideTelemetry{ + OrganizationID: organization.ID.String(), + Provider: provider, + Model: model, + }) + } + } + val, err := json.Marshal(payload) if err != nil { opts.Logger.Warn(ctx, "marshal agent advisor telemetry", slog.Error(err)) @@ -2445,10 +2479,6 @@ func CollectAgentsAdvisor(ctx context.Context, opts Options) json.RawMessage { } func advisorModelTelemetry(ctx context.Context, db database.Store, log slog.Logger, id uuid.UUID) (provider string, model string) { - if id == uuid.Nil { - return AgentsExperimentAdvisorReuseChatModel, AgentsExperimentAdvisorReuseChatModel - } - cfg, err := db.GetEnabledChatModelConfigByID(ctx, id) if errors.Is(err, sql.ErrNoRows) { // An inactive override; the runtime falls back to the chat model. diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 30d2e881766..1e11062ca86 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -2382,162 +2382,126 @@ func TestCollectAgentsAdvisor(t *testing.T) { require.NoError(t, err) return string(raw) } + expectOrganizations := func(db *dbmock.MockStore, organizations ...database.Organization) { + db.EXPECT().GetOrganizations(gomock.Any(), database.GetOrganizationsParams{}).Return(organizations, nil) + } - t.Run("ReuseChatModel", func(t *testing.T) { + t.Run("Defaults", func(t *testing.T) { t.Parallel() - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(marshalConfig(t, codersdk.AdvisorConfig{}), nil) + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(marshalConfig(t, codersdk.AdvisorConfig{}), nil) + expectOrganizations(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.False(t, payload.Enabled) require.Zero(t, payload.MaxUsesPerRun) require.Zero(t, payload.MaxOutputTokens) - require.Equal(t, telemetry.AgentsExperimentAdvisorReuseChatModel, payload.Provider) - require.Equal(t, telemetry.AgentsExperimentAdvisorReuseChatModel, payload.Model) + require.Empty(t, payload.Overrides) }) - t.Run("ModelOverride", func(t *testing.T) { + t.Run("ModelOverrides", func(t *testing.T) { t.Parallel() - + org := database.Organization{ID: uuid.New()} + deletedOrg := database.Organization{ID: uuid.New(), Deleted: true} modelID := uuid.New() providerID := uuid.New() db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(marshalConfig(t, codersdk.AdvisorConfig{ - Enabled: true, - MaxUsesPerRun: 7, - MaxOutputTokens: 2048, - ModelConfigID: modelID, - }), nil) + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(marshalConfig(t, codersdk.AdvisorConfig{MaxUsesPerRun: 7, MaxOutputTokens: 2048}), nil) + expectOrganizations(db, org, deletedOrg) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), database.GetChatOrganizationModelOverrideParams{ + OrganizationID: org.ID, Context: string(codersdk.ChatModelOverrideContextAdvisor), + }).Return(database.ChatOrganizationModelOverride{OrganizationID: org.ID, ModelConfigID: modelID}, nil) db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{ - Model: "gpt-6-preview", - AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, - }, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{ - Type: database.AIProviderTypeOpenai, + Model: "gpt-6-preview", AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, }, nil) + db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{Type: database.AIProviderTypeOpenai}, nil) - payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - // Stored enabled is ignored; the chat-advisor experiment gates it. - require.False(t, payload.Enabled) + payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t), Experiments: codersdk.Experiments{codersdk.ExperimentChatAdvisor}}) + require.True(t, payload.Enabled) require.Equal(t, 7, payload.MaxUsesPerRun) require.Equal(t, int64(2048), payload.MaxOutputTokens) - require.Equal(t, string(database.AIProviderTypeOpenai), payload.Provider) - require.Equal(t, "gpt-6-preview", payload.Model) + require.Equal(t, []telemetry.AgentsAdvisorOverrideTelemetry{{ + OrganizationID: org.ID.String(), Provider: string(database.AIProviderTypeOpenai), Model: "gpt-6-preview", + }}, payload.Overrides) }) - t.Run("ExperimentEnabled", func(t *testing.T) { + t.Run("NoOverride", func(t *testing.T) { t.Parallel() - + org := database.Organization{ID: uuid.New()} db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(marshalConfig(t, codersdk.AdvisorConfig{}), nil) - - payload := collect(t, telemetry.Options{ - Database: db, - Logger: testutil.Logger(t), - Experiments: codersdk.Experiments{codersdk.ExperimentChatAdvisor}, - }) - require.True(t, payload.Enabled) - }) - - t.Run("MalformedJSON", func(t *testing.T) { - t.Parallel() - - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("not-json", nil) - + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) + expectOrganizations(db, org) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), gomock.Any()).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Provider) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Model) + require.Empty(t, payload.Overrides) }) - t.Run("PartialParse", func(t *testing.T) { + t.Run("MalformedConfig", func(t *testing.T) { t.Parallel() - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(`{"max_uses_per_run": 42, "model_config_id": "not-a-uuid"}`, nil) - + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("not-json", nil) + expectOrganizations(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Zero(t, payload.MaxUsesPerRun) require.Zero(t, payload.MaxOutputTokens) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Provider) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Model) }) t.Run("ClampsNegativeLimits", func(t *testing.T) { t.Parallel() - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(`{"max_uses_per_run": -3, "max_output_tokens": -99}`, nil) - + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(`{"max_uses_per_run":-3,"max_output_tokens":-99}`, nil) + expectOrganizations(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Zero(t, payload.MaxUsesPerRun) require.Zero(t, payload.MaxOutputTokens) }) - t.Run("InactiveModelConfig", func(t *testing.T) { - t.Parallel() - - modelID := uuid.New() - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(marshalConfig(t, codersdk.AdvisorConfig{ModelConfigID: modelID}), nil) - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID). - Return(database.ChatModelConfig{}, sql.ErrNoRows) - - payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - require.Equal(t, telemetry.AgentsExperimentAdvisorReuseChatModel, payload.Provider) - require.Equal(t, telemetry.AgentsExperimentAdvisorReuseChatModel, payload.Model) - }) - t.Run("ConfigFetchError", func(t *testing.T) { t.Parallel() - db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("", sql.ErrConnDone) - + expectOrganizations(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Provider) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Model) + require.Empty(t, payload.Overrides) }) - t.Run("ModelResolveError", func(t *testing.T) { + t.Run("OrganizationsFetchError", func(t *testing.T) { t.Parallel() - - modelID := uuid.New() db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(marshalConfig(t, codersdk.AdvisorConfig{ModelConfigID: modelID}), nil) - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID). - Return(database.ChatModelConfig{}, sql.ErrConnDone) - + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) + db.EXPECT().GetOrganizations(gomock.Any(), database.GetOrganizationsParams{}).Return(nil, sql.ErrConnDone) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Provider) - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Model) + require.Empty(t, payload.Overrides) }) - t.Run("ProviderResolveError", func(t *testing.T) { - t.Parallel() - - modelID := uuid.New() - providerID := uuid.New() - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()). - Return(marshalConfig(t, codersdk.AdvisorConfig{ModelConfigID: modelID}), nil) - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{ - Model: "gpt-6-preview", - AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, - }, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID). - Return(database.AIProvider{}, sql.ErrConnDone) - - payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - // The provider is unknown, but the already-resolved model still ships. - require.Equal(t, telemetry.AgentsExperimentUnknown, payload.Provider) - require.Equal(t, "gpt-6-preview", payload.Model) - }) + for _, tc := range []struct { + name string + modelErr error + providerErr error + wantProvider string + wantModel string + }{ + {name: "InactiveModel", modelErr: sql.ErrNoRows, wantProvider: telemetry.AgentsExperimentAdvisorReuseChatModel, wantModel: telemetry.AgentsExperimentAdvisorReuseChatModel}, + {name: "ModelResolveError", modelErr: sql.ErrConnDone, wantProvider: telemetry.AgentsExperimentUnknown, wantModel: telemetry.AgentsExperimentUnknown}, + {name: "ProviderResolveError", providerErr: sql.ErrConnDone, wantProvider: telemetry.AgentsExperimentUnknown, wantModel: "gpt-6-preview"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + org := database.Organization{ID: uuid.New()} + modelID := uuid.New() + providerID := uuid.New() + db := dbmock.NewMockStore(gomock.NewController(t)) + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) + expectOrganizations(db, org) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), gomock.Any()).Return(database.ChatOrganizationModelOverride{OrganizationID: org.ID, ModelConfigID: modelID}, nil) + if tc.modelErr != nil { + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{}, tc.modelErr) + } else { + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{Model: "gpt-6-preview", AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}}, nil) + db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{}, tc.providerErr) + } + payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) + require.Equal(t, []telemetry.AgentsAdvisorOverrideTelemetry{{OrganizationID: org.ID.String(), Provider: tc.wantProvider, Model: tc.wantModel}}, payload.Overrides) + }) + } } diff --git a/coderd/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go index 5b95831dd68..010c4a8e5b6 100644 --- a/coderd/x/chatd/advisor_internal_test.go +++ b/coderd/x/chatd/advisor_internal_test.go @@ -142,14 +142,15 @@ func resolveAdvisorModelOverrideForTest( ctx context.Context, p *Server, chat database.Chat, - cfg codersdk.AdvisorConfig, + modelConfigID uuid.UUID, + reasoningEffort *string, maxOutputTokens int64, modelOpts modelBuildOptions, logger slog.Logger, ) (resolvedModelCall, bool, error) { if store, ok := p.db.(*advisorOverrideStubStore); ok { - store.advisorModelConfigID = cfg.ModelConfigID - store.advisorReasoningEffort = cfg.ReasoningEffort + store.advisorModelConfigID = modelConfigID + store.advisorReasoningEffort = reasoningEffort } return p.resolveAdvisorModelOverride(ctx, chat, maxOutputTokens, modelOpts, logger) } @@ -194,13 +195,14 @@ func TestResolveAdvisorModelOverride(t *testing.T) { logger := slog.Make() - requireChatModel := func(t *testing.T, p *Server, advisorCfg codersdk.AdvisorConfig) { + requireChatModel := func(t *testing.T, p *Server, modelConfigID uuid.UUID) { t.Helper() ctx := testutil.Context(t, testutil.WaitShort) resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, p, database.Chat{}, - advisorCfg, + modelConfigID, + nil, advisorTestMaxOutputTokens, modelBuildOptions{}, logger, @@ -217,7 +219,7 @@ func TestResolveAdvisorModelOverride(t *testing.T) { store := &advisorOverrideStubStore{} p := newAdvisorTestServer(ctx, t, store) - requireChatModel(t, p, codersdk.AdvisorConfig{}) + requireChatModel(t, p, uuid.Nil) }) t.Run("ConfigLookupErrorUsesChatModel", func(t *testing.T) { @@ -230,7 +232,7 @@ func TestResolveAdvisorModelOverride(t *testing.T) { } p := newAdvisorTestServer(ctx, t, store) - requireChatModel(t, p, codersdk.AdvisorConfig{ModelConfigID: uuid.New()}) + requireChatModel(t, p, uuid.New()) }) // Covers the sql.ErrNoRows branch separately from the generic-error @@ -249,7 +251,7 @@ func TestResolveAdvisorModelOverride(t *testing.T) { } p := newAdvisorTestServer(ctx, t, store) - requireChatModel(t, p, codersdk.AdvisorConfig{ModelConfigID: uuid.New()}) + requireChatModel(t, p, uuid.New()) }) t.Run("ForeignOrgConfigUsesChatModel", func(t *testing.T) { @@ -272,7 +274,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, p, database.Chat{OrganizationID: chatOrgID}, - codersdk.AdvisorConfig{ModelConfigID: configID}, + configID, + nil, advisorTestMaxOutputTokens, modelBuildOptions{}, logger, @@ -301,7 +304,7 @@ func TestResolveAdvisorModelOverride(t *testing.T) { } p := newAdvisorTestServer(ctx, t, store) - requireChatModel(t, p, codersdk.AdvisorConfig{ModelConfigID: configID}) + requireChatModel(t, p, configID) }) t.Run("InvalidOptionsJSONWithLinkedProviderUsesChatModel", func(t *testing.T) { @@ -322,7 +325,7 @@ func TestResolveAdvisorModelOverride(t *testing.T) { } p := newAdvisorTestServer(ctx, t, store) - requireChatModel(t, p, codersdk.AdvisorConfig{ModelConfigID: configID}) + requireChatModel(t, p, configID) }) t.Run("MissingProviderKeyUsesChatModel", func(t *testing.T) { @@ -354,7 +357,7 @@ func TestResolveAdvisorModelOverride(t *testing.T) { } p := newAdvisorTestServer(ctx, t, store) - requireChatModel(t, p, codersdk.AdvisorConfig{ModelConfigID: configID}) + requireChatModel(t, p, configID) }) t.Run("SuccessReturnsOverrideModelAndConfig", func(t *testing.T) { @@ -393,10 +396,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, p, database.Chat{}, - codersdk.AdvisorConfig{ - ModelConfigID: configID, - ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), - }, + configID, + ptr.Ref(codersdk.ChatModelReasoningEffortHigh), advisorTestMaxOutputTokens, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, logger, @@ -453,7 +454,8 @@ func TestResolveAdvisorModelOverride(t *testing.T) { resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, p, database.Chat{}, - codersdk.AdvisorConfig{ModelConfigID: configID}, + configID, + nil, advisorTestMaxOutputTokens, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, logger, @@ -498,7 +500,8 @@ func TestResolveAdvisorModelOverridePromotesAIBridgeErrors(t *testing.T) { resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx, p, database.Chat{ID: uuid.New()}, - codersdk.AdvisorConfig{ModelConfigID: configID}, + configID, + nil, advisorTestMaxOutputTokens, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, slog.Make(), diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 49c09d1fa35..d32455e8ee4 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1132,7 +1132,7 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { LimitVal: manualTitleMessageWindowLimit, }, ).Return(nil, nil) - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), organizationID).Return(nil, nil) db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( @@ -1281,7 +1281,7 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing LimitVal: manualTitleMessageWindowLimit, }, ).Return(nil, nil) - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), organizationID).Return(nil, nil) db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 5bec32c8de4..a60f752a989 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -5775,7 +5775,11 @@ func TestActiveServer_CompactionModelOverride(t *testing.T) { Max: &effort, }, }) - require.NoError(t, db.UpsertChatCompactionModelOverride(ctx, overrideModel.ID.String())) + require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{ + OrganizationID: overrideModel.OrganizationID, + Context: string(codersdk.ChatModelOverrideContextCompaction), + ModelConfigID: overrideModel.ID, + })) return overrideModel } diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index bf34f5f78c6..fe67c2da4a4 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -643,7 +643,7 @@ func TestMaybeGenerateChatTitleAppliesModelConfigReasoningEffort(t *testing.T) { } db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ ID: chat.ID, Title: "Reasoning title", diff --git a/codersdk/chats.go b/codersdk/chats.go index 7df91c96cc9..7925546922a 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -757,8 +757,8 @@ type UpdateChatPlanModeInstructionsRequest struct { PlanModeInstructions string `json:"plan_mode_instructions"` } -// ChatModelOverrideContext identifies which chat model override context a -// deployment override applies to. +// ChatModelOverrideContext identifies which chat model override context an +// organization override applies to. type ChatModelOverrideContext string const ( @@ -766,6 +766,7 @@ const ( ChatModelOverrideContextExplore ChatModelOverrideContext = "explore" ChatModelOverrideContextTitleGeneration ChatModelOverrideContext = "title_generation" ChatModelOverrideContextCompaction ChatModelOverrideContext = "compaction" + ChatModelOverrideContextAdvisor ChatModelOverrideContext = "advisor" ) // Valid reports whether the override context is one of the supported values. @@ -774,7 +775,8 @@ func (c ChatModelOverrideContext) Valid() bool { case ChatModelOverrideContextGeneral, ChatModelOverrideContextExplore, ChatModelOverrideContextTitleGeneration, - ChatModelOverrideContextCompaction: + ChatModelOverrideContextCompaction, + ChatModelOverrideContextAdvisor: return true default: return false @@ -788,16 +790,20 @@ func AllChatModelOverrideContexts() []ChatModelOverrideContext { ChatModelOverrideContextExplore, ChatModelOverrideContextTitleGeneration, ChatModelOverrideContextCompaction, + ChatModelOverrideContextAdvisor, } } -// ChatModelOverrideResponse is the response body for the chat model override -// configuration endpoint. +// ChatModelOverrideResponse is the response body for one chat model override. type ChatModelOverrideResponse struct { Context ChatModelOverrideContext `json:"context"` ModelConfigID string `json:"model_config_id"` ReasoningEffort *string `json:"reasoning_effort,omitempty"` - IsMalformed bool `json:"is_malformed"` +} + +// ChatModelOverridesResponse is the response body for organization chat model overrides. +type ChatModelOverridesResponse struct { + Overrides []ChatModelOverrideResponse `json:"overrides"` } // UpdateChatModelOverrideRequest is the request body for updating the chat @@ -834,7 +840,6 @@ type ChatPersonalModelOverride struct { ModelConfigID string `json:"model_config_id"` ReasoningEffort *string `json:"reasoning_effort,omitempty"` IsSet bool `json:"is_set"` - IsMalformed bool `json:"is_malformed"` } // ChatPersonalModelOverrideDeploymentDefaults describes the deployment-level @@ -914,21 +919,17 @@ type AdvisorConfig struct { // MaxOutputTokens caps the advisor model response tokens. 0 means // use the runtime default. MaxOutputTokens int64 `json:"max_output_tokens"` - // ModelConfigID selects a specific admin-managed ChatModel to power the - // advisor. uuid.Nil means reuse the outer chat model. The runtime - // must fall back to the outer chat model when this ID cannot be - // resolved, such as when the referenced ChatModel was soft-deleted or - // its provider was disabled after the admin saved this configuration. - ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"` - // ReasoningEffort overrides the selected advisor model's configured default. - // It requires a non-zero ModelConfigID. - ReasoningEffort *string `json:"reasoning_effort,omitempty"` } -// UpdateAdvisorConfigRequest is the request body for updating advisor -// runtime configuration. It is a type alias for AdvisorConfig because -// the request and response shapes are currently identical. -type UpdateAdvisorConfigRequest = AdvisorConfig +// UpdateAdvisorConfigRequest is the request body for updating advisor runtime configuration. +type UpdateAdvisorConfigRequest struct { + MaxUsesPerRun int `json:"max_uses_per_run"` + MaxOutputTokens int64 `json:"max_output_tokens"` + // Deprecated: moved to the organization model override endpoint. + DeprecatedModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + // Deprecated: moved to the organization model override endpoint. + DeprecatedReasoningEffort *string `json:"reasoning_effort,omitempty"` +} // ChatComputerUseProvider identifies the provider that backs computer use for // the virtual desktop. @@ -2364,41 +2365,38 @@ func (c *ExperimentalClient) UpdateChatPlanModeInstructions(ctx context.Context, return nil } -// GetChatModelOverride returns the deployment-wide chat model override for -// the requested context. -func (c *ExperimentalClient) GetChatModelOverride(ctx context.Context, override ChatModelOverrideContext) (ChatModelOverrideResponse, error) { - path := fmt.Sprintf( - "/api/experimental/chats/config/model-override/%s", - url.PathEscape(string(override)), - ) +// OrganizationChatModelOverrides returns the configured chat model overrides for an organization. +func (c *ExperimentalClient) OrganizationChatModelOverrides(ctx context.Context, organizationID uuid.UUID) (ChatModelOverridesResponse, error) { + path := fmt.Sprintf("/api/experimental/organizations/%s/chats/model-overrides", organizationID) res, err := c.Request(ctx, http.MethodGet, path, nil) if err != nil { - return ChatModelOverrideResponse{}, err + return ChatModelOverridesResponse{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return ChatModelOverrideResponse{}, ReadBodyAsError(res) + return ChatModelOverridesResponse{}, ReadBodyAsError(res) } - var resp ChatModelOverrideResponse + var resp ChatModelOverridesResponse return resp, ReadBodyAsJSON(res, &resp) } -// UpdateChatModelOverride updates the deployment-wide chat model override for -// the requested context. -func (c *ExperimentalClient) UpdateChatModelOverride(ctx context.Context, override ChatModelOverrideContext, req UpdateChatModelOverrideRequest) error { +// UpdateOrganizationChatModelOverride updates or clears a chat model override for an organization. +func (c *ExperimentalClient) UpdateOrganizationChatModelOverride(ctx context.Context, organizationID uuid.UUID, override ChatModelOverrideContext, req UpdateChatModelOverrideRequest) (ChatModelOverrideResponse, error) { path := fmt.Sprintf( - "/api/experimental/chats/config/model-override/%s", + "/api/experimental/organizations/%s/chats/model-overrides/%s", + organizationID, url.PathEscape(string(override)), ) res, err := c.Request(ctx, http.MethodPut, path, req) if err != nil { - return err + return ChatModelOverrideResponse{}, err } defer res.Body.Close() - if res.StatusCode != http.StatusNoContent { - return ReadBodyAsError(res) + if res.StatusCode != http.StatusOK { + return ChatModelOverrideResponse{}, ReadBodyAsError(res) } - return nil + var resp ChatModelOverrideResponse + return resp, ReadBodyAsJSON(res, &resp) } // GetChatPersonalModelOverridesAdminSettings returns the deployment-wide @@ -2430,10 +2428,14 @@ func (c *ExperimentalClient) UpdateChatPersonalModelOverridesAdminSettings(ctx c return nil } -// GetUserChatPersonalModelOverrides fetches the user's personal model -// override settings. -func (c *ExperimentalClient) GetUserChatPersonalModelOverrides(ctx context.Context) (UserChatPersonalModelOverridesResponse, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/user-personal-model-overrides", nil) +// UserChatPersonalModelOverrides returns a user's personal model overrides in an organization. +func (c *ExperimentalClient) UserChatPersonalModelOverrides(ctx context.Context, organizationID uuid.UUID, user string) (UserChatPersonalModelOverridesResponse, error) { + path := fmt.Sprintf( + "/api/experimental/organizations/%s/members/%s/chats/model-overrides", + organizationID, + url.PathEscape(user), + ) + res, err := c.Request(ctx, http.MethodGet, path, nil) if err != nil { return UserChatPersonalModelOverridesResponse{}, err } @@ -2445,11 +2447,12 @@ func (c *ExperimentalClient) GetUserChatPersonalModelOverrides(ctx context.Conte return resp, ReadBodyAsJSON(res, &resp) } -// UpdateUserChatPersonalModelOverride updates the user's personal model -// override for the requested context. -func (c *ExperimentalClient) UpdateUserChatPersonalModelOverride(ctx context.Context, override ChatPersonalModelOverrideContext, req UpdateUserChatPersonalModelOverrideRequest) error { +// UpdateUserChatPersonalModelOverride updates a user's personal model override in an organization. +func (c *ExperimentalClient) UpdateUserChatPersonalModelOverride(ctx context.Context, organizationID uuid.UUID, user string, override ChatPersonalModelOverrideContext, req UpdateUserChatPersonalModelOverrideRequest) error { path := fmt.Sprintf( - "/api/experimental/chats/config/user-personal-model-overrides/%s", + "/api/experimental/organizations/%s/members/%s/chats/model-overrides/%s", + organizationID, + url.PathEscape(user), url.PathEscape(string(override)), ) res, err := c.Request(ctx, http.MethodPut, path, req) diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b7ac8f58506..a757b639485 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4087,6 +4087,58 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `reasoning` | [codersdk.ChatModelReasoningOptions](#codersdkchatmodelreasoningoptions) | false | | | | `user` | string | false | | | +## codersdk.ChatModelOverrideContext + +```json +"general" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------------------------------------| +| `advisor`, `compaction`, `explore`, `general`, `title_generation` | + +## codersdk.ChatModelOverrideResponse + +```json +{ + "context": "general", + "model_config_id": "string", + "reasoning_effort": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|------------------------------------------------------------------------|----------|--------------|-------------| +| `context` | [codersdk.ChatModelOverrideContext](#codersdkchatmodeloverridecontext) | false | | | +| `model_config_id` | string | false | | | +| `reasoning_effort` | string | false | | | + +## codersdk.ChatModelOverridesResponse + +```json +{ + "overrides": [ + { + "context": "general", + "model_config_id": "string", + "reasoning_effort": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|-----------------------------------------------------------------------------------|----------|--------------|-------------| +| `overrides` | array of [codersdk.ChatModelOverrideResponse](#codersdkchatmodeloverrideresponse) | false | | | + ## codersdk.ChatModelProviderDescriptor ```json @@ -4390,6 +4442,80 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `top_logprobs` | integer | false | | | | `user` | string | false | | | +## codersdk.ChatPersonalModelOverride + +```json +{ + "context": "root", + "is_set": true, + "mode": "deployment_default", + "model_config_id": "string", + "reasoning_effort": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|----------------------------------------------------------------------------------------|----------|--------------|-------------| +| `context` | [codersdk.ChatPersonalModelOverrideContext](#codersdkchatpersonalmodeloverridecontext) | false | | | +| `is_set` | boolean | false | | | +| `mode` | [codersdk.ChatPersonalModelOverrideMode](#codersdkchatpersonalmodeloverridemode) | false | | | +| `model_config_id` | string | false | | | +| `reasoning_effort` | string | false | | | + +## codersdk.ChatPersonalModelOverrideContext + +```json +"root" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|------------------------------| +| `explore`, `general`, `root` | + +## codersdk.ChatPersonalModelOverrideDeploymentDefaults + +```json +{ + "explore": { + "context": "general", + "model_config_id": "string", + "reasoning_effort": "string" + }, + "general": { + "context": "general", + "model_config_id": "string", + "reasoning_effort": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------------------------------------------------------------------------|----------|--------------|-------------| +| `explore` | [codersdk.ChatModelOverrideResponse](#codersdkchatmodeloverrideresponse) | false | | | +| `general` | [codersdk.ChatModelOverrideResponse](#codersdkchatmodeloverrideresponse) | false | | | + +## codersdk.ChatPersonalModelOverrideMode + +```json +"deployment_default" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------------------------------------| +| `chat_default`, `deployment_default`, `model` | + ## codersdk.ChatPlanMode ```json @@ -14982,6 +15108,22 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `user_roles` | object | false | | | | » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | +## codersdk.UpdateChatModelOverrideRequest + +```json +{ + "model_config_id": "string", + "reasoning_effort": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|--------|----------|--------------|-------------| +| `model_config_id` | string | false | | | +| `reasoning_effort` | string | false | | | + ## codersdk.UpdateChatModelRequest ```json @@ -15485,6 +15627,24 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `theme_light` | `dark`, `dark-protan-deuter`, `dark-tritan`, `light`, `light-protan-deuter`, `light-tritan` | | `theme_mode` | `single`, `sync` | +## codersdk.UpdateUserChatPersonalModelOverrideRequest + +```json +{ + "mode": "deployment_default", + "model_config_id": "string", + "reasoning_effort": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------------|----------------------------------------------------------------------------------|----------|--------------|-------------| +| `mode` | [codersdk.ChatPersonalModelOverrideMode](#codersdkchatpersonalmodeloverridemode) | false | | | +| `model_config_id` | string | false | | | +| `reasoning_effort` | string | false | | | + ## codersdk.UpdateUserNotificationPreferences ```json @@ -16111,6 +16271,57 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `theme_mode` | [codersdk.ThemeMode](#codersdkthememode) | false | | | | `theme_preference` | string | false | | Theme preference is the legacy single-field appearance setting. In "single" mode it mirrors the active theme. In "sync" mode modern clients normally mirror the active OS slot, but older clients can update only this field, so it may diverge from ThemeLight or ThemeDark until a modern client saves the full appearance state again. | +## codersdk.UserChatPersonalModelOverridesResponse + +```json +{ + "deployment_defaults": { + "explore": { + "context": "general", + "model_config_id": "string", + "reasoning_effort": "string" + }, + "general": { + "context": "general", + "model_config_id": "string", + "reasoning_effort": "string" + } + }, + "enabled": true, + "explore": { + "context": "root", + "is_set": true, + "mode": "deployment_default", + "model_config_id": "string", + "reasoning_effort": "string" + }, + "general": { + "context": "root", + "is_set": true, + "mode": "deployment_default", + "model_config_id": "string", + "reasoning_effort": "string" + }, + "root": { + "context": "root", + "is_set": true, + "mode": "deployment_default", + "model_config_id": "string", + "reasoning_effort": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------------------|--------------------------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `deployment_defaults` | [codersdk.ChatPersonalModelOverrideDeploymentDefaults](#codersdkchatpersonalmodeloverridedeploymentdefaults) | false | | | +| `enabled` | boolean | false | | | +| `explore` | [codersdk.ChatPersonalModelOverride](#codersdkchatpersonalmodeloverride) | false | | | +| `general` | [codersdk.ChatPersonalModelOverride](#codersdkchatpersonalmodeloverride) | false | | | +| `root` | [codersdk.ChatPersonalModelOverride](#codersdkchatpersonalmodeloverride) | false | | | + ## codersdk.UserLatency ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index fb0a0139ef9..1fe4ca502e4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3598,23 +3598,25 @@ class ExperimentalApiMethods { ); }; - getChatModelOverride = async ( - context: TypesGen.ChatModelOverrideContext, - ): Promise => { - const response = await this.axios.get( - `/api/experimental/chats/config/model-override/${encodeURIComponent(context)}`, + getOrganizationChatModelOverrides = async ( + organizationId: string, + ): Promise => { + const response = await this.axios.get( + `/api/experimental/organizations/${encodeURIComponent(organizationId)}/chats/model-overrides`, ); return response.data; }; - updateChatModelOverride = async ( + updateOrganizationChatModelOverride = async ( + organizationId: string, context: TypesGen.ChatModelOverrideContext, req: TypesGen.UpdateChatModelOverrideRequest, - ): Promise => { - await this.axios.put( - `/api/experimental/chats/config/model-override/${encodeURIComponent(context)}`, + ): Promise => { + const response = await this.axios.put( + `/api/experimental/organizations/${encodeURIComponent(organizationId)}/chats/model-overrides/${encodeURIComponent(context)}`, req, ); + return response.data; }; getChatPersonalModelOverridesAdminSettings = @@ -3668,21 +3670,25 @@ class ExperimentalApiMethods { ); }; - getUserChatPersonalModelOverrides = - async (): Promise => { - const response = - await this.axios.get( - "/api/experimental/chats/config/user-personal-model-overrides", - ); - return response.data; - }; + getUserChatPersonalModelOverrides = async ( + organizationId: string, + user: string, + ): Promise => { + const response = + await this.axios.get( + `/api/experimental/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(user)}/chats/model-overrides`, + ); + return response.data; + }; updateUserChatPersonalModelOverride = async ( + organizationId: string, + user: string, context: TypesGen.ChatPersonalModelOverrideContext, req: TypesGen.UpdateUserChatPersonalModelOverrideRequest, ): Promise => { await this.axios.put( - `/api/experimental/chats/config/user-personal-model-overrides/${encodeURIComponent(context)}`, + `/api/experimental/organizations/${encodeURIComponent(organizationId)}/members/${encodeURIComponent(user)}/chats/model-overrides/${encodeURIComponent(context)}`, req, ); }; diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 5264ab284cd..5bc443aef8d 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -87,7 +87,6 @@ import { toChatListParams, unarchiveChat, unpinChat, - updateChatAdvisorConfig, updateChatModel, updateChatModelACL, updateChatPlanMode, @@ -344,7 +343,6 @@ describe("advisor config query factories", () => { enabled: true, max_uses_per_run: 5, max_output_tokens: 2048, - model_config_id: "00000000-0000-0000-0000-000000000000", }; vi.mocked(API.experimental.getChatAdvisorConfig).mockResolvedValue( advisorConfig, @@ -356,33 +354,6 @@ describe("advisor config query factories", () => { await expect(query.queryFn()).resolves.toEqual(advisorConfig); expect(API.experimental.getChatAdvisorConfig).toHaveBeenCalled(); }); - - it("sends the update request and invalidates the advisor config cache", async () => { - const queryClient = createTestQueryClient(); - queryClient.setQueryData(chatAdvisorConfigKey, { - enabled: false, - max_uses_per_run: 0, - max_output_tokens: 0, - model_config_id: "", - } as TypesGen.AdvisorConfig); - - const req: TypesGen.UpdateAdvisorConfigRequest = { - enabled: true, - max_uses_per_run: 5, - max_output_tokens: 2048, - model_config_id: "00000000-0000-0000-0000-000000000000", - }; - vi.mocked(API.experimental.updateChatAdvisorConfig).mockResolvedValue(); - - const mutation = updateChatAdvisorConfig(queryClient); - await mutation.mutationFn(req); - expect(API.experimental.updateChatAdvisorConfig).toHaveBeenCalledWith(req); - - await mutation.onSuccess?.(); - expect(queryClient.getQueryState(chatAdvisorConfigKey)?.isInvalidated).toBe( - true, - ); - }); }); describe("invalidateChatListQueries", () => { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index cfae7eecc02..ab8a21d0b2b 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1913,9 +1913,13 @@ export const updateChatPlanModeInstructions = (queryClient: QueryClient) => ({ }, }); -const chatPersonalModelOverridesAdminSettingsKey = [ +const userChatPersonalModelOverridesKeyRoot = [ ...chatConfigKey, "personal-model-overrides", +] as const; + +const chatPersonalModelOverridesAdminSettingsKey = [ + ...userChatPersonalModelOverridesKeyRoot, "admin", ] as const; @@ -1935,7 +1939,7 @@ export const updateChatPersonalModelOverridesAdminSettings = ( queryKey: chatPersonalModelOverridesAdminSettingsKey, }); await queryClient.invalidateQueries({ - queryKey: userChatPersonalModelOverridesKey, + queryKey: userChatPersonalModelOverridesKeyRoot, }); }, }); @@ -2101,16 +2105,19 @@ export const updateUserChatCustomPrompt = (queryClient: QueryClient) => ({ }, }); -const userChatPersonalModelOverridesKey = [ - ...chatConfigKey, - "personal-model-overrides", - "me", -] as const; +const userChatPersonalModelOverridesKey = ( + organizationId: string, + user: string, +) => [...userChatPersonalModelOverridesKeyRoot, organizationId, user] as const; -export const userChatPersonalModelOverrides = () => ({ - queryKey: userChatPersonalModelOverridesKey, +export const userChatPersonalModelOverrides = ( + organizationId: string, + user = "me", +) => ({ + queryKey: userChatPersonalModelOverridesKey(organizationId, user), queryFn: (): Promise => - API.experimental.getUserChatPersonalModelOverrides(), + API.experimental.getUserChatPersonalModelOverrides(organizationId, user), + enabled: organizationId !== "", }); type UpdateUserChatPersonalModelOverrideArgs = { @@ -2120,12 +2127,19 @@ type UpdateUserChatPersonalModelOverrideArgs = { export const updateUserChatPersonalModelOverride = ( queryClient: QueryClient, + organizationId: string, + user = "me", ) => ({ mutationFn: ({ context, req }: UpdateUserChatPersonalModelOverrideArgs) => - API.experimental.updateUserChatPersonalModelOverride(context, req), + API.experimental.updateUserChatPersonalModelOverride( + organizationId, + user, + context, + req, + ), onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: userChatPersonalModelOverridesKey, + queryKey: userChatPersonalModelOverridesKey(organizationId, user), }); }, }); @@ -2385,25 +2399,30 @@ export const chatCost = (rootChatId: string) => ({ staleTime: GATEWAY_REQUEST_STALE_MS, }); -const chatModelOverrideKey = (context: TypesGen.ChatModelOverrideContext) => - [...chatConfigKey, "model-overrides", context] as const; +const organizationChatModelOverridesKey = (organizationId: string) => + [...chatConfigKey, "model-overrides", organizationId] as const; -export const chatModelOverride = ( - context: TypesGen.ChatModelOverrideContext, -) => ({ - queryKey: chatModelOverrideKey(context), - queryFn: () => API.experimental.getChatModelOverride(context), +export const organizationChatModelOverrides = (organizationId: string) => ({ + queryKey: organizationChatModelOverridesKey(organizationId), + queryFn: () => + API.experimental.getOrganizationChatModelOverrides(organizationId), + enabled: organizationId !== "", }); -export const updateChatModelOverride = ( +export const updateOrganizationChatModelOverride = ( queryClient: QueryClient, + organizationId: string, context: TypesGen.ChatModelOverrideContext, ) => ({ mutationFn: (req: TypesGen.UpdateChatModelOverrideRequest) => - API.experimental.updateChatModelOverride(context, req), + API.experimental.updateOrganizationChatModelOverride( + organizationId, + context, + req, + ), onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: chatModelOverrideKey(context), + queryKey: organizationChatModelOverridesKey(organizationId), exact: true, }); }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8d903ee2a25..9e73794b016 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1213,19 +1213,6 @@ export interface AdvisorConfig { * use the runtime default. */ readonly max_output_tokens: number; - /** - * ModelConfigID selects a specific admin-managed ChatModel to power the - * advisor. uuid.Nil means reuse the outer chat model. The runtime - * must fall back to the outer chat model when this ID cannot be - * resolved, such as when the referenced ChatModel was soft-deleted or - * its provider was disabled after the admin saved this configuration. - */ - readonly model_config_id: string; - /** - * ReasoningEffort overrides the selected advisor model's configured default. - * It requires a non-zero ModelConfigID. - */ - readonly reasoning_effort?: string; } // From codersdk/users.go @@ -3013,12 +3000,14 @@ export interface ChatModelOpenRouterProviderOptions { // From codersdk/chats.go export type ChatModelOverrideContext = + | "advisor" | "compaction" | "explore" | "general" | "title_generation"; export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [ + "advisor", "compaction", "explore", "general", @@ -3027,14 +3016,20 @@ export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [ // From codersdk/chats.go /** - * ChatModelOverrideResponse is the response body for the chat model override - * configuration endpoint. + * ChatModelOverrideResponse is the response body for one chat model override. */ export interface ChatModelOverrideResponse { readonly context: ChatModelOverrideContext; readonly model_config_id: string; readonly reasoning_effort?: string; - readonly is_malformed: boolean; +} + +// From codersdk/chats.go +/** + * ChatModelOverridesResponse is the response body for organization chat model overrides. + */ +export interface ChatModelOverridesResponse { + readonly overrides: readonly ChatModelOverrideResponse[]; } // From codersdk/chats.go @@ -3183,7 +3178,6 @@ export interface ChatPersonalModelOverride { readonly model_config_id: string; readonly reasoning_effort?: string; readonly is_set: boolean; - readonly is_malformed: boolean; } // From codersdk/chats.go @@ -9697,38 +9691,17 @@ export interface UpdateActiveTemplateVersion { // From codersdk/chats.go /** - * UpdateAdvisorConfigRequest is the request body for updating advisor - * runtime configuration. It is a type alias for AdvisorConfig because - * the request and response shapes are currently identical. + * UpdateAdvisorConfigRequest is the request body for updating advisor runtime configuration. */ export interface UpdateAdvisorConfigRequest { - /** - * Enabled reflects whether the chat-advisor experiment is active. - * The experiment flag is the sole gate; this field is read-only and - * always matches the experiment state regardless of the stored DB value. - */ - readonly enabled: boolean; - /** - * MaxUsesPerRun caps how many times the advisor can be invoked per - * chat run. 0 means unlimited. - */ readonly max_uses_per_run: number; - /** - * MaxOutputTokens caps the advisor model response tokens. 0 means - * use the runtime default. - */ readonly max_output_tokens: number; /** - * ModelConfigID selects a specific admin-managed ChatModel to power the - * advisor. uuid.Nil means reuse the outer chat model. The runtime - * must fall back to the outer chat model when this ID cannot be - * resolved, such as when the referenced ChatModel was soft-deleted or - * its provider was disabled after the admin saved this configuration. + * @deprecated moved to the organization model override endpoint. */ - readonly model_config_id: string; + readonly model_config_id?: string; /** - * ReasoningEffort overrides the selected advisor model's configured default. - * It requires a non-zero ModelConfigID. + * @deprecated moved to the organization model override endpoint. */ readonly reasoning_effort?: string; } diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx index ca36a41cf52..029dfe24e6a 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPage.tsx @@ -3,64 +3,30 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { chatAdvisorConfig, chatComputerUseProvider, - chatModelOverride, - chatModels, chatPersonalModelOverridesAdminSettings, updateChatAdvisorConfig, updateChatComputerUseProvider, - updateChatModelOverride, updateChatPersonalModelOverridesAdminSettings, } from "#/api/queries/chats"; -import type * as TypesGen from "#/api/typesGenerated"; import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { - getDefaultOrganizationId, - useDashboard, -} from "#/modules/dashboard/useDashboard"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; -import { providerInfoByIDFromDescriptors } from "#/pages/AgentsPage/utils/modelOptions"; import { pageTitle } from "#/utils/page"; import { CoderAgentsPageView } from "./CoderAgentsPageView"; -const generalOverrideContext: TypesGen.ChatModelOverrideContext = "general"; -const exploreOverrideContext: TypesGen.ChatModelOverrideContext = "explore"; -const titleGenerationOverrideContext: TypesGen.ChatModelOverrideContext = - "title_generation"; -const compactionOverrideContext: TypesGen.ChatModelOverrideContext = - "compaction"; - const CoderAgentsPage: FC = () => { const { permissions } = useAuthenticated(); - const { experiments, organizations } = useDashboard(); + const { experiments } = useDashboard(); const queryClient = useQueryClient(); const canEditDeploymentConfig = permissions.editDeploymentConfig; const showAdvisorSettings = experiments.includes("chat-advisor"); const showVirtualDesktopSettings = experiments.includes( "chat-virtual-desktop", ); - const defaultOrganizationId = getDefaultOrganizationId(organizations); - - const personalModelOverridesAdminSettingsQuery = useQuery({ + const personalOverridesQuery = useQuery({ ...chatPersonalModelOverridesAdminSettings(), enabled: canEditDeploymentConfig, }); - const generalModelOverrideQuery = useQuery({ - ...chatModelOverride(generalOverrideContext), - enabled: canEditDeploymentConfig, - }); - const exploreModelOverrideQuery = useQuery({ - ...chatModelOverride(exploreOverrideContext), - enabled: canEditDeploymentConfig, - }); - const titleGenerationModelQuery = useQuery({ - ...chatModelOverride(titleGenerationOverrideContext), - enabled: canEditDeploymentConfig, - }); - const compactionModelQuery = useQuery({ - ...chatModelOverride(compactionOverrideContext), - enabled: canEditDeploymentConfig, - }); - const modelsQuery = useQuery(chatModels(defaultOrganizationId)); const advisorConfigQuery = useQuery({ ...chatAdvisorConfig(), enabled: canEditDeploymentConfig && showAdvisorSettings, @@ -69,21 +35,9 @@ const CoderAgentsPage: FC = () => { ...chatComputerUseProvider(), enabled: canEditDeploymentConfig && showVirtualDesktopSettings, }); - const savePersonalModelOverridesAdminSettingsMutation = useMutation( + const savePersonalOverridesMutation = useMutation( updateChatPersonalModelOverridesAdminSettings(queryClient), ); - const saveGeneralModelOverrideMutation = useMutation( - updateChatModelOverride(queryClient, generalOverrideContext), - ); - const saveTitleGenerationModelMutation = useMutation( - updateChatModelOverride(queryClient, titleGenerationOverrideContext), - ); - const saveCompactionModelMutation = useMutation( - updateChatModelOverride(queryClient, compactionOverrideContext), - ); - const saveExploreModelOverrideMutation = useMutation( - updateChatModelOverride(queryClient, exploreOverrideContext), - ); const saveAdvisorConfigMutation = useMutation( updateChatAdvisorConfig(queryClient), ); @@ -91,65 +45,17 @@ const CoderAgentsPage: FC = () => { updateChatComputerUseProvider(queryClient), ); - const providerInfoByID = providerInfoByIDFromDescriptors( - modelsQuery.data?.providers, - ); - const defaultOrganizationModels = modelsQuery.data?.models ?? []; - return ( Codestin Search App { - void personalModelOverridesAdminSettingsQuery.refetch(); - }} - isRetryingAdminOverrides={ - personalModelOverridesAdminSettingsQuery.isFetching - } - onSaveAdminOverrides={ - savePersonalModelOverridesAdminSettingsMutation.mutate - } - isSavingAdminOverrides={ - savePersonalModelOverridesAdminSettingsMutation.isPending - } - isSaveAdminOverridesError={ - savePersonalModelOverridesAdminSettingsMutation.isError - } - generalModelOverrideData={generalModelOverrideQuery.data} - titleGenerationModelOverrideData={titleGenerationModelQuery.data} - compactionModelOverrideData={compactionModelQuery.data} - exploreModelOverrideData={exploreModelOverrideQuery.data} - models={defaultOrganizationModels} - providerInfoByID={providerInfoByID} - modelsError={modelsQuery.error} - isLoadingModels={modelsQuery.isLoading} - isFetchingModels={modelsQuery.isFetching} - onSaveGeneralModelOverride={saveGeneralModelOverrideMutation.mutate} - isSavingGeneralModelOverride={ - saveGeneralModelOverrideMutation.isPending - } - isSaveGeneralModelOverrideError={ - saveGeneralModelOverrideMutation.isError - } - onSaveTitleGenerationModel={saveTitleGenerationModelMutation.mutate} - isSavingTitleGenerationModel={ - saveTitleGenerationModelMutation.isPending - } - isSaveTitleGenerationModelError={ - saveTitleGenerationModelMutation.isError - } - onSaveCompactionModel={saveCompactionModelMutation.mutate} - isSavingCompactionModel={saveCompactionModelMutation.isPending} - isSaveCompactionModelError={saveCompactionModelMutation.isError} - onSaveExploreModelOverride={saveExploreModelOverrideMutation.mutate} - isSavingExploreModelOverride={ - saveExploreModelOverrideMutation.isPending - } - isSaveExploreModelOverrideError={ - saveExploreModelOverrideMutation.isError - } + adminOverridesData={personalOverridesQuery.data} + adminOverridesError={personalOverridesQuery.error} + onRetryAdminOverrides={() => void personalOverridesQuery.refetch()} + isRetryingAdminOverrides={personalOverridesQuery.isFetching} + onSaveAdminOverrides={savePersonalOverridesMutation.mutate} + isSavingAdminOverrides={savePersonalOverridesMutation.isPending} + isSaveAdminOverridesError={savePersonalOverridesMutation.isError} showAdvisorSettings={showAdvisorSettings} advisorConfigData={advisorConfigQuery.data} isAdvisorConfigLoading={advisorConfigQuery.isLoading} diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx index 35cd45fba83..57f18d2def2 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx @@ -1,827 +1,58 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type * as TypesGen from "#/api/typesGenerated"; -import { MockChatModel } from "#/testHelpers/chatModels"; +import { expect, fn, within } from "storybook/test"; import { CoderAgentsPageView, type CoderAgentsPageViewProps, } from "./CoderAgentsPageView"; -const OVERRIDE_MALFORMED_WARNING = - "The saved override is malformed and is being treated as unset. Click Save to clear it."; -const UNAVAILABLE_SAVED_MODEL_WARNING = - "The saved model is no longer enabled and will be ignored until you choose a new override."; -const TITLE_UNAVAILABLE_SAVED_MODEL_WARNING = - "The selected model is currently unavailable. Title generation will be skipped until you choose another model or clear this setting."; - -const buildModelConfig = ( - overrides: Partial, -): TypesGen.ChatModel => ({ - ...MockChatModel, - id: "model-default", - model: "gpt-4.1-mini", - display_name: "GPT 4.1 Mini", - context_limit: 1_000_000, - created_at: "2026-03-12T12:00:00.000Z", - updated_at: "2026-03-12T12:00:00.000Z", - ...overrides, -}); - -const buildOverrideData = ( - context: TypesGen.ChatModelOverrideContext, - overrides: Partial = {}, -): TypesGen.ChatModelOverrideResponse => ({ - context, - model_config_id: "", - is_malformed: false, - ...overrides, -}); - -const buildTitleGenerationModelOverrideData = ( - overrides: Partial = {}, -): TypesGen.ChatModelOverrideResponse => - buildOverrideData("title_generation", overrides); - -const generalModelConfig = buildModelConfig({ - id: "model-general-gpt-4.1-mini", - display_name: "GPT 4.1 Mini", -}); - -const claudeSonnetModelConfig = buildModelConfig({ - id: "model-claude-sonnet-4", - ai_provider_id: "provider-anthropic", - model: "claude-sonnet-4", - display_name: "Claude Sonnet 4", - context_limit: 200_000, -}); - -const advisorReasoningModelConfig = buildModelConfig({ - id: "model-advisor-gpt-5.2", - model: "gpt-5.2", - display_name: "GPT 5.2", - context_limit: 200_000, - model_config: { - reasoning_effort: { default: "medium", max: "xhigh" }, - }, - reasoning_efforts: ["none", "minimal", "low", "medium", "high", "xhigh"], -}); - -const titleModelConfig = buildModelConfig({ - id: "model-title-gpt-4o-mini", - model: "gpt-4o-mini", - display_name: "GPT 4o Mini", - context_limit: 128_000, -}); - -const exploreFallbackModelConfig = buildModelConfig({ - id: "model-explore-blank-display", - ai_provider_id: "provider-anthropic", - model: "claude-sonnet-4-20250514", - display_name: "", - context_limit: 200_000, -}); - -const generalDisabledModelConfig = buildModelConfig({ - id: "model-general-disabled", - model: "gpt-4.1-legacy", - display_name: "GPT 4.1 Legacy", - enabled: false, -}); - -const titleDisabledModelConfig = buildModelConfig({ - id: "model-title-disabled", - model: "gpt-4o-mini-legacy", - display_name: "GPT 4o Mini Legacy", - enabled: false, - context_limit: 128_000, -}); - -const exploreDisabledModelConfig = buildModelConfig({ - id: "model-explore-disabled", - ai_provider_id: "provider-anthropic", - model: "claude-haiku-legacy", - display_name: "Claude Haiku Legacy", - enabled: false, - context_limit: 200_000, -}); - -const compactionDisabledModelConfig = buildModelConfig({ - id: "model-compaction-disabled", - model: "gpt-4.1-nano-legacy", - display_name: "GPT 4.1 Nano Legacy", - enabled: false, - context_limit: 128_000, -}); - -const providerDisabledModelConfig = buildModelConfig({ - id: "model-provider-disabled", - ai_provider_id: "provider-openai-disabled", - model: "gpt-4o-secondary", - display_name: "GPT 4o Secondary", - context_limit: 128_000, -}); - -const allModels: TypesGen.ChatModel[] = [ - generalModelConfig, - claudeSonnetModelConfig, - advisorReasoningModelConfig, - titleModelConfig, - exploreFallbackModelConfig, - generalDisabledModelConfig, - titleDisabledModelConfig, - exploreDisabledModelConfig, - compactionDisabledModelConfig, - providerDisabledModelConfig, -]; - -const providerInfoByID = new Map([ - [ - "provider-1", - { provider: "openai", displayName: "OpenAI", icon: "", enabled: true }, - ], - [ - "provider-anthropic", - { - provider: "anthropic", - displayName: "Anthropic", - icon: "", - enabled: true, - }, - ], - [ - "provider-openai-disabled", - { - provider: "openai", - displayName: "OpenAI Secondary", - icon: "", - enabled: false, - }, - ], -]); - -const buildArgs = ( - overrides: Partial = {}, -): CoderAgentsPageViewProps => ({ - adminOverridesData: { allow_users: false }, - adminOverridesError: undefined, - onRetryAdminOverrides: fn(), - isRetryingAdminOverrides: false, +const defaultArgs: CoderAgentsPageViewProps = { + adminOverridesData: { allow_users: true }, onSaveAdminOverrides: fn(), isSavingAdminOverrides: false, isSaveAdminOverridesError: false, - generalModelOverrideData: buildOverrideData("general"), - titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData(), - compactionModelOverrideData: buildOverrideData("compaction"), - exploreModelOverrideData: buildOverrideData("explore"), - models: allModels, - providerInfoByID, - modelsError: undefined, - isLoadingModels: false, - isFetchingModels: false, - onSaveGeneralModelOverride: fn(), - isSavingGeneralModelOverride: false, - isSaveGeneralModelOverrideError: false, - onSaveTitleGenerationModel: fn(), - isSavingTitleGenerationModel: false, - isSaveTitleGenerationModelError: false, - onSaveCompactionModel: fn(), - isSavingCompactionModel: false, - isSaveCompactionModelError: false, - onSaveExploreModelOverride: fn(), - isSavingExploreModelOverride: false, - isSaveExploreModelOverrideError: false, - showAdvisorSettings: false, - advisorConfigData: undefined, + showAdvisorSettings: true, + advisorConfigData: { + enabled: true, + max_uses_per_run: 5, + max_output_tokens: 2048, + }, isAdvisorConfigLoading: false, isAdvisorConfigFetching: false, isAdvisorConfigLoadError: false, onSaveAdvisorConfig: fn(), isSavingAdvisorConfig: false, isSaveAdvisorConfigError: false, - saveAdvisorConfigError: undefined, + saveAdvisorConfigError: null, showVirtualDesktopSettings: false, computerUseProviderData: undefined, isLoadingComputerUseProvider: false, onSaveComputerUseProvider: fn(), isSavingComputerUseProvider: false, computerUseProviderSaveError: null, - ...overrides, -}); - -const getSection = async ( - canvasElement: HTMLElement, - headingName: string, -): Promise => { - const canvas = within(canvasElement); - const heading = await canvas.findByRole("heading", { name: headingName }); - const setting = heading.closest("form"); - if (!(setting instanceof HTMLElement)) { - throw new Error(`Expected ${headingName} heading to live inside a form.`); - } - return setting; }; -const selectModelInSection = async ( - section: HTMLElement, - canvasElement: HTMLElement, - currentSelectionName: string | RegExp, - optionName: string | RegExp, -) => { - const trigger = within(section).getByRole("combobox", { - name: currentSelectionName, - }); - await userEvent.click(trigger); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click(await body.findByRole("option", { name: optionName })); -}; - -const meta = { +const meta: Meta = { title: "pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView", component: CoderAgentsPageView, - // TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude. - parameters: { pixel: { exclude: true } }, - args: buildArgs(), -} satisfies Meta; - + args: defaultArgs, +}; export default meta; type Story = StoryObj; -export const AllOverridesUnset: Story = { - args: buildArgs(), +export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( - await canvas.findByRole("heading", { name: "Coder Agents" }), - ).toBeVisible(); - expect( - canvas.getByText( - "Configure deployment-wide defaults for Coder Agents and agent-specific capabilities.", - ), + await expect( + canvas.getByRole("link", { name: "Defaults & overrides" }), ).toBeVisible(); - - expect(canvas.getByText("Allow personal model overrides")).toBeVisible(); - const headings = await canvas.findAllByRole("heading", { level: 3 }); - expect(headings.map((heading) => heading.textContent?.trim())).toEqual([ - "General model", - "Title generation model", - "Compaction model", - "Explore subagent model", - ]); - await canvas.findByText( - "Leave unset to use Coder's title default, which prefers fast models from configured providers.", - ); - - const unsetSections = [ - { headingName: "General model", placeholder: "Use chat default" }, - { - headingName: "Title generation model", - placeholder: "Use title default", - }, - { - headingName: "Compaction model", - placeholder: "Use chat model", - }, - { - headingName: "Explore subagent model", - placeholder: "Use chat default", - }, - ]; - for (const { headingName, placeholder } of unsetSections) { - const section = await getSection(canvasElement, headingName); - expect( - within(section).getByRole("combobox", { name: placeholder }), - ).toBeInTheDocument(); - expect( - within(section).queryByRole("button", { name: "Save" }), - ).not.toBeInTheDocument(); - } + await expect(canvas.getByText("Advisor")).toBeVisible(); }, }; -export const PersonalOverridesDisabled: Story = { - args: buildArgs({ - adminOverridesData: { allow_users: false }, - }), +export const WithoutAdvisor: Story = { + args: { showAdvisorSettings: false }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const toggle = await canvas.findByRole("switch", { - name: "Allow personal model overrides", - }); - - expect(toggle).not.toBeChecked(); - }, -}; - -export const PersonalOverridesEnabled: Story = { - args: buildArgs({ - adminOverridesData: { allow_users: true }, - }), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const toggle = await canvas.findByRole("switch", { - name: "Allow personal model overrides", - }); - - expect(toggle).toBeChecked(); - }, -}; - -export const PersonalOverridesLoadError: Story = { - args: buildArgs({ - adminOverridesData: undefined, - adminOverridesError: new Error("Failed to load personal model overrides."), - }), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect( - await canvas.findByText("Failed to load personal model overrides."), - ).toBeInTheDocument(); - expect( - canvas.queryByText("Loading personal model override settings..."), - ).not.toBeInTheDocument(); - }, -}; - -export const EachOverrideSetToEnabledModel: Story = { - args: buildArgs({ - generalModelOverrideData: buildOverrideData("general", { - model_config_id: generalModelConfig.id, - }), - titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData({ - model_config_id: titleModelConfig.id, - }), - compactionModelOverrideData: buildOverrideData("compaction", { - model_config_id: claudeSonnetModelConfig.id, - }), - exploreModelOverrideData: buildOverrideData("explore", { - model_config_id: exploreFallbackModelConfig.id, - }), - }), - play: async ({ canvasElement, args }) => { - const generalSection = await getSection(canvasElement, "General model"); - const titleSection = await getSection( - canvasElement, - "Title generation model", - ); - const compactionSection = await getSection( - canvasElement, - "Compaction model", - ); - const exploreSection = await getSection( - canvasElement, - "Explore subagent model", - ); - - expect( - within(exploreSection).getByRole("combobox", { - name: /claude-sonnet-4-20250514/i, - }), - ).toHaveTextContent("claude-sonnet-4-20250514"); - - expect( - within(titleSection).getByRole("combobox", { - name: /gpt 4o mini/i, - }), - ).toHaveTextContent("GPT 4o Mini"); - - await selectModelInSection( - generalSection, - canvasElement, - /gpt 4\.1 mini/i, - /^Claude Sonnet 4\b/, - ); - const generalSaveButton = within(generalSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(generalSaveButton).toBeEnabled(); - }); - await userEvent.click(generalSaveButton); - await waitFor(() => { - expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith( - { model_config_id: claudeSonnetModelConfig.id }, - expect.anything(), - ); - }); - - await selectModelInSection( - titleSection, - canvasElement, - /gpt 4o mini/i, - /^Claude Sonnet 4\b/, - ); - const titleSaveButton = within(titleSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(titleSaveButton).toBeEnabled(); - }); - await userEvent.click(titleSaveButton); - await waitFor(() => { - expect(args.onSaveTitleGenerationModel).toHaveBeenCalledWith( - { model_config_id: claudeSonnetModelConfig.id }, - expect.anything(), - ); - }); - - await selectModelInSection( - compactionSection, - canvasElement, - /claude sonnet 4/i, - /^GPT 4o Mini\b/, - ); - const compactionSaveButton = within(compactionSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(compactionSaveButton).toBeEnabled(); - }); - await userEvent.click(compactionSaveButton); - await waitFor(() => { - expect(args.onSaveCompactionModel).toHaveBeenCalledWith( - { model_config_id: titleModelConfig.id }, - expect.anything(), - ); - }); - - const exploreClearButton = within(exploreSection).getByRole("button", { - name: "Clear", - }); - await userEvent.click(exploreClearButton); - const exploreSaveButton = within(exploreSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(exploreSaveButton).toBeEnabled(); - }); - await userEvent.click(exploreSaveButton); - await waitFor(() => { - expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith( - { model_config_id: "" }, - expect.anything(), - ); - }); - }, -}; - -export const MalformedOverridesRemainClearableAndSaveable: Story = { - args: buildArgs({ - generalModelOverrideData: buildOverrideData("general", { - is_malformed: true, - }), - titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData({ - is_malformed: true, - }), - compactionModelOverrideData: buildOverrideData("compaction", { - is_malformed: true, - }), - exploreModelOverrideData: buildOverrideData("explore", { - is_malformed: true, - }), - }), - play: async ({ canvasElement, args }) => { - const generalSection = await getSection(canvasElement, "General model"); - const titleSection = await getSection( - canvasElement, - "Title generation model", - ); - const compactionSection = await getSection( - canvasElement, - "Compaction model", - ); - const exploreSection = await getSection( - canvasElement, - "Explore subagent model", - ); - - for (const section of [ - generalSection, - titleSection, - compactionSection, - exploreSection, - ]) { - await within(section).findByText(OVERRIDE_MALFORMED_WARNING); - } - - const generalSaveButton = within(generalSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(generalSaveButton).toBeEnabled(); - }); - await userEvent.click(generalSaveButton); - await waitFor(() => { - expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith( - { model_config_id: "" }, - expect.anything(), - ); - }); - - const titleSaveButton = within(titleSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(titleSaveButton).toBeEnabled(); - }); - await userEvent.click(titleSaveButton); - await waitFor(() => { - expect(args.onSaveTitleGenerationModel).toHaveBeenCalledWith( - { model_config_id: "" }, - expect.anything(), - ); - }); - - const exploreSaveButton = within(exploreSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(exploreSaveButton).toBeEnabled(); - }); - await userEvent.click(exploreSaveButton); - await waitFor(() => { - expect(args.onSaveExploreModelOverride).toHaveBeenCalledWith( - { model_config_id: "" }, - expect.anything(), - ); - }); - - const compactionSaveButton = within(compactionSection).getByRole("button", { - name: "Save", - }); - await waitFor(() => { - expect(compactionSaveButton).toBeEnabled(); - }); - await userEvent.click(compactionSaveButton); - await waitFor(() => { - expect(args.onSaveCompactionModel).toHaveBeenCalledWith( - { model_config_id: "" }, - expect.anything(), - ); - }); - }, -}; - -export const UnavailableSavedModels: Story = { - args: buildArgs({ - generalModelOverrideData: buildOverrideData("general", { - model_config_id: generalDisabledModelConfig.id, - }), - titleGenerationModelOverrideData: buildTitleGenerationModelOverrideData({ - model_config_id: titleDisabledModelConfig.id, - }), - compactionModelOverrideData: buildOverrideData("compaction", { - model_config_id: compactionDisabledModelConfig.id, - }), - exploreModelOverrideData: buildOverrideData("explore", { - model_config_id: exploreDisabledModelConfig.id, - }), - }), - play: async ({ canvasElement }) => { - const generalSection = await getSection(canvasElement, "General model"); - const titleSection = await getSection( - canvasElement, - "Title generation model", - ); - const compactionSection = await getSection( - canvasElement, - "Compaction model", - ); - const exploreSection = await getSection( - canvasElement, - "Explore subagent model", - ); - - for (const section of [generalSection, compactionSection, exploreSection]) { - await within(section).findByText(UNAVAILABLE_SAVED_MODEL_WARNING); - expect( - within(section).getByRole("combobox", { name: "Unavailable model" }), - ).toBeInTheDocument(); - } - await within(titleSection).findByText( - TITLE_UNAVAILABLE_SAVED_MODEL_WARNING, - ); - expect( - within(titleSection).getByRole("combobox", { - name: "Unavailable model", - }), - ).toBeInTheDocument(); - }, -}; - -export const AdvisorSettingsVisible: Story = { - args: buildArgs({ - showAdvisorSettings: true, - advisorConfigData: { - enabled: true, - max_uses_per_run: 3, - max_output_tokens: 16384, - model_config_id: "00000000-0000-0000-0000-000000000000", - }, - }), - play: async ({ canvasElement, args }) => { - const section = await getSection(canvasElement, "Advisor"); - expect( - within(section).getByRole("spinbutton", { - name: "Max uses per turn", - }), - ).toHaveValue(3); - expect( - within(section).getByRole("spinbutton", { - name: "Max output tokens", - }), - ).toHaveValue(16384); - expect( - within(section).getByRole("combobox", { name: "Use chat model" }), - ).toBeInTheDocument(); - - // Changing a value exposes the Save button. - const maxUses = within(section).getByRole("spinbutton", { - name: "Max uses per turn", - }); - await userEvent.clear(maxUses); - await userEvent.type(maxUses, "5"); - const saveButton = within(section).getByRole("button", { name: "Save" }); - await waitFor(() => { - expect(saveButton).toBeEnabled(); - }); - await userEvent.click(saveButton); - await waitFor(() => { - expect(args.onSaveAdvisorConfig).toHaveBeenCalledWith( - expect.objectContaining({ max_uses_per_run: 5 }), - expect.anything(), - ); - }); - }, -}; - -export const AdvisorReasoningEffort: Story = { - args: buildArgs({ - showAdvisorSettings: true, - advisorConfigData: { - enabled: true, - max_uses_per_run: 3, - max_output_tokens: 16384, - model_config_id: advisorReasoningModelConfig.id, - reasoning_effort: "medium", - }, - }), - play: async ({ canvasElement, args }) => { - const section = await getSection(canvasElement, "Advisor"); - const modelSelector = within(section).getByRole("combobox", { - name: "GPT 5.2", - }); - expect(modelSelector).toBeInTheDocument(); - const maxUses = within(section).getByRole("spinbutton", { - name: "Max uses per turn", - }); - await userEvent.clear(maxUses); - await userEvent.type(maxUses, "5"); - - const saveButton = await within(section).findByRole("button", { - name: "Save", - }); - expect(saveButton).toBeEnabled(); - await userEvent.click(saveButton); - await waitFor(() => { - expect(args.onSaveAdvisorConfig).toHaveBeenCalledWith( - expect.objectContaining({ - max_uses_per_run: 5, - model_config_id: advisorReasoningModelConfig.id, - reasoning_effort: "medium", - }), - expect.anything(), - ); - }); - }, -}; - -export const DisabledProviderModelsHidden: Story = { - args: buildArgs({ - showAdvisorSettings: true, - advisorConfigData: { - enabled: true, - max_uses_per_run: 3, - max_output_tokens: 16384, - model_config_id: "00000000-0000-0000-0000-000000000000", - }, - }), - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - const generalSection = await getSection(canvasElement, "General model"); - const generalTrigger = within(generalSection).getByRole("combobox", { - name: "Use chat default", - }); - await userEvent.click(generalTrigger); - expect( - await body.findByRole("option", { name: /GPT 4\.1 Mini/ }), - ).toBeInTheDocument(); - expect( - body.queryByRole("option", { name: /GPT 4o Secondary/ }), - ).not.toBeInTheDocument(); - await userEvent.keyboard("{Escape}"); - - const advisorSection = await getSection(canvasElement, "Advisor"); - const advisorTrigger = within(advisorSection).getByRole("combobox", { - name: "Use chat model", - }); - await userEvent.click(advisorTrigger); - expect( - await body.findByRole("option", { name: /GPT 4\.1 Mini/ }), - ).toBeInTheDocument(); - expect( - body.queryByRole("option", { name: /GPT 4o Secondary/ }), - ).not.toBeInTheDocument(); - await userEvent.keyboard("{Escape}"); - }, -}; - -export const AdvisorClearButton: Story = { - args: buildArgs({ - showAdvisorSettings: true, - advisorConfigData: { - enabled: true, - max_uses_per_run: 3, - max_output_tokens: 16384, - model_config_id: advisorReasoningModelConfig.id, - reasoning_effort: "high", - }, - }), - play: async ({ canvasElement, args }) => { - const section = await getSection(canvasElement, "Advisor"); - const clearButton = within(section).getByRole("button", { name: "Clear" }); - await userEvent.click(clearButton); - expect( - within(section).getByRole("spinbutton", { - name: "Max uses per turn", - }), - ).toHaveValue(0); - expect( - within(section).getByRole("spinbutton", { - name: "Max output tokens", - }), - ).toHaveValue(0); - expect( - within(section).getByRole("combobox", { name: "Use chat model" }), - ).toHaveTextContent("Use chat model"); - const saveButton = within(section).getByRole("button", { name: "Save" }); - await waitFor(() => { - expect(saveButton).toBeEnabled(); - }); - await userEvent.click(saveButton); - await waitFor(() => { - expect(args.onSaveAdvisorConfig).toHaveBeenCalledWith( - { - enabled: true, - max_uses_per_run: 0, - max_output_tokens: 0, - model_config_id: "00000000-0000-0000-0000-000000000000", - }, - expect.anything(), - ); - }); - }, -}; - -export const VirtualDesktopSettingsVisible: Story = { - args: buildArgs({ - showVirtualDesktopSettings: true, - computerUseProviderData: { provider: "anthropic" }, - }), - play: async ({ canvasElement }) => { - const section = await getSection(canvasElement, "Virtual desktop"); - expect( - within(section).getByRole("combobox", { - name: "Computer use provider", - }), - ).toHaveTextContent("Anthropic"); - }, -}; - -export const VirtualDesktopProviderChange: Story = { - args: buildArgs({ - showVirtualDesktopSettings: true, - computerUseProviderData: { provider: "anthropic" }, - }), - play: async ({ canvasElement, args }) => { - const section = await getSection(canvasElement, "Virtual desktop"); - const trigger = within(section).getByRole("combobox", { - name: "Computer use provider", - }); - await userEvent.click(trigger); - const body = within(canvasElement.ownerDocument.body); - await userEvent.click(await body.findByRole("option", { name: "OpenAI" })); - const saveButton = within(section).getByRole("button", { name: "Save" }); - await waitFor(() => { - expect(saveButton).toBeEnabled(); - }); - await userEvent.click(saveButton); - await waitFor(() => { - expect(args.onSaveComputerUseProvider).toHaveBeenCalledWith( - { provider: "openai" }, - expect.anything(), - ); - }); + await expect(canvas.queryByText("Advisor")).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx index 3252fc282e0..20bd28594f6 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx @@ -1,5 +1,6 @@ import type { FC } from "react"; import type { UseMutateFunction } from "react-query"; +import { Link } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { SettingsHeader, @@ -8,23 +9,11 @@ import { } from "#/components/SettingsHeader/SettingsHeader"; import { AdvisorSettings } from "#/pages/AgentsPage/components/AdvisorSettings"; import { VirtualDesktopSettings } from "#/pages/AgentsPage/components/VirtualDesktopSettings"; -import { - filterModelsWithEnabledProvider, - type ProviderInfo, -} from "#/pages/AgentsPage/utils/modelOptions"; import { AdminPersonalModelOverridesSettings, type SavePersonalModelOverridesAdminSetting, } from "./components/AdminPersonalModelOverridesSettings"; -import { - type MutationCallbacks, - SubagentModelOverrideSettings, -} from "./components/SubagentModelOverrideSettings"; - -type SaveModelOverride = ( - req: TypesGen.UpdateChatModelOverrideRequest, - options?: MutationCallbacks, -) => void; +import type { MutationCallbacks } from "./components/SubagentModelOverrideSettings"; export interface CoderAgentsPageViewProps { adminOverridesData?: TypesGen.ChatPersonalModelOverridesAdminSettings; @@ -34,27 +23,6 @@ export interface CoderAgentsPageViewProps { onSaveAdminOverrides: SavePersonalModelOverridesAdminSetting; isSavingAdminOverrides: boolean; isSaveAdminOverridesError: boolean; - generalModelOverrideData?: TypesGen.ChatModelOverrideResponse; - titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; - compactionModelOverrideData?: TypesGen.ChatModelOverrideResponse; - exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse; - models: readonly TypesGen.ChatModel[] | undefined; - providerInfoByID: ReadonlyMap; - modelsError: unknown; - isLoadingModels: boolean; - isFetchingModels: boolean; - onSaveGeneralModelOverride?: SaveModelOverride; - isSavingGeneralModelOverride?: boolean; - isSaveGeneralModelOverrideError?: boolean; - onSaveTitleGenerationModel: SaveModelOverride; - isSavingTitleGenerationModel: boolean; - isSaveTitleGenerationModelError: boolean; - onSaveCompactionModel: SaveModelOverride; - isSavingCompactionModel: boolean; - isSaveCompactionModelError: boolean; - onSaveExploreModelOverride: SaveModelOverride; - isSavingExploreModelOverride: boolean; - isSaveExploreModelOverrideError: boolean; showAdvisorSettings: boolean; advisorConfigData: TypesGen.AdvisorConfig | undefined; isAdvisorConfigLoading: boolean; @@ -78,6 +46,28 @@ export interface CoderAgentsPageViewProps { >; isSavingComputerUseProvider: boolean; computerUseProviderSaveError: Error | null; + generalModelOverrideData?: TypesGen.ChatModelOverrideResponse; + exploreModelOverrideData?: TypesGen.ChatModelOverrideResponse; + titleGenerationModelOverrideData?: TypesGen.ChatModelOverrideResponse; + compactionModelOverrideData?: TypesGen.ChatModelOverrideResponse; + models?: readonly TypesGen.ChatModel[]; + providerInfoByID?: ReadonlyMap; + modelsError?: unknown; + isLoadingModels?: boolean; + isFetchingModels?: boolean; + onSaveGeneralModelOverride?: unknown; + onSaveExploreModelOverride?: unknown; + onSaveTitleGenerationModel?: unknown; + onSaveCompactionModel?: unknown; + isSavingGeneralModelOverride?: boolean; + isSavingExploreModelOverride?: boolean; + isSavingTitleGenerationModel?: boolean; + isSavingCompactionModel?: boolean; + isSaveGeneralModelOverrideError?: boolean; + isSaveExploreModelOverrideError?: boolean; + isSaveTitleGenerationModelError?: boolean; + isSaveCompactionModelError?: boolean; + advisorModelOverrideData?: TypesGen.ChatModelOverrideResponse; } export const CoderAgentsPageView: FC = ({ @@ -88,27 +78,6 @@ export const CoderAgentsPageView: FC = ({ onSaveAdminOverrides, isSavingAdminOverrides, isSaveAdminOverridesError, - generalModelOverrideData, - titleGenerationModelOverrideData, - compactionModelOverrideData, - exploreModelOverrideData, - models, - providerInfoByID, - modelsError, - isLoadingModels, - isFetchingModels, - onSaveGeneralModelOverride, - isSavingGeneralModelOverride = false, - isSaveGeneralModelOverrideError = false, - onSaveTitleGenerationModel, - isSavingTitleGenerationModel, - isSaveTitleGenerationModelError, - onSaveCompactionModel, - isSavingCompactionModel, - isSaveCompactionModelError, - onSaveExploreModelOverride, - isSavingExploreModelOverride, - isSaveExploreModelOverrideError, showAdvisorSettings, advisorConfigData, isAdvisorConfigLoading, @@ -124,120 +93,47 @@ export const CoderAgentsPageView: FC = ({ onSaveComputerUseProvider, isSavingComputerUseProvider, computerUseProviderSaveError, -}) => { - const enabledModels = filterModelsWithEnabledProvider( - (models ?? []).filter((modelConfig) => modelConfig.enabled), - providerInfoByID, - ); - const showGeneralModelSection = - onSaveGeneralModelOverride !== undefined || - generalModelOverrideData !== undefined || - isSavingGeneralModelOverride || - isSaveGeneralModelOverrideError; - - return ( -
- - Coder Agents - - Configure deployment-wide defaults for Coder Agents and agent-specific - capabilities. - - -
- - {showGeneralModelSection && onSaveGeneralModelOverride && ( - - )} - - ( +
+ + Coder Agents + + Configure deployment-wide Coder Agents capabilities. Model defaults are + configured per organization in{" "} + Defaults & overrides. + + +
+ + {showVirtualDesktopSettings && ( + - - {showVirtualDesktopSettings && ( - - )} - {showAdvisorSettings && ( - - )} -
+ )}
- ); -}; +
+); diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx index 04e548ed15c..29148afc304 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx @@ -17,7 +17,6 @@ export interface MutationCallbacks { interface ModelOverrideData { readonly model_config_id: string; readonly reasoning_effort?: string; - readonly is_malformed: boolean; } interface UpdateModelOverrideRequest { @@ -65,7 +64,6 @@ export const SubagentModelOverrideSettings: FC< }) => { const { isSavedVisible, showSavedState } = useTemporarySavedState(); const hasLoadedModelOverride = modelOverrideData !== undefined; - const isMalformedOverride = modelOverrideData?.is_malformed ?? false; const enabledModelOptions = enabledModels.map((modelConfig) => { const providerInfo = providerInfoByID.get(modelConfig.ai_provider_id); const reasoningEffort = modelConfig.model_config?.reasoning_effort; @@ -111,8 +109,7 @@ export const SubagentModelOverrideSettings: FC< }); const isFormDisabled = disabled || isSaving || isLoading || !hasLoadedModelOverride; - const canSave = - hasLoadedModelOverride && !disabled && (form.dirty || isMalformedOverride); + const canSave = hasLoadedModelOverride && !disabled && form.dirty; const selectedModelOption = enabledModelOptions.find( (option) => option.id === form.values.model_config_id, @@ -175,8 +172,8 @@ export const SubagentModelOverrideSettings: FC<
diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx new file mode 100644 index 00000000000..4f849431fac --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx @@ -0,0 +1,123 @@ +import type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { + chatModels, + organizationChatModelOverrides, + updateOrganizationChatModelOverride, +} from "#/api/queries/chats"; +import type { ChatModelOverrideContext } from "#/api/typesGenerated"; +import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { + filterModelsWithEnabledProvider, + providerInfoByIDFromDescriptors, +} from "#/pages/AgentsPage/utils/modelOptions"; +import { pageTitle } from "#/utils/page"; +import { + splitModelQueryErrors, + useOrganizationModels, +} from "../organizationModels"; +import DefaultsPageView, { type SaveModelOverride } from "./DefaultsPageView"; + +const contexts: readonly ChatModelOverrideContext[] = [ + "general", + "explore", + "title_generation", + "compaction", + "advisor", +]; + +const DefaultsPage: FC = () => { + const queryClient = useQueryClient(); + const { experiments } = useDashboard(); + const { organization, permissions } = useOrganizationModels(); + const modelsQuery = useQuery(chatModels(organization.id)); + const overridesQuery = useQuery( + organizationChatModelOverrides(organization.id), + ); + const generalMutation = useMutation( + updateOrganizationChatModelOverride( + queryClient, + organization.id, + "general", + ), + ); + const exploreMutation = useMutation( + updateOrganizationChatModelOverride( + queryClient, + organization.id, + "explore", + ), + ); + const titleMutation = useMutation( + updateOrganizationChatModelOverride( + queryClient, + organization.id, + "title_generation", + ), + ); + const compactionMutation = useMutation( + updateOrganizationChatModelOverride( + queryClient, + organization.id, + "compaction", + ), + ); + const advisorMutation = useMutation( + updateOrganizationChatModelOverride( + queryClient, + organization.id, + "advisor", + ), + ); + const mutations = [ + generalMutation, + exploreMutation, + titleMutation, + compactionMutation, + advisorMutation, + ] as const; + const providerInfoByID = providerInfoByIDFromDescriptors( + modelsQuery.data?.providers, + ); + const enabledModels = filterModelsWithEnabledProvider( + (modelsQuery.data?.models ?? []).filter((model) => model.enabled), + providerInfoByID, + ); + const { loadError, refetchError } = splitModelQueryErrors( + modelsQuery, + overridesQuery, + ); + const saveByContext = new Map(); + for (const [index, context] of contexts.entries()) { + const mutation = mutations[index]; + if (mutation) { + saveByContext.set(context, mutation.mutate); + } + } + + return ( + <> + Codestin Search App + mutations[index]?.isPending)) + } + errorContexts={ + new Set(contexts.filter((_, index) => mutations[index]?.isError)) + } + /> + + ); +}; + +export default DefaultsPage; diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx new file mode 100644 index 00000000000..b8b7c9f9dcb --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import type * as TypesGen from "#/api/typesGenerated"; +import { MockChatModel } from "#/testHelpers/chatModels"; +import DefaultsPageView from "./DefaultsPageView"; + +const model: TypesGen.ChatModel = { + ...MockChatModel, + id: "model-1", + display_name: "Model One", +}; +const overrides: readonly TypesGen.ChatModelOverrideResponse[] = [ + { context: "general", model_config_id: "model-1", reasoning_effort: "high" }, + { context: "compaction", model_config_id: "model-1" }, +]; +const saveByContext = new Map< + TypesGen.ChatModelOverrideContext, + (req: TypesGen.UpdateChatModelOverrideRequest) => void +>([ + ["general", fn()], + ["explore", fn()], + ["title_generation", fn()], + ["compaction", fn()], + ["advisor", fn()], +]); + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/DefaultsPageView", + component: DefaultsPageView, + args: { + overrides, + enabledModels: [model], + providerInfoByID: new Map([ + [ + model.ai_provider_id, + { provider: "openai", displayName: "OpenAI", icon: "" }, + ], + ]), + isLoading: false, + loadError: null, + refetchError: null, + canEdit: true, + showAdvisor: true, + saveByContext, + savingContexts: new Set(), + errorContexts: new Set(), + }, +}; +export default meta; +type Story = StoryObj; + +export const SetAndUnset: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("General subagent")).toBeVisible(); + await expect(canvas.getAllByText("Use default").length).toBeGreaterThan(0); + }, +}; +export const AdvisorDisabled: Story = { + args: { showAdvisor: false }, + play: async ({ canvasElement }) => { + await expect( + within(canvasElement).queryByText("Advisor"), + ).not.toBeInTheDocument(); + }, +}; +export const ReadOnly: Story = { + args: { canEdit: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + for (const button of canvas.getAllByRole("button")) + await expect(button).toBeDisabled(); + }, +}; +export const NoModels: Story = { + args: { enabledModels: [] }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByRole("status")).toHaveTextContent( + "no enabled chat models", + ); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx new file mode 100644 index 00000000000..554d26f116e --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx @@ -0,0 +1,135 @@ +import type { FC } from "react"; +import type * as TypesGen from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions"; +import { SubagentModelOverrideSettings } from "#/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings"; + +export type SaveModelOverride = ( + req: TypesGen.UpdateChatModelOverrideRequest, + options?: { onSuccess?: () => void; onError?: () => void }, +) => void; + +interface DefaultsPageViewProps { + overrides: readonly TypesGen.ChatModelOverrideResponse[] | undefined; + enabledModels: readonly TypesGen.ChatModel[]; + providerInfoByID: ReadonlyMap; + isLoading: boolean; + loadError: unknown; + refetchError: unknown; + canEdit: boolean; + showAdvisor: boolean; + saveByContext: ReadonlyMap< + TypesGen.ChatModelOverrideContext, + SaveModelOverride + >; + savingContexts: ReadonlySet; + errorContexts: ReadonlySet; +} + +const settings: readonly { + context: TypesGen.ChatModelOverrideContext; + title: string; + description: string; +}[] = [ + { + context: "general", + title: "General subagent", + description: + "Used by delegated agents that can edit files or run commands.", + }, + { + context: "explore", + title: "Explore subagent", + description: "Used for read-only codebase exploration.", + }, + { + context: "title_generation", + title: "Title generation", + description: "Used to generate chat titles.", + }, + { + context: "compaction", + title: "Compaction", + description: "Used to summarize conversations near the context limit.", + }, + { + context: "advisor", + title: "Advisor", + description: "Used by the advisor for strategic guidance.", + }, +]; + +const DefaultsPageView: FC = ({ + overrides, + enabledModels, + providerInfoByID, + isLoading, + loadError, + refetchError, + canEdit, + showAdvisor, + saveByContext, + savingContexts, + errorContexts, +}) => { + if (loadError) { + return ; + } + const visibleSettings = settings.filter( + (setting) => setting.context !== "advisor" || showAdvisor, + ); + + return ( +
+ + Defaults & overrides + + Choose organization defaults for each Coder Agents context. Leave a + model unset to use the default model selection. + + + {refetchError != null && } + {enabledModels.length === 0 && !isLoading && ( +

+ This organization has no enabled chat models. +

+ )} +
+ {visibleSettings.map((setting) => { + const saved = overrides?.find( + (override) => override.context === setting.context, + ) ?? { context: setting.context, model_config_id: "" }; + const onSave = saveByContext.get(setting.context); + if (!onSave) { + return null; + } + return ( + + ); + })} +
+
+ ); +}; + +export default DefaultsPageView; diff --git a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx index 94191dac283..18f47fb7e0c 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.tsx @@ -13,11 +13,14 @@ import { getOrganizationLabel, OrganizationAutocomplete, } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import NotFoundPage from "#/pages/NotFoundPage/NotFoundPage"; import { modelOrganizationSearchParam, OrganizationModelsContext, + organizationModelDefaultsPath, + organizationModelsPath, selectModelOrganization, useAccessibleModelOrganizations, } from "./organizationModels"; @@ -91,6 +94,29 @@ const OrganizationModelsLayout: FC = () => { /> )} + + + + Models + + + Defaults & overrides + + + {accessibleOrganizations.length > 1 && (
organizationModelSettingsPath(organization, "", searchParams); +export const organizationModelDefaultsPath = ( + organization: Organization, + searchParams?: URLSearchParams, +): string => + organizationModelSettingsPath(organization, "/defaults", searchParams); + export const organizationAddModelPath = ( organization: Organization, searchParams?: URLSearchParams, diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index fc68de9c5ec..c81fce40061 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -3,10 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; -import { - createChat, - userChatPersonalModelOverrides, -} from "#/api/queries/chats"; +import { createChat } from "#/api/queries/chats"; import { preferenceSettings } from "#/api/queries/users"; import { workspaces } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; @@ -32,10 +29,6 @@ const AgentCreatePage: FC = () => { const navigate = useNavigate(); const { permissions } = useAuthenticated(); const aiGatewayDisabled = !useAIGatewayEnabled(); - - const personalModelOverridesQuery = useQuery( - userChatPersonalModelOverrides(), - ); const preferencesQuery = useQuery(preferenceSettings()); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const createMutation = useMutation(createChat(queryClient)); @@ -83,10 +76,6 @@ const AgentCreatePage: FC = () => { }); }; - const rootPersonalModelOverride = personalModelOverridesQuery.data?.enabled - ? personalModelOverridesQuery.data.root - : undefined; - const handleChimeToggle = () => { const next = !chimeEnabled; setChimeEnabledState(next); @@ -128,8 +117,6 @@ const AgentCreatePage: FC = () => { canCreateChat={permissions.createChat} canConfigureAgentSetup={permissions.editDeploymentConfig} aiGatewayDisabled={aiGatewayDisabled} - rootPersonalModelOverride={rootPersonalModelOverride} - isPersonalModelOverridesLoading={personalModelOverridesQuery.isLoading} workspaceCount={workspacesQuery.data?.count} workspaceOptions={workspacesQuery.data?.workspaces ?? []} workspacesError={workspacesQuery.error} diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx index c302468dfe8..8556b919120 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { chatModels, @@ -16,27 +16,41 @@ import { resolveModelSelector } from "./utils/modelOptions"; const AgentSettingsUserAgentsPage: FC = () => { const queryClient = useQueryClient(); const { organizations } = useDashboard(); - const overridesQuery = useQuery(userChatPersonalModelOverrides()); const defaultOrganizationId = getDefaultOrganizationId(organizations); - const modelsQuery = useQuery(chatModels(defaultOrganizationId)); + const [selectedOrganizationId, setSelectedOrganizationId] = useState( + defaultOrganizationId, + ); + const selectedOrganization = + organizations.find( + (organization) => organization.id === selectedOrganizationId, + ) ?? + organizations.find( + (organization) => organization.id === defaultOrganizationId, + ) ?? + organizations[0]; + const organizationId = selectedOrganization?.id ?? ""; + const overridesQuery = useQuery( + userChatPersonalModelOverrides(organizationId), + ); + const modelsQuery = useQuery(chatModels(organizationId)); const saveRootModelOverrideMutation = useMutation( - updateUserChatPersonalModelOverride(queryClient), + updateUserChatPersonalModelOverride(queryClient, organizationId), ); const saveGeneralModelOverrideMutation = useMutation( - updateUserChatPersonalModelOverride(queryClient), + updateUserChatPersonalModelOverride(queryClient, organizationId), ); const saveExploreModelOverrideMutation = useMutation( - updateUserChatPersonalModelOverride(queryClient), + updateUserChatPersonalModelOverride(queryClient, organizationId), ); - const defaultOrgModelConfigs = modelsQuery.data?.models ?? []; + const organizationModelConfigs = modelsQuery.data?.models ?? []; const { options: modelOptions, isModelCatalogLoading } = resolveModelSelector( - defaultOrganizationId, + organizationId, modelsQuery, ); - const hasNoAvailableDefaultOrgModels = - defaultOrganizationId !== "" && + const hasNoOrganizationModels = + organizationId !== "" && !modelsQuery.isLoading && modelsQuery.error === null && modelsQuery.data !== undefined && @@ -64,11 +78,16 @@ const AgentSettingsUserAgentsPage: FC = () => { isRetryingOverrides={overridesQuery.isFetching} isLoadingOverrides={overridesQuery.isLoading} modelOptions={modelOptions} - models={defaultOrgModelConfigs} + organizations={organizations} + selectedOrganization={selectedOrganization} + onSelectOrganization={(organization) => + setSelectedOrganizationId(organization.id) + } + models={organizationModelConfigs} modelsError={modelsQuery.error} isLoadingModels={isModelCatalogLoading} - isDefaultOrganizationUnresolved={defaultOrganizationId === ""} - hasNoAvailableDefaultOrgModels={hasNoAvailableDefaultOrgModels} + isOrganizationUnresolved={organizationId === ""} + hasNoOrganizationModels={hasNoOrganizationModels} onSaveRootModelOverride={saveModelOverride( "root", saveRootModelOverrideMutation, diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx index 5f76db380fa..f37ecb21b9e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { MockChatModel } from "#/testHelpers/chatModels"; +import { MockDefaultOrganization } from "#/testHelpers/entities"; import { AgentSettingsUserAgentsPageView, type AgentSettingsUserAgentsPageViewProps, @@ -34,7 +35,6 @@ const buildOverride = ( mode: context === "root" ? "chat_default" : "deployment_default", model_config_id: "", is_set: false, - is_malformed: false, ...overrides, }); @@ -44,7 +44,6 @@ const buildDeploymentDefault = ( ): TypesGen.ChatModelOverrideResponse => ({ context, model_config_id: "", - is_malformed: false, ...overrides, }); @@ -160,8 +159,11 @@ const buildArgs = ( models, modelsError: undefined, isLoadingModels: false, - isDefaultOrganizationUnresolved: false, - hasNoAvailableDefaultOrgModels: false, + organizations: [MockDefaultOrganization], + selectedOrganization: MockDefaultOrganization, + onSelectOrganization: fn(), + isOrganizationUnresolved: false, + hasNoOrganizationModels: false, onSaveRootModelOverride: fn(), isSavingRootModelOverride: false, isSaveRootModelOverrideError: false, @@ -228,10 +230,10 @@ export const EnabledWithNoSavedValues: Story = { expect(rootSection).toHaveTextContent("Chat default: GPT 4.1 Mini"); expect(generalSection).toHaveTextContent( - "Deployment default: Claude Sonnet 4", + "Organization default: Claude Sonnet 4", ); expect(exploreSection).toHaveTextContent( - "Deployment default: Claude Sonnet 4", + "Organization default: Claude Sonnet 4", ); for (const section of [rootSection, generalSection, exploreSection]) { @@ -299,7 +301,7 @@ export const EnabledWithSavedValues: Story = { await selectOption( generalSection, canvasElement, - "General subagent model behavior, Deployment default: Claude Sonnet 4", + "General subagent model behavior, Organization default: Claude Sonnet 4", /Chat default/i, ); await userEvent.click( @@ -425,11 +427,7 @@ export const SavedLowReasoningEffort: Story = { export const MalformedSavedValues: Story = { args: buildArgs({ - overridesData: buildOverridesResponse({ - root: buildOverride("root", { is_malformed: true }), - general: buildOverride("general", { is_malformed: true }), - explore: buildOverride("explore", { is_malformed: true }), - }), + overridesData: buildOverridesResponse({}), }), play: async ({ canvasElement, args }) => { const rootSection = await getSection(canvasElement, "Root agent model"); @@ -468,19 +466,16 @@ export const MalformedEmptyModelSavedValues: Story = { mode: "model", model_config_id: "", is_set: true, - is_malformed: true, }), general: buildOverride("general", { mode: "model", model_config_id: "", is_set: true, - is_malformed: true, }), explore: buildOverride("explore", { mode: "model", model_config_id: "", is_set: true, - is_malformed: true, }), }), }), @@ -496,8 +491,8 @@ export const MalformedEmptyModelSavedValues: Story = { ); expect(rootSection).toHaveTextContent("Chat default"); - expect(generalSection).toHaveTextContent("Deployment default"); - expect(exploreSection).toHaveTextContent("Deployment default"); + expect(generalSection).toHaveTextContent("Organization default"); + expect(exploreSection).toHaveTextContent("Organization default"); for (const section of [rootSection, generalSection, exploreSection]) { expect(within(section).getByText(MALFORMED_WARNING)).toBeInTheDocument(); @@ -612,7 +607,7 @@ export const ModelsError: Story = { generalSection, canvasElement, "General subagent model behavior, Claude Sonnet 4", - /Deployment default/i, + /Organization default/i, ); await selectOption( exploreSection, @@ -622,7 +617,7 @@ export const ModelsError: Story = { ); expect(rootSection).toHaveTextContent("Chat default"); - expect(generalSection).toHaveTextContent("Deployment default"); + expect(generalSection).toHaveTextContent("Organization default"); expect(exploreSection).toHaveTextContent("Chat default"); }, }; @@ -700,16 +695,16 @@ export const SaveErrorState: Story = { }, }; -export const NoAvailableDefaultOrgModels: Story = { +export const NoAvailableOrganizationModels: Story = { args: buildArgs({ - hasNoAvailableDefaultOrgModels: true, + hasNoOrganizationModels: true, modelOptions: [], models: [disabledModelConfig], }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect( - canvas.getByText(/default organization has no available chat models/i), + canvas.getByText(/selected organization has no available chat models/i), ).toBeInTheDocument(); const rootSection = await getSection(canvasElement, "Root agent model"); const generalSection = await getSection( @@ -733,7 +728,7 @@ export const NoAvailableDefaultOrgModels: Story = { export const DefaultOrganizationUnresolved: Story = { args: buildArgs({ - isDefaultOrganizationUnresolved: true, + isOrganizationUnresolved: true, modelOptions: [], models: [], }), diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx index 36c4bfef8b0..7d77c73123b 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx @@ -3,6 +3,10 @@ import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; +import { + getOrganizationLabel, + OrganizationAutocomplete, +} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import type { ModelSelectorOption } from "./components/ChatElements"; import { PersonalModelOverrideRow, @@ -20,8 +24,11 @@ export interface AgentSettingsUserAgentsPageViewProps { models: readonly TypesGen.ChatModel[]; modelsError: unknown; isLoadingModels: boolean; - isDefaultOrganizationUnresolved: boolean; - hasNoAvailableDefaultOrgModels: boolean; + organizations: readonly TypesGen.Organization[]; + selectedOrganization: TypesGen.Organization | undefined; + onSelectOrganization: (organization: TypesGen.Organization) => void; + isOrganizationUnresolved: boolean; + hasNoOrganizationModels: boolean; onSaveRootModelOverride: SavePersonalOverride; isSavingRootModelOverride: boolean; isSaveRootModelOverrideError: boolean; @@ -45,8 +52,11 @@ export const AgentSettingsUserAgentsPageView: FC< models, modelsError, isLoadingModels, - isDefaultOrganizationUnresolved, - hasNoAvailableDefaultOrgModels, + organizations, + selectedOrganization, + onSelectOrganization, + isOrganizationUnresolved, + hasNoOrganizationModels, onSaveRootModelOverride, isSavingRootModelOverride, isSaveRootModelOverrideError, @@ -62,8 +72,8 @@ export const AgentSettingsUserAgentsPageView: FC< const isDisabled = isLoading || !personalOverridesEnabled || - isDefaultOrganizationUnresolved || - hasNoAvailableDefaultOrgModels; + isOrganizationUnresolved || + hasNoOrganizationModels; return (
@@ -71,6 +81,18 @@ export const AgentSettingsUserAgentsPageView: FC< label="Agents" description="Choose personal model defaults for root agents and delegated agents." /> + {organizations.length > 1 && selectedOrganization && ( + { + if (organization) onSelectOrganization(organization); + }} + /> + )} {overridesError ? (
@@ -95,18 +117,18 @@ export const AgentSettingsUserAgentsPageView: FC< )} - {isDefaultOrganizationUnresolved && ( + {isOrganizationUnresolved && ( - Your default organization is not available. Personal model overrides - cannot be changed. + An organization is not available. Personal model overrides cannot be + changed. )} - {hasNoAvailableDefaultOrgModels && ( + {hasNoOrganizationModels && ( - Your default organization has no available chat models. Ask an + The selected organization has no available chat models. Ask an organization administrator to add and enable a model before you set personal overrides. diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 71536340b18..d0f694f1725 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -113,12 +113,10 @@ const AgentsRouteElement = () => ( exploreModelOverrideData={{ context: "explore", model_config_id: "", - is_malformed: false, }} titleGenerationModelOverrideData={{ context: "title_generation", model_config_id: "", - is_malformed: false, }} models={[]} providerInfoByID={new Map()} @@ -302,32 +300,27 @@ const meta: Meta = { mode: "deployment_default", model_config_id: "", is_set: false, - is_malformed: false, }, general: { context: "general", mode: "deployment_default", model_config_id: "", is_set: false, - is_malformed: false, }, explore: { context: "explore", mode: "deployment_default", model_config_id: "", is_set: false, - is_malformed: false, }, deployment_defaults: { general: { context: "general", model_config_id: "", - is_malformed: false, }, explore: { context: "explore", model_config_id: "", - is_malformed: false, }, }, }); diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index a2e114d7734..5add02ac67b 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -55,6 +55,7 @@ import type * as TypesGen from "#/api/typesGenerated"; import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { + getDefaultOrganizationId, getDefaultOrganizationName, useDashboard, } from "#/modules/dashboard/useDashboard"; @@ -162,6 +163,7 @@ const AgentsPageLayout: FC = () => { const { permissions, user } = useAuthenticated(); const { organizations } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); + const defaultOrganizationId = getDefaultOrganizationId(organizations); const isAgentsAdmin = permissions.editDeploymentConfig; const [sidebarFilters, setSidebarFilters] = getAgentSidebarFilters( @@ -230,7 +232,7 @@ const AgentsPageLayout: FC = () => { organizations.map((organization) => organization.id), ); const personalModelOverridesQuery = useQuery( - userChatPersonalModelOverrides(), + userChatPersonalModelOverrides(defaultOrganizationId), ); const [chatErrorReasons, setChatErrorReasons] = useState< Record diff --git a/site/src/pages/AgentsPage/components/AdvisorSettings.tsx b/site/src/pages/AgentsPage/components/AdvisorSettings.tsx index 4670d364970..406d56bc215 100644 --- a/site/src/pages/AgentsPage/components/AdvisorSettings.tsx +++ b/site/src/pages/AgentsPage/components/AdvisorSettings.tsx @@ -1,20 +1,13 @@ import { useFormik } from "formik"; import { type FC, useId } from "react"; +import { Link } from "react-router"; import { getErrorMessage } from "#/api/errors"; import type { AdvisorConfig, - ChatModel, UpdateAdvisorConfigRequest, } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState"; -import { ModelSelector } from "#/pages/AgentsPage/components/ChatElements/ModelSelector"; -import { - isUnsetModelRef, - NIL_UUID, - type ProviderInfo, -} from "#/pages/AgentsPage/utils/modelOptions"; -import { pickReasoningEffort } from "#/pages/AgentsPage/utils/reasoningEffort"; import { AgentSettingLayout } from "#/pages/AISettingsPage/CoderAgentsPage/components/AgentSettingLayout"; import { cn } from "#/utils/cn"; @@ -28,11 +21,6 @@ interface AdvisorSettingsProps { isAdvisorConfigLoading: boolean; isAdvisorConfigFetching: boolean; isAdvisorConfigLoadError: boolean; - enabledModels: readonly ChatModel[]; - providerInfoByID: ReadonlyMap; - modelsError: unknown; - isLoadingModels: boolean; - isFetchingModels: boolean; onSaveAdvisorConfig: ( req: UpdateAdvisorConfigRequest, options?: MutationCallbacks, @@ -45,22 +33,17 @@ interface AdvisorSettingsProps { type AdvisorSettingsFormValues = { max_uses_per_run: string; max_output_tokens: string; - model_config_id: string; - reasoning_effort: string; }; const normalizeNonNegativeInteger = ( value: number | string | undefined, ): number => { const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed) || parsed < 0) { - return 0; - } - return Math.trunc(parsed); + return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0; }; const normalizeAdvisorConfig = ( - config: AdvisorConfig | undefined, + config: AdvisorConfig | UpdateAdvisorConfigRequest | undefined, ): AdvisorSettingsFormValues => ({ max_uses_per_run: String( normalizeNonNegativeInteger(config?.max_uses_per_run), @@ -68,49 +51,26 @@ const normalizeAdvisorConfig = ( max_output_tokens: String( normalizeNonNegativeInteger(config?.max_output_tokens), ), - model_config_id: - typeof config?.model_config_id === "string" && - !isUnsetModelRef(config.model_config_id) - ? config.model_config_id - : "", - reasoning_effort: config?.reasoning_effort ?? "", -}); - -const toAdvisorConfigRequest = ( - values: AdvisorSettingsFormValues, -): UpdateAdvisorConfigRequest => ({ - enabled: true, - max_uses_per_run: normalizeNonNegativeInteger(values.max_uses_per_run), - max_output_tokens: normalizeNonNegativeInteger(values.max_output_tokens), - model_config_id: isUnsetModelRef(values.model_config_id) - ? NIL_UUID - : values.model_config_id, - ...(!isUnsetModelRef(values.model_config_id) && values.reasoning_effort - ? { reasoning_effort: values.reasoning_effort } - : {}), }); const isNonNegativeIntegerString = (value: string): boolean => { - if (value.trim() === "") { - return false; - } const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 && Number.isInteger(parsed); + return ( + value.trim() !== "" && + Number.isFinite(parsed) && + parsed >= 0 && + Number.isInteger(parsed) + ); }; const validateAdvisorConfig = (values: AdvisorSettingsFormValues) => { const errors: Partial> = {}; - - if (!isNonNegativeIntegerString(values.max_uses_per_run)) { + if (!isNonNegativeIntegerString(values.max_uses_per_run)) errors.max_uses_per_run = "Max uses per turn must be a non-negative integer."; - } - - if (!isNonNegativeIntegerString(values.max_output_tokens)) { + if (!isNonNegativeIntegerString(values.max_output_tokens)) errors.max_output_tokens = "Max output tokens must be a non-negative integer."; - } - return errors; }; @@ -119,11 +79,6 @@ export const AdvisorSettings: FC = ({ isAdvisorConfigLoading, isAdvisorConfigFetching, isAdvisorConfigLoadError, - enabledModels, - providerInfoByID, - modelsError, - isLoadingModels, - isFetchingModels, onSaveAdvisorConfig, isSavingAdvisorConfig, isSaveAdvisorConfigError, @@ -133,111 +88,42 @@ export const AdvisorSettings: FC = ({ const maxOutputTokensId = useId(); const { isSavedVisible, showSavedState } = useTemporarySavedState(); const hasLoadedAdvisorConfig = advisorConfigData !== undefined; - const enabledModelOptions = enabledModels.map((config) => { - const providerInfo = providerInfoByID.get(config.ai_provider_id); - const reasoningEffort = config.model_config?.reasoning_effort; - const reasoningEfforts = config.reasoning_efforts ?? []; - return { - id: config.id, - provider: providerInfo?.provider ?? "", - providerId: config.ai_provider_id, - providerLabel: providerInfo?.displayName, - providerIcon: providerInfo?.icon, - model: config.model, - displayName: config.display_name.trim() || config.model, - contextLimit: config.context_limit, - ...(reasoningEffort?.default - ? { reasoningEffortDefault: reasoningEffort.default } - : {}), - ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}), - }; - }); - const form = useFormik({ enableReinitialize: true, validateOnMount: true, initialValues: normalizeAdvisorConfig(advisorConfigData), validate: validateAdvisorConfig, onSubmit: (values, { resetForm }) => { - // If the last committed model override references a model config - // that is no longer available, the backend rejects the stale ID - // with a 400. Clear the override so a save stays reliable in - // that edge case. Only scrub when model configs have loaded - // successfully and no refetch is in flight. - let source = values; - if ( - !isUnsetModelRef(source.model_config_id) && - !isLoadingModels && - !isFetchingModels && - !modelsError && - !enabledModels.some((model) => model.id === source.model_config_id) - ) { - source = { ...source, model_config_id: "", reasoning_effort: "" }; - } - // A stored effort can become unselectable if the model config's - // efforts changed after the setting was saved. Submit the same - // sanitized effort the slider displays so the backend does not - // reject the stale value. An empty effort stays empty so the - // advisor keeps following the model config's default. When the - // saved model is proven unavailable (disabled), drop the effort so - // unrelated edits still save; while configs are loading, - // refetching, or errored, preserve the stored effort. - const submitOption = enabledModelOptions.find( - (option) => option.id === source.model_config_id, - ); - if (source.reasoning_effort) { - if (submitOption) { - source = { - ...source, - reasoning_effort: - pickReasoningEffort( - source.reasoning_effort, - submitOption.reasoningEfforts ?? [], - submitOption.reasoningEffortDefault, - ) ?? "", - }; - } else if (!isLoadingModels && !isFetchingModels && !modelsError) { - source = { ...source, reasoning_effort: "" }; - } - } - const request = toAdvisorConfigRequest(source); + const request: UpdateAdvisorConfigRequest = { + max_uses_per_run: normalizeNonNegativeInteger(values.max_uses_per_run), + max_output_tokens: normalizeNonNegativeInteger( + values.max_output_tokens, + ), + }; onSaveAdvisorConfig(request, { onSuccess: () => { - const nextValues = normalizeAdvisorConfig(request); showSavedState(); - resetForm({ values: nextValues }); + resetForm({ values: normalizeAdvisorConfig(request) }); }, }); }, }); - const isFormDisabled = isSavingAdvisorConfig || isAdvisorConfigLoading || isAdvisorConfigFetching || !hasLoadedAdvisorConfig; - const isModelSelectDisabled = - isFormDisabled || isLoadingModels || Boolean(modelsError); - const selectedModelOption = enabledModelOptions.find( - (option) => option.id === form.values.model_config_id, - ); - const selectedReasoningEffort = selectedModelOption - ? pickReasoningEffort( - form.values.reasoning_effort, - selectedModelOption.reasoningEfforts ?? [], - selectedModelOption.reasoningEffortDefault, - ) - : undefined; - const hasUnavailableSelectedModel = - !isLoadingModels && - !isUnsetModelRef(form.values.model_config_id) && - selectedModelOption === undefined; const canSave = hasLoadedAdvisorConfig && form.dirty && form.isValid; - return ( + Cap advisor usage per turn. Configure its model in{" "} + Defaults & overrides. + Set limits to 0 for unlimited. + + } showSave={canSave} isSaving={isSavingAdvisorConfig} isSavedVisible={isSavedVisible} @@ -282,55 +168,13 @@ export const AdvisorSettings: FC = ({ disabled={isFormDisabled} className="w-36" /> - { - const option = enabledModelOptions.find( - (option) => option.id === value, - ); - let reasoningEffort = ""; - if (option) { - reasoningEffort = - pickReasoningEffort( - "", - option.reasoningEfforts ?? [], - option.reasoningEffortDefault, - ) ?? ""; - } - void form.setValues({ - ...form.values, - model_config_id: value, - reasoning_effort: reasoningEffort, - }); - }} - disabled={isModelSelectDisabled} - placeholder={ - hasUnavailableSelectedModel ? "Unavailable model" : "Use chat model" - } - unsetLabel="Use chat model" - emptyMessage={ - isLoadingModels ? "Loading models..." : "No enabled models found." - } - className="h-10 w-[22rem] max-w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm" - contentClassName="min-w-[18rem]" - reasoningEffort={selectedReasoningEffort} - onReasoningEffortChange={(value) => - void form.setFieldValue("reasoning_effort", value) - } - />
diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx index b8b7c9f9dcb..05979fe8382 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { MockChatModel } from "#/testHelpers/chatModels"; import DefaultsPageView from "./DefaultsPageView"; @@ -9,6 +9,14 @@ const model: TypesGen.ChatModel = { id: "model-1", display_name: "Model One", }; +const alternateModel: TypesGen.ChatModel = { + ...MockChatModel, + id: "model-2", + model: "model-two", + display_name: "Model Two", +}; +const saveGeneralOverride = fn(); +const saveExploreOverride = fn(); const overrides: readonly TypesGen.ChatModelOverrideResponse[] = [ { context: "general", model_config_id: "model-1", reasoning_effort: "high" }, { context: "compaction", model_config_id: "model-1" }, @@ -17,8 +25,8 @@ const saveByContext = new Map< TypesGen.ChatModelOverrideContext, (req: TypesGen.UpdateChatModelOverrideRequest) => void >([ - ["general", fn()], - ["explore", fn()], + ["general", saveGeneralOverride], + ["explore", saveExploreOverride], ["title_generation", fn()], ["compaction", fn()], ["advisor", fn()], @@ -29,7 +37,7 @@ const meta: Meta = { component: DefaultsPageView, args: { overrides, - enabledModels: [model], + enabledModels: [model, alternateModel], providerInfoByID: new Map([ [ model.ai_provider_id, @@ -50,10 +58,52 @@ export default meta; type Story = StoryObj; export const SetAndUnset: Story = { + beforeEach: () => { + saveGeneralOverride.mockClear(); + saveExploreOverride.mockClear(); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("General subagent")).toBeVisible(); - await expect(canvas.getAllByText("Use default").length).toBeGreaterThan(0); + const body = within(canvasElement.ownerDocument.body); + const generalSection = canvas.getByRole("form", { + name: "General subagent", + }); + const exploreSection = canvas.getByRole("form", { + name: "Explore subagent", + }); + + await userEvent.click( + within(exploreSection).getByRole("combobox", { name: "Use default" }), + ); + await userEvent.click( + await body.findByRole("option", { name: /Model Two/i }), + ); + const exploreSave = within(exploreSection).getByRole("button", { + name: "Save", + }); + await waitFor(() => expect(exploreSave).toBeEnabled()); + await userEvent.click(exploreSave); + await waitFor(() => { + expect(saveExploreOverride).toHaveBeenCalledWith( + { model_config_id: alternateModel.id }, + expect.anything(), + ); + }); + + await userEvent.click( + within(generalSection).getByRole("button", { name: "Clear" }), + ); + const generalSave = within(generalSection).getByRole("button", { + name: "Save", + }); + await waitFor(() => expect(generalSave).toBeEnabled()); + await userEvent.click(generalSave); + await waitFor(() => { + expect(saveGeneralOverride).toHaveBeenCalledWith( + { model_config_id: "" }, + expect.anything(), + ); + }); }, }; export const AdvisorDisabled: Story = { diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx index afbf07b29e4..f18e4c91f25 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx @@ -1,8 +1,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { useState } from "react"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { MockChatModel } from "#/testHelpers/chatModels"; -import { MockDefaultOrganization } from "#/testHelpers/entities"; +import { + MockDefaultOrganization, + MockOrganization2, +} from "#/testHelpers/entities"; import { AgentSettingsUserAgentsPageView, type AgentSettingsUserAgentsPageViewProps, @@ -109,6 +113,22 @@ const reasoningModelOption: ModelSelectorOption = { reasoningEfforts: ["none", "minimal", "low", "medium", "high"], }; +const organization2ModelConfig = buildModelConfig({ + id: "organization-2-model", + organization_id: MockOrganization2.id, + model: "organization-two-model", + display_name: "Organization Two Model", + is_default: true, +}); + +const organization2ModelOption: ModelSelectorOption = { + id: organization2ModelConfig.id, + provider: "openai", + model: organization2ModelConfig.model, + displayName: organization2ModelConfig.display_name, + contextLimit: organization2ModelConfig.context_limit, +}; + const modelOptions: ModelSelectorOption[] = [ { id: defaultModelConfig.id, @@ -174,6 +194,36 @@ const buildArgs = ( ...overrides, }); +const organization2OverridesResponse = buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: organization2ModelConfig.id, + is_set: true, + }), +}); + +const MultiOrganizationView = (props: AgentSettingsUserAgentsPageViewProps) => { + const [selectedOrganization, setSelectedOrganization] = useState( + MockDefaultOrganization, + ); + const isOrganization2 = selectedOrganization.id === MockOrganization2.id; + return ( + + ); +}; + const getSection = async ( canvasElement: HTMLElement, headingName: string, @@ -578,6 +628,30 @@ export const OverridesError: Story = { }, }; +export const SwitchOrganizations: Story = { + args: buildArgs(), + render: (args) => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { + name: new RegExp(MockDefaultOrganization.display_name, "i"), + }), + ); + await userEvent.click( + await screen.findByRole("option", { + name: new RegExp(MockOrganization2.display_name, "i"), + }), + ); + const rootSection = await getSection(canvasElement, "Root agent model"); + await expect( + within(rootSection).getByRole("combobox", { + name: /Organization Two Model$/, + }), + ).toBeVisible(); + }, +}; + export const SaveErrorState: Story = { args: buildArgs({ isSaveGeneralModelOverrideError: true, diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index d0f694f1725..cf860c8ba97 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -110,28 +110,6 @@ const AgentsRouteElement = () => ( onSaveAdminOverrides={fn()} isSavingAdminOverrides={false} isSaveAdminOverridesError={false} - exploreModelOverrideData={{ - context: "explore", - model_config_id: "", - }} - titleGenerationModelOverrideData={{ - context: "title_generation", - model_config_id: "", - }} - models={[]} - providerInfoByID={new Map()} - modelsError={undefined} - isLoadingModels={false} - isFetchingModels={false} - onSaveTitleGenerationModel={fn()} - isSavingTitleGenerationModel={false} - isSaveTitleGenerationModelError={false} - onSaveCompactionModel={fn()} - isSavingCompactionModel={false} - isSaveCompactionModelError={false} - onSaveExploreModelOverride={fn()} - isSavingExploreModelOverride={false} - isSaveExploreModelOverrideError={false} showAdvisorSettings={false} advisorConfigData={undefined} isAdvisorConfigLoading={false} @@ -1269,9 +1247,7 @@ export const SettingsViewCoderAgentsLink: Story = { await waitFor(() => { expect( - screen.getByText( - "Configure deployment-wide defaults for Coder Agents and agent-specific capabilities.", - ), + screen.getByText(/Configure deployment-wide Coder Agents capabilities/), ).toBeInTheDocument(); }); }, diff --git a/site/src/pages/AgentsPage/components/AdvisorSettings.tsx b/site/src/pages/AgentsPage/components/AdvisorSettings.tsx index 406d56bc215..3c22db36293 100644 --- a/site/src/pages/AgentsPage/components/AdvisorSettings.tsx +++ b/site/src/pages/AgentsPage/components/AdvisorSettings.tsx @@ -146,7 +146,7 @@ export const AdvisorSettings: FC = ({ id={maxUsesId} name="max_uses_per_run" label="Uses / turn" - ariaLabel="Max uses per turn" + ariaLabel="Uses / turn" value={form.values.max_uses_per_run} onChange={(value) => void form.setFieldValue("max_uses_per_run", value)} onBlur={form.handleBlur} @@ -158,7 +158,7 @@ export const AdvisorSettings: FC = ({ id={maxOutputTokensId} name="max_output_tokens" label="Max tokens" - ariaLabel="Max output tokens" + ariaLabel="Max tokens" value={form.values.max_output_tokens} onChange={(value) => void form.setFieldValue("max_output_tokens", value) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index b2bb10275a9..276963c7749 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -15,6 +15,7 @@ import { API } from "#/api/api"; import { aiProvidersListKey } from "#/api/queries/aiProviders"; import { organizationChatModelsKey, + userChatPersonalModelOverrides, userChatProviderConfigsKey, } from "#/api/queries/chats"; import { permittedOrganizationsKey } from "#/api/queries/organizations"; @@ -204,6 +205,29 @@ const buildRootPersonalModelOverride = ( ...overrides, }); +const buildPersonalModelOverridesResponse = ( + root = buildRootPersonalModelOverride({ is_set: false }), +): TypesGen.UserChatPersonalModelOverridesResponse => ({ + enabled: true, + root, + general: { + context: "general", + mode: "deployment_default", + model_config_id: "", + is_set: false, + }, + explore: { + context: "explore", + mode: "deployment_default", + model_config_id: "", + is_set: false, + }, + deployment_defaults: { + general: { context: "general", model_config_id: "" }, + explore: { context: "explore", model_config_id: "" }, + }, +}); + const mock403Error = Object.assign( new Error("Request failed with status code 403"), { @@ -248,6 +272,11 @@ const meta: Meta = { key: userChatProviderConfigsKey, data: defaultUserProviderConfigs, }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse(), + }, ], }, beforeEach: () => { @@ -307,15 +336,33 @@ export const RootPersonalModelOverrideModelSelected: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "model", - model_config_id: claudeModelConfigID, - }), + }, + parameters: { + queries: [ + { + key: organizationChatModelsKey(MockDefaultOrganization.id), + data: defaultModelCatalog, + }, + { + key: userChatProviderConfigsKey, + data: defaultUserProviderConfigs, + }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "model", + model_config_id: claudeModelConfigID, + }), + ), + }, + ], }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - expect( - canvas.getByRole("combobox", { name: "Claude Sonnet 4" }), + await expect( + await canvas.findByRole("combobox", { name: "Claude Sonnet 4" }), ).toBeInTheDocument(); await submitMessage(canvasElement, "create with saved root model"); await waitFor(() => { @@ -329,10 +376,28 @@ export const RootChatDefaultSubmitsDisplayedModel: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "chat_default", - model_config_id: "", - }), + }, + parameters: { + queries: [ + { + key: organizationChatModelsKey(MockDefaultOrganization.id), + data: defaultModelCatalog, + }, + { + key: userChatProviderConfigsKey, + data: defaultUserProviderConfigs, + }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "chat_default", + model_config_id: "", + }), + ), + }, + ], }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); @@ -351,11 +416,29 @@ export const RootOverrideMissingFromCatalog: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "model", - model_config_id: "model-does-not-exist", - is_set: true, - }), + }, + parameters: { + queries: [ + { + key: organizationChatModelsKey(MockDefaultOrganization.id), + data: defaultModelCatalog, + }, + { + key: userChatProviderConfigsKey, + data: defaultUserProviderConfigs, + }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "model", + model_config_id: "model-does-not-exist", + is_set: true, + }), + ), + }, + ], }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); @@ -396,10 +479,28 @@ export const ManualSelectionOverridesRootChatDefault: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "chat_default", - model_config_id: "", - }), + }, + parameters: { + queries: [ + { + key: organizationChatModelsKey(MockDefaultOrganization.id), + data: defaultModelCatalog, + }, + { + key: userChatProviderConfigsKey, + data: defaultUserProviderConfigs, + }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "chat_default", + model_config_id: "", + }), + ), + }, + ], }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); @@ -519,11 +620,6 @@ export const PersistedReasoningEffortOutranksRootOverride: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "model", - model_config_id: modelID, - reasoning_effort: "high", - }), }, parameters: { queries: [ @@ -535,6 +631,17 @@ export const PersistedReasoningEffortOutranksRootOverride: Story = { key: userChatProviderConfigsKey, data: defaultUserProviderConfigs, }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "model", + model_config_id: modelID, + reasoning_effort: "high", + }), + ), + }, ], }, beforeEach: () => { @@ -564,11 +671,6 @@ export const PersistedReasoningEffortOutranksRootOverride: Story = { export const ManualReselectKeepsRootOverrideEffort: Story = { args: { ...defaultArgs, - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "model", - model_config_id: modelID, - reasoning_effort: "high", - }), }, parameters: { queries: [ @@ -580,6 +682,17 @@ export const ManualReselectKeepsRootOverrideEffort: Story = { key: userChatProviderConfigsKey, data: defaultUserProviderConfigs, }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "model", + model_config_id: modelID, + reasoning_effort: "high", + }), + ), + }, ], }, play: async ({ canvasElement }) => { @@ -602,11 +715,6 @@ export const StalePersistedEffortFallsThroughToRootOverride: Story = { args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), - rootPersonalModelOverride: buildRootPersonalModelOverride({ - mode: "model", - model_config_id: modelID, - reasoning_effort: "medium", - }), }, parameters: { queries: [ @@ -618,6 +726,17 @@ export const StalePersistedEffortFallsThroughToRootOverride: Story = { key: userChatProviderConfigsKey, data: defaultUserProviderConfigs, }, + { + key: userChatPersonalModelOverrides(MockDefaultOrganization.id) + .queryKey, + data: buildPersonalModelOverridesResponse( + buildRootPersonalModelOverride({ + mode: "model", + model_config_id: modelID, + reasoning_effort: "medium", + }), + ), + }, ], }, beforeEach: () => { @@ -863,7 +982,24 @@ export const CachedModelsWithRefetchError: Story = { export const LoadingPersonalModelOverrides: Story = { args: { ...defaultArgs, - isPersonalModelOverridesLoading: true, + }, + beforeEach: () => { + spyOn( + API.experimental, + "getUserChatPersonalModelOverrides", + ).mockReturnValue(new Promise(() => undefined)); + }, + parameters: { + queries: [ + { + key: organizationChatModelsKey(MockDefaultOrganization.id), + data: defaultModelCatalog, + }, + { + key: userChatProviderConfigsKey, + data: defaultUserProviderConfigs, + }, + ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 33b9e97d2ea..ff7f2fc2836 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -135,8 +135,6 @@ interface AgentCreateFormProps { canCreateChat: boolean; canConfigureAgentSetup: boolean; aiGatewayDisabled?: boolean; - rootPersonalModelOverride?: TypesGen.ChatPersonalModelOverride; - isPersonalModelOverridesLoading?: boolean; workspaceCount: number | undefined; workspaceOptions: readonly TypesGen.Workspace[]; workspacesError: unknown; @@ -151,7 +149,6 @@ export const AgentCreateForm: FC = ({ canCreateChat, canConfigureAgentSetup, aiGatewayDisabled, - rootPersonalModelOverride, workspaceCount: _workspaceCount, workspaceOptions, workspacesError, @@ -283,10 +280,8 @@ export const AgentCreateForm: FC = ({ ? personalModelOverridesQuery.data.root : undefined; const effectiveRootPersonalModelOverride = - organizationRootPersonalModelOverride ?? rootPersonalModelOverride; - const isPersonalModelOverridesLoading = - personalModelOverridesQuery.isLoading && - rootPersonalModelOverride === undefined; + organizationRootPersonalModelOverride; + const isPersonalModelOverridesLoading = personalModelOverridesQuery.isLoading; const availableModelConfigs = modelsQuery.data?.models ?? []; const chatProviderConfigsQuery = useQuery({ ...chatProviderConfigs(), @@ -593,6 +588,9 @@ export const AgentCreateForm: FC = ({ {modelsQuery.error != null && ( )} + {mcpServersQuery.error != null && ( + + )} {organizationId !== "" && modelsQuery.data !== undefined && modelsQuery.error == null && diff --git a/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx b/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx index f2c8f162d6e..3ae545c3b04 100644 --- a/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx +++ b/site/src/pages/AgentsPage/components/ModelOverrideAlerts.tsx @@ -4,8 +4,6 @@ import { Alert, AlertDescription } from "#/components/Alert/Alert"; interface ModelOverrideAlertsProps { isUnavailableSavedModel: boolean; unavailableMessage: ReactNode; - isMalformedOverride: boolean; - malformedMessage: ReactNode; modelsError: unknown; children?: ReactNode; } @@ -13,8 +11,6 @@ interface ModelOverrideAlertsProps { export const ModelOverrideAlerts: FC = ({ isUnavailableSavedModel, unavailableMessage, - isMalformedOverride, - malformedMessage, modelsError, children, }) => { @@ -25,11 +21,6 @@ export const ModelOverrideAlerts: FC = ({ {unavailableMessage} )} - {isMalformedOverride && ( - - {malformedMessage} - - )} {children} {Boolean(modelsError) && (

diff --git a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx index f07aeffd7d7..4a523aeaf8b 100644 --- a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx +++ b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx @@ -288,8 +288,6 @@ export const PersonalModelOverrideRow: FC = ({ {isInvalidRootDeploymentDefault && ( From 0dcd25372526480916ed8e70a1aa84a8ce63b828 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:33:23 +0000 Subject: [PATCH 06/16] fix: address organization model override review findings Fetch advisor overrides for telemetry in one bulk query instead of one query per organization, keep the Clear action usable when an organization has no enabled models, remount override settings forms on organization switch so mutation state never leaks across organizations, and resolve the sidebar personal-overrides feature flag through an accessible organization for users outside the default organization. --- coderd/database/dbauthz/dbauthz.go | 9 ++ coderd/database/dbauthz/dbauthz_test.go | 4 + coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/querier.go | 5 ++ coderd/database/querier_test.go | 79 +++++++++++++++++ coderd/database/queries.sql.go | 59 +++++++++++++ .../database/queries/chatmodeloverrides.sql | 23 +++++ coderd/telemetry/telemetry.go | 46 ++-------- coderd/telemetry/telemetry_test.go | 85 +++++++------------ .../ModelsPage/DefaultsPage/DefaultsPage.tsx | 8 +- .../DefaultsPage/DefaultsPageView.stories.tsx | 25 +++++- .../DefaultsPage/DefaultsPageView.tsx | 2 +- .../AgentSettingsUserAgentsPage.tsx | 36 +++++++- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 7 +- 15 files changed, 313 insertions(+), 98 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d18374a26d1..32641625617 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3571,6 +3571,15 @@ func (q *querier) GetChatOrganizationModelOverrides(ctx context.Context, organiz return q.db.GetChatOrganizationModelOverrides(ctx, organizationID) } +func (q *querier) GetChatOrganizationModelOverridesByContext(ctx context.Context, argContext string) ([]database.GetChatOrganizationModelOverridesByContextRow, error) { + // This read spans every organization, so it requires site-wide access to + // chat model configs. It exists for bulk consumers such as telemetry. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChatModelConfig); err != nil { + return nil, err + } + return q.db.GetChatOrganizationModelOverridesByContext(ctx, argContext) +} + func (q *querier) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { // The personal model overrides flag is a deployment-wide setting read by // authenticated chat users. We only require that an explicit actor is diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 124bc5ae064..5eab2778354 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1308,6 +1308,10 @@ func (s *MethodTestSuite) TestChats() { }) check.Args(orgID).Asserts(object, policy.ActionRead) })) + s.Run("GetChatOrganizationModelOverridesByContext", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatOrganizationModelOverridesByContext(gomock.Any(), "advisor").Return([]database.GetChatOrganizationModelOverridesByContextRow{}, nil).AnyTimes() + check.Args("advisor").Asserts(rbac.ResourceChatModelConfig, policy.ActionRead) + })) s.Run("DeleteChatOrganizationModelOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { orgID := uuid.New() arg := database.DeleteChatOrganizationModelOverrideParams{OrganizationID: orgID, Context: "general"} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 548fecc09f3..1cba16aad4a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1720,6 +1720,14 @@ func (m queryMetricsStore) GetChatOrganizationModelOverrides(ctx context.Context return r0, r1 } +func (m queryMetricsStore) GetChatOrganizationModelOverridesByContext(ctx context.Context, argContext string) ([]database.GetChatOrganizationModelOverridesByContextRow, error) { + start := time.Now() + r0, r1 := m.s.GetChatOrganizationModelOverridesByContext(ctx, argContext) + m.queryLatencies.WithLabelValues("GetChatOrganizationModelOverridesByContext").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatOrganizationModelOverridesByContext").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { start := time.Now() r0, r1 := m.s.GetChatPersonalModelOverridesEnabled(ctx) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 7b0b06fdb36..f0630ba28c1 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3224,6 +3224,21 @@ func (mr *MockStoreMockRecorder) GetChatOrganizationModelOverrides(ctx, organiza return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatOrganizationModelOverrides", reflect.TypeOf((*MockStore)(nil).GetChatOrganizationModelOverrides), ctx, organizationID) } +// GetChatOrganizationModelOverridesByContext mocks base method. +func (m *MockStore) GetChatOrganizationModelOverridesByContext(ctx context.Context, argContext string) ([]database.GetChatOrganizationModelOverridesByContextRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatOrganizationModelOverridesByContext", ctx, argContext) + ret0, _ := ret[0].([]database.GetChatOrganizationModelOverridesByContextRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatOrganizationModelOverridesByContext indicates an expected call of GetChatOrganizationModelOverridesByContext. +func (mr *MockStoreMockRecorder) GetChatOrganizationModelOverridesByContext(ctx, argContext any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatOrganizationModelOverridesByContext", reflect.TypeOf((*MockStore)(nil).GetChatOrganizationModelOverridesByContext), ctx, argContext) +} + // GetChatPersonalModelOverridesEnabled mocks base method. func (m *MockStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ea9208e5e00..7e32ec91fce 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -490,6 +490,11 @@ type sqlcQuerier interface { GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error) GetChatOrganizationModelOverride(ctx context.Context, arg GetChatOrganizationModelOverrideParams) (ChatOrganizationModelOverride, error) GetChatOrganizationModelOverrides(ctx context.Context, organizationID uuid.UUID) ([]ChatOrganizationModelOverride, error) + // Returns every non-deleted organization's override for one context together + // with the resolved model and provider, for bulk consumers such as telemetry. + // model_available mirrors GetEnabledChatModelConfigByID: it is false when the + // referenced config or its provider is disabled or deleted. + GetChatOrganizationModelOverridesByContext(ctx context.Context, argContext string) ([]GetChatOrganizationModelOverridesByContextRow, error) // GetChatPersonalModelOverridesEnabled returns whether users may configure // personal chat model overrides. It defaults to false when unset. GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 2af1b17da1c..aab0595e533 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -6491,6 +6491,85 @@ func TestGetUserStatusCounts(t *testing.T) { } } +func TestGetChatOrganizationModelOverridesByContext(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + availableOrg := dbgen.Organization(t, db, database.Organization{}) + availableModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "bulk-available-" + uuid.NewString(), + Enabled: true, + OrganizationID: availableOrg.ID, + }) + require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{ + OrganizationID: availableOrg.ID, + Context: "general", + ModelConfigID: availableModel.ID, + })) + + // An override referencing a disabled model reports the model unavailable. + // dbgen coerces Enabled=false to true, so disable it after insertion. + disabledOrg := dbgen.Organization(t, db, database.Organization{}) + disabledModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "bulk-disabled-" + uuid.NewString(), + OrganizationID: disabledOrg.ID, + }) + _, err := db.UpdateChatModelConfig(ctx, database.UpdateChatModelConfigParams{ + ID: disabledModel.ID, + Model: disabledModel.Model, + DisplayName: disabledModel.DisplayName, + Enabled: false, + IsDefault: disabledModel.IsDefault, + ContextLimit: disabledModel.ContextLimit, + CompressionThreshold: disabledModel.CompressionThreshold, + Options: disabledModel.Options, + AIProviderID: disabledModel.AIProviderID, + }) + require.NoError(t, err) + require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{ + OrganizationID: disabledOrg.ID, + Context: "general", + ModelConfigID: disabledModel.ID, + })) + + // Deleted organizations are excluded from the bulk read. + deletedOrg := dbgen.Organization(t, db, database.Organization{}) + deletedOrgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "bulk-deleted-org-" + uuid.NewString(), + Enabled: true, + OrganizationID: deletedOrg.ID, + }) + require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{ + OrganizationID: deletedOrg.ID, + Context: "general", + ModelConfigID: deletedOrgModel.ID, + })) + require.NoError(t, db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{ + ID: deletedOrg.ID, + UpdatedAt: dbtime.Now(), + })) + + rows, err := db.GetChatOrganizationModelOverridesByContext(ctx, "general") + require.NoError(t, err) + require.Len(t, rows, 2) + byOrg := make(map[uuid.UUID]database.GetChatOrganizationModelOverridesByContextRow, len(rows)) + for _, row := range rows { + byOrg[row.OrganizationID] = row + } + available := byOrg[availableOrg.ID] + require.True(t, available.ModelAvailable) + require.Equal(t, availableModel.Model, available.Model) + require.NotEmpty(t, available.ProviderType) + unavailable := byOrg[disabledOrg.ID] + require.False(t, unavailable.ModelAvailable) + require.Empty(t, unavailable.Model) + + otherContext, err := db.GetChatOrganizationModelOverridesByContext(ctx, "advisor") + require.NoError(t, err) + require.Empty(t, otherContext) +} + func TestOrganizationDeleteTrigger(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 571170eb17c..0ba38bb148d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6769,6 +6769,65 @@ func (q *sqlQuerier) GetChatOrganizationModelOverrides(ctx context.Context, orga return items, nil } +const getChatOrganizationModelOverridesByContext = `-- name: GetChatOrganizationModelOverridesByContext :many +SELECT + o.organization_id, + (cmc.id IS NOT NULL AND ap.id IS NOT NULL)::boolean AS model_available, + COALESCE(cmc.model, '')::text AS model, + COALESCE(ap.type::text, '')::text AS provider_type +FROM chat_organization_model_overrides o +JOIN organizations org ON org.id = o.organization_id AND NOT org.deleted +LEFT JOIN chat_model_configs cmc + ON cmc.id = o.model_config_id + AND cmc.deleted = FALSE + AND cmc.enabled = TRUE +LEFT JOIN ai_providers ap + ON ap.id = cmc.ai_provider_id + AND ap.enabled = TRUE + AND ap.deleted = FALSE +WHERE o.context = $1 +ORDER BY o.organization_id +` + +type GetChatOrganizationModelOverridesByContextRow struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + ModelAvailable bool `db:"model_available" json:"model_available"` + Model string `db:"model" json:"model"` + ProviderType string `db:"provider_type" json:"provider_type"` +} + +// Returns every non-deleted organization's override for one context together +// with the resolved model and provider, for bulk consumers such as telemetry. +// model_available mirrors GetEnabledChatModelConfigByID: it is false when the +// referenced config or its provider is disabled or deleted. +func (q *sqlQuerier) GetChatOrganizationModelOverridesByContext(ctx context.Context, argContext string) ([]GetChatOrganizationModelOverridesByContextRow, error) { + rows, err := q.db.QueryContext(ctx, getChatOrganizationModelOverridesByContext, argContext) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetChatOrganizationModelOverridesByContextRow + for rows.Next() { + var i GetChatOrganizationModelOverridesByContextRow + if err := rows.Scan( + &i.OrganizationID, + &i.ModelAvailable, + &i.Model, + &i.ProviderType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getChatUserModelOverride = `-- name: GetChatUserModelOverride :one SELECT id, user_id, organization_id, context, mode, model_config_id, reasoning_effort FROM chat_user_model_overrides diff --git a/coderd/database/queries/chatmodeloverrides.sql b/coderd/database/queries/chatmodeloverrides.sql index 992d2719291..393686de24e 100644 --- a/coderd/database/queries/chatmodeloverrides.sql +++ b/coderd/database/queries/chatmodeloverrides.sql @@ -10,6 +10,29 @@ FROM chat_organization_model_overrides WHERE organization_id = @organization_id AND context = @context; +-- name: GetChatOrganizationModelOverridesByContext :many +-- Returns every non-deleted organization's override for one context together +-- with the resolved model and provider, for bulk consumers such as telemetry. +-- model_available mirrors GetEnabledChatModelConfigByID: it is false when the +-- referenced config or its provider is disabled or deleted. +SELECT + o.organization_id, + (cmc.id IS NOT NULL AND ap.id IS NOT NULL)::boolean AS model_available, + COALESCE(cmc.model, '')::text AS model, + COALESCE(ap.type::text, '')::text AS provider_type +FROM chat_organization_model_overrides o +JOIN organizations org ON org.id = o.organization_id AND NOT org.deleted +LEFT JOIN chat_model_configs cmc + ON cmc.id = o.model_config_id + AND cmc.deleted = FALSE + AND cmc.enabled = TRUE +LEFT JOIN ai_providers ap + ON ap.id = cmc.ai_provider_id + AND ap.enabled = TRUE + AND ap.deleted = FALSE +WHERE o.context = @context +ORDER BY o.organization_id; + -- name: UpsertChatOrganizationModelOverride :exec INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index e47bfc3460e..ec1e9c39929 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -2441,29 +2441,19 @@ func CollectAgentsAdvisor(ctx context.Context, opts Options) json.RawMessage { payload.MaxOutputTokens = max(cfg.MaxOutputTokens, 0) } - organizations, err := opts.Database.GetOrganizations(ctx, database.GetOrganizationsParams{}) + overrides, err := opts.Database.GetChatOrganizationModelOverridesByContext(ctx, string(codersdk.ChatModelOverrideContextAdvisor)) if err != nil { - opts.Logger.Warn(ctx, "get organizations for advisor telemetry", slog.Error(err)) + opts.Logger.Warn(ctx, "get chat advisor model overrides for telemetry", slog.Error(err)) } else { - for _, organization := range organizations { - if organization.Deleted { - continue + for _, override := range overrides { + // When the override's model is disabled or deleted, the runtime + // falls back to the chat model. + provider, model := AgentsExperimentAdvisorReuseChatModel, AgentsExperimentAdvisorReuseChatModel + if override.ModelAvailable { + provider, model = override.ProviderType, override.Model } - override, err := opts.Database.GetChatOrganizationModelOverride(ctx, database.GetChatOrganizationModelOverrideParams{ - OrganizationID: organization.ID, - Context: string(codersdk.ChatModelOverrideContextAdvisor), - }) - if errors.Is(err, sql.ErrNoRows) { - continue - } - if err != nil { - opts.Logger.Warn(ctx, "get chat advisor model override for telemetry", - slog.F("organization_id", organization.ID), slog.Error(err)) - continue - } - provider, model := advisorModelTelemetry(ctx, opts.Database, opts.Logger, override.ModelConfigID) payload.Overrides = append(payload.Overrides, AgentsAdvisorOverrideTelemetry{ - OrganizationID: organization.ID.String(), + OrganizationID: override.OrganizationID.String(), Provider: provider, Model: model, }) @@ -2478,24 +2468,6 @@ func CollectAgentsAdvisor(ctx context.Context, opts Options) json.RawMessage { return val } -func advisorModelTelemetry(ctx context.Context, db database.Store, log slog.Logger, id uuid.UUID) (provider string, model string) { - cfg, err := db.GetEnabledChatModelConfigByID(ctx, id) - if errors.Is(err, sql.ErrNoRows) { - // An inactive override; the runtime falls back to the chat model. - return AgentsExperimentAdvisorReuseChatModel, AgentsExperimentAdvisorReuseChatModel - } - if err != nil { - log.Warn(ctx, "resolve chat advisor model config for telemetry", slog.Error(err)) - return AgentsExperimentUnknown, AgentsExperimentUnknown - } - providerRow, err := db.GetAIProviderByID(ctx, cfg.AIProviderID.UUID) - if err != nil { - log.Warn(ctx, "resolve chat advisor model provider for telemetry", slog.Error(err)) - return AgentsExperimentUnknown, cfg.Model - } - return string(providerRow.Type), cfg.Model -} - type TelemetryItem struct { Key string `json:"key"` Value string `json:"value"` diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 1e11062ca86..4fe8978a0c0 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -2382,15 +2382,15 @@ func TestCollectAgentsAdvisor(t *testing.T) { require.NoError(t, err) return string(raw) } - expectOrganizations := func(db *dbmock.MockStore, organizations ...database.Organization) { - db.EXPECT().GetOrganizations(gomock.Any(), database.GetOrganizationsParams{}).Return(organizations, nil) + expectOverrides := func(db *dbmock.MockStore, rows ...database.GetChatOrganizationModelOverridesByContextRow) { + db.EXPECT().GetChatOrganizationModelOverridesByContext(gomock.Any(), string(codersdk.ChatModelOverrideContextAdvisor)).Return(rows, nil) } t.Run("Defaults", func(t *testing.T) { t.Parallel() db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(marshalConfig(t, codersdk.AdvisorConfig{}), nil) - expectOrganizations(db) + expectOverrides(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.False(t, payload.Enabled) @@ -2402,19 +2402,14 @@ func TestCollectAgentsAdvisor(t *testing.T) { t.Run("ModelOverrides", func(t *testing.T) { t.Parallel() org := database.Organization{ID: uuid.New()} - deletedOrg := database.Organization{ID: uuid.New(), Deleted: true} - modelID := uuid.New() - providerID := uuid.New() db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(marshalConfig(t, codersdk.AdvisorConfig{MaxUsesPerRun: 7, MaxOutputTokens: 2048}), nil) - expectOrganizations(db, org, deletedOrg) - db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), database.GetChatOrganizationModelOverrideParams{ - OrganizationID: org.ID, Context: string(codersdk.ChatModelOverrideContextAdvisor), - }).Return(database.ChatOrganizationModelOverride{OrganizationID: org.ID, ModelConfigID: modelID}, nil) - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{ - Model: "gpt-6-preview", AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, - }, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{Type: database.AIProviderTypeOpenai}, nil) + expectOverrides(db, database.GetChatOrganizationModelOverridesByContextRow{ + OrganizationID: org.ID, + ModelAvailable: true, + Model: "gpt-6-preview", + ProviderType: string(database.AIProviderTypeOpenai), + }) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t), Experiments: codersdk.Experiments{codersdk.ExperimentChatAdvisor}}) require.True(t, payload.Enabled) @@ -2425,13 +2420,28 @@ func TestCollectAgentsAdvisor(t *testing.T) { }}, payload.Overrides) }) - t.Run("NoOverride", func(t *testing.T) { + t.Run("InactiveModel", func(t *testing.T) { t.Parallel() org := database.Organization{ID: uuid.New()} db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) - expectOrganizations(db, org) - db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), gomock.Any()).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) + expectOverrides(db, database.GetChatOrganizationModelOverridesByContextRow{ + OrganizationID: org.ID, + ModelAvailable: false, + }) + payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) + require.Equal(t, []telemetry.AgentsAdvisorOverrideTelemetry{{ + OrganizationID: org.ID.String(), + Provider: telemetry.AgentsExperimentAdvisorReuseChatModel, + Model: telemetry.AgentsExperimentAdvisorReuseChatModel, + }}, payload.Overrides) + }) + + t.Run("NoOverride", func(t *testing.T) { + t.Parallel() + db := dbmock.NewMockStore(gomock.NewController(t)) + db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) + expectOverrides(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Empty(t, payload.Overrides) }) @@ -2440,7 +2450,7 @@ func TestCollectAgentsAdvisor(t *testing.T) { t.Parallel() db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("not-json", nil) - expectOrganizations(db) + expectOverrides(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Zero(t, payload.MaxUsesPerRun) require.Zero(t, payload.MaxOutputTokens) @@ -2450,7 +2460,7 @@ func TestCollectAgentsAdvisor(t *testing.T) { t.Parallel() db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return(`{"max_uses_per_run":-3,"max_output_tokens":-99}`, nil) - expectOrganizations(db) + expectOverrides(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Zero(t, payload.MaxUsesPerRun) require.Zero(t, payload.MaxOutputTokens) @@ -2460,48 +2470,17 @@ func TestCollectAgentsAdvisor(t *testing.T) { t.Parallel() db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("", sql.ErrConnDone) - expectOrganizations(db) + expectOverrides(db) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Empty(t, payload.Overrides) }) - t.Run("OrganizationsFetchError", func(t *testing.T) { + t.Run("OverridesFetchError", func(t *testing.T) { t.Parallel() db := dbmock.NewMockStore(gomock.NewController(t)) db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) - db.EXPECT().GetOrganizations(gomock.Any(), database.GetOrganizationsParams{}).Return(nil, sql.ErrConnDone) + db.EXPECT().GetChatOrganizationModelOverridesByContext(gomock.Any(), gomock.Any()).Return(nil, sql.ErrConnDone) payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) require.Empty(t, payload.Overrides) }) - - for _, tc := range []struct { - name string - modelErr error - providerErr error - wantProvider string - wantModel string - }{ - {name: "InactiveModel", modelErr: sql.ErrNoRows, wantProvider: telemetry.AgentsExperimentAdvisorReuseChatModel, wantModel: telemetry.AgentsExperimentAdvisorReuseChatModel}, - {name: "ModelResolveError", modelErr: sql.ErrConnDone, wantProvider: telemetry.AgentsExperimentUnknown, wantModel: telemetry.AgentsExperimentUnknown}, - {name: "ProviderResolveError", providerErr: sql.ErrConnDone, wantProvider: telemetry.AgentsExperimentUnknown, wantModel: "gpt-6-preview"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - org := database.Organization{ID: uuid.New()} - modelID := uuid.New() - providerID := uuid.New() - db := dbmock.NewMockStore(gomock.NewController(t)) - db.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil) - expectOrganizations(db, org) - db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), gomock.Any()).Return(database.ChatOrganizationModelOverride{OrganizationID: org.ID, ModelConfigID: modelID}, nil) - if tc.modelErr != nil { - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{}, tc.modelErr) - } else { - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelID).Return(database.ChatModelConfig{Model: "gpt-6-preview", AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}}, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{}, tc.providerErr) - } - payload := collect(t, telemetry.Options{Database: db, Logger: testutil.Logger(t)}) - require.Equal(t, []telemetry.AgentsAdvisorOverrideTelemetry{{OrganizationID: org.ID.String(), Provider: tc.wantProvider, Model: tc.wantModel}}, payload.Overrides) - }) - } } diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx index 4f849431fac..d7316cde71e 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx @@ -27,6 +27,13 @@ const contexts: readonly ChatModelOverrideContext[] = [ ]; const DefaultsPage: FC = () => { + const { organization } = useOrganizationModels(); + // Remount on organization change so mutation state (pending saves, + // errors) from one organization never renders on another's form. + return ; +}; + +const DefaultsPageContent: FC = () => { const queryClient = useQueryClient(); const { experiments } = useDashboard(); const { organization, permissions } = useOrganizationModels(); @@ -99,7 +106,6 @@ const DefaultsPage: FC = () => { <> Codestin Search App { + saveGeneralOverride.mockClear(); + }, play: async ({ canvasElement }) => { - await expect(within(canvasElement).getByRole("status")).toHaveTextContent( + const canvas = within(canvasElement); + await expect(canvas.getByRole("status")).toHaveTextContent( "no enabled chat models", ); + // A saved override that references a now-disabled model must remain + // clearable even though no replacement model can be selected. + const generalSection = canvas.getByRole("form", { + name: "General subagent", + }); + const clear = within(generalSection).getByRole("button", { + name: "Clear", + }); + await expect(clear).toBeEnabled(); + await userEvent.click(clear); + const save = within(generalSection).getByRole("button", { name: "Save" }); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(save); + await waitFor(() => { + expect(saveGeneralOverride).toHaveBeenCalledWith( + { model_config_id: "" }, + expect.anything(), + ); + }); }, }; diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx index 554d26f116e..814fd810431 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx @@ -123,7 +123,7 @@ const DefaultsPageView: FC = ({ isSaveError={errorContexts.has(setting.context)} saveErrorMessage={`Failed to save ${setting.title.toLowerCase()} override.`} unsetPlaceholder="Use default" - disabled={!canEdit || enabledModels.length === 0} + disabled={!canEdit} /> ); })} diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx index 8556b919120..9f87f1a6698 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPage.tsx @@ -14,7 +14,6 @@ import { AgentSettingsUserAgentsPageView } from "./AgentSettingsUserAgentsPageVi import { resolveModelSelector } from "./utils/modelOptions"; const AgentSettingsUserAgentsPage: FC = () => { - const queryClient = useQueryClient(); const { organizations } = useDashboard(); const defaultOrganizationId = getDefaultOrganizationId(organizations); const [selectedOrganizationId, setSelectedOrganizationId] = useState( @@ -29,6 +28,37 @@ const AgentSettingsUserAgentsPage: FC = () => { ) ?? organizations[0]; const organizationId = selectedOrganization?.id ?? ""; + // Remount on organization change so mutation state (pending saves, + // errors) from one organization never renders on another's form. + return ( + + setSelectedOrganizationId(organization.id) + } + /> + ); +}; + +interface AgentSettingsUserAgentsPageContentProps { + organizations: readonly TypesGen.Organization[]; + selectedOrganization: TypesGen.Organization | undefined; + organizationId: string; + onSelectOrganization: (organization: TypesGen.Organization) => void; +} + +const AgentSettingsUserAgentsPageContent: FC< + AgentSettingsUserAgentsPageContentProps +> = ({ + organizations, + selectedOrganization, + organizationId, + onSelectOrganization, +}) => { + const queryClient = useQueryClient(); const overridesQuery = useQuery( userChatPersonalModelOverrides(organizationId), ); @@ -80,9 +110,7 @@ const AgentSettingsUserAgentsPage: FC = () => { modelOptions={modelOptions} organizations={organizations} selectedOrganization={selectedOrganization} - onSelectOrganization={(organization) => - setSelectedOrganizationId(organization.id) - } + onSelectOrganization={onSelectOrganization} models={organizationModelConfigs} modelsError={modelsQuery.error} isLoadingModels={isModelCatalogLoading} diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 5add02ac67b..5ebb75fe3f3 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -164,6 +164,11 @@ const AgentsPageLayout: FC = () => { const { organizations } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); const defaultOrganizationId = getDefaultOrganizationId(organizations); + // The personal-overrides feature flag is deployment-wide but read through + // an organization-scoped endpoint, so fall back to any accessible + // organization for users outside the default organization. + const personalOverridesOrganizationId = + defaultOrganizationId || (organizations[0]?.id ?? ""); const isAgentsAdmin = permissions.editDeploymentConfig; const [sidebarFilters, setSidebarFilters] = getAgentSidebarFilters( @@ -232,7 +237,7 @@ const AgentsPageLayout: FC = () => { organizations.map((organization) => organization.id), ); const personalModelOverridesQuery = useQuery( - userChatPersonalModelOverrides(defaultOrganizationId), + userChatPersonalModelOverrides(personalOverridesOrganizationId), ); const [chatErrorReasons, setChatErrorReasons] = useState< Record From a7c85d4ee3928f905471ad91989285938be4484b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:55:25 +0000 Subject: [PATCH 07/16] fix: harden advisor config migration and override loading Parse the stored advisor config JSON inside plpgsql exception handlers in both migration directions so a malformed persisted value cannot abort an upgrade or rollback, and keep chat creation blocked when the personal override fetch fails so a catalog fallback is never submitted as an explicit model that bypasses the saved root override. --- ...583_chat_model_override_org_scope.down.sql | 61 ++++++++----- ...00583_chat_model_override_org_scope.up.sql | 88 +++++++++++-------- coderd/database/migrations/migrate_test.go | 29 ++++++ .../components/AgentCreateForm.stories.tsx | 42 +++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 11 ++- 5 files changed, 171 insertions(+), 60 deletions(-) diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql index c7c89715c57..8de84c97725 100644 --- a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql @@ -19,29 +19,48 @@ WHERE o.is_default ) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; -INSERT INTO site_configs (key, value) -SELECT - 'agents_advisor_config', - jsonb_strip_nulls(jsonb_build_object( +-- The stored advisor config is unrestricted text, so guard the jsonb parse of +-- the existing row with an exception handler; a malformed value is replaced +-- rather than aborting the rollback. +DO $$ +DECLARE + fields jsonb; + existing jsonb; +BEGIN + SELECT jsonb_strip_nulls(jsonb_build_object( 'model_config_id', omo.model_config_id, 'reasoning_effort', omo.reasoning_effort - ))::text -FROM chat_organization_model_overrides omo -JOIN organizations o ON o.id = omo.organization_id -WHERE o.is_default - AND omo.context = 'advisor' - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = omo.model_config_id - AND cmc.organization_id = omo.organization_id - AND NOT cmc.deleted - ) -ON CONFLICT (key) DO UPDATE -SET value = ( - COALESCE(NULLIF(site_configs.value, ''), '{}')::jsonb - || EXCLUDED.value::jsonb -)::text; + )) + INTO fields + FROM chat_organization_model_overrides omo + JOIN organizations o ON o.id = omo.organization_id + WHERE o.is_default + AND omo.context = 'advisor' + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = omo.model_config_id + AND cmc.organization_id = omo.organization_id + AND NOT cmc.deleted + ); + IF fields IS NULL THEN + RETURN; + END IF; + BEGIN + SELECT NULLIF(value, '')::jsonb INTO existing + FROM site_configs + WHERE key = 'agents_advisor_config'; + EXCEPTION WHEN others THEN + existing := NULL; + END; + IF existing IS NULL OR jsonb_typeof(existing) != 'object' THEN + existing := '{}'::jsonb; + END IF; + INSERT INTO site_configs (key, value) + VALUES ('agents_advisor_config', (existing || fields)::text) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; +END; +$$; INSERT INTO user_configs (user_id, key, value) SELECT diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql index 2b0bea50c56..604421fd35e 100644 --- a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql @@ -85,44 +85,58 @@ WHERE key IN ( 'agents_chat_compaction_model_override' ); -WITH advisor AS ( - SELECT - sc.value::jsonb AS blob, - ( - SELECT (sc.value::jsonb ->> 'model_config_id')::uuid - WHERE sc.value::jsonb ->> 'model_config_id' - ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' - ) AS config_id - FROM site_configs sc - WHERE sc.key = 'agents_advisor_config' - AND trim(sc.value) != '' - AND sc.value::jsonb ? 'model_config_id' -) -INSERT INTO chat_organization_model_overrides - (organization_id, context, model_config_id, reasoning_effort) -SELECT - o.id, - 'advisor', - a.config_id, - NULLIF(trim(a.blob ->> 'reasoning_effort'), '') -FROM advisor a -CROSS JOIN organizations o -WHERE o.is_default - AND a.config_id IS NOT NULL - AND a.config_id != '00000000-0000-0000-0000-000000000000'::uuid - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = a.config_id - AND cmc.organization_id = o.id - AND NOT cmc.deleted - ) -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO NOTHING; +-- The advisor config is stored as unrestricted text, so a malformed value is +-- representable and a bare ::jsonb cast would abort the migration. Parse it +-- inside an exception handler (pg_input_is_valid needs PostgreSQL 16) and +-- leave a malformed row untouched. +DO $$ +DECLARE + raw text; + blob jsonb; + config_id uuid; +BEGIN + SELECT value INTO raw FROM site_configs WHERE key = 'agents_advisor_config'; + IF raw IS NULL THEN + RETURN; + END IF; + BEGIN + blob := raw::jsonb; + EXCEPTION WHEN others THEN + RETURN; + END; + IF jsonb_typeof(blob) != 'object' THEN + RETURN; + END IF; + + IF blob ->> 'model_config_id' + ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN + config_id := (blob ->> 'model_config_id')::uuid; + INSERT INTO chat_organization_model_overrides + (organization_id, context, model_config_id, reasoning_effort) + SELECT + o.id, + 'advisor', + config_id, + NULLIF(trim(blob ->> 'reasoning_effort'), '') + FROM organizations o + WHERE o.is_default + AND config_id != '00000000-0000-0000-0000-000000000000'::uuid + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = config_id + AND cmc.organization_id = o.id + AND NOT cmc.deleted + ) + ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key + DO NOTHING; + END IF; -UPDATE site_configs -SET value = (value::jsonb - 'model_config_id' - 'reasoning_effort')::text -WHERE key = 'agents_advisor_config'; + UPDATE site_configs + SET value = (blob - 'model_config_id' - 'reasoning_effort')::text + WHERE key = 'agents_advisor_config'; +END; +$$; WITH parsed AS ( SELECT diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index d26d65f6d21..228a0a86881 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3404,4 +3404,33 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { require.JSONEq(t, fmt.Sprintf( `{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, modelID), value) + + // A malformed advisor config is a representable persisted state. It must + // not abort the migration and must be left untouched. + _, err = db.ExecContext(ctx, + "UPDATE site_configs SET value = 'not-json' WHERE key = 'agents_advisor_config'") + require.NoError(t, err) + _, err = db.ExecContext(ctx, string(upSQL)) + require.NoError(t, err) + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) + require.Equal(t, "not-json", value) + var advisorCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM chat_organization_model_overrides + WHERE context = 'advisor'`).Scan(&advisorCount)) + require.Zero(t, advisorCount) + + // The rollback must tolerate a malformed stored value too, replacing it + // with the restored advisor model fields. + _, err = db.ExecContext(ctx, ` + INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) + VALUES ($1, 'advisor', $2, 'low')`, orgID, modelID) + require.NoError(t, err) + _, err = db.ExecContext(ctx, string(downSQL)) + require.NoError(t, err) + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) + require.JSONEq(t, fmt.Sprintf( + `{"model_config_id":%q,"reasoning_effort":"low"}`, modelID), value) } diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 276963c7749..a5b8c48ce34 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -282,6 +282,13 @@ const meta: Meta = { beforeEach: () => { localStorage.clear(); spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([]); + // Stories that replace parameters.queries lose the seeded overrides + // entry above; resolve the API for any organization so the send gate + // (blocked until overrides resolve) does not stall unrelated stories. + spyOn( + API.experimental, + "getUserChatPersonalModelOverrides", + ).mockResolvedValue(buildPersonalModelOverridesResponse()); }, }; @@ -1010,6 +1017,41 @@ export const LoadingPersonalModelOverrides: Story = { }, }; +export const FailedPersonalModelOverridesBlocksSend: Story = { + args: { + ...defaultArgs, + }, + beforeEach: () => { + spyOn( + API.experimental, + "getUserChatPersonalModelOverrides", + ).mockRejectedValue(new Error("failed to load personal overrides")); + }, + parameters: { + queries: [ + { + key: organizationChatModelsKey(MockDefaultOrganization.id), + data: defaultModelCatalog, + }, + { + key: userChatProviderConfigsKey, + data: defaultUserProviderConfigs, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A failed override fetch must keep sending blocked: submitting would + // pass a catalog fallback as an explicit model, silently bypassing the + // user's saved root override. + await canvas.findAllByText(/failed to load personal overrides/i); + await expect(canvas.getByRole("textbox")).toHaveAttribute( + "aria-disabled", + "true", + ); + }, +}; + const emptyModelCatalog: TypesGen.OrganizationChatModelsResponse = { models: [], providers: [], diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index ff7f2fc2836..6246051183b 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -281,7 +281,11 @@ export const AgentCreateForm: FC = ({ : undefined; const effectiveRootPersonalModelOverride = organizationRootPersonalModelOverride; - const isPersonalModelOverridesLoading = personalModelOverridesQuery.isLoading; + // A failed overrides fetch must keep the form blocked: submitting with an + // undefined override would send a catalog fallback as an explicit + // model_config_id, silently bypassing the user's saved root override. + const isPersonalModelOverridesUnresolved = + organizationId !== "" && personalModelOverridesQuery.data === undefined; const availableModelConfigs = modelsQuery.data?.models ?? []; const chatProviderConfigsQuery = useQuery({ ...chatProviderConfigs(), @@ -588,6 +592,9 @@ export const AgentCreateForm: FC = ({ {modelsQuery.error != null && ( )} + {personalModelOverridesQuery.error != null && ( + + )} {mcpServersQuery.error != null && ( )} @@ -639,7 +646,7 @@ export const AgentCreateForm: FC = ({ // Sending before adoption would omit persisted files not yet restored. !organizationAdopted || workspaceValidationPending || - isPersonalModelOverridesLoading || + isPersonalModelOverridesUnresolved || isMCPSelectionUnresolved || !hasModelOptions || Boolean(aiGatewayDisabled) From bf968c251323ae290394256483612eb1bb0cb456 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:07:58 +0000 Subject: [PATCH 08/16] docs(coderd/x/chatd): mark override sections affected by org scoping --- coderd/x/chatd/ARCHITECTURE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index a30a41cff24..9a773c9c56e 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -864,6 +864,7 @@ The generation goroutine supports: Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. Users select a per-turn effort when sending or editing a message; the value is stored on `chat_messages.reasoning_effort` and on `chat_queued_messages.reasoning_effort` for queued messages. Queued messages carry the value through promotion, and `chats.last_reasoning_effort` tracks the most recent message that set one, mirroring `last_model_config_id`. + Subagent spawning is a second source of both values. `spawn_agent` accepts optional `model_config_id` and `reasoning_effort` args (discoverable via the `list_subagent_models` tool): an explicit model selection becomes the child chat's `last_model_config_id` and wins over personal and deployment subagent overrides and over parent inheritance, and an explicit effort is stored on the child's initial message and wins over effort carried by those overrides. Both are validated at spawn time (enabled config, enabled provider, usable credentials, effort on the global scale) and rejected with tool errors before the child chat is created; `computer_use` spawns reject both args because their model routing is specialized. Generation-time resolution and clamping below apply to the child unchanged. During generation preparation, the effective effort is resolved as the chat's `last_reasoning_effort` if set, else the config's `default`; clamped to the config's `max` on the global scale `none < minimal < low < medium < high < xhigh < max`; and passed through to the provider. The provider verifies whether the configured value is valid for that model at runtime. If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options by `chatprovider.ProviderOptionsForCall`, which converts the model config and applies the effort in one step. For Anthropic, the fantasy provider converts effort into enabled budget thinking on models older than Claude 4.6, which reject adaptive thinking. @@ -921,6 +922,7 @@ The model editor scopes the field to openai-typed providers with a `providers` s Compaction is an auxiliary LLM call: when the conversation approaches the context limit, the generation goroutine asks a model to summarize the history, commits the summary as a compressed boundary, and continues the turn on the chat model. + By default the summary is generated with the chat model. Admins can override the compaction model deployment-wide via the `compaction` context of the chat model override API (`/api/experimental/chats/config/model-override/{context}`, stored in the `agents_chat_compaction_model_override` site config). The override affects only the summary call; thresholds, compressed-message storage, and the post-compaction assistant generation keep using the chat model. Details that follow from the override: From b6806253dc3985bd9f81e036dbf9f54090cd4f12 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:27:10 +0000 Subject: [PATCH 09/16] fix: address organization override review round-4 findings Seed legacy mode-only personal overrides into every organization the user belongs to (they were deployment-wide) and model-backed overrides into the organization that owns the model; prefer the default organization when rolling back. Look up the referenced model under a system context in the organization override PUT so custom roles with update-but-not-read access can use the endpoint they are authorized to call. Cover the new Defaults & overrides tab with a Storybook interaction. --- ...583_chat_model_override_org_scope.down.sql | 22 +++++----- ...00583_chat_model_override_org_scope.up.sql | 35 ++++++++++------ coderd/database/migrations/migrate_test.go | 31 ++++++++++++++ coderd/exp_chats.go | 7 +++- .../OrganizationModelsLayout.stories.tsx | 40 +++++++++++++++++++ 5 files changed, 110 insertions(+), 25 deletions(-) diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql index 8de84c97725..4c6ec3e1089 100644 --- a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql @@ -63,7 +63,7 @@ END; $$; INSERT INTO user_configs (user_id, key, value) -SELECT +SELECT DISTINCT ON (umo.user_id, umo.context) umo.user_id, 'chat_personal_model_override:' || umo.context, CASE @@ -76,17 +76,17 @@ SELECT END FROM chat_user_model_overrides umo JOIN organizations o ON o.id = umo.organization_id -WHERE o.is_default - AND ( - umo.mode IN ('chat_default', 'deployment_default') - OR EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = umo.model_config_id - AND cmc.organization_id = umo.organization_id - AND NOT cmc.deleted - ) +WHERE umo.mode IN ('chat_default', 'deployment_default') + OR EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = umo.model_config_id + AND cmc.organization_id = umo.organization_id + AND NOT cmc.deleted ) +-- The legacy setting was deployment-wide; prefer the default organization's +-- row when a user has rows in several organizations. +ORDER BY umo.user_id, umo.context, o.is_default DESC ON CONFLICT ON CONSTRAINT user_configs_pkey DO UPDATE SET value = EXCLUDED.value; diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql index 604421fd35e..5010de71488 100644 --- a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql @@ -173,20 +173,29 @@ SELECT ELSE NULL END FROM parsed p -CROSS JOIN organizations o -WHERE o.is_default - AND ( +JOIN organizations o ON NOT o.deleted +WHERE ( + -- Legacy mode-only settings applied deployment-wide, so seed them into + -- every organization the user belongs to. trim(p.value) IN ('chat_default', 'deployment_default') - OR ( - split_part(trim(p.value), ':', 1) = 'model' - AND p.config_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = p.config_id - AND cmc.organization_id = o.id - AND NOT cmc.deleted - ) + AND EXISTS ( + SELECT 1 + FROM organization_members om + WHERE om.user_id = p.user_id + AND om.organization_id = o.id + ) + ) + OR ( + -- A model reference is only representable in the organization that owns + -- the model (composite foreign key). + split_part(trim(p.value), ':', 1) = 'model' + AND p.config_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM chat_model_configs cmc + WHERE cmc.id = p.config_id + AND cmc.organization_id = o.id + AND NOT cmc.deleted ) ) ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 228a0a86881..3b04122ddb4 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3298,6 +3298,23 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { VALUES ($1, $2, $3, $4, $5, $5, 'active', '{}', 'password')`, userID, "model-override-"+userID.String(), userID.String()+"@example.com", []byte{}, now) require.NoError(t, err) + + // A second organization the user belongs to: legacy mode-only personal + // overrides were deployment-wide and must be seeded into every member + // organization, not only the default one. + memberOrgID := uuid.New() + _, err = db.ExecContext(ctx, ` + INSERT INTO organizations ( + id, name, display_name, description, created_at, updated_at, + is_default, default_org_member_roles + ) VALUES ($1, $2, $2, '', $3, $3, false, '{}')`, + memberOrgID, "model-override-"+memberOrgID.String(), now) + require.NoError(t, err) + _, err = db.ExecContext(ctx, ` + INSERT INTO organization_members (user_id, organization_id, created_at, updated_at) + VALUES ($1, $2, $3, $3), ($1, $4, $3, $3)`, + userID, orgID, now, memberOrgID) + require.NoError(t, err) _, err = db.ExecContext(ctx, ` INSERT INTO ai_providers (id, type, name, enabled, base_url, created_at, updated_at) VALUES ($1, 'openai', $2, true, 'https://example.com', $3, $3)`, @@ -3360,6 +3377,20 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { require.Equal(t, modelID, userModelID) require.Equal(t, sql.NullString{String: "max", Valid: true}, userEffort) + // The mode-only general setting is seeded into both member organizations; + // the model-backed root setting only where the model lives. + var generalOrgCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM chat_user_model_overrides + WHERE user_id = $1 AND context = 'general' AND mode = 'chat_default' + AND organization_id IN ($2, $3)`, userID, orgID, memberOrgID).Scan(&generalOrgCount)) + require.Equal(t, 2, generalOrgCount) + var rootOrgCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM chat_user_model_overrides + WHERE user_id = $1 AND context = 'root'`, userID).Scan(&rootOrgCount)) + require.Equal(t, 1, rootOrgCount) + var advisorConfig string require.NoError(t, db.QueryRowContext(ctx, "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&advisorConfig)) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index f1a45aa5556..9bc56238714 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4802,8 +4802,13 @@ func (api *API) putOrganizationChatModelOverride(rw http.ResponseWriter, r *http }) 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( - ctx, + dbauthz.AsSystemRestricted(ctx), api.Database, organization.ID, &modelConfigID, diff --git a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx index 9a314fd88e7..c67e0e764de 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx @@ -111,6 +111,46 @@ export const SwitchOrganizationPreservesAuxiliaryParameters: Story = { }, }; +export const DefaultsTabNavigatesToOrganizationDefaults: Story = { + beforeEach: () => { + // Mock at the API layer so a background refetch cannot replace the + // seeded permissions. + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries(Object.keys(checks).map((id) => [id, true])), + ); + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/ai/settings/models", + searchParams: { org: MockOrganization2.name }, + }, + routing: [{ path: "*", useStoryElement: true }], + }), + }, + render: () => ( + <> + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const defaultsTab = await canvas.findByRole("link", { + name: "Defaults & overrides", + }); + await userEvent.click(defaultsTab); + await waitFor(() => { + expect(screen.getByTestId("location-probe")).toHaveTextContent( + `/ai/settings/models/defaults?org=${MockOrganization2.name}`, + ); + expect( + canvas.getByRole("link", { name: "Defaults & overrides" }), + ).toHaveAttribute("aria-current", "page"); + }); + }, +}; + export const InvalidRequestedOrganizationFallsBackToDefault: Story = { parameters: { reactRouter: reactRouterParameters({ From dd4ebbf2bcdec0b696d5e27d8897dd727084ffba Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:48:37 +0000 Subject: [PATCH 10/16] fix: address organization override review round-5 findings --- .../DefaultsPage/DefaultsPageView.stories.tsx | 26 ++++++++++++ .../DefaultsPage/DefaultsPageView.tsx | 6 +++ ...gentSettingsUserAgentsPageView.stories.tsx | 41 ++++++++++++------- .../AgentSettingsUserAgentsPageView.tsx | 14 +++---- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx index e89e3b66d2e..78c976a78de 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.stories.tsx @@ -122,6 +122,32 @@ export const ReadOnly: Story = { await expect(button).toBeDisabled(); }, }; +export const UnavailableSavedModels: Story = { + args: { + overrides: [ + { context: "general", model_config_id: "model-gone" }, + { context: "title_generation", model_config_id: "model-gone" }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Title generation fails hard on a broken override, so its warning must + // not claim the model is ignored in favor of default selection. + const titleSection = canvas.getByRole("form", { + name: "Title generation", + }); + await expect( + within(titleSection).getByText(/Title generation will be skipped/), + ).toBeVisible(); + const generalSection = canvas.getByRole("form", { + name: "General subagent", + }); + await expect( + within(generalSection).getByText(/will be ignored/), + ).toBeVisible(); + }, +}; + export const NoModels: Story = { args: { enabledModels: [] }, beforeEach: () => { diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx index 814fd810431..10b7f9d0aac 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx @@ -35,6 +35,7 @@ const settings: readonly { context: TypesGen.ChatModelOverrideContext; title: string; description: string; + unavailableModelWarning?: string; }[] = [ { context: "general", @@ -51,6 +52,10 @@ const settings: readonly { context: "title_generation", title: "Title generation", description: "Used to generate chat titles.", + // Title generation fails hard on a broken override instead of falling + // back to default model selection, so the generic warning is wrong here. + unavailableModelWarning: + "The selected model is currently unavailable. Title generation will be skipped until you choose another model or clear this setting.", }, { context: "compaction", @@ -122,6 +127,7 @@ const DefaultsPageView: FC = ({ isSaving={savingContexts.has(setting.context)} isSaveError={errorContexts.has(setting.context)} saveErrorMessage={`Failed to save ${setting.title.toLowerCase()} override.`} + unavailableModelWarning={setting.unavailableModelWarning} unsetPlaceholder="Use default" disabled={!canEdit} /> diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx index f18e4c91f25..4ebcc29a581 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx @@ -674,29 +674,40 @@ export const NoAvailableOrganizationModels: Story = { hasNoOrganizationModels: true, modelOptions: [], models: [disabledModelConfig], + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: "model-stale", + is_set: true, + }), + }), }), - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); expect( canvas.getByText(/selected organization has no available chat models/i), ).toBeInTheDocument(); + // A stale saved model override must remain replaceable with the + // model-free default modes even though no model can be selected. const rootSection = await getSection(canvasElement, "Root agent model"); - const generalSection = await getSection( - canvasElement, - "General subagent model", + await userEvent.click( + within(rootSection).getByRole("combobox", { + name: /^Root agent model behavior/, + }), ); - const exploreSection = await getSection( - canvasElement, - "Explore subagent model", + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("option", { name: /Chat default/i }), ); - for (const section of [rootSection, generalSection, exploreSection]) { - expect( - within(section).getByRole("combobox", { name: /behavior/i }), - ).toBeDisabled(); - expect( - within(section).getByRole("button", { name: "Save" }), - ).toBeDisabled(); - } + const save = within(rootSection).getByRole("button", { name: "Save" }); + await waitFor(() => expect(save).toBeEnabled()); + await userEvent.click(save); + await waitFor(() => { + expect(args.onSaveRootModelOverride).toHaveBeenCalledWith( + { mode: "chat_default", model_config_id: "" }, + expect.anything(), + ); + }); }, }; diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx index 7d77c73123b..ef7841442a3 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.tsx @@ -69,11 +69,11 @@ export const AgentSettingsUserAgentsPageView: FC< }) => { const personalOverridesEnabled = overridesData?.enabled ?? true; const isLoading = isLoadingOverrides || isLoadingModels; + // Rows stay enabled when the organization has no models so the model-free + // default modes can still replace a stale saved model override; mode + // "model" cannot be saved without a valid model anyway. const isDisabled = - isLoading || - !personalOverridesEnabled || - isOrganizationUnresolved || - hasNoOrganizationModels; + isLoading || !personalOverridesEnabled || isOrganizationUnresolved; return (

@@ -128,9 +128,9 @@ export const AgentSettingsUserAgentsPageView: FC< {hasNoOrganizationModels && ( - The selected organization has no available chat models. Ask an - organization administrator to add and enable a model before you set - personal overrides. + The selected organization has no available chat models. Default + options can still be saved. Ask an organization administrator to add + and enable a model before you choose a specific model. )} From c031a2001eac622833e6cb7b2ad0c2a56ae4618c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:03:58 +0000 Subject: [PATCH 11/16] fix: address organization override review round-6 findings --- coderd/exp_chats.go | 17 ++++- coderd/exp_chats_test.go | 21 ++++++ .../DefaultsPage/DefaultsPage.stories.tsx | 65 +++++++++++++++++++ .../ModelsPage/DefaultsPage/DefaultsPage.tsx | 11 ++-- .../DefaultsPage/DefaultsPageView.tsx | 2 +- 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.stories.tsx diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 9bc56238714..362eaf2ff07 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -5076,8 +5076,23 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http }) 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( - ctx, member.UserID, organization.ID, parsedModelConfigID, + validateCtx, member.UserID, organization.ID, parsedModelConfigID, ) if validationResponse != nil { httpapi.Write(ctx, rw, status, *validationResponse) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index ffebdd28035..688acc1650d 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -15610,6 +15610,27 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { } }) + t.Run("AdminWriteValidatesWithMemberACL", func(t *testing.T) { + // The admin can read the ACL-restricted model, but the target member + // cannot, and chatd resolves the override under the member's actor. + privateModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: defaultModel.AIProviderID, Valid: true}, + OrganizationID: firstUser.OrganizationID, + Model: "private-personal-" + uuid.NewString(), + Enabled: true, + GroupACL: database.ChatACL{}, + }) + err := adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, member.ID.String(), codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: privateModel.ID.String(), + }) + requireSDKError(t, err, http.StatusBadRequest) + // A model the member can use is still accepted from an admin caller. + err = adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, member.ID.String(), codersdk.ChatPersonalModelOverrideContextGeneral, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, ModelConfigID: model.ID.String(), + }) + require.NoError(t, err) + }) + t.Run("OrganizationIsolation", func(t *testing.T) { dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: otherOrg.ID, UserID: member.ID}) err := db.UpsertChatUserModelOverride(dbauthz.AsSystemRestricted(ctx), database.UpsertChatUserModelOverrideParams{ diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.stories.tsx new file mode 100644 index 00000000000..7807b2911e4 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import { + MockDefaultOrganization, + MockOrganizationPermissions, +} from "#/testHelpers/entities"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { OrganizationModelsContext } from "../organizationModels"; +import DefaultsPage from "./DefaultsPage"; + +const meta: Meta = { + title: "pages/AISettingsPage/ModelsPage/DefaultsPage", + component: DefaultsPage, + decorators: [ + (Story) => ( + + + + ), + withDashboardProvider, + ], +}; +export default meta; +type Story = StoryObj; + +export const ClearableWhenModelCatalogFails: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChatModels").mockRejectedValue( + new Error("failed to load models"), + ); + spyOn( + API.experimental, + "getOrganizationChatModelOverrides", + ).mockResolvedValue({ + overrides: [{ context: "general", model_config_id: "model-gone" }], + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A failed model catalog fetch must not hide the override rows: an + // admin must still be able to clear a stale override without it. + await canvas.findAllByText(/failed to load models/i); + const generalSection = await canvas.findByRole("form", { + name: "General subagent", + }); + const clear = within(generalSection).getByRole("button", { + name: "Clear", + }); + await expect(clear).toBeEnabled(); + await userEvent.click(clear); + await waitFor(() => + expect( + within(generalSection).getByRole("button", { name: "Save" }), + ).toBeEnabled(), + ); + }, +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx index d7316cde71e..a93f14d5cb7 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPage.tsx @@ -90,10 +90,11 @@ const DefaultsPageContent: FC = () => { (modelsQuery.data?.models ?? []).filter((model) => model.enabled), providerInfoByID, ); - const { loadError, refetchError } = splitModelQueryErrors( - modelsQuery, - overridesQuery, - ); + // Only the overrides request gates the page: when the model catalog + // fails, the rows must stay rendered with the error inline so a stale + // override can still be cleared without the catalog. + const { loadError, refetchError } = splitModelQueryErrors(overridesQuery); + const inlineError = refetchError ?? modelsQuery.error; const saveByContext = new Map(); for (const [index, context] of contexts.entries()) { const mutation = mutations[index]; @@ -111,7 +112,7 @@ const DefaultsPageContent: FC = () => { providerInfoByID={providerInfoByID} isLoading={modelsQuery.isLoading || overridesQuery.isLoading} loadError={loadError} - refetchError={refetchError} + refetchError={inlineError} canEdit={permissions?.editChatModelConfigs ?? false} showAdvisor={experiments.includes("chat-advisor")} saveByContext={saveByContext} diff --git a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx index 10b7f9d0aac..91075e4cea5 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/DefaultsPage/DefaultsPageView.tsx @@ -99,7 +99,7 @@ const DefaultsPageView: FC = ({ {refetchError != null && } - {enabledModels.length === 0 && !isLoading && ( + {enabledModels.length === 0 && !isLoading && refetchError == null && (

This organization has no enabled chat models.

From 43e3328c8398b3c59165add4fa3247f9cbf27184 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:39:42 +0000 Subject: [PATCH 12/16] refactor(coderd): drop legacy chat model overrides instead of migrating them The data-carry migration parsed three serialized legacy formats inside SQL (colon-joined site configs, advisor config JSON, personal override strings) with exception-guarded jsonb casts and per-organization seeding rules. Deleting the legacy keys outright removes all of that: admins and users re-select models in the new organization-scoped settings. Stale advisor model fields in agents_advisor_config are harmless because reads ignore unknown JSON keys and the next settings write rewrites the blob. The ParseChatPersonalModelOverride helper existed only to parse the legacy serialized values and is now dead code. --- ...583_chat_model_override_org_scope.down.sql | 92 ---------- ...00583_chat_model_override_org_scope.up.sql | 162 +----------------- coderd/database/migrations/migrate_test.go | 142 ++++----------- coderd/x/chatd/personal_model_override.go | 81 --------- .../x/chatd/personal_model_override_test.go | 123 ------------- 5 files changed, 35 insertions(+), 565 deletions(-) delete mode 100644 coderd/x/chatd/personal_model_override.go delete mode 100644 coderd/x/chatd/personal_model_override_test.go diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql index 4c6ec3e1089..fc71f4abf4b 100644 --- a/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.down.sql @@ -1,95 +1,3 @@ -INSERT INTO site_configs (key, value) -SELECT - 'agents_chat_' || omo.context || '_model_override', - omo.model_config_id::text || CASE - WHEN omo.reasoning_effort IS NOT NULL AND omo.reasoning_effort != '' - THEN ':' || omo.reasoning_effort - ELSE '' - END -FROM chat_organization_model_overrides omo -JOIN organizations o ON o.id = omo.organization_id -WHERE o.is_default - AND omo.context IN ('general', 'explore', 'title_generation', 'compaction') - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = omo.model_config_id - AND cmc.organization_id = omo.organization_id - AND NOT cmc.deleted - ) -ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; - --- The stored advisor config is unrestricted text, so guard the jsonb parse of --- the existing row with an exception handler; a malformed value is replaced --- rather than aborting the rollback. -DO $$ -DECLARE - fields jsonb; - existing jsonb; -BEGIN - SELECT jsonb_strip_nulls(jsonb_build_object( - 'model_config_id', omo.model_config_id, - 'reasoning_effort', omo.reasoning_effort - )) - INTO fields - FROM chat_organization_model_overrides omo - JOIN organizations o ON o.id = omo.organization_id - WHERE o.is_default - AND omo.context = 'advisor' - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = omo.model_config_id - AND cmc.organization_id = omo.organization_id - AND NOT cmc.deleted - ); - IF fields IS NULL THEN - RETURN; - END IF; - BEGIN - SELECT NULLIF(value, '')::jsonb INTO existing - FROM site_configs - WHERE key = 'agents_advisor_config'; - EXCEPTION WHEN others THEN - existing := NULL; - END; - IF existing IS NULL OR jsonb_typeof(existing) != 'object' THEN - existing := '{}'::jsonb; - END IF; - INSERT INTO site_configs (key, value) - VALUES ('agents_advisor_config', (existing || fields)::text) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; -END; -$$; - -INSERT INTO user_configs (user_id, key, value) -SELECT DISTINCT ON (umo.user_id, umo.context) - umo.user_id, - 'chat_personal_model_override:' || umo.context, - CASE - WHEN umo.mode IN ('chat_default', 'deployment_default') THEN umo.mode - ELSE 'model:' || umo.model_config_id::text || CASE - WHEN umo.reasoning_effort IS NOT NULL AND umo.reasoning_effort != '' - THEN ':' || umo.reasoning_effort - ELSE '' - END - END -FROM chat_user_model_overrides umo -JOIN organizations o ON o.id = umo.organization_id -WHERE umo.mode IN ('chat_default', 'deployment_default') - OR EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = umo.model_config_id - AND cmc.organization_id = umo.organization_id - AND NOT cmc.deleted - ) --- The legacy setting was deployment-wide; prefer the default organization's --- row when a user has rows in several organizations. -ORDER BY umo.user_id, umo.context, o.is_default DESC -ON CONFLICT ON CONSTRAINT user_configs_pkey -DO UPDATE SET value = EXCLUDED.value; - DROP TABLE chat_user_model_overrides; DROP TABLE chat_organization_model_overrides; diff --git a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql index 5010de71488..f6be7ed4b3e 100644 --- a/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql +++ b/coderd/database/migrations/000583_chat_model_override_org_scope.up.sql @@ -38,45 +38,10 @@ CREATE TABLE chat_user_model_overrides ( REFERENCES chat_model_configs (organization_id, id) ); -WITH parsed AS ( - SELECT - substring(sc.key FROM '^agents_chat_(.*)_model_override$') AS context, - sc.value, - ( - SELECT split_part(trim(sc.value), ':', 1)::uuid - WHERE split_part(trim(sc.value), ':', 1) - ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' - ) AS config_id - FROM site_configs sc - WHERE sc.key IN ( - 'agents_chat_general_model_override', - 'agents_chat_explore_model_override', - 'agents_chat_title_generation_model_override', - 'agents_chat_compaction_model_override' - ) - AND trim(sc.value) != '' -) -INSERT INTO chat_organization_model_overrides - (organization_id, context, model_config_id, reasoning_effort) -SELECT - o.id, - p.context, - p.config_id, - NULLIF(split_part(trim(p.value), ':', 2), '') -FROM parsed p -CROSS JOIN organizations o -WHERE o.is_default - AND p.config_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = p.config_id - AND cmc.organization_id = o.id - AND NOT cmc.deleted - ) -ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key -DO NOTHING; - +-- Legacy serialized overrides are dropped rather than migrated; admins and +-- users re-select models in the organization-scoped settings. Stale advisor +-- model fields inside agents_advisor_config are left as-is: reads ignore +-- unknown JSON keys and the next settings write rewrites the blob. DELETE FROM site_configs WHERE key IN ( 'agents_chat_general_model_override', @@ -85,122 +50,5 @@ WHERE key IN ( 'agents_chat_compaction_model_override' ); --- The advisor config is stored as unrestricted text, so a malformed value is --- representable and a bare ::jsonb cast would abort the migration. Parse it --- inside an exception handler (pg_input_is_valid needs PostgreSQL 16) and --- leave a malformed row untouched. -DO $$ -DECLARE - raw text; - blob jsonb; - config_id uuid; -BEGIN - SELECT value INTO raw FROM site_configs WHERE key = 'agents_advisor_config'; - IF raw IS NULL THEN - RETURN; - END IF; - BEGIN - blob := raw::jsonb; - EXCEPTION WHEN others THEN - RETURN; - END; - IF jsonb_typeof(blob) != 'object' THEN - RETURN; - END IF; - - IF blob ->> 'model_config_id' - ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' THEN - config_id := (blob ->> 'model_config_id')::uuid; - INSERT INTO chat_organization_model_overrides - (organization_id, context, model_config_id, reasoning_effort) - SELECT - o.id, - 'advisor', - config_id, - NULLIF(trim(blob ->> 'reasoning_effort'), '') - FROM organizations o - WHERE o.is_default - AND config_id != '00000000-0000-0000-0000-000000000000'::uuid - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = config_id - AND cmc.organization_id = o.id - AND NOT cmc.deleted - ) - ON CONFLICT ON CONSTRAINT chat_organization_model_overrides_organization_id_context_key - DO NOTHING; - END IF; - - UPDATE site_configs - SET value = (blob - 'model_config_id' - 'reasoning_effort')::text - WHERE key = 'agents_advisor_config'; -END; -$$; - -WITH parsed AS ( - SELECT - uc.user_id, - substring(uc.key FROM 'chat_personal_model_override:(.*)') AS context, - uc.value, - ( - SELECT split_part(trim(uc.value), ':', 2)::uuid - WHERE split_part(trim(uc.value), ':', 1) = 'model' - AND split_part(trim(uc.value), ':', 2) - ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' - ) AS config_id - FROM user_configs uc - WHERE uc.key LIKE 'chat\_personal\_model\_override:%' - AND substring(uc.key FROM 'chat_personal_model_override:(.*)') IN ('root', 'general', 'explore') -) -INSERT INTO chat_user_model_overrides - (user_id, organization_id, context, mode, model_config_id, reasoning_effort) -SELECT - p.user_id, - o.id, - p.context, - CASE - WHEN trim(p.value) IN ('chat_default', 'deployment_default') THEN trim(p.value) - ELSE 'model' - END, - CASE - WHEN trim(p.value) IN ('chat_default', 'deployment_default') THEN NULL - ELSE p.config_id - END, - CASE - WHEN split_part(trim(p.value), ':', 1) = 'model' - THEN NULLIF(split_part(trim(p.value), ':', 3), '') - ELSE NULL - END -FROM parsed p -JOIN organizations o ON NOT o.deleted -WHERE ( - -- Legacy mode-only settings applied deployment-wide, so seed them into - -- every organization the user belongs to. - trim(p.value) IN ('chat_default', 'deployment_default') - AND EXISTS ( - SELECT 1 - FROM organization_members om - WHERE om.user_id = p.user_id - AND om.organization_id = o.id - ) - ) - OR ( - -- A model reference is only representable in the organization that owns - -- the model (composite foreign key). - split_part(trim(p.value), ':', 1) = 'model' - AND p.config_id IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM chat_model_configs cmc - WHERE cmc.id = p.config_id - AND cmc.organization_id = o.id - AND NOT cmc.deleted - ) - ) -ON CONFLICT ON CONSTRAINT chat_user_model_overrides_user_organization_context_key -DO NOTHING; - DELETE FROM user_configs -WHERE key LIKE 'chat\_personal\_model\_override:%' - AND substring(key FROM 'chat_personal_model_override:(.*)') IN ('root', 'general', 'explore'); +WHERE key LIKE 'chat\_personal\_model\_override:%'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 3b04122ddb4..53050bb8ed7 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -3298,23 +3298,6 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { VALUES ($1, $2, $3, $4, $5, $5, 'active', '{}', 'password')`, userID, "model-override-"+userID.String(), userID.String()+"@example.com", []byte{}, now) require.NoError(t, err) - - // A second organization the user belongs to: legacy mode-only personal - // overrides were deployment-wide and must be seeded into every member - // organization, not only the default one. - memberOrgID := uuid.New() - _, err = db.ExecContext(ctx, ` - INSERT INTO organizations ( - id, name, display_name, description, created_at, updated_at, - is_default, default_org_member_roles - ) VALUES ($1, $2, $2, '', $3, $3, false, '{}')`, - memberOrgID, "model-override-"+memberOrgID.String(), now) - require.NoError(t, err) - _, err = db.ExecContext(ctx, ` - INSERT INTO organization_members (user_id, organization_id, created_at, updated_at) - VALUES ($1, $2, $3, $3), ($1, $4, $3, $3)`, - userID, orgID, now, memberOrgID) - require.NoError(t, err) _, err = db.ExecContext(ctx, ` INSERT INTO ai_providers (id, type, name, enabled, base_url, created_at, updated_at) VALUES ($1, 'openai', $2, true, 'https://example.com', $3, $3)`, @@ -3329,13 +3312,15 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { modelID, "model-override-"+modelID.String(), providerID, orgID, now) require.NoError(t, err) + advisorConfig := fmt.Sprintf( + `{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, modelID) _, err = db.ExecContext(ctx, ` INSERT INTO site_configs (key, value) VALUES ('agents_chat_general_model_override', $1), + ('agents_chat_title_generation_model_override', 'not-a-uuid'), ('agents_advisor_config', $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, - modelID.String()+":high", - fmt.Sprintf(`{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, modelID)) + modelID.String()+":high", advisorConfig) require.NoError(t, err) _, err = db.ExecContext(ctx, ` INSERT INTO user_configs (user_id, key, value) VALUES @@ -3347,65 +3332,38 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { _, err = db.ExecContext(ctx, string(upSQL)) require.NoError(t, err) - var orgModelID uuid.UUID - var orgEffort sql.NullString - require.NoError(t, db.QueryRowContext(ctx, ` - SELECT model_config_id, reasoning_effort - FROM chat_organization_model_overrides - WHERE organization_id = $1 AND context = 'general'`, orgID).Scan(&orgModelID, &orgEffort)) - require.Equal(t, modelID, orgModelID) - require.Equal(t, sql.NullString{String: "high", Valid: true}, orgEffort) - - var advisorModelID uuid.UUID - var advisorEffort sql.NullString - require.NoError(t, db.QueryRowContext(ctx, ` - SELECT model_config_id, reasoning_effort - FROM chat_organization_model_overrides - WHERE organization_id = $1 AND context = 'advisor'`, orgID).Scan(&advisorModelID, &advisorEffort)) - require.Equal(t, modelID, advisorModelID) - require.Equal(t, sql.NullString{String: "low", Valid: true}, advisorEffort) - - var mode string - var userModelID uuid.UUID - var userEffort sql.NullString - require.NoError(t, db.QueryRowContext(ctx, ` - SELECT mode, model_config_id, reasoning_effort - FROM chat_user_model_overrides - WHERE user_id = $1 AND organization_id = $2 AND context = 'root'`, userID, orgID). - Scan(&mode, &userModelID, &userEffort)) - require.Equal(t, "model", mode) - require.Equal(t, modelID, userModelID) - require.Equal(t, sql.NullString{String: "max", Valid: true}, userEffort) - - // The mode-only general setting is seeded into both member organizations; - // the model-backed root setting only where the model lives. - var generalOrgCount int - require.NoError(t, db.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM chat_user_model_overrides - WHERE user_id = $1 AND context = 'general' AND mode = 'chat_default' - AND organization_id IN ($2, $3)`, userID, orgID, memberOrgID).Scan(&generalOrgCount)) - require.Equal(t, 2, generalOrgCount) - var rootOrgCount int - require.NoError(t, db.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM chat_user_model_overrides - WHERE user_id = $1 AND context = 'root'`, userID).Scan(&rootOrgCount)) - require.Equal(t, 1, rootOrgCount) - - var advisorConfig string + // Legacy overrides are dropped rather than migrated: the new tables start + // empty and the serialized keys are gone. + var count int require.NoError(t, db.QueryRowContext(ctx, - "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&advisorConfig)) - require.JSONEq(t, `{"enabled":true,"max_uses_per_run":2}`, advisorConfig) - - var legacyCount int + "SELECT COUNT(*) FROM chat_organization_model_overrides").Scan(&count)) + require.Zero(t, count) + require.NoError(t, db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM chat_user_model_overrides").Scan(&count)) + require.Zero(t, count) require.NoError(t, db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM site_configs - WHERE key = 'agents_chat_general_model_override'`).Scan(&legacyCount)) - require.Zero(t, legacyCount) + WHERE key IN ( + 'agents_chat_general_model_override', + 'agents_chat_explore_model_override', + 'agents_chat_title_generation_model_override', + 'agents_chat_compaction_model_override' + )`).Scan(&count)) + require.Zero(t, count) require.NoError(t, db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM user_configs - WHERE user_id = $1 AND key LIKE 'chat\_personal\_model\_override:%'`, userID).Scan(&legacyCount)) - require.Zero(t, legacyCount) + WHERE user_id = $1 AND key LIKE 'chat\_personal\_model\_override:%'`, userID).Scan(&count)) + require.Zero(t, count) + // The migration does not parse the advisor runtime config; stale model + // fields stay in the stored JSON and are ignored by readers. + var value string + require.NoError(t, db.QueryRowContext(ctx, + "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) + require.Equal(t, advisorConfig, value) + + // The composite foreign key rejects model references from another + // organization. otherOrgID := uuid.New() _, err = db.ExecContext(ctx, ` INSERT INTO organizations ( @@ -3422,46 +3380,6 @@ func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) { _, err = db.ExecContext(ctx, string(downSQL)) require.NoError(t, err) - - var value string - require.NoError(t, db.QueryRowContext(ctx, - "SELECT value FROM site_configs WHERE key = 'agents_chat_general_model_override'").Scan(&value)) - require.Equal(t, modelID.String()+":high", value) - require.NoError(t, db.QueryRowContext(ctx, - "SELECT value FROM user_configs WHERE user_id = $1 AND key = 'chat_personal_model_override:root'", userID).Scan(&value)) - require.Equal(t, "model:"+modelID.String()+":max", value) - require.NoError(t, db.QueryRowContext(ctx, - "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) - require.JSONEq(t, fmt.Sprintf( - `{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, - modelID), value) - - // A malformed advisor config is a representable persisted state. It must - // not abort the migration and must be left untouched. - _, err = db.ExecContext(ctx, - "UPDATE site_configs SET value = 'not-json' WHERE key = 'agents_advisor_config'") - require.NoError(t, err) _, err = db.ExecContext(ctx, string(upSQL)) require.NoError(t, err) - require.NoError(t, db.QueryRowContext(ctx, - "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) - require.Equal(t, "not-json", value) - var advisorCount int - require.NoError(t, db.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM chat_organization_model_overrides - WHERE context = 'advisor'`).Scan(&advisorCount)) - require.Zero(t, advisorCount) - - // The rollback must tolerate a malformed stored value too, replacing it - // with the restored advisor model fields. - _, err = db.ExecContext(ctx, ` - INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id, reasoning_effort) - VALUES ($1, 'advisor', $2, 'low')`, orgID, modelID) - require.NoError(t, err) - _, err = db.ExecContext(ctx, string(downSQL)) - require.NoError(t, err) - require.NoError(t, db.QueryRowContext(ctx, - "SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value)) - require.JSONEq(t, fmt.Sprintf( - `{"model_config_id":%q,"reasoning_effort":"low"}`, modelID), value) } diff --git a/coderd/x/chatd/personal_model_override.go b/coderd/x/chatd/personal_model_override.go deleted file mode 100644 index 27277194387..00000000000 --- a/coderd/x/chatd/personal_model_override.go +++ /dev/null @@ -1,81 +0,0 @@ -package chatd - -import ( - "strings" - - "github.com/google/uuid" - - "github.com/coder/coder/v2/codersdk" -) - -// ChatPersonalModelOverrideKeyPrefix is the user config key prefix for -// chat personal model overrides. Values under this prefix should be parsed -// with ParseChatPersonalModelOverride so malformed values use one fallback. -const ChatPersonalModelOverrideKeyPrefix = "chat_personal_model_override:" - -// ChatPersonalModelOverrideKey returns the user config key for a chat -// personal model override context. Values stored at the returned key should -// use ParseChatPersonalModelOverride so malformed values fall back safely. -func ChatPersonalModelOverrideKey( - overrideContext codersdk.ChatPersonalModelOverrideContext, -) string { - return ChatPersonalModelOverrideKeyPrefix + string(overrideContext) -} - -// ParsedChatPersonalModelOverride is a parsed personal model override value. -// When Malformed is true, Mode is the provided default and ModelConfigID is -// uuid.Nil. -type ParsedChatPersonalModelOverride struct { - Mode codersdk.ChatPersonalModelOverrideMode - ModelConfigID uuid.UUID - ReasoningEffort *string - Malformed bool -} - -// ParseChatPersonalModelOverride parses a stored personal model override. -// Empty values return defaultMode without marking the value malformed. -// Malformed values return defaultMode, uuid.Nil, and Malformed true. -func ParseChatPersonalModelOverride( - raw string, - defaultMode codersdk.ChatPersonalModelOverrideMode, -) ParsedChatPersonalModelOverride { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return ParsedChatPersonalModelOverride{Mode: defaultMode} - } - - switch trimmed { - case string(codersdk.ChatPersonalModelOverrideModeChatDefault): - return ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, - } - case string(codersdk.ChatPersonalModelOverrideModeDeploymentDefault): - return ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - } - } - - mode, rawModelConfigID, ok := strings.Cut(trimmed, ":") - if !ok || mode != string(codersdk.ChatPersonalModelOverrideModeModel) { - return ParsedChatPersonalModelOverride{ - Mode: defaultMode, - Malformed: true, - } - } - rawID, rawEffort, hasEffort := strings.Cut(rawModelConfigID, ":") - modelConfigID, err := uuid.Parse(rawID) - if err != nil || (hasEffort && rawEffort == "") { - return ParsedChatPersonalModelOverride{ - Mode: defaultMode, - Malformed: true, - } - } - parsed := ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfigID, - } - if hasEffort { - parsed.ReasoningEffort = &rawEffort - } - return parsed -} diff --git a/coderd/x/chatd/personal_model_override_test.go b/coderd/x/chatd/personal_model_override_test.go deleted file mode 100644 index a9b7cf588d4..00000000000 --- a/coderd/x/chatd/personal_model_override_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package chatd_test - -import ( - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/util/ptr" - "github.com/coder/coder/v2/coderd/x/chatd" - "github.com/coder/coder/v2/codersdk" -) - -func TestChatPersonalModelOverrideKey(t *testing.T) { - t.Parallel() - - require.Equal( - t, - "chat_personal_model_override:root", - chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot), - ) -} - -func TestParseChatPersonalModelOverride(t *testing.T) { - t.Parallel() - - modelConfigID := uuid.MustParse("11111111-1111-1111-1111-111111111111") - tests := []struct { - name string - raw string - defaultMode codersdk.ChatPersonalModelOverrideMode - want chatd.ParsedChatPersonalModelOverride - }{ - { - name: "EmptyUsesDefault", - raw: "", - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - }, - }, - { - name: "ChatDefault", - raw: string(codersdk.ChatPersonalModelOverrideModeChatDefault), - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, - }, - }, - { - name: "DeploymentDefault", - raw: string(codersdk.ChatPersonalModelOverrideModeDeploymentDefault), - defaultMode: codersdk.ChatPersonalModelOverrideModeChatDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - }, - }, - { - name: "Model", - raw: "model:" + modelConfigID.String(), - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfigID, - }, - }, - { - name: "ModelWithReasoningEffort", - raw: "model:" + modelConfigID.String() + ":high", - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfigID, - ReasoningEffort: ptr.Ref("high"), - }, - }, - { - name: "ModelWithEmptyReasoningEffort", - raw: "model:" + modelConfigID.String() + ":", - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - Malformed: true, - }, - }, - { - name: "InvalidModelUUID", - raw: "model:not-a-uuid", - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - Malformed: true, - }, - }, - { - name: "UnknownValue", - raw: "unknown", - defaultMode: codersdk.ChatPersonalModelOverrideModeChatDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeChatDefault, - Malformed: true, - }, - }, - { - name: "OuterWhitespace", - raw: " \tmodel:" + modelConfigID.String() + "\n", - defaultMode: codersdk.ChatPersonalModelOverrideModeDeploymentDefault, - want: chatd.ParsedChatPersonalModelOverride{ - Mode: codersdk.ChatPersonalModelOverrideModeModel, - ModelConfigID: modelConfigID, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := chatd.ParseChatPersonalModelOverride(tt.raw, tt.defaultMode) - require.Equal(t, tt.want, got) - }) - } -} From d64c3ee4d9d483f2949b7532082476fd3faf958b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:27:24 +0000 Subject: [PATCH 13/16] docs(docs/ai-coder/agents): note that legacy model overrides are dropped on upgrade --- docs/ai-coder/agents/models.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index fd92f9981b4..84c8de91b34 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -308,6 +308,12 @@ layers: enable the toggle on the same admin page; once on, each user sees an **Agents** tab in their personal **Agents** > **Settings**. +> [!IMPORTANT] +> Overrides are stored per organization. When a deployment upgrades from the +> older deployment-wide override storage, existing admin and personal +> overrides are deleted rather than migrated. Admins and users must re-select +> their override models in settings after the upgrade. + The configurable contexts: | Context | Layer | Applies to | From 2027288a5cddad508b69d5bb41ae8591e3a32955 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:03:59 +0000 Subject: [PATCH 14/16] docs(docs/ai-coder/agents): update model override docs for organization scoping --- docs/ai-coder/agents/models.md | 63 +++++++++++-------- .../agents/platform-controls/advisor.md | 21 ++++--- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 84c8de91b34..855bbedc6ab 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -298,43 +298,50 @@ contact an administrator. ## Model overrides Beyond the chat-level model picker, Coder Agents supports two override -layers: - -- **Subagent overrides** (admin, deployment-wide): Pin specific subagent - contexts to a particular model. Configure them at **Agents** > - **Settings** > **Manage Agents** > **Agents**. -- **Personal overrides** (per user, opt-in by admin): Let users override - the model for their own root chats and delegated subagents. Admins - enable the toggle on the same admin page; once on, each user sees an - **Agents** tab in their personal **Agents** > **Settings**. +layers. Both are stored per organization and resolve from the chat's +organization: + +- **Admin overrides** (per organization): Pin specific contexts to a + particular model. Configure them on the **Defaults & overrides** tab + under **AI Settings** > **Models** for the selected organization. +- **Personal overrides** (per user and organization, opt-in by admin): + Let users override the model for their own root chats and delegated + subagents. Admins enable the deployment-wide toggle under + **AI Settings** > **Coder Agents**; once on, each user sees an + **Agents** tab in their personal **Agents** > **Settings**. Users in + more than one organization pick which organization to configure. > [!IMPORTANT] -> Overrides are stored per organization. When a deployment upgrades from the -> older deployment-wide override storage, existing admin and personal -> overrides are deleted rather than migrated. Admins and users must re-select -> their override models in settings after the upgrade. +> When a deployment upgrades from the older deployment-wide override +> storage, existing admin and personal overrides are deleted rather than +> migrated. Admins and users must re-select their override models in +> settings after the upgrade. The configurable contexts: -| Context | Layer | Applies to | -|----------------------|--------------|--------------------------------------------------------------------------------| -| **General** | Admin + user | Write-capable subagents (`spawn_agent` with `type=general` or `computer_use`). | -| **Explore** | Admin + user | Read-only subagents (`spawn_agent` with `type=explore`). | -| **Title generation** | Admin only | Automatic title generation for new chats. | -| **Root** | User only | The user's own root chats. | +| Context | Layer | Applies to | +|----------------------|--------------|----------------------------------------------------------------------------------------| +| **General** | Admin + user | Write-capable subagents (`spawn_agent` with `type=general` or `computer_use`). | +| **Explore** | Admin + user | Read-only subagents (`spawn_agent` with `type=explore`). | +| **Title generation** | Admin only | Automatic title generation for new chats. | +| **Compaction** | Admin only | Conversation summarization near the context limit. | +| **Advisor** | Admin only | The [advisor](./platform-controls/advisor.md). Requires the `chat-advisor` experiment. | +| **Root** | User only | The user's own root chats. | -Resolution order, evaluated per chat or subagent: +Resolution order, evaluated per chat or subagent from the chat's +organization: 1. Explicit `model_config_id` on the `spawn_agent` tool call (general and explore subagents only). 1. Personal override (when the admin gate is on and a model is set). -1. Admin subagent override. -1. The chat's selected model (or the deployment default for new chats). +1. Admin override. +1. The chat's selected model (or the organization default for new chats). If a referenced model is later disabled or deleted, that layer is skipped -and resolution falls through to the next. Explicit `spawn_agent` selection -is different: an unusable `model_config_id` fails the tool call instead of -falling through. Agents discover selectable models (and their reasoning +and resolution falls through to the next, with two exceptions: an unusable +explicit `spawn_agent` `model_config_id` fails the tool call, and an +unusable title generation override skips title generation instead of +falling back. Agents discover selectable models (and their reasoning effort ranges) with the `list_subagent_models` tool, which only returns enabled models usable with the chat owner's credentials. Computer-use subagents always run on the administrator-configured computer-use model and @@ -342,8 +349,10 @@ reject explicit model selection. > [!NOTE] > Both override layers may change between releases. -> The same values are available through the chat -> configuration API under `/api/v2/chats/config/`. +> Admin overrides are available through the API at +> `/api/experimental/organizations/{organization}/chats/model-overrides` +> and personal overrides at +> `/api/experimental/organizations/{organization}/members/{user}/chats/model-overrides`. ## User API keys (BYOK) diff --git a/docs/ai-coder/agents/platform-controls/advisor.md b/docs/ai-coder/agents/platform-controls/advisor.md index 01427f12296..aabdaae0875 100644 --- a/docs/ai-coder/agents/platform-controls/advisor.md +++ b/docs/ai-coder/agents/platform-controls/advisor.md @@ -29,20 +29,25 @@ after repeated failures, or risk reduction before a destructive operation. ## Configuration -Once the experiment is enabled, configure the advisor under **AI Settings** > -**Coder Agents** > **Advisor**. +Once the experiment is enabled, configure the advisor's runtime limits +under **AI Settings** > **Coder Agents** > **Advisor**. These limits apply +deployment-wide. | Field | Default | Notes | |-------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------| | Max uses per turn | `0` (unlimited) | Caps how many times the root agent can call the advisor in a single chat turn. Must be a non-negative integer. | | Max output tokens | `0` (server default) | Caps the advisor model's response length. `0` uses the server default of 16,384 tokens. Must be a non-negative integer. | -| Advisor model | Use chat model | Optional dedicated chat model config for the advisor. When unset, the advisor reuses the root agent's model. | -| Reasoning effort | Model default | Overrides the selected advisor model's reasoning effort. Available only when the model supports selectable effort. | + +The advisor model and its reasoning effort are organization-scoped +[model overrides](../models.md#model-overrides). Configure them on the +**Defaults & overrides** tab under **AI Settings** > **Models** for the +selected organization. When no override is set, the advisor reuses the +root agent's model. The advisor is not available in plan mode or to subagents. Failed advisor invocations refund the per-turn budget. -The same configuration is available at: - -- `GET /api/v2/chats/config/advisor` -- `PUT /api/v2/chats/config/advisor` +The same configuration is available through the API: runtime limits at +`GET`/`PUT` `/api/experimental/chats/config/advisor`, and the advisor +model override at +`PUT /api/experimental/organizations/{organization}/chats/model-overrides/advisor`. From 59f47c5b60902c5d9b8d56e858315fe30050c9f2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:19:14 +0000 Subject: [PATCH 15/16] refactor(coderd): extract sql.NullString pointer helper into util/ptr --- coderd/util/ptr/ptr.go | 16 +++++++++++++++- coderd/util/ptr/ptr_test.go | 16 ++++++++++++++++ coderd/x/chatd/chatd.go | 2 +- coderd/x/chatd/subagent.go | 20 +++++++------------- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/coderd/util/ptr/ptr.go b/coderd/util/ptr/ptr.go index 3500805c6fe..2f6fb9d6f65 100644 --- a/coderd/util/ptr/ptr.go +++ b/coderd/util/ptr/ptr.go @@ -1,7 +1,11 @@ // Package ptr contains some utility methods related to pointers. package ptr -import "golang.org/x/exp/constraints" +import ( + "database/sql" + + "golang.org/x/exp/constraints" +) type number interface { constraints.Integer | constraints.Float @@ -38,3 +42,13 @@ func NilToDefault[T any](s *T, def T) T { func NilOrZero[T number](v *T) bool { return v == nil || *v == 0 } + +// FromNullString returns a pointer to the string when it is valid and nil +// otherwise. +func FromNullString(v sql.NullString) *string { + if !v.Valid { + return nil + } + value := v.String + return &value +} diff --git a/coderd/util/ptr/ptr_test.go b/coderd/util/ptr/ptr_test.go index 355b32fc5cd..576bf85b1cf 100644 --- a/coderd/util/ptr/ptr_test.go +++ b/coderd/util/ptr/ptr_test.go @@ -1,6 +1,7 @@ package ptr_test import ( + "database/sql" "testing" "time" @@ -101,3 +102,18 @@ func Test_NilOrZero(t *testing.T) { assert.False(t, ptr.NilOrZero(&nonZeroFloat64)) assert.False(t, ptr.NilOrZero(&nonZeroDuration)) } + +func Test_FromNullString(t *testing.T) { + t.Parallel() + + assert.Nil(t, ptr.FromNullString(sql.NullString{})) + assert.Nil(t, ptr.FromNullString(sql.NullString{String: "ignored"})) + + valid := ptr.FromNullString(sql.NullString{String: "value", Valid: true}) + assert.NotNil(t, valid) + assert.Equal(t, "value", *valid) + + empty := ptr.FromNullString(sql.NullString{Valid: true}) + assert.NotNil(t, empty) + assert.Equal(t, "", *empty) +} diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 6d945c431b2..05f71d3ba88 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -316,7 +316,7 @@ func (p *Server) resolveAdvisorModelOverride( purpose: "advisor", chat: chat, explicitConfig: &overrideConfig, - requestedEffort: nullStringPtr(override.ReasoningEffort), + requestedEffort: ptr.FromNullString(override.ReasoningEffort), maxOutputTokens: ptr.Ref(maxOutputTokens), buildOptions: modelOpts, }) diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index dc2efc11f44..15fb622c251 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/agenthooks/dispatch" "github.com/coder/coder/v2/coderd/x/chatd/chathooks" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" @@ -328,13 +329,6 @@ func (p *Server) resolveConfiguredModelOverride( return modelConfig, providerName, parsed.reasoningEffort, true, nil } -func nullStringPtr(v sql.NullString) *string { - if !v.Valid { - return nil - } - return &v.String -} - // resolveOrganizationModelOverride resolves an override row whose composite // foreign key already binds the model config to the same organization. func (p *Server) resolveOrganizationModelOverride( @@ -352,20 +346,20 @@ func (p *Server) resolveOrganizationModelOverride( label := modelOverrideErrorLabel(overrideContext) switch { case errors.Is(err, sql.ErrNoRows): - return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + return database.ChatModelConfig{}, "", ptr.FromNullString(override.ReasoningEffort), true, xerrors.Errorf( "%s model override is unavailable: %s", label, override.ModelConfigID, ) case errors.Is(err, errInvalidModelOverrideMetadata): - return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + return database.ChatModelConfig{}, "", ptr.FromNullString(override.ReasoningEffort), true, xerrors.Errorf( "%s model override metadata is invalid for %s: %w", label, override.ModelConfigID, err, ) default: - return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + return database.ChatModelConfig{}, "", ptr.FromNullString(override.ReasoningEffort), true, xerrors.Errorf( "resolve %s model override %s: %w", label, override.ModelConfigID, @@ -408,7 +402,7 @@ func (p *Server) resolveOrganizationModelOverride( } if !userCanUseProviderKeys(providerKeys, providerName) { if failureMode == modelOverrideFailureModeHard { - return database.ChatModelConfig{}, "", nullStringPtr(override.ReasoningEffort), true, xerrors.Errorf( + return database.ChatModelConfig{}, "", ptr.FromNullString(override.ReasoningEffort), true, xerrors.Errorf( "%s model override credentials are unavailable for provider %q", modelOverrideErrorLabel(overrideContext), providerName, @@ -422,7 +416,7 @@ func (p *Server) resolveOrganizationModelOverride( ) return database.ChatModelConfig{}, "", nil, false, nil } - return modelConfig, providerName, nullStringPtr(override.ReasoningEffort), true, nil + return modelConfig, providerName, ptr.FromNullString(override.ReasoningEffort), true, nil } func (p *Server) resolvePersonalSubagentModelConfigID( @@ -474,7 +468,7 @@ func (p *Server) resolvePersonalSubagentModelConfigID( return uuid.Nil, nil, false, err } if ok { - return modelConfig.ID, nullStringPtr(override.ReasoningEffort), true, nil + return modelConfig.ID, ptr.FromNullString(override.ReasoningEffort), true, nil } default: p.logger.Warn(ctx, From 117310cefccc603d8bc6c9de0da18b815921f505 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Aug 2026 08:58:55 +0000 Subject: [PATCH 16/16] fix(coderd): use organization middleware for model overrides --- coderd/coderd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 20b0a9bac14..208b484e5ee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1439,7 +1439,7 @@ func New(options *Options) *API { r.Route("/organizations/{organization}/chats/model-overrides", func(r chi.Router) { r.Use( apiKeyMiddleware, - httpmw.ExtractChatModelOrganizationParam(options.Database), + httpmw.ExtractOrganizationParam(options.Database), ) r.Get("/", api.getOrganizationChatModelOverrides) r.Put("/{context}", api.putOrganizationChatModelOverride)