diff --git a/coderd/coderd.go b/coderd/coderd.go index c0b9b0d3ce..dfa2d055b6 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1192,6 +1192,8 @@ func New(options *Options) *API { r.Put("/debug-logging", api.putChatDebugLogging) r.Get("/user-debug-logging", api.getUserChatDebugLogging) r.Put("/user-debug-logging", api.putUserChatDebugLogging) + r.Get("/advisor", api.getChatAdvisorConfig) + r.Put("/advisor", api.putChatAdvisorConfig) r.Get("/user-prompt", api.getUserChatCustomPrompt) r.Put("/user-prompt", api.putUserChatCustomPrompt) r.Get("/user-compaction-thresholds", api.getUserChatCompactionThresholds) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 19acd82638..7cf4bf227b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2573,6 +2573,17 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } +func (q *querier) GetChatAdvisorConfig(ctx context.Context) (string, error) { + // The advisor configuration is a deployment-wide setting read by any + // authenticated chat user and by chatd when deciding whether to attach + // advisor behavior. We only require that an explicit actor is present + // in the context so unauthenticated calls fail closed. + if _, ok := ActorFromContext(ctx); !ok { + return "", ErrNoActor + } + return q.db.GetChatAdvisorConfig(ctx) +} + func (q *querier) GetChatAutoArchiveDays(ctx context.Context, defaultAutoArchiveDays int32) (int32, error) { // Chat auto-archive is a deployment-wide config read by dbpurge. // Only requires a valid actor in context. The HTTP GET handler @@ -7405,6 +7416,13 @@ func (q *querier) UpsertBoundaryUsageStats(ctx context.Context, arg database.Ups return q.db.UpsertBoundaryUsageStats(ctx, arg) } +func (q *querier) UpsertChatAdvisorConfig(ctx context.Context, value string) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatAdvisorConfig(ctx, value) +} + func (q *querier) UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays int32) 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 85e5d3fef7..db39439930 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -568,6 +568,14 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpsertChatDebugLoggingAllowUsers(gomock.Any(), true).Return(nil).AnyTimes() check.Args(true).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("GetChatAdvisorConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatAdvisorConfig(gomock.Any()).Return("{}", nil).AnyTimes() + check.Args().Asserts().Returns("{}") + })) + s.Run("UpsertChatAdvisorConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatAdvisorConfig(gomock.Any(), "{}").Return(nil).AnyTimes() + check.Args("{}").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("GetChatByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index aa5017e912..58dbfa3a87 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1120,6 +1120,14 @@ func (m queryMetricsStore) GetAuthorizationUserRoles(ctx context.Context, userID return r0, r1 } +func (m queryMetricsStore) GetChatAdvisorConfig(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetChatAdvisorConfig(ctx) + m.queryLatencies.WithLabelValues("GetChatAdvisorConfig").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatAdvisorConfig").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatAutoArchiveDays(ctx context.Context, defaultAutoArchiveDays int32) (int32, error) { start := time.Now() r0, r1 := m.s.GetChatAutoArchiveDays(ctx, defaultAutoArchiveDays) @@ -5296,6 +5304,14 @@ func (m queryMetricsStore) UpsertBoundaryUsageStats(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) UpsertChatAdvisorConfig(ctx context.Context, value string) error { + start := time.Now() + r0 := m.s.UpsertChatAdvisorConfig(ctx, value) + m.queryLatencies.WithLabelValues("UpsertChatAdvisorConfig").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatAdvisorConfig").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays int32) error { start := time.Now() r0 := m.s.UpsertChatAutoArchiveDays(ctx, autoArchiveDays) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index a218b84a41..baa9a4ab93 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2056,6 +2056,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), ctx, ownerID, prepared) } +// GetChatAdvisorConfig mocks base method. +func (m *MockStore) GetChatAdvisorConfig(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatAdvisorConfig", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatAdvisorConfig indicates an expected call of GetChatAdvisorConfig. +func (mr *MockStoreMockRecorder) GetChatAdvisorConfig(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatAdvisorConfig", reflect.TypeOf((*MockStore)(nil).GetChatAdvisorConfig), ctx) +} + // GetChatAutoArchiveDays mocks base method. func (m *MockStore) GetChatAutoArchiveDays(ctx context.Context, defaultAutoArchiveDays int32) (int32, error) { m.ctrl.T.Helper() @@ -9951,6 +9966,20 @@ func (mr *MockStoreMockRecorder) UpsertBoundaryUsageStats(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertBoundaryUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertBoundaryUsageStats), ctx, arg) } +// UpsertChatAdvisorConfig mocks base method. +func (m *MockStore) UpsertChatAdvisorConfig(ctx context.Context, value string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatAdvisorConfig", ctx, value) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatAdvisorConfig indicates an expected call of UpsertChatAdvisorConfig. +func (mr *MockStoreMockRecorder) UpsertChatAdvisorConfig(ctx, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatAdvisorConfig", reflect.TypeOf((*MockStore)(nil).UpsertChatAdvisorConfig), ctx, value) +} + // UpsertChatAutoArchiveDays mocks base method. func (m *MockStore) UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays int32) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 84c142e4de..cd28f742fd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -273,6 +273,11 @@ type sqlcQuerier interface { // This function returns roles for authorization purposes. Implied member roles // are included. GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error) + // GetChatAdvisorConfig returns the deployment-wide runtime configuration + // for the experimental chat advisor as a JSON blob. Callers unmarshal the + // result into codersdk.AdvisorConfig. Returns '{}' when unset so zero + // values apply by default. + GetChatAdvisorConfig(ctx context.Context) (string, error) // Auto-archive window in days. 0 disables. GetChatAutoArchiveDays(ctx context.Context, defaultAutoArchiveDays int32) (int32, error) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error) @@ -1183,6 +1188,10 @@ type sqlcQuerier interface { // cumulative values for unique counts (accurate period totals). Request counts // are always deltas, accumulated in DB. Returns true if insert, false if update. UpsertBoundaryUsageStats(ctx context.Context, arg UpsertBoundaryUsageStatsParams) (bool, error) + // UpsertChatAdvisorConfig stores the deployment-wide runtime configuration + // for the experimental chat advisor. Callers marshal codersdk.AdvisorConfig + // to JSON before invoking this query. + UpsertChatAdvisorConfig(ctx context.Context, value string) error UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays int32) error // UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that // allows users to opt into chat debug logging. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 14546f09e1..e3f652c8eb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -20536,6 +20536,22 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) { return value, err } +const getChatAdvisorConfig = `-- name: GetChatAdvisorConfig :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_advisor_config'), '{}') :: text AS advisor_config +` + +// GetChatAdvisorConfig returns the deployment-wide runtime configuration +// for the experimental chat advisor as a JSON blob. Callers unmarshal the +// result into codersdk.AdvisorConfig. Returns '{}' when unset so zero +// values apply by default. +func (q *sqlQuerier) GetChatAdvisorConfig(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getChatAdvisorConfig) + var advisor_config string + err := row.Scan(&advisor_config) + return advisor_config, err +} + const getChatAutoArchiveDays = `-- name: GetChatAutoArchiveDays :one SELECT COALESCE( (SELECT value::integer FROM site_configs @@ -20914,6 +20930,19 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er return err } +const upsertChatAdvisorConfig = `-- name: UpsertChatAdvisorConfig :exec +INSERT INTO site_configs (key, value) VALUES ('agents_advisor_config', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_advisor_config' +` + +// UpsertChatAdvisorConfig stores the deployment-wide runtime configuration +// for the experimental chat advisor. Callers marshal codersdk.AdvisorConfig +// to JSON before invoking this query. +func (q *sqlQuerier) UpsertChatAdvisorConfig(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertChatAdvisorConfig, value) + return err +} + const upsertChatAutoArchiveDays = `-- name: UpsertChatAutoArchiveDays :exec INSERT INTO site_configs (key, value) VALUES ('agents_chat_auto_archive_days', CAST($1 AS integer)::text) diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 0a02ace3a8..2001b910e3 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -203,6 +203,21 @@ SET value = CASE END WHERE site_configs.key = 'agents_desktop_enabled'; +-- GetChatAdvisorConfig returns the deployment-wide runtime configuration +-- for the experimental chat advisor as a JSON blob. Callers unmarshal the +-- result into codersdk.AdvisorConfig. Returns '{}' when unset so zero +-- values apply by default. +-- name: GetChatAdvisorConfig :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'agents_advisor_config'), '{}') :: text AS advisor_config; + +-- UpsertChatAdvisorConfig stores the deployment-wide runtime configuration +-- for the experimental chat advisor. Callers marshal codersdk.AdvisorConfig +-- to JSON before invoking this query. +-- name: UpsertChatAdvisorConfig :exec +INSERT INTO site_configs (key, value) VALUES ('agents_advisor_config', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_advisor_config'; + -- GetChatDebugLoggingAllowUsers returns the runtime admin setting that -- allows users to opt into chat debug logging when the deployment does -- not already force debug logging on globally. diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 98e36320f1..6ac231359a 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4218,6 +4218,106 @@ func (api *API) putUserChatDebugLogging(rw http.ResponseWriter, r *http.Request) rw.WriteHeader(http.StatusNoContent) } +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + raw, err := api.Database.GetChatAdvisorConfig(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching advisor configuration.", + Detail: err.Error(), + }) + return + } + + var resp codersdk.AdvisorConfig + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Stored advisor configuration is invalid.", + Detail: err.Error(), + }) + return + } + resp.MaxUsesPerRun = max(resp.MaxUsesPerRun, 0) + resp.MaxOutputTokens = max(resp.MaxOutputTokens, 0) + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +// EXPERIMENTAL: this endpoint is experimental and is subject to change. +func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.UpdateAdvisorConfigRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + if req.MaxUsesPerRun < 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("max_uses_per_run %d must be non-negative.", req.MaxUsesPerRun), + }) + return + } + if req.MaxOutputTokens < 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("max_output_tokens %d must be non-negative.", req.MaxOutputTokens), + }) + return + } + switch req.ReasoningEffort { + case "", "low", "medium", "high": + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf(`reasoning_effort %q is not valid; must be one of "", "low", "medium", or "high".`, req.ReasoningEffort), + }) + return + } + if req.ModelConfigID != uuid.Nil { + // Use system context because GetChatModelConfigByID requires + // deployment-config read access, which can be broader than the + // handler's explicit update check. The lookup only validates that + // the referenced model exists before persisting deployment config. + //nolint:gocritic // This admin-authorized validation lookup intentionally bypasses read authz. + if _, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), req.ModelConfigID); 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 existing model config.", req.ModelConfigID), + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error validating advisor model config.", + Detail: err.Error(), + }) + return + } + } + + raw, err := json.Marshal(req) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error encoding advisor configuration.", + Detail: err.Error(), + }) + return + } + if err := api.Database.UpsertChatAdvisorConfig(ctx, string(raw)); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating advisor configuration.", + Detail: err.Error(), + }) + return + } + + rw.WriteHeader(http.StatusNoContent) +} + // EXPERIMENTAL: this endpoint is experimental and is subject to change. // //nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 3e163273d4..da382bf787 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -11141,6 +11141,321 @@ 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) + require.Equal(t, codersdk.AdvisorConfig{}, resp) +} + +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, + ReasoningEffort: "high", + } + + 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_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) + 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) +} + +func TestChatAdvisorConfig_NegativeMaxUsesPerRunRejected(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 := createChatModelConfig(t, adminClient) + + want := codersdk.AdvisorConfig{ + Enabled: true, + MaxUsesPerRun: 3, + MaxOutputTokens: 2048, + ModelConfigID: modelConfig.ID, + ReasoningEffort: "medium", + } + + 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_InvalidReasoningEffort(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: "ultra", + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, `reasoning_effort "ultra"`) + require.Contains(t, sdkErr.Message, "not valid") +} + +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 existing model config") +} + +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) + + resp, err := adminClient.GetChatAdvisorConfig(ctx) + require.NoError(t, err) + require.Equal(t, want, 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 := createChatModelConfig(t, adminClient) + + rich := codersdk.AdvisorConfig{ + Enabled: true, + MaxUsesPerRun: 5, + MaxOutputTokens: 1024, + ModelConfigID: modelConfig.ID, + ReasoningEffort: "high", + } + err := adminClient.UpdateChatAdvisorConfig(ctx, rich) + require.NoError(t, err) + + sparse := codersdk.AdvisorConfig{Enabled: true} + err = adminClient.UpdateChatAdvisorConfig(ctx, sparse) + require.NoError(t, err) + + resp, err := adminClient.GetChatAdvisorConfig(ctx) + require.NoError(t, err) + require.Equal(t, sparse, resp) +} + +// TestChatAdvisorConfig_CanBeDisabledAfterEnabled pins the feature +// gate's "off" path. The downstream runtime gates the advisor tool and +// prompt guidance on Enabled, so a regression that silently drops or +// ignores Enabled: false on PUT would leave the feature stuck on. +func TestChatAdvisorConfig_CanBeDisabledAfterEnabled(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + 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) + require.NoError(t, err) + require.False(t, disabledResp.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) + + resp, err := adminClient.GetChatAdvisorConfig(ctx) + require.NoError(t, err) + require.Equal(t, codersdk.AdvisorConfig{ + Enabled: true, + MaxUsesPerRun: 0, + MaxOutputTokens: 0, + }, 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) + 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()) +} + func TestChatWorkspaceTTL(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/codersdk/chats.go b/codersdk/chats.go index 689b80bb7e..66c4d3f3b8 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -639,6 +639,36 @@ type UpdateChatDesktopEnabledRequest struct { EnableDesktop bool `json:"enable_desktop"` } +// AdvisorConfig is the deployment-wide runtime configuration for the +// experimental chat advisor. +// +// EXPERIMENTAL: this type is experimental and is subject to change. +type AdvisorConfig struct { + // Enabled toggles the advisor runtime. When false, advisor is not + // attached to new chats. + Enabled bool `json:"enabled"` + // MaxUsesPerRun caps how many times the advisor can be invoked per + // chat run. 0 means unlimited. + MaxUsesPerRun int `json:"max_uses_per_run"` + // MaxOutputTokens caps the advisor model response tokens. 0 means + // use the runtime default. + MaxOutputTokens int64 `json:"max_output_tokens"` + // ModelConfigID selects a specific chat model config 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 (e.g. the referenced model config was soft-deleted or + // its provider was disabled after the admin saved this config). + ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"` + // ReasoningEffort overlays provider reasoning effort on the advisor + // call config when supported. Allowed: "", "low", "medium", "high". + ReasoningEffort string `json:"reasoning_effort"` +} + +// 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 + // ChatDebugLoggingAdminSettings describes the runtime admin setting // that allows users to opt into chat debug logging. type ChatDebugLoggingAdminSettings struct { @@ -2146,6 +2176,33 @@ func (c *ExperimentalClient) UpdateChatDesktopEnabled(ctx context.Context, req U return nil } +// GetChatAdvisorConfig returns the deployment-wide advisor configuration. +func (c *ExperimentalClient) GetChatAdvisorConfig(ctx context.Context) (AdvisorConfig, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/advisor", nil) + if err != nil { + return AdvisorConfig{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return AdvisorConfig{}, ReadBodyAsError(res) + } + var resp AdvisorConfig + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateChatAdvisorConfig updates the deployment-wide advisor configuration. +func (c *ExperimentalClient) UpdateChatAdvisorConfig(ctx context.Context, req UpdateAdvisorConfigRequest) error { + res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/advisor", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // GetChatWorkspaceTTL returns the configured chat workspace TTL. func (c *ExperimentalClient) GetChatWorkspaceTTL(ctx context.Context) (ChatWorkspaceTTLResponse, error) { res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/workspace-ttl", nil) diff --git a/scripts/dbgen/main.go b/scripts/dbgen/main.go index 71fdcbbeef..265503dad5 100644 --- a/scripts/dbgen/main.go +++ b/scripts/dbgen/main.go @@ -107,6 +107,14 @@ type stubParams struct { func orderAndStubDatabaseFunctions(filePath, receiver, structName string, stub func(params stubParams) string) error { declByName := map[string]*dst.FuncDecl{} packageName := filepath.Base(filepath.Dir(filePath)) + externalMethods, err := loadExternalReceiverMethods( + filepath.Dir(filePath), + filepath.Base(filePath), + structName, + ) + if err != nil { + return xerrors.Errorf("load external receiver methods: %w", err) + } contents, err := os.ReadFile(filePath) if err != nil { @@ -149,6 +157,10 @@ func orderAndStubDatabaseFunctions(filePath, receiver, structName string, stub f } for _, fn := range funcs { + if _, ok := externalMethods[fn.Name]; ok { + continue + } + var bodyStmts []dst.Stmt decl, ok := declByName[fn.Name] @@ -316,6 +328,57 @@ func parseDBFile(filename string) (*dst.File, error) { return f, err } +func loadExternalReceiverMethods( + dirPath string, + excludeFile string, + structName string, +) (map[string]struct{}, error) { + methods := make(map[string]struct{}) + entries, err := os.ReadDir(dirPath) + if err != nil { + return nil, xerrors.Errorf("read dir %s: %w", dirPath, err) + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || name == excludeFile || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + + contents, err := os.ReadFile(filepath.Join(dirPath, name)) + if err != nil { + return nil, xerrors.Errorf("read %s: %w", name, err) + } + f, err := decorator.Parse(contents) + if err != nil { + return nil, xerrors.Errorf("parse %s: %w", name, err) + } + for _, decl := range f.Decls { + funcDecl, ok := decl.(*dst.FuncDecl) + if !ok || funcDecl.Recv == nil || len(funcDecl.Recv.List) == 0 { + continue + } + + var ident *dst.Ident + switch recv := funcDecl.Recv.List[0].Type.(type) { + case *dst.Ident: + ident = recv + case *dst.StarExpr: + ident, ok = recv.X.(*dst.Ident) + if !ok { + continue + } + } + if ident == nil || ident.Name != structName { + continue + } + methods[funcDecl.Name.Name] = struct{}{} + } + } + + return methods, nil +} + func loadInterfaceFuncs(f *dst.File, interfaceName string) ([]querierFunction, error) { var querier *dst.InterfaceType for _, decl := range f.Decls { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 468c24a110..7039bf0c70 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -797,6 +797,44 @@ export type Addon = "ai_governance"; export const Addons: Addon[] = ["ai_governance"]; +// From codersdk/chats.go +/** + * AdvisorConfig is the deployment-wide runtime configuration for the + * experimental chat advisor. + * + * EXPERIMENTAL: this type is experimental and is subject to change. + */ +export interface AdvisorConfig { + /** + * Enabled toggles the advisor runtime. When false, advisor is not + * attached to new chats. + */ + 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 chat model config 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 (e.g. the referenced model config was soft-deleted or + * its provider was disabled after the admin saved this config). + */ + readonly model_config_id: string; + /** + * ReasoningEffort overlays provider reasoning effort on the advisor + * call config when supported. Allowed: "", "low", "medium", "high". + */ + readonly reasoning_effort: string; +} + // From codersdk/workspacebuilds.go export interface AgentConnectionTiming { readonly started_at: string; @@ -7720,6 +7758,43 @@ export interface UpdateActiveTemplateVersion { readonly id: string; } +// 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. + */ +export interface UpdateAdvisorConfigRequest { + /** + * Enabled toggles the advisor runtime. When false, advisor is not + * attached to new chats. + */ + 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 chat model config 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 (e.g. the referenced model config was soft-deleted or + * its provider was disabled after the admin saved this config). + */ + readonly model_config_id: string; + /** + * ReasoningEffort overlays provider reasoning effort on the advisor + * call config when supported. Allowed: "", "low", "medium", "high". + */ + readonly reasoning_effort: string; +} + // From codersdk/deployment.go export interface UpdateAppearanceConfig { readonly application_name: string;