From 8f4025c755af3046f00df3eb2acdb7888c07342c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 7 Jul 2026 09:56:15 +0000 Subject: [PATCH 1/3] feat: replace fixed per-provider reasoning effort with per-model default and max --- coderd/database/dump.sql | 14 +- .../000541_chat_reasoning_effort.down.sql | 60 +++++ .../000541_chat_reasoning_effort.up.sql | 87 +++++++ coderd/database/migrations/migrate_test.go | 74 ++++++ .../000422_chat_provider_model_configs.up.sql | 148 +++++++++-- coderd/database/modelqueries.go | 2 + coderd/database/models.go | 7 + coderd/database/queries.sql.go | 194 +++++++++----- coderd/database/queries/chats.sql | 18 ++ coderd/exp_chats.go | 41 ++- coderd/exp_chats_test.go | 112 +++++++++ coderd/x/chatd/ARCHITECTURE.md | 4 + coderd/x/chatd/advisor_internal_test.go | 37 +++ coderd/x/chatd/chatd.go | 7 +- coderd/x/chatd/chatopenai/options.go | 30 --- coderd/x/chatd/chatopenai/options_test.go | 49 +--- coderd/x/chatd/chatprovider/chatprovider.go | 84 +------ .../x/chatd/chatprovider/chatprovider_test.go | 75 ------ .../x/chatd/chatprovider/reasoningeffort.go | 145 +++++++++++ .../chatprovider/reasoningeffort_test.go | 238 ++++++++++++++++++ coderd/x/chatd/generation_preparer.go | 2 + coderd/x/chatd/quickgen.go | 65 +++-- coderd/x/chatd/quickgen_internal_test.go | 64 ++++- .../x/chatd/title_override_internal_test.go | 12 +- codersdk/chats.go | 71 ++++-- codersdk/chats_test.go | 22 +- docs/admin/security/audit-logs.md | 70 +++--- enterprise/audit/table.go | 1 + site/src/api/chatModelOptionsGenerated.json | 66 ++--- site/src/api/typesGenerated.ts | 63 ++++- .../applyKnownModelDefaults.test.ts | 55 +++- .../knownModels/applyKnownModelDefaults.ts | 18 +- .../modelConfigFormLogic.test.ts | 184 ++++++++------ .../modelConfigFormLogic.ts | 43 +++- .../AgentsPage/utils/reasoningEffort.test.ts | 28 +++ .../pages/AgentsPage/utils/reasoningEffort.ts | 24 ++ 36 files changed, 1646 insertions(+), 568 deletions(-) create mode 100644 coderd/database/migrations/000541_chat_reasoning_effort.down.sql create mode 100644 coderd/database/migrations/000541_chat_reasoning_effort.up.sql create mode 100644 coderd/x/chatd/chatprovider/reasoningeffort.go create mode 100644 coderd/x/chatd/chatprovider/reasoningeffort_test.go create mode 100644 site/src/pages/AgentsPage/utils/reasoningEffort.test.ts create mode 100644 site/src/pages/AgentsPage/utils/reasoningEffort.ts diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b36d92f17d5ce..e9fd54e82cb89 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1917,9 +1917,12 @@ CREATE TABLE chat_messages ( deleted boolean DEFAULT false NOT NULL, provider_response_id text, api_key_id text, - revision bigint NOT NULL + revision bigint NOT NULL, + reasoning_effort text ); +COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort for the turn triggered by this message.'; + CREATE SEQUENCE chat_messages_id_seq START WITH 1 INCREMENT BY 1 @@ -1965,9 +1968,12 @@ CREATE TABLE chat_queued_messages ( model_config_id uuid, api_key_id text, "position" bigint DEFAULT nextval('chat_queued_messages_position_seq'::regclass) NOT NULL, - created_by uuid NOT NULL + created_by uuid NOT NULL, + reasoning_effort text ); +COMMENT ON COLUMN chat_queued_messages.reasoning_effort IS 'Stores the selected effort until the queued row is promoted.'; + CREATE SEQUENCE chat_queued_messages_id_seq START WITH 1 INCREMENT BY 1 @@ -2041,6 +2047,7 @@ CREATE TABLE chats ( context_dirty_since timestamp with time zone, context_dirty_resources jsonb, context_error text DEFAULT ''::text NOT NULL, + last_reasoning_effort text, CONSTRAINT chat_acl_only_on_root_chats CHECK ((((parent_chat_id IS NULL) AND (root_chat_id IS NULL)) OR ((user_acl = '{}'::jsonb) AND (group_acl = '{}'::jsonb)))), CONSTRAINT chat_group_acl_not_null_jsonb CHECK (((group_acl IS NOT NULL) AND (jsonb_typeof(group_acl) = 'object'::text))), CONSTRAINT chat_user_acl_not_null_jsonb CHECK (((user_acl IS NOT NULL) AND (jsonb_typeof(user_acl) = 'object'::text))), @@ -2062,6 +2069,8 @@ COMMENT ON COLUMN chats.context_dirty_resources IS 'Deterministic prefix of reso COMMENT ON COLUMN chats.context_error IS 'Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy.'; +COMMENT ON COLUMN chats.last_reasoning_effort IS 'Stores the most recent message effort once per-turn selection is wired.'; + CREATE TABLE users ( id uuid NOT NULL, email text NOT NULL, @@ -2127,6 +2136,7 @@ CREATE VIEW chats_expanded AS c.parent_chat_id, c.root_chat_id, c.last_model_config_id, + c.last_reasoning_effort, c.archived, c.last_error, c.mode, diff --git a/coderd/database/migrations/000541_chat_reasoning_effort.down.sql b/coderd/database/migrations/000541_chat_reasoning_effort.down.sql new file mode 100644 index 0000000000000..d941bb90aabb7 --- /dev/null +++ b/coderd/database/migrations/000541_chat_reasoning_effort.down.sql @@ -0,0 +1,60 @@ +DROP VIEW IF EXISTS chats_expanded; + +-- The up migration left the legacy per-provider effort keys in +-- place, so removing the reasoning_effort key restores the previous +-- state exactly. +UPDATE chat_model_configs +SET options = options - 'reasoning_effort' +WHERE options ? 'reasoning_effort'; + +ALTER TABLE chats DROP COLUMN last_reasoning_effort; +ALTER TABLE chat_messages DROP COLUMN reasoning_effort; +ALTER TABLE chat_queued_messages DROP COLUMN reasoning_effort; + +CREATE VIEW chats_expanded AS + SELECT c.id, + c.owner_id, + c.workspace_id, + c.title, + c.status, + c.worker_id, + c.started_at, + c.heartbeat_at, + c.created_at, + c.updated_at, + c.parent_chat_id, + c.root_chat_id, + c.last_model_config_id, + c.archived, + c.last_error, + c.mode, + c.mcp_server_ids, + c.labels, + c.build_id, + c.agent_id, + c.pin_order, + c.last_read_message_id, + c.dynamic_tools, + c.organization_id, + c.plan_mode, + c.client_type, + c.last_turn_summary, + c.snapshot_version, + c.history_version, + c.queue_version, + c.generation_attempt, + c.retry_state, + c.retry_state_version, + c.runner_id, + c.requires_action_deadline_at, + COALESCE(root.user_acl, c.user_acl) AS user_acl, + COALESCE(root.group_acl, c.group_acl) AS group_acl, + owner.username AS owner_username, + owner.name AS owner_name, + c.context_aggregate_hash, + c.context_dirty_since, + c.context_dirty_resources, + c.context_error + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); diff --git a/coderd/database/migrations/000541_chat_reasoning_effort.up.sql b/coderd/database/migrations/000541_chat_reasoning_effort.up.sql new file mode 100644 index 0000000000000..2120c9fb248ec --- /dev/null +++ b/coderd/database/migrations/000541_chat_reasoning_effort.up.sql @@ -0,0 +1,87 @@ +-- Per-turn reasoning effort. The chats_expanded view must be dropped +-- and recreated so the new chats column can appear in its column list. +DROP VIEW IF EXISTS chats_expanded; + +ALTER TABLE chats ADD COLUMN last_reasoning_effort text; +ALTER TABLE chat_messages ADD COLUMN reasoning_effort text; +ALTER TABLE chat_queued_messages ADD COLUMN reasoning_effort text; + +-- Reserved for follow-up per-turn reasoning effort support. +COMMENT ON COLUMN chats.last_reasoning_effort IS 'Stores the most recent message effort once per-turn selection is wired.'; +COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort for the turn triggered by this message.'; +COMMENT ON COLUMN chat_queued_messages.reasoning_effort IS 'Stores the selected effort until the queued row is promoted.'; + +CREATE VIEW chats_expanded AS + SELECT c.id, + c.owner_id, + c.workspace_id, + c.title, + c.status, + c.worker_id, + c.started_at, + c.heartbeat_at, + c.created_at, + c.updated_at, + c.parent_chat_id, + c.root_chat_id, + c.last_model_config_id, + c.last_reasoning_effort, + c.archived, + c.last_error, + c.mode, + c.mcp_server_ids, + c.labels, + c.build_id, + c.agent_id, + c.pin_order, + c.last_read_message_id, + c.dynamic_tools, + c.organization_id, + c.plan_mode, + c.client_type, + c.last_turn_summary, + c.snapshot_version, + c.history_version, + c.queue_version, + c.generation_attempt, + c.retry_state, + c.retry_state_version, + c.runner_id, + c.requires_action_deadline_at, + COALESCE(root.user_acl, c.user_acl) AS user_acl, + COALESCE(root.group_acl, c.group_acl) AS group_acl, + owner.username AS owner_username, + owner.name AS owner_name, + c.context_aggregate_hash, + c.context_dirty_since, + c.context_dirty_resources, + c.context_error + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); + +-- Copy legacy per-provider effort values to top-level +-- reasoning_effort. Preserve legacy keys so the down migration can +-- restore the original options shape. +UPDATE chat_model_configs +SET options = options || jsonb_build_object( + 'reasoning_effort', + jsonb_build_object('default', legacy.effort, 'max', legacy.effort) +) +FROM ( + SELECT + id, + COALESCE( + NULLIF(lower(trim(options #>> '{provider_options,openai,reasoning_effort}')), ''), + NULLIF(lower(trim(options #>> '{provider_options,azure,reasoning_effort}')), ''), + NULLIF(lower(trim(options #>> '{provider_options,anthropic,effort}')), ''), + NULLIF(lower(trim(options #>> '{provider_options,bedrock,effort}')), ''), + NULLIF(lower(trim(options #>> '{provider_options,openaicompat,reasoning_effort}')), ''), + NULLIF(lower(trim(options #>> '{provider_options,openrouter,reasoning,effort}')), ''), + NULLIF(lower(trim(options #>> '{provider_options,vercel,reasoning,effort}')), '') + ) AS effort + FROM chat_model_configs +) legacy +WHERE chat_model_configs.id = legacy.id + AND legacy.effort IS NOT NULL + AND legacy.effort IN ('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index f148860bc5f7b..c55be88054f11 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1582,6 +1582,80 @@ func applyMigrationsInTxn(ctx context.Context, t *testing.T, sqlDB *sql.DB, from require.NoError(t, tx.Commit()) } +func TestMigration000541ChatReasoningEffortBackfill(t *testing.T) { + t.Parallel() + + const priorMigrationVersion = 539 + + sqlDB := testSQLDB(t) + + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more || version == priorMigrationVersion { + break + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + now := time.Now().UTC().Truncate(time.Microsecond) + + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() + azureID := uuid.New() + bedrockID := uuid.New() + emptyID := uuid.New() + invalidID := uuid.New() + _, err = tx.ExecContext(ctx, ` + INSERT INTO ai_providers (id, type, name, enabled, base_url, created_at, updated_at) + VALUES + ($1, 'azure', 'test-azure-reasoning', TRUE, '', $3, $3), + ($2, 'bedrock', 'test-bedrock-reasoning', TRUE, '', $3, $3) + `, azureID, bedrockID, now) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, ` + INSERT INTO chat_model_configs (id, ai_provider_id, model, display_name, enabled, context_limit, compression_threshold, options, created_at, updated_at) + VALUES + ($3, $1, 'gpt-5.1-azure', 'Azure GPT-5.1', TRUE, 200000, 70, '{"provider_options": {"azure": {"reasoning_effort": " LOW "}}}', $5, $5), + ($4, $2, 'anthropic.claude-opus-4-6', 'Bedrock Claude Opus', TRUE, 200000, 70, '{"provider_options": {"bedrock": {"effort": "minimal"}}}', $5, $5), + (gen_random_uuid(), $1, 'gpt-5.1-empty-effort', 'Azure Empty Effort', TRUE, 200000, 70, '{"provider_options": {"azure": {"reasoning_effort": ""}}}', $5, $5), + (gen_random_uuid(), $2, 'anthropic.invalid-effort', 'Bedrock Invalid Effort', TRUE, 200000, 70, '{"provider_options": {"bedrock": {"effort": "extreme"}}}', $5, $5) + `, azureID, bedrockID, emptyID, invalidID, now) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + + migrationSQL, err := os.ReadFile("000541_chat_reasoning_effort.up.sql") + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + rows, err := sqlDB.QueryContext(ctx, ` + SELECT ap.type::text, cmc.model, cmc.options->'reasoning_effort'->>'default' + FROM chat_model_configs cmc + JOIN ai_providers ap ON ap.id = cmc.ai_provider_id + WHERE cmc.ai_provider_id IN ($1, $2) + ORDER BY cmc.model + `, azureID, bedrockID) + require.NoError(t, err) + defer rows.Close() + + got := map[string]sql.NullString{} + for rows.Next() { + var provider, model string + var effort sql.NullString + require.NoError(t, rows.Scan(&provider, &model, &effort)) + got[provider+":"+model] = effort + } + require.NoError(t, rows.Err()) + require.Equal(t, sql.NullString{String: "low", Valid: true}, got["azure:gpt-5.1-azure"]) + require.Equal(t, sql.NullString{}, got["azure:gpt-5.1-empty-effort"]) + require.Equal(t, sql.NullString{String: "minimal", Valid: true}, got["bedrock:anthropic.claude-opus-4-6"]) + require.Equal(t, sql.NullString{}, got["bedrock:anthropic.invalid-effort"]) +} + func TestMigration000498SoftDeleteStaleWorkspaceAgents(t *testing.T) { t.Parallel() diff --git a/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql b/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql index 0da5c47df7176..b1b109e7ffbce 100644 --- a/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000422_chat_provider_model_configs.up.sql @@ -7,16 +7,47 @@ INSERT INTO chat_providers ( enabled, created_at, updated_at -) VALUES ( - '0a8b2f84-b5a8-4c44-8c9f-e58c44a534a7', - 'openai', - 'OpenAI', - '', - NULL, - TRUE, - '2024-01-01 00:00:00+00', - '2024-01-01 00:00:00+00' -); +) VALUES + ( + '0a8b2f84-b5a8-4c44-8c9f-e58c44a534a7', + 'openai', + 'OpenAI', + '', + NULL, + TRUE, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e02', + 'anthropic', + 'Anthropic (Reasoning Effort Fixture)', + '', + NULL, + TRUE, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e03', + 'azure', + 'Azure OpenAI (Reasoning Effort Fixture)', + '', + NULL, + TRUE, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e04', + 'bedrock', + 'Bedrock (Reasoning Effort Fixture)', + '', + NULL, + TRUE, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ); INSERT INTO chat_model_configs ( id, @@ -26,19 +57,94 @@ INSERT INTO chat_model_configs ( enabled, context_limit, compression_threshold, + options, created_at, updated_at -) VALUES ( - '9af5f8d5-6a57-4505-8a69-3d6c787b95fd', - 'openai', - 'gpt-5.2', - 'GPT 5.2', - TRUE, - 200000, - 70, - '2024-01-01 00:00:00+00', - '2024-01-01 00:00:00+00' -); +) VALUES + ( + '9af5f8d5-6a57-4505-8a69-3d6c787b95fd', + 'openai', + 'gpt-5.2', + 'GPT 5.2', + TRUE, + 200000, + 70, + '{}'::jsonb, + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f01', + 'openai', + 'gpt-5.1', + 'GPT-5.1 (Legacy Effort)', + TRUE, + 200000, + 70, + '{"provider_options": {"openai": {"reasoning_effort": " HIGH ", "reasoning_summary": "auto"}}}', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f02', + 'anthropic', + 'claude-opus-4-6', + 'Claude Opus (Legacy Effort)', + TRUE, + 200000, + 70, + '{"provider_options": {"anthropic": {"effort": "max", "send_reasoning": true}}}', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f05', + 'azure', + 'gpt-5.1-azure', + 'Azure GPT-5.1 (Legacy Effort)', + TRUE, + 200000, + 70, + '{"provider_options": {"azure": {"reasoning_effort": "low"}}}', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f06', + 'bedrock', + 'anthropic.claude-opus-4-6', + 'Bedrock Claude Opus (Legacy Effort)', + TRUE, + 200000, + 70, + '{"provider_options": {"bedrock": {"effort": "minimal"}}}', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f03', + 'openai', + 'gpt-5.1-empty-effort', + 'GPT-5.1 (Empty Legacy Effort)', + TRUE, + 200000, + 70, + '{"provider_options": {"openai": {"reasoning_effort": ""}}}', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f04', + 'openai', + 'gpt-5.1-invalid-effort', + 'GPT-5.1 (Invalid Legacy Effort)', + TRUE, + 200000, + 70, + '{"provider_options": {"openai": {"reasoning_effort": "extreme"}}}', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ); INSERT INTO chats ( id, diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index e5618d5564e41..acceecbd2d5b1 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -810,6 +810,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, &i.Chat.ParentChatID, &i.Chat.RootChatID, &i.Chat.LastModelConfigID, + &i.Chat.LastReasoningEffort, &i.Chat.Archived, &i.Chat.LastError, &i.Chat.Mode, @@ -888,6 +889,7 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, diff --git a/coderd/database/models.go b/coderd/database/models.go index 4b11ac4e0663e..575f5b20457ae 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4799,6 +4799,7 @@ type Chat struct { ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` Archived bool `db:"archived" json:"archived"` LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` Mode NullChatMode `db:"mode" json:"mode"` @@ -4964,6 +4965,8 @@ type ChatMessage struct { ProviderResponseID sql.NullString `db:"provider_response_id" json:"provider_response_id"` APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` Revision int64 `db:"revision" json:"revision"` + // Stores the selected effort for the turn triggered by this message. + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` } type ChatModelConfig struct { @@ -4993,6 +4996,8 @@ type ChatQueuedMessage struct { APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` Position int64 `db:"position" json:"position"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` + // Stores the selected effort until the queued row is promoted. + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` } type ChatTable struct { @@ -5044,6 +5049,8 @@ type ChatTable struct { ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` // Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy. ContextError string `db:"context_error" json:"context_error"` + // Stores the most recent message effort once per-turn selection is wired. + LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` } type ChatUsageLimitConfig struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 05533001720de..1b4c0e53591f6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5561,7 +5561,7 @@ WHERE LIMIT $3::int ) -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -5578,6 +5578,7 @@ chats_expanded AS ( acquired_chats.parent_chat_id, acquired_chats.root_chat_id, acquired_chats.last_model_config_id, + acquired_chats.last_reasoning_effort, acquired_chats.archived, acquired_chats.last_error, acquired_chats.mode, @@ -5613,7 +5614,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id) JOIN visible_users owner ON owner.id = acquired_chats.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -5648,6 +5649,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -5816,7 +5818,7 @@ WITH updated_chats AS ( UPDATE chats SET archived = true, pin_order = 0, updated_at = NOW() WHERE id = $1::uuid OR root_chat_id = $1::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -5833,6 +5835,7 @@ chats_expanded AS ( updated_chats.parent_chat_id, updated_chats.root_chat_id, updated_chats.last_model_config_id, + updated_chats.last_reasoning_effort, updated_chats.archived, updated_chats.last_error, updated_chats.mode, @@ -5868,7 +5871,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chats.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -5896,6 +5899,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -5976,10 +5980,10 @@ archived AS ( FROM to_archive t WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children AND c.archived = false - RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error + RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error, c.last_reasoning_effort ) SELECT - a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error, + a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error, a.last_reasoning_effort, -- Children inherit their root's activity so last_activity_at is never null. COALESCE( t.last_activity_at, @@ -6038,6 +6042,7 @@ type AutoArchiveInactiveChatsRow struct { ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` ContextError string `db:"context_error" json:"context_error"` + LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } @@ -6099,6 +6104,7 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi &i.ContextDirtySince, &i.ContextDirtyResources, &i.ContextError, + &i.LastReasoningEffort, &i.LastActivityAt, ); err != nil { return nil, err @@ -6362,7 +6368,7 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds } const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE agent_id = $1::uuid AND archived = false @@ -6396,6 +6402,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -6442,7 +6449,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U const getAutoArchiveInactiveChatCandidates = `-- name: GetAutoArchiveInactiveChatCandidates :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.last_reasoning_effort, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at FROM chats_expanded LEFT JOIN LATERAL ( @@ -6488,6 +6495,7 @@ type GetAutoArchiveInactiveChatCandidatesRow struct { ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` Archived bool `db:"archived" json:"archived"` LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` Mode NullChatMode `db:"mode" json:"mode"` @@ -6547,6 +6555,7 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -6615,7 +6624,7 @@ func (q *sqlQuerier) GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatA } const getChatByID = `-- name: GetChatByID :one -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE id = $1::uuid ` @@ -6637,6 +6646,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -6673,7 +6683,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error const getChatByIDForShare = `-- name: GetChatByIDForShare :one WITH shared_chat AS ( - SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort FROM chats WHERE id = $1::uuid FOR SHARE @@ -6693,6 +6703,7 @@ chats_expanded AS ( shared_chat.parent_chat_id, shared_chat.root_chat_id, shared_chat.last_model_config_id, + shared_chat.last_reasoning_effort, shared_chat.archived, shared_chat.last_error, shared_chat.mode, @@ -6728,7 +6739,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id) JOIN visible_users owner ON owner.id = shared_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -6749,6 +6760,7 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -6785,7 +6797,7 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one WITH locked_chat AS ( - SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort FROM chats WHERE id = $1::uuid FOR UPDATE @@ -6805,6 +6817,7 @@ chats_expanded AS ( locked_chat.parent_chat_id, locked_chat.root_chat_id, locked_chat.last_model_config_id, + locked_chat.last_reasoning_effort, locked_chat.archived, locked_chat.last_error, locked_chat.mode, @@ -6840,7 +6853,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id) JOIN visible_users owner ON owner.id = locked_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -6861,6 +6874,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -7477,7 +7491,7 @@ func (q *sqlQuerier) GetChatHeartbeat(ctx context.Context, arg GetChatHeartbeatP const getChatMessageByID = `-- name: GetChatMessageByID :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -7512,6 +7526,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ) return i, err } @@ -7601,7 +7616,7 @@ func (q *sqlQuerier) GetChatMessageSummariesPerChat(ctx context.Context, created const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -7651,6 +7666,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7667,7 +7683,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes const getChatMessagesByChatIDAscPaginated = `-- name: GetChatMessagesByChatIDAscPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -7720,6 +7736,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7736,7 +7753,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar const getChatMessagesByChatIDDescPaginated = `-- name: GetChatMessagesByChatIDDescPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -7802,6 +7819,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7818,7 +7836,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a const getChatMessagesByRevisionForStream = `-- name: GetChatMessagesByRevisionForStream :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -7867,6 +7885,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7899,7 +7918,7 @@ WITH latest_compressed_summary AS ( 1 ) SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -7973,6 +7992,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -8036,7 +8056,7 @@ func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]Get } const getChatQueuedMessageByID = `-- name: GetChatQueuedMessageByID :one -SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by FROM chat_queued_messages +SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort FROM chat_queued_messages WHERE id = $1::bigint AND chat_id = $2::uuid ` @@ -8057,12 +8077,13 @@ func (q *sqlQuerier) GetChatQueuedMessageByID(ctx context.Context, arg GetChatQu &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } const getChatQueuedMessageHead = `-- name: GetChatQueuedMessageHead :one -SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by FROM chat_queued_messages +SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort FROM chat_queued_messages WHERE chat_id = $1::uuid ORDER BY position ASC, id ASC LIMIT 1 @@ -8081,12 +8102,13 @@ func (q *sqlQuerier) GetChatQueuedMessageHead(ctx context.Context, chatID uuid.U &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } const getChatQueuedMessages = `-- name: GetChatQueuedMessages :many -SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by FROM chat_queued_messages +SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort FROM chat_queued_messages WHERE chat_id = $1 ORDER BY created_at ASC, id ASC ` @@ -8109,6 +8131,7 @@ func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -8124,7 +8147,7 @@ func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID } const getChatQueuedMessagesByPosition = `-- name: GetChatQueuedMessagesByPosition :many -SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by FROM chat_queued_messages +SELECT id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort FROM chat_queued_messages WHERE chat_id = $1::uuid ORDER BY position ASC, id ASC ` @@ -8148,6 +8171,7 @@ func (q *sqlQuerier) GetChatQueuedMessagesByPosition(ctx context.Context, chatID &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -8343,7 +8367,7 @@ func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChat const getChatWorkerAcquisitionCandidates = `-- name: GetChatWorkerAcquisitionCandidates :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.last_reasoning_effort, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, chat_heartbeats.heartbeat_at AS current_heartbeat_at, NOT EXISTS ( SELECT 1 @@ -8393,6 +8417,7 @@ type GetChatWorkerAcquisitionCandidatesRow struct { ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` Archived bool `db:"archived" json:"archived"` LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` Mode NullChatMode `db:"mode" json:"mode"` @@ -8461,6 +8486,7 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -8517,7 +8543,7 @@ WITH cursor_chat AS ( WHERE id = $7 ) SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.last_reasoning_effort, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -8742,6 +8768,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha &i.Chat.ParentChatID, &i.Chat.RootChatID, &i.Chat.LastModelConfigID, + &i.Chat.LastReasoningEffort, &i.Chat.Archived, &i.Chat.LastError, &i.Chat.Mode, @@ -8789,7 +8816,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha const getChatsByChatFileID = `-- name: GetChatsByChatFileID :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE @@ -8825,6 +8852,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -8870,7 +8898,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) } const getChatsByIDsForRunnerSync = `-- name: GetChatsByIDsForRunnerSync :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE id = ANY($1::uuid[]) ORDER BY id ASC @@ -8899,6 +8927,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -8944,7 +8973,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. } const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -8974,6 +9003,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -9088,7 +9118,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.last_reasoning_effort, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -9146,6 +9176,7 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC &i.Chat.ParentChatID, &i.Chat.RootChatID, &i.Chat.LastModelConfigID, + &i.Chat.LastReasoningEffort, &i.Chat.Archived, &i.Chat.LastError, &i.Chat.Mode, @@ -9207,7 +9238,7 @@ func (q *sqlQuerier) GetDatabaseNow(ctx context.Context) (time.Time, error) { const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort FROM chat_messages WHERE @@ -9252,13 +9283,14 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ) return i, err } const getStaleChats = `-- name: GetStaleChats :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE @@ -9304,6 +9336,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -9539,7 +9572,7 @@ INSERT INTO chats ( $15::jsonb, $16::chat_client_type ) -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -9556,6 +9589,7 @@ chats_expanded AS ( inserted_chat.parent_chat_id, inserted_chat.root_chat_id, inserted_chat.last_model_config_id, + inserted_chat.last_reasoning_effort, inserted_chat.archived, inserted_chat.last_error, inserted_chat.mode, @@ -9591,7 +9625,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id) JOIN visible_users owner ON owner.id = inserted_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -9648,6 +9682,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -9751,7 +9786,7 @@ SELECT NULLIF(UNNEST($17::bigint[]), 0), NULLIF(UNNEST($18::bigint[]), 0) RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort ` type InsertChatMessagesParams struct { @@ -9827,6 +9862,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -9851,7 +9887,7 @@ SELECT chats.owner_id FROM chats WHERE chats.id = $1::uuid -RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by +RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort ` type InsertChatQueuedMessageParams struct { @@ -9881,6 +9917,7 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } @@ -9894,7 +9931,7 @@ VALUES ( $4::text, $5::uuid ) -RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by +RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort ` type InsertChatQueuedMessageWithCreatorParams struct { @@ -9926,6 +9963,7 @@ func (q *sqlQuerier) InsertChatQueuedMessageWithCreator(ctx context.Context, arg &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } @@ -10159,7 +10197,7 @@ WITH bumped_chat AS ( WHERE id = $1::uuid FOR UPDATE ) - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -10176,6 +10214,7 @@ chats_expanded AS ( bumped_chat.parent_chat_id, bumped_chat.root_chat_id, bumped_chat.last_model_config_id, + bumped_chat.last_reasoning_effort, bumped_chat.archived, bumped_chat.last_error, bumped_chat.mode, @@ -10210,7 +10249,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id) JOIN visible_users owner ON owner.id = bumped_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10235,6 +10274,7 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -10389,7 +10429,7 @@ WHERE id = ( ORDER BY cqm.created_at ASC, cqm.id ASC LIMIT 1 ) -RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by +RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort ` func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error) { @@ -10404,6 +10444,7 @@ func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } @@ -10595,7 +10636,7 @@ WITH updated_chats AS ( archived = false, updated_at = NOW() WHERE id = $1::uuid OR root_chat_id = $1::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -10612,6 +10653,7 @@ chats_expanded AS ( updated_chats.parent_chat_id, updated_chats.root_chat_id, updated_chats.last_model_config_id, + updated_chats.last_reasoning_effort, updated_chats.archived, updated_chats.last_error, updated_chats.mode, @@ -10647,7 +10689,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chats.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -10679,6 +10721,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -10811,7 +10854,7 @@ UPDATE chats SET updated_at = NOW() WHERE id = $3::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -10828,6 +10871,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -10863,7 +10907,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10890,6 +10934,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -10933,7 +10978,7 @@ SET updated_at = NOW() WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -10950,6 +10995,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -10985,7 +11031,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11011,6 +11057,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11058,7 +11105,7 @@ WITH updated_chat AS ( pin_order = CASE WHEN $2::boolean THEN 0 ELSE pin_order END, updated_at = NOW() WHERE id = $7::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -11075,6 +11122,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -11109,7 +11157,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11152,6 +11200,7 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11240,7 +11289,7 @@ SET updated_at = NOW() WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -11257,6 +11306,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -11292,7 +11342,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11318,6 +11368,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11361,7 +11412,7 @@ SET last_model_config_id = $1::uuid WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -11378,6 +11429,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -11413,7 +11465,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11439,6 +11491,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11532,7 +11585,7 @@ SET updated_at = NOW() WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -11549,6 +11602,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -11584,7 +11638,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11610,6 +11664,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11653,7 +11708,7 @@ SET WHERE id = $3::bigint RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort ` type UpdateChatMessageByIDParams struct { @@ -11689,6 +11744,7 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ) return i, err } @@ -11773,7 +11829,7 @@ SET plan_mode = $1::chat_plan_mode WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -11790,6 +11846,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -11825,7 +11882,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11851,6 +11908,7 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11892,7 +11950,7 @@ WITH updated_chat AS ( retry_state = $1::jsonb, updated_at = NOW() WHERE id = $2::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -11909,6 +11967,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -11943,7 +12002,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11971,6 +12030,7 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -12018,7 +12078,7 @@ SET updated_at = NOW() WHERE id = $6::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -12035,6 +12095,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -12070,7 +12131,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12107,6 +12168,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -12152,7 +12214,7 @@ SET title = $1::text WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -12169,6 +12231,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -12204,7 +12267,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12230,6 +12293,7 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -12272,7 +12336,7 @@ UPDATE chats SET agent_id = $3::uuid, updated_at = NOW() WHERE id = $4::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error, last_reasoning_effort ), chats_expanded AS ( SELECT @@ -12289,6 +12353,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -12324,7 +12389,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, last_reasoning_effort, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12357,6 +12422,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index f6b0aba7d4878..effa7df218a19 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -20,6 +20,7 @@ chats_expanded AS ( updated_chats.parent_chat_id, updated_chats.root_chat_id, updated_chats.last_model_config_id, + updated_chats.last_reasoning_effort, updated_chats.archived, updated_chats.last_error, updated_chats.mode, @@ -86,6 +87,7 @@ chats_expanded AS ( updated_chats.parent_chat_id, updated_chats.root_chat_id, updated_chats.last_model_config_id, + updated_chats.last_reasoning_effort, updated_chats.archived, updated_chats.last_error, updated_chats.mode, @@ -751,6 +753,7 @@ chats_expanded AS ( inserted_chat.parent_chat_id, inserted_chat.root_chat_id, inserted_chat.last_model_config_id, + inserted_chat.last_reasoning_effort, inserted_chat.archived, inserted_chat.last_error, inserted_chat.mode, @@ -897,6 +900,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -963,6 +967,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1027,6 +1032,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1091,6 +1097,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1155,6 +1162,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1218,6 +1226,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1281,6 +1290,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1362,6 +1372,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1578,6 +1589,7 @@ chats_expanded AS ( acquired_chats.parent_chat_id, acquired_chats.root_chat_id, acquired_chats.last_model_config_id, + acquired_chats.last_reasoning_effort, acquired_chats.archived, acquired_chats.last_error, acquired_chats.mode, @@ -1646,6 +1658,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -1921,6 +1934,7 @@ chats_expanded AS ( locked_chat.parent_chat_id, locked_chat.root_chat_id, locked_chat.last_model_config_id, + locked_chat.last_reasoning_effort, locked_chat.archived, locked_chat.last_error, locked_chat.mode, @@ -1981,6 +1995,7 @@ chats_expanded AS ( shared_chat.parent_chat_id, shared_chat.root_chat_id, shared_chat.last_model_config_id, + shared_chat.last_reasoning_effort, shared_chat.archived, shared_chat.last_error, shared_chat.mode, @@ -2657,6 +2672,7 @@ chats_expanded AS ( bumped_chat.parent_chat_id, bumped_chat.root_chat_id, bumped_chat.last_model_config_id, + bumped_chat.last_reasoning_effort, bumped_chat.archived, bumped_chat.last_error, bumped_chat.mode, @@ -2728,6 +2744,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, @@ -2791,6 +2808,7 @@ chats_expanded AS ( updated_chat.parent_chat_id, updated_chat.root_chat_id, updated_chat.last_model_config_id, + updated_chat.last_reasoning_effort, updated_chat.archived, updated_chat.last_error, updated_chat.mode, diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 83c95c157879c..c647f97eb85b5 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7491,6 +7491,12 @@ func parseChatModelConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, } func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModelConfig { + modelConfig := unmarshalChatModelCallConfig(config.Options) + var reasoningEffortConfig *codersdk.ChatModelReasoningEffortConfig + if modelConfig != nil { + reasoningEffortConfig = modelConfig.ReasoningEffort + } + // Active configs always carry a non-null ai_provider_id (CHECK // chat_model_configs_ai_provider_required_when_active). return codersdk.ChatModelConfig{ @@ -7502,15 +7508,14 @@ func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModelC IsDefault: config.IsDefault, ContextLimit: config.ContextLimit, CompressionThreshold: config.CompressionThreshold, - ModelConfig: unmarshalChatModelCallConfig(config.Options), + ModelConfig: modelConfig, + ReasoningEfforts: chatprovider.SelectableReasoningEfforts(reasoningEffortConfig), CreatedAt: config.CreatedAt, UpdatedAt: config.UpdatedAt, } } -func marshalChatModelCallConfig( - modelConfig *codersdk.ChatModelCallConfig, -) (json.RawMessage, error) { +func marshalChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) (json.RawMessage, error) { if modelConfig == nil { return json.RawMessage("{}"), nil } @@ -7551,9 +7556,36 @@ func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) erro } } + if err := validateChatModelReasoningEffortConfig(modelConfig); err != nil { + return err + } + return validateChatModelProviderOptions(modelConfig.ProviderOptions) } +// validateChatModelReasoningEffortConfig validates the reasoning_effort +// config. Values must exactly match the global effort scale, and default +// must not exceed max. +func validateChatModelReasoningEffortConfig(modelConfig *codersdk.ChatModelCallConfig) error { + config := modelConfig.ReasoningEffort + if config == nil { + return nil + } + if config.Default == nil || config.Max == nil { + return xerrors.New("reasoning_effort.default and reasoning_effort.max must both be set") + } + if !chatprovider.IsValidReasoningEffort(*config.Default) { + return xerrors.New("reasoning_effort.default must be one of none, minimal, low, medium, high, xhigh, max") + } + if !chatprovider.IsValidReasoningEffort(*config.Max) { + return xerrors.New("reasoning_effort.max must be one of none, minimal, low, medium, high, xhigh, max") + } + if !chatprovider.ReasoningEffortLessOrEqual(*config.Default, *config.Max) { + return xerrors.New("reasoning_effort.default must not exceed reasoning_effort.max") + } + return nil +} + func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) error { if options == nil || options.Anthropic == nil || options.Anthropic.ThinkingDisplay == nil { return nil @@ -7604,6 +7636,7 @@ func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool { config.TopK == nil && config.PresencePenalty == nil && config.FrequencyPenalty == nil && + config.ReasoningEffort == nil && isZeroModelCostConfig(config.Cost) && isZeroChatModelProviderOptions(config.ProviderOptions) } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index d79e2f83e9bcc..4ac1e3f5d4352 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3744,6 +3744,118 @@ func TestCreateChatModelConfig(t *testing.T) { ) }) + t.Run("ReasoningEffortStored", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + + contextLimit := int64(4096) + modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("medium"), + Max: ptr.Ref("xhigh"), + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, modelConfig.ModelConfig) + require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort) + require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort.Default) + require.Equal(t, "medium", *modelConfig.ModelConfig.ReasoningEffort.Default) + require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort.Max) + require.Equal(t, "xhigh", *modelConfig.ModelConfig.ReasoningEffort.Max) + require.Equal(t, []string{"none", "minimal", "low", "medium", "high", "xhigh"}, modelConfig.ReasoningEfforts) + }) + + t.Run("ReasoningEffortRejectsSingleValue", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + + contextLimit := int64(4096) + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("high"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Equal(t, "reasoning_effort.default and reasoning_effort.max must both be set", sdkErr.Detail) + }) + + t.Run("ReasoningEffortRejectsInvalidValue", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + + contextLimit := int64(4096) + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(" HIGH "), + Max: ptr.Ref("high"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Equal( + t, + "reasoning_effort.default must be one of none, minimal, low, medium, high, xhigh, max", + sdkErr.Detail, + ) + }) + + t.Run("ReasoningEffortRejectsDefaultAboveMax", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + + contextLimit := int64(4096) + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("xhigh"), + Max: ptr.Ref("low"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "must not exceed") + }) + t.Run("MissingContextLimit", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index bc588ccd436c5..e4b4cba7f6627 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -825,6 +825,10 @@ The generation goroutine supports: - turn limit after a user message (the LLM shouldn't be able to spin forever in loop) - and other things +##### Reasoning effort + +Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. During generation preparation, the effective effort is resolved from 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. The resolved value is injected into the provider-native options with `chatprovider.ApplyReasoningEffort` after provider option conversion. + #### Interrupt goroutine The interrupt goroutine is responsible for handling interrupts. It is spawned when the event indicates the core state machine is in `I0` or `I1` (status is `interrupting`). diff --git a/coderd/x/chatd/advisor_internal_test.go b/coderd/x/chatd/advisor_internal_test.go index 05bbc28a45cca..e3b7623307a06 100644 --- a/coderd/x/chatd/advisor_internal_test.go +++ b/coderd/x/chatd/advisor_internal_test.go @@ -9,6 +9,7 @@ import ( "time" "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" "github.com/google/uuid" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -16,6 +17,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatadvisor" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" @@ -608,4 +610,39 @@ func TestNewAdvisorRuntime(t *testing.T) { require.Equal(t, int64(defaultAdvisorMaxOutputTokens), rt.MaxOutputTokens(), "zero max output tokens must be replaced with defaultAdvisorMaxOutputTokens") }) + + t.Run("AppliesReasoningEffortToProviderOptions", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + store := &advisorOverrideStubStore{} + p := newAdvisorTestServer(ctx, t, store) + + rt := p.newAdvisorRuntimeOrFallback( + ctx, + database.Chat{}, + codersdk.AdvisorConfig{ + Enabled: true, + MaxUsesPerRun: 3, + MaxOutputTokens: 16384, + }, + fallbackModel, + codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortXHigh), + }, + ProviderOptions: &codersdk.ChatModelProviderOptions{ + OpenAI: &codersdk.ChatModelOpenAIProviderOptions{ + User: ptr.Ref("advisor-user"), + }, + }, + }, + modelBuildOptions{}, + logger, + ) + require.NotNil(t, rt) + providerOptions := rt.ProviderOptions()[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.Equal(t, "advisor-user", *providerOptions.User) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) } diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index f57058f136358..be82d40fe5f39 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -402,6 +402,11 @@ func (p *Server) newAdvisorRuntime( advisorModel, advisorCallConfig.ProviderOptions, ) + providerOptions = chatprovider.ApplyReasoningEffort( + advisorModel, + providerOptions, + chatprovider.ResolveReasoningEffort(advisorCallConfig.ReasoningEffort), + ) rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{ Model: advisorModel, @@ -2336,7 +2341,7 @@ func (p *Server) generateManualTitleCandidate( ) } - title, usage, err := generateManualTitle(titleCtx, messages, titleModel) + title, usage, err := generateManualTitle(titleCtx, messages, titleModel, p.titleGenerationProviderOptions(ctx, titleModel, modelConfig)) finishDebugRun(err) result.title = title result.usage = usage diff --git a/coderd/x/chatd/chatopenai/options.go b/coderd/x/chatd/chatopenai/options.go index 91d87fe582661..5623f76828748 100644 --- a/coderd/x/chatd/chatopenai/options.go +++ b/coderd/x/chatd/chatopenai/options.go @@ -18,7 +18,6 @@ func ProviderOptionsFromChatConfig( model fantasy.LanguageModel, options *codersdk.ChatModelOpenAIProviderOptions, ) fantasy.ProviderOptionsData { - reasoningEffort := ReasoningEffortFromChat(options.ReasoningEffort) if UsesResponsesOptions(model) { include := EnsureResponseIncludes(IncludeFromChat(options.Include)) providerOptions := &fantasyopenai.ResponsesProviderOptions{ @@ -29,7 +28,6 @@ func ProviderOptionsFromChatConfig( Metadata: options.Metadata, ParallelToolCalls: options.ParallelToolCalls, PromptCacheKey: chatutil.NormalizedStringPointer(options.PromptCacheKey), - ReasoningEffort: reasoningEffort, ReasoningSummary: chatutil.NormalizedStringPointer(options.ReasoningSummary), SafetyIdentifier: chatutil.NormalizedStringPointer(options.SafetyIdentifier), ServiceTier: ServiceTierFromChat(options.ServiceTier), @@ -47,7 +45,6 @@ func ProviderOptionsFromChatConfig( TopLogProbs: options.TopLogProbs, ParallelToolCalls: options.ParallelToolCalls, User: chatutil.NormalizedStringPointer(options.User), - ReasoningEffort: reasoningEffort, MaxCompletionTokens: options.MaxCompletionTokens, TextVerbosity: chatutil.NormalizedStringPointer(options.TextVerbosity), Prediction: options.Prediction, @@ -133,33 +130,6 @@ func UsesResponsesOptions(model fantasy.LanguageModel) bool { } } -// ReasoningEffortFromChat normalizes chat-config reasoning effort values for -// OpenAI and returns the canonical provider effort value. -func ReasoningEffortFromChat(value *string) *fantasyopenai.ReasoningEffort { - if value == nil { - return nil - } - - normalized := strings.ToLower(strings.TrimSpace(*value)) - if normalized == "" { - return nil - } - - effort := chatutil.NormalizedEnumValue( - normalized, - string(fantasyopenai.ReasoningEffortMinimal), - string(fantasyopenai.ReasoningEffortLow), - string(fantasyopenai.ReasoningEffortMedium), - string(fantasyopenai.ReasoningEffortHigh), - string(fantasyopenai.ReasoningEffortXHigh), - ) - if effort == nil { - return nil - } - valueCopy := fantasyopenai.ReasoningEffort(*effort) - return &valueCopy -} - // ServiceTierFromChat normalizes chat-config service tier values for OpenAI // Responses API and returns the canonical provider service tier value. func ServiceTierFromChat(value *string) *fantasyopenai.ServiceTier { diff --git a/coderd/x/chatd/chatopenai/options_test.go b/coderd/x/chatd/chatopenai/options_test.go index 1320300b11cb9..32e8a8720454c 100644 --- a/coderd/x/chatd/chatopenai/options_test.go +++ b/coderd/x/chatd/chatopenai/options_test.go @@ -30,7 +30,6 @@ func TestProviderOptionsFromChatConfigLegacy(t *testing.T) { TopLogProbs: &topLogProbs, ParallelToolCalls: ¶llelToolCalls, User: ptr(" user-1 "), - ReasoningEffort: ptr(" HIGH "), MaxCompletionTokens: &maxCompletionTokens, TextVerbosity: ptr(" High "), Prediction: map[string]any{ @@ -56,7 +55,7 @@ func TestProviderOptionsFromChatConfigLegacy(t *testing.T) { require.Same(t, options.TopLogProbs, providerOptions.TopLogProbs) require.Same(t, options.ParallelToolCalls, providerOptions.ParallelToolCalls) require.Equal(t, "user-1", requireStringPointerValue(t, providerOptions.User)) - require.Equal(t, fantasyopenai.ReasoningEffortHigh, requireReasoningEffortPointerValue(t, providerOptions.ReasoningEffort)) + require.Nil(t, providerOptions.ReasoningEffort) require.Same(t, options.MaxCompletionTokens, providerOptions.MaxCompletionTokens) require.Equal(t, "High", requireStringPointerValue(t, providerOptions.TextVerbosity)) require.Equal(t, options.Prediction, providerOptions.Prediction) @@ -88,7 +87,6 @@ func TestProviderOptionsFromChatConfigResponses(t *testing.T) { Metadata: map[string]any{"scope": "unit"}, ParallelToolCalls: ¶llelToolCalls, PromptCacheKey: ptr(" prompt-cache "), - ReasoningEffort: ptr(" minimal "), ReasoningSummary: ptr(" auto "), SafetyIdentifier: ptr(" safety "), ServiceTier: ptr(" FLEX "), @@ -114,7 +112,7 @@ func TestProviderOptionsFromChatConfigResponses(t *testing.T) { require.Equal(t, options.Metadata, providerOptions.Metadata) require.Same(t, options.ParallelToolCalls, providerOptions.ParallelToolCalls) require.Equal(t, "prompt-cache", requireStringPointerValue(t, providerOptions.PromptCacheKey)) - require.Equal(t, fantasyopenai.ReasoningEffortMinimal, requireReasoningEffortPointerValue(t, providerOptions.ReasoningEffort)) + require.Nil(t, providerOptions.ReasoningEffort) require.Equal(t, "auto", requireStringPointerValue(t, providerOptions.ReasoningSummary)) require.Equal(t, "safety", requireStringPointerValue(t, providerOptions.SafetyIdentifier)) require.Equal(t, fantasyopenai.ServiceTierFlex, requireServiceTierPointerValue(t, providerOptions.ServiceTier)) @@ -281,40 +279,6 @@ func TestUsesResponsesOptions(t *testing.T) { } } -func TestReasoningEffortFromChat(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value *string - want *fantasyopenai.ReasoningEffort - }{ - {name: "Nil"}, - {name: "Empty", value: ptr(" ")}, - {name: "Minimal", value: ptr(" minimal "), want: ptr(fantasyopenai.ReasoningEffortMinimal)}, - {name: "LowCase", value: ptr(" LOW "), want: ptr(fantasyopenai.ReasoningEffortLow)}, - {name: "Medium", value: ptr("medium"), want: ptr(fantasyopenai.ReasoningEffortMedium)}, - {name: "High", value: ptr("high"), want: ptr(fantasyopenai.ReasoningEffortHigh)}, - {name: "XHigh", value: ptr("xhigh"), want: ptr(fantasyopenai.ReasoningEffortXHigh)}, - {name: "NoneUnsupported", value: ptr("none")}, - {name: "Invalid", value: ptr("max")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := chatopenai.ReasoningEffortFromChat(tt.value) - if tt.want == nil { - require.Nil(t, got) - return - } - require.NotNil(t, got) - require.Equal(t, *tt.want, *got) - }) - } -} - func TestServiceTierFromChat(t *testing.T) { t.Parallel() @@ -438,15 +402,6 @@ func requireBoolPointerValue(t *testing.T, value *bool) bool { return *value } -func requireReasoningEffortPointerValue( - t *testing.T, - value *fantasyopenai.ReasoningEffort, -) fantasyopenai.ReasoningEffort { - t.Helper() - require.NotNil(t, value) - return *value -} - func requireServiceTierPointerValue( t *testing.T, value *fantasyopenai.ServiceTier, diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 4f116ef1b75a4..e9f466f4eda0a 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -812,57 +812,6 @@ func isChatModelForProvider(provider, modelID string) bool { } } -// ReasoningEffortFromChat normalizes chat-config reasoning effort values for a -// provider and returns the canonical provider effort value. -func ReasoningEffortFromChat(provider string, value *string) *string { - if value == nil { - return nil - } - - normalized := strings.ToLower(strings.TrimSpace(*value)) - if normalized == "" { - return nil - } - - switch NormalizeProvider(provider) { - case fantasyopenai.Name: - effort := chatopenai.ReasoningEffortFromChat(value) - if effort == nil { - return nil - } - valueCopy := string(*effort) - return &valueCopy - case fantasyanthropic.Name: - return chatutil.NormalizedEnumValue( - normalized, - string(fantasyanthropic.EffortLow), - string(fantasyanthropic.EffortMedium), - string(fantasyanthropic.EffortHigh), - string(fantasyanthropic.EffortXHigh), - string(fantasyanthropic.EffortMax), - ) - case fantasyopenrouter.Name: - return chatutil.NormalizedEnumValue( - normalized, - string(fantasyopenrouter.ReasoningEffortLow), - string(fantasyopenrouter.ReasoningEffortMedium), - string(fantasyopenrouter.ReasoningEffortHigh), - ) - case fantasyvercel.Name: - return chatutil.NormalizedEnumValue( - normalized, - string(fantasyvercel.ReasoningEffortNone), - string(fantasyvercel.ReasoningEffortMinimal), - string(fantasyvercel.ReasoningEffortLow), - string(fantasyvercel.ReasoningEffortMedium), - string(fantasyvercel.ReasoningEffortHigh), - string(fantasyvercel.ReasoningEffortXHigh), - ) - default: - return nil - } -} - // AnthropicThinkingDisplayFromChat normalizes chat-config thinking display // values for Anthropic and returns the canonical provider display value. func AnthropicThinkingDisplayFromChat(value *string) *fantasyanthropic.ThinkingDisplay { @@ -1176,7 +1125,6 @@ func anthropicProviderOptionsFromChatConfig( ) *fantasyanthropic.ProviderOptions { result := &fantasyanthropic.ProviderOptions{ SendReasoning: options.SendReasoning, - Effort: anthropicEffortFromChat(options.Effort), ThinkingDisplay: AnthropicThinkingDisplayFromChat(options.ThinkingDisplay), DisableParallelToolUse: options.DisableParallelToolUse, } @@ -1221,8 +1169,7 @@ func openAICompatProviderOptionsFromChatConfig( options *codersdk.ChatModelOpenAICompatProviderOptions, ) *fantasyopenaicompat.ProviderOptions { return &fantasyopenaicompat.ProviderOptions{ - User: chatutil.NormalizedStringPointer(options.User), - ReasoningEffort: chatopenai.ReasoningEffortFromChat(options.ReasoningEffort), + User: chatutil.NormalizedStringPointer(options.User), } } @@ -1242,7 +1189,6 @@ func openRouterProviderOptionsFromChatConfig( Enabled: options.Reasoning.Enabled, Exclude: options.Reasoning.Exclude, MaxTokens: options.Reasoning.MaxTokens, - Effort: openRouterReasoningEffortFromChat(options.Reasoning.Effort), } } if options.Provider != nil { @@ -1275,7 +1221,6 @@ func vercelProviderOptionsFromChatConfig( result.Reasoning = &fantasyvercel.ReasoningOptions{ Enabled: options.Reasoning.Enabled, MaxTokens: options.Reasoning.MaxTokens, - Effort: vercelReasoningEffortFromChat(options.Reasoning.Effort), Exclude: options.Reasoning.Exclude, } } @@ -1287,30 +1232,3 @@ func vercelProviderOptionsFromChatConfig( } return result } - -func anthropicEffortFromChat(value *string) *fantasyanthropic.Effort { - effort := ReasoningEffortFromChat(fantasyanthropic.Name, value) - if effort == nil { - return nil - } - valueCopy := fantasyanthropic.Effort(*effort) - return &valueCopy -} - -func openRouterReasoningEffortFromChat(value *string) *fantasyopenrouter.ReasoningEffort { - effort := ReasoningEffortFromChat(fantasyopenrouter.Name, value) - if effort == nil { - return nil - } - valueCopy := fantasyopenrouter.ReasoningEffort(*effort) - return &valueCopy -} - -func vercelReasoningEffortFromChat(value *string) *fantasyvercel.ReasoningEffort { - effort := ReasoningEffortFromChat(fantasyvercel.Name, value) - if effort == nil { - return nil - } - valueCopy := fantasyvercel.ReasoningEffort(*effort) - return &valueCopy -} diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 8a8904c8ef4f4..9961eb1f1cf92 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -349,81 +349,6 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) return fn(req) } -func TestReasoningEffortFromChat(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - provider string - input *string - want *string - }{ - { - name: "OpenAICaseInsensitive", - provider: "openai", - input: ptr.Ref(" HIGH "), - want: ptr.Ref(string(fantasyopenai.ReasoningEffortHigh)), - }, - { - name: "OpenAIXHighEffort", - provider: "openai", - input: ptr.Ref("xhigh"), - want: ptr.Ref(string(fantasyopenai.ReasoningEffortXHigh)), - }, - { - name: "AnthropicEffort", - provider: "anthropic", - input: ptr.Ref("max"), - want: ptr.Ref(string(fantasyanthropic.EffortMax)), - }, - { - name: "AnthropicXHighEffort", - provider: "anthropic", - input: ptr.Ref("xhigh"), - want: ptr.Ref(string(fantasyanthropic.EffortXHigh)), - }, - { - name: "OpenRouterEffort", - provider: "openrouter", - input: ptr.Ref("medium"), - want: ptr.Ref(string(fantasyopenrouter.ReasoningEffortMedium)), - }, - { - name: "VercelEffort", - provider: "vercel", - input: ptr.Ref("xhigh"), - want: ptr.Ref(string(fantasyvercel.ReasoningEffortXHigh)), - }, - { - name: "InvalidEffortReturnsNil", - provider: "openai", - input: ptr.Ref("unknown"), - want: nil, - }, - { - name: "UnsupportedProviderReturnsNil", - provider: "bedrock", - input: ptr.Ref("high"), - want: nil, - }, - { - name: "NilInputReturnsNil", - provider: "openai", - input: nil, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := chatprovider.ReasoningEffortFromChat(tt.provider, tt.input) - require.Equal(t, tt.want, got) - }) - } -} - func TestAnthropicThinkingDisplayFromChat(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go new file mode 100644 index 0000000000000..869ec7e2df1a6 --- /dev/null +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -0,0 +1,145 @@ +package chatprovider + +import ( + "slices" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasyazure "charm.land/fantasy/providers/azure" + fantasybedrock "charm.land/fantasy/providers/bedrock" + fantasyopenai "charm.land/fantasy/providers/openai" + fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" + fantasyopenrouter "charm.land/fantasy/providers/openrouter" + fantasyvercel "charm.land/fantasy/providers/vercel" + + "github.com/coder/coder/v2/coderd/x/chatd/chatopenai" + "github.com/coder/coder/v2/codersdk" +) + +func reasoningEffortRank(value string) (int, bool) { + rank := slices.Index(codersdk.ChatModelReasoningEffortValues(), value) + return rank, rank >= 0 +} + +func IsValidReasoningEffort(value string) bool { + _, ok := reasoningEffortRank(value) + return ok +} + +// ReasoningEffortLessOrEqual reports whether a is lower than or equal +// to b on the global effort scale. Unknown values return false. +func ReasoningEffortLessOrEqual(a, b string) bool { + aRank, aOK := reasoningEffortRank(a) + bRank, bOK := reasoningEffortRank(b) + return aOK && bOK && aRank <= bRank +} + +// ResolveReasoningEffort computes the effective reasoning effort for a +// generation. The config default is clamped to the config max on the +// global scale. Returns nil when the model config has no reasoning +// effort configured or when the default is unknown. +func ResolveReasoningEffort( + config *codersdk.ChatModelReasoningEffortConfig, +) *string { + if config == nil || config.Default == nil { + return nil + } + + rank, ok := reasoningEffortRank(*config.Default) + if !ok { + return nil + } + if config.Max != nil { + maxRank, ok := reasoningEffortRank(*config.Max) + if !ok { + return nil + } + if rank > maxRank { + return config.Max + } + } + return config.Default +} + +func SelectableReasoningEfforts( + config *codersdk.ChatModelReasoningEffortConfig, +) []string { + if config == nil || config.Max == nil { + return nil + } + maxRank, ok := reasoningEffortRank(*config.Max) + if !ok { + return nil + } + values := codersdk.ChatModelReasoningEffortValues() + return values[:maxRank+1] +} + +func ApplyReasoningEffort( + model fantasy.LanguageModel, + options fantasy.ProviderOptions, + effort *string, +) fantasy.ProviderOptions { + if effort == nil || model == nil { + return options + } + if options == nil { + options = fantasy.ProviderOptions{} + } + + switch NormalizeProvider(model.Provider()) { + case fantasyopenai.Name, fantasyazure.Name: + providerEffort := fantasyopenai.ReasoningEffort(*effort) + switch opts := options[fantasyopenai.Name].(type) { + case *fantasyopenai.ResponsesProviderOptions: + opts.ReasoningEffort = &providerEffort + case *fantasyopenai.ProviderOptions: + opts.ReasoningEffort = &providerEffort + default: + if chatopenai.UsesResponsesOptions(model) { + options[fantasyopenai.Name] = &fantasyopenai.ResponsesProviderOptions{ + ReasoningEffort: &providerEffort, + } + return options + } + options[fantasyopenai.Name] = &fantasyopenai.ProviderOptions{ + ReasoningEffort: &providerEffort, + } + } + case fantasyanthropic.Name, fantasybedrock.Name: + providerEffort := fantasyanthropic.Effort(*effort) + providerOptions := ensureProviderOptions[fantasyanthropic.ProviderOptions](options, fantasyanthropic.Name) + providerOptions.Effort = &providerEffort + case fantasyopenaicompat.Name: + providerEffort := fantasyopenai.ReasoningEffort(*effort) + providerOptions := ensureProviderOptions[fantasyopenaicompat.ProviderOptions](options, fantasyopenaicompat.Name) + providerOptions.ReasoningEffort = &providerEffort + case fantasyopenrouter.Name: + providerEffort := fantasyopenrouter.ReasoningEffort(*effort) + providerOptions := ensureProviderOptions[fantasyopenrouter.ProviderOptions](options, fantasyopenrouter.Name) + if providerOptions.Reasoning == nil { + providerOptions.Reasoning = &fantasyopenrouter.ReasoningOptions{} + } + providerOptions.Reasoning.Effort = &providerEffort + case fantasyvercel.Name: + providerEffort := fantasyvercel.ReasoningEffort(*effort) + providerOptions := ensureProviderOptions[fantasyvercel.ProviderOptions](options, fantasyvercel.Name) + if providerOptions.Reasoning == nil { + providerOptions.Reasoning = &fantasyvercel.ReasoningOptions{} + } + providerOptions.Reasoning.Effort = &providerEffort + } + return options +} + +func ensureProviderOptions[T any, PT interface { + *T + fantasy.ProviderOptionsData +}](options fantasy.ProviderOptions, name string) PT { + providerOptions, _ := options[name].(PT) + if providerOptions == nil { + providerOptions = PT(new(T)) + options[name] = providerOptions + } + return providerOptions +} diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_test.go new file mode 100644 index 0000000000000..9e056b63891c0 --- /dev/null +++ b/coderd/x/chatd/chatprovider/reasoningeffort_test.go @@ -0,0 +1,238 @@ +package chatprovider_test + +import ( + "testing" + + "charm.land/fantasy" + fantasyanthropic "charm.land/fantasy/providers/anthropic" + fantasyopenai "charm.land/fantasy/providers/openai" + fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" + fantasyopenrouter "charm.land/fantasy/providers/openrouter" + fantasyvercel "charm.land/fantasy/providers/vercel" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" +) + +func TestResolveReasoningEffort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *codersdk.ChatModelReasoningEffortConfig + want *string + }{ + {name: "NilConfig"}, + {name: "DefaultUsed", config: effortConfig("medium", "high"), want: ptr.Ref("medium")}, + {name: "DefaultClampedToMax", config: effortConfig("xhigh", "medium"), want: ptr.Ref("medium")}, + {name: "InvalidDefaultReturnsNil", config: effortConfig(" HIGH ", "high")}, + {name: "InvalidMaxReturnsNil", config: effortConfig("high", " HIGH ")}, + {name: "EmptyConfigReturnsNil", config: &codersdk.ChatModelReasoningEffortConfig{}}, + {name: "MaxSupported", config: effortConfig("max", "max"), want: ptr.Ref("max")}, + {name: "NoneSupported", config: effortConfig("none", "xhigh"), want: ptr.Ref("none")}, + {name: "MaxOnlyConfigReturnsNil", config: effortConfig("", "medium")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := chatprovider.ResolveReasoningEffort(tt.config) + if tt.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *tt.want, *got) + }) + } +} + +func TestSelectableReasoningEfforts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *codersdk.ChatModelReasoningEffortConfig + want []string + }{ + {name: "NilConfig"}, + {name: "NoMax", config: effortConfig("medium", "")}, + {name: "UnknownMax", config: effortConfig("medium", " HIGH ")}, + {name: "ThroughMedium", config: effortConfig("low", "medium"), want: []string{"none", "minimal", "low", "medium"}}, + {name: "ThroughMax", config: effortConfig("medium", "max"), want: []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, chatprovider.SelectableReasoningEfforts(tt.config)) + }) + } +} + +func TestApplyReasoningEffort(t *testing.T) { + t.Parallel() + + t.Run("CreatesOpenAIResponsesEntry", func(t *testing.T) { + t.Parallel() + + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, ptr.Ref("high")) + providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", got[fantasyopenai.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) + + t.Run("PreservesOpenAIResponsesEntry", func(t *testing.T) { + t.Parallel() + + options := fantasy.ProviderOptions{ + fantasyopenai.Name: &fantasyopenai.ResponsesProviderOptions{ + Instructions: ptr.Ref("answer briefly"), + Store: ptr.Ref(true), + }, + } + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, ptr.Ref("high")) + providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", got[fantasyopenai.Name]) + require.Same(t, options[fantasyopenai.Name], providerOptions) + require.Equal(t, "answer briefly", *providerOptions.Instructions) + require.True(t, *providerOptions.Store) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) + + t.Run("PreservesOpenAILegacyEntry", func(t *testing.T) { + t.Parallel() + + options := fantasy.ProviderOptions{ + fantasyopenai.Name: &fantasyopenai.ProviderOptions{ + User: ptr.Ref("user"), + ParallelToolCalls: ptr.Ref(true), + }, + } + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, ptr.Ref("high")) + providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenai.Name]) + require.Same(t, options[fantasyopenai.Name], providerOptions) + require.Equal(t, "user", *providerOptions.User) + require.True(t, *providerOptions.ParallelToolCalls) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }) + + tests := []struct { + name string + provider string + options fantasy.ProviderOptions + assert func(*testing.T, fantasy.ProviderOptions) + }{ + { + name: "CreatesAnthropicEntry", + provider: fantasyanthropic.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) + require.True(t, ok, "%T", got[fantasyanthropic.Name]) + require.NotNil(t, providerOptions.Effort) + require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) + }, + }, + { + name: "PreservesAnthropicEntry", + provider: fantasyanthropic.Name, + options: fantasy.ProviderOptions{fantasyanthropic.Name: &fantasyanthropic.ProviderOptions{SendReasoning: ptr.Ref(true)}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) + require.True(t, *providerOptions.SendReasoning) + require.Equal(t, fantasyanthropic.EffortHigh, *providerOptions.Effort) + }, + }, + { + name: "CreatesOpenAICompatEntry", + provider: fantasyopenaicompat.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenaicompat.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }, + }, + { + name: "PreservesOpenAICompatEntry", + provider: fantasyopenaicompat.Name, + options: fantasy.ProviderOptions{fantasyopenaicompat.Name: &fantasyopenaicompat.ProviderOptions{User: ptr.Ref("user")}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasyopenaicompat.Name].(*fantasyopenaicompat.ProviderOptions) + require.Equal(t, "user", *providerOptions.User) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + }, + }, + { + name: "CreatesVercelEntry", + provider: fantasyvercel.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyvercel.Name].(*fantasyvercel.ProviderOptions) + require.True(t, ok, "%T", got[fantasyvercel.Name]) + require.NotNil(t, providerOptions.Reasoning) + require.NotNil(t, providerOptions.Reasoning.Effort) + require.Equal(t, fantasyvercel.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + { + name: "PreservesVercelNestedEntry", + provider: fantasyvercel.Name, + options: fantasy.ProviderOptions{fantasyvercel.Name: &fantasyvercel.ProviderOptions{Reasoning: &fantasyvercel.ReasoningOptions{Enabled: ptr.Ref(true), MaxTokens: ptr.Ref(int64(1024))}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions := got[fantasyvercel.Name].(*fantasyvercel.ProviderOptions) + require.True(t, *providerOptions.Reasoning.Enabled) + require.Equal(t, int64(1024), *providerOptions.Reasoning.MaxTokens) + require.Equal(t, fantasyvercel.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + { + name: "CreatesOpenRouterEntry", + provider: fantasyopenrouter.Name, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenrouter.Name]) + require.NotNil(t, providerOptions.Reasoning) + require.NotNil(t, providerOptions.Reasoning.Effort) + require.Equal(t, fantasyopenrouter.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + { + name: "PreservesOpenRouterNestedEntry", + provider: fantasyopenrouter.Name, + options: fantasy.ProviderOptions{fantasyopenrouter.Name: &fantasyopenrouter.ProviderOptions{Reasoning: &fantasyopenrouter.ReasoningOptions{Enabled: ptr.Ref(true), MaxTokens: ptr.Ref(int64(1024))}}}, + assert: func(t *testing.T, got fantasy.ProviderOptions) { + providerOptions, ok := got[fantasyopenrouter.Name].(*fantasyopenrouter.ProviderOptions) + require.True(t, ok, "%T", got[fantasyopenrouter.Name]) + require.True(t, *providerOptions.Reasoning.Enabled) + require.Equal(t, int64(1024), *providerOptions.Reasoning.MaxTokens) + require.NotNil(t, providerOptions.Reasoning.Effort) + require.Equal(t, fantasyopenrouter.ReasoningEffortHigh, *providerOptions.Reasoning.Effort) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, ptr.Ref("high")) + tt.assert(t, got) + }) + } +} + +func effortConfig(defaultEffort, maxEffort string) *codersdk.ChatModelReasoningEffortConfig { + cfg := &codersdk.ChatModelReasoningEffortConfig{} + if defaultEffort != "" { + cfg.Default = ptr.Ref(defaultEffort) + } + if maxEffort != "" { + cfg.Max = ptr.Ref(maxEffort) + } + return cfg +} diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 0a80f2de16a59..d408001bd6a62 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -532,7 +532,9 @@ func (server *Server) prepareGeneration( } } + reasoningEffort := chatprovider.ResolveReasoningEffort(callConfig.ReasoningEffort) providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions) + providerOptions = chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort) activeToolNames := activeToolNamesForTurn(tools, currentPlanMode, chat.ParentChatID, approvedPlanMCPConfigIDs) if isExploreSubagent { diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 91f37a874632b..13be09d0882eb 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -2,6 +2,7 @@ package chatd import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -85,10 +86,11 @@ var preferredTitleModels = []struct { } type shortTextCandidate struct { - provider string - model string - route aiGatewayModelRoute - lm fantasy.LanguageModel + provider string + model string + route aiGatewayModelRoute + lm fantasy.LanguageModel + providerOptions fantasy.ProviderOptions } func selectPreferredConfiguredShortTextModelConfig( @@ -176,7 +178,7 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat) chat, messages, string(route.Provider.Type), - modelConfig.Model, + modelConfig, model, route, modelOpts, @@ -206,7 +208,7 @@ func (p *Server) maybeGenerateChatTitle( chat database.Chat, messages []database.ChatMessage, fallbackProvider string, - fallbackModelName string, + fallbackConfig database.ChatModelConfig, fallbackModel fantasy.LanguageModel, fallbackRoute aiGatewayModelRoute, modelOpts modelBuildOptions, @@ -247,17 +249,19 @@ func (p *Server) maybeGenerateChatTitle( var candidate shortTextCandidate if overrideSet { candidate = shortTextCandidate{ - provider: string(overrideRoute.Provider.Type), - model: overrideConfig.Model, - route: overrideRoute, - lm: overrideModel, + provider: string(overrideRoute.Provider.Type), + model: overrideConfig.Model, + route: overrideRoute, + lm: overrideModel, + providerOptions: p.titleGenerationProviderOptions(ctx, overrideModel, overrideConfig), } } else { candidate = shortTextCandidate{ - provider: fallbackProvider, - model: fallbackModelName, - route: fallbackRoute, - lm: fallbackModel, + provider: fallbackProvider, + model: fallbackConfig.Model, + route: fallbackRoute, + lm: fallbackModel, + providerOptions: p.titleGenerationProviderOptions(ctx, fallbackModel, fallbackConfig), } } @@ -299,7 +303,7 @@ func (p *Server) maybeGenerateChatTitle( ) } - title, err := generateTitle(candidateCtx, candidateModel, input) + title, err := generateTitle(candidateCtx, candidateModel, candidate.providerOptions, input) finishDebugRun(err) if err != nil { if overrideSet { @@ -338,6 +342,28 @@ func (p *Server) maybeGenerateChatTitle( p.publishChatPubsubEvent(chat, codersdk.ChatWatchEventKindTitleChange, nil) } +func (p *Server) titleGenerationProviderOptions( + ctx context.Context, + model fantasy.LanguageModel, + config database.ChatModelConfig, +) fantasy.ProviderOptions { + callConfig := codersdk.ChatModelCallConfig{} + if len(config.Options) > 0 { + if err := json.Unmarshal(config.Options, &callConfig); err != nil { + p.logger.Debug(ctx, "failed to parse title generation model call config", + slog.F("model_config_id", config.ID), + slog.Error(err), + ) + } + } + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions) + return chatprovider.ApplyReasoningEffort( + model, + providerOptions, + chatprovider.ResolveReasoningEffort(callConfig.ReasoningEffort), + ) +} + func (p *Server) newQuickgenDebugModel( ctx context.Context, chat database.Chat, @@ -461,9 +487,10 @@ func (p *Server) prepareQuickgenDebugCandidate( func generateTitle( ctx context.Context, model fantasy.LanguageModel, + providerOptions fantasy.ProviderOptions, input string, ) (string, error) { - title, err := generateStructuredTitle(ctx, model, titleGenerationPrompt, input) + title, err := generateStructuredTitle(ctx, model, providerOptions, titleGenerationPrompt, input) if err != nil { return "", err } @@ -473,12 +500,14 @@ func generateTitle( func generateStructuredTitle( ctx context.Context, model fantasy.LanguageModel, + providerOptions fantasy.ProviderOptions, systemPrompt string, userInput string, ) (string, error) { title, _, err := generateStructuredTitleWithUsage( ctx, model, + providerOptions, systemPrompt, userInput, ) @@ -491,6 +520,7 @@ func generateStructuredTitle( func generateStructuredTitleWithUsage( ctx context.Context, model fantasy.LanguageModel, + providerOptions fantasy.ProviderOptions, systemPrompt string, userInput string, ) (string, fantasy.Usage, error) { @@ -524,6 +554,7 @@ func generateStructuredTitleWithUsage( SchemaDescription: "Propose a short chat title.", MaxOutputTokens: &maxOutputTokens, Temperature: ptr.Ref(quickgenTemperature), + ProviderOptions: providerOptions, }) return genErr }, nil) @@ -803,6 +834,7 @@ func generateManualTitle( ctx context.Context, messages []database.ChatMessage, fallbackModel fantasy.LanguageModel, + providerOptions fantasy.ProviderOptions, ) (string, fantasy.Usage, error) { turns := extractManualTitleTurns(messages) selected := selectManualTitleTurnIndexes(turns) @@ -833,6 +865,7 @@ func generateManualTitle( title, usage, err := generateStructuredTitleWithUsage( titleCtx, fallbackModel, + providerOptions, systemPrompt, userInput, ) diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index f2cfef1a2455b..014d9ba43dbe1 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -10,13 +10,16 @@ import ( "time" "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" fantasyopenaicompat "charm.land/fantasy/providers/openaicompat" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattest" @@ -423,7 +426,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { chat, []database.ChatMessage{message}, "openai", - "test-model", + database.ChatModelConfig{Model: "test-model"}, model, aiGatewayModelRoute{}, modelBuildOptions{}, @@ -446,6 +449,61 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) { require.Equal(t, wantTitle, gotTitle) } +func TestMaybeGenerateChatTitleAppliesModelConfigReasoningEffort(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + chat, messages := titleOverrideTestChatAndMessages(t) + reasoningEffort := "high" + maxReasoningEffort := "max" + modelConfigRaw, err := json.Marshal(codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: &reasoningEffort, + Max: &maxReasoningEffort, + }, + }) + require.NoError(t, err) + + model := &chattest.FakeModel{ + ProviderName: fantasyopenai.Name, + ModelName: "gpt-4o-mini", + GenerateObjectFn: func(_ context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + require.NotNil(t, call.MaxOutputTokens) + require.Equal(t, int64(256), *call.MaxOutputTokens) + providerOptions, ok := call.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", call.ProviderOptions[fantasyopenai.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortHigh, *providerOptions.ReasoningEffort) + return &fantasy.ObjectResponse{ + Object: map[string]any{"title": "Reasoning title"}, + }, nil + }, + } + + db := dbmock.NewMockStore(gomock.NewController(t)) + db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil) + db.EXPECT().UpdateChatTitleByID(gomock.Any(), database.UpdateChatTitleByIDParams{ + ID: chat.ID, + Title: "Reasoning title", + }).Return(chatWithTitle(chat, "Reasoning title"), nil) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + server := titleOverrideTestServer(db, logger) + server.maybeGenerateChatTitle( + ctx, + chat, + messages, + fantasyopenai.Name, + database.ChatModelConfig{Model: "gpt-4o-mini", Options: modelConfigRaw}, + model, + aiGatewayModelRoute{}, + modelBuildOptions{}, + &generatedChatTitle{}, + logger, + nil, + ) +} + func Test_titleGenerationPrompt_UsesSlimRules(t *testing.T) { t.Parallel() @@ -489,6 +547,7 @@ func Test_generateManualTitle_UsesTimeout(t *testing.T) { context.Background(), messages, model, + nil, ) require.NoError(t, err) require.Equal(t, "Refresh title", title) @@ -525,6 +584,7 @@ func Test_generateManualTitle_TruncatesFirstUserInput(t *testing.T) { context.Background(), messages, model, + nil, ) require.NoError(t, err) } @@ -558,6 +618,7 @@ func Test_generateManualTitle_ReturnsUsageForEmptyNormalizedTitle(t *testing.T) context.Background(), messages, model, + nil, ) require.ErrorContains(t, err, "generated title was empty") require.Equal(t, int64(11), usage.InputTokens) @@ -663,6 +724,7 @@ func TestGenerateStructuredTitleWithUsage_OpenAICompatibleRequiredToolChoice(t * title, _, err := generateStructuredTitleWithUsage( t.Context(), model, + nil, titleGenerationPrompt, "summarize failed workspace build logs", ) diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 83fd36d9ce463..b91a0f5ccec69 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -63,7 +63,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideUnset(t *testing.T) { chat, messages, "openai", - "fallback-chat-model", + database.ChatModelConfig{Model: "fallback-chat-model"}, fallbackModel, aiGatewayModelRoute{}, modelBuildOptions{}, @@ -112,7 +112,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideReadDBError(t *testing.T) chat, messages, "openai", - "fallback-chat-model", + database.ChatModelConfig{Model: "fallback-chat-model"}, fallbackModel, aiGatewayModelRoute{}, modelBuildOptions{}, @@ -160,7 +160,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideMalformedFallsThrough(t * chat, messages, "openai", - "fallback-chat-model", + database.ChatModelConfig{Model: "fallback-chat-model"}, fallbackModel, aiGatewayModelRoute{}, modelBuildOptions{}, @@ -233,7 +233,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) { chat, messages, "openai", - "fallback-chat-model", + database.ChatModelConfig{Model: "fallback-chat-model"}, fallbackModel, aiGatewayModelRoute{}, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, @@ -274,7 +274,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUnusableSkips(t *testi chat, messages, "openai", - "fallback-chat-model", + database.ChatModelConfig{Model: "fallback-chat-model"}, fallbackModel, aiGatewayModelRoute{}, modelBuildOptions{}, @@ -327,7 +327,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback( chat, messages, "openai", - "fallback-chat-model", + database.ChatModelConfig{Model: "fallback-chat-model"}, fallbackModel, aiGatewayModelRoute{}, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, diff --git a/codersdk/chats.go b/codersdk/chats.go index d9330a2f26ddf..35b3174db8cfb 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -10,6 +10,7 @@ import ( "mime" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -1262,8 +1263,11 @@ type ChatModelConfig struct { ContextLimit int64 `json:"context_limit"` CompressionThreshold int32 `json:"compression_threshold"` ModelConfig *ChatModelCallConfig `json:"model_config,omitempty"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` + // ReasoningEfforts lists selectable reasoning effort values through + // the model's configured maximum. + ReasoningEfforts []string `json:"reasoning_efforts,omitempty"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` } // ChatModelProviderOptions contains typed provider-specific options. @@ -1289,7 +1293,6 @@ type ChatModelOpenAIProviderOptions struct { MaxToolCalls *int64 `json:"max_tool_calls,omitempty" description:"Maximum number of tool calls per response"` ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" description:"Whether the model may make multiple tool calls in parallel"` User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"` - ReasoningEffort *string `json:"reasoning_effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"` ReasoningSummary *string `json:"reasoning_summary,omitempty" description:"Controls whether reasoning tokens are summarized in the response" enum:"auto,concise,detailed"` MaxCompletionTokens *int64 `json:"max_completion_tokens,omitempty" description:"Upper bound on tokens the model may generate"` TextVerbosity *string `json:"text_verbosity,omitempty" description:"Controls the verbosity of the text response" enum:"low,medium,high"` @@ -1315,7 +1318,6 @@ type ChatModelAnthropicThinkingOptions struct { type ChatModelAnthropicProviderOptions struct { SendReasoning *bool `json:"send_reasoning,omitempty" description:"Whether to include reasoning content in the response"` Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty" description:"Configuration for extended thinking"` - Effort *string `json:"effort,omitempty" label:"Reasoning Effort" description:"Controls the level of reasoning effort" enum:"low,medium,high,xhigh,max"` ThinkingDisplay *string `json:"thinking_display,omitempty" label:"Thinking Display" description:"Controls how Anthropic returns thinking content" enum:"summarized,omitted"` DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"` WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"` @@ -1346,17 +1348,15 @@ type ChatModelGoogleProviderOptions struct { // ChatModelOpenAICompatProviderOptions configures OpenAI-compatible behavior. type ChatModelOpenAICompatProviderOptions struct { - User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"` - ReasoningEffort *string `json:"reasoning_effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"` + User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"` } // ChatModelReasoningOptions configures reasoning behavior for model // providers that support it. type ChatModelReasoningOptions struct { - Enabled *bool `json:"enabled,omitempty" description:"Whether reasoning is enabled"` - Exclude *bool `json:"exclude,omitempty" description:"Whether to exclude reasoning content from the response"` - MaxTokens *int64 `json:"max_tokens,omitempty" description:"Maximum number of tokens for reasoning output"` - Effort *string `json:"effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"` + Enabled *bool `json:"enabled,omitempty" description:"Whether reasoning is enabled"` + Exclude *bool `json:"exclude,omitempty" description:"Whether to exclude reasoning content from the response"` + MaxTokens *int64 `json:"max_tokens,omitempty" description:"Maximum number of tokens for reasoning output"` } // ChatModelOpenRouterProvider configures OpenRouter routing preferences. @@ -1409,16 +1409,51 @@ type ModelCostConfig struct { CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty" description:"Cache write or cache creation token price in USD per 1M tokens"` } +// Reasoning effort levels, ordered low to high for clamping and comparison. +const ( + ChatModelReasoningEffortNone = "none" + ChatModelReasoningEffortMinimal = "minimal" + ChatModelReasoningEffortLow = "low" + ChatModelReasoningEffortMedium = "medium" + ChatModelReasoningEffortHigh = "high" + ChatModelReasoningEffortXHigh = "xhigh" + ChatModelReasoningEffortMax = "max" +) + +var chatModelReasoningEffortValues = []string{ + ChatModelReasoningEffortNone, + ChatModelReasoningEffortMinimal, + ChatModelReasoningEffortLow, + ChatModelReasoningEffortMedium, + ChatModelReasoningEffortHigh, + ChatModelReasoningEffortXHigh, + ChatModelReasoningEffortMax, +} + +// ChatModelReasoningEffortValues returns the global reasoning effort scale. +func ChatModelReasoningEffortValues() []string { + return slices.Clone(chatModelReasoningEffortValues) +} + +// ChatModelReasoningEffortConfig configures per-model reasoning effort +// bounds. When configured, Default and Max must both be provided before +// storing. +type ChatModelReasoningEffortConfig struct { + Default *string `json:"default,omitempty" label:"Default Reasoning Effort" description:"Reasoning effort used when the user has not selected one" enum:"none,minimal,low,medium,high,xhigh,max"` + Max *string `json:"max,omitempty" label:"Max Reasoning Effort" description:"Maximum reasoning effort the user may select" enum:"none,minimal,low,medium,high,xhigh,max"` +} + // ChatModelCallConfig configures per-call model behavior defaults. type ChatModelCallConfig struct { - MaxOutputTokens *int64 `json:"max_output_tokens,omitempty" description:"Upper bound on tokens the model may generate"` - Temperature *float64 `json:"temperature,omitempty" description:"Sampling temperature between 0 and 2"` - TopP *float64 `json:"top_p,omitempty" description:"Nucleus sampling probability cutoff"` - TopK *int64 `json:"top_k,omitempty" description:"Number of highest-probability tokens to keep for sampling"` - PresencePenalty *float64 `json:"presence_penalty,omitempty" description:"Penalty for tokens that have already appeared in the output"` - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" description:"Penalty for tokens based on their frequency in the output"` - Cost *ModelCostConfig `json:"cost,omitempty" description:"Optional pricing metadata for this model"` - ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty" description:"Provider-specific option overrides"` + MaxOutputTokens *int64 `json:"max_output_tokens,omitempty" description:"Upper bound on tokens the model may generate"` + Temperature *float64 `json:"temperature,omitempty" description:"Sampling temperature between 0 and 2"` + TopP *float64 `json:"top_p,omitempty" description:"Nucleus sampling probability cutoff"` + TopK *int64 `json:"top_k,omitempty" description:"Number of highest-probability tokens to keep for sampling"` + PresencePenalty *float64 `json:"presence_penalty,omitempty" description:"Penalty for tokens that have already appeared in the output"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" description:"Penalty for tokens based on their frequency in the output"` + Cost *ModelCostConfig `json:"cost,omitempty" description:"Optional pricing metadata for this model"` + ReasoningEffort *ChatModelReasoningEffortConfig `json:"reasoning_effort,omitempty" description:"Default and max reasoning effort for the model"` + ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty" description:"Provider-specific option overrides"` } // UnmarshalJSON accepts both the current nested cost object and the previous diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index a21b5ae7e20af..bfb6ed8f1504d 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -23,13 +23,11 @@ func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testin t.Parallel() sendReasoning := true - effort := "high" thinkingDisplay := "summarized" raw, err := json.Marshal(codersdk.ChatModelProviderOptions{ Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ SendReasoning: &sendReasoning, - Effort: &effort, ThinkingDisplay: &thinkingDisplay, }, }) @@ -37,7 +35,6 @@ func TestChatModelProviderOptions_MarshalJSON_UsesPlainProviderPayload(t *testin require.NotContains(t, string(raw), `"type":"anthropic.options"`) require.NotContains(t, string(raw), `"data":`) require.Contains(t, string(raw), `"send_reasoning":true`) - require.Contains(t, string(raw), `"effort":"high"`) require.Contains(t, string(raw), `"thinking_display":"summarized"`) } @@ -47,7 +44,6 @@ func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *t raw := []byte(`{ "anthropic": { "send_reasoning": true, - "effort": "high", "thinking_display": "summarized" } }`) @@ -58,12 +54,6 @@ func TestChatModelProviderOptions_UnmarshalJSON_ParsesPlainProviderPayloads(t *t require.NotNil(t, decoded.Anthropic) require.NotNil(t, decoded.Anthropic.SendReasoning) require.True(t, *decoded.Anthropic.SendReasoning) - require.NotNil(t, decoded.Anthropic.Effort) - require.Equal( - t, - "high", - *decoded.Anthropic.Effort, - ) require.NotNil(t, decoded.Anthropic.ThinkingDisplay) require.Equal(t, "summarized", *decoded.Anthropic.ThinkingDisplay) } @@ -288,6 +278,18 @@ func TestChatMessagePart_StripInternal(t *testing.T) { }) } +func TestChatModelReasoningEffortConfigEnumTags(t *testing.T) { + t.Parallel() + + want := strings.Join(codersdk.ChatModelReasoningEffortValues(), ",") + typ := reflect.TypeOf(codersdk.ChatModelReasoningEffortConfig{}) + for _, fieldName := range []string{"Default", "Max"} { + field, ok := typ.FieldByName(fieldName) + require.True(t, ok) + require.Equal(t, want, field.Tag.Get("enum")) + } +} + // TestChatMessagePartVariantTags validates the `variants` struct tags // on ChatMessagePart fields. Every field must either declare variant // membership or be explicitly excluded, and every known part type diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 7c9beae37eb58..4c7522d6b5d1d 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -13,41 +13,41 @@ We track the following resources: -| Resource | | | -|-----------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_heartbeat_atfalse
nametrue
secret_prefixtrue
| -| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
icontrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| -| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| -| AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| -| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| AuditableGroupAIBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| -| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| -| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| -| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| -| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| -| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| -| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| -| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| -| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| -| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
default_org_member_rolestrue
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| -| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| -| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| -| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| -| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| -| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| -| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| -| User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| -| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| -| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| -| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| +| Resource | | | +|-----------------------------------------------------------------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_heartbeat_atfalse
nametrue
secret_prefixtrue
| +| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
icontrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| +| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| +| AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| +| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| AuditableGroupAIBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| +| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_reasoning_effortfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| +| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| +| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| +| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| +| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| +| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| +| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| +| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| +| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| +| Organization
| |
FieldTracked
created_atfalse
default_org_member_rolestrue
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| +| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| +| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| +| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| +| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| +| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| +| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| +| User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| +| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| +| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 2ed6e40acbad2..5870b5f226238 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -457,6 +457,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "parent_chat_id": ActionIgnore, // Immutable after creation. "root_chat_id": ActionIgnore, // Immutable after creation. "last_model_config_id": ActionIgnore, // Churns every message. + "last_reasoning_effort": ActionIgnore, // Churns every message. "archived": ActionTrack, "last_error": ActionIgnore, // Internal. "last_turn_summary": ActionIgnore, // Internal cached display text. diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index fb3bafadcedef..e5c723027ca5d 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -80,6 +80,26 @@ "description": "Cache write or cache creation token price in USD per 1M tokens", "required": false, "input_type": "input" + }, + { + "json_name": "reasoning_effort.default", + "go_name": "ReasoningEffort.Default", + "type": "string", + "description": "Reasoning effort used when the user has not selected one", + "label": "Default Reasoning Effort", + "required": false, + "enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + "input_type": "select" + }, + { + "json_name": "reasoning_effort.max", + "go_name": "ReasoningEffort.Max", + "type": "string", + "description": "Maximum reasoning effort the user may select", + "label": "Max Reasoning Effort", + "required": false, + "enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + "input_type": "select" } ] }, @@ -102,16 +122,6 @@ "required": false, "input_type": "input" }, - { - "json_name": "effort", - "go_name": "Effort", - "type": "string", - "description": "Controls the level of reasoning effort", - "label": "Reasoning Effort", - "required": false, - "enum": ["low", "medium", "high", "xhigh", "max"], - "input_type": "select" - }, { "json_name": "thinking_display", "go_name": "ThinkingDisplay", @@ -288,15 +298,6 @@ "input_type": "input", "hidden": true }, - { - "json_name": "reasoning_effort", - "go_name": "ReasoningEffort", - "type": "string", - "description": "Controls the level of reasoning effort", - "required": false, - "enum": ["none", "minimal", "low", "medium", "high", "xhigh"], - "input_type": "select" - }, { "json_name": "reasoning_summary", "go_name": "ReasoningSummary", @@ -433,15 +434,6 @@ "required": false, "input_type": "input", "hidden": true - }, - { - "json_name": "reasoning_effort", - "go_name": "ReasoningEffort", - "type": "string", - "description": "Controls the level of reasoning effort", - "required": false, - "enum": ["none", "minimal", "low", "medium", "high", "xhigh"], - "input_type": "select" } ] }, @@ -471,15 +463,6 @@ "required": false, "input_type": "input" }, - { - "json_name": "reasoning.effort", - "go_name": "Reasoning.Effort", - "type": "string", - "description": "Controls the level of reasoning effort", - "required": false, - "enum": ["none", "minimal", "low", "medium", "high", "xhigh"], - "input_type": "select" - }, { "json_name": "extra_body", "go_name": "ExtraBody", @@ -570,15 +553,6 @@ "required": false, "input_type": "input" }, - { - "json_name": "reasoning.effort", - "go_name": "Reasoning.Effort", - "type": "string", - "description": "Controls the level of reasoning effort", - "required": false, - "enum": ["none", "minimal", "low", "medium", "high", "xhigh"], - "input_type": "select" - }, { "json_name": "providerOptions", "go_name": "ProviderOptions", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2ec8b15a07990..b72f077f27318 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2461,7 +2461,6 @@ export interface ChatModel { export interface ChatModelAnthropicProviderOptions { readonly send_reasoning?: boolean; readonly thinking?: ChatModelAnthropicThinkingOptions; - readonly effort?: string; readonly thinking_display?: string; readonly disable_parallel_tool_use?: boolean; readonly web_search_enabled?: boolean; @@ -2489,6 +2488,7 @@ export interface ChatModelCallConfig { readonly presence_penalty?: number; readonly frequency_penalty?: number; readonly cost?: ModelCostConfig; + readonly reasoning_effort?: ChatModelReasoningEffortConfig; readonly provider_options?: ChatModelProviderOptions; } @@ -2506,6 +2506,11 @@ export interface ChatModelConfig { readonly context_limit: number; readonly compression_threshold: number; readonly model_config?: ChatModelCallConfig; + /** + * ReasoningEfforts lists selectable reasoning effort values through + * the model's configured maximum. + */ + readonly reasoning_efforts?: readonly string[]; readonly created_at: string; readonly updated_at: string; } @@ -2546,7 +2551,6 @@ export interface ChatModelGoogleThinkingConfig { */ export interface ChatModelOpenAICompatProviderOptions { readonly user?: string; - readonly reasoning_effort?: string; } // From codersdk/chats.go @@ -2562,7 +2566,6 @@ export interface ChatModelOpenAIProviderOptions { readonly max_tool_calls?: number; readonly parallel_tool_calls?: boolean; readonly user?: string; - readonly reasoning_effort?: string; readonly reasoning_summary?: string; readonly max_completion_tokens?: number; readonly text_verbosity?: string; @@ -2671,6 +2674,59 @@ export type ChatModelProviderUnavailableReason = export const ChatModelProviderUnavailableReasons: ChatModelProviderUnavailableReason[] = ["fetch_failed", "missing_api_key", "user_api_key_required"]; +// From codersdk/chats.go +/** + * ChatModelReasoningEffortConfig configures per-model reasoning effort + * bounds. When configured, Default and Max must both be provided before + * storing. + */ +export interface ChatModelReasoningEffortConfig { + readonly default?: string; + readonly max?: string; +} + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortHigh = "high"; + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortLow = "low"; + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortMax = "max"; + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortMedium = "medium"; + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortMinimal = "minimal"; + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortNone = "none"; + +// From codersdk/chats.go +/** + * Reasoning effort levels, ordered low to high for clamping and comparison. + */ +export const ChatModelReasoningEffortXHigh = "xhigh"; + // From codersdk/chats.go /** * ChatModelReasoningOptions configures reasoning behavior for model @@ -2680,7 +2736,6 @@ export interface ChatModelReasoningOptions { readonly enabled?: boolean; readonly exclude?: boolean; readonly max_tokens?: number; - readonly effort?: string; } // From codersdk/chats.go diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts index 4dbc1cc39b3ec..e3ad0bd36c71b 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts @@ -284,7 +284,7 @@ describe("applyKnownModelDefaults", () => { expect(result.appliedFields).not.toContain("compressionThreshold"); }); - it("does not set OpenAI reasoning fields without catalog defaults", () => { + it("does not set reasoning effort fields without catalog defaults", () => { const result = applyDefaults({ values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), @@ -292,15 +292,19 @@ describe("applyKnownModelDefaults", () => { knownModel: requireKnownModel("openai", "gpt-5.4"), }); - expect(getPath(result.values, "config.openai.reasoningEffort")).toBe(""); + expect(getPath(result.values, "config.reasoningEffort.default")).toBe(""); + expect(getPath(result.values, "config.reasoningEffort.max")).toBe(""); expect(getPath(result.values, "config.openai.reasoningSummary")).toBe(""); - expect(result.appliedFields).not.toContain("config.openai.reasoningEffort"); + expect(result.appliedFields).not.toContain( + "config.reasoningEffort.default", + ); + expect(result.appliedFields).not.toContain("config.reasoningEffort.max"); expect(result.appliedFields).not.toContain( "config.openai.reasoningSummary", ); }); - it("sets OpenAI reasoning effort for reasoning-capable catalog entries", () => { + it("sets reasoning effort bounds for reasoning-capable catalog entries", () => { const result = applyDefaults({ values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), @@ -308,17 +312,19 @@ describe("applyKnownModelDefaults", () => { knownModel: requireKnownModel("openai", "gpt-5.5"), }); - expect(getPath(result.values, "config.openai.reasoningEffort")).toBe( + expect(getPath(result.values, "config.reasoningEffort.default")).toBe( "medium", ); + expect(getPath(result.values, "config.reasoningEffort.max")).toBe("medium"); expect(getPath(result.values, "config.openai.reasoningSummary")).toBe(""); - expect(result.appliedFields).toContain("config.openai.reasoningEffort"); + expect(result.appliedFields).toContain("config.reasoningEffort.default"); + expect(result.appliedFields).toContain("config.reasoningEffort.max"); expect(result.appliedFields).not.toContain( "config.openai.reasoningSummary", ); }); - it("sets Anthropic effort for extended-thinking catalog entries", () => { + it("sets reasoning effort bounds for Anthropic extended-thinking catalog entries", () => { const result = applyDefaults({ values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), @@ -326,8 +332,31 @@ describe("applyKnownModelDefaults", () => { knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); - expect(getPath(result.values, "config.anthropic.effort")).toBe("high"); - expect(result.appliedFields).toContain("config.anthropic.effort"); + expect(getPath(result.values, "config.reasoningEffort.default")).toBe( + "high", + ); + expect(getPath(result.values, "config.reasoningEffort.max")).toBe("high"); + expect(result.appliedFields).toContain("config.reasoningEffort.default"); + expect(result.appliedFields).toContain("config.reasoningEffort.max"); + }); + + it("sets reasoning effort for any provider with a catalog default", () => { + const result = applyDefaults({ + values: buildInitialModelFormValues(), + initialValues: buildInitialModelFormValues(), + provider: "google", + knownModel: customKnownModel({ + provider: "google", + reasoningEffort: "medium", + }), + }); + + expect(getPath(result.values, "config.reasoningEffort.default")).toBe( + "medium", + ); + expect(getPath(result.values, "config.reasoningEffort.max")).toBe("medium"); + expect(result.appliedFields).toContain("config.reasoningEffort.default"); + expect(result.appliedFields).toContain("config.reasoningEffort.max"); }); it.each([ @@ -347,8 +376,12 @@ describe("applyKnownModelDefaults", () => { expect(result.appliedFields).toContain( "config.anthropic.thinking.budgetTokens", ); - expect(getPath(result.values, "config.anthropic.effort")).toBe(""); - expect(result.appliedFields).not.toContain("config.anthropic.effort"); + expect(getPath(result.values, "config.reasoningEffort.default")).toBe(""); + expect(getPath(result.values, "config.reasoningEffort.max")).toBe(""); + expect(result.appliedFields).not.toContain( + "config.reasoningEffort.default", + ); + expect(result.appliedFields).not.toContain("config.reasoningEffort.max"); }); it("does not set Anthropic sendReasoning or thinking budget fields", () => { diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts index 550c9a0f5ed39..90a3368f11c44 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts @@ -35,11 +35,6 @@ const pricingModelFieldByName = { KnownModelCostField >; -const reasoningEffortPathByProvider: Record = { - openai: "config.openai.reasoningEffort", - anthropic: "config.anthropic.effort", -}; - const thinkingBudgetTokensPathByProvider: Record = { anthropic: "config.anthropic.thinking.budgetTokens", }; @@ -118,16 +113,17 @@ export const applyKnownModelDefaults = ({ } if (knownModel.reasoningEffort !== undefined) { - // The catalog uses a single `reasoningEffort` field, but each provider - // exposes it under a different form path: OpenAI as `reasoningEffort`, - // Anthropic as `effort`. Providers without a mapping skip this default. - const reasoningEffortPath = reasoningEffortPathByProvider[provider]; - if (reasoningEffortPath !== undefined) { + // The catalog carries a single curated effort value. Write both + // reasoning_effort bounds because the API requires default and max. + for (const path of [ + "config.reasoningEffort.default", + "config.reasoningEffort.max", + ]) { maybeApplyDefault({ appliedFields, initialValues, nextValues, - path: reasoningEffortPath, + path, value: knownModel.reasoningEffort, values, }); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts index bcd1a6b0d2b66..2d8bd15c534df 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts @@ -262,13 +262,28 @@ describe("extractModelConfigFormState", () => { "0.3", ); }); + + it("extracts reasoning effort bounds", () => { + const model: TypesGen.ChatModelConfig = { + ...baseChatModelConfig, + model_config: { + reasoning_effort: { + default: "medium", + max: "xhigh", + }, + }, + }; + const result = extractModelConfigFormState(model); + expect(deepGet(result, ["reasoningEffort", "default"])).toBe("medium"); + expect(deepGet(result, ["reasoningEffort", "max"])).toBe("xhigh"); + }); + it("extracts OpenAI provider options", () => { const model: TypesGen.ChatModelConfig = { ...baseChatModelConfig, model_config: { provider_options: { openai: { - reasoning_effort: "high", parallel_tool_calls: true, text_verbosity: "medium", service_tier: "auto", @@ -281,7 +296,6 @@ describe("extractModelConfigFormState", () => { }; const result = extractModelConfigFormState(model); const openai = result.openai as Record; - expect(openai.reasoningEffort).toBe("high"); expect(openai.parallelToolCalls).toBe("true"); expect(openai.textVerbosity).toBe("medium"); expect(openai.serviceTier).toBe("auto"); @@ -296,7 +310,6 @@ describe("extractModelConfigFormState", () => { model_config: { provider_options: { anthropic: { - effort: "high", thinking: { budget_tokens: 1024 }, send_reasoning: true, disable_parallel_tool_use: false, @@ -306,7 +319,6 @@ describe("extractModelConfigFormState", () => { }; const result = extractModelConfigFormState(model); const anthropic = result.anthropic as Record; - expect(anthropic.effort).toBe("high"); expect(deepGet(anthropic, ["thinking", "budgetTokens"])).toBe("1024"); expect(anthropic.sendReasoning).toBe("true"); expect(anthropic.disableParallelToolUse).toBe("false"); @@ -357,7 +369,6 @@ describe("extractModelConfigFormState", () => { model_config: { provider_options: { openaicompat: { - reasoning_effort: "low", user: "compat-user", }, }, @@ -365,7 +376,6 @@ describe("extractModelConfigFormState", () => { }; const result = extractModelConfigFormState(model); const openaicompat = result.openaicompat as Record; - expect(openaicompat.reasoningEffort).toBe("low"); expect(openaicompat.user).toBe("compat-user"); }); @@ -377,7 +387,6 @@ describe("extractModelConfigFormState", () => { openrouter: { reasoning: { enabled: true, - effort: "medium", max_tokens: 500, exclude: false, }, @@ -391,7 +400,6 @@ describe("extractModelConfigFormState", () => { const result = extractModelConfigFormState(model); const openrouter = result.openrouter as Record; expect(deepGet(openrouter, ["reasoning", "enabled"])).toBe("true"); - expect(deepGet(openrouter, ["reasoning", "effort"])).toBe("medium"); expect(deepGet(openrouter, ["reasoning", "maxTokens"])).toBe("500"); expect(deepGet(openrouter, ["reasoning", "exclude"])).toBe("false"); expect(openrouter.parallelToolCalls).toBe("true"); @@ -407,7 +415,6 @@ describe("extractModelConfigFormState", () => { vercel: { reasoning: { enabled: false, - effort: "high", max_tokens: 1000, exclude: true, }, @@ -420,7 +427,6 @@ describe("extractModelConfigFormState", () => { const result = extractModelConfigFormState(model); const vercel = result.vercel as Record; expect(deepGet(vercel, ["reasoning", "enabled"])).toBe("false"); - expect(deepGet(vercel, ["reasoning", "effort"])).toBe("high"); expect(deepGet(vercel, ["reasoning", "maxTokens"])).toBe("1000"); expect(deepGet(vercel, ["reasoning", "exclude"])).toBe("true"); expect(vercel.parallelToolCalls).toBe("false"); @@ -438,9 +444,9 @@ describe("extractModelConfigFormState", () => { expect(result.temperature).toBe("0.5"); // All provider-specific fields should be empty. const openai = result.openai as Record; - expect(openai.reasoningEffort).toBe(""); + expect(openai.textVerbosity).toBe(""); const anthropic = result.anthropic as Record; - expect(anthropic.effort).toBe(""); + expect(anthropic.sendReasoning).toBe(""); const google = result.google as Record; expect(deepGet(google, ["thinkingConfig", "thinkingBudget"])).toBe(""); }); @@ -471,6 +477,83 @@ describe("buildModelConfigFromForm", () => { }); }); + describe("reasoning effort bounds", () => { + it("builds config with valid default and max", () => { + const result = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { default: "medium", max: "xhigh" } }), + ); + expect(result.fieldErrors).toEqual({}); + expect(result.modelConfig?.reasoning_effort).toEqual({ + default: "medium", + max: "xhigh", + }); + }); + + it("builds config with equal default and max", () => { + const result = buildModelConfigFromForm( + "anthropic", + formWith({ reasoningEffort: { default: "high", max: "high" } }), + ); + expect(result.fieldErrors).toEqual({}); + expect(result.modelConfig?.reasoning_effort).toEqual({ + default: "high", + max: "high", + }); + }); + + it("omits reasoning effort when both fields are unset", () => { + const result = buildModelConfigFromForm( + "openai", + formWith({ temperature: "0.5" }), + ); + expect(result.fieldErrors).toEqual({}); + expect(result.modelConfig?.reasoning_effort).toBeUndefined(); + }); + + it("reports error when default exceeds max on the global ordering", () => { + const result = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { default: "high", max: "low" } }), + ); + expect(result.fieldErrors["reasoningEffort.default"]).toContain( + "must not exceed the max reasoning effort", + ); + expect(result.modelConfig).toBeUndefined(); + }); + + it("requires default and max together", () => { + const defaultOnly = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { default: "high" } }), + ); + expect(defaultOnly.fieldErrors["reasoningEffort.max"]).toContain( + "must both be set", + ); + expect(defaultOnly.modelConfig).toBeUndefined(); + + const maxOnly = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { max: "high" } }), + ); + expect(maxOnly.fieldErrors["reasoningEffort.default"]).toContain( + "must both be set", + ); + expect(maxOnly.modelConfig).toBeUndefined(); + }); + + it("reports error for values outside the effort enum", () => { + const result = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { default: "extreme" } }), + ); + expect(result.fieldErrors["reasoningEffort.default"]).toContain( + "invalid value", + ); + expect(result.modelConfig).toBeUndefined(); + }); + }); + describe("top-level numeric fields", () => { it("builds config with valid maxOutputTokens", () => { const result = buildModelConfigFromForm( @@ -606,14 +689,14 @@ describe("buildModelConfigFromForm", () => { }); }); describe("OpenAI / Azure provider", () => { - it("builds OpenAI provider options with reasoning effort", () => { + it("builds OpenAI provider options with text verbosity", () => { const result = buildModelConfigFromForm( "openai", - formWith({ openai: { reasoningEffort: "high" } }), + formWith({ openai: { textVerbosity: "high" } }), ); expect(result.fieldErrors).toEqual({}); expect(result.modelConfig?.provider_options?.openai).toEqual({ - reasoning_effort: "high", + text_verbosity: "high", }); }); @@ -633,7 +716,6 @@ describe("buildModelConfigFromForm", () => { "openai", formWith({ openai: { - reasoningEffort: "medium", parallelToolCalls: "false", textVerbosity: "low", serviceTier: "auto", @@ -648,7 +730,6 @@ describe("buildModelConfigFromForm", () => { string, unknown >; - expect(openai.reasoning_effort).toBe("medium"); expect(openai.parallel_tool_calls).toBe(false); expect(openai.text_verbosity).toBe("low"); expect(openai.service_tier).toBe("auto"); @@ -657,16 +738,6 @@ describe("buildModelConfigFromForm", () => { expect(openai.prompt_cache_key).toBe("cache-key-1"); }); - it("reports error for invalid reasoning effort option", () => { - const result = buildModelConfigFromForm( - "openai", - formWith({ openai: { reasoningEffort: "invalid_value" } }), - ); - expect(result.fieldErrors["openai.reasoningEffort"]).toContain( - "invalid value", - ); - }); - it("reports error for invalid parallel tool calls boolean", () => { const result = buildModelConfigFromForm( "openai", @@ -742,14 +813,14 @@ describe("buildModelConfigFromForm", () => { }); describe("Anthropic / Bedrock provider", () => { - it("builds Anthropic provider options with effort", () => { + it("builds Anthropic provider options with thinking display", () => { const result = buildModelConfigFromForm( "anthropic", - formWith({ anthropic: { effort: "high" } }), + formWith({ anthropic: { thinkingDisplay: "summarized" } }), ); expect(result.fieldErrors).toEqual({}); expect(result.modelConfig?.provider_options?.anthropic).toEqual({ - effort: "high", + thinking_display: "summarized", }); }); @@ -782,7 +853,6 @@ describe("buildModelConfigFromForm", () => { "anthropic", formWith({ anthropic: { - effort: "max", thinking: { budgetTokens: "1024" }, sendReasoning: "false", disableParallelToolUse: "true", @@ -792,31 +862,11 @@ describe("buildModelConfigFromForm", () => { expect(result.fieldErrors).toEqual({}); const anthropic = result.modelConfig?.provider_options ?.anthropic as Record; - expect(anthropic.effort).toBe("max"); expect(anthropic.thinking).toEqual({ budget_tokens: 1024 }); expect(anthropic.send_reasoning).toBe(false); expect(anthropic.disable_parallel_tool_use).toBe(true); }); - it("accepts xhigh for Anthropic effort", () => { - const result = buildModelConfigFromForm( - "anthropic", - formWith({ anthropic: { effort: "xhigh" } }), - ); - expect(result.fieldErrors).toEqual({}); - const anthropic = result.modelConfig?.provider_options - ?.anthropic as Record; - expect(anthropic.effort).toBe("xhigh"); - }); - - it("reports error for invalid Anthropic effort option", () => { - const result = buildModelConfigFromForm( - "anthropic", - formWith({ anthropic: { effort: "ultra" } }), - ); - expect(result.fieldErrors["anthropic.effort"]).toContain("invalid value"); - }); - it("reports error for non-numeric thinking budget tokens", () => { const result = buildModelConfigFromForm( "anthropic", @@ -946,28 +996,16 @@ describe("buildModelConfigFromForm", () => { "openaicompat", formWith({ openaicompat: { - reasoningEffort: "low", user: "compat-user", }, }), ); expect(result.fieldErrors).toEqual({}); expect(result.modelConfig?.provider_options?.openaicompat).toEqual({ - reasoning_effort: "low", user: "compat-user", }); }); - it("reports error for invalid reasoning effort", () => { - const result = buildModelConfigFromForm( - "openaicompat", - formWith({ openaicompat: { reasoningEffort: "super" } }), - ); - expect(result.fieldErrors["openaicompat.reasoningEffort"]).toContain( - "invalid value", - ); - }); - it("does not set provider_options when all fields empty", () => { const result = buildModelConfigFromForm( "openaicompat", @@ -985,7 +1023,6 @@ describe("buildModelConfigFromForm", () => { openrouter: { reasoning: { enabled: "true", - effort: "high", maxTokens: "500", exclude: "false", }, @@ -997,7 +1034,6 @@ describe("buildModelConfigFromForm", () => { ?.openrouter as Record; expect(openrouter.reasoning).toEqual({ enabled: true, - effort: "high", max_tokens: 500, exclude: false, }); @@ -1031,18 +1067,6 @@ describe("buildModelConfigFromForm", () => { expect(openrouter.include_usage).toBe(true); }); - it("reports error for invalid reasoning effort", () => { - const result = buildModelConfigFromForm( - "openrouter", - formWith({ - openrouter: { reasoning: { effort: "turbo" } }, - }), - ); - expect(result.fieldErrors["openrouter.reasoning.effort"]).toContain( - "invalid value", - ); - }); - it("reports error for invalid boolean in reasoning enabled", () => { const result = buildModelConfigFromForm( "openrouter", @@ -1064,7 +1088,6 @@ describe("buildModelConfigFromForm", () => { vercel: { reasoning: { enabled: "true", - effort: "medium", maxTokens: "1000", exclude: "true", }, @@ -1078,7 +1101,6 @@ describe("buildModelConfigFromForm", () => { >; expect(vercel.reasoning).toEqual({ enabled: true, - effort: "medium", max_tokens: 1000, exclude: true, }); @@ -1135,7 +1157,7 @@ describe("buildModelConfigFromForm", () => { it("normalizes provider case (e.g. 'OpenAI' โ†’ 'openai')", () => { const result = buildModelConfigFromForm( "OpenAI", - formWith({ openai: { reasoningEffort: "high" } }), + formWith({ openai: { textVerbosity: "high" } }), ); expect(result.fieldErrors).toEqual({}); expect(result.modelConfig?.provider_options?.openai).toBeDefined(); @@ -1144,7 +1166,7 @@ describe("buildModelConfigFromForm", () => { it("trims provider whitespace", () => { const result = buildModelConfigFromForm( " anthropic ", - formWith({ anthropic: { effort: "low" } }), + formWith({ anthropic: { sendReasoning: "true" } }), ); expect(result.fieldErrors).toEqual({}); expect(result.modelConfig?.provider_options?.anthropic).toBeDefined(); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts index 972e81e620324..b2072ac39ac7d 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts @@ -271,6 +271,14 @@ function isNonNegativePricingField(field: FieldSchema): boolean { return pricingFieldNames.has(field.json_name); } +const reasoningEffortEnum = + getGeneralFields().find( + (field) => field.json_name === "reasoning_effort.default", + )?.enum ?? []; + +const reasoningEffortRank = (value: string): number => + reasoningEffortEnum.indexOf(value.trim().toLowerCase()); + function isValidOptionalNumber( value: string | undefined, minimum?: number, @@ -445,8 +453,39 @@ function buildYupSchema( return Yup.object(shape) as Yup.ObjectSchema>; } -// Pre-built general-fields schema. -const generalFieldsSchema = buildYupSchema(getGeneralFields()); +// Pre-built general-fields schema with reasoning effort bounds. +const generalFieldsSchema = buildYupSchema(getGeneralFields()).test( + "reasoning-effort-default-lte-max", + "Default reasoning effort must not exceed the max reasoning effort.", + function validate(value) { + const efforts = deepGet(value, ["reasoningEffort"]); + const defaultValue = deepGet(efforts, ["default"]); + const maxValue = deepGet(efforts, ["max"]); + const defaultSet = + typeof defaultValue === "string" && defaultValue.trim() !== ""; + const maxSet = typeof maxValue === "string" && maxValue.trim() !== ""; + if (defaultSet !== maxSet) { + return this.createError({ + path: defaultSet ? "reasoningEffort.max" : "reasoningEffort.default", + message: "Default and max reasoning effort must both be set.", + }); + } + if (!defaultSet || !maxSet) { + return true; + } + const defaultRank = reasoningEffortRank(defaultValue); + const maxRank = reasoningEffortRank(maxValue); + // Unset or invalid values are covered by the per-field enum tests. + if (defaultRank < 0 || maxRank < 0 || defaultRank <= maxRank) { + return true; + } + return this.createError({ + path: "reasoningEffort.default", + message: + "Default reasoning effort must not exceed the max reasoning effort.", + }); + }, +); // Cache of per-provider Yup schemas, built lazily. const providerSchemaCache = new Map< diff --git a/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts b/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts new file mode 100644 index 0000000000000..83f79e2f344db --- /dev/null +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { formatReasoningEffort, pickReasoningEffort } from "./reasoningEffort"; + +describe("formatReasoningEffort", () => { + it("formats xhigh", () => { + expect(formatReasoningEffort("xhigh")).toBe("Xhigh"); + }); +}); + +describe("pickReasoningEffort", () => { + const efforts = ["low", "medium", "high"]; + + it("keeps a selectable value", () => { + expect(pickReasoningEffort("high", efforts, "medium")).toBe("high"); + }); + + it("falls back to the default when the value is not exact", () => { + expect(pickReasoningEffort(" High ", efforts, "medium")).toBe("medium"); + }); + + it("falls back to the highest server-provided effort", () => { + expect(pickReasoningEffort("xhigh", efforts, "max")).toBe("high"); + }); + + it("returns undefined without selectable efforts", () => { + expect(pickReasoningEffort("high", [], "medium")).toBeUndefined(); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/reasoningEffort.ts b/site/src/pages/AgentsPage/utils/reasoningEffort.ts new file mode 100644 index 0000000000000..f3ae6364529df --- /dev/null +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.ts @@ -0,0 +1,24 @@ +/** Display label for an effort value, e.g. "xhigh" renders as "Xhigh". */ +export const formatReasoningEffort = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + +/** Chooses requested effort, then default effort, then the highest effort. */ +export const pickReasoningEffort = ( + value: string | undefined, + efforts: readonly string[], + defaultValue?: string, +): string | undefined => { + if (efforts.length === 0) { + return undefined; + } + + if (value && efforts.includes(value)) { + return value; + } + + if (defaultValue && efforts.includes(defaultValue)) { + return defaultValue; + } + + return efforts[efforts.length - 1]; +}; From e737c0a8770f3412bba182ed79ca3bc2d659522e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 9 Jul 2026 23:04:24 +0100 Subject: [PATCH 2/3] feat(site): surface reasoning effort configuration on the models page (#26976) --- .../components/ModelForm.stories.tsx | 73 +++++++++++++++++++ .../ModelsPage/components/ModelFormFields.tsx | 7 ++ .../ChatModelAdminPanel/ModelConfigFields.tsx | 66 +++++++++++++---- 3 files changed, 132 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 703a4028c973d..4ee792c71945f 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -218,6 +218,79 @@ export const EditUpdateDisabledUntilDirty: Story = { }, }; +export const ReasoningEffortVisibleWithoutExpanding: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const defaultSelect = canvas.getByRole("combobox", { + name: /default reasoning effort/i, + }); + const maxSelect = canvas.getByRole("combobox", { + name: /max reasoning effort/i, + }); + await expect(defaultSelect).toBeVisible(); + await expect(maxSelect).toBeVisible(); + await expect(defaultSelect).toHaveTextContent("Not set"); + await expect(maxSelect).toHaveTextContent("Not set"); + + await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); + await userEvent.type(canvas.getByLabelText(/context limit/i), "200000"); + + await userEvent.click(defaultSelect); + for (const option of [ + "None", + "Minimal", + "Low", + "Medium", + "High", + "Xhigh", + "Max", + ]) { + await expect( + await screen.findByRole("option", { name: option }), + ).toBeInTheDocument(); + } + await userEvent.click( + await screen.findByRole("option", { name: "Medium" }), + ); + + await userEvent.click(maxSelect); + await userEvent.click(await screen.findByRole("option", { name: "Max" })); + + await userEvent.click(canvas.getByRole("button", { name: /add model/i })); + await expect(args.onCreateModel).toHaveBeenCalledWith( + expect.objectContaining({ + model_config: expect.objectContaining({ + reasoning_effort: { default: "medium", max: "max" }, + }), + }), + ); + }, +}; + +export const ReasoningEffortValidationError: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const defaultSelect = canvas.getByRole("combobox", { + name: /default reasoning effort/i, + }); + const maxSelect = canvas.getByRole("combobox", { + name: /max reasoning effort/i, + }); + + await userEvent.click(defaultSelect); + await userEvent.click(await screen.findByRole("option", { name: "High" })); + await userEvent.click(maxSelect); + await userEvent.click(await screen.findByRole("option", { name: "Low" })); + + await expect( + canvas.getByText( + "Default reasoning effort must not exceed the max reasoning effort.", + ), + ).toBeVisible(); + }, +}; + export const CostTrackingExpanded: Story = { args: { editingModel: mockGPT5, diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx index a9142e018c9d5..9a36d6e42181c 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx @@ -29,6 +29,7 @@ import { GeneralModelConfigFields, ModelConfigFields, PricingModelConfigFields, + ReasoningEffortConfigFields, } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields"; import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField"; import type { @@ -239,6 +240,12 @@ export const ModelFormFields: FC<{ +
diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx index b9ea8337c99e6..ab4cc4d9125dc 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx @@ -50,6 +50,10 @@ const booleanFieldOptions = [ /** Sentinel value for Select components to represent "no selection". */ const unsetSelectValue = "__unset__"; +const isReasoningEffortField = (jsonName: string): boolean => + jsonName === "reasoning_effort.default" || + jsonName === "reasoning_effort.max"; + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Short display labels for pricing fields to avoid overly verbose names. */ @@ -79,10 +83,10 @@ const fieldSuffix: Record = { * where the valid range is more useful than an empty box. */ const placeholderOverrides: Record = { - temperature: "0.0โ€“2.0", - top_p: "0.0โ€“1.0", - presence_penalty: "-2.0โ€“2.0", - frequency_penalty: "-2.0โ€“2.0", + temperature: "0.0 to 2.0", + top_p: "0.0 to 1.0", + presence_penalty: "-2.0 to 2.0", + frequency_penalty: "-2.0 to 2.0", }; /** @@ -240,6 +244,7 @@ const SelectField: FC< label: string; description?: string; options: readonly string[]; + placeholderLabel?: string; } > = ({ form, @@ -250,6 +255,7 @@ const SelectField: FC< label, description, options, + placeholderLabel = "Default", }) => { const errorId = `${fieldKey}-error`; const fieldError = fieldErrors[errorKey ?? fieldKey]; @@ -273,10 +279,10 @@ const SelectField: FC< aria-invalid={Boolean(fieldError)} aria-describedby={fieldError ? errorId : undefined} > - + - Default + {placeholderLabel} {options.map((option) => ( {capitalize(option)} @@ -655,13 +661,44 @@ export const PricingModelConfigFields: FC = ({ ); }; -/** - * General model config fields (max output tokens, temperature, - * top P, etc.) intended to be shown under an "Advanced" section. - * - * Fields are driven by the auto-generated schema in - * `api/chatModelOptions`. - */ +/** Reasoning effort selects, outside Advanced. */ +export const ReasoningEffortConfigFields: FC = ({ + form, + fieldErrors, + disabled, +}) => { + const ctx: FieldRenderContext = { form, fieldErrors, disabled }; + const fields = getVisibleGeneralFields().filter(({ json_name }) => + isReasoningEffortField(json_name), + ); + + return ( + <> + {fields.map((field) => { + const camelName = field.json_name + .split(".") + .map(snakeToCamel) + .join("."); + const fieldKey = `config.${camelName}`; + + return ( + + ); + })} + + ); +}; + +/** See ReasoningEffortConfigFields for reasoning effort fields. */ export const GeneralModelConfigFields: FC = ({ form, fieldErrors, @@ -669,7 +706,8 @@ export const GeneralModelConfigFields: FC = ({ }) => { const ctx: FieldRenderContext = { form, fieldErrors, disabled }; const fields = getVisibleGeneralFields().filter( - ({ json_name }) => !pricingFieldNames.has(json_name), + ({ json_name }) => + !pricingFieldNames.has(json_name) && !isReasoningEffortField(json_name), ); return ( From 6038eb2b9d5b969dbfd6f9cca7a39cdddc07e37c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 9 Jul 2026 23:05:03 +0100 Subject: [PATCH 3/3] feat: add per-turn reasoning effort selection to chat API (#26975) --- coderd/apidoc/docs.go | 12 + coderd/apidoc/swagger.json | 12 + coderd/database/db2sdk/db2sdk.go | 4 + coderd/database/db2sdk/db2sdk_test.go | 49 +- coderd/database/dbgen/dbgen.go | 1 + coderd/database/dump.sql | 16 +- .../000541_chat_reasoning_effort.down.sql | 1 + .../000541_chat_reasoning_effort.up.sql | 9 +- coderd/database/models.go | 167 ++++-- coderd/database/queries.sql.go | 361 ++++++------ coderd/database/queries/chats.sql | 47 +- coderd/exp_chats.go | 335 ++++++++--- coderd/exp_chats_test.go | 557 +++++++++++++++++- coderd/x/chatd/ARCHITECTURE.md | 4 +- coderd/x/chatd/chatd.go | 51 +- coderd/x/chatd/chatd_test.go | 111 +++- .../x/chatd/chatprovider/reasoningeffort.go | 25 +- .../chatprovider/reasoningeffort_test.go | 37 +- coderd/x/chatd/chatstate/messages.go | 5 + coderd/x/chatd/chatstate/transitions.go | 56 +- coderd/x/chatd/chatstate_bridge.go | 21 +- coderd/x/chatd/generation_preparer.go | 9 +- .../generation_preparer_internal_test.go | 76 +++ coderd/x/chatd/personal_model_override.go | 18 +- .../x/chatd/personal_model_override_test.go | 20 + coderd/x/chatd/quickgen.go | 2 +- coderd/x/chatd/subagent.go | 139 +++-- coderd/x/chatd/subagent_catalog.go | 12 +- coderd/x/chatd/subagent_internal_test.go | 74 ++- coderd/x/chatd/title_override.go | 26 +- .../x/chatd/title_override_internal_test.go | 57 +- codersdk/chats.go | 88 +-- docs/reference/api/chats.md | 180 +++--- docs/reference/api/schemas.md | 102 ++-- site/src/api/typesGenerated.ts | 8 + .../CoderAgentsPage/CoderAgentsPageView.tsx | 2 +- .../SubagentModelOverrideSettings.tsx | 54 +- .../AgentsPage/AgentChatPage.stories.tsx | 64 ++ site/src/pages/AgentsPage/AgentChatPage.tsx | 34 +- .../pages/AgentsPage/AgentChatPageView.tsx | 6 + site/src/pages/AgentsPage/AgentCreatePage.tsx | 2 + .../AgentsPage/components/AgentChatInput.tsx | 6 + .../components/AgentCreateForm.stories.tsx | 59 ++ .../AgentsPage/components/AgentCreateForm.tsx | 16 + .../ChatElements/ModelSelector.stories.tsx | 156 ++++- .../components/ChatElements/ModelSelector.tsx | 87 ++- .../AgentsPage/components/ChatPageContent.tsx | 6 + .../components/PersonalModelOverrideRow.tsx | 101 +++- .../AgentsPage/utils/modelOptions.test.ts | 52 ++ .../pages/AgentsPage/utils/modelOptions.ts | 5 + .../pages/AgentsPage/utils/reasoningEffort.ts | 2 +- 51 files changed, 2629 insertions(+), 715 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8ad4b48543799..df0c80b265897 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16683,6 +16683,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "last_reasoning_effort": { + "type": "string" + }, "last_turn_summary": { "type": "string" }, @@ -18161,6 +18164,9 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatPlanMode" } ] + }, + "reasoning_effort": { + "type": "string" } } }, @@ -18220,6 +18226,9 @@ const docTemplate = `{ "plan_mode": { "$ref": "#/definitions/codersdk.ChatPlanMode" }, + "reasoning_effort": { + "type": "string" + }, "system_prompt": { "type": "string" }, @@ -19545,6 +19554,9 @@ const docTemplate = `{ "description": "ModelConfigID, when set, overrides the model used for the\nreplacement user message and the assistant turn that follows.\nWhen nil the original message's model is preserved.", "type": "string", "format": "uuid" + }, + "reasoning_effort": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7551b5f93aaf9..dc7c2eee2c9b4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14987,6 +14987,9 @@ "type": "string", "format": "uuid" }, + "last_reasoning_effort": { + "type": "string" + }, "last_turn_summary": { "type": "string" }, @@ -16404,6 +16407,9 @@ "$ref": "#/definitions/codersdk.ChatPlanMode" } ] + }, + "reasoning_effort": { + "type": "string" } } }, @@ -16463,6 +16469,9 @@ "plan_mode": { "$ref": "#/definitions/codersdk.ChatPlanMode" }, + "reasoning_effort": { + "type": "string" + }, "system_prompt": { "type": "string" }, @@ -17742,6 +17751,9 @@ "description": "ModelConfigID, when set, overrides the model used for the\nreplacement user message and the assistant turn that follows.\nWhen nil the original message's model is preserved.", "type": "string", "format": "uuid" + }, + "reasoning_effort": { + "type": "string" } } }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index cbb6107353af0..9610626d26682 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1702,6 +1702,10 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database if c.LastTurnSummary.Valid { chat.LastTurnSummary = &c.LastTurnSummary.String } + if c.LastReasoningEffort.Valid { + lastReasoningEffort := string(c.LastReasoningEffort.ChatReasoningEffort) + chat.LastReasoningEffort = &lastReasoningEffort + } if c.PlanMode.Valid { chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode) } diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 735e841082860..44d43442d20e1 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -696,30 +696,31 @@ func TestChat_AllFieldsPopulated(t *testing.T) { require.NoError(t, err) input := database.Chat{ - ID: uuid.New(), - OwnerID: uuid.New(), - OwnerUsername: "owner-username", - OwnerName: "Owner Name", - OrganizationID: uuid.New(), - WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - BuildID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - RootChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, - LastModelConfigID: uuid.New(), - Title: "all-fields-test", - Status: database.ChatStatusRunning, - ClientType: database.ChatClientTypeUi, - LastError: pqtype.NullRawMessage{RawMessage: lastErrorRaw, Valid: true}, - LastTurnSummary: sql.NullString{String: "turn completed", Valid: true}, - CreatedAt: now, - UpdatedAt: now, - Archived: true, - UserACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}}, - PinOrder: 1, - PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true}, - MCPServerIDs: []uuid.UUID{uuid.New()}, - Labels: database.StringMap{"env": "prod"}, + ID: uuid.New(), + OwnerID: uuid.New(), + OwnerUsername: "owner-username", + OwnerName: "Owner Name", + OrganizationID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + BuildID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + ParentChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + RootChatID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + LastModelConfigID: uuid.New(), + LastReasoningEffort: database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true}, + Title: "all-fields-test", + Status: database.ChatStatusRunning, + ClientType: database.ChatClientTypeUi, + LastError: pqtype.NullRawMessage{RawMessage: lastErrorRaw, Valid: true}, + LastTurnSummary: sql.NullString{String: "turn completed", Valid: true}, + CreatedAt: now, + UpdatedAt: now, + Archived: true, + UserACL: database.ChatACL{uuid.NewString(): database.ChatACLEntry{}}, + PinOrder: 1, + PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true}, + MCPServerIDs: []uuid.UUID{uuid.New()}, + Labels: database.StringMap{"env": "prod"}, DynamicTools: pqtype.NullRawMessage{ RawMessage: json.RawMessage(`[{"name":"tool1","description":"test tool","inputSchema":{"type":"object"}}]`), Valid: true, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 782e004e6b195..ef639bef9bd69 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -131,6 +131,7 @@ func ChatMessage(t testing.TB, db database.Store, seed database.ChatMessage) dat CreatedBy: []uuid.UUID{seed.CreatedBy.UUID}, APIKeyID: []string{apiKeyID}, ModelConfigID: []uuid.UUID{seed.ModelConfigID.UUID}, + ReasoningEffort: []string{string(seed.ReasoningEffort.ChatReasoningEffort)}, Role: []database.ChatMessageRole{role}, Content: []string{content}, ContentVersion: []int16{takeFirst(seed.ContentVersion, chatprompt.CurrentContentVersion)}, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e9fd54e82cb89..870c22d1c9817 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -335,6 +335,16 @@ CREATE TYPE chat_plan_mode AS ENUM ( 'plan' ); +CREATE TYPE chat_reasoning_effort AS ENUM ( + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max' +); + CREATE TYPE chat_status AS ENUM ( 'waiting', 'pending', @@ -1918,7 +1928,7 @@ CREATE TABLE chat_messages ( provider_response_id text, api_key_id text, revision bigint NOT NULL, - reasoning_effort text + reasoning_effort chat_reasoning_effort ); COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort for the turn triggered by this message.'; @@ -1969,7 +1979,7 @@ CREATE TABLE chat_queued_messages ( api_key_id text, "position" bigint DEFAULT nextval('chat_queued_messages_position_seq'::regclass) NOT NULL, created_by uuid NOT NULL, - reasoning_effort text + reasoning_effort chat_reasoning_effort ); COMMENT ON COLUMN chat_queued_messages.reasoning_effort IS 'Stores the selected effort until the queued row is promoted.'; @@ -2047,7 +2057,7 @@ CREATE TABLE chats ( context_dirty_since timestamp with time zone, context_dirty_resources jsonb, context_error text DEFAULT ''::text NOT NULL, - last_reasoning_effort text, + last_reasoning_effort chat_reasoning_effort, CONSTRAINT chat_acl_only_on_root_chats CHECK ((((parent_chat_id IS NULL) AND (root_chat_id IS NULL)) OR ((user_acl = '{}'::jsonb) AND (group_acl = '{}'::jsonb)))), CONSTRAINT chat_group_acl_not_null_jsonb CHECK (((group_acl IS NOT NULL) AND (jsonb_typeof(group_acl) = 'object'::text))), CONSTRAINT chat_user_acl_not_null_jsonb CHECK (((user_acl IS NOT NULL) AND (jsonb_typeof(user_acl) = 'object'::text))), diff --git a/coderd/database/migrations/000541_chat_reasoning_effort.down.sql b/coderd/database/migrations/000541_chat_reasoning_effort.down.sql index d941bb90aabb7..a97f901391b86 100644 --- a/coderd/database/migrations/000541_chat_reasoning_effort.down.sql +++ b/coderd/database/migrations/000541_chat_reasoning_effort.down.sql @@ -10,6 +10,7 @@ WHERE options ? 'reasoning_effort'; ALTER TABLE chats DROP COLUMN last_reasoning_effort; ALTER TABLE chat_messages DROP COLUMN reasoning_effort; ALTER TABLE chat_queued_messages DROP COLUMN reasoning_effort; +DROP TYPE chat_reasoning_effort; CREATE VIEW chats_expanded AS SELECT c.id, diff --git a/coderd/database/migrations/000541_chat_reasoning_effort.up.sql b/coderd/database/migrations/000541_chat_reasoning_effort.up.sql index 2120c9fb248ec..3e0059ff2cb2a 100644 --- a/coderd/database/migrations/000541_chat_reasoning_effort.up.sql +++ b/coderd/database/migrations/000541_chat_reasoning_effort.up.sql @@ -2,11 +2,12 @@ -- and recreated so the new chats column can appear in its column list. DROP VIEW IF EXISTS chats_expanded; -ALTER TABLE chats ADD COLUMN last_reasoning_effort text; -ALTER TABLE chat_messages ADD COLUMN reasoning_effort text; -ALTER TABLE chat_queued_messages ADD COLUMN reasoning_effort text; +CREATE TYPE chat_reasoning_effort AS ENUM ('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'); + +ALTER TABLE chats ADD COLUMN last_reasoning_effort chat_reasoning_effort; +ALTER TABLE chat_messages ADD COLUMN reasoning_effort chat_reasoning_effort; +ALTER TABLE chat_queued_messages ADD COLUMN reasoning_effort chat_reasoning_effort; --- Reserved for follow-up per-turn reasoning effort support. COMMENT ON COLUMN chats.last_reasoning_effort IS 'Stores the most recent message effort once per-turn selection is wired.'; COMMENT ON COLUMN chat_messages.reasoning_effort IS 'Stores the selected effort for the turn triggered by this message.'; COMMENT ON COLUMN chat_queued_messages.reasoning_effort IS 'Stores the selected effort until the queued row is promoted.'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 575f5b20457ae..7a73e4a16d706 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1560,6 +1560,79 @@ func AllChatPlanModeValues() []ChatPlanMode { } } +type ChatReasoningEffort string + +const ( + ChatReasoningEffortNone ChatReasoningEffort = "none" + ChatReasoningEffortMinimal ChatReasoningEffort = "minimal" + ChatReasoningEffortLow ChatReasoningEffort = "low" + ChatReasoningEffortMedium ChatReasoningEffort = "medium" + ChatReasoningEffortHigh ChatReasoningEffort = "high" + ChatReasoningEffortXhigh ChatReasoningEffort = "xhigh" + ChatReasoningEffortMax ChatReasoningEffort = "max" +) + +func (e *ChatReasoningEffort) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ChatReasoningEffort(s) + case string: + *e = ChatReasoningEffort(s) + default: + return fmt.Errorf("unsupported scan type for ChatReasoningEffort: %T", src) + } + return nil +} + +type NullChatReasoningEffort struct { + ChatReasoningEffort ChatReasoningEffort `json:"chat_reasoning_effort"` + Valid bool `json:"valid"` // Valid is true if ChatReasoningEffort is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullChatReasoningEffort) Scan(value interface{}) error { + if value == nil { + ns.ChatReasoningEffort, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ChatReasoningEffort.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullChatReasoningEffort) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ChatReasoningEffort), nil +} + +func (e ChatReasoningEffort) Valid() bool { + switch e { + case ChatReasoningEffortNone, + ChatReasoningEffortMinimal, + ChatReasoningEffortLow, + ChatReasoningEffortMedium, + ChatReasoningEffortHigh, + ChatReasoningEffortXhigh, + ChatReasoningEffortMax: + return true + } + return false +} + +func AllChatReasoningEffortValues() []ChatReasoningEffort { + return []ChatReasoningEffort{ + ChatReasoningEffortNone, + ChatReasoningEffortMinimal, + ChatReasoningEffortLow, + ChatReasoningEffortMedium, + ChatReasoningEffortHigh, + ChatReasoningEffortXhigh, + ChatReasoningEffortMax, + } +} + type ChatStatus string const ( @@ -4786,50 +4859,50 @@ type BoundaryUsageStat struct { } type Chat struct { - ID uuid.UUID `db:"id" json:"id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` - Title string `db:"title" json:"title"` - Status ChatStatus `db:"status" json:"status"` - WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` - StartedAt sql.NullTime `db:"started_at" json:"started_at"` - HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` - RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` - LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` - LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` - Archived bool `db:"archived" json:"archived"` - LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` - Mode NullChatMode `db:"mode" json:"mode"` - MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` - Labels StringMap `db:"labels" json:"labels"` - BuildID uuid.NullUUID `db:"build_id" json:"build_id"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` - PinOrder int32 `db:"pin_order" json:"pin_order"` - LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` - ClientType ChatClientType `db:"client_type" json:"client_type"` - LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` - SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` - HistoryVersion int64 `db:"history_version" json:"history_version"` - QueueVersion int64 `db:"queue_version" json:"queue_version"` - GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` - RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` - RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` - RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` - RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` - UserACL ChatACL `db:"user_acl" json:"user_acl"` - GroupACL ChatACL `db:"group_acl" json:"group_acl"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` - ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` - ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` - ContextError string `db:"context_error" json:"context_error"` + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + Title string `db:"title" json:"title"` + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` + Archived bool `db:"archived" json:"archived"` + LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` + Mode NullChatMode `db:"mode" json:"mode"` + MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` + Labels StringMap `db:"labels" json:"labels"` + BuildID uuid.NullUUID `db:"build_id" json:"build_id"` + AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` + LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` + DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` + ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` + SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` + HistoryVersion int64 `db:"history_version" json:"history_version"` + QueueVersion int64 `db:"queue_version" json:"queue_version"` + GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` + RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` + RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` + RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` + RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` + UserACL ChatACL `db:"user_acl" json:"user_acl"` + GroupACL ChatACL `db:"group_acl" json:"group_acl"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` + ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` + ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` + ContextError string `db:"context_error" json:"context_error"` } // Per-chat pinned copy of the agent context resources a chat is hydrated against. Copied from workspace_agent_context_resources at chat hydration and context refresh; survives agent replacement and workspace rebuilds. @@ -4966,7 +5039,7 @@ type ChatMessage struct { APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` Revision int64 `db:"revision" json:"revision"` // Stores the selected effort for the turn triggered by this message. - ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` + ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` } type ChatModelConfig struct { @@ -4997,7 +5070,7 @@ type ChatQueuedMessage struct { Position int64 `db:"position" json:"position"` CreatedBy uuid.UUID `db:"created_by" json:"created_by"` // Stores the selected effort until the queued row is promoted. - ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` + ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` } type ChatTable struct { @@ -5050,7 +5123,7 @@ type ChatTable struct { // Snapshot-level error copied from the pinned snapshot (count cap exceeded, watcher degraded, etc.). Empty when healthy. ContextError string `db:"context_error" json:"context_error"` // Stores the most recent message effort once per-turn selection is wired. - LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` + LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` } type ChatUsageLimitConfig struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1b4c0e53591f6..4cc53d82b3739 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6001,49 +6001,49 @@ type AutoArchiveInactiveChatsParams struct { } type AutoArchiveInactiveChatsRow struct { - ID uuid.UUID `db:"id" json:"id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` - Title string `db:"title" json:"title"` - Status ChatStatus `db:"status" json:"status"` - WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` - StartedAt sql.NullTime `db:"started_at" json:"started_at"` - HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` - RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` - LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` - Archived bool `db:"archived" json:"archived"` - LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` - Mode NullChatMode `db:"mode" json:"mode"` - MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` - Labels json.RawMessage `db:"labels" json:"labels"` - BuildID uuid.NullUUID `db:"build_id" json:"build_id"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` - PinOrder int32 `db:"pin_order" json:"pin_order"` - LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` - ClientType ChatClientType `db:"client_type" json:"client_type"` - LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` - UserACL json.RawMessage `db:"user_acl" json:"user_acl"` - GroupACL json.RawMessage `db:"group_acl" json:"group_acl"` - SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` - HistoryVersion int64 `db:"history_version" json:"history_version"` - QueueVersion int64 `db:"queue_version" json:"queue_version"` - GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` - RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` - RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` - RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` - RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` - ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` - ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` - ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` - ContextError string `db:"context_error" json:"context_error"` - LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` - LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + Title string `db:"title" json:"title"` + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + Archived bool `db:"archived" json:"archived"` + LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` + Mode NullChatMode `db:"mode" json:"mode"` + MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` + Labels json.RawMessage `db:"labels" json:"labels"` + BuildID uuid.NullUUID `db:"build_id" json:"build_id"` + AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` + LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` + DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` + ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` + UserACL json.RawMessage `db:"user_acl" json:"user_acl"` + GroupACL json.RawMessage `db:"group_acl" json:"group_acl"` + SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` + HistoryVersion int64 `db:"history_version" json:"history_version"` + QueueVersion int64 `db:"queue_version" json:"queue_version"` + GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` + RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` + RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` + RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` + RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` + ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` + ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` + ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` + ContextError string `db:"context_error" json:"context_error"` + LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` + LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } // Archives inactive root chats (pinned and already-archived chats skipped), @@ -6482,51 +6482,51 @@ type GetAutoArchiveInactiveChatCandidatesParams struct { } type GetAutoArchiveInactiveChatCandidatesRow struct { - ID uuid.UUID `db:"id" json:"id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` - Title string `db:"title" json:"title"` - Status ChatStatus `db:"status" json:"status"` - WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` - StartedAt sql.NullTime `db:"started_at" json:"started_at"` - HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` - RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` - LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` - LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` - Archived bool `db:"archived" json:"archived"` - LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` - Mode NullChatMode `db:"mode" json:"mode"` - MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` - Labels StringMap `db:"labels" json:"labels"` - BuildID uuid.NullUUID `db:"build_id" json:"build_id"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` - PinOrder int32 `db:"pin_order" json:"pin_order"` - LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` - ClientType ChatClientType `db:"client_type" json:"client_type"` - LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` - SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` - HistoryVersion int64 `db:"history_version" json:"history_version"` - QueueVersion int64 `db:"queue_version" json:"queue_version"` - GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` - RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` - RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` - RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` - RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` - UserACL ChatACL `db:"user_acl" json:"user_acl"` - GroupACL ChatACL `db:"group_acl" json:"group_acl"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` - ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` - ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` - ContextError string `db:"context_error" json:"context_error"` - LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + Title string `db:"title" json:"title"` + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` + Archived bool `db:"archived" json:"archived"` + LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` + Mode NullChatMode `db:"mode" json:"mode"` + MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` + Labels StringMap `db:"labels" json:"labels"` + BuildID uuid.NullUUID `db:"build_id" json:"build_id"` + AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` + LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` + DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` + ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` + SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` + HistoryVersion int64 `db:"history_version" json:"history_version"` + QueueVersion int64 `db:"queue_version" json:"queue_version"` + GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` + RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` + RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` + RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` + RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` + UserACL ChatACL `db:"user_acl" json:"user_acl"` + GroupACL ChatACL `db:"group_acl" json:"group_acl"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` + ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` + ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` + ContextError string `db:"context_error" json:"context_error"` + LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"` } // Returns read-only root chat candidates for state-machine-backed @@ -8404,52 +8404,52 @@ type GetChatWorkerAcquisitionCandidatesParams struct { } type GetChatWorkerAcquisitionCandidatesRow struct { - ID uuid.UUID `db:"id" json:"id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` - Title string `db:"title" json:"title"` - Status ChatStatus `db:"status" json:"status"` - WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` - StartedAt sql.NullTime `db:"started_at" json:"started_at"` - HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` - RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` - LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` - LastReasoningEffort sql.NullString `db:"last_reasoning_effort" json:"last_reasoning_effort"` - Archived bool `db:"archived" json:"archived"` - LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` - Mode NullChatMode `db:"mode" json:"mode"` - MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` - Labels StringMap `db:"labels" json:"labels"` - BuildID uuid.NullUUID `db:"build_id" json:"build_id"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` - PinOrder int32 `db:"pin_order" json:"pin_order"` - LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` - ClientType ChatClientType `db:"client_type" json:"client_type"` - LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` - SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` - HistoryVersion int64 `db:"history_version" json:"history_version"` - QueueVersion int64 `db:"queue_version" json:"queue_version"` - GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` - RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` - RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` - RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` - RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` - UserACL ChatACL `db:"user_acl" json:"user_acl"` - GroupACL ChatACL `db:"group_acl" json:"group_acl"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` - ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` - ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` - ContextError string `db:"context_error" json:"context_error"` - CurrentHeartbeatAt sql.NullTime `db:"current_heartbeat_at" json:"current_heartbeat_at"` - HeartbeatStale bool `db:"heartbeat_stale" json:"heartbeat_stale"` + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + Title string `db:"title" json:"title"` + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"` + Archived bool `db:"archived" json:"archived"` + LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` + Mode NullChatMode `db:"mode" json:"mode"` + MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` + Labels StringMap `db:"labels" json:"labels"` + BuildID uuid.NullUUID `db:"build_id" json:"build_id"` + AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` + LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` + DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` + ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` + SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` + HistoryVersion int64 `db:"history_version" json:"history_version"` + QueueVersion int64 `db:"queue_version" json:"queue_version"` + GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` + RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` + RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"` + RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"` + RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"` + UserACL ChatACL `db:"user_acl" json:"user_acl"` + GroupACL ChatACL `db:"group_acl" json:"group_acl"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"` + ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"` + ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"` + ContextError string `db:"context_error" json:"context_error"` + CurrentHeartbeatAt sql.NullTime `db:"current_heartbeat_at" json:"current_heartbeat_at"` + HeartbeatStale bool `db:"heartbeat_stale" json:"heartbeat_stale"` } // Returns chats that workers may try to acquire. Candidates must be: @@ -9718,32 +9718,37 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat } const insertChatMessages = `-- name: InsertChatMessages :many -WITH updated_chat AS ( - UPDATE - chats - SET - last_model_config_id = ( +WITH batch AS ( + SELECT + ( SELECT val FROM UNNEST($4::uuid[]) WITH ORDINALITY AS t(val, ord) WHERE val != '00000000-0000-0000-0000-000000000000'::uuid ORDER BY ord DESC LIMIT 1 - ) - WHERE - id = $1::uuid - AND EXISTS ( - SELECT 1 - FROM UNNEST($4::uuid[]) - WHERE unnest != '00000000-0000-0000-0000-000000000000'::uuid - ) - AND chats.last_model_config_id IS DISTINCT FROM ( - SELECT val - FROM UNNEST($4::uuid[]) + ) AS last_model_config_id, + ( + SELECT NULLIF(val, '')::chat_reasoning_effort + FROM UNNEST($5::text[]) WITH ORDINALITY AS t(val, ord) - WHERE val != '00000000-0000-0000-0000-000000000000'::uuid + WHERE val != '' ORDER BY ord DESC LIMIT 1 + ) AS last_reasoning_effort +), +updated_chat AS ( + UPDATE + chats + SET + last_model_config_id = COALESCE(batch.last_model_config_id, chats.last_model_config_id), + last_reasoning_effort = COALESCE(batch.last_reasoning_effort, chats.last_reasoning_effort) + FROM batch + WHERE + chats.id = $1::uuid + AND ( + chats.last_model_config_id IS DISTINCT FROM COALESCE(batch.last_model_config_id, chats.last_model_config_id) + OR chats.last_reasoning_effort IS DISTINCT FROM COALESCE(batch.last_reasoning_effort, chats.last_reasoning_effort) ) ) INSERT INTO chat_messages ( @@ -9751,6 +9756,7 @@ INSERT INTO chat_messages ( created_by, api_key_id, model_config_id, + reasoning_effort, role, content, content_version, @@ -9771,20 +9777,21 @@ SELECT NULLIF(UNNEST($2::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid), NULLIF(UNNEST($3::text[]), ''), NULLIF(UNNEST($4::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid), - UNNEST($5::chat_message_role[]), - UNNEST($6::text[])::jsonb, - UNNEST($7::smallint[]), - UNNEST($8::chat_message_visibility[]), - NULLIF(UNNEST($9::bigint[]), 0), + NULLIF(UNNEST($5::text[]), '')::chat_reasoning_effort, + UNNEST($6::chat_message_role[]), + UNNEST($7::text[])::jsonb, + UNNEST($8::smallint[]), + UNNEST($9::chat_message_visibility[]), NULLIF(UNNEST($10::bigint[]), 0), NULLIF(UNNEST($11::bigint[]), 0), NULLIF(UNNEST($12::bigint[]), 0), NULLIF(UNNEST($13::bigint[]), 0), NULLIF(UNNEST($14::bigint[]), 0), NULLIF(UNNEST($15::bigint[]), 0), - UNNEST($16::boolean[]), - NULLIF(UNNEST($17::bigint[]), 0), - NULLIF(UNNEST($18::bigint[]), 0) + NULLIF(UNNEST($16::bigint[]), 0), + UNNEST($17::boolean[]), + NULLIF(UNNEST($18::bigint[]), 0), + NULLIF(UNNEST($19::bigint[]), 0) RETURNING id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted, provider_response_id, api_key_id, revision, reasoning_effort ` @@ -9794,6 +9801,7 @@ type InsertChatMessagesParams struct { CreatedBy []uuid.UUID `db:"created_by" json:"created_by"` APIKeyID []string `db:"api_key_id" json:"api_key_id"` ModelConfigID []uuid.UUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort []string `db:"reasoning_effort" json:"reasoning_effort"` Role []ChatMessageRole `db:"role" json:"role"` Content []string `db:"content" json:"content"` ContentVersion []int16 `db:"content_version" json:"content_version"` @@ -9816,6 +9824,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa pq.Array(arg.CreatedBy), pq.Array(arg.APIKeyID), pq.Array(arg.ModelConfigID), + pq.Array(arg.ReasoningEffort), pq.Array(arg.Role), pq.Array(arg.Content), pq.Array(arg.ContentVersion), @@ -9878,12 +9887,13 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa } const insertChatQueuedMessage = `-- name: InsertChatQueuedMessage :one -INSERT INTO chat_queued_messages (chat_id, content, model_config_id, api_key_id, created_by) +INSERT INTO chat_queued_messages (chat_id, content, model_config_id, reasoning_effort, api_key_id, created_by) SELECT $1::uuid, $2::jsonb, $3::uuid, - $4::text, + $4::chat_reasoning_effort, + $5::text, chats.owner_id FROM chats WHERE chats.id = $1::uuid @@ -9891,10 +9901,11 @@ RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, positio ` type InsertChatQueuedMessageParams struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - Content json.RawMessage `db:"content" json:"content"` - ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` - APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Content json.RawMessage `db:"content" json:"content"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` + APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` } // Legacy queue insertion path. When no caller-supplied creator exists, @@ -9905,6 +9916,7 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat arg.ChatID, arg.Content, arg.ModelConfigID, + arg.ReasoningEffort, arg.APIKeyID, ) var i ChatQueuedMessage @@ -9923,23 +9935,25 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat } const insertChatQueuedMessageWithCreator = `-- name: InsertChatQueuedMessageWithCreator :one -INSERT INTO chat_queued_messages (chat_id, content, model_config_id, api_key_id, created_by) +INSERT INTO chat_queued_messages (chat_id, content, model_config_id, reasoning_effort, api_key_id, created_by) VALUES ( $1::uuid, $2::jsonb, $3::uuid, - $4::text, - $5::uuid + $4::chat_reasoning_effort, + $5::text, + $6::uuid ) RETURNING id, chat_id, content, created_at, model_config_id, api_key_id, position, created_by, reasoning_effort ` type InsertChatQueuedMessageWithCreatorParams struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - Content json.RawMessage `db:"content" json:"content"` - ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` - APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` - CreatedBy uuid.UUID `db:"created_by" json:"created_by"` + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + Content json.RawMessage `db:"content" json:"content"` + ModelConfigID uuid.NullUUID `db:"model_config_id" json:"model_config_id"` + ReasoningEffort NullChatReasoningEffort `db:"reasoning_effort" json:"reasoning_effort"` + APIKeyID sql.NullString `db:"api_key_id" json:"api_key_id"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` } // Inserts a queued message that carries a position (from the default @@ -9950,6 +9964,7 @@ func (q *sqlQuerier) InsertChatQueuedMessageWithCreator(ctx context.Context, arg arg.ChatID, arg.Content, arg.ModelConfigID, + arg.ReasoningEffort, arg.APIKeyID, arg.CreatedBy, ) diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index effa7df218a19..6dfaeae6e221e 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -793,32 +793,37 @@ SELECT * FROM chats_expanded; -- name: InsertChatMessages :many -WITH updated_chat AS ( - UPDATE - chats - SET - last_model_config_id = ( +WITH batch AS ( + SELECT + ( SELECT val FROM UNNEST(@model_config_id::uuid[]) WITH ORDINALITY AS t(val, ord) WHERE val != '00000000-0000-0000-0000-000000000000'::uuid ORDER BY ord DESC LIMIT 1 - ) - WHERE - id = @chat_id::uuid - AND EXISTS ( - SELECT 1 - FROM UNNEST(@model_config_id::uuid[]) - WHERE unnest != '00000000-0000-0000-0000-000000000000'::uuid - ) - AND chats.last_model_config_id IS DISTINCT FROM ( - SELECT val - FROM UNNEST(@model_config_id::uuid[]) + ) AS last_model_config_id, + ( + SELECT NULLIF(val, '')::chat_reasoning_effort + FROM UNNEST(@reasoning_effort::text[]) WITH ORDINALITY AS t(val, ord) - WHERE val != '00000000-0000-0000-0000-000000000000'::uuid + WHERE val != '' ORDER BY ord DESC LIMIT 1 + ) AS last_reasoning_effort +), +updated_chat AS ( + UPDATE + chats + SET + last_model_config_id = COALESCE(batch.last_model_config_id, chats.last_model_config_id), + last_reasoning_effort = COALESCE(batch.last_reasoning_effort, chats.last_reasoning_effort) + FROM batch + WHERE + chats.id = @chat_id::uuid + AND ( + chats.last_model_config_id IS DISTINCT FROM COALESCE(batch.last_model_config_id, chats.last_model_config_id) + OR chats.last_reasoning_effort IS DISTINCT FROM COALESCE(batch.last_reasoning_effort, chats.last_reasoning_effort) ) ) INSERT INTO chat_messages ( @@ -826,6 +831,7 @@ INSERT INTO chat_messages ( created_by, api_key_id, model_config_id, + reasoning_effort, role, content, content_version, @@ -846,6 +852,7 @@ SELECT NULLIF(UNNEST(@created_by::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid), NULLIF(UNNEST(@api_key_id::text[]), ''), NULLIF(UNNEST(@model_config_id::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid), + NULLIF(UNNEST(@reasoning_effort::text[]), '')::chat_reasoning_effort, UNNEST(@role::chat_message_role[]), UNNEST(@content::text[])::jsonb, UNNEST(@content_version::smallint[]), @@ -1855,11 +1862,12 @@ RETURNING -- Legacy queue insertion path. When no caller-supplied creator exists, -- preserve the created_by invariant by attributing the queued row to the -- chat owner. -INSERT INTO chat_queued_messages (chat_id, content, model_config_id, api_key_id, created_by) +INSERT INTO chat_queued_messages (chat_id, content, model_config_id, reasoning_effort, api_key_id, created_by) SELECT @chat_id::uuid, @content::jsonb, sqlc.narg('model_config_id')::uuid, + sqlc.narg('reasoning_effort')::chat_reasoning_effort, sqlc.narg('api_key_id')::text, chats.owner_id FROM chats @@ -2863,11 +2871,12 @@ SELECT NOW()::timestamptz AS now; -- Inserts a queued message that carries a position (from the default -- sequence) and an explicit created_by reference. Use this when the -- queued-message creator differs from the chat owner. -INSERT INTO chat_queued_messages (chat_id, content, model_config_id, api_key_id, created_by) +INSERT INTO chat_queued_messages (chat_id, content, model_config_id, reasoning_effort, api_key_id, created_by) VALUES ( @chat_id::uuid, @content::jsonb, sqlc.narg('model_config_id')::uuid, + sqlc.narg('reasoning_effort')::chat_reasoning_effort, sqlc.narg('api_key_id')::text, @created_by::uuid ) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index c647f97eb85b5..4f360b95c5ccc 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -70,6 +70,8 @@ const ( maxSystemPromptLenBytes = 131072 // 128 KiB ) +var allowedReasoningEffortValues = strings.Join(codersdk.ChatModelReasoningEffortValues(), ", ") + // chatGitRef holds the branch, remote origin, and optional chat // ID reported by the workspace agent during a git operation. type chatGitRef struct { @@ -518,24 +520,40 @@ func validateChatPlanMode(mode codersdk.ChatPlanMode) bool { } } -func parseChatModelOverride(raw string) (*uuid.UUID, error) { +type parsedChatModelOverride struct { + modelConfigID *uuid.UUID + reasoningEffort *string +} + +func parseChatModelOverride(raw string) (parsedChatModelOverride, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { - //nolint:nilnil // Empty site-config value means the override is unset. - return nil, nil + return parsedChatModelOverride{}, nil } - modelConfigID, err := uuid.Parse(trimmed) + rawID, rawEffort, hasEffort := strings.Cut(trimmed, ":") + modelConfigID, err := uuid.Parse(rawID) if err != nil { - return nil, xerrors.Errorf("parse chat model override: %w", err) + return parsedChatModelOverride{}, xerrors.Errorf("parse chat model override: %w", err) } - return &modelConfigID, nil + 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) string { +func formatChatModelOverride(id *uuid.UUID, effort *string) string { if id == nil { return "" } - return id.String() + formatted := id.String() + if effort != nil { + formatted += ":" + *effort + } + return formatted } func lookupEnabledChatModelConfigByID( @@ -548,12 +566,65 @@ func lookupEnabledChatModelConfigByID( return db.GetEnabledChatModelConfigByID(dbauthz.AsChatd(ctx), id) } -func validateChatModelOverrideID( +func parseChatModelCallConfig(options json.RawMessage) (*codersdk.ChatModelCallConfig, error) { + callConfig := &codersdk.ChatModelCallConfig{} + if len(options) == 0 { + return callConfig, nil + } + if err := json.Unmarshal(options, callConfig); err != nil { + return nil, err + } + return callConfig, nil +} + +func validateChatModelOverrideEffort( + modelConfig database.ChatModelConfig, + effort *string, +) (int, *codersdk.Response) { + if effort == nil { + return 0, nil + } + if !chatprovider.IsValidReasoningEffort(*effort) { + return http.StatusBadRequest, &codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: "Must be one of none, minimal, low, medium, high, xhigh, max.", + } + } + callConfig, err := parseChatModelCallConfig(modelConfig.Options) + if err != nil { + return http.StatusInternalServerError, &codersdk.Response{ + Message: "Internal error validating reasoning effort.", + Detail: err.Error(), + } + } + selectableEfforts := chatprovider.SelectableReasoningEfforts(callConfig.ReasoningEffort) + if len(selectableEfforts) == 0 { + return http.StatusBadRequest, &codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: "This model does not support reasoning effort.", + } + } + if !slices.Contains(selectableEfforts, *effort) { + return http.StatusBadRequest, &codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: "Must be one of " + strings.Join(selectableEfforts, ", ") + ".", + } + } + return 0, nil +} + +func validateChatModelOverride( ctx context.Context, db database.Store, id *uuid.UUID, + effort *string, ) (int, *codersdk.Response) { if id == nil { + if effort != nil { + return http.StatusBadRequest, &codersdk.Response{ + Message: "reasoning_effort requires model_config_id.", + } + } return 0, nil } if *id == uuid.Nil { @@ -561,31 +632,31 @@ func validateChatModelOverrideID( Message: "Invalid model_config_id.", } } - _, err := lookupEnabledChatModelConfigByID(ctx, db, *id) - if err == nil { - return 0, nil - } - if xerrors.Is(err, sql.ErrNoRows) { - return http.StatusBadRequest, &codersdk.Response{ - Message: "Invalid model_config_id.", + modelConfig, err := lookupEnabledChatModelConfigByID(ctx, db, *id) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return http.StatusBadRequest, &codersdk.Response{ + Message: "Invalid model_config_id.", + } + } + return http.StatusInternalServerError, &codersdk.Response{ + Message: "Internal error validating model config override.", + Detail: err.Error(), } } - return http.StatusInternalServerError, &codersdk.Response{ - Message: "Internal error validating model config override.", - Detail: err.Error(), - } + return validateChatModelOverrideEffort(modelConfig, effort) } func (api *API) getChatModelOverrideConfig( ctx context.Context, settingName string, getter func(context.Context) (string, error), -) (*uuid.UUID, bool, error) { +) (*uuid.UUID, *string, bool, error) { raw, err := getter(ctx) if err != nil { - return nil, false, xerrors.Errorf("get %s model override: %w", settingName, err) + return nil, nil, false, xerrors.Errorf("get %s model override: %w", settingName, err) } - id, err := parseChatModelOverride(raw) + 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. @@ -596,9 +667,9 @@ func (api *API) getChatModelOverrideConfig( slog.F("raw_value", raw), slog.Error(err), ) - return nil, true, nil + return nil, nil, true, nil } - return id, false, nil + return parsed.modelConfigID, parsed.reasoningEffort, false, nil } func parseChatModelOverrideContext(raw string) (codersdk.ChatModelOverrideContext, error) { @@ -648,25 +719,26 @@ func (api *API) chatModelOverrideSiteConfig( func (api *API) readChatModelOverrideConfig( ctx context.Context, overrideContext codersdk.ChatModelOverrideContext, -) (*uuid.UUID, bool, string, error) { +) (*uuid.UUID, *string, bool, string, error) { siteConfig, err := api.chatModelOverrideSiteConfig(overrideContext) if err != nil { - return nil, false, "", err + return nil, nil, false, "", err } - id, isMalformed, err := api.getChatModelOverrideConfig(ctx, siteConfig.label, siteConfig.getter) - return id, isMalformed, siteConfig.label, 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)) + return siteConfig.label, siteConfig.upsert(ctx, formatChatModelOverride(modelConfigID, reasoningEffort)) } var chatPersonalModelOverrideContexts = []codersdk.ChatPersonalModelOverrideContext{ @@ -716,9 +788,14 @@ func parseChatPersonalModelOverrideValue( func formatChatPersonalModelOverrideValue( mode codersdk.ChatPersonalModelOverrideMode, modelConfigID string, + reasoningEffort *string, ) string { if mode == codersdk.ChatPersonalModelOverrideModeModel { - return string(mode) + ":" + strings.TrimSpace(modelConfigID) + value := string(mode) + ":" + strings.TrimSpace(modelConfigID) + if reasoningEffort != nil { + value += ":" + *reasoningEffort + } + return value } return string(mode) } @@ -730,15 +807,18 @@ func chatPersonalModelOverrideResponse( ) 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, - IsSet: isSet, - IsMalformed: parsed.Malformed, + Context: overrideContext, + Mode: parsed.Mode, + ModelConfigID: modelConfigID, + ReasoningEffort: reasoningEffort, + IsSet: isSet, + IsMalformed: parsed.Malformed, } } @@ -750,7 +830,7 @@ func (api *API) chatPersonalModelOverrideDeploymentDefaultResponse( // 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, isMalformed, _, err := api.readChatModelOverrideConfig( + modelConfigID, reasoningEffort, isMalformed, _, err := api.readChatModelOverrideConfig( dbauthz.AsSystemRestricted(ctx), overrideContext, ) @@ -758,9 +838,10 @@ func (api *API) chatPersonalModelOverrideDeploymentDefaultResponse( return codersdk.ChatModelOverrideResponse{}, err } return codersdk.ChatModelOverrideResponse{ - Context: overrideContext, - ModelConfigID: formatChatModelOverride(modelConfigID), - IsMalformed: isMalformed, + Context: overrideContext, + ModelConfigID: formatChatModelOverride(modelConfigID, nil), + ReasoningEffort: reasoningEffort, + IsMalformed: isMalformed, }, nil } @@ -945,9 +1026,9 @@ func (api *API) userCanUseChatModelConfig( ctx context.Context, userID uuid.UUID, modelConfigID uuid.UUID, -) (chatModelConfigUnavailableReason, error) { +) (database.ChatModelConfig, chatModelConfigUnavailableReason, error) { if modelConfigID == uuid.Nil { - return chatModelConfigUnavailableModelNotFoundOrDisabled, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil } //nolint:gocritic // Non-admin users need deployment config validation. model, err := api.Database.GetChatModelConfigByID( @@ -956,63 +1037,63 @@ func (api *API) userCanUseChatModelConfig( ) if err != nil { if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { - return chatModelConfigUnavailableModelNotFoundOrDisabled, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil } - return chatModelConfigAvailable, err + return database.ChatModelConfig{}, chatModelConfigAvailable, err } if !model.Enabled { - return chatModelConfigUnavailableModelNotFoundOrDisabled, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil } availability, err := api.getUserChatProviderAvailability(ctx, userID) if err != nil { - return chatModelConfigAvailable, err + return database.ChatModelConfig{}, chatModelConfigAvailable, err } if model.AIProviderID.Valid { providerID := model.AIProviderID.UUID if _, ok := availability.enabledProviderIDs[providerID]; !ok { - return chatModelConfigUnavailableProviderDisabled, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableProviderDisabled, nil } providerStatus, ok := availability.providerStatusByID[providerID] if !ok { - return chatModelConfigUnavailableProviderDisabled, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableProviderDisabled, nil } if !providerStatus.Available { - return chatModelConfigUnavailableCredentialsMissing, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableCredentialsMissing, nil } - return chatModelConfigAvailable, nil + return model, chatModelConfigAvailable, nil } // Active configs always carry a provider FK (CHECK // chat_model_configs_ai_provider_required_when_active), so an unset FK // means the config is not usable. - return chatModelConfigUnavailableModelNotFoundOrDisabled, nil + return database.ChatModelConfig{}, chatModelConfigUnavailableModelNotFoundOrDisabled, nil } func (api *API) validateUserChatModelConfigAvailable( ctx context.Context, userID uuid.UUID, modelConfigID uuid.UUID, -) (int, *codersdk.Response) { - reason, err := api.userCanUseChatModelConfig(ctx, userID, modelConfigID) +) (database.ChatModelConfig, int, *codersdk.Response) { + modelConfig, reason, err := api.userCanUseChatModelConfig(ctx, userID, modelConfigID) if err != nil { - return http.StatusInternalServerError, &codersdk.Response{ + return database.ChatModelConfig{}, http.StatusInternalServerError, &codersdk.Response{ Message: "Internal error validating model config override.", Detail: err.Error(), } } switch reason { case chatModelConfigAvailable: - return 0, nil + return modelConfig, 0, nil case chatModelConfigUnavailableModelNotFoundOrDisabled: - return http.StatusBadRequest, &codersdk.Response{ + return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{ Message: "Invalid model_config_id: model config not found or disabled.", } case chatModelConfigUnavailableCredentialsMissing: - return http.StatusBadRequest, &codersdk.Response{ + return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{ Message: "Invalid model_config_id: provider credentials unavailable for this model.", } case chatModelConfigUnavailableProviderDisabled: - return http.StatusBadRequest, &codersdk.Response{ + return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{ Message: "Invalid model_config_id: provider is not enabled for this model.", } default: @@ -1022,7 +1103,7 @@ func (api *API) validateUserChatModelConfigAvailable( slog.F("model_config_id", modelConfigID), slog.F("reason", reason), ) - return http.StatusBadRequest, &codersdk.Response{ + return database.ChatModelConfig{}, http.StatusBadRequest, &codersdk.Response{ Message: "Invalid model_config_id.", } } @@ -1119,7 +1200,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { title := chatTitleFromMessage(titleSource) - modelConfigID, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, apiKey.UserID, req) + modelConfigID, personalOverrideEffort, modelConfigStatus, modelConfigError := api.resolveCreateChatModelConfigID(ctx, apiKey.UserID, req) if modelConfigError != nil { httpapi.Write(ctx, rw, modelConfigStatus, *modelConfigError) return @@ -1235,12 +1316,22 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { } } + reasoningEffort := req.ReasoningEffort + if reasoningEffort == nil { + reasoningEffort = personalOverrideEffort + } + if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) { + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*reasoningEffort)) + return + } + chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{ OrganizationID: req.OrganizationID, OwnerID: apiKey.UserID, WorkspaceID: workspaceSelection.WorkspaceID, Title: title, ModelConfigID: modelConfigID, + ReasoningEffort: reasoningEffort, PlanMode: planModeToNullChatPlanMode(req.PlanMode), ClientType: clientType, SystemPrompt: req.SystemPrompt, @@ -3154,17 +3245,24 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { modelConfigID = *req.ModelConfigID } + reasoningEffort := req.ReasoningEffort + if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) { + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*reasoningEffort)) + return + } + sendResult, sendErr := api.chatDaemon.SendMessage( ctx, chatd.SendMessageOptions{ - ChatID: chatID, - CreatedBy: apiKey.UserID, - Content: contentBlocks, - ModelConfigID: modelConfigID, - APIKeyID: apiKey.ID, - BusyBehavior: busyBehavior, - PlanMode: sendPlanMode, - MCPServerIDs: req.MCPServerIDs, + ChatID: chatID, + CreatedBy: apiKey.UserID, + Content: contentBlocks, + ModelConfigID: modelConfigID, + ReasoningEffort: reasoningEffort, + APIKeyID: apiKey.ID, + BusyBehavior: busyBehavior, + PlanMode: sendPlanMode, + MCPServerIDs: req.MCPServerIDs, }, ) if sendErr != nil { @@ -3312,6 +3410,12 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { editModelConfigID = *req.ModelConfigID } + editReasoningEffort := req.ReasoningEffort + if editReasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*editReasoningEffort) { + httpapi.Write(ctx, rw, http.StatusBadRequest, invalidReasoningEffortResponse(*editReasoningEffort)) + return + } + editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{ ChatID: chat.ID, CreatedBy: apiKey.UserID, @@ -3319,6 +3423,7 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { Content: contentBlocks, APIKeyID: apiKey.ID, ModelConfigID: editModelConfigID, + ReasoningEffort: editReasoningEffort, }) if editErr != nil { if maybeWriteLimitErr(ctx, rw, editErr) { @@ -4614,25 +4719,26 @@ func (api *API) resolveCreateChatModelConfigID( ctx context.Context, userID uuid.UUID, req codersdk.CreateChatRequest, -) (uuid.UUID, int, *codersdk.Response) { +) (uuid.UUID, *string, int, *codersdk.Response) { if req.ModelConfigID != nil { if *req.ModelConfigID == uuid.Nil { - return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ + return uuid.Nil, nil, http.StatusBadRequest, &codersdk.Response{ Message: "Invalid model config ID.", } } - return *req.ModelConfigID, 0, nil + return *req.ModelConfigID, nil, 0, nil } personalOverridesEnabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx) if err != nil { - return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ + return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ Message: "Failed to resolve chat model config.", Detail: err.Error(), } } if !personalOverridesEnabled { - return api.defaultCreateChatModelConfigID(ctx) + id, status, resp := api.defaultCreateChatModelConfigID(ctx) + return id, nil, status, resp } raw, err := api.Database.GetUserChatPersonalModelOverride(ctx, database.GetUserChatPersonalModelOverrideParams{ @@ -4640,7 +4746,7 @@ func (api *API) resolveCreateChatModelConfigID( Key: chatd.ChatPersonalModelOverrideKey(codersdk.ChatPersonalModelOverrideContextRoot), }) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ + return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ Message: "Failed to resolve chat model config.", Detail: err.Error(), } @@ -4663,19 +4769,19 @@ func (api *API) resolveCreateChatModelConfigID( // For root context, chat_default and the defensive default // case both fall through to the deployment default model below. case codersdk.ChatPersonalModelOverrideModeModel: - reason, err := api.userCanUseChatModelConfig( + _, reason, err := api.userCanUseChatModelConfig( ctx, userID, parsed.ModelConfigID, ) if err != nil { - return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ + return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ Message: "Failed to resolve chat model config.", Detail: err.Error(), } } if reason == chatModelConfigAvailable { - return parsed.ModelConfigID, 0, nil + return parsed.ModelConfigID, parsed.ReasoningEffort, 0, nil } api.Logger.Debug( ctx, @@ -4694,7 +4800,8 @@ func (api *API) resolveCreateChatModelConfigID( } } - return api.defaultCreateChatModelConfigID(ctx) + id, status, resp := api.defaultCreateChatModelConfigID(ctx) + return id, nil, status, resp } func (api *API) defaultCreateChatModelConfigID( @@ -4922,7 +5029,7 @@ func (api *API) getChatModelOverride(rw http.ResponseWriter, r *http.Request) { return } - modelConfigID, isMalformed, label, err := api.readChatModelOverrideConfig(ctx, overrideContext) + modelConfigID, reasoningEffort, isMalformed, label, err := api.readChatModelOverrideConfig(ctx, overrideContext) if err != nil { if label == "" { label = string(overrideContext) @@ -4935,9 +5042,10 @@ func (api *API) getChatModelOverride(rw http.ResponseWriter, r *http.Request) { } resp := codersdk.ChatModelOverrideResponse{ - Context: overrideContext, - ModelConfigID: formatChatModelOverride(modelConfigID), - IsMalformed: isMalformed, + Context: overrideContext, + ModelConfigID: formatChatModelOverride(modelConfigID, nil), + ReasoningEffort: reasoningEffort, + IsMalformed: isMalformed, } httpapi.Write(ctx, rw, http.StatusOK, resp) @@ -4960,22 +5068,34 @@ func (api *API) putChatModelOverride(rw http.ResponseWriter, r *http.Request) { return } - modelConfigID, err := parseChatModelOverride(req.ModelConfigID) - 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), - }) - return + var modelConfigID *uuid.UUID + trimmedModelConfigID := strings.TrimSpace(req.ModelConfigID) + if trimmedModelConfigID != "" { + if strings.Contains(trimmedModelConfigID, ":") { + 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 + } + parsedModelConfigID, err := uuid.Parse(trimmedModelConfigID) + 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), + }) + return + } + modelConfigID = &parsedModelConfigID } - status, resp := validateChatModelOverrideID(ctx, api.Database, modelConfigID) + status, resp := validateChatModelOverride(ctx, api.Database, modelConfigID, req.ReasoningEffort) if resp != nil { httpapi.Write(ctx, rw, status, *resp) return } - label, err := api.upsertChatModelOverrideConfig(ctx, overrideContext, modelConfigID) + label, err := api.upsertChatModelOverrideConfig(ctx, overrideContext, modelConfigID, req.ReasoningEffort) if err != nil { if label == "" { label = string(overrideContext) @@ -5153,6 +5273,7 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http } modelConfigID := "" + reasoningEffort := req.ReasoningEffort rawModelConfigID := strings.TrimSpace(req.ModelConfigID) switch req.Mode { case codersdk.ChatPersonalModelOverrideModeChatDefault: @@ -5162,6 +5283,12 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http }) return } + if reasoningEffort != nil { + 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{ @@ -5175,6 +5302,12 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http }) return } + if reasoningEffort != nil { + 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{ @@ -5196,7 +5329,12 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http }) return } - status, resp := api.validateUserChatModelConfigAvailable(ctx, apiKey.UserID, parsedModelConfigID) + modelConfig, status, resp := api.validateUserChatModelConfigAvailable(ctx, apiKey.UserID, parsedModelConfigID) + if resp != nil { + httpapi.Write(ctx, rw, status, *resp) + return + } + status, resp = validateChatModelOverrideEffort(modelConfig, reasoningEffort) if resp != nil { httpapi.Write(ctx, rw, status, *resp) return @@ -5212,7 +5350,7 @@ func (api *API) putUserChatPersonalModelOverride(rw http.ResponseWriter, r *http if err := api.Database.UpsertUserChatPersonalModelOverride(ctx, database.UpsertUserChatPersonalModelOverrideParams{ UserID: apiKey.UserID, Key: chatd.ChatPersonalModelOverrideKey(overrideContext), - Value: formatChatPersonalModelOverrideValue(req.Mode, modelConfigID), + Value: formatChatPersonalModelOverrideValue(req.Mode, modelConfigID, reasoningEffort), }); err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error updating user personal model override.", @@ -7531,6 +7669,13 @@ func marshalChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) (json return encoded, nil } +func invalidReasoningEffortResponse(value string) codersdk.Response { + return codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: fmt.Sprintf("Invalid value %q, must be one of %s", value, allowedReasoningEffortValues), + } +} + func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) error { if modelConfig == nil { return nil @@ -7575,10 +7720,10 @@ func validateChatModelReasoningEffortConfig(modelConfig *codersdk.ChatModelCallC return xerrors.New("reasoning_effort.default and reasoning_effort.max must both be set") } if !chatprovider.IsValidReasoningEffort(*config.Default) { - return xerrors.New("reasoning_effort.default must be one of none, minimal, low, medium, high, xhigh, max") + return xerrors.Errorf("reasoning_effort.default %q must be one of %s", *config.Default, allowedReasoningEffortValues) } if !chatprovider.IsValidReasoningEffort(*config.Max) { - return xerrors.New("reasoning_effort.max must be one of none, minimal, low, medium, high, xhigh, max") + return xerrors.Errorf("reasoning_effort.max %q must be one of %s", *config.Max, allowedReasoningEffortValues) } if !chatprovider.ReasoningEffortLessOrEqual(*config.Default, *config.Max) { return xerrors.New("reasoning_effort.default must not exceed reasoning_effort.max") diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 4ac1e3f5d4352..1468cc52e4689 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -157,14 +157,29 @@ func insertTestChatQueuedMessage( apiKeyID string, ) database.ChatQueuedMessage { t.Helper() + return insertTestChatQueuedMessageWithReasoningEffort(ctx, t, db, chatID, content, modelConfigID, apiKeyID, "") +} + +func insertTestChatQueuedMessageWithReasoningEffort( + ctx context.Context, + t testing.TB, + db database.Store, + chatID uuid.UUID, + content json.RawMessage, + modelConfigID uuid.UUID, + apiKeyID string, + reasoningEffort string, +) database.ChatQueuedMessage { + t.Helper() queued, err := db.InsertChatQueuedMessage( dbauthz.AsSystemRestricted(ctx), database.InsertChatQueuedMessageParams{ - ChatID: chatID, - Content: content, - ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, - APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""}, + ChatID: chatID, + Content: content, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ReasoningEffort: database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffort(reasoningEffort), Valid: reasoningEffort != ""}, + APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""}, }, ) require.NoError(t, err) @@ -416,6 +431,65 @@ func TestPostChats(t *testing.T) { requireSDKError(t, err, http.StatusForbidden) }) + t.Run("WithReasoningEffort", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: user.OrganizationID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "think hard from the start", + }, + }, + ReasoningEffort: ptr.Ref("high"), + }) + require.NoError(t, err) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, storedChat.LastReasoningEffort.ChatReasoningEffort) + + messages, err := db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(ctx), database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + AfterID: 0, + }) + require.NoError(t, err) + userMsg := findUserMessage(t, messages) + require.True(t, userMsg.ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, userMsg.ReasoningEffort.ChatReasoningEffort) + }) + + t.Run("RejectsInvalidReasoningEffort", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + user := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: user.OrganizationID, + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }, + }, + ReasoningEffort: ptr.Ref(" HIGH "), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, `Invalid value " HIGH "`) + require.Contains(t, sdkErr.Detail, "must be one of none, minimal, low, medium, high, xhigh, max") + }) + t.Run("HidesSystemPromptMessages", func(t *testing.T) { t.Parallel() @@ -3825,7 +3899,7 @@ func TestCreateChatModelConfig(t *testing.T) { require.Equal(t, "Invalid model config.", sdkErr.Message) require.Equal( t, - "reasoning_effort.default must be one of none, minimal, low, medium, high, xhigh, max", + `reasoning_effort.default " HIGH " must be one of none, minimal, low, medium, high, xhigh, max`, sdkErr.Detail, ) }) @@ -6796,6 +6870,137 @@ func TestSendMessageWithModelOverrideUpdatesLastModelConfigID(t *testing.T) { require.Equal(t, modelConfigB.ID, userMsg.ModelConfigID.UUID) } +func TestSendMessageWithReasoningEffortUpdatesLastReasoningEffort(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "per-turn reasoning effort", + }) + + resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "think hard about this", + }}, + ReasoningEffort: ptr.Ref("high"), + }) + require.NoError(t, err) + require.False(t, resp.Queued) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, storedChat.LastReasoningEffort.ChatReasoningEffort) + + messages, err := db.GetChatMessagesByChatID(dbauthz.AsSystemRestricted(ctx), database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + AfterID: 0, + }) + require.NoError(t, err) + userMsg := findUserMessage(t, messages) + require.True(t, userMsg.ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, userMsg.ReasoningEffort.ChatReasoningEffort) + + // A follow-up message without a reasoning effort leaves the chat's + // last effort unchanged. + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "and another thing", + }}, + BusyBehavior: codersdk.ChatBusyBehaviorInterrupt, + }) + require.NoError(t, err) + + storedChat, err = db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, storedChat.LastReasoningEffort.ChatReasoningEffort) +} + +func TestSendMessageRejectsInvalidReasoningEffort(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "invalid reasoning effort", + }) + + _, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }}, + ReasoningEffort: ptr.Ref(" HIGH "), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, `Invalid value " HIGH "`) + require.Contains(t, sdkErr.Detail, "must be one of none, minimal, low, medium, high, xhigh, max") +} + +func TestSendMessageQueuesReasoningEffort(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + user := coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + LastModelConfigID: modelConfig.ID, + Title: "queued reasoning effort", + }) + + _, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusRunning, + WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + StartedAt: sql.NullTime{Time: time.Now(), Valid: true}, + HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true}, + LastError: pqtype.NullRawMessage{}, + }) + require.NoError(t, err) + + resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "queue this with effort", + }}, + ReasoningEffort: ptr.Ref("high"), + BusyBehavior: codersdk.ChatBusyBehaviorQueue, + }) + require.NoError(t, err) + require.True(t, resp.Queued) + require.NotNil(t, resp.QueuedMessage) + queuedMessages, err := db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.Len(t, queuedMessages, 1) + require.True(t, queuedMessages[0].ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, queuedMessages[0].ReasoningEffort.ChatReasoningEffort) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.False(t, storedChat.LastReasoningEffort.Valid) +} + func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { t.Parallel() @@ -6827,8 +7032,9 @@ func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { Type: codersdk.ChatInputPartTypeText, Text: "queue this with model b", }}, - ModelConfigID: ptr.Ref(modelConfigB.ID), - BusyBehavior: codersdk.ChatBusyBehaviorQueue, + ModelConfigID: ptr.Ref(modelConfigB.ID), + ReasoningEffort: ptr.Ref("high"), + BusyBehavior: codersdk.ChatBusyBehaviorQueue, }) require.NoError(t, err) require.True(t, resp.Queued) @@ -6841,10 +7047,13 @@ func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { require.Len(t, queuedMessages, 1) require.True(t, queuedMessages[0].ModelConfigID.Valid) require.Equal(t, modelConfigB.ID, queuedMessages[0].ModelConfigID.UUID) + require.True(t, queuedMessages[0].ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, queuedMessages[0].ReasoningEffort.ChatReasoningEffort) storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) require.Equal(t, modelConfigA.ID, storedChat.LastModelConfigID) + require.False(t, storedChat.LastReasoningEffort.Valid) } func TestQueuedMessageWithoutOverrideCapturesEnqueueTimeModel(t *testing.T) { @@ -7930,6 +8139,94 @@ func TestPatchChatMessage(t *testing.T) { require.False(t, foundOriginalInChat) }) + t.Run("ReasoningEffort", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + requested *string + want string + }{ + {name: "PreservesByDefault", want: "low"}, + {name: "Overrides", requested: ptr.Ref("high"), want: "high"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "before edit effort", + }}, + ReasoningEffort: ptr.Ref("low"), + }) + require.NoError(t, err) + + messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + userMessageID := messagesResult.Messages[0].ID + + edited, err := client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "after edit effort", + }}, + ReasoningEffort: tc.requested, + }) + require.NoError(t, err) + storedMessage, err := db.GetChatMessageByID(dbauthz.AsSystemRestricted(ctx), edited.Message.ID) + require.NoError(t, err) + require.True(t, storedMessage.ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffort(tc.want), storedMessage.ReasoningEffort.ChatReasoningEffort) + + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffort(tc.want), storedChat.LastReasoningEffort.ChatReasoningEffort) + }) + } + }) + + t.Run("RejectsInvalidReasoningEffort", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "before invalid effort edit", + }}, + }) + require.NoError(t, err) + + messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + userMessageID := messagesResult.Messages[0].ID + + _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "after invalid effort edit", + }}, + ReasoningEffort: ptr.Ref(" HIGH "), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, `Invalid value " HIGH "`) + require.Contains(t, sdkErr.Detail, "must be one of none, minimal, low, medium, high, xhigh, max") + }) + t.Run("PreservesFileID", func(t *testing.T) { t.Parallel() @@ -11169,6 +11466,34 @@ func createAdditionalChatModelConfig( model string, ) codersdk.ChatModelConfig { t.Helper() + return createAdditionalChatModelConfigWithModelConfig(t, client, provider, model, nil) +} + +func createAdditionalChatModelConfigWithReasoningEffort( + t *testing.T, + client *codersdk.ExperimentalClient, + provider string, + model string, + defaultEffort string, + maxEffort string, +) codersdk.ChatModelConfig { + t.Helper() + return createAdditionalChatModelConfigWithModelConfig(t, client, provider, model, &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(defaultEffort), + Max: ptr.Ref(maxEffort), + }, + }) +} + +func createAdditionalChatModelConfigWithModelConfig( + t *testing.T, + client *codersdk.ExperimentalClient, + provider string, + model string, + modelCallConfig *codersdk.ChatModelCallConfig, +) codersdk.ChatModelConfig { + t.Helper() ctx := testutil.Context(t, testutil.WaitLong) aiProvider := createAIProviderForTest(t, client, provider, "test-api-key") @@ -11179,6 +11504,7 @@ func createAdditionalChatModelConfig( Model: model, ContextLimit: &contextLimit, IsDefault: &isDefault, + ModelConfig: modelCallConfig, }) require.NoError(t, err) return modelConfig @@ -11744,9 +12070,10 @@ func TestChatModelOverrides(t *testing.T) { t.Parallel() type overrideResponse struct { - context codersdk.ChatModelOverrideContext - modelConfigID string - isMalformed bool + context codersdk.ChatModelOverrideContext + modelConfigID string + reasoningEffort *string + isMalformed bool } type settingTest struct { @@ -11770,24 +12097,37 @@ func TestChatModelOverrides(t *testing.T) { return overrideResponse{}, err } return overrideResponse{ - context: resp.Context, - modelConfigID: resp.ModelConfigID, - isMalformed: resp.IsMalformed, + context: resp.Context, + modelConfigID: resp.ModelConfigID, + reasoningEffort: resp.ReasoningEffort, + isMalformed: resp.IsMalformed, }, nil } - putOverride := func( + 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}, + 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{ { @@ -11833,6 +12173,14 @@ func TestChatModelOverrides(t *testing.T) { coderdtest.TestChatProviderOpenAICompat, "gpt-4.1-mini-"+string(setting.context), ) + reasoningModel := createAdditionalChatModelConfigWithReasoningEffort( + t, + adminClient, + coderdtest.TestChatProviderOpenAICompat, + "gpt-4.1-reasoning-"+string(setting.context), + "medium", + "high", + ) disabledModel := createDisabledChatModelConfig( t, adminClient, @@ -11886,6 +12234,62 @@ func TestChatModelOverrides(t *testing.T) { 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) @@ -12055,11 +12459,21 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { }) require.NoError(t, err) contextLimit := int64(4096) - modelConfig, err := adminClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + modelConfigRequest := codersdk.CreateChatModelConfigRequest{ AIProviderID: &modelProvider.ID, Model: "claude-personal-" + uuid.NewString(), ContextLimit: &contextLimit, - }) + } + modelConfig, err := adminClient.CreateChatModelConfig(ctx, 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.CreateChatModelConfig(ctx, modelConfigRequest) require.NoError(t, err) err = adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{ ModelConfigID: modelConfig.ID.String(), @@ -12109,11 +12523,12 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { return codersdk.ChatPersonalModelOverride{} } } - assertOverride := func( + assertOverrideWithEffort := func( resp codersdk.UserChatPersonalModelOverridesResponse, overrideContext codersdk.ChatPersonalModelOverrideContext, mode codersdk.ChatPersonalModelOverrideMode, modelConfigID string, + reasoningEffort *string, isSet bool, isMalformed bool, ) { @@ -12122,13 +12537,26 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { 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, 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() @@ -12143,6 +12571,7 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { } 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( @@ -12199,8 +12628,20 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { t.Run("GETIncludesDeploymentDefaults", func(t *testing.T) { resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) require.NoError(t, err) - assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextGeneral, modelConfig.ID.String(), false) - assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextExplore, defaultModelConfig.ID.String(), false) + assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextGeneral, modelConfig.ID.String(), nil, false) + assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextExplore, defaultModelConfig.ID.String(), nil, false) + }) + + t.Run("GETIncludesDeploymentDefaultReasoningEffort", func(t *testing.T) { + err := adminClient.UpdateChatModelOverride(ctx, codersdk.ChatModelOverrideContextGeneral, codersdk.UpdateChatModelOverrideRequest{ + ModelConfigID: reasoningModelConfig.ID.String(), + ReasoningEffort: ptr.Ref("high"), + }) + require.NoError(t, err) + + resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + require.NoError(t, err) + assertDeploymentDefault(resp, codersdk.ChatModelOverrideContextGeneral, reasoningModelConfig.ID.String(), ptr.Ref("high"), false) }) t.Run("PUTDisabledReturns403AndPreservesRows", func(t *testing.T) { @@ -12311,6 +12752,57 @@ func TestUserChatPersonalModelOverrides(t *testing.T) { } }) + 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"), + }) + require.NoError(t, err) + + require.Equal(t, "model:"+reasoningModelConfig.ID.String()+":high", getRaw(codersdk.ChatPersonalModelOverrideContextGeneral)) + resp, err := memberClient.GetUserChatPersonalModelOverrides(ctx) + 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) { cases := []struct { name string @@ -12528,6 +13020,31 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { require.Equal(t, overrideModel.ID, chat.LastModelConfigID) }) + t.Run("RootModelOverrideUsesSavedReasoningEffort", func(t *testing.T) { + reasoningModel, err := adminClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &overrideProvider.ID, + Model: "claude-root-personal-reasoning-" + uuid.NewString(), + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("medium"), + Max: ptr.Ref("high"), + }, + }, + }) + require.NoError(t, err) + err = adminClient.UpdateUserChatPersonalModelOverride(ctx, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, + ModelConfigID: reasoningModel.ID.String(), + ReasoningEffort: ptr.Ref("high"), + }) + require.NoError(t, err) + + chat := createChat(adminClient, "root model override uses saved reasoning effort", nil) + require.Equal(t, reasoningModel.ID, chat.LastModelConfigID) + require.Equal(t, ptr.Ref("high"), chat.LastReasoningEffort) + }) + t.Run("UnavailableRootModelFallsBackToDefault", func(t *testing.T) { upsertRootRaw(firstUser.UserID, "model:"+disabledModel.ID.String()) chat := createChat(adminClient, "disabled root model falls back", nil) diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index e4b4cba7f6627..d1519231ff60e 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -827,7 +827,9 @@ The generation goroutine supports: ##### Reasoning effort -Model configs may carry a `reasoning_effort` config (`{default, max}`) inside `chat_model_configs.options`. During generation preparation, the effective effort is resolved from 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. The resolved value is injected into the provider-native options with `chatprovider.ApplyReasoningEffort` after provider option conversion. +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`. + +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 with `chatprovider.ApplyReasoningEffort` after provider option conversion. #### Interrupt goroutine diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index be82d40fe5f39..31c91abbabdc1 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -398,6 +398,12 @@ func (p *Server) newAdvisorRuntime( } advisorCallConfig.MaxOutputTokens = ptr.Ref(maxOutputTokens) + // The advisor has no per-turn effort selection; its model config's + // default effort applies. + advisorReasoningEffort := chatprovider.ResolveReasoningEffort( + nil, + advisorCallConfig.ReasoningEffort, + ) providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( advisorModel, advisorCallConfig.ProviderOptions, @@ -405,7 +411,7 @@ func (p *Server) newAdvisorRuntime( providerOptions = chatprovider.ApplyReasoningEffort( advisorModel, providerOptions, - chatprovider.ResolveReasoningEffort(advisorCallConfig.ReasoningEffort), + advisorReasoningEffort, ) rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{ @@ -1136,6 +1142,7 @@ type CreateOptions struct { RootChatID uuid.NullUUID Title string ModelConfigID uuid.UUID + ReasoningEffort *string ChatMode database.NullChatMode PlanMode database.NullChatPlanMode ClientType database.ChatClientType @@ -1162,14 +1169,15 @@ const ( // SendMessageOptions controls user message insertion with busy-state behavior. type SendMessageOptions struct { - ChatID uuid.UUID - CreatedBy uuid.UUID - Content []codersdk.ChatMessagePart - ModelConfigID uuid.UUID - APIKeyID string - BusyBehavior SendMessageBusyBehavior - PlanMode *database.NullChatPlanMode - MCPServerIDs *[]uuid.UUID + ChatID uuid.UUID + CreatedBy uuid.UUID + Content []codersdk.ChatMessagePart + ModelConfigID uuid.UUID + ReasoningEffort *string + APIKeyID string + BusyBehavior SendMessageBusyBehavior + PlanMode *database.NullChatPlanMode + MCPServerIDs *[]uuid.UUID } // SendMessageResult contains the outcome of user message processing. @@ -1190,7 +1198,8 @@ type EditMessageOptions struct { // ModelConfigID, when non-zero, overrides the model used for // the replacement user message. When set to uuid.Nil the // original message's model is preserved. - ModelConfigID uuid.UUID + ModelConfigID uuid.UUID + ReasoningEffort *string } // EditMessageResult contains the replacement user message and chat status. @@ -1304,7 +1313,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C initialMessages = append(initialMessages, systemMessage(userPromptContent, opts.ModelConfigID)) } initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, opts.ModelConfigID)) - initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, opts.ModelConfigID, opts.OwnerID, opts.APIKeyID)) + initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, opts.ModelConfigID, opts.OwnerID, opts.APIKeyID, opts.ReasoningEffort)) result, err := chatstate.CreateChat(ctx, p.db, p.pubsub, chatstate.CreateChatInput{ OrganizationID: opts.OrganizationID, @@ -1451,7 +1460,7 @@ func (p *Server) SendMessage( // Queue capacity is enforced inside tx.SendMessage; this // wrapper only propagates the typed error. sendResult, err := tx.SendMessage(chatstate.SendMessageInput{ - Message: userMessageWithAPIKeyID(content, modelConfigID, messageCreatedBy, opts.APIKeyID), + Message: userMessageWithAPIKeyID(content, modelConfigID, messageCreatedBy, opts.APIKeyID, opts.ReasoningEffort), BusyBehavior: busyBehaviorToChatState(busyBehavior), }) if err != nil { @@ -1659,12 +1668,18 @@ func (p *Server) EditMessage( modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} } + var reasoningEffortOverride database.NullChatReasoningEffort + if opts.ReasoningEffort != nil && *opts.ReasoningEffort != "" { + reasoningEffortOverride = database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffort(*opts.ReasoningEffort), Valid: true} + } + editResult, err := tx.EditMessage(chatstate.EditMessageInput{ - MessageID: opts.EditedMessageID, - CreatedBy: opts.CreatedBy, - Content: content, - ModelConfigIDOverride: modelOverride, - APIKeyID: sql.NullString{String: opts.APIKeyID, Valid: opts.APIKeyID != ""}, + MessageID: opts.EditedMessageID, + CreatedBy: opts.CreatedBy, + Content: content, + ModelConfigIDOverride: modelOverride, + ReasoningEffortOverride: reasoningEffortOverride, + APIKeyID: sql.NullString{String: opts.APIKeyID, Valid: opts.APIKeyID != ""}, }) if err != nil { if errors.Is(err, chatstate.ErrEditedMessageNotUser) { @@ -2804,6 +2819,7 @@ func recordManualTitleUsage( CreatedBy: []uuid.UUID{chat.OwnerID}, APIKeyID: []string{activeAPIKeyID}, ModelConfigID: []uuid.UUID{modelConfig.ID}, + ReasoningEffort: []string{""}, Role: []database.ChatMessageRole{database.ChatMessageRoleAssistant}, Content: []string{content}, ContentVersion: []int16{chatprompt.CurrentContentVersion}, @@ -2932,6 +2948,7 @@ func appendMessageFields( params.CreatedBy = append(params.CreatedBy, msg.createdBy) params.APIKeyID = append(params.APIKeyID, apiKeyID) params.ModelConfigID = append(params.ModelConfigID, msg.modelConfigID) + params.ReasoningEffort = append(params.ReasoningEffort, "") params.Role = append(params.Role, msg.role) params.Content = append(params.Content, string(msg.content.RawMessage)) params.ContentVersion = append(params.ContentVersion, msg.contentVersion) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index b89fed6068b93..300ed4c647d19 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -46,6 +46,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/coderd/x/chatd" @@ -78,9 +79,9 @@ func testAPIKeyID(t testing.TB, db database.Store, userID uuid.UUID) string { } func chatAIGatewayTransportFactoryPointer(factory aibridge.TransportFactory) *atomic.Pointer[aibridge.TransportFactory] { - var ptr atomic.Pointer[aibridge.TransportFactory] - ptr.Store(&factory) - return &ptr + var factoryPtr atomic.Pointer[aibridge.TransportFactory] + factoryPtr.Store(&factory) + return &factoryPtr } func openAIToolName(tool chattest.OpenAITool) string { @@ -11663,6 +11664,65 @@ func TestEditMessagePreservesModelConfigByDefault(t *testing.T) { "edit without model override must not change last_model_config_id") } +func TestEditMessageReasoningEffort(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + requested *string + want string + }{ + {name: "PreservesByDefault", want: "low"}, + {name: "Overrides", requested: ptr.Ref("high"), want: "high"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + + chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + OrganizationID: org.ID, + Title: "edit-reasoning-effort", + ModelConfigID: model.ID, + ReasoningEffort: ptr.Ref("low"), + InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("original")}, + }) + require.NoError(t, err) + + initial, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ + ChatID: chat.ID, + AfterID: 0, + }) + require.NoError(t, err) + require.Len(t, initial, 1) + require.True(t, initial[0].ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortLow, initial[0].ReasoningEffort.ChatReasoningEffort) + + result, err := replica.EditMessage(ctx, chatd.EditMessageOptions{ + ChatID: chat.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + EditedMessageID: initial[0].ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited")}, + ReasoningEffort: tc.requested, + }) + require.NoError(t, err) + require.True(t, result.Message.ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffort(tc.want), result.Message.ReasoningEffort.ChatReasoningEffort) + + storedChat, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffort(tc.want), storedChat.LastReasoningEffort.ChatReasoningEffort) + }) + } +} + // TestEditMessageRejectsUnknownModelConfig verifies the edit handler // returns ErrInvalidModelConfigID when the requested model does not // exist, mirroring SendMessage's validation. @@ -11716,6 +11776,51 @@ func TestEditMessageRejectsUnknownModelConfig(t *testing.T) { require.Equal(t, modelA.ID, storedChat.LastModelConfigID) } +func TestPromoteQueuedPreservesReasoningEffort(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + replica := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: model.ID, + Title: "promote-reasoning-effort", + Status: database.ChatStatusError, + }) + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText("queued")}) + require.NoError(t, err) + queued, err := db.InsertChatQueuedMessageWithCreator(ctx, database.InsertChatQueuedMessageWithCreatorParams{ + ChatID: chat.ID, + Content: content.RawMessage, + ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, + ReasoningEffort: database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true}, + APIKeyID: sql.NullString{String: testAPIKeyID(t, db, user.ID), Valid: true}, + CreatedBy: user.ID, + }) + require.NoError(t, err) + require.True(t, queued.ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, queued.ReasoningEffort.ChatReasoningEffort) + + result, err := replica.PromoteQueued(ctx, chatd.PromoteQueuedOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + QueuedMessageID: queued.ID, + }) + require.NoError(t, err) + require.True(t, result.PromotedMessage.ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, result.PromotedMessage.ReasoningEffort.ChatReasoningEffort) + + storedChat, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, storedChat.LastReasoningEffort.ChatReasoningEffort) +} + // TestPromoteQueuedWhileRequiresActionMixedTools guards against func TestAcquireChatsSkipsArchivedPendingChat(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go index 869ec7e2df1a6..95ee8fe24ef7c 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -35,17 +35,30 @@ func ReasoningEffortLessOrEqual(a, b string) bool { } // ResolveReasoningEffort computes the effective reasoning effort for a -// generation. The config default is clamped to the config max on the -// global scale. Returns nil when the model config has no reasoning -// effort configured or when the default is unknown. +// generation. The requested per-turn value wins over the config's default, +// and the result is clamped to the config's max on the global scale. Returns +// nil when the model config has no reasoning effort configured, no usable +// value remains, or the max is unknown. func ResolveReasoningEffort( + requested *string, config *codersdk.ChatModelReasoningEffortConfig, ) *string { - if config == nil || config.Default == nil { + if config == nil { return nil } - rank, ok := reasoningEffortRank(*config.Default) + effective := requested + var rank int + var ok bool + if effective != nil { + rank, ok = reasoningEffortRank(*effective) + } + if !ok { + effective = config.Default + if effective != nil { + rank, ok = reasoningEffortRank(*effective) + } + } if !ok { return nil } @@ -58,7 +71,7 @@ func ResolveReasoningEffort( return config.Max } } - return config.Default + return effective } func SelectableReasoningEfforts( diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_test.go index 9e056b63891c0..d301c6d589312 100644 --- a/coderd/x/chatd/chatprovider/reasoningeffort_test.go +++ b/coderd/x/chatd/chatprovider/reasoningeffort_test.go @@ -21,26 +21,31 @@ func TestResolveReasoningEffort(t *testing.T) { t.Parallel() tests := []struct { - name string - config *codersdk.ChatModelReasoningEffortConfig - want *string + name string + requested *string + config *codersdk.ChatModelReasoningEffortConfig + want *string }{ - {name: "NilConfig"}, - {name: "DefaultUsed", config: effortConfig("medium", "high"), want: ptr.Ref("medium")}, - {name: "DefaultClampedToMax", config: effortConfig("xhigh", "medium"), want: ptr.Ref("medium")}, - {name: "InvalidDefaultReturnsNil", config: effortConfig(" HIGH ", "high")}, - {name: "InvalidMaxReturnsNil", config: effortConfig("high", " HIGH ")}, + {name: "NilConfigIgnoresRequested", requested: new(codersdk.ChatModelReasoningEffortHigh)}, + {name: "DefaultUsedWhenNoRequested", config: effortConfig("medium", "high"), want: new(codersdk.ChatModelReasoningEffortMedium)}, + {name: "RequestedWinsOverDefault", requested: new(codersdk.ChatModelReasoningEffortHigh), config: effortConfig("medium", "high"), want: new(codersdk.ChatModelReasoningEffortHigh)}, + {name: "RequestedWinsWithoutMax", requested: new(codersdk.ChatModelReasoningEffortHigh), config: effortConfig("medium", ""), want: new(codersdk.ChatModelReasoningEffortHigh)}, + {name: "RequestedClampedToMax", requested: new(codersdk.ChatModelReasoningEffortXHigh), config: effortConfig("low", "medium"), want: new(codersdk.ChatModelReasoningEffortMedium)}, + {name: "DefaultClampedToMax", config: effortConfig("xhigh", "medium"), want: new(codersdk.ChatModelReasoningEffortMedium)}, + {name: "InvalidRequestedFallsBackToDefault", requested: ptr.Ref(" HIGH "), config: effortConfig("low", "high"), want: new(codersdk.ChatModelReasoningEffortLow)}, + {name: "InvalidMaxReturnsNil", requested: new(codersdk.ChatModelReasoningEffortMedium), config: effortConfig("low", " HIGH ")}, {name: "EmptyConfigReturnsNil", config: &codersdk.ChatModelReasoningEffortConfig{}}, - {name: "MaxSupported", config: effortConfig("max", "max"), want: ptr.Ref("max")}, - {name: "NoneSupported", config: effortConfig("none", "xhigh"), want: ptr.Ref("none")}, - {name: "MaxOnlyConfigReturnsNil", config: effortConfig("", "medium")}, + {name: "MaxSupported", requested: new(codersdk.ChatModelReasoningEffortMax), config: effortConfig("medium", "max"), want: new(codersdk.ChatModelReasoningEffortMax)}, + {name: "NoneSupported", requested: new(codersdk.ChatModelReasoningEffortNone), config: effortConfig("medium", "xhigh"), want: new(codersdk.ChatModelReasoningEffortNone)}, + {name: "MaxOnlyConfigClampsRequested", requested: new(codersdk.ChatModelReasoningEffortXHigh), config: effortConfig("", "medium"), want: new(codersdk.ChatModelReasoningEffortMedium)}, + {name: "MaxOnlyConfigWithoutRequestedReturnsNil", config: effortConfig("", "medium")}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := chatprovider.ResolveReasoningEffort(tt.config) + got := chatprovider.ResolveReasoningEffort(tt.requested, tt.config) if tt.want == nil { require.Nil(t, got) return @@ -80,7 +85,7 @@ func TestApplyReasoningEffort(t *testing.T) { t.Run("CreatesOpenAIResponsesEntry", func(t *testing.T) { t.Parallel() - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, ptr.Ref("high")) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, nil, new(codersdk.ChatModelReasoningEffortHigh)) providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) require.True(t, ok, "%T", got[fantasyopenai.Name]) require.NotNil(t, providerOptions.ReasoningEffort) @@ -96,7 +101,7 @@ func TestApplyReasoningEffort(t *testing.T) { Store: ptr.Ref(true), }, } - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, ptr.Ref("high")) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-5"}, options, new(codersdk.ChatModelReasoningEffortHigh)) providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) require.True(t, ok, "%T", got[fantasyopenai.Name]) require.Same(t, options[fantasyopenai.Name], providerOptions) @@ -114,7 +119,7 @@ func TestApplyReasoningEffort(t *testing.T) { ParallelToolCalls: ptr.Ref(true), }, } - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, ptr.Ref("high")) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: fantasyopenai.Name, ModelName: "gpt-4"}, options, new(codersdk.ChatModelReasoningEffortHigh)) providerOptions, ok := got[fantasyopenai.Name].(*fantasyopenai.ProviderOptions) require.True(t, ok, "%T", got[fantasyopenai.Name]) require.Same(t, options[fantasyopenai.Name], providerOptions) @@ -220,7 +225,7 @@ func TestApplyReasoningEffort(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, ptr.Ref("high")) + got := chatprovider.ApplyReasoningEffort(&chattest.FakeModel{ProviderName: tt.provider}, tt.options, new(codersdk.ChatModelReasoningEffortHigh)) tt.assert(t, got) }) } diff --git a/coderd/x/chatd/chatstate/messages.go b/coderd/x/chatd/chatstate/messages.go index 90b2b9eae970d..50d84563f22e4 100644 --- a/coderd/x/chatd/chatstate/messages.go +++ b/coderd/x/chatd/chatstate/messages.go @@ -22,6 +22,7 @@ type Message struct { Content pqtype.NullRawMessage Visibility database.ChatMessageVisibility ModelConfigID uuid.NullUUID + ReasoningEffort database.NullChatReasoningEffort CreatedBy uuid.NullUUID ContentVersion int16 Compressed bool @@ -49,6 +50,7 @@ func toInsertParams(chatID uuid.UUID, messages []Message) database.InsertChatMes ChatID: chatID, CreatedBy: make([]uuid.UUID, n), ModelConfigID: make([]uuid.UUID, n), + ReasoningEffort: make([]string, n), APIKeyID: make([]string, n), Role: make([]database.ChatMessageRole, n), Content: make([]string, n), @@ -68,6 +70,9 @@ func toInsertParams(chatID uuid.UUID, messages []Message) database.InsertChatMes for i, m := range messages { params.CreatedBy[i] = nullUUIDOrNil(m.CreatedBy) params.ModelConfigID[i] = nullUUIDOrNil(m.ModelConfigID) + if m.ReasoningEffort.Valid { + params.ReasoningEffort[i] = string(m.ReasoningEffort.ChatReasoningEffort) + } if m.APIKeyID.Valid { params.APIKeyID[i] = m.APIKeyID.String } diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index a052a51df23f9..3b2f23479c175 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -231,11 +231,12 @@ func (tx *Tx) insertQueuedMessage(ownerFallback uuid.UUID, m Message) (database. return database.ChatQueuedMessage{}, err } return tx.store.InsertChatQueuedMessageWithCreator(tx.ctx, database.InsertChatQueuedMessageWithCreatorParams{ - ChatID: tx.chatID, - Content: rawContent, - ModelConfigID: m.ModelConfigID, - CreatedBy: createdBy, - APIKeyID: m.APIKeyID, + ChatID: tx.chatID, + Content: rawContent, + ModelConfigID: m.ModelConfigID, + ReasoningEffort: m.ReasoningEffort, + CreatedBy: createdBy, + APIKeyID: m.APIKeyID, }) } @@ -243,13 +244,14 @@ func (tx *Tx) insertQueuedMessage(ownerFallback uuid.UUID, m Message) (database. // suitable for promoting into active history. func messageFromQueuedRow(q database.ChatQueuedMessage) Message { return Message{ - Role: database.ChatMessageRoleUser, - Content: pqtype.NullRawMessage{RawMessage: q.Content, Valid: q.Content != nil}, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: q.ModelConfigID, - CreatedBy: uuid.NullUUID{UUID: q.CreatedBy, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - APIKeyID: q.APIKeyID, + Role: database.ChatMessageRoleUser, + Content: pqtype.NullRawMessage{RawMessage: q.Content, Valid: q.Content != nil}, + Visibility: database.ChatMessageVisibilityBoth, + ModelConfigID: q.ModelConfigID, + ReasoningEffort: q.ReasoningEffort, + CreatedBy: uuid.NullUUID{UUID: q.CreatedBy, Valid: true}, + ContentVersion: chatprompt.CurrentContentVersion, + APIKeyID: q.APIKeyID, } } @@ -484,11 +486,12 @@ func (tx *Tx) sendMessageInterruptRequiresAction(chat database.Chat, m Message) // EditMessageInput configures [Tx.EditMessage]. type EditMessageInput struct { - MessageID int64 - CreatedBy uuid.UUID - Content pqtype.NullRawMessage - ModelConfigIDOverride uuid.NullUUID - APIKeyID sql.NullString + MessageID int64 + CreatedBy uuid.UUID + Content pqtype.NullRawMessage + ModelConfigIDOverride uuid.NullUUID + ReasoningEffortOverride database.NullChatReasoningEffort + APIKeyID sql.NullString } // EditMessageResult is returned by [Tx.EditMessage]. @@ -564,18 +567,23 @@ func (tx *Tx) EditMessage(input EditMessageInput) (EditMessageResult, error) { if input.ModelConfigIDOverride.Valid { modelConfig = input.ModelConfigIDOverride } + reasoningEffort := target.ReasoningEffort + if input.ReasoningEffortOverride.Valid { + reasoningEffort = input.ReasoningEffortOverride + } apiKeyID := input.APIKeyID if !apiKeyID.Valid { return EditMessageResult{}, xerrors.Errorf("api_key_id is required") } replacement := Message{ - Role: database.ChatMessageRoleUser, - Content: input.Content, - Visibility: target.Visibility, - ModelConfigID: modelConfig, - CreatedBy: uuid.NullUUID{UUID: input.CreatedBy, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - APIKeyID: apiKeyID, + Role: database.ChatMessageRoleUser, + Content: input.Content, + Visibility: target.Visibility, + ModelConfigID: modelConfig, + ReasoningEffort: reasoningEffort, + CreatedBy: uuid.NullUUID{UUID: input.CreatedBy, Valid: true}, + ContentVersion: chatprompt.CurrentContentVersion, + APIKeyID: apiKeyID, } insertedReplacement, err := tx.insertMessages([]Message{replacement}) if err != nil { diff --git a/coderd/x/chatd/chatstate_bridge.go b/coderd/x/chatd/chatstate_bridge.go index 2a6f394d4d593..04aae64c07363 100644 --- a/coderd/x/chatd/chatstate_bridge.go +++ b/coderd/x/chatd/chatstate_bridge.go @@ -29,15 +29,20 @@ func systemMessage(rawContent pqtype.NullRawMessage, modelConfigID uuid.UUID) ch } } -func userMessageWithAPIKeyID(rawContent pqtype.NullRawMessage, modelConfigID, createdBy uuid.UUID, apiKeyID string) chatstate.Message { +func userMessageWithAPIKeyID(rawContent pqtype.NullRawMessage, modelConfigID, createdBy uuid.UUID, apiKeyID string, reasoningEffort *string) chatstate.Message { + var effort database.NullChatReasoningEffort + if reasoningEffort != nil && *reasoningEffort != "" { + effort = database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffort(*reasoningEffort), Valid: true} + } return chatstate.Message{ - Role: database.ChatMessageRoleUser, - Content: rawContent, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, - CreatedBy: uuid.NullUUID{UUID: createdBy, Valid: createdBy != uuid.Nil}, - ContentVersion: chatprompt.CurrentContentVersion, - APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""}, + Role: database.ChatMessageRoleUser, + Content: rawContent, + Visibility: database.ChatMessageVisibilityBoth, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ReasoningEffort: effort, + CreatedBy: uuid.NullUUID{UUID: createdBy, Valid: createdBy != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""}, } } diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index d408001bd6a62..6ca947b1f792d 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -532,7 +532,14 @@ func (server *Server) prepareGeneration( } } - reasoningEffort := chatprovider.ResolveReasoningEffort(callConfig.ReasoningEffort) + var requestedEffort *string + if chat.LastReasoningEffort.Valid { + requestedEffort = new(string(chat.LastReasoningEffort.ChatReasoningEffort)) + } + reasoningEffort := chatprovider.ResolveReasoningEffort( + requestedEffort, + callConfig.ReasoningEffort, + ) providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions) providerOptions = chatprovider.ApplyReasoningEffort(model, providerOptions, reasoningEffort) diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 66968f88a9be0..13cbb4ac13b0d 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "testing" + fantasyopenai "charm.land/fantasy/providers/openai" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" @@ -13,6 +14,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" @@ -85,6 +87,80 @@ func TestLatestAssistantText(t *testing.T) { }) } +func TestPrepareGenerationClampsRequestedReasoningEffortToMax(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := chatdTestContext(t) + user := dbgen.User(t, db, database.User{}) + apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + provider := dbgen.AIProviderWithOptionalKey(t, db, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + }, "test-key") + modelConfigRaw, err := json.Marshal(codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortMedium), + }, + }) + require.NoError(t, err) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Model: "gpt-4o-mini", + Options: modelConfigRaw, + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + }, func(p *database.InsertChatModelConfigParams) { + p.Enabled = true + }) + + created, err := chatstate.CreateChat(ctx, db, ps, chatstate.CreateChatInput{ + OrganizationID: org.ID, + OwnerID: user.ID, + LastModelConfigID: modelConfig.ID, + Title: "clamp reasoning effort", + ClientType: database.ChatClientTypeApi, + InitialMessages: []chatstate.Message{ + { + Role: database.ChatMessageRoleUser, + Content: mustMarshalText(t, "hello"), + Visibility: database.ChatMessageVisibilityBoth, + ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true}, + ReasoningEffort: database.NullChatReasoningEffort{ + ChatReasoningEffort: database.ChatReasoningEffortHigh, + Valid: true, + }, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + ContentVersion: chatprompt.CurrentContentVersion, + APIKeyID: sql.NullString{String: apiKey.ID, Valid: true}, + }, + }, + }) + require.NoError(t, err) + + server := newInternalTestServer( + t, + db, + ps, + chatprovider.ProviderAPIKeys{}, + withInternalTestServerTransportFactory(&aibridgeTestFactory{}), + ) + prepared, err := server.prepareGeneration(ctx, generationPrepareInput{ + Chat: created.Chat, + Messages: created.InitialMessages, + }) + require.NoError(t, err) + t.Cleanup(prepared.Cleanup) + + providerOptions, ok := prepared.ProviderOptions[fantasyopenai.Name].(*fantasyopenai.ResponsesProviderOptions) + require.True(t, ok, "%T", prepared.ProviderOptions[fantasyopenai.Name]) + require.NotNil(t, providerOptions.ReasoningEffort) + require.Equal(t, fantasyopenai.ReasoningEffortMedium, *providerOptions.ReasoningEffort) +} + // TestDeriveFinalTurnRunResult exercises the re-derivation path that replaces // the old in-memory generationSideEffects stash. The server here never ran // prepareGeneration, so a passing test proves the finish-turn inputs are diff --git a/coderd/x/chatd/personal_model_override.go b/coderd/x/chatd/personal_model_override.go index 001a8cad4da5a..2727719438739 100644 --- a/coderd/x/chatd/personal_model_override.go +++ b/coderd/x/chatd/personal_model_override.go @@ -26,9 +26,10 @@ func ChatPersonalModelOverrideKey( // When Malformed is true, Mode is the provided default and ModelConfigID is // uuid.Nil. type ParsedChatPersonalModelOverride struct { - Mode codersdk.ChatPersonalModelOverrideMode - ModelConfigID uuid.UUID - Malformed bool + Mode codersdk.ChatPersonalModelOverrideMode + ModelConfigID uuid.UUID + ReasoningEffort *string + Malformed bool } // ParseChatPersonalModelOverride parses a stored personal model override. @@ -61,15 +62,20 @@ func ParseChatPersonalModelOverride( Malformed: true, } } - modelConfigID, err := uuid.Parse(rawModelConfigID) - if err != nil { + rawID, rawEffort, hasEffort := strings.Cut(rawModelConfigID, ":") + modelConfigID, err := uuid.Parse(rawID) + if err != nil || (hasEffort && rawEffort == "") { return ParsedChatPersonalModelOverride{ Mode: defaultMode, Malformed: true, } } - return ParsedChatPersonalModelOverride{ + 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 index 2227e07151002..a9b7cf588d449 100644 --- a/coderd/x/chatd/personal_model_override_test.go +++ b/coderd/x/chatd/personal_model_override_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -63,6 +64,25 @@ func TestParseChatPersonalModelOverride(t *testing.T) { 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", diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 13be09d0882eb..dc62db7192434 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -360,7 +360,7 @@ func (p *Server) titleGenerationProviderOptions( return chatprovider.ApplyReasoningEffort( model, providerOptions, - chatprovider.ResolveReasoningEffort(callConfig.ReasoningEffort), + chatprovider.ResolveReasoningEffort(nil, callConfig.ReasoningEffort), ) } diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index b5e9888793b2a..ccb7e1a26d3df 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -192,48 +192,46 @@ func (p *Server) resolveConfiguredModelOverride( resolveModelConfig modelOverrideConfigResolver, resolveProviderKeys modelOverrideProviderKeysResolver, failureMode modelOverrideFailureMode, -) (database.ChatModelConfig, bool, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return database.ChatModelConfig{}, false, nil - } - configuredModelConfigID, err := uuid.Parse(trimmed) - if err != nil { +) (database.ChatModelConfig, *string, bool, error) { + parsed, ok := parseModelOverride(raw) + if !ok { p.logger.Info(ctx, "invalid model override, ignoring", slog.F("override_context", overrideContext), - slog.F("raw_model_config_id", trimmed), - slog.Error(err), + slog.F("raw_model_config_id", strings.TrimSpace(raw)), ) - return database.ChatModelConfig{}, false, nil + return database.ChatModelConfig{}, nil, false, nil + } + if parsed.modelConfigID == uuid.Nil { + return database.ChatModelConfig{}, nil, false, nil } modelConfig, providerName, err := resolveModelConfig( ctx, - configuredModelConfigID, + parsed.modelConfigID, ) if err != nil { if failureMode == modelOverrideFailureModeHard { label := modelOverrideErrorLabel(overrideContext) switch { case errors.Is(err, sql.ErrNoRows): - return database.ChatModelConfig{}, true, xerrors.Errorf( + return database.ChatModelConfig{}, parsed.reasoningEffort, true, xerrors.Errorf( "%s model override is unavailable: %s", label, - configuredModelConfigID, + parsed.modelConfigID, ) case errors.Is(err, errInvalidModelOverrideMetadata): - return database.ChatModelConfig{}, true, xerrors.Errorf( + return database.ChatModelConfig{}, parsed.reasoningEffort, true, xerrors.Errorf( "%s model override metadata is invalid for %s: %w", label, - configuredModelConfigID, + parsed.modelConfigID, err, ) default: - return database.ChatModelConfig{}, true, xerrors.Errorf( + return database.ChatModelConfig{}, parsed.reasoningEffort, true, xerrors.Errorf( "resolve %s model override %s: %w", label, - configuredModelConfigID, + parsed.modelConfigID, err, ) } @@ -244,36 +242,36 @@ func (p *Server) resolveConfiguredModelOverride( p.logger.Info(ctx, "model override is unavailable, ignoring", slog.F("override_context", overrideContext), - slog.F("model_config_id", configuredModelConfigID), + slog.F("model_config_id", parsed.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", configuredModelConfigID), + slog.F("model_config_id", parsed.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", configuredModelConfigID), + slog.F("model_config_id", parsed.modelConfigID), slog.Error(err), ) } - return database.ChatModelConfig{}, false, nil + return database.ChatModelConfig{}, nil, false, nil } providerKeys, err := resolveProviderKeys(ctx, ownerID, modelConfigAIProviderID(modelConfig)) if err != nil { - return database.ChatModelConfig{}, false, xerrors.Errorf( + return database.ChatModelConfig{}, nil, false, xerrors.Errorf( "resolve provider API keys: %w", err, ) } if !userCanUseProviderKeys(providerKeys, providerName) { if failureMode == modelOverrideFailureModeHard { - return database.ChatModelConfig{}, true, xerrors.Errorf( + return database.ChatModelConfig{}, parsed.reasoningEffort, true, xerrors.Errorf( "%s model override credentials are unavailable for provider %q", modelOverrideErrorLabel(overrideContext), providerName, @@ -283,22 +281,22 @@ func (p *Server) resolveConfiguredModelOverride( p.logger.Info(ctx, "model override credentials are unavailable, ignoring", slog.F("override_context", overrideContext), - slog.F("model_config_id", configuredModelConfigID), + slog.F("model_config_id", parsed.modelConfigID), slog.F("provider", providerName), ) - return database.ChatModelConfig{}, false, nil + return database.ChatModelConfig{}, nil, false, nil } - return modelConfig, true, nil + return modelConfig, parsed.reasoningEffort, true, nil } func (p *Server) resolvePersonalSubagentModelConfigID( ctx context.Context, ownerID uuid.UUID, overrideContext codersdk.ChatModelOverrideContext, -) (uuid.UUID, bool, error) { +) (uuid.UUID, *string, bool, error) { personalContext, err := personalModelOverrideContextForSubagent(overrideContext) if err != nil { - return uuid.Nil, false, err + return uuid.Nil, nil, false, err } raw, err := p.db.GetUserChatPersonalModelOverride( ctx, @@ -309,7 +307,7 @@ func (p *Server) resolvePersonalSubagentModelConfigID( ) if err != nil { if !xerrors.Is(err, sql.ErrNoRows) { - return uuid.Nil, false, xerrors.Errorf( + return uuid.Nil, nil, false, xerrors.Errorf( "get %s personal model override: %w", subagentModelOverrideLogLabel(overrideContext), err, @@ -332,7 +330,7 @@ func (p *Server) resolvePersonalSubagentModelConfigID( } switch parsed.Mode { case codersdk.ChatPersonalModelOverrideModeChatDefault: - return uuid.Nil, true, nil + return uuid.Nil, nil, true, nil case codersdk.ChatPersonalModelOverrideModeDeploymentDefault: case codersdk.ChatPersonalModelOverrideModeModel: modelConfig, ok, err := p.resolvePersonalModelOverride( @@ -342,10 +340,10 @@ func (p *Server) resolvePersonalSubagentModelConfigID( parsed.ModelConfigID, ) if err != nil { - return uuid.Nil, false, err + return uuid.Nil, nil, false, err } if ok { - return modelConfig.ID, true, nil + return modelConfig.ID, parsed.ReasoningEffort, true, nil } default: p.logger.Warn(ctx, @@ -356,7 +354,7 @@ func (p *Server) resolvePersonalSubagentModelConfigID( ) } - return uuid.Nil, false, nil + return uuid.Nil, nil, false, nil } func (p *Server) resolvePersonalModelOverride( @@ -417,43 +415,75 @@ func (p *Server) resolvePersonalModelOverride( return modelConfig, true, nil } +func withResolvedReasoningEffort( + modelConfig database.ChatModelConfig, + reasoningEffort *string, +) database.ChatModelConfig { + if reasoningEffort == nil { + return modelConfig + } + callConfig := codersdk.ChatModelCallConfig{} + if len(modelConfig.Options) > 0 { + if err := json.Unmarshal(modelConfig.Options, &callConfig); err != nil { + return modelConfig + } + } + resolvedEffort := chatprovider.ResolveReasoningEffort( + reasoningEffort, + callConfig.ReasoningEffort, + ) + if resolvedEffort == nil { + return modelConfig + } + callConfig.ReasoningEffort = &codersdk.ChatModelReasoningEffortConfig{ + Default: resolvedEffort, + Max: resolvedEffort, + } + options, err := json.Marshal(callConfig) + if err != nil { + return modelConfig + } + modelConfig.Options = options + return modelConfig +} + func (p *Server) resolveSubagentModelConfigID( ctx context.Context, ownerID uuid.UUID, overrideContext codersdk.ChatModelOverrideContext, -) (uuid.UUID, error) { +) (uuid.UUID, *string, error) { //nolint:gocritic // Chatd needs its scoped config and user-data access here. chatdCtx := dbauthz.AsChatd(ctx) personalOverridesEnabled, err := p.db.GetChatPersonalModelOverridesEnabled(chatdCtx) if err != nil { - return uuid.Nil, xerrors.Errorf( + return uuid.Nil, nil, xerrors.Errorf( "get chat personal model overrides enabled: %w", err, ) } if personalOverridesEnabled { - modelConfigID, resolved, err := p.resolvePersonalSubagentModelConfigID( + modelConfigID, reasoningEffort, resolved, err := p.resolvePersonalSubagentModelConfigID( chatdCtx, ownerID, overrideContext, ) if err != nil { - return uuid.Nil, err + return uuid.Nil, nil, err } if resolved { - return modelConfigID, nil + return modelConfigID, reasoningEffort, nil } } raw, err := readSubagentModelOverride(chatdCtx, p.db, overrideContext) if err != nil { - return uuid.Nil, xerrors.Errorf( + return uuid.Nil, nil, xerrors.Errorf( "get %s model override: %w", subagentModelOverrideLogLabel(overrideContext), err, ) } - modelConfig, ok, err := p.resolveConfiguredModelOverride( + modelConfig, reasoningEffort, ok, err := p.resolveConfiguredModelOverride( chatdCtx, string(overrideContext), raw, @@ -463,12 +493,12 @@ func (p *Server) resolveSubagentModelConfigID( modelOverrideFailureModeSoft, ) if err != nil { - return uuid.Nil, err + return uuid.Nil, nil, err } if !ok { - return uuid.Nil, nil + return uuid.Nil, nil, nil } - return modelConfig.ID, nil + return modelConfig.ID, reasoningEffort, nil } func modelConfigAIProviderID(modelConfig database.ChatModelConfig) uuid.UUID { @@ -932,17 +962,18 @@ func parseSubagentToolChatID(raw string) (uuid.UUID, error) { } // childSubagentChatOptions carries per-child overrides for subagent chat -// creation. modelConfigIDOverride and planModeOverride apply to any -// subagent. inheritedMCPServerIDs is an Explore-only snapshot of the -// spawning parent turn's effective external MCP entitlement. -// resolveExploreToolSnapshot computes and persists it on the child chat. -// Non-Explore children ignore this field. +// creation. modelConfigIDOverride, reasoningEffortOverride, and +// planModeOverride apply to any subagent. inheritedMCPServerIDs is an +// Explore-only snapshot of the spawning parent turn's effective external MCP +// entitlement. resolveExploreToolSnapshot computes and persists it on the +// child chat. Non-Explore children ignore this field. type childSubagentChatOptions struct { - chatMode database.NullChatMode - systemPrompt string - modelConfigIDOverride *uuid.UUID - planModeOverride *database.NullChatPlanMode - inheritedMCPServerIDs []uuid.UUID + chatMode database.NullChatMode + systemPrompt string + modelConfigIDOverride *uuid.UUID + reasoningEffortOverride *string + planModeOverride *database.NullChatPlanMode + inheritedMCPServerIDs []uuid.UUID } // resolveExploreToolSnapshot computes the child chat's inherited MCP @@ -1118,7 +1149,7 @@ func (p *Server) createChildSubagentChatWithOptions( // workspace context the same way a top-level chat does: pinned from the // agent's latest snapshot (see hydrateChatContextOnCreate below). The // parent's context is not copied into child history. - initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, modelConfigID, parent.OwnerID, childAPIKeyID)) + initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, modelConfigID, parent.OwnerID, childAPIKeyID, opts.reasoningEffortOverride)) publisher := p.pubsub if publisher == nil { diff --git a/coderd/x/chatd/subagent_catalog.go b/coderd/x/chatd/subagent_catalog.go index b8de9f104b22a..301ec9866c17a 100644 --- a/coderd/x/chatd/subagent_catalog.go +++ b/coderd/x/chatd/subagent_catalog.go @@ -56,7 +56,7 @@ func allSubagentDefinitions() []subagentDefinition { id: subagentTypeGeneral, description: "substantial delegated research, analysis, reasoning, review, planning support, and implementation", buildOptions: func(ctx context.Context, p *Server, parent database.Chat, _ database.Chat, _ uuid.UUID, _ string) (childSubagentChatOptions, error) { - modelConfigID, err := p.resolveSubagentModelConfigID( + modelConfigID, reasoningEffort, err := p.resolveSubagentModelConfigID( ctx, parent.OwnerID, codersdk.ChatModelOverrideContextGeneral, @@ -67,6 +67,7 @@ func allSubagentDefinitions() []subagentDefinition { options := childSubagentChatOptions{} if modelConfigID != uuid.Nil { options.modelConfigIDOverride = &modelConfigID + options.reasoningEffortOverride = reasoningEffort } return options, nil }, @@ -75,7 +76,7 @@ func allSubagentDefinitions() []subagentDefinition { id: subagentTypeExplore, description: "narrow repository-local read-only code discovery and code tracing", buildOptions: func(ctx context.Context, p *Server, _ database.Chat, turnParent database.Chat, currentModelConfigID uuid.UUID, _ string) (childSubagentChatOptions, error) { - modelConfigID, err := p.resolveSubagentModelConfigID( + modelConfigID, reasoningEffort, err := p.resolveSubagentModelConfigID( ctx, turnParent.OwnerID, codersdk.ChatModelOverrideContextExplore, @@ -101,9 +102,10 @@ func allSubagentDefinitions() []subagentDefinition { ChatMode: database.ChatModeExplore, Valid: true, }, - modelConfigIDOverride: &modelConfigID, - planModeOverride: &clearPlanMode, - inheritedMCPServerIDs: inheritedMCPServerIDs, + modelConfigIDOverride: &modelConfigID, + reasoningEffortOverride: reasoningEffort, + planModeOverride: &clearPlanMode, + inheritedMCPServerIDs: inheritedMCPServerIDs, }, nil }, }, diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 0e034b4ed4773..9ea4d057ad7d7 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -29,6 +29,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "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/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" @@ -1489,7 +1490,7 @@ func TestResolveConfiguredModelOverride_AcceptsAmbientCredentialsProvider( Enabled: true, } - resolvedModelConfig, ok, err := server.resolveConfiguredModelOverride( + resolvedModelConfig, reasoningEffort, ok, err := server.resolveConfiguredModelOverride( ctx, "plan", modelConfig.ID.String(), @@ -1514,6 +1515,7 @@ func TestResolveConfiguredModelOverride_AcceptsAmbientCredentialsProvider( modelOverrideFailureModeSoft, ) require.NoError(t, err) + require.Nil(t, reasoningEffort) require.True(t, ok) require.Equal(t, modelConfig, resolvedModelConfig) require.Empty(t, logSink.entriesAtLevelWithMessage( @@ -1522,6 +1524,76 @@ func TestResolveConfiguredModelOverride_AcceptsAmbientCredentialsProvider( )) } +func TestWithResolvedReasoningEffort(t *testing.T) { + t.Parallel() + + baseOptions, err := json.Marshal(codersdk.ChatModelCallConfig{ + MaxOutputTokens: ptr.Ref(int64(123)), + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }) + require.NoError(t, err) + + t.Run("NilEffortReturnsOriginalConfig", func(t *testing.T) { + t.Parallel() + modelConfig := database.ChatModelConfig{Options: baseOptions} + require.Equal(t, modelConfig, withResolvedReasoningEffort(modelConfig, nil)) + }) + + t.Run("InvalidJSONReturnsOriginalConfig", func(t *testing.T) { + t.Parallel() + modelConfig := database.ChatModelConfig{Options: []byte(`{`)} + require.Equal(t, modelConfig, withResolvedReasoningEffort(modelConfig, ptr.Ref(codersdk.ChatModelReasoningEffortHigh))) + }) + + t.Run("NoReasoningConfigReturnsOriginalConfig", func(t *testing.T) { + t.Parallel() + modelConfig := database.ChatModelConfig{Options: []byte(`{"max_output_tokens":123}`)} + require.Equal(t, modelConfig, withResolvedReasoningEffort(modelConfig, ptr.Ref(codersdk.ChatModelReasoningEffortHigh))) + }) + + t.Run("ClampsRequestedEffortAndPreservesOtherOptions", func(t *testing.T) { + t.Parallel() + modelConfig := database.ChatModelConfig{Options: baseOptions} + + got := withResolvedReasoningEffort(modelConfig, ptr.Ref(codersdk.ChatModelReasoningEffortXHigh)) + + var callConfig codersdk.ChatModelCallConfig + require.NoError(t, json.Unmarshal(got.Options, &callConfig)) + require.Equal(t, ptr.Ref(int64(123)), callConfig.MaxOutputTokens) + require.Equal(t, ptr.Ref(codersdk.ChatModelReasoningEffortHigh), callConfig.ReasoningEffort.Default) + require.Equal(t, ptr.Ref(codersdk.ChatModelReasoningEffortHigh), callConfig.ReasoningEffort.Max) + }) +} + +func TestCreateChildSubagentChat_StoresReasoningEffortOverride(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + + ctx := chatdTestContext(t) + user, org, model := seedInternalChatDeps(t, db) + parentChat := createInternalParentChat( + ctx, t, server, db, org.ID, user.ID, model.ID, "parent-effort-override", + ) + ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID)) + child, err := server.createChildSubagentChatWithOptions( + ctx, + parentChat, + "delegate work", + "", + childSubagentChatOptions{reasoningEffortOverride: ptr.Ref("high")}, + ) + require.NoError(t, err) + + childChat, err := db.GetChatByID(ctx, child.ID) + require.NoError(t, err) + require.Equal(t, database.NullChatReasoningEffort{ChatReasoningEffort: database.ChatReasoningEffortHigh, Valid: true}, childChat.LastReasoningEffort) +} + func TestCreateChildSubagentChat_OverrideWorksWhenParentHasNoModel(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/title_override.go b/coderd/x/chatd/title_override.go index 2d48b3c714b89..0072587498aaa 100644 --- a/coderd/x/chatd/title_override.go +++ b/coderd/x/chatd/title_override.go @@ -2,6 +2,7 @@ package chatd import ( "context" + "strings" "charm.land/fantasy" "github.com/google/uuid" @@ -14,6 +15,28 @@ 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, @@ -47,7 +70,7 @@ func (p *Server) resolveTitleGenerationModelOverride( ) } - modelConfig, overrideSet, err := p.resolveConfiguredModelOverride( + modelConfig, overrideEffort, overrideSet, err := p.resolveConfiguredModelOverride( ctx, titleGenerationOverrideContext, raw, @@ -64,6 +87,7 @@ func (p *Server) resolveTitleGenerationModelOverride( if !overrideSet { return database.ChatModelConfig{}, nil, aiGatewayModelRoute{}, false, nil } + modelConfig = withResolvedReasoningEffort(modelConfig, overrideEffort) //nolint:gocritic // Title overrides need chatd-scoped provider reads for user-owned chats. route, err := p.resolveModelRouteForConfig(dbauthz.AsChatd(ctx), chat.OwnerID, modelConfig) diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index b91a0f5ccec69..8ade93bbc44c4 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -3,6 +3,7 @@ package chatd import ( "context" "database/sql" + "encoding/json" "io" "net/http" "strconv" @@ -11,6 +12,7 @@ import ( "testing" "charm.land/fantasy" + fantasyopenai "charm.land/fantasy/providers/openai" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -21,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -183,16 +186,29 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) { db := dbmock.NewMockStore(ctrl) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) chat, messages := titleOverrideTestChatAndMessages(t) - overrideConfig := titleOverrideModelConfig("gpt-4.1", true) + overrideConfig := titleOverrideModelConfig("gpt-5", true) providerID := uuid.New() overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true} + options, err := json.Marshal(codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow), + Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh), + }, + }) + require.NoError(t, err) + overrideConfig.Options = options wantTitle := "Override title" var requestCount atomic.Int32 factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) { requestCount.Add(1) + bodyBytes, err := io.ReadAll(req.Body) + require.NoError(t, err) + var raw map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &raw)) + require.Equal(t, string(fantasyopenai.ReasoningEffortHigh), raw["reasoning"].(map[string]any)["effort"]) text := strconv.Quote(`{"title":"` + wantTitle + `"}`) - body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4.1","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` + body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-5","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, @@ -213,7 +229,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideSetUsable(t *testing.T) { }, } - db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil) + db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String()+":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{{ @@ -660,6 +676,41 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T require.Equal(t, database.ChatModelConfig{}, gotConfig) } +func TestParseModelOverride(t *testing.T) { + t.Parallel() + + modelConfigID := uuid.New() + tests := []struct { + name string + raw string + wantID uuid.UUID + wantEffort *string + wantOK bool + }{ + {name: "Empty", raw: "", wantOK: true}, + {name: "Whitespace", raw: " \t\n ", wantOK: true}, + {name: "IDOnly", raw: modelConfigID.String(), wantID: modelConfigID, wantOK: true}, + {name: "IDWithEffort", raw: modelConfigID.String() + ":high", wantID: modelConfigID, wantEffort: ptr.Ref("high"), wantOK: true}, + {name: "IDEmptyEffort", raw: modelConfigID.String() + ":", wantOK: false}, + {name: "OuterWhitespace", raw: " \t" + modelConfigID.String() + ":high\n ", wantID: modelConfigID, wantEffort: ptr.Ref("high"), wantOK: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := parseModelOverride(tt.raw) + + require.Equal(t, tt.wantOK, ok) + if !tt.wantOK { + return + } + require.Equal(t, tt.wantID, got.modelConfigID) + require.Equal(t, tt.wantEffort, got.reasoningEffort) + }) + } +} + func titleOverrideTestChatAndMessages(t *testing.T) (database.Chat, []database.ChatMessage) { t.Helper() diff --git a/codersdk/chats.go b/codersdk/chats.go index 35b3174db8cfb..7a02d86aaa56d 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -108,26 +108,27 @@ const ( // Chat represents a chat session with an AI agent. type Chat struct { - ID uuid.UUID `json:"id" format:"uuid"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OwnerID uuid.UUID `json:"owner_id" format:"uuid"` - OwnerUsername string `json:"owner_username,omitempty"` - OwnerName string `json:"owner_name,omitempty"` - WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` - BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"` - AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` - ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` - RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` - LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` - Title string `json:"title"` - Status ChatStatus `json:"status"` - PlanMode ChatPlanMode `json:"plan_mode,omitempty"` - LastError *ChatError `json:"last_error,omitempty"` - LastTurnSummary *string `json:"last_turn_summary"` - DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` - Archived bool `json:"archived"` + ID uuid.UUID `json:"id" format:"uuid"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OwnerID uuid.UUID `json:"owner_id" format:"uuid"` + OwnerUsername string `json:"owner_username,omitempty"` + OwnerName string `json:"owner_name,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` + BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"` + AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"` + ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"` + RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"` + LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"` + LastReasoningEffort *string `json:"last_reasoning_effort,omitempty"` + Title string `json:"title"` + Status ChatStatus `json:"status"` + PlanMode ChatPlanMode `json:"plan_mode,omitempty"` + LastError *ChatError `json:"last_error,omitempty"` + LastTurnSummary *string `json:"last_turn_summary"` + DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` + Archived bool `json:"archived"` // Shared is true when this chat's root chat has explicit user or group ACL entries. Shared bool `json:"shared"` PinOrder int32 `json:"pin_order"` @@ -549,13 +550,14 @@ type ToolResult struct { // CreateChatRequest is the request to create a new chat. type CreateChatRequest struct { - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - Content []ChatInputPart `json:"content"` - SystemPrompt string `json:"system_prompt,omitempty"` - WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` - ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` - MCPServerIDs []uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"` - Labels map[string]string `json:"labels,omitempty"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + Content []ChatInputPart `json:"content"` + SystemPrompt string `json:"system_prompt,omitempty"` + WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` + MCPServerIDs []uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"` + Labels map[string]string `json:"labels,omitempty"` // UnsafeDynamicTools declares client-executed tools that the // LLM can invoke. This API is highly experimental and highly // subject to change. @@ -616,7 +618,8 @@ type CreateChatMessageRequest struct { BusyBehavior ChatBusyBehavior `json:"busy_behavior,omitempty" enums:"queue,interrupt"` // PlanMode switches the chat's persistent plan mode. // nil: no change, ptr to "plan": enable, ptr to "": clear. - PlanMode *ChatPlanMode `json:"plan_mode,omitempty"` + PlanMode *ChatPlanMode `json:"plan_mode,omitempty"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` } // EditChatMessageRequest is the request to edit a user message in a chat. @@ -625,7 +628,8 @@ type EditChatMessageRequest struct { // ModelConfigID, when set, overrides the model used for the // replacement user message and the assistant turn that follows. // When nil the original message's model is preserved. - ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` } // CreateChatMessageResponse is the response from adding a message to a chat. @@ -776,15 +780,17 @@ func AllChatModelOverrideContexts() []ChatModelOverrideContext { // ChatModelOverrideResponse is the response body for the chat model override // configuration endpoint. type ChatModelOverrideResponse struct { - Context ChatModelOverrideContext `json:"context"` - ModelConfigID string `json:"model_config_id"` - IsMalformed bool `json:"is_malformed"` + Context ChatModelOverrideContext `json:"context"` + ModelConfigID string `json:"model_config_id"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` + IsMalformed bool `json:"is_malformed"` } // UpdateChatModelOverrideRequest is the request body for updating the chat // model override configuration endpoint. type UpdateChatModelOverrideRequest struct { - ModelConfigID string `json:"model_config_id"` + ModelConfigID string `json:"model_config_id"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` } // ChatPersonalModelOverrideContext identifies which chat context the user @@ -809,11 +815,12 @@ const ( // ChatPersonalModelOverride is a resolved user personal model override. type ChatPersonalModelOverride struct { - Context ChatPersonalModelOverrideContext `json:"context"` - Mode ChatPersonalModelOverrideMode `json:"mode"` - ModelConfigID string `json:"model_config_id"` - IsSet bool `json:"is_set"` - IsMalformed bool `json:"is_malformed"` + Context ChatPersonalModelOverrideContext `json:"context"` + Mode ChatPersonalModelOverrideMode `json:"mode"` + 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 @@ -836,8 +843,9 @@ type UserChatPersonalModelOverridesResponse struct { // UpdateUserChatPersonalModelOverrideRequest is the request body for updating // a user personal model override. type UpdateUserChatPersonalModelOverrideRequest struct { - Mode ChatPersonalModelOverrideMode `json:"mode"` - ModelConfigID string `json:"model_config_id"` + Mode ChatPersonalModelOverrideMode `json:"mode"` + ModelConfigID string `json:"model_config_id"` + ReasoningEffort *string `json:"reasoning_effort,omitempty"` } // ChatPersonalModelOverridesAdminSettings describes whether users may manage diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 96db32dbeb37d..40bbd32637b85 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -107,6 +107,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -141,85 +142,86 @@ Experimental: this endpoint is subject to change. Status Code **200** -| Name | Type | Required | Restrictions | Description | -|--------------------------|------------------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[array item]` | array | false | | | -| `ยป agent_id` | string(uuid) | false | | | -| `ยป archived` | boolean | false | | | -| `ยป build_id` | string(uuid) | false | | | -| `ยป children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `ยป client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | -| `ยป context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | -| `ยปยป dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | -| `ยปยป dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | -| `ยปยป error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | -| `ยปยป resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | -| `ยปยปยป error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | -| `ยปยปยป kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | -| `ยปยปยป size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | -| `ยปยปยป skill_description` | string | false | | | -| `ยปยปยป skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | -| `ยปยปยป source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | -| `ยปยปยป status` | [codersdk.ChatContextResourceStatus](schemas.md#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, tools) are empty. | -| `ยปยปยป tools` | array | false | | Tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | -| `ยปยปยปยป description` | string | false | | Description is the tool's human-readable summary; may be empty. | -| `ยปยปยปยป name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | -| `ยป created_at` | string(date-time) | false | | | -| `ยป diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | -| `ยปยป additions` | integer | false | | | -| `ยปยป approved` | boolean | false | | | -| `ยปยป author_avatar_url` | string | false | | | -| `ยปยป author_login` | string | false | | | -| `ยปยป base_branch` | string | false | | | -| `ยปยป changed_files` | integer | false | | | -| `ยปยป changes_requested` | boolean | false | | | -| `ยปยป chat_id` | string(uuid) | false | | | -| `ยปยป commits` | integer | false | | | -| `ยปยป deletions` | integer | false | | | -| `ยปยป head_branch` | string | false | | | -| `ยปยป pr_number` | integer | false | | | -| `ยปยป pull_request_draft` | boolean | false | | | -| `ยปยป pull_request_state` | string | false | | | -| `ยปยป pull_request_title` | string | false | | | -| `ยปยป refreshed_at` | string(date-time) | false | | | -| `ยปยป reviewer_count` | integer | false | | | -| `ยปยป stale_at` | string(date-time) | false | | | -| `ยปยป url` | string | false | | | -| `ยป files` | array | false | | | -| `ยปยป created_at` | string(date-time) | false | | | -| `ยปยป id` | string(uuid) | false | | | -| `ยปยป mime_type` | string | false | | | -| `ยปยป name` | string | false | | | -| `ยปยป organization_id` | string(uuid) | false | | | -| `ยปยป owner_id` | string(uuid) | false | | | -| `ยป has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `ยป id` | string(uuid) | false | | | -| `ยป labels` | object | false | | | -| `ยปยป [any property]` | string | false | | | -| `ยป last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | -| `ยปยป detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | -| `ยปยป kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | -| `ยปยป message` | string | false | | Message is the normalized, user-facing error message. | -| `ยปยป provider` | string | false | | Provider identifies the upstream model provider when known. | -| `ยปยป retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | -| `ยปยป status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `ยป last_model_config_id` | string(uuid) | false | | | -| `ยป last_turn_summary` | string | false | | | -| `ยป mcp_server_ids` | array | false | | | -| `ยป organization_id` | string(uuid) | false | | | -| `ยป owner_id` | string(uuid) | false | | | -| `ยป owner_name` | string | false | | | -| `ยป owner_username` | string | false | | | -| `ยป parent_chat_id` | string(uuid) | false | | | -| `ยป pin_order` | integer | false | | | -| `ยป plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | -| `ยป root_chat_id` | string(uuid) | false | | | -| `ยป shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | -| `ยป status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `ยป title` | string | false | | | -| `ยป updated_at` | string(date-time) | false | | | -| `ยป warnings` | array | false | | | -| `ยป workspace_id` | string(uuid) | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------|------------------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `ยป agent_id` | string(uuid) | false | | | +| `ยป archived` | boolean | false | | | +| `ยป build_id` | string(uuid) | false | | | +| `ยป children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `ยป client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | +| `ยป context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | +| `ยปยป dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | +| `ยปยป dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | +| `ยปยป error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| `ยปยป resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | +| `ยปยปยป error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | +| `ยปยปยป kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `ยปยปยป size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `ยปยปยป skill_description` | string | false | | | +| `ยปยปยป skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `ยปยปยป source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| `ยปยปยป status` | [codersdk.ChatContextResourceStatus](schemas.md#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, tools) are empty. | +| `ยปยปยป tools` | array | false | | Tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `ยปยปยปยป description` | string | false | | Description is the tool's human-readable summary; may be empty. | +| `ยปยปยปยป name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | +| `ยป created_at` | string(date-time) | false | | | +| `ยป diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | +| `ยปยป additions` | integer | false | | | +| `ยปยป approved` | boolean | false | | | +| `ยปยป author_avatar_url` | string | false | | | +| `ยปยป author_login` | string | false | | | +| `ยปยป base_branch` | string | false | | | +| `ยปยป changed_files` | integer | false | | | +| `ยปยป changes_requested` | boolean | false | | | +| `ยปยป chat_id` | string(uuid) | false | | | +| `ยปยป commits` | integer | false | | | +| `ยปยป deletions` | integer | false | | | +| `ยปยป head_branch` | string | false | | | +| `ยปยป pr_number` | integer | false | | | +| `ยปยป pull_request_draft` | boolean | false | | | +| `ยปยป pull_request_state` | string | false | | | +| `ยปยป pull_request_title` | string | false | | | +| `ยปยป refreshed_at` | string(date-time) | false | | | +| `ยปยป reviewer_count` | integer | false | | | +| `ยปยป stale_at` | string(date-time) | false | | | +| `ยปยป url` | string | false | | | +| `ยป files` | array | false | | | +| `ยปยป created_at` | string(date-time) | false | | | +| `ยปยป id` | string(uuid) | false | | | +| `ยปยป mime_type` | string | false | | | +| `ยปยป name` | string | false | | | +| `ยปยป organization_id` | string(uuid) | false | | | +| `ยปยป owner_id` | string(uuid) | false | | | +| `ยป has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `ยป id` | string(uuid) | false | | | +| `ยป labels` | object | false | | | +| `ยปยป [any property]` | string | false | | | +| `ยป last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | +| `ยปยป detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | +| `ยปยป kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | +| `ยปยป message` | string | false | | Message is the normalized, user-facing error message. | +| `ยปยป provider` | string | false | | Provider identifies the upstream model provider when known. | +| `ยปยป retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | +| `ยปยป status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | +| `ยป last_model_config_id` | string(uuid) | false | | | +| `ยป last_reasoning_effort` | string | false | | | +| `ยป last_turn_summary` | string | false | | | +| `ยป mcp_server_ids` | array | false | | | +| `ยป organization_id` | string(uuid) | false | | | +| `ยป owner_id` | string(uuid) | false | | | +| `ยป owner_name` | string | false | | | +| `ยป owner_username` | string | false | | | +| `ยป parent_chat_id` | string(uuid) | false | | | +| `ยป pin_order` | integer | false | | | +| `ยป plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | +| `ยป root_chat_id` | string(uuid) | false | | | +| `ยป shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | +| `ยป status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `ยป title` | string | false | | | +| `ยป updated_at` | string(date-time) | false | | | +| `ยป warnings` | array | false | | | +| `ยป workspace_id` | string(uuid) | false | | | #### Enumerated Values @@ -274,6 +276,7 @@ Experimental: this endpoint is subject to change. "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "plan_mode": "plan", + "reasoning_effort": "string", "system_prompt": "string", "unsafe_dynamic_tools": [ { @@ -379,6 +382,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -471,6 +475,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -720,6 +725,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -866,6 +872,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -958,6 +965,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -1141,6 +1149,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -1233,6 +1242,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -1414,6 +1424,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -1506,6 +1517,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -1770,7 +1782,8 @@ Experimental: this endpoint is subject to change. "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", - "plan_mode": "plan" + "plan_mode": "plan", + "reasoning_effort": "string" } ``` @@ -1984,7 +1997,8 @@ Experimental: this endpoint is subject to change. "type": "text" } ], - "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205" + "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", + "reasoning_effort": "string" } ``` @@ -2254,6 +2268,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -2346,6 +2361,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -2852,6 +2868,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -2944,6 +2961,7 @@ Experimental: this endpoint is subject to change. "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ebf9ae1dea0dd..1ff7a87be7090 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2083,6 +2083,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -2175,6 +2176,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -2200,39 +2202,40 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|-----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent_id` | string | false | | | -| `archived` | boolean | false | | | -| `build_id` | string | false | | | -| `children` | array of [codersdk.Chat](#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | -| `context` | [codersdk.ChatContext](#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | -| `created_at` | string | false | | | -| `diff_status` | [codersdk.ChatDiffStatus](#codersdkchatdiffstatus) | false | | | -| `files` | array of [codersdk.ChatFileMetadata](#codersdkchatfilemetadata) | false | | | -| `has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `id` | string | false | | | -| `labels` | object | false | | | -| ยป `[any property]` | string | false | | | -| `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | | -| `last_model_config_id` | string | false | | | -| `last_turn_summary` | string | false | | | -| `mcp_server_ids` | array of string | false | | | -| `organization_id` | string | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | | -| `owner_username` | string | false | | | -| `parent_chat_id` | string | false | | | -| `pin_order` | integer | false | | | -| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | -| `root_chat_id` | string | false | | | -| `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | -| `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | -| `title` | string | false | | | -| `updated_at` | string | false | | | -| `warnings` | array of string | false | | | -| `workspace_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------|-----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent_id` | string | false | | | +| `archived` | boolean | false | | | +| `build_id` | string | false | | | +| `children` | array of [codersdk.Chat](#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | +| `context` | [codersdk.ChatContext](#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | +| `created_at` | string | false | | | +| `diff_status` | [codersdk.ChatDiffStatus](#codersdkchatdiffstatus) | false | | | +| `files` | array of [codersdk.ChatFileMetadata](#codersdkchatfilemetadata) | false | | | +| `has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `id` | string | false | | | +| `labels` | object | false | | | +| ยป `[any property]` | string | false | | | +| `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | | +| `last_model_config_id` | string | false | | | +| `last_reasoning_effort` | string | false | | | +| `last_turn_summary` | string | false | | | +| `mcp_server_ids` | array of string | false | | | +| `organization_id` | string | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | | +| `owner_username` | string | false | | | +| `parent_chat_id` | string | false | | | +| `pin_order` | integer | false | | | +| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | +| `root_chat_id` | string | false | | | +| `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | +| `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | +| `title` | string | false | | | +| `updated_at` | string | false | | | +| `warnings` | array of string | false | | | +| `workspace_id` | string | false | | | ## codersdk.ChatACL @@ -3958,6 +3961,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "status_code": 0 }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", + "last_reasoning_effort": "string", "last_turn_summary": "string", "mcp_server_ids": [ "497f6eca-6276-4993-bfeb-53cbbbba6f08" @@ -4369,19 +4373,21 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", - "plan_mode": "plan" + "plan_mode": "plan", + "reasoning_effort": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------|-----------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------| -| `busy_behavior` | [codersdk.ChatBusyBehavior](#codersdkchatbusybehavior) | false | | | -| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | -| `mcp_server_ids` | array of string | false | | | -| `model_config_id` | string | false | | | -| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | Plan mode switches the chat's persistent plan mode. nil: no change, ptr to "plan": enable, ptr to "": clear. | +| Name | Type | Required | Restrictions | Description | +|--------------------|-----------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------| +| `busy_behavior` | [codersdk.ChatBusyBehavior](#codersdkchatbusybehavior) | false | | | +| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | +| `mcp_server_ids` | array of string | false | | | +| `model_config_id` | string | false | | | +| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | Plan mode switches the chat's persistent plan mode. nil: no change, ptr to "plan": enable, ptr to "": clear. | +| `reasoning_effort` | string | false | | | #### Enumerated Values @@ -4586,6 +4592,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "plan_mode": "plan", + "reasoning_effort": "string", "system_prompt": "string", "unsafe_dynamic_tools": [ { @@ -4612,6 +4619,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `model_config_id` | string | false | | | | `organization_id` | string | false | | | | `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | +| `reasoning_effort` | string | false | | | | `system_prompt` | string | false | | | | `unsafe_dynamic_tools` | array of [codersdk.DynamicTool](#codersdkdynamictool) | false | | Unsafe dynamic tools declares client-executed tools that the LLM can invoke. This API is highly experimental and highly subject to change. | | `workspace_id` | string | false | | | @@ -6910,16 +6918,18 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "type": "text" } ], - "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205" + "model_config_id": "f5fb4d91-62ca-4377-9ee6-5d43ba00d205", + "reasoning_effort": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------|-----------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | -| `model_config_id` | string | false | | Model config ID when set, overrides the model used for the replacement user message and the assistant turn that follows. When nil the original message's model is preserved. | +| Name | Type | Required | Restrictions | Description | +|--------------------|-----------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | +| `model_config_id` | string | false | | Model config ID when set, overrides the model used for the replacement user message and the assistant turn that follows. When nil the original message's model is preserved. | +| `reasoning_effort` | string | false | | | ## codersdk.EditChatMessageResponse diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b72f077f27318..a881abf894e87 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1578,6 +1578,7 @@ export interface Chat { readonly parent_chat_id?: string; readonly root_chat_id?: string; readonly last_model_config_id: string; + readonly last_reasoning_effort?: string; readonly title: string; readonly status: ChatStatus; readonly plan_mode?: ChatPlanMode; @@ -2635,6 +2636,7 @@ export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [ export interface ChatModelOverrideResponse { readonly context: ChatModelOverrideContext; readonly model_config_id: string; + readonly reasoning_effort?: string; readonly is_malformed: boolean; } @@ -2784,6 +2786,7 @@ export interface ChatPersonalModelOverride { readonly context: ChatPersonalModelOverrideContext; readonly mode: ChatPersonalModelOverrideMode; readonly model_config_id: string; + readonly reasoning_effort?: string; readonly is_set: boolean; readonly is_malformed: boolean; } @@ -3568,6 +3571,7 @@ export interface CreateChatMessageRequest { * nil: no change, ptr to "plan": enable, ptr to "": clear. */ readonly plan_mode?: ChatPlanMode; + readonly reasoning_effort?: string; } // From codersdk/chats.go @@ -3622,6 +3626,7 @@ export interface CreateChatRequest { readonly system_prompt?: string; readonly workspace_id?: string; readonly model_config_id?: string; + readonly reasoning_effort?: string; readonly mcp_server_ids?: readonly string[]; readonly labels?: Record; /** @@ -4595,6 +4600,7 @@ export interface EditChatMessageRequest { * When nil the original message's model is preserved. */ readonly model_config_id?: string; + readonly reasoning_effort?: string; } // From codersdk/chats.go @@ -9006,6 +9012,7 @@ export interface UpdateChatModelConfigRequest { */ export interface UpdateChatModelOverrideRequest { readonly model_config_id: string; + readonly reasoning_effort?: string; } // From codersdk/chats.go @@ -9375,6 +9382,7 @@ export interface UpdateUserChatDebugLoggingRequest { export interface UpdateUserChatPersonalModelOverrideRequest { readonly mode: ChatPersonalModelOverrideMode; readonly model_config_id: string; + readonly reasoning_effort?: string; } // From codersdk/notifications.go diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx index 656d3498578e9..2018150861827 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx @@ -19,7 +19,7 @@ import { } from "./components/SubagentModelOverrideSettings"; type SaveModelOverride = ( - req: { readonly model_config_id: string }, + req: TypesGen.UpdateChatModelOverrideRequest, options?: MutationCallbacks, ) => void; diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx index e0e000ca53717..e147646091ad9 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/components/SubagentModelOverrideSettings.tsx @@ -6,6 +6,7 @@ import { useTemporarySavedState } from "#/components/TemporarySavedState/Tempora import { ModelSelector } from "#/pages/AgentsPage/components/ChatElements/ModelSelector"; import { ModelOverrideAlerts } from "#/pages/AgentsPage/components/ModelOverrideAlerts"; import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions"; +import { pickReasoningEffort } from "#/pages/AgentsPage/utils/reasoningEffort"; import { AgentSettingLayout } from "./AgentSettingLayout"; export interface MutationCallbacks { @@ -15,11 +16,13 @@ export interface MutationCallbacks { interface ModelOverrideData { readonly model_config_id: string; + readonly reasoning_effort?: string; readonly is_malformed: boolean; } interface UpdateModelOverrideRequest { readonly model_config_id: string; + readonly reasoning_effort?: string; } interface SubagentModelOverrideSettingsProps { @@ -65,6 +68,8 @@ export const SubagentModelOverrideSettings: FC< const isMalformedOverride = modelOverrideData?.is_malformed ?? false; const enabledModelOptions = enabledModelConfigs.map((modelConfig) => { const providerInfo = providerInfoByID.get(modelConfig.ai_provider_id); + const reasoningEffort = modelConfig.model_config?.reasoning_effort; + const reasoningEfforts = modelConfig.reasoning_efforts ?? []; return { id: modelConfig.id, provider: providerInfo?.provider ?? "", @@ -74,6 +79,10 @@ export const SubagentModelOverrideSettings: FC< model: modelConfig.model, displayName: modelConfig.display_name.trim() || modelConfig.model, contextLimit: modelConfig.context_limit, + ...(reasoningEffort?.default + ? { reasoningEffortDefault: reasoningEffort.default } + : {}), + ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}), }; }); @@ -81,11 +90,15 @@ export const SubagentModelOverrideSettings: FC< enableReinitialize: true, initialValues: { model_config_id: modelOverrideData?.model_config_id ?? "", + reasoning_effort: modelOverrideData?.reasoning_effort ?? "", }, onSubmit: (values, { resetForm }) => { onSaveModelOverride( { model_config_id: values.model_config_id, + ...(values.reasoning_effort + ? { reasoning_effort: values.reasoning_effort } + : {}), }, { onSuccess: () => { @@ -101,11 +114,18 @@ export const SubagentModelOverrideSettings: FC< const canSave = hasLoadedModelOverride && !disabled && (form.dirty || isMalformedOverride); + 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 isUnavailableSavedModel = - form.values.model_config_id !== "" && - !enabledModelOptions.some( - (option) => option.id === form.values.model_config_id, - ); + form.values.model_config_id !== "" && selectedModelOption === undefined; return ( - void form.setFieldValue("model_config_id", value) - } + onValueChange={(value) => { + const option = enabledModelOptions.find( + (option) => option.id === value, + ); + void form.setValues({ + model_config_id: value, + reasoning_effort: + pickReasoningEffort( + "", + option?.reasoningEfforts ?? [], + option?.reasoningEffortDefault, + ) ?? "", + }); + }} disabled={isFormDisabled} placeholder={ isUnavailableSavedModel ? "Unavailable model" : unsetPlaceholder @@ -136,6 +167,10 @@ export const SubagentModelOverrideSettings: FC< } className="h-10 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) + } /> { - void form.setFieldValue("model_config_id", ""); + void form.setValues({ + model_config_id: "", + reasoning_effort: "", + }); }} disabled={isFormDisabled} className="h-10" diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index df8ed532ee393..487e5ccf45edb 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -21,6 +21,7 @@ import { } from "#/api/queries/chats"; import { workspaceByIdKey } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; +import { MockChatMessage } from "#/testHelpers/chatEntities"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; import { MockGroup, @@ -122,6 +123,10 @@ const mockModelConfigs: TypesGen.ChatModelConfig[] = [ model: "gpt-4o", display_name: "GPT-4o", is_default: true, + model_config: { + reasoning_effort: { default: "medium", max: "high" }, + }, + reasoning_efforts: ["low", "medium", "high"], created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", }, @@ -1189,8 +1194,38 @@ export const WithMessageHistory: Story = { { diffUrl: undefined }, ), }, + beforeEach: () => { + spyOn(API.experimental, "getChat").mockResolvedValue({ + id: CHAT_ID, + ...baseChatFields, + title: "Markdown rendering showcase", + status: "waiting", + }); + spyOn(API.experimental, "editChatMessage").mockResolvedValue({ + message: { + ...MockChatMessage, + id: 5, + created_at: "2026-02-18T00:03:00.000Z", + }, + }); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + const changeReasoningEffort = async (key: string) => { + const modelSelector = canvas.getByRole("combobox", { name: "GPT-4o" }); + await user.click(modelSelector); + const slider = await body.findByRole("slider"); + slider.focus(); + await user.keyboard(key); + await user.click(modelSelector); + }; + const editLastMessage = async () => { + const buttons = canvas.getAllByRole("button", { name: "Edit message" }); + await user.click(buttons[buttons.length - 1]); + }; + expect( await canvas.findByText("Markdown rendering showcase"), ).toBeVisible(); @@ -1199,6 +1234,35 @@ export const WithMessageHistory: Story = { canvas.queryByText(/^This chat is owned by/), ).not.toBeInTheDocument(); }); + + await changeReasoningEffort("{ArrowRight}"); + await editLastMessage(); + await user.click(canvas.getByRole("button", { name: "Save Edit" })); + await waitFor(() => { + expect(API.experimental.editChatMessage).toHaveBeenCalledTimes(1); + expect( + canvas.getByRole("textbox", { name: "Chat message" }), + ).toBeEnabled(); + }); + + await editLastMessage(); + await changeReasoningEffort("{ArrowLeft}"); + await user.click(canvas.getByRole("button", { name: "Save Edit" })); + await waitFor(() => { + expect(API.experimental.editChatMessage).toHaveBeenCalledTimes(2); + }); + expect(API.experimental.editChatMessage).toHaveBeenNthCalledWith( + 1, + CHAT_ID, + 5, + expect.not.objectContaining({ reasoning_effort: expect.anything() }), + ); + expect(API.experimental.editChatMessage).toHaveBeenNthCalledWith( + 2, + CHAT_ID, + 5, + expect.objectContaining({ reasoning_effort: "medium" }), + ); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index a57643b6e1d64..ac645dc296bf8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -96,6 +96,7 @@ import { resolveModelSelector, } from "./utils/modelOptions"; import { parsePullRequestUrl } from "./utils/pullRequest"; +import { pickReasoningEffort } from "./utils/reasoningEffort"; import { type ChatDetailError, formatUsageLimitMessage, @@ -720,6 +721,8 @@ const AgentChatPage: FC = () => { const { organizations, experiments } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); const [selectedModel, setSelectedModel] = useState(""); + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); + const isEditReasoningEffortDirtyRef = useRef(false); const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); const inputValueRef = useRef( @@ -1133,6 +1136,17 @@ const AgentChatPage: FC = () => { return modelOptions[0]?.id ?? ""; })(); + const effectiveModelOption = modelOptions.find( + (option) => option.id === effectiveSelectedModel, + ); + const effectiveReasoningEffort = effectiveModelOption + ? pickReasoningEffort( + selectedReasoningEffort || chatRecord?.last_reasoning_effort, + effectiveModelOption.reasoningEfforts ?? [], + effectiveModelOption.reasoningEffortDefault, + ) + : undefined; + const compressionThreshold = resolveCompactionThreshold( chatLastModelConfigID, userThresholdsQuery.data?.thresholds, @@ -1257,6 +1271,12 @@ const AgentChatPage: FC = () => { chatInputRef, inputValueRef, }); + const handleEditUserMessage = ( + ...args: Parameters + ) => { + isEditReasoningEffortDirtyRef.current = false; + editing.handleEditUserMessage(...args); + }; const chatTitle = chatQuery.data?.title; @@ -1453,9 +1473,13 @@ const AgentChatPage: FC = () => { pickerModelConfigID !== originalModelConfigID ? pickerModelConfigID : undefined; + // Omit so the backend preserves the original effort. const request: TypesGen.EditChatMessageRequest = { content, model_config_id: editSelectedModelConfigID, + reasoning_effort: isEditReasoningEffortDirtyRef.current + ? effectiveReasoningEffort + : undefined, }; const optimisticMessage = originalEditedMessage ? buildOptimisticEditedMessage({ @@ -1498,6 +1522,7 @@ const AgentChatPage: FC = () => { const request: CreateChatMessageRequestWithClearablePlanMode = { content, model_config_id: selectedModelConfigID, + reasoning_effort: effectiveReasoningEffort, mcp_server_ids: effectiveMCPServerIds.length > 0 ? [...effectiveMCPServerIds] @@ -1643,12 +1668,19 @@ const AgentChatPage: FC = () => { workspaceAgent={workspaceAgent} chatBuildId={chatQuery.data?.build_id} store={store} - editing={editing} + editing={{ ...editing, handleEditUserMessage }} effectiveSelectedModel={effectiveSelectedModel} setSelectedModel={setSelectedModel} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + reasoningEffort={effectiveReasoningEffort} + onReasoningEffortChange={(value) => { + setSelectedReasoningEffort(value); + if (editing.editingMessageId !== null) { + isEditReasoningEffortDirtyRef.current = true; + } + }} canConfigureAgentSetup={permissions.editDeploymentConfig} providerCount={providerCount} modelCount={modelCount} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 1f46b9f8c130d..e61fcd1230557 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -136,6 +136,8 @@ interface AgentChatPageViewProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; @@ -328,6 +330,8 @@ export const AgentChatPageView: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + reasoningEffort, + onReasoningEffortChange, canConfigureAgentSetup, providerCount, modelCount, @@ -941,6 +945,8 @@ export const AgentChatPageView: FC = ({ modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + reasoningEffort={reasoningEffort} + onReasoningEffortChange={onReasoningEffortChange} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} isModelCatalogLoading={isModelCatalogLoading} diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 8de13a09a9b5c..23fa489a23d2b 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -88,6 +88,7 @@ const AgentCreatePage: FC = () => { fileIDs, workspaceId, model, + reasoningEffort, mcpServerIds, organizationId, planMode, @@ -110,6 +111,7 @@ const AgentCreatePage: FC = () => { plan_mode: planMode === "plan" ? "plan" : undefined, client_type: "ui", ...(model ? { model_config_id: model } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), }; const createdChat = await createMutation.mutateAsync(createRequest); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 7df03bbc01402..9867529733740 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -119,6 +119,8 @@ interface AgentChatInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; hasModelOptions: boolean; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; @@ -354,6 +356,8 @@ export const AgentChatInput: FC = ({ modelOptions, modelSelectorPlaceholder, hasModelOptions, + reasoningEffort, + onReasoningEffortChange, planModeEnabled = false, onPlanModeToggle, isModelCatalogLoading = false, @@ -1428,6 +1432,8 @@ export const AgentChatInput: FC = ({ dropdownSide="top" dropdownAlign="start" enableMobileFullWidthDropdown + reasoningEffort={reasoningEffort} + onReasoningEffortChange={onReasoningEffortChange} /> )} {planModeEnabled && !shouldOverflowPlanningBadge && ( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 8d2d7318e0d55..0ca8352bfd974 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -157,6 +157,7 @@ const getCreateOptions = (onCreateChat: unknown): CreateChatSubmission => { type CreateChatSubmission = { model?: string; + reasoningEffort?: string; }; export const RootPersonalModelOverrideModelSelected: Story = { @@ -305,6 +306,64 @@ export const ManualSelectionOverridesRootChatDefault: Story = { }, }; +// Model options with reasoning effort bounds configured. GPT-4o uses the +// full global scale; Claude is capped at medium. +const effortModelOptions = [ + { + ...modelOptions[0], + reasoningEffortDefault: "medium", + reasoningEfforts: [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ], + }, + { + ...modelOptions[1], + reasoningEffortDefault: "low", + reasoningEfforts: ["low", "medium"], + }, +] as const; + +export const SubmitsReasoningEffort: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelOptions: [...effortModelOptions], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + // Open the model selector; the effort row shows the model default. + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + const slider = await body.findByRole("slider"); + // "medium" is the fourth of seven selectable efforts. + expect(slider).toHaveAttribute("aria-valuenow", "3"); + + // Bump the effort to "high" with the keyboard, then close. + await userEvent.tab(); + expect(slider).toHaveFocus(); + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "4"); + }); + await userEvent.keyboard("{Escape}"); + + await submitMessage(canvasElement, "create with reasoning effort"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + const options = getCreateOptions(args.onCreateChat); + expect(options.model).toBe(modelConfigID); + expect(options.reasoningEffort).toBe("high"); + }, +}; + const mockWorkspaces = [ { ...MockWorkspace, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index d1aaf04e4e6ce..2d1683d5229d3 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -20,6 +20,7 @@ import { hasConfiguredModelsInCatalog, hasUserFixableProviders, } from "../utils/modelOptions"; +import { pickReasoningEffort } from "../utils/reasoningEffort"; import { formatUsageLimitMessage, isChatUsageLimitExceededResponse, @@ -47,6 +48,7 @@ export type CreateChatOptions = { fileIDs?: string[]; workspaceId?: string; model?: string; + reasoningEffort?: string; mcpServerIds?: string[]; organizationId: string; planMode?: TypesGen.ChatPlanMode; @@ -238,6 +240,17 @@ export const AgentCreateForm: FC = ({ } return selectedModel || undefined; })(); + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); + const selectedModelOption = modelOptions.find( + (option) => option.id === selectedModel, + ); + const effectiveReasoningEffort = selectedModelOption + ? pickReasoningEffort( + selectedReasoningEffort, + selectedModelOption.reasoningEfforts ?? [], + selectedModelOption.reasoningEffortDefault, + ) + : undefined; const initialOrg = organizations.find((o) => o.is_default) ?? organizations[0]; const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( @@ -367,6 +380,7 @@ export const AgentCreateForm: FC = ({ fileIDs, workspaceId: effectiveWorkspaceId ?? undefined, model: submittedModel, + reasoningEffort: effectiveReasoningEffort, organizationId, mcpServerIds: effectiveMCPServerIds.length > 0 @@ -529,6 +543,8 @@ export const AgentCreateForm: FC = ({ onModelChange={handleModelChange} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} + reasoningEffort={effectiveReasoningEffort} + onReasoningEffortChange={setSelectedReasoningEffort} isModelCatalogLoading={isModelCatalogLoading} hasModelOptions={hasModelOptions} planModeEnabled={planModeEnabled} diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx index 01815edcae557..0311eb75aaf0d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -1,5 +1,6 @@ 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 { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; import { MockModelSelectorOption } from "./modelSelectorFixtures"; @@ -48,6 +49,24 @@ const anthropicModels: ModelSelectorOption[] = [ const allModels: ModelSelectorOption[] = [...openAIModels, ...anthropicModels]; +const effortModel: ModelSelectorOption = { + ...MockModelSelectorOption, + id: "openai/gpt-5", + model: "gpt-5", + displayName: "GPT-5", + contextLimit: 400_000, + reasoningEffortDefault: "medium", + reasoningEfforts: [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ], +}; + const meta: Meta = { title: "pages/AgentsPage/ChatElements/ModelSelector", component: ModelSelector, @@ -254,3 +273,138 @@ export const FiltersModels: Story = { ); }, }; + +// --------------------------------------------------------------------------- +// Reasoning effort row +// --------------------------------------------------------------------------- + +export const EffortRowHiddenWithoutConfig: Story = { + args: { + options: openAIModels, + value: "openai/gpt-4o", + reasoningEffort: "medium", + onReasoningEffortChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click(canvas.getByRole("combobox")); + await body.findByRole("listbox"); + + expect(body.queryByRole("slider")).not.toBeInTheDocument(); + expect(body.queryByText("Effort")).not.toBeInTheDocument(); + }, +}; + +const EffortRowStory = ({ + onReasoningEffortChange, +}: { + onReasoningEffortChange: (value: string) => void; +}) => { + const [effort, setEffort] = useState("medium"); + return ( + { + onReasoningEffortChange(value); + setEffort(value); + }} + /> + ); +}; + +export const EffortRow: Story = { + args: { + onReasoningEffortChange: fn(), + }, + render: (args) => ( + args.onReasoningEffortChange?.(value)} + /> + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-5" })); + await body.findByRole("listbox"); + + // The row is visible with one discrete step per selectable effort. + await waitFor(() => { + expect(body.getByText("Effort")).toBeVisible(); + }); + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemin", "0"); + expect(slider).toHaveAttribute("aria-valuemax", "6"); + // "medium" is the fourth of seven selectable efforts. + expect(slider).toHaveAttribute("aria-valuenow", "3"); + expect(body.getByText("Medium")).toBeVisible(); + + const infoTrigger = body.getByRole("button", { + name: "About reasoning effort", + }); + await userEvent.tab(); + expect(infoTrigger).toHaveFocus(); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Controls how much reasoning the model performs before responding.", + ); + + await userEvent.tab(); + expect(slider).toHaveFocus(); + + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "4"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("high"); + expect(body.getByText("High")).toBeVisible(); + + await userEvent.keyboard("{ArrowRight}{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "6"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("max"); + expect(body.getByText("Max")).toBeVisible(); + + await userEvent.keyboard("{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "2"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("low"); + expect(body.getByText("Low")).toBeVisible(); + }, +}; + +export const EffortRowClampedToMax: Story = { + args: { + options: [ + { + ...effortModel, + reasoningEffortDefault: "low", + reasoningEfforts: ["none", "minimal", "low", "medium"], + }, + ], + value: "openai/gpt-5", + reasoningEffort: "low", + onReasoningEffortChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click(canvas.getByRole("combobox")); + await body.findByRole("listbox"); + + // Selectable efforts stop at the configured max. + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemax", "3"); + expect(slider).toHaveAttribute("aria-valuenow", "2"); + await waitFor(() => { + expect(body.getByText("Low")).toBeVisible(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index d6285f7ed87e0..ebd894b0c15fe 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { CheckIcon } from "lucide-react"; +import { CheckIcon, InfoIcon } from "lucide-react"; import { type FC, useState } from "react"; import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; import { Button } from "#/components/Button/Button"; @@ -15,9 +15,16 @@ import { PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; +import { Slider } from "#/components/Slider/Slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; import { formatProviderLabel as defaultFormatProviderLabel } from "#/utils/aiProviders"; import { cn } from "#/utils/cn"; +import { formatReasoningEffort } from "../../utils/reasoningEffort"; export interface ModelSelectorOption { id: string; @@ -28,6 +35,8 @@ export interface ModelSelectorOption { model: string; displayName: string; contextLimit?: number; + reasoningEffortDefault?: string; + reasoningEfforts?: readonly string[]; } interface ModelSelectorProps { @@ -44,6 +53,8 @@ interface ModelSelectorProps { contentClassName?: string; onTriggerTouchStart?: () => void; enableMobileFullWidthDropdown?: boolean; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; } const formatContextLimit = (tokens: number): string => { @@ -85,6 +96,8 @@ export const ModelSelector: FC = ({ contentClassName, onTriggerTouchStart, enableMobileFullWidthDropdown = false, + reasoningEffort, + onReasoningEffortChange, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -224,11 +237,83 @@ export const ModelSelector: FC = ({ })} + {selectedModel && + reasoningEffort !== undefined && + onReasoningEffortChange && ( + + )} ); }; +interface ReasoningEffortRowProps { + option: ModelSelectorOption; + value: string; + onChange: (value: string) => void; +} + +// Effort row pinned below the model list. Lives outside the Command +// so it stays visible while the list scrolls and cmdk's arrow-key +// navigation does not capture the slider's keyboard interaction. +const ReasoningEffortRow: FC = ({ + option, + value, + onChange, +}) => { + const selectableEfforts = option.reasoningEfforts ?? []; + if (selectableEfforts.length === 0) { + return null; + } + const valueIndex = selectableEfforts.indexOf(value); + const effortIndex = valueIndex >= 0 ? valueIndex : 0; + + return ( +
+
+ + Effort + + + + + + + Controls how much reasoning the model performs before responding. + Higher effort can improve quality but is slower and costs more. + + +
+ { + const nextEffort = selectableEfforts[index]; + if (nextEffort && nextEffort !== value) { + onChange(nextEffort); + } + }} + min={0} + max={selectableEfforts.length - 1} + step={1} + /> + + {formatReasoningEffort(value)} + +
+ ); +}; + interface ModelOptionItemProps { option: ModelSelectorOption; isSelected: boolean; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 654951e4934de..0d7ca6c4688a5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -173,6 +173,8 @@ interface ChatPageInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; @@ -244,6 +246,8 @@ export const ChatPageInput: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + reasoningEffort, + onReasoningEffortChange, canConfigureAgentSetup, providerCount, modelCount, @@ -504,6 +508,8 @@ export const ChatPageInput: FC = ({ onModelChange={onModelChange} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} + reasoningEffort={reasoningEffort} + onReasoningEffortChange={onReasoningEffortChange} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} isModelCatalogLoading={isModelCatalogLoading} diff --git a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx index e07e8a2b23320..5b96de5f0e697 100644 --- a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx +++ b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx @@ -12,6 +12,11 @@ import { SelectTrigger, SelectValue, } from "#/components/Select/Select"; +import { Slider } from "#/components/Slider/Slider"; +import { + formatReasoningEffort, + pickReasoningEffort, +} from "../utils/reasoningEffort"; import type { ModelSelectorOption } from "./ChatElements"; import { ModelOverrideAlerts } from "./ModelOverrideAlerts"; import { SectionHeader } from "./SectionHeader"; @@ -35,6 +40,7 @@ export type SavePersonalOverride = ( interface PersonalOverrideFormValues { mode: PersonalOverrideMode; model_config_id: string; + reasoning_effort: string; } interface PersonalModelOverrideRowProps { @@ -65,12 +71,20 @@ const toFormValues = ( context: PersonalOverrideContext, ): PersonalOverrideFormValues => { if (!overrideData || overrideData.is_malformed) { - return { mode: getDefaultMode(context), model_config_id: "" }; + return { + mode: getDefaultMode(context), + model_config_id: "", + reasoning_effort: "", + }; } return { mode: overrideData.mode, model_config_id: overrideData.mode === "model" ? overrideData.model_config_id : "", + reasoning_effort: + overrideData.mode === "model" + ? (overrideData.reasoning_effort ?? "") + : "", }; }; @@ -81,6 +95,9 @@ const toUpdateRequest = ( return { mode: "model", model_config_id: values.model_config_id, + ...(values.reasoning_effort + ? { reasoning_effort: values.reasoning_effort } + : {}), }; } return { mode: values.mode, model_config_id: "" }; @@ -258,6 +275,17 @@ export const PersonalModelOverrideRow: FC = ({ form.values.mode === "model" ? form.values.model_config_id : form.values.mode; + const selectedModelOption = modelOptions.find( + (option) => option.id === form.values.model_config_id, + ); + const selectedReasoningEffort = + form.values.mode === "model" && selectedModelOption + ? pickReasoningEffort( + form.values.reasoning_effort, + selectedModelOption.reasoningEfforts ?? [], + selectedModelOption.reasoningEffortDefault, + ) + : undefined; const selectionLabel = getSelectionLabel({ context, deploymentDefault, @@ -280,10 +308,27 @@ export const PersonalModelOverrideRow: FC = ({ value={selectionValue} onValueChange={(value) => { if (isDefaultModeOption(value)) { - void form.setValues({ mode: value, model_config_id: "" }); + void form.setValues({ + mode: value, + model_config_id: "", + reasoning_effort: "", + }); return; } - void form.setValues({ mode: "model", model_config_id: value }); + const option = modelOptions.find((option) => option.id === value); + const reasoningEffortDefault = option + ? option.reasoningEffortDefault + : undefined; + void form.setValues({ + mode: "model", + model_config_id: value, + reasoning_effort: + pickReasoningEffort( + "", + option?.reasoningEfforts ?? [], + reasoningEffortDefault, + ) ?? "", + }); }} disabled={isFormDisabled} > @@ -339,6 +384,15 @@ export const PersonalModelOverrideRow: FC = ({
+ {selectedReasoningEffort !== undefined && selectedModelOption && ( + + void form.setFieldValue("reasoning_effort", value) + } + /> + )} = ({ ); }; +interface PersonalReasoningEffortRowProps { + option: ModelSelectorOption; + value: string; + onChange: (value: string) => void; +} + +const PersonalReasoningEffortRow: FC = ({ + option, + value, + onChange, +}) => { + const selectableEfforts = option.reasoningEfforts ?? []; + if (selectableEfforts.length === 0) { + return null; + } + const valueIndex = selectableEfforts.indexOf(value); + const effortIndex = valueIndex >= 0 ? valueIndex : 0; + + return ( +
+ Effort + { + const nextEffort = selectableEfforts[index]; + if (nextEffort && nextEffort !== value) { + onChange(nextEffort); + } + }} + min={0} + max={selectableEfforts.length - 1} + step={1} + /> + + {formatReasoningEffort(value)} + +
+ ); +}; + interface DefaultModeSelectItemProps { mode: Exclude; context: PersonalOverrideContext; diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index ad5866975e172..d009a01c26680 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -323,6 +323,58 @@ describe("getModelOptionsFromConfigs", () => { ]); }); + it("populates reasoning effort bounds from the model config", () => { + const configs = [ + createConfig({ + id: "config-effort", + ai_provider_id: "prov-openai", + model: "gpt-5", + display_name: "GPT-5", + model_config: { + reasoning_effort: { default: "medium", max: "xhigh" }, + }, + reasoning_efforts: ["minimal", "low", "medium", "high", "xhigh"], + }), + createConfig({ + id: "config-no-effort", + ai_provider_id: "prov-openai", + model: "gpt-4o", + display_name: "GPT-4o", + model_config: {}, + }), + ]; + const catalog = createCatalog([ + { provider: "openai", available: true, models: [] }, + ]); + + expect( + getModelOptionsFromConfigs(configs, catalog, providerInfoByID), + ).toEqual([ + { + id: "config-no-effort", + provider: "openai", + providerId: "prov-openai", + providerLabel: "OpenAI", + providerIcon: "", + model: "gpt-4o", + displayName: "GPT-4o", + contextLimit: 0, + }, + { + id: "config-effort", + provider: "openai", + providerId: "prov-openai", + providerLabel: "OpenAI", + providerIcon: "", + model: "gpt-5", + displayName: "GPT-5", + contextLimit: 0, + reasoningEffortDefault: "medium", + reasoningEfforts: ["minimal", "low", "medium", "high", "xhigh"], + }, + ]); + }); + it("excludes configs whose providers are unavailable", () => { const configs = [ createConfig({ diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index 6bae7d6730beb..fab4acf1046e7 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -253,6 +253,9 @@ export const getModelOptionsFromConfigs = ( const displayName = config.display_name.trim() || model; const contextLimit = asNumber(config.context_limit); + const reasoningEffort = config.model_config?.reasoning_effort; + const reasoningEffortDefault = asString(reasoningEffort?.default).trim(); + const reasoningEfforts = config.reasoning_efforts ?? []; options.push({ id: configID, provider, @@ -262,6 +265,8 @@ export const getModelOptionsFromConfigs = ( model, displayName, ...(contextLimit !== undefined ? { contextLimit } : {}), + ...(reasoningEffortDefault ? { reasoningEffortDefault } : {}), + ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}), }); } diff --git a/site/src/pages/AgentsPage/utils/reasoningEffort.ts b/site/src/pages/AgentsPage/utils/reasoningEffort.ts index f3ae6364529df..8c023a0bf6332 100644 --- a/site/src/pages/AgentsPage/utils/reasoningEffort.ts +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.ts @@ -2,7 +2,7 @@ export const formatReasoningEffort = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1); -/** Chooses requested effort, then default effort, then the highest effort. */ +/** Chooses requested effort, then default effort, then the last selectable effort. */ export const pickReasoningEffort = ( value: string | undefined, efforts: readonly string[],