diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 86eea40d2ce0c..3ef8472de5454 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16680,6 +16680,10 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "last_reasoning_effort": { + "description": "LastReasoningEffort is the reasoning effort carried by the most\nrecent message that set one. Used to initialize the effort\nselector for subsequent turns.", + "type": "string" + }, "last_turn_summary": { "type": "string" }, @@ -18155,6 +18159,10 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatPlanMode" } ] + }, + "reasoning_effort": { + "description": "ReasoningEffort is the user-selected reasoning effort for the\nturn triggered by this message. Clamped to the model config's\nmax effort at generation time. Ignored when the model config\nhas no reasoning effort configured.", + "type": "string" } } }, @@ -18214,6 +18222,10 @@ const docTemplate = `{ "plan_mode": { "$ref": "#/definitions/codersdk.ChatPlanMode" }, + "reasoning_effort": { + "description": "ReasoningEffort is the user-selected reasoning effort for the\nfirst turn. Clamped to the model config's max effort at\ngeneration time. Ignored when the model config has no\nreasoning effort configured.", + "type": "string" + }, "system_prompt": { "type": "string" }, @@ -19539,6 +19551,10 @@ 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": { + "description": "ReasoningEffort, when set, overrides the reasoning effort for\nthe replacement user message. When nil the original message's\nreasoning effort is preserved.", + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d30e6894daceb..0d05cc8a67000 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14984,6 +14984,10 @@ "type": "string", "format": "uuid" }, + "last_reasoning_effort": { + "description": "LastReasoningEffort is the reasoning effort carried by the most\nrecent message that set one. Used to initialize the effort\nselector for subsequent turns.", + "type": "string" + }, "last_turn_summary": { "type": "string" }, @@ -16398,6 +16402,10 @@ "$ref": "#/definitions/codersdk.ChatPlanMode" } ] + }, + "reasoning_effort": { + "description": "ReasoningEffort is the user-selected reasoning effort for the\nturn triggered by this message. Clamped to the model config's\nmax effort at generation time. Ignored when the model config\nhas no reasoning effort configured.", + "type": "string" } } }, @@ -16457,6 +16465,10 @@ "plan_mode": { "$ref": "#/definitions/codersdk.ChatPlanMode" }, + "reasoning_effort": { + "description": "ReasoningEffort is the user-selected reasoning effort for the\nfirst turn. Clamped to the model config's max effort at\ngeneration time. Ignored when the model config has no\nreasoning effort configured.", + "type": "string" + }, "system_prompt": { "type": "string" }, @@ -17736,6 +17748,10 @@ "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": { + "description": "ReasoningEffort, when set, overrides the reasoning effort for\nthe replacement user message. When nil the original message's\nreasoning effort is preserved.", + "type": "string" } } }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 3072c4b290797..2072f2010c2f2 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1701,6 +1701,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 := c.LastReasoningEffort.String + 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..38f4cbd329d14 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: sql.NullString{String: "high", 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 79b0245c6bf72..a79cf79547cf3 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{seed.ReasoningEffort.String}, 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 7b659c63dd794..d45e36cfa5b70 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1913,9 +1913,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 'User-selected reasoning effort for the turn triggered by this message. NULL when the sender did not select one.'; + CREATE SEQUENCE chat_messages_id_seq START WITH 1 INCREMENT BY 1 @@ -1961,9 +1964,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 'User-selected reasoning effort carried into the message when the queued row is promoted.'; + CREATE SEQUENCE chat_queued_messages_id_seq START WITH 1 INCREMENT BY 1 @@ -2037,6 +2043,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))), @@ -2058,6 +2065,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 'Reasoning effort carried by the most recent message that set one. Used as the per-turn effort for subsequent generations until overridden.'; + CREATE TABLE users ( id uuid NOT NULL, email text NOT NULL, @@ -2123,6 +2132,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/000537_chat_reasoning_effort.down.sql b/coderd/database/migrations/000537_chat_reasoning_effort.down.sql new file mode 100644 index 0000000000000..5cf6a9c76459d --- /dev/null +++ b/coderd/database/migrations/000537_chat_reasoning_effort.down.sql @@ -0,0 +1,136 @@ +DROP VIEW IF EXISTS chats_expanded; + +-- Move the reasoning effort default back to the provider-appropriate +-- legacy path. Rows whose provider cannot be determined just lose the +-- reasoning_effort key in the cleanup below. +UPDATE chat_model_configs cmc +SET options = (cmc.options - 'reasoning_effort') || jsonb_build_object( + 'provider_options', + COALESCE(cmc.options -> 'provider_options', '{}'::jsonb) || jsonb_build_object( + 'openai', + COALESCE(cmc.options #> '{provider_options,openai}', '{}'::jsonb) || + jsonb_build_object('reasoning_effort', cmc.options #> '{reasoning_effort,default}') + ) +) +FROM ai_providers ap +WHERE ap.id = cmc.ai_provider_id + AND ap.type IN ('openai', 'azure') + AND cmc.options #>> '{reasoning_effort,default}' IS NOT NULL; + +UPDATE chat_model_configs cmc +SET options = (cmc.options - 'reasoning_effort') || jsonb_build_object( + 'provider_options', + COALESCE(cmc.options -> 'provider_options', '{}'::jsonb) || jsonb_build_object( + 'anthropic', + COALESCE(cmc.options #> '{provider_options,anthropic}', '{}'::jsonb) || + jsonb_build_object('effort', cmc.options #> '{reasoning_effort,default}') + ) +) +FROM ai_providers ap +WHERE ap.id = cmc.ai_provider_id + AND ap.type IN ('anthropic', 'bedrock') + AND cmc.options #>> '{reasoning_effort,default}' IS NOT NULL; + +UPDATE chat_model_configs cmc +SET options = (cmc.options - 'reasoning_effort') || jsonb_build_object( + 'provider_options', + COALESCE(cmc.options -> 'provider_options', '{}'::jsonb) || jsonb_build_object( + 'openaicompat', + COALESCE(cmc.options #> '{provider_options,openaicompat}', '{}'::jsonb) || + jsonb_build_object('reasoning_effort', cmc.options #> '{reasoning_effort,default}') + ) +) +FROM ai_providers ap +WHERE ap.id = cmc.ai_provider_id + AND ap.type = 'openai-compat' + AND cmc.options #>> '{reasoning_effort,default}' IS NOT NULL; + +UPDATE chat_model_configs cmc +SET options = (cmc.options - 'reasoning_effort') || jsonb_build_object( + 'provider_options', + COALESCE(cmc.options -> 'provider_options', '{}'::jsonb) || jsonb_build_object( + 'openrouter', + COALESCE(cmc.options #> '{provider_options,openrouter}', '{}'::jsonb) || jsonb_build_object( + 'reasoning', + COALESCE(cmc.options #> '{provider_options,openrouter,reasoning}', '{}'::jsonb) || + jsonb_build_object('effort', cmc.options #> '{reasoning_effort,default}') + ) + ) +) +FROM ai_providers ap +WHERE ap.id = cmc.ai_provider_id + AND ap.type = 'openrouter' + AND cmc.options #>> '{reasoning_effort,default}' IS NOT NULL; + +UPDATE chat_model_configs cmc +SET options = (cmc.options - 'reasoning_effort') || jsonb_build_object( + 'provider_options', + COALESCE(cmc.options -> 'provider_options', '{}'::jsonb) || jsonb_build_object( + 'vercel', + COALESCE(cmc.options #> '{provider_options,vercel}', '{}'::jsonb) || jsonb_build_object( + 'reasoning', + COALESCE(cmc.options #> '{provider_options,vercel,reasoning}', '{}'::jsonb) || + jsonb_build_object('effort', cmc.options #> '{reasoning_effort,default}') + ) + ) +) +FROM ai_providers ap +WHERE ap.id = cmc.ai_provider_id + AND ap.type = 'vercel' + AND cmc.options #>> '{reasoning_effort,default}' IS NOT NULL; + +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/000537_chat_reasoning_effort.up.sql b/coderd/database/migrations/000537_chat_reasoning_effort.up.sql new file mode 100644 index 0000000000000..1335544b354fa --- /dev/null +++ b/coderd/database/migrations/000537_chat_reasoning_effort.up.sql @@ -0,0 +1,113 @@ +-- 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; + +COMMENT ON COLUMN chats.last_reasoning_effort IS 'Reasoning effort carried by the most recent message that set one. Used as the per-turn effort for subsequent generations until overridden.'; +COMMENT ON COLUMN chat_messages.reasoning_effort IS 'User-selected reasoning effort for the turn triggered by this message. NULL when the sender did not select one.'; +COMMENT ON COLUMN chat_queued_messages.reasoning_effort IS 'User-selected reasoning effort carried into the message when 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))); + +-- Migrate the legacy per-provider effort values inside +-- chat_model_configs.options to the new top-level reasoning_effort +-- config. The old fixed value becomes both the default and the max. +-- The legacy path is selected by the config's provider type; rows +-- with a NULL or unknown provider fall back to the first populated +-- legacy path. +WITH legacy AS ( + SELECT + cmc.id, + CASE ap.type + WHEN 'openai' THEN NULLIF(cmc.options #>> '{provider_options,openai,reasoning_effort}', '') + WHEN 'azure' THEN NULLIF(cmc.options #>> '{provider_options,openai,reasoning_effort}', '') + WHEN 'anthropic' THEN NULLIF(cmc.options #>> '{provider_options,anthropic,effort}', '') + WHEN 'bedrock' THEN NULLIF(cmc.options #>> '{provider_options,anthropic,effort}', '') + WHEN 'openai-compat' THEN NULLIF(cmc.options #>> '{provider_options,openaicompat,reasoning_effort}', '') + WHEN 'openrouter' THEN NULLIF(cmc.options #>> '{provider_options,openrouter,reasoning,effort}', '') + WHEN 'vercel' THEN NULLIF(cmc.options #>> '{provider_options,vercel,reasoning,effort}', '') + ELSE COALESCE( + NULLIF(cmc.options #>> '{provider_options,openai,reasoning_effort}', ''), + NULLIF(cmc.options #>> '{provider_options,anthropic,effort}', ''), + NULLIF(cmc.options #>> '{provider_options,openaicompat,reasoning_effort}', ''), + NULLIF(cmc.options #>> '{provider_options,openrouter,reasoning,effort}', ''), + NULLIF(cmc.options #>> '{provider_options,vercel,reasoning,effort}', '') + ) + END AS effort + FROM chat_model_configs cmc + LEFT JOIN ai_providers ap ON ap.id = cmc.ai_provider_id +) +UPDATE chat_model_configs +SET options = jsonb_set( + chat_model_configs.options, + '{reasoning_effort}', + jsonb_build_object('default', legacy.effort, 'max', legacy.effort) +) +FROM legacy +WHERE chat_model_configs.id = legacy.id + AND legacy.effort IS NOT NULL; + +-- Strip the legacy per-provider effort keys, including empty-string +-- values that were skipped above. +UPDATE chat_model_configs +SET options = ((((options + #- '{provider_options,openai,reasoning_effort}') + #- '{provider_options,anthropic,effort}') + #- '{provider_options,openaicompat,reasoning_effort}') + #- '{provider_options,openrouter,reasoning,effort}') + #- '{provider_options,vercel,reasoning,effort}' +WHERE options #> '{provider_options,openai,reasoning_effort}' IS NOT NULL + OR options #> '{provider_options,anthropic,effort}' IS NOT NULL + OR options #> '{provider_options,openaicompat,reasoning_effort}' IS NOT NULL + OR options #> '{provider_options,openrouter,reasoning,effort}' IS NOT NULL + OR options #> '{provider_options,vercel,reasoning,effort}' IS NOT NULL; diff --git a/coderd/database/migrations/testdata/fixtures/000535_chat_model_config_legacy_reasoning_effort.up.sql b/coderd/database/migrations/testdata/fixtures/000535_chat_model_config_legacy_reasoning_effort.up.sql new file mode 100644 index 0000000000000..6f24c9aed2ace --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000535_chat_model_config_legacy_reasoning_effort.up.sql @@ -0,0 +1,91 @@ +-- Chat model configs carrying legacy per-provider reasoning effort +-- values inside options. Inserted at 000535 so the 000537 data +-- migration rewrites them into the top-level reasoning_effort config +-- ({default, max}) and strips the legacy keys. +INSERT INTO ai_providers ( + id, + type, + name, + display_name, + enabled, + deleted, + base_url, + settings +) VALUES + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e01', + 'openai', + 'openai-effort-fixture', + 'OpenAI (Reasoning Effort Fixture)', + TRUE, + FALSE, + 'https://api.openai.com/v1/', + '' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e02', + 'anthropic', + 'anthropic-effort-fixture', + 'Anthropic (Reasoning Effort Fixture)', + TRUE, + FALSE, + 'https://api.anthropic.com/', + '' + ); + +INSERT INTO chat_model_configs ( + id, + model, + display_name, + enabled, + is_default, + deleted, + context_limit, + compression_threshold, + options, + ai_provider_id, + created_at, + updated_at +) VALUES + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f01', + 'gpt-5.1', + 'GPT-5.1 (Legacy Effort)', + TRUE, + FALSE, + FALSE, + 200000, + 70, + '{"provider_options": {"openai": {"reasoning_effort": "high", "reasoning_summary": "auto"}}}', + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e01', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f02', + 'claude-opus-4-6', + 'Claude Opus (Legacy Effort)', + TRUE, + FALSE, + FALSE, + 200000, + 70, + '{"provider_options": {"anthropic": {"effort": "max", "send_reasoning": true}}}', + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e02', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ), + ( + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6f03', + 'gpt-5.1-empty-effort', + 'GPT-5.1 (Empty Legacy Effort)', + TRUE, + FALSE, + FALSE, + 200000, + 70, + '{"provider_options": {"openai": {"reasoning_effort": ""}}}', + '4f0a9c2e-1d3b-4a5c-8e7f-6a9b8c7d6e01', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00' + ); 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 2abae159c1caa..031d6455bb581 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4796,6 +4796,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"` @@ -4961,6 +4962,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"` + // User-selected reasoning effort for the turn triggered by this message. NULL when the sender did not select one. + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` } type ChatModelConfig struct { @@ -4990,6 +4993,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"` + // User-selected reasoning effort carried into the message when the queued row is promoted. + ReasoningEffort sql.NullString `db:"reasoning_effort" json:"reasoning_effort"` } type ChatTable struct { @@ -5041,6 +5046,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"` + // Reasoning effort carried by the most recent message that set one. Used as the per-turn effort for subsequent generations until overridden. + 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 4a5c315c63ec7..a384dcdee626f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5542,7 +5542,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 @@ -5559,6 +5559,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, @@ -5594,7 +5595,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 ` @@ -5629,6 +5630,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -5797,7 +5799,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 @@ -5814,6 +5816,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, @@ -5849,7 +5852,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 ` @@ -5877,6 +5880,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, @@ -5957,10 +5961,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, @@ -6019,6 +6023,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"` } @@ -6080,6 +6085,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 @@ -6356,7 +6362,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 @@ -6390,6 +6396,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, @@ -6436,7 +6443,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 ( @@ -6482,6 +6489,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"` @@ -6541,6 +6549,7 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -6609,7 +6618,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 ` @@ -6631,6 +6640,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, @@ -6667,7 +6677,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 @@ -6687,6 +6697,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, @@ -6722,7 +6733,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 ` @@ -6743,6 +6754,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, @@ -6779,7 +6791,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 @@ -6799,6 +6811,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, @@ -6834,7 +6847,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 ` @@ -6855,6 +6868,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, @@ -7471,7 +7485,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 @@ -7506,6 +7520,7 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ) return i, err } @@ -7595,7 +7610,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 @@ -7645,6 +7660,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7661,7 +7677,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 @@ -7714,6 +7730,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDAscPaginated(ctx context.Context, ar &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7730,7 +7747,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 @@ -7796,6 +7813,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7812,7 +7830,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 @@ -7861,6 +7879,7 @@ func (q *sqlQuerier) GetChatMessagesByRevisionForStream(ctx context.Context, arg &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -7893,7 +7912,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 @@ -7967,6 +7986,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -8030,7 +8050,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 ` @@ -8051,12 +8071,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 @@ -8075,12 +8096,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 ` @@ -8103,6 +8125,7 @@ func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -8118,7 +8141,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 ` @@ -8142,6 +8165,7 @@ func (q *sqlQuerier) GetChatQueuedMessagesByPosition(ctx context.Context, chatID &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -8337,7 +8361,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 @@ -8387,6 +8411,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"` @@ -8455,6 +8480,7 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -8511,7 +8537,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 @@ -8736,6 +8762,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, @@ -8783,7 +8810,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 @@ -8819,6 +8846,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, @@ -8864,7 +8892,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 @@ -8893,6 +8921,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -8938,7 +8967,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[]) @@ -8968,6 +8997,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, @@ -9082,7 +9112,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 @@ -9140,6 +9170,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, @@ -9201,7 +9232,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 @@ -9246,13 +9277,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 @@ -9298,6 +9330,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, @@ -9533,7 +9566,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 @@ -9550,6 +9583,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, @@ -9585,7 +9619,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 ` @@ -9642,6 +9676,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, @@ -9677,32 +9712,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 ( + ) AS last_model_config_id, + ( SELECT val - FROM UNNEST($4::uuid[]) + 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 ( @@ -9710,6 +9750,7 @@ INSERT INTO chat_messages ( created_by, api_key_id, model_config_id, + reasoning_effort, role, content, content_version, @@ -9731,23 +9772,24 @@ 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[]), ''), + 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($16::bigint[]), 0), + UNNEST($17::boolean[]), NULLIF(UNNEST($18::bigint[]), 0), - NULLIF(UNNEST($19::text[]), '') + NULLIF(UNNEST($19::bigint[]), 0), + NULLIF(UNNEST($20::text[]), '') 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 { @@ -9755,6 +9797,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"` @@ -9778,6 +9821,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), @@ -9825,6 +9869,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ); err != nil { return nil, err } @@ -9840,23 +9885,25 @@ 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, + $5::text, 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 { - 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 sql.NullString `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, @@ -9867,6 +9914,7 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat arg.ChatID, arg.Content, arg.ModelConfigID, + arg.ReasoningEffort, arg.APIKeyID, ) var i ChatQueuedMessage @@ -9879,28 +9927,31 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } 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 + $5::text, + $6::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 { - 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 sql.NullString `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 @@ -9911,6 +9962,7 @@ func (q *sqlQuerier) InsertChatQueuedMessageWithCreator(ctx context.Context, arg arg.ChatID, arg.Content, arg.ModelConfigID, + arg.ReasoningEffort, arg.APIKeyID, arg.CreatedBy, ) @@ -9924,6 +9976,7 @@ func (q *sqlQuerier) InsertChatQueuedMessageWithCreator(ctx context.Context, arg &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } @@ -10157,7 +10210,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 @@ -10174,6 +10227,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, @@ -10208,7 +10262,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 ` @@ -10233,6 +10287,7 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -10387,7 +10442,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) { @@ -10402,6 +10457,7 @@ func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID) &i.APIKeyID, &i.Position, &i.CreatedBy, + &i.ReasoningEffort, ) return i, err } @@ -10593,7 +10649,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 @@ -10610,6 +10666,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, @@ -10645,7 +10702,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 ` @@ -10677,6 +10734,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, @@ -10809,7 +10867,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 @@ -10826,6 +10884,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, @@ -10861,7 +10920,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 ` @@ -10888,6 +10947,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -10931,7 +10991,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 @@ -10948,6 +11008,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, @@ -10983,7 +11044,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 ` @@ -11009,6 +11070,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11056,7 +11118,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 @@ -11073,6 +11135,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, @@ -11107,7 +11170,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 ` @@ -11150,6 +11213,7 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11238,7 +11302,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 @@ -11255,6 +11319,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, @@ -11290,7 +11355,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 ` @@ -11316,6 +11381,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11359,7 +11425,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 @@ -11376,6 +11442,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, @@ -11411,7 +11478,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 ` @@ -11437,6 +11504,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11530,7 +11598,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 @@ -11547,6 +11615,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, @@ -11582,7 +11651,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 ` @@ -11608,6 +11677,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11651,7 +11721,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 { @@ -11687,6 +11757,7 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ProviderResponseID, &i.APIKeyID, &i.Revision, + &i.ReasoningEffort, ) return i, err } @@ -11771,7 +11842,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 @@ -11788,6 +11859,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, @@ -11823,7 +11895,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 ` @@ -11849,6 +11921,7 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -11890,7 +11963,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 @@ -11907,6 +11980,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, @@ -11941,7 +12015,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 ` @@ -11969,6 +12043,7 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -12016,7 +12091,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 @@ -12033,6 +12108,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, @@ -12068,7 +12144,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 ` @@ -12105,6 +12181,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 +12229,7 @@ SET updated_at = $6::timestamptz 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 @@ -12169,6 +12246,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 +12282,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 ` @@ -12243,6 +12321,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -12288,7 +12367,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 @@ -12305,6 +12384,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, @@ -12340,7 +12420,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 ` @@ -12366,6 +12446,7 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl &i.ParentChatID, &i.RootChatID, &i.LastModelConfigID, + &i.LastReasoningEffort, &i.Archived, &i.LastError, &i.Mode, @@ -12408,7 +12489,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 @@ -12425,6 +12506,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, @@ -12460,7 +12542,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 ` @@ -12493,6 +12575,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 b7bbe8ecb38d7..6c2b66eac0ee0 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, @@ -790,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 ( + ) AS last_model_config_id, + ( SELECT val - FROM UNNEST(@model_config_id::uuid[]) + 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 ( @@ -823,6 +831,7 @@ INSERT INTO chat_messages ( created_by, api_key_id, model_config_id, + reasoning_effort, role, content, content_version, @@ -844,6 +853,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[]), ''), UNNEST(@role::chat_message_role[]), UNNEST(@content::text[])::jsonb, UNNEST(@content_version::smallint[]), @@ -899,6 +909,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, @@ -965,6 +976,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, @@ -1029,6 +1041,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, @@ -1093,6 +1106,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, @@ -1157,6 +1171,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, @@ -1220,6 +1235,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, @@ -1283,6 +1299,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, @@ -1364,6 +1381,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, @@ -1580,6 +1598,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, @@ -1648,6 +1667,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, @@ -1716,6 +1736,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, @@ -1912,11 +1933,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')::text, sqlc.narg('api_key_id')::text, chats.owner_id FROM chats @@ -1991,6 +2013,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, @@ -2051,6 +2074,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, @@ -2734,6 +2758,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, @@ -2805,6 +2830,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, @@ -2868,6 +2894,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, @@ -2922,11 +2949,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')::text, sqlc.narg('api_key_id')::text, @created_by::uuid ) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 03b3fe745cc48..4f0a3ddb5507d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1235,12 +1235,22 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { } } + chatReasoningEffort := chatprovider.NormalizeGlobalReasoningEffort(req.ReasoningEffort) + if req.ReasoningEffort != nil && chatReasoningEffort == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: "Must be one of none, minimal, low, medium, high, xhigh, max.", + }) + return + } + chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{ OrganizationID: req.OrganizationID, OwnerID: apiKey.UserID, WorkspaceID: workspaceSelection.WorkspaceID, Title: title, ModelConfigID: modelConfigID, + ReasoningEffort: chatReasoningEffort, PlanMode: planModeToNullChatPlanMode(req.PlanMode), ClientType: clientType, SystemPrompt: req.SystemPrompt, @@ -3160,17 +3170,27 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { modelConfigID = *req.ModelConfigID } + reasoningEffort := chatprovider.NormalizeGlobalReasoningEffort(req.ReasoningEffort) + if req.ReasoningEffort != nil && reasoningEffort == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: "Must be one of none, minimal, low, medium, high, xhigh, max.", + }) + 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 { @@ -3318,6 +3338,15 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { editModelConfigID = *req.ModelConfigID } + editReasoningEffort := chatprovider.NormalizeGlobalReasoningEffort(req.ReasoningEffort) + if req.ReasoningEffort != nil && editReasoningEffort == nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid reasoning_effort value.", + Detail: "Must be one of none, minimal, low, medium, high, xhigh, max.", + }) + return + } + editResult, editErr := api.chatDaemon.EditMessage(ctx, chatd.EditMessageOptions{ ChatID: chat.ID, CreatedBy: apiKey.UserID, @@ -3325,6 +3354,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) { @@ -7003,7 +7033,7 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - modelConfigRaw, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig) + modelConfigRaw, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig, string(aiProvider.Type)) if modelConfigErr != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid model config.", @@ -7143,6 +7173,7 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { } aiProviderID := existing.AIProviderID + aiProviderType := "" if req.AIProviderID != nil { //nolint:gocritic // The route already authorized chat model config updates. aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID) @@ -7162,6 +7193,27 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } aiProviderID = uuid.NullUUID{UUID: aiProvider.ID, Valid: true} + aiProviderType = string(aiProvider.Type) + } else if req.ModelConfig != nil && existing.AIProviderID.Valid { + // Model config validation needs the provider type to check + // reasoning effort against the provider's supported set. + //nolint:gocritic // The route already authorized chat model config updates. + aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), existing.AIProviderID.UUID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Cannot update model config.", + Detail: "The AI provider for this model config no longer exists. Set ai_provider_id to an existing provider.", + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get AI provider.", + Detail: err.Error(), + }) + return + } + aiProviderType = string(aiProvider.Type) } model := existing.Model @@ -7208,7 +7260,7 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { modelConfigRaw := existing.Options if req.ModelConfig != nil { - encodedModelConfig, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig) + encodedModelConfig, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig, aiProviderType) if modelConfigErr != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid model config.", @@ -7515,12 +7567,13 @@ func convertChatModelConfig(config database.ChatModelConfig) codersdk.ChatModelC func marshalChatModelCallConfig( modelConfig *codersdk.ChatModelCallConfig, + providerType string, ) (json.RawMessage, error) { if modelConfig == nil { return json.RawMessage("{}"), nil } - if err := validateChatModelCallConfig(modelConfig); err != nil { + if err := validateChatModelCallConfig(modelConfig, providerType); err != nil { return nil, err } @@ -7531,7 +7584,7 @@ func marshalChatModelCallConfig( return encoded, nil } -func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) error { +func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig, providerType string) error { if modelConfig == nil { return nil } @@ -7556,9 +7609,78 @@ func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) erro } } + if err := validateChatModelReasoningEffortConfig(modelConfig, providerType); err != nil { + return err + } + return validateChatModelProviderOptions(modelConfig.ProviderOptions) } +// validateChatModelReasoningEffortConfig validates and canonicalizes +// the reasoning_effort config in place. When only one of default/max +// is provided, it is mirrored into the other. Values must be on the +// provider's supported effort set (or the global scale when the +// provider is unknown) and default must not exceed max. +func validateChatModelReasoningEffortConfig(modelConfig *codersdk.ChatModelCallConfig, providerType string) error { + config := modelConfig.ReasoningEffort + if config == nil { + return nil + } + + // Mirror a single provided value into the other bound. + if config.Default == nil { + config.Default = config.Max + } + if config.Max == nil { + config.Max = config.Default + } + if config.Default == nil { + // Both bounds absent; treat the config as unset. + modelConfig.ReasoningEffort = nil + return nil + } + + supported := chatprovider.SupportedReasoningEfforts(providerType) + if chatprovider.NormalizeProvider(providerType) != "" && len(supported) == 0 { + return xerrors.Errorf("reasoning_effort is not supported for provider type %q", providerType) + } + + normalizeField := func(name string, value *string) (*string, error) { + normalized := chatprovider.NormalizeGlobalReasoningEffort(value) + if normalized == nil { + return nil, xerrors.Errorf("reasoning_effort.%s must be one of none, minimal, low, medium, high, xhigh, max", name) + } + if len(supported) > 0 && !slices.Contains(supported, *normalized) { + return nil, xerrors.Errorf( + "reasoning_effort.%s must be one of %s for provider type %q", + name, + strings.Join(supported, ", "), + providerType, + ) + } + return normalized, nil + } + + defaultEffort, err := normalizeField("default", config.Default) + if err != nil { + return err + } + maxEffort, err := normalizeField("max", config.Max) + if err != nil { + return err + } + + defaultRank, _ := chatprovider.ReasoningEffortRank(*defaultEffort) + maxRank, _ := chatprovider.ReasoningEffortRank(*maxEffort) + if defaultRank > maxRank { + return xerrors.Errorf("reasoning_effort.default must not exceed reasoning_effort.max") + } + + config.Default = defaultEffort + config.Max = maxEffort + return nil +} + func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) error { if options == nil || options.Anthropic == nil || options.Anthropic.ThinkingDisplay == nil { return nil @@ -7609,6 +7731,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 ac326a1514494..4931c2b2187b9 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -416,6 +416,63 @@ 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, "high", storedChat.LastReasoningEffort.String) + + 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, "high", userMsg.ReasoningEffort.String) + }) + + 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("extreme"), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) + }) + t.Run("HidesSystemPromptMessages", func(t *testing.T) { t.Parallel() @@ -3744,6 +3801,195 @@ 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) + }) + + t.Run("ReasoningEffortMirrorsSingleValue", 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("high"), + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, modelConfig.ModelConfig) + require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort) + require.NotNil(t, modelConfig.ModelConfig.ReasoningEffort.Max) + require.Equal(t, "high", *modelConfig.ModelConfig.ReasoningEffort.Max) + }) + + 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("extreme"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + }) + + t.Run("ReasoningEffortRejectsUnsupportedProviderValue", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider := createAIProviderForTest(t, client, "anthropic", "test-api-key") + + contextLimit := int64(4096) + // "minimal" is on the global scale but Anthropic's runtime set + // starts at "low". + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "claude-sonnet-4-5", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("minimal"), + Max: ptr.Ref("high"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "reasoning_effort.default") + }) + + t.Run("ReasoningEffortAllowsNoneForVercel", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + aiProvider := createAIProviderForTest(t, client, "vercel", "test-api-key") + + contextLimit := int64(4096) + modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "openai/gpt-5.1", + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("none"), + Max: ptr.Ref("high"), + }, + }, + }) + 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, "none", *modelConfig.ModelConfig.ReasoningEffort.Default) + }) + + t.Run("ReasoningEffortRejectsNoneForOpenAI", 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("none"), + Max: ptr.Ref("high"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model config.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "reasoning_effort.default") + }) + + 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() @@ -3946,6 +4192,30 @@ func TestUpdateChatModelConfig(t *testing.T) { requireChatModelPricing(t, configs[0].ModelConfig, pricing) }) + t.Run("ModelConfigWithDeletedProviderFails", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := newChatClientWithDatabase(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + // Soft-delete the config's provider so the lookup 404s. + err := db.DeleteAIProviderByID(dbauthz.AsSystemRestricted(ctx), modelConfig.AIProviderID) + require.NoError(t, err) + + _, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("high"), + }, + }, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Cannot update model config.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "no longer exists") + }) + t.Run("UnchangedProviderWithoutAIProviderID", func(t *testing.T) { t.Parallel() @@ -6684,6 +6954,88 @@ 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, "high", storedChat.LastReasoningEffort.String) + + 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, "high", userMsg.ReasoningEffort.String) + + // 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, "high", storedChat.LastReasoningEffort.String) +} + +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("extreme"), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message) +} + func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index bc588ccd436c5..4bfacd5bb91a8 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -825,6 +825,12 @@ 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`. Users select a per-turn effort when sending or editing a message; the value is stored on `chat_messages.reasoning_effort` (and `chat_queued_messages.reasoning_effort` for queued messages, which carry it 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`; then snapped into the provider's supported subset (the largest supported value not exceeding it, or the provider minimum when below it). If the model config has no `reasoning_effort`, any user-selected value is ignored. The resolved value is injected into the provider-native options (`chatprovider.ProviderOptionsFromChatModelConfig`). + #### 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/chatd.go b/coderd/x/chatd/chatd.go index c337f7df82d85..0dbc62fb883c4 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -397,9 +397,17 @@ 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( + advisorModel.Provider(), + nil, + advisorCallConfig.ReasoningEffort, + ) providerOptions := chatprovider.ProviderOptionsFromChatModelConfig( advisorModel, advisorCallConfig.ProviderOptions, + advisorReasoningEffort, ) rt, err := chatadvisor.NewRuntime(chatadvisor.RuntimeConfig{ @@ -1118,15 +1126,19 @@ func (e *UsageLimitExceededError) Error() string { // CreateOptions controls chat creation in the shared chat mutation path. type CreateOptions struct { - OrganizationID uuid.UUID - OwnerID uuid.UUID - WorkspaceID uuid.NullUUID - BuildID uuid.NullUUID - AgentID uuid.NullUUID - ParentChatID uuid.NullUUID - RootChatID uuid.NullUUID - Title string - ModelConfigID uuid.UUID + OrganizationID uuid.UUID + OwnerID uuid.UUID + WorkspaceID uuid.NullUUID + BuildID uuid.NullUUID + AgentID uuid.NullUUID + ParentChatID uuid.NullUUID + RootChatID uuid.NullUUID + Title string + ModelConfigID uuid.UUID + // ReasoningEffort is the user-selected reasoning effort carried + // by the initial user message. Nil when the user did not select + // one. + ReasoningEffort *string ChatMode database.NullChatMode PlanMode database.NullChatPlanMode ClientType database.ChatClientType @@ -1157,10 +1169,13 @@ type SendMessageOptions struct { CreatedBy uuid.UUID Content []codersdk.ChatMessagePart ModelConfigID uuid.UUID - APIKeyID string - BusyBehavior SendMessageBusyBehavior - PlanMode *database.NullChatPlanMode - MCPServerIDs *[]uuid.UUID + // ReasoningEffort is the user-selected reasoning effort for the + // turn. Nil when the user did not select one. + ReasoningEffort *string + APIKeyID string + BusyBehavior SendMessageBusyBehavior + PlanMode *database.NullChatPlanMode + MCPServerIDs *[]uuid.UUID } // SendMessageResult contains the outcome of user message processing. @@ -1182,6 +1197,10 @@ type EditMessageOptions struct { // the replacement user message. When set to uuid.Nil the // original message's model is preserved. ModelConfigID uuid.UUID + // ReasoningEffort, when non-nil, overrides the reasoning effort + // for the replacement user message. When nil the original + // message's reasoning effort is preserved. + ReasoningEffort *string } // EditMessageResult contains the replacement user message and chat status. @@ -1295,7 +1314,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, @@ -1442,7 +1461,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 { @@ -1650,12 +1669,18 @@ func (p *Server) EditMessage( modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} } + var reasoningEffortOverride sql.NullString + if opts.ReasoningEffort != nil && *opts.ReasoningEffort != "" { + reasoningEffortOverride = sql.NullString{String: *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) { @@ -2914,6 +2939,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}, diff --git a/coderd/x/chatd/chatopenai/options.go b/coderd/x/chatd/chatopenai/options.go index 91d87fe582661..4c622a0a67f35 100644 --- a/coderd/x/chatd/chatopenai/options.go +++ b/coderd/x/chatd/chatopenai/options.go @@ -13,12 +13,14 @@ import ( ) // ProviderOptionsFromChatConfig converts chat model OpenAI options to fantasy -// provider options used for inference calls. +// provider options used for inference calls. reasoningEffort is the resolved +// per-turn reasoning effort. func ProviderOptionsFromChatConfig( model fantasy.LanguageModel, options *codersdk.ChatModelOpenAIProviderOptions, + reasoningEffort *string, ) fantasy.ProviderOptionsData { - reasoningEffort := ReasoningEffortFromChat(options.ReasoningEffort) + effort := ReasoningEffortFromChat(reasoningEffort) if UsesResponsesOptions(model) { include := EnsureResponseIncludes(IncludeFromChat(options.Include)) providerOptions := &fantasyopenai.ResponsesProviderOptions{ @@ -29,7 +31,7 @@ func ProviderOptionsFromChatConfig( Metadata: options.Metadata, ParallelToolCalls: options.ParallelToolCalls, PromptCacheKey: chatutil.NormalizedStringPointer(options.PromptCacheKey), - ReasoningEffort: reasoningEffort, + ReasoningEffort: effort, ReasoningSummary: chatutil.NormalizedStringPointer(options.ReasoningSummary), SafetyIdentifier: chatutil.NormalizedStringPointer(options.SafetyIdentifier), ServiceTier: ServiceTierFromChat(options.ServiceTier), @@ -47,7 +49,7 @@ func ProviderOptionsFromChatConfig( TopLogProbs: options.TopLogProbs, ParallelToolCalls: options.ParallelToolCalls, User: chatutil.NormalizedStringPointer(options.User), - ReasoningEffort: reasoningEffort, + ReasoningEffort: effort, MaxCompletionTokens: options.MaxCompletionTokens, TextVerbosity: chatutil.NormalizedStringPointer(options.TextVerbosity), Prediction: options.Prediction, diff --git a/coderd/x/chatd/chatopenai/options_test.go b/coderd/x/chatd/chatopenai/options_test.go index 1320300b11cb9..b9b8b6df851c9 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{ @@ -47,6 +46,7 @@ func TestProviderOptionsFromChatConfigLegacy(t *testing.T) { got := chatopenai.ProviderOptionsFromChatConfig( fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-3.5-turbo-instruct"}, options, + ptr(" HIGH "), ) providerOptions, ok := got.(*fantasyopenai.ProviderOptions) @@ -88,7 +88,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 "), @@ -100,6 +99,7 @@ func TestProviderOptionsFromChatConfigResponses(t *testing.T) { got := chatopenai.ProviderOptionsFromChatConfig( fakeLanguageModel{provider: fantasyopenai.Name, model: "gpt-4.1"}, options, + ptr(" minimal "), ) providerOptions, ok := got.(*fantasyopenai.ResponsesProviderOptions) diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index 4f116ef1b75a4..fbb4ca30551bd 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -1122,46 +1122,94 @@ func missingProviderAPIKeyError(provider string) error { } // ProviderOptionsFromChatModelConfig converts chat model provider options to -// fantasy provider options used for inference calls. +// fantasy provider options used for inference calls. reasoningEffort is the +// resolved per-turn reasoning effort (see ResolveReasoningEffort); it is +// routed to the option section matching the model's provider, creating the +// section when the config has no other options for it. Azure uses the openai +// section and Bedrock uses the anthropic section. func ProviderOptionsFromChatModelConfig( model fantasy.LanguageModel, options *codersdk.ChatModelProviderOptions, + reasoningEffort *string, ) fantasy.ProviderOptions { - if options == nil { - return nil + opts := codersdk.ChatModelProviderOptions{} + if options != nil { + opts = *options + } + + var ( + openAIEffort *string + anthropicEffort *string + openAICompatEffort *string + openRouterEffort *string + vercelEffort *string + ) + if reasoningEffort != nil && model != nil { + switch NormalizeProvider(model.Provider()) { + case fantasyopenai.Name, fantasyazure.Name: + openAIEffort = reasoningEffort + if opts.OpenAI == nil { + opts.OpenAI = &codersdk.ChatModelOpenAIProviderOptions{} + } + case fantasyanthropic.Name, fantasybedrock.Name: + anthropicEffort = reasoningEffort + if opts.Anthropic == nil { + opts.Anthropic = &codersdk.ChatModelAnthropicProviderOptions{} + } + case fantasyopenaicompat.Name: + openAICompatEffort = reasoningEffort + if opts.OpenAICompat == nil { + opts.OpenAICompat = &codersdk.ChatModelOpenAICompatProviderOptions{} + } + case fantasyopenrouter.Name: + openRouterEffort = reasoningEffort + if opts.OpenRouter == nil { + opts.OpenRouter = &codersdk.ChatModelOpenRouterProviderOptions{} + } + case fantasyvercel.Name: + vercelEffort = reasoningEffort + if opts.Vercel == nil { + opts.Vercel = &codersdk.ChatModelVercelProviderOptions{} + } + } } result := fantasy.ProviderOptions{} - if options.OpenAI != nil { + if opts.OpenAI != nil { result[fantasyopenai.Name] = chatopenai.ProviderOptionsFromChatConfig( model, - options.OpenAI, + opts.OpenAI, + openAIEffort, ) } - if options.Anthropic != nil { + if opts.Anthropic != nil { result[fantasyanthropic.Name] = anthropicProviderOptionsFromChatConfig( - options.Anthropic, + opts.Anthropic, + anthropicEffort, ) } - if options.Google != nil { + if opts.Google != nil { result[fantasygoogle.Name] = googleProviderOptionsFromChatConfig( - options.Google, + opts.Google, ) } - if options.OpenAICompat != nil { + if opts.OpenAICompat != nil { result[fantasyopenaicompat.Name] = openAICompatProviderOptionsFromChatConfig( - options.OpenAICompat, + opts.OpenAICompat, + openAICompatEffort, ) } - if options.OpenRouter != nil { + if opts.OpenRouter != nil { result[fantasyopenrouter.Name] = openRouterProviderOptionsFromChatConfig( - options.OpenRouter, + opts.OpenRouter, + openRouterEffort, ) } - if options.Vercel != nil { + if opts.Vercel != nil { result[fantasyvercel.Name] = vercelProviderOptionsFromChatConfig( - options.Vercel, + opts.Vercel, + vercelEffort, ) } @@ -1173,10 +1221,11 @@ func ProviderOptionsFromChatModelConfig( func anthropicProviderOptionsFromChatConfig( options *codersdk.ChatModelAnthropicProviderOptions, + reasoningEffort *string, ) *fantasyanthropic.ProviderOptions { result := &fantasyanthropic.ProviderOptions{ SendReasoning: options.SendReasoning, - Effort: anthropicEffortFromChat(options.Effort), + Effort: anthropicEffortFromChat(reasoningEffort), ThinkingDisplay: AnthropicThinkingDisplayFromChat(options.ThinkingDisplay), DisableParallelToolUse: options.DisableParallelToolUse, } @@ -1219,15 +1268,17 @@ func googleProviderOptionsFromChatConfig( func openAICompatProviderOptionsFromChatConfig( options *codersdk.ChatModelOpenAICompatProviderOptions, + reasoningEffort *string, ) *fantasyopenaicompat.ProviderOptions { return &fantasyopenaicompat.ProviderOptions{ User: chatutil.NormalizedStringPointer(options.User), - ReasoningEffort: chatopenai.ReasoningEffortFromChat(options.ReasoningEffort), + ReasoningEffort: chatopenai.ReasoningEffortFromChat(reasoningEffort), } } func openRouterProviderOptionsFromChatConfig( options *codersdk.ChatModelOpenRouterProviderOptions, + reasoningEffort *string, ) *fantasyopenrouter.ProviderOptions { result := &fantasyopenrouter.ProviderOptions{ ExtraBody: options.ExtraBody, @@ -1237,12 +1288,14 @@ func openRouterProviderOptionsFromChatConfig( ParallelToolCalls: options.ParallelToolCalls, User: chatutil.NormalizedStringPointer(options.User), } - if options.Reasoning != nil { + if options.Reasoning != nil || reasoningEffort != nil { result.Reasoning = &fantasyopenrouter.ReasoningOptions{ - Enabled: options.Reasoning.Enabled, - Exclude: options.Reasoning.Exclude, - MaxTokens: options.Reasoning.MaxTokens, - Effort: openRouterReasoningEffortFromChat(options.Reasoning.Effort), + Effort: openRouterReasoningEffortFromChat(reasoningEffort), + } + if options.Reasoning != nil { + result.Reasoning.Enabled = options.Reasoning.Enabled + result.Reasoning.Exclude = options.Reasoning.Exclude + result.Reasoning.MaxTokens = options.Reasoning.MaxTokens } } if options.Provider != nil { @@ -1262,6 +1315,7 @@ func openRouterProviderOptionsFromChatConfig( func vercelProviderOptionsFromChatConfig( options *codersdk.ChatModelVercelProviderOptions, + reasoningEffort *string, ) *fantasyvercel.ProviderOptions { result := &fantasyvercel.ProviderOptions{ User: chatutil.NormalizedStringPointer(options.User), @@ -1271,12 +1325,14 @@ func vercelProviderOptionsFromChatConfig( ParallelToolCalls: options.ParallelToolCalls, ExtraBody: options.ExtraBody, } - if options.Reasoning != nil { + if options.Reasoning != nil || reasoningEffort != nil { result.Reasoning = &fantasyvercel.ReasoningOptions{ - Enabled: options.Reasoning.Enabled, - MaxTokens: options.Reasoning.MaxTokens, - Effort: vercelReasoningEffortFromChat(options.Reasoning.Effort), - Exclude: options.Reasoning.Exclude, + Effort: vercelReasoningEffortFromChat(reasoningEffort), + } + if options.Reasoning != nil { + result.Reasoning.Enabled = options.Reasoning.Enabled + result.Reasoning.MaxTokens = options.Reasoning.MaxTokens + result.Reasoning.Exclude = options.Reasoning.Exclude } } if options.ProviderOptions != nil { diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 8a8904c8ef4f4..05dd4e283ddcf 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -468,7 +468,7 @@ func TestProviderOptionsFromChatModelConfig_AnthropicThinkingDisplay(t *testing. Anthropic: &codersdk.ChatModelAnthropicProviderOptions{ ThinkingDisplay: ptr.Ref(" SUMMARIZED "), }, - }) + }, nil) require.NotNil(t, providerOptions) anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions) diff --git a/coderd/x/chatd/chatprovider/reasoningeffort.go b/coderd/x/chatd/chatprovider/reasoningeffort.go new file mode 100644 index 0000000000000..6000c2d36df99 --- /dev/null +++ b/coderd/x/chatd/chatprovider/reasoningeffort.go @@ -0,0 +1,122 @@ +package chatprovider + +import ( + "strings" + + 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/codersdk" +) + +// reasoningEffortOrder is the global reasoning effort scale used for +// clamping and comparison. Each provider supports a contiguous subset. +var reasoningEffortOrder = []string{ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +} + +// ReasoningEffortRank returns the position of value on the global +// effort scale. Unknown values return ok=false. +func ReasoningEffortRank(value string) (int, bool) { + for i, v := range reasoningEffortOrder { + if v == value { + return i, true + } + } + return 0, false +} + +// NormalizeGlobalReasoningEffort lowercases and trims value and +// returns it when it is on the global effort scale, or nil otherwise. +func NormalizeGlobalReasoningEffort(value *string) *string { + if value == nil { + return nil + } + normalized := strings.ToLower(strings.TrimSpace(*value)) + if _, ok := ReasoningEffortRank(normalized); !ok { + return nil + } + return &normalized +} + +// SupportedReasoningEfforts returns the provider's runtime-supported +// effort values in ascending global order. Azure shares OpenAI's set +// and Bedrock shares Anthropic's. Providers without reasoning effort +// support return nil. +func SupportedReasoningEfforts(provider string) []string { + switch NormalizeProvider(provider) { + case fantasyopenai.Name, fantasyazure.Name, fantasyopenaicompat.Name: + return []string{"minimal", "low", "medium", "high", "xhigh"} + case fantasyanthropic.Name, fantasybedrock.Name: + return []string{"low", "medium", "high", "xhigh", "max"} + case fantasyopenrouter.Name: + return []string{"low", "medium", "high"} + case fantasyvercel.Name: + return []string{"none", "minimal", "low", "medium", "high", "xhigh"} + default: + return nil + } +} + +// ResolveReasoningEffort computes the effective reasoning effort for a +// generation. The requested per-turn value wins over the config's +// default; the result is clamped to the config's max on the global +// scale and then snapped into the provider's supported set: the +// largest supported value not exceeding the clamped value, or the +// provider minimum when the value is below it. Returns nil when the +// model config has no reasoning effort configured, when no usable +// value remains, or when the provider does not support reasoning +// effort. +func ResolveReasoningEffort( + provider string, + requested *string, + config *codersdk.ChatModelReasoningEffortConfig, +) *string { + if config == nil { + return nil + } + + effective := NormalizeGlobalReasoningEffort(requested) + if effective == nil { + effective = NormalizeGlobalReasoningEffort(config.Default) + } + if effective == nil { + return nil + } + rank, _ := ReasoningEffortRank(*effective) + + if maxEffort := NormalizeGlobalReasoningEffort(config.Max); maxEffort != nil { + if maxRank, _ := ReasoningEffortRank(*maxEffort); rank > maxRank { + rank = maxRank + } + } + + supported := SupportedReasoningEfforts(provider) + if len(supported) == 0 { + return nil + } + + // Snap to the largest supported value not exceeding the effective + // value. Values below the provider minimum clamp up to it so + // reasoning is not silently disabled. + result := supported[0] + for _, candidate := range supported { + candidateRank, _ := ReasoningEffortRank(candidate) + if candidateRank > rank { + break + } + result = candidate + } + return &result +} diff --git a/coderd/x/chatd/chatprovider/reasoningeffort_test.go b/coderd/x/chatd/chatprovider/reasoningeffort_test.go new file mode 100644 index 0000000000000..babf51901f955 --- /dev/null +++ b/coderd/x/chatd/chatprovider/reasoningeffort_test.go @@ -0,0 +1,184 @@ +package chatprovider_test + +import ( + "testing" + + "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/codersdk" +) + +func TestNormalizeGlobalReasoningEffort(t *testing.T) { + t.Parallel() + + require.Nil(t, chatprovider.NormalizeGlobalReasoningEffort(nil)) + require.Nil(t, chatprovider.NormalizeGlobalReasoningEffort(ptr.Ref(""))) + require.Nil(t, chatprovider.NormalizeGlobalReasoningEffort(ptr.Ref("extreme"))) + + got := chatprovider.NormalizeGlobalReasoningEffort(ptr.Ref(" HIGH ")) + require.NotNil(t, got) + require.Equal(t, "high", *got) +} + +func TestResolveReasoningEffort(t *testing.T) { + t.Parallel() + + config := func(defaultEffort, maxEffort string) *codersdk.ChatModelReasoningEffortConfig { + cfg := &codersdk.ChatModelReasoningEffortConfig{} + if defaultEffort != "" { + cfg.Default = ptr.Ref(defaultEffort) + } + if maxEffort != "" { + cfg.Max = ptr.Ref(maxEffort) + } + return cfg + } + + tests := []struct { + name string + provider string + requested *string + config *codersdk.ChatModelReasoningEffortConfig + want *string + }{ + { + name: "NilConfigIgnoresRequested", + provider: "openai", + requested: ptr.Ref("high"), + config: nil, + want: nil, + }, + { + name: "DefaultUsedWhenNoRequested", + provider: "openai", + config: config("medium", "high"), + want: ptr.Ref("medium"), + }, + { + name: "RequestedWinsOverDefault", + provider: "openai", + requested: ptr.Ref("high"), + config: config("medium", "high"), + want: ptr.Ref("high"), + }, + { + name: "RequestedClampedToMax", + provider: "openai", + requested: ptr.Ref("xhigh"), + config: config("low", "medium"), + want: ptr.Ref("medium"), + }, + { + name: "InvalidRequestedFallsBackToDefault", + provider: "openai", + requested: ptr.Ref("extreme"), + config: config("low", "high"), + want: ptr.Ref("low"), + }, + { + name: "EmptyConfigReturnsNil", + provider: "openai", + config: &codersdk.ChatModelReasoningEffortConfig{}, + want: nil, + }, + { + name: "BelowProviderMinimumClampsUp", + provider: "anthropic", + requested: ptr.Ref("minimal"), + config: config("medium", "max"), + want: ptr.Ref("low"), + }, + { + name: "AboveProviderMaximumSnapsDown", + provider: "openrouter", + requested: ptr.Ref("max"), + config: config("medium", "max"), + want: ptr.Ref("high"), + }, + { + name: "AnthropicMaxSupported", + provider: "anthropic", + requested: ptr.Ref("max"), + config: config("medium", "max"), + want: ptr.Ref("max"), + }, + { + name: "BedrockSharesAnthropicSet", + provider: "bedrock", + requested: ptr.Ref("xhigh"), + config: config("medium", "xhigh"), + want: ptr.Ref("xhigh"), + }, + { + name: "AzureSharesOpenAISet", + provider: "azure", + requested: ptr.Ref("minimal"), + config: config("medium", "xhigh"), + want: ptr.Ref("minimal"), + }, + { + name: "OpenAISnapsMaxToXHigh", + provider: "openai", + requested: ptr.Ref("max"), + config: config("medium", "max"), + want: ptr.Ref("xhigh"), + }, + { + name: "VercelNoneSupported", + provider: "vercel", + requested: ptr.Ref("none"), + config: config("medium", "xhigh"), + want: ptr.Ref("none"), + }, + { + name: "GoogleUnsupportedReturnsNil", + provider: "google", + requested: ptr.Ref("high"), + config: config("medium", "high"), + want: nil, + }, + { + name: "UnknownProviderReturnsNil", + provider: "copilot", + requested: ptr.Ref("high"), + config: config("medium", "high"), + want: nil, + }, + { + name: "RequestedNormalized", + provider: "openai", + requested: ptr.Ref(" HIGH "), + config: config("medium", "xhigh"), + want: ptr.Ref("high"), + }, + { + name: "MaxOnlyConfigClampsRequested", + provider: "openai", + requested: ptr.Ref("xhigh"), + config: config("", "medium"), + want: ptr.Ref("medium"), + }, + { + name: "MaxOnlyConfigWithoutRequestedReturnsNil", + provider: "openai", + config: config("", "medium"), + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := chatprovider.ResolveReasoningEffort(tt.provider, tt.requested, tt.config) + if tt.want == nil { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, *tt.want, *got) + }) + } +} diff --git a/coderd/x/chatd/chatstate/messages.go b/coderd/x/chatd/chatstate/messages.go index ee92e0ea1305e..1ea8d13f7cd9a 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 sql.NullString CreatedBy uuid.NullUUID ContentVersion int16 Compressed bool @@ -50,6 +51,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), @@ -70,6 +72,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] = m.ReasoningEffort.String + } 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..d2c0a89bc0051 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, } } @@ -488,7 +490,11 @@ type EditMessageInput struct { CreatedBy uuid.UUID Content pqtype.NullRawMessage ModelConfigIDOverride uuid.NullUUID - APIKeyID sql.NullString + // ReasoningEffortOverride, when valid, replaces the edited + // message's reasoning effort. When invalid the original + // message's reasoning effort is preserved. + ReasoningEffortOverride sql.NullString + APIKeyID sql.NullString } // EditMessageResult is returned by [Tx.EditMessage]. @@ -564,18 +570,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..ffc882b433ce1 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 sql.NullString + if reasoningEffort != nil && *reasoningEffort != "" { + effort = sql.NullString{String: *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 9289276ebcf94..f475a9fb7e3ab 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -533,7 +533,20 @@ func (server *Server) prepareGeneration( } } - providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions) + // Per-turn reasoning effort: the last user-selected effort wins + // over the model config's default, clamped to the config's max and + // the provider's supported range. Nil when the model config has no + // reasoning effort configured. + var requestedEffort *string + if chat.LastReasoningEffort.Valid { + requestedEffort = &chat.LastReasoningEffort.String + } + reasoningEffort := chatprovider.ResolveReasoningEffort( + resolvedProvider, + requestedEffort, + callConfig.ReasoningEffort, + ) + providerOptions := chatprovider.ProviderOptionsFromChatModelConfig(model, callConfig.ProviderOptions, reasoningEffort) chainInfo := chatopenai.ResolveChainMode(promptRows) if !input.ChainModeDisabled && chatopenai.ShouldActivateChainMode( providerOptions, diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index b5e9888793b2a..d11b7380aabfc 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -1118,7 +1118,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, nil)) publisher := p.pubsub if publisher == nil { diff --git a/codersdk/chats.go b/codersdk/chats.go index b374c5f244779..c448506a79ff8 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -107,26 +107,30 @@ 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 is the reasoning effort carried by the most + // recent message that set one. Used to initialize the effort + // selector for subsequent turns. + 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"` @@ -548,13 +552,18 @@ 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 is the user-selected reasoning effort for the + // first turn. Clamped to the model config's max effort at + // generation time. Ignored when the model config has no + // reasoning effort configured. + 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,6 +625,11 @@ type CreateChatMessageRequest struct { // PlanMode switches the chat's persistent plan mode. // nil: no change, ptr to "plan": enable, ptr to "": clear. PlanMode *ChatPlanMode `json:"plan_mode,omitempty"` + // ReasoningEffort is the user-selected reasoning effort for the + // turn triggered by this message. Clamped to the model config's + // max effort at generation time. Ignored when the model config + // has no reasoning effort configured. + ReasoningEffort *string `json:"reasoning_effort,omitempty"` } // EditChatMessageRequest is the request to edit a user message in a chat. @@ -625,6 +639,10 @@ type EditChatMessageRequest struct { // 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"` + // ReasoningEffort, when set, overrides the reasoning effort for + // the replacement user message. When nil the original message's + // reasoning effort is preserved. + ReasoningEffort *string `json:"reasoning_effort,omitempty"` } // CreateChatMessageResponse is the response from adding a message to a chat. @@ -1284,7 +1302,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"` @@ -1310,7 +1327,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"` @@ -1341,17 +1357,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. @@ -1404,16 +1418,28 @@ 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"` } +// ChatModelReasoningEffortConfig configures per-model reasoning effort +// bounds. Values are ordered on the global effort scale +// none < minimal < low < medium < high < xhigh < max; each provider +// supports a subset and the effective effort is clamped into it at +// generation time. When only one of Default or Max is provided, it is +// mirrored into the other 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..20bf0d2d04b77 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) } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 27556bca61df6..6d9af138b86e1 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
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
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/docs/reference/api/chats.md b/docs/reference/api/chats.md index 96db32dbeb37d..050d02b606faa 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 reasoning effort is the reasoning effort carried by the most recent message that set one. Used to initialize the effort selector for subsequent turns. | +| `» 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 879951910b0fb..7139e434948fc 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2081,6 +2081,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" @@ -2173,6 +2174,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" @@ -2198,39 +2200,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 reasoning effort is the reasoning effort carried by the most recent message that set one. Used to initialize the effort selector for subsequent turns. | +| `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 @@ -3956,6 +3959,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" @@ -4365,19 +4369,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 | | Reasoning effort is the user-selected reasoning effort for the turn triggered by this message. Clamped to the model config's max effort at generation time. Ignored when the model config has no reasoning effort configured. | #### Enumerated Values @@ -4582,6 +4588,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": [ { @@ -4598,19 +4605,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|-----------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | -| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | -| `labels` | object | false | | | -| » `[any property]` | string | false | | | -| `mcp_server_ids` | array of string | false | | | -| `model_config_id` | string | false | | | -| `organization_id` | string | false | | | -| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | 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 | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|-----------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | +| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | +| `labels` | object | false | | | +| » `[any property]` | string | false | | | +| `mcp_server_ids` | array of string | false | | | +| `model_config_id` | string | false | | | +| `organization_id` | string | false | | | +| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | +| `reasoning_effort` | string | false | | Reasoning effort is the user-selected reasoning effort for the first turn. Clamped to the model config's max effort at generation time. Ignored when the model config has no reasoning effort configured. | +| `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 | | | ## codersdk.CreateFirstUserOnboardingInfo @@ -6900,16 +6908,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 | | Reasoning effort when set, overrides the reasoning effort for the replacement user message. When nil the original message's reasoning effort is preserved. | ## codersdk.EditChatMessageResponse diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index e197a7782b9f6..286784a84a80d 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -456,6 +456,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 09e6ac16cb1ad..be23a945d941c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1576,6 +1576,12 @@ export interface Chat { readonly parent_chat_id?: string; readonly root_chat_id?: string; readonly last_model_config_id: string; + /** + * LastReasoningEffort is the reasoning effort carried by the most + * recent message that set one. Used to initialize the effort + * selector for subsequent turns. + */ + readonly last_reasoning_effort?: string; readonly title: string; readonly status: ChatStatus; readonly plan_mode?: ChatPlanMode; @@ -2459,7 +2465,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; @@ -2487,6 +2492,7 @@ export interface ChatModelCallConfig { readonly presence_penalty?: number; readonly frequency_penalty?: number; readonly cost?: ModelCostConfig; + readonly reasoning_effort?: ChatModelReasoningEffortConfig; readonly provider_options?: ChatModelProviderOptions; } @@ -2544,7 +2550,6 @@ export interface ChatModelGoogleThinkingConfig { */ export interface ChatModelOpenAICompatProviderOptions { readonly user?: string; - readonly reasoning_effort?: string; } // From codersdk/chats.go @@ -2560,7 +2565,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; @@ -2669,6 +2673,20 @@ 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. Values are ordered on the global effort scale + * none < minimal < low < medium < high < xhigh < max; each provider + * supports a subset and the effective effort is clamped into it at + * generation time. When only one of Default or Max is provided, it is + * mirrored into the other before storing. + */ +export interface ChatModelReasoningEffortConfig { + readonly default?: string; + readonly max?: string; +} + // From codersdk/chats.go /** * ChatModelReasoningOptions configures reasoning behavior for model @@ -2678,7 +2696,6 @@ export interface ChatModelReasoningOptions { readonly enabled?: boolean; readonly exclude?: boolean; readonly max_tokens?: number; - readonly effort?: string; } // From codersdk/chats.go @@ -3509,6 +3526,13 @@ export interface CreateChatMessageRequest { * nil: no change, ptr to "plan": enable, ptr to "": clear. */ readonly plan_mode?: ChatPlanMode; + /** + * ReasoningEffort is the user-selected reasoning effort for the + * turn triggered by this message. Clamped to the model config's + * max effort at generation time. Ignored when the model config + * has no reasoning effort configured. + */ + readonly reasoning_effort?: string; } // From codersdk/chats.go @@ -3562,6 +3586,13 @@ export interface CreateChatRequest { readonly system_prompt?: string; readonly workspace_id?: string; readonly model_config_id?: string; + /** + * ReasoningEffort is the user-selected reasoning effort for the + * first turn. Clamped to the model config's max effort at + * generation time. Ignored when the model config has no + * reasoning effort configured. + */ + readonly reasoning_effort?: string; readonly mcp_server_ids?: readonly string[]; readonly labels?: Record; /** @@ -4535,6 +4566,12 @@ export interface EditChatMessageRequest { * When nil the original message's model is preserved. */ readonly model_config_id?: string; + /** + * ReasoningEffort, when set, overrides the reasoning effort for + * the replacement user message. When nil the original message's + * reasoning effort is preserved. + */ + readonly reasoning_effort?: string; } // From codersdk/chats.go diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 703a4028c973d..968675e00980f 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -218,6 +218,49 @@ export const EditUpdateDisabledUntilDirty: Story = { }, }; +export const ReasoningEffortVisibleWithoutExpanding: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // The effort selects sit in the always-visible top grid; no + // collapsible section is expanded in this story. + 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 userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5"); + await userEvent.type(canvas.getByLabelText(/context limit/i), "200000"); + + // Options are limited to OpenAI's supported effort set: + // "max" (Anthropic-only) is not offered. + await userEvent.click(defaultSelect); + await expect( + await screen.findByRole("option", { name: "Medium" }), + ).toBeInTheDocument(); + await expect( + screen.queryByRole("option", { name: "Max" }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("option", { name: "Medium" })); + + await userEvent.click(maxSelect); + await userEvent.click(await screen.findByRole("option", { name: "Xhigh" })); + + 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: "xhigh" }, + }), + }), + ); + }, +}; + 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/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index a6cdb51e915b1..70bd49773dee0 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 { clampReasoningEffort } from "./utils/reasoningEffort"; import { type ChatDetailError, formatUsageLimitMessage, @@ -721,6 +722,7 @@ const AgentChatPage: FC = () => { const { organizations, experiments } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); const [selectedModel, setSelectedModel] = useState(""); + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); const inputValueRef = useRef( @@ -1138,6 +1140,22 @@ const AgentChatPage: FC = () => { return modelOptions[0]?.id ?? ""; })(); + // Effective per-turn reasoning effort. The user's explicit selection + // wins; otherwise the chat's last effort, falling back to the model's + // default. clampReasoningEffort re-validates against the effective + // model, so switching models keeps the selection when supported and + // falls back to the new model's default otherwise. Undefined when the + // effective model has no reasoning effort configured. + const effectiveModelOption = modelOptions.find( + (option) => option.id === effectiveSelectedModel, + ); + const effectiveReasoningEffort = effectiveModelOption + ? clampReasoningEffort( + selectedReasoningEffort || chatRecord?.last_reasoning_effort, + effectiveModelOption, + ) + : undefined; + const compressionThreshold = resolveCompactionThreshold( chatLastModelConfigID, userThresholdsQuery.data?.thresholds, @@ -1458,9 +1476,16 @@ const AgentChatPage: FC = () => { pickerModelConfigID !== originalModelConfigID ? pickerModelConfigID : undefined; + // Only override the original message's reasoning effort when + // the user explicitly moved the slider this session; otherwise + // omit it so the backend preserves the original message's + // effort, mirroring the model override above. const request: TypesGen.EditChatMessageRequest = { content, model_config_id: editSelectedModelConfigID, + reasoning_effort: selectedReasoningEffort + ? effectiveReasoningEffort + : undefined, }; const optimisticMessage = originalEditedMessage ? buildOptimisticEditedMessage({ @@ -1503,6 +1528,7 @@ const AgentChatPage: FC = () => { const request: CreateChatMessageRequestWithClearablePlanMode = { content, model_config_id: selectedModelConfigID, + reasoning_effort: effectiveReasoningEffort, mcp_server_ids: effectiveMCPServerIds.length > 0 ? [...effectiveMCPServerIds] @@ -1654,6 +1680,8 @@ const AgentChatPage: FC = () => { modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + reasoningEffort={effectiveReasoningEffort} + onReasoningEffortChange={setSelectedReasoningEffort} canConfigureAgentSetup={permissions.editDeploymentConfig} providerCount={providerCount} modelCount={modelCount} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 73fc40fdf326b..2302b5414dc41 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -136,6 +136,9 @@ interface AgentChatPageViewProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + // Per-turn reasoning effort for the selected model, when configured. + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; @@ -329,6 +332,8 @@ export const AgentChatPageView: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + reasoningEffort, + onReasoningEffortChange, canConfigureAgentSetup, providerCount, modelCount, @@ -944,6 +949,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..d3911b37d05b8 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -119,6 +119,10 @@ interface AgentChatInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; hasModelOptions: boolean; + // Per-turn reasoning effort, shown in the model selector dropdown + // when the selected model has reasoning effort configured. + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; @@ -354,6 +358,8 @@ export const AgentChatInput: FC = ({ modelOptions, modelSelectorPlaceholder, hasModelOptions, + reasoningEffort, + onReasoningEffortChange, planModeEnabled = false, onPlanModeToggle, isModelCatalogLoading = false, @@ -1428,6 +1434,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..27db922022a4f 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,56 @@ export const ManualSelectionOverridesRootChatDefault: Story = { }, }; +// Model options with reasoning effort bounds configured. GPT-4o +// supports the OpenAI range up to xhigh; Claude is capped at medium. +const effortModelOptions = [ + { + ...modelOptions[0], + reasoningEffortDefault: "medium", + reasoningEffortMax: "xhigh", + }, + { + ...modelOptions[1], + reasoningEffortDefault: "low", + reasoningEffortMax: "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 third of five selectable OpenAI efforts. + expect(slider).toHaveAttribute("aria-valuenow", "2"); + + // 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", "3"); + }); + 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..e8ba2301d477a 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 { clampReasoningEffort } 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,19 @@ export const AgentCreateForm: FC = ({ } return selectedModel || undefined; })(); + // Per-turn reasoning effort for the first message. The user's + // explicit selection wins; clampReasoningEffort re-validates it + // against the displayed model, so switching models keeps the + // selection when supported and falls back to the new model's + // default otherwise. Undefined when the model has no reasoning + // effort configured. + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); + const selectedModelOption = modelOptions.find( + (option) => option.id === selectedModel, + ); + const effectiveReasoningEffort = selectedModelOption + ? clampReasoningEffort(selectedReasoningEffort, selectedModelOption) + : undefined; const initialOrg = organizations.find((o) => o.is_default) ?? organizations[0]; const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( @@ -367,6 +382,7 @@ export const AgentCreateForm: FC = ({ fileIDs, workspaceId: effectiveWorkspaceId ?? undefined, model: submittedModel, + reasoningEffort: effectiveReasoningEffort, organizationId, mcpServerIds: effectiveMCPServerIds.length > 0 @@ -529,6 +545,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 dea8feb462ffe..963622f927ea8 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; import { MockModelSelectorOption } from "./modelSelectorFixtures"; @@ -48,6 +49,16 @@ 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", + reasoningEffortMax: "xhigh", +}; + const meta: Meta = { title: "pages/AgentsPage/ChatElements/ModelSelector", component: ModelSelector, @@ -224,3 +235,134 @@ 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 + // (OpenAI supports minimal..xhigh, max is xhigh: 5 steps, 0-4). + await waitFor(() => { + expect(body.getByText("Effort")).toBeVisible(); + }); + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemin", "0"); + expect(slider).toHaveAttribute("aria-valuemax", "4"); + // "medium" is the third of five selectable efforts. + expect(slider).toHaveAttribute("aria-valuenow", "2"); + expect(body.getByText("Medium")).toBeVisible(); + + // The slider is keyboard-reachable from the search input and + // arrow keys move between efforts, updating the badge and + // notifying the caller. + await userEvent.tab(); + expect(slider).toHaveFocus(); + + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "3"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("high"); + expect(body.getByText("High")).toBeVisible(); + + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "4"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("xhigh"); + expect(body.getByText("Xhigh")).toBeVisible(); + + await userEvent.keyboard("{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "0"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("minimal"); + expect(body.getByText("Minimal")).toBeVisible(); + }, +}; + +export const EffortRowClampedToMax: Story = { + args: { + options: [ + { + ...effortModel, + reasoningEffortDefault: "low", + reasoningEffortMax: "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 + // (minimal, low, medium: 3 steps, 0-2). + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemax", "2"); + expect(slider).toHaveAttribute("aria-valuenow", "1"); + 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 bf558251dd64b..0b68c9a51a8c0 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,8 +15,18 @@ import { PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; +import { Slider } from "#/components/Slider/Slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { formatProviderLabel as defaultFormatProviderLabel } from "#/utils/aiProviders"; import { cn } from "#/utils/cn"; +import { + formatReasoningEffort, + getSelectableReasoningEfforts, +} from "../../utils/reasoningEffort"; export interface ModelSelectorOption { id: string; @@ -24,6 +34,8 @@ export interface ModelSelectorOption { model: string; displayName: string; contextLimit?: number; + reasoningEffortDefault?: string; + reasoningEffortMax?: string; } interface ModelSelectorProps { @@ -40,6 +52,11 @@ interface ModelSelectorProps { contentClassName?: string; onTriggerTouchStart?: () => void; enableMobileFullWidthDropdown?: boolean; + // Per-turn reasoning effort for the selected model. The Effort row + // renders only when both props are provided and the selected option + // has reasoning effort configured (a usable max). + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; } const formatContextLimit = (tokens: number): string => { @@ -76,6 +93,8 @@ export const ModelSelector: FC = ({ contentClassName, onTriggerTouchStart, enableMobileFullWidthDropdown = false, + reasoningEffort, + onReasoningEffortChange, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -198,11 +217,77 @@ 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 = getSelectableReasoningEfforts(option); + 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/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx index b9ea8337c99e6..89c2ebff94079 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx @@ -31,6 +31,7 @@ import { } from "#/components/Tooltip/Tooltip"; import { normalizeProvider } from "#/modules/aiModels/helpers"; import { cn } from "#/utils/cn"; +import { getSupportedReasoningEfforts } from "../../utils/reasoningEffort"; import { isFieldConflictDisabled, isVisibleWhenSatisfied, @@ -50,6 +51,11 @@ const booleanFieldOptions = [ /** Sentinel value for Select components to represent "no selection". */ const unsetSelectValue = "__unset__"; +/** General fields configuring the per-model reasoning effort bounds. */ +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. */ @@ -655,12 +661,103 @@ export const PricingModelConfigFields: FC = ({ ); }; +/** + * Default/Max reasoning effort selects, schema-driven from the + * general `reasoning_effort.default` / `reasoning_effort.max` fields. + * Options are limited to the selected provider's supported effort + * set; renders nothing for providers without reasoning effort + * support. Kept out of the Advanced section so admins can configure + * effort bounds without expanding anything. + */ +export const ReasoningEffortConfigFields: FC = ({ + provider, + form, + fieldErrors, + disabled, +}) => { + const supportedEfforts = getSupportedReasoningEfforts( + normalizeProvider(provider), + ); + if (supportedEfforts.length === 0) { + return null; + } + 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}`; + const errorId = `${fieldKey}-error`; + const fieldError = fieldErrors[camelName]; + const currentValue = (getIn(form.values, fieldKey) as string) || ""; + // Rendered inline rather than through SelectField so the + // unset choice can read "Not set": next to a field named + // "Default Reasoning Effort", the generic "Default" label + // would be ambiguous. + return ( +
+ + + {fieldError && ( +

+ {fieldError} +

+ )} +
+ ); + })} + + ); +}; + /** * 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`. + * `api/chatModelOptions`. The reasoning effort bounds are excluded + * here; they render prominently via ReasoningEffortConfigFields. */ export const GeneralModelConfigFields: FC = ({ form, @@ -669,7 +766,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 ( 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..f3dfe05b5e500 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("skips reasoning effort for providers without effort support", () => { + const result = applyDefaults({ + values: buildInitialModelFormValues(), + initialValues: buildInitialModelFormValues(), + provider: "google", + knownModel: customKnownModel({ + provider: "google", + reasoningEffort: "medium", + }), + }); + + 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.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..989a2630df4a6 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts @@ -1,4 +1,5 @@ import { toFormFieldKey } from "#/api/chatModelOptions"; +import { getSupportedReasoningEfforts } from "../../../utils/reasoningEffort"; import { deepGet, deepSet, @@ -35,11 +36,6 @@ const pricingModelFieldByName = { KnownModelCostField >; -const reasoningEffortPathByProvider: Record = { - openai: "config.openai.reasoningEffort", - anthropic: "config.anthropic.effort", -}; - const thinkingBudgetTokensPathByProvider: Record = { anthropic: "config.anthropic.thinking.budgetTokens", }; @@ -118,19 +114,25 @@ 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) { - maybeApplyDefault({ - appliedFields, - initialValues, - nextValues, - path: reasoningEffortPath, - value: knownModel.reasoningEffort, - values, - }); + // The catalog carries a single editorial effort value. Mirror it + // into both reasoning_effort bounds (default and max), matching + // the server-side migration semantics for legacy per-provider + // effort fields. Providers without runtime effort support skip + // this default. + if (getSupportedReasoningEfforts(provider).length > 0) { + for (const path of [ + "config.reasoningEffort.default", + "config.reasoningEffort.max", + ]) { + maybeApplyDefault({ + appliedFields, + initialValues, + nextValues, + 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..1dd811f472de3 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,81 @@ 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("allows default without max and max without default", () => { + const defaultOnly = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { default: "high" } }), + ); + expect(defaultOnly.fieldErrors).toEqual({}); + expect(defaultOnly.modelConfig?.reasoning_effort).toEqual({ + default: "high", + }); + + const maxOnly = buildModelConfigFromForm( + "openai", + formWith({ reasoningEffort: { max: "high" } }), + ); + expect(maxOnly.fieldErrors).toEqual({}); + expect(maxOnly.modelConfig?.reasoning_effort).toEqual({ max: "high" }); + }); + + 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 +687,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 +714,6 @@ describe("buildModelConfigFromForm", () => { "openai", formWith({ openai: { - reasoningEffort: "medium", parallelToolCalls: "false", textVerbosity: "low", serviceTier: "auto", @@ -648,7 +728,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 +736,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 +811,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 +851,6 @@ describe("buildModelConfigFromForm", () => { "anthropic", formWith({ anthropic: { - effort: "max", thinking: { budgetTokens: "1024" }, sendReasoning: "false", disableParallelToolUse: "true", @@ -792,31 +860,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 +994,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 +1021,6 @@ describe("buildModelConfigFromForm", () => { openrouter: { reasoning: { enabled: "true", - effort: "high", maxTokens: "500", exclude: "false", }, @@ -997,7 +1032,6 @@ describe("buildModelConfigFromForm", () => { ?.openrouter as Record; expect(openrouter.reasoning).toEqual({ enabled: true, - effort: "high", max_tokens: 500, exclude: false, }); @@ -1031,18 +1065,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 +1086,6 @@ describe("buildModelConfigFromForm", () => { vercel: { reasoning: { enabled: "true", - effort: "medium", maxTokens: "1000", exclude: "true", }, @@ -1078,7 +1099,6 @@ describe("buildModelConfigFromForm", () => { >; expect(vercel.reasoning).toEqual({ enabled: true, - effort: "medium", max_tokens: 1000, exclude: true, }); @@ -1135,7 +1155,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 +1164,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..4341aa2790901 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts @@ -8,6 +8,7 @@ import { snakeToCamel, } from "#/api/chatModelOptions"; import type * as TypesGen from "#/api/typesGenerated"; +import { reasoningEffortRank } from "../../utils/reasoningEffort"; import { pricingFieldNames } from "./pricingFields"; // ── Preserved public types ───────────────────────────────────── @@ -445,8 +446,32 @@ function buildYupSchema( return Yup.object(shape) as Yup.ObjectSchema>; } -// Pre-built general-fields schema. -const generalFieldsSchema = buildYupSchema(getGeneralFields()); +// Pre-built general-fields schema. The reasoning effort bounds are +// cross-validated on the global effort ordering: an out-of-range pair +// like default=high, max=low is rejected before it reaches the API. +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"]); + if (typeof defaultValue !== "string" || typeof maxValue !== "string") { + 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/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 654951e4934de..91884d61766d8 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -173,6 +173,9 @@ interface ChatPageInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + // Per-turn reasoning effort for the selected model, when configured. + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; @@ -244,6 +247,8 @@ export const ChatPageInput: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + reasoningEffort, + onReasoningEffortChange, canConfigureAgentSetup, providerCount, modelCount, @@ -504,6 +509,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/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index 0f6d7bf57f9c3..9e14e5671665c 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -312,6 +312,51 @@ 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" }, + }, + }), + 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, providerTypeByID), + ).toEqual([ + { + id: "config-no-effort", + provider: "openai", + model: "gpt-4o", + displayName: "GPT-4o", + contextLimit: 0, + }, + { + id: "config-effort", + provider: "openai", + model: "gpt-5", + displayName: "GPT-5", + contextLimit: 0, + reasoningEffortDefault: "medium", + reasoningEffortMax: "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 3f1d1c799a6ae..fc610935e2091 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -217,12 +217,17 @@ 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 reasoningEffortMax = asString(reasoningEffort?.max).trim(); options.push({ id: configID, provider, model, displayName, ...(contextLimit !== undefined ? { contextLimit } : {}), + ...(reasoningEffortDefault ? { reasoningEffortDefault } : {}), + ...(reasoningEffortMax ? { reasoningEffortMax } : {}), }); } 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..b9fe6f62de740 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; +import { + clampReasoningEffort, + formatReasoningEffort, + getSelectableReasoningEfforts, + getSupportedReasoningEfforts, + reasoningEffortRank, +} from "./reasoningEffort"; + +describe("reasoningEffortRank", () => { + it("orders efforts on the global scale", () => { + expect(reasoningEffortRank("none")).toBeLessThan( + reasoningEffortRank("minimal"), + ); + expect(reasoningEffortRank("minimal")).toBeLessThan( + reasoningEffortRank("low"), + ); + expect(reasoningEffortRank("low")).toBeLessThan( + reasoningEffortRank("medium"), + ); + expect(reasoningEffortRank("medium")).toBeLessThan( + reasoningEffortRank("high"), + ); + expect(reasoningEffortRank("high")).toBeLessThan( + reasoningEffortRank("xhigh"), + ); + expect(reasoningEffortRank("xhigh")).toBeLessThan( + reasoningEffortRank("max"), + ); + }); + + it("ranks none at the bottom of the scale", () => { + expect(reasoningEffortRank("none")).toBe(0); + }); + + it("returns -1 for unknown values", () => { + expect(reasoningEffortRank("extreme")).toBe(-1); + expect(reasoningEffortRank("")).toBe(-1); + }); + + it("normalizes case and whitespace", () => { + expect(reasoningEffortRank(" High ")).toBe(reasoningEffortRank("high")); + }); +}); + +describe("formatReasoningEffort", () => { + it.each([ + ["none", "None"], + ["minimal", "Minimal"], + ["low", "Low"], + ["medium", "Medium"], + ["high", "High"], + ["xhigh", "Xhigh"], + ["max", "Max"], + ])("formats %s as %s", (value, expected) => { + expect(formatReasoningEffort(value)).toBe(expected); + }); +}); + +describe("getSupportedReasoningEfforts", () => { + it("returns provider runtime sets", () => { + expect(getSupportedReasoningEfforts("openai")).toEqual([ + "minimal", + "low", + "medium", + "high", + "xhigh", + ]); + expect(getSupportedReasoningEfforts("anthropic")).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + ]); + expect(getSupportedReasoningEfforts("openrouter")).toEqual([ + "low", + "medium", + "high", + ]); + expect(getSupportedReasoningEfforts("vercel")).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + ]); + }); + + it("shares OpenAI's set with Azure and Anthropic's with Bedrock", () => { + expect(getSupportedReasoningEfforts("azure")).toEqual( + getSupportedReasoningEfforts("openai"), + ); + expect(getSupportedReasoningEfforts("bedrock")).toEqual( + getSupportedReasoningEfforts("anthropic"), + ); + }); + + it("returns an empty set for unsupported providers", () => { + expect(getSupportedReasoningEfforts("google")).toEqual([]); + expect(getSupportedReasoningEfforts("")).toEqual([]); + }); +}); + +describe("getSelectableReasoningEfforts", () => { + it("returns provider values up to the configured max", () => { + expect( + getSelectableReasoningEfforts({ + provider: "openai", + reasoningEffortDefault: "medium", + reasoningEffortMax: "high", + }), + ).toEqual(["minimal", "low", "medium", "high"]); + }); + + it("returns the full supported set when max is the top value", () => { + expect( + getSelectableReasoningEfforts({ + provider: "anthropic", + reasoningEffortMax: "max", + }), + ).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + it("returns empty when max is not configured", () => { + expect( + getSelectableReasoningEfforts({ + provider: "openai", + reasoningEffortDefault: "medium", + }), + ).toEqual([]); + }); + + it("returns empty for providers without effort support", () => { + expect( + getSelectableReasoningEfforts({ + provider: "google", + reasoningEffortMax: "high", + }), + ).toEqual([]); + }); + + it("keeps the provider minimum when max is below it", () => { + expect( + getSelectableReasoningEfforts({ + provider: "anthropic", + reasoningEffortMax: "minimal", + }), + ).toEqual(["low"]); + }); + + it("ignores invalid max values", () => { + expect( + getSelectableReasoningEfforts({ + provider: "openai", + reasoningEffortMax: "extreme", + }), + ).toEqual([]); + }); + + it("includes none for Vercel from the bottom of its range", () => { + expect( + getSelectableReasoningEfforts({ + provider: "vercel", + reasoningEffortDefault: "medium", + reasoningEffortMax: "xhigh", + }), + ).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]); + // "none" itself is a selectable value. + expect( + clampReasoningEffort("none", { + provider: "vercel", + reasoningEffortDefault: "medium", + reasoningEffortMax: "xhigh", + }), + ).toBe("none"); + }); +}); + +describe("clampReasoningEffort", () => { + const openaiModel = { + provider: "openai", + reasoningEffortDefault: "medium", + reasoningEffortMax: "high", + }; + + it("keeps a value the model supports under its max", () => { + expect(clampReasoningEffort("low", openaiModel)).toBe("low"); + expect(clampReasoningEffort("high", openaiModel)).toBe("high"); + }); + + it("falls back to the default when the value exceeds max", () => { + expect(clampReasoningEffort("xhigh", openaiModel)).toBe("medium"); + }); + + it("falls back to the default when the value is unsupported", () => { + // "max" is on the global scale but not in OpenAI's set. + expect(clampReasoningEffort("max", openaiModel)).toBe("medium"); + expect(clampReasoningEffort("bogus", openaiModel)).toBe("medium"); + }); + + it("falls back to the default when no value is given", () => { + expect(clampReasoningEffort(undefined, openaiModel)).toBe("medium"); + expect(clampReasoningEffort("", openaiModel)).toBe("medium"); + }); + + it("normalizes case and whitespace", () => { + expect(clampReasoningEffort(" High ", openaiModel)).toBe("high"); + }); + + it("snaps a default above max down to max", () => { + expect( + clampReasoningEffort(undefined, { + provider: "openai", + reasoningEffortDefault: "xhigh", + reasoningEffortMax: "medium", + }), + ).toBe("medium"); + }); + + it("returns undefined when the model has no effort configured", () => { + expect( + clampReasoningEffort("high", { + provider: "openai", + reasoningEffortDefault: "medium", + }), + ).toBeUndefined(); + }); + + it("returns undefined when no default is configured and value invalid", () => { + expect( + clampReasoningEffort("bogus", { + provider: "openai", + reasoningEffortMax: "high", + }), + ).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..334a3d20c7a82 --- /dev/null +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.ts @@ -0,0 +1,141 @@ +/** + * Reasoning effort helpers for the per-turn effort selector. + * + * Mirrors the backend's global effort scale and per-provider runtime + * support in coderd/x/chatd/chatprovider/reasoningeffort.go. The + * backend clamps whatever the client sends; these helpers exist so + * the UI only offers values the model can actually use. + */ + +/** + * Global effort ordering used for clamping and slider positions. + * Each provider supports a contiguous subset. + */ +export const reasoningEffortOrder = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +// Runtime-supported effort values per provider, in ascending global +// order. Azure shares OpenAI's set and Bedrock shares Anthropic's. +const supportedEffortsByProvider: Record = { + openai: ["minimal", "low", "medium", "high", "xhigh"], + azure: ["minimal", "low", "medium", "high", "xhigh"], + openaicompat: ["minimal", "low", "medium", "high", "xhigh"], + anthropic: ["low", "medium", "high", "xhigh", "max"], + bedrock: ["low", "medium", "high", "xhigh", "max"], + openrouter: ["low", "medium", "high"], + vercel: ["none", "minimal", "low", "medium", "high", "xhigh"], +}; + +/** + * The effort-relevant slice of a model option: the provider decides + * which values are supported and the config's max/default bound the + * selectable range. + */ +interface ReasoningEffortModel { + readonly provider: string; + readonly reasoningEffortDefault?: string; + readonly reasoningEffortMax?: string; +} + +/** + * Position of value on the global effort scale, or -1 when the value + * is not a known effort. + */ +export const reasoningEffortRank = (value: string): number => + reasoningEffortOrder.indexOf( + value.trim().toLowerCase() as (typeof reasoningEffortOrder)[number], + ); + +const normalizeEffort = (value: string | undefined): string | undefined => { + const normalized = value?.trim().toLowerCase(); + return normalized && reasoningEffortRank(normalized) >= 0 + ? normalized + : undefined; +}; + +/** Display label for an effort value, e.g. "xhigh" renders as "Xhigh". */ +export const formatReasoningEffort = (value: string): string => { + const normalized = value.trim().toLowerCase(); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +}; + +/** + * Effort values supported by the provider's runtime, in ascending + * global order. Empty for providers without reasoning effort support. + */ +export const getSupportedReasoningEfforts = ( + provider: string, +): readonly string[] => + supportedEffortsByProvider[provider.trim().toLowerCase()] ?? []; + +/** + * Effort values the user may select for a model: the provider's + * supported values from the provider minimum up to the model's + * configured max. Empty when the model has no max configured or the + * provider does not support reasoning effort. A max below the provider + * minimum leaves only the minimum selectable, mirroring the backend's + * clamp-up behavior. + */ +export const getSelectableReasoningEfforts = ( + model: ReasoningEffortModel, +): readonly string[] => { + const supported = getSupportedReasoningEfforts(model.provider); + const max = normalizeEffort(model.reasoningEffortMax); + if (supported.length === 0 || !max) { + return []; + } + const maxRank = reasoningEffortRank(max); + const selectable = supported.filter( + (effort) => reasoningEffortRank(effort) <= maxRank, + ); + return selectable.length > 0 ? selectable : supported.slice(0, 1); +}; + +/** + * Resolve value to an effort valid for the model. Keeps value when + * the model supports it under its max, otherwise falls back to the + * model's default (snapped into the selectable range). Returns + * undefined when the model has no reasoning effort configured or no + * usable value remains. + */ +export const clampReasoningEffort = ( + value: string | undefined, + model: ReasoningEffortModel, +): string | undefined => { + const selectable = getSelectableReasoningEfforts(model); + if (selectable.length === 0) { + return undefined; + } + + const normalized = normalizeEffort(value); + if (normalized && selectable.includes(normalized)) { + return normalized; + } + + const defaultEffort = normalizeEffort(model.reasoningEffortDefault); + if (!defaultEffort) { + return undefined; + } + if (selectable.includes(defaultEffort)) { + return defaultEffort; + } + + // Default outside the selectable range: snap to the largest + // selectable value not exceeding it, or the minimum when below. + const defaultRank = reasoningEffortRank(defaultEffort); + let snapped = selectable[0]; + for (const candidate of selectable) { + if (reasoningEffortRank(candidate) > defaultRank) { + break; + } + snapped = candidate; + } + return snapped; +};