From 0187e606f858cb10e3c5089c31e034893c71cfc3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:59:58 +0000 Subject: [PATCH 01/12] fix: hide and reject chat models from disabled AI providers Disabling an AI provider left its enabled model configs selectable in the frontend and accepted by the API, producing an unclear runtime failure. Model selectors now filter by provider row (instance) state, and the create-chat, send-message, edit-message, subagent/advisor override endpoints reject model configs whose provider is disabled with a clear 400. - codersdk: expose provider enabled state on UserChatProviderConfig - site: skip disabled-provider configs in model option builders, the subagent/advisor override selectors, and the model form provider select (create flow only); invalidate chat queries on AI provider mutations - coderd: validate explicit model_config_id at admission using the existing availability check; advisor override PUT now requires an enabled config + provider - admin model-config list keeps returning provider-disabled configs so the ModelsPage management view can show them Fixes coder/internal#CODAGT-601 --- coderd/exp_chats.go | 41 ++- coderd/exp_chats_test.go | 243 +++++++++++++++++- codersdk/chats.go | 1 + site/src/api/queries/aiProviders.ts | 4 + site/src/api/queries/chats.ts | 12 + site/src/api/typesGenerated.ts | 1 + .../CoderAgentsPageView.stories.tsx | 76 +++++- .../CoderAgentsPage/CoderAgentsPageView.tsx | 12 +- .../ModelsPage/ModelsPageView.stories.tsx | 21 ++ .../components/ModelForm.stories.tsx | 44 ++++ .../components/ModelFormProviderSelect.tsx | 9 +- .../AISettingsPage/ModelsPage/testFixtures.ts | 27 ++ .../AgentSettingsAPIKeysPage.stories.tsx | 1 + .../AgentsPage/components/AdvisorSettings.tsx | 29 ++- .../AgentsPage/utils/modelOptions.test.ts | 164 ++++++++++++ .../pages/AgentsPage/utils/modelOptions.ts | 24 ++ 16 files changed, 685 insertions(+), 24 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 46323846834..79faf017c7f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3336,6 +3336,17 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { if req.ModelConfigID != nil { modelConfigID = *req.ModelConfigID } + // Reject explicitly requested models the user cannot use (for example + // when the model or its provider is disabled) instead of admitting the + // message and failing later at runtime. A nil ID keeps the chat's + // current model and is validated by the daemon's fallback logic. + if modelConfigID != uuid.Nil { + status, resp := api.validateUserChatModelConfigAvailable(ctx, apiKey.UserID, modelConfigID) + if resp != nil { + httpapi.Write(ctx, rw, status, *resp) + return + } + } reasoningEffort := req.ReasoningEffort if reasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*reasoningEffort) { @@ -3501,6 +3512,17 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { if req.ModelConfigID != nil { editModelConfigID = *req.ModelConfigID } + // Reject explicitly requested models the user cannot use (for example + // when the model or its provider is disabled) instead of admitting the + // edit and failing later at runtime. A nil ID keeps the chat's current + // model and is validated by the daemon's fallback logic. + if editModelConfigID != uuid.Nil { + status, resp := api.validateUserChatModelConfigAvailable(ctx, apiKey.UserID, editModelConfigID) + if resp != nil { + httpapi.Write(ctx, rw, status, *resp) + return + } + } editReasoningEffort := req.ReasoningEffort if editReasoningEffort != nil && !chatprovider.IsValidReasoningEffort(*editReasoningEffort) { @@ -4818,6 +4840,12 @@ func (api *API) resolveCreateChatModelConfigID( Message: "Invalid model config ID.", } } + // Reject explicitly requested models the user cannot use (for + // example when the model or its provider is disabled) instead of + // admitting the chat and failing later at runtime. + if _, status, resp := api.validateUserChatModelConfigAvailable(ctx, userID, *req.ModelConfigID); resp != nil { + return uuid.Nil, nil, status, resp + } return *req.ModelConfigID, nil, 0, nil } @@ -5703,16 +5731,15 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { return } } else { - // Use system context because GetChatModelConfigByID requires - // deployment-config read access, which can be broader than the - // handler's explicit update check. The lookup validates the model and - // any selected reasoning effort before persisting deployment config. - //nolint:gocritic // This admin-authorized validation lookup intentionally bypasses read authz. - modelConfig, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), req.ModelConfigID) + // Require the referenced model config and its provider to be + // enabled so a deployment-wide advisor override never points at a + // model that cannot serve requests. The lookup also validates any + // selected reasoning effort before persisting deployment config. + modelConfig, err := lookupEnabledChatModelConfigByID(ctx, api.Database, req.ModelConfigID) if err != nil { if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("model_config_id %q does not match any existing model config.", req.ModelConfigID), + Message: fmt.Sprintf("model_config_id %q does not match any enabled model config.", req.ModelConfigID), }) return } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 209e51d1e71..bb9c4a86a1a 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -517,6 +517,58 @@ func TestPostChats(t *testing.T) { } }) + t.Run("DisabledModelConfigRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + disabledConfig := createDisabledChatModelConfig( + t, + client, + coderdtest.TestChatProviderOpenAICompat, + "gpt-4o-create-disabled-"+uuid.NewString(), + ) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }}, + ModelConfigID: ptr.Ref(disabledConfig.ID), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) + }) + + t.Run("ProviderDisabledModelConfigRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + providerDisabledConfig := createProviderDisabledChatModelConfig( + t, + client, + "openai", + "gpt-4o-create-provider-disabled-"+uuid.NewString(), + ) + + _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }}, + ModelConfigID: ptr.Ref(providerDisabledConfig.ID), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) + }) + t.Run("WithPerChatSystemPrompt", func(t *testing.T) { t.Parallel() @@ -3847,6 +3899,39 @@ func TestListChatModelConfigs(t *testing.T) { require.True(t, configs[0].Enabled) }) + // An enabled config under a disabled provider must stay visible to + // admins (management view) while being hidden from non-admins (usage + // view). + t.Run("ProviderDisabled", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + enabledConfig := createChatModelConfig(t, adminClient) + providerDisabledConfig := createProviderDisabledChatModelConfig( + t, + adminClient, + "openai", + "gpt-4o-provider-disabled-"+uuid.NewString(), + ) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + adminConfigs, err := adminClient.ListChatModelConfigs(ctx) + require.NoError(t, err) + adminIDs := make([]uuid.UUID, 0, len(adminConfigs)) + for _, config := range adminConfigs { + adminIDs = append(adminIDs, config.ID) + } + require.Contains(t, adminIDs, providerDisabledConfig.ID) + + memberConfigs, err := memberClient.ListChatModelConfigs(ctx) + require.NoError(t, err) + require.Len(t, memberConfigs, 1) + require.Equal(t, enabledConfig.ID, memberConfigs[0].ID) + }) + t.Run("DeserializesLegacyPricingJSON", func(t *testing.T) { t.Parallel() @@ -6881,6 +6966,41 @@ func TestPostChatMessages(t *testing.T) { } }) + t.Run("ProviderDisabledModelConfigRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "initial message before disabled provider switch", + }}, + }) + require.NoError(t, err) + + providerDisabledConfig := createProviderDisabledChatModelConfig( + t, + client, + "openai", + "gpt-4o-send-provider-disabled-"+uuid.NewString(), + ) + + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "switch to a provider-disabled model", + }}, + ModelConfigID: ptr.Ref(providerDisabledConfig.ID), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) + }) + t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { t.Parallel() @@ -8961,7 +9081,52 @@ func TestPatchChatMessage(t *testing.T) { ModelConfigID: &unknownID, }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Invalid model config ID.", sdkErr.Message) + require.Equal(t, "Invalid model_config_id: model config not found or disabled.", sdkErr.Message) + }) + + t.Run("ProviderDisabledModelConfigID", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }}, + }) + require.NoError(t, err) + + messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + var userMessageID int64 + for _, message := range messagesResult.Messages { + if message.Role == codersdk.ChatMessageRoleUser { + userMessageID = message.ID + break + } + } + require.NotZero(t, userMessageID) + + providerDisabledConfig := createProviderDisabledChatModelConfig( + t, + client, + "openai", + "gpt-4o-edit-provider-disabled-"+uuid.NewString(), + ) + _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "edited with provider-disabled model", + }}, + ModelConfigID: &providerDisabledConfig.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) }) } @@ -11889,6 +12054,26 @@ func createDisabledChatModelConfig( return updated } +// createProviderDisabledChatModelConfig creates an enabled model config and +// then disables its parent AI provider, leaving a config that is enabled at +// the config level but unusable because of provider state. +func createProviderDisabledChatModelConfig( + t *testing.T, + client *codersdk.ExperimentalClient, + provider string, + model string, +) codersdk.ChatModelConfig { + t.Helper() + + modelConfig := createAdditionalChatModelConfig(t, client, provider, model) + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + return modelConfig +} + func enableUserChatProviderKey( t testing.TB, adminClient *codersdk.ExperimentalClient, @@ -12704,6 +12889,20 @@ func TestChatModelOverrides(t *testing.T) { require.Equal(t, "Invalid model_config_id.", sdkErr.Message) }) + t.Run("ProviderDisabledModelReturns400", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + providerDisabledModel := createProviderDisabledChatModelConfig( + t, + adminClient, + "openai", + "gpt-4.1-provider-disabled-"+string(setting.context), + ) + err := putOverride(ctx, adminClient, setting.context, providerDisabledModel.ID.String()) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid model_config_id.", sdkErr.Message) + }) + t.Run("UnknownModelReturns400", func(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) unknownModelID := uuid.New() @@ -14201,7 +14400,47 @@ func TestChatAdvisorConfig_InvalidModelConfigID(t *testing.T) { }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Contains(t, sdkErr.Message, unknownID.String()) - require.Contains(t, sdkErr.Message, "does not match any existing model config") + require.Contains(t, sdkErr.Message, "does not match any enabled model config") +} + +func TestChatAdvisorConfig_DisabledModelConfigID(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + coderdtest.CreateFirstUser(t, adminClient.Client) + + disabledConfig := createDisabledChatModelConfig( + t, + adminClient, + coderdtest.TestChatProviderOpenAICompat, + "gpt-4o-advisor-disabled-"+uuid.NewString(), + ) + err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ + ModelConfigID: disabledConfig.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "does not match any enabled model config") +} + +func TestChatAdvisorConfig_ProviderDisabledModelConfigID(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient := newChatClient(t) + coderdtest.CreateFirstUser(t, adminClient.Client) + + providerDisabledConfig := createProviderDisabledChatModelConfig( + t, + adminClient, + "openai", + "gpt-4o-advisor-provider-disabled-"+uuid.NewString(), + ) + err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{ + ModelConfigID: providerDisabledConfig.ID, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "does not match any enabled model config") } func TestChatAdvisorConfig_ReasoningEffortRequiresModelConfig(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index f28ef011eea..dc361d1e9ec 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1272,6 +1272,7 @@ type UserChatProviderConfig struct { Provider string `json:"provider"` DisplayName string `json:"display_name"` Icon string `json:"icon"` + Enabled bool `json:"enabled"` HasUserAPIKey bool `json:"has_user_api_key"` HasCentralAPIKeyFallback bool `json:"has_central_api_key_fallback"` BYOKEnabled bool `json:"byok_enabled"` diff --git a/site/src/api/queries/aiProviders.ts b/site/src/api/queries/aiProviders.ts index 7a3f01cf535..58e706c7d0e 100644 --- a/site/src/api/queries/aiProviders.ts +++ b/site/src/api/queries/aiProviders.ts @@ -1,5 +1,6 @@ import type { QueryClient } from "react-query"; import { API } from "#/api/api"; +import { invalidateChatProviderDependentQueries } from "#/api/queries/chats"; import type { AIProvider, CreateAIProviderRequest, @@ -26,6 +27,7 @@ export const createAIProviderMutation = (queryClient: QueryClient) => ({ API.createAIProvider(request), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: aiProvidersListKey }); + await invalidateChatProviderDependentQueries(queryClient); }, }); @@ -40,6 +42,7 @@ export const updateAIProviderMutation = ( await queryClient.invalidateQueries({ queryKey: aiProviderKeyFor(idOrName), }); + await invalidateChatProviderDependentQueries(queryClient); }, }); @@ -51,5 +54,6 @@ export const deleteAIProviderMutation = ( onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: aiProvidersListKey }); queryClient.removeQueries({ queryKey: aiProviderKeyFor(idOrName) }); + await invalidateChatProviderDependentQueries(queryClient); }, }); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 281f04234b1..8a9e045bb4a 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1826,6 +1826,7 @@ export const userChatProviderConfigs = () => ({ provider: config.provider.type, display_name: config.provider.display_name || config.provider.type, icon: config.provider.icon, + enabled: config.provider.enabled, has_user_api_key: config.has_user_api_key, byok_enabled: config.byok_enabled, has_central_api_key_fallback: config.has_provider_api_key, @@ -1872,6 +1873,17 @@ const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => { ]); }; +// Refreshes every chat query derived from AI provider state so provider +// mutations (enable/disable/delete) update open model pickers in-session. +export const invalidateChatProviderDependentQueries = async ( + queryClient: QueryClient, +) => { + await Promise.all([ + invalidateChatConfigurationQueries(queryClient), + queryClient.invalidateQueries({ queryKey: userChatProviderConfigsKey }), + ]); +}; + export const createChatModelConfig = (queryClient: QueryClient) => ({ mutationFn: (req: TypesGen.CreateChatModelConfigRequest) => API.experimental.createChatModelConfig(req), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c21a3338a6e..3aebc9edcaa 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -9947,6 +9947,7 @@ export interface UserChatProviderConfig { readonly provider: string; readonly display_name: string; readonly icon: string; + readonly enabled: boolean; readonly has_user_api_key: boolean; readonly has_central_api_key_fallback: boolean; readonly byok_enabled: boolean; diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx index a67df1eaf97..bdc600a11d0 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx @@ -113,6 +113,16 @@ const compactionDisabledModelConfig = buildModelConfig({ context_limit: 128_000, }); +// Enabled at the config level, but its provider row is disabled, so no +// selector may offer it. +const providerDisabledModelConfig = buildModelConfig({ + id: "model-provider-disabled", + ai_provider_id: "provider-openai-disabled", + model: "gpt-4o-secondary", + display_name: "GPT 4o Secondary", + context_limit: 128_000, +}); + const allModelConfigs: TypesGen.ChatModelConfig[] = [ generalModelConfig, claudeSonnetModelConfig, @@ -123,13 +133,31 @@ const allModelConfigs: TypesGen.ChatModelConfig[] = [ titleDisabledModelConfig, exploreDisabledModelConfig, compactionDisabledModelConfig, + providerDisabledModelConfig, ]; const providerInfoByID = new Map([ - ["provider-1", { provider: "openai", displayName: "OpenAI", icon: "" }], + [ + "provider-1", + { provider: "openai", displayName: "OpenAI", icon: "", enabled: true }, + ], [ "provider-anthropic", - { provider: "anthropic", displayName: "Anthropic", icon: "" }, + { + provider: "anthropic", + displayName: "Anthropic", + icon: "", + enabled: true, + }, + ], + [ + "provider-openai-disabled", + { + provider: "openai", + displayName: "OpenAI Secondary", + icon: "", + enabled: false, + }, ], ]); @@ -668,6 +696,50 @@ export const AdvisorReasoningEffort: Story = { }, }; +export const DisabledProviderModelsHidden: Story = { + args: buildArgs({ + showAdvisorSettings: true, + advisorConfigData: { + enabled: true, + max_uses_per_run: 3, + max_output_tokens: 16384, + model_config_id: "00000000-0000-0000-0000-000000000000", + }, + }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // The subagent override selectors must not offer the enabled config + // whose provider row is disabled. + const generalSection = await getSection(canvasElement, "General model"); + const generalTrigger = within(generalSection).getByRole("combobox", { + name: "Use chat default", + }); + await userEvent.click(generalTrigger); + expect( + await body.findByRole("option", { name: /GPT 4\.1 Mini/ }), + ).toBeInTheDocument(); + expect( + body.queryByRole("option", { name: /GPT 4o Secondary/ }), + ).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + + // The advisor model select must hide it as well. + const advisorSection = await getSection(canvasElement, "Advisor"); + const advisorTrigger = within(advisorSection).getByRole("combobox", { + name: "Use chat model", + }); + await userEvent.click(advisorTrigger); + expect( + await body.findByRole("option", { name: /GPT 4\.1 Mini/ }), + ).toBeInTheDocument(); + expect( + body.queryByRole("option", { name: /GPT 4o Secondary/ }), + ).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + }, +}; + export const AdvisorClearButton: Story = { args: buildArgs({ showAdvisorSettings: true, diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx index 0022c2dc3b8..290386e6f9d 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.tsx @@ -8,7 +8,10 @@ import { } from "#/components/SettingsHeader/SettingsHeader"; import { AdvisorSettings } from "#/pages/AgentsPage/components/AdvisorSettings"; import { VirtualDesktopSettings } from "#/pages/AgentsPage/components/VirtualDesktopSettings"; -import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions"; +import { + filterConfigsWithEnabledProvider, + type ProviderInfo, +} from "#/pages/AgentsPage/utils/modelOptions"; import { AdminPersonalModelOverridesSettings, type SavePersonalModelOverridesAdminSetting, @@ -122,8 +125,11 @@ export const CoderAgentsPageView: FC = ({ isSavingComputerUseProvider, computerUseProviderSaveError, }) => { - const enabledModelConfigs = (modelConfigsData ?? []).filter( - (modelConfig) => modelConfig.enabled, + // Offer only models that can actually serve requests: the config and + // its provider row must both be enabled. + const enabledModelConfigs = filterConfigsWithEnabledProvider( + (modelConfigsData ?? []).filter((modelConfig) => modelConfig.enabled), + providerInfoByID, ); const showGeneralModelSection = onSaveGeneralModelOverride !== undefined || diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx index c4de1c4ffda..519ea2c4014 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -6,11 +6,13 @@ import ModelsPageView from "./ModelsPageView"; import { MockAnthropicProviderState, MockBedrockProviderState, + MockDisabledProviderState, MockOpenAIProviderState, mockBedrockClaude, mockClaude, mockDisabledModel, mockGPT5, + mockProviderDisabledModel, } from "./testFixtures"; const meta: Meta = { @@ -113,6 +115,25 @@ export const NoMatchingModels: Story = { }, }; +// The management view must keep showing enabled configs whose provider is +// disabled so admins can find and fix them, even though model selectors +// hide those configs. +export const DisabledProviderModelsStillListed: Story = { + args: { + models: [mockGPT5, mockProviderDisabledModel], + providerStates: [MockOpenAIProviderState, MockDisabledProviderState], + providerTypeByID: new Map([ + ["prov-openai", "openai"], + ["prov-openai-disabled", "openai"], + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("GPT-4o Secondary")).toBeInTheDocument(); + await expect(canvas.getByText("OpenAI Secondary")).toBeInTheDocument(); + }, +}; + export const Loading: Story = { args: { isLoading: true, diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 91b9de74620..79077810967 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -4,8 +4,10 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { withToaster } from "#/testHelpers/storybook"; import { MockAnthropicProviderState, + MockDisabledProviderState, MockOpenAIProviderState, mockGPT5, + mockProviderDisabledModel, } from "../testFixtures"; import { ModelForm } from "./ModelForm"; @@ -126,6 +128,48 @@ export const ReplaceDefaultWarning: Story = { }, }; +export const AddHidesDisabledProviders: Story = { + args: { + providerStates: [ + MockOpenAIProviderState, + MockAnthropicProviderState, + MockDisabledProviderState, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("combobox", { name: /provider/i })); + // Option names include the provider icon alt text, so match loosely. + const optionNames = screen + .getAllByRole("option") + .map((option) => option.textContent?.trim()); + await expect(optionNames).toEqual(["OpenAI", "Anthropic"]); + // The backend rejects creating configs under disabled providers, so + // the create flow must not offer them. + await expect( + screen.queryByRole("option", { name: /Secondary/ }), + ).not.toBeInTheDocument(); + }, +}; + +export const EditKeepsDisabledProviderVisible: Story = { + args: { + providerStates: [MockOpenAIProviderState, MockDisabledProviderState], + selectedProviderState: MockDisabledProviderState, + editingModel: mockProviderDisabledModel, + onDeleteModel: fn(async () => undefined), + onDuplicate: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Editing a config whose provider was disabled afterwards keeps the + // provider rendered so the form stays usable. + await expect( + canvas.getByRole("combobox", { name: /provider/i }), + ).toHaveTextContent("OpenAI Secondary"); + }, +}; + export const Edit: Story = { args: { editingModel: mockGPT5, diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx index 9ae0b7d5c16..630bfa9674e 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx @@ -16,6 +16,13 @@ export const ModelFormProviderSelect: FC<{ onProviderChange: (providerKey: string) => void; disabled: boolean; }> = ({ providerStates, selectedProviderKey, onProviderChange, disabled }) => { + // Hide disabled providers: the backend rejects creating model configs + // under them. Keep the currently selected provider visible so editing + // a config whose provider was disabled afterwards still renders. + const selectableProviderStates = providerStates.filter( + (ps) => + ps.providerConfig?.enabled !== false || ps.key === selectedProviderKey, + ); return (
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx index cb464de7e8f..aff9db74be8 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx @@ -149,6 +149,7 @@ export const ModelFormFields: FC<{ diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx index 630bfa9674e..7f57489d147 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx @@ -15,13 +15,23 @@ export const ModelFormProviderSelect: FC<{ selectedProviderKey: string; onProviderChange: (providerKey: string) => void; disabled: boolean; -}> = ({ providerStates, selectedProviderKey, onProviderChange, disabled }) => { + isEditing: boolean; +}> = ({ + providerStates, + selectedProviderKey, + onProviderChange, + disabled, + isEditing, +}) => { // Hide disabled providers: the backend rejects creating model configs - // under them. Keep the currently selected provider visible so editing - // a config whose provider was disabled afterwards still renders. + // under them. When editing, keep the currently selected provider + // visible so a config whose provider was disabled afterwards still + // renders. On the add path the selection can come from a ?provider= + // query param, so a disabled provider must not stay selectable. const selectableProviderStates = providerStates.filter( (ps) => - ps.providerConfig?.enabled !== false || ps.key === selectedProviderKey, + ps.providerConfig?.enabled !== false || + (isEditing && ps.key === selectedProviderKey), ); return (
From 3e6f82e0479d253e790a9ef8ee57b74e28a71b65 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:23:52 +0000 Subject: [PATCH 06/12] fix(coderd/x/chatd): validate fallback chat models at message admission When a chat omitted model_config_id, admission reused the chat's last model via a lookup that only excluded deleted configs, so chats whose model or provider was disabled kept sending requests through the disabled provider. Resolve the fallback through the enabled-config lookup: a disabled last model now falls back to the default, and a disabled default is rejected with ErrNoDefaultChatModelConfig instead of being admitted and failing at generation time. --- coderd/x/chatd/chatd.go | 20 +++++++- coderd/x/chatd/chatd_internal_test.go | 74 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index fa2012b2ade..506aa77f412 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1589,7 +1589,7 @@ func resolveFallbackModelConfigID( ) (uuid.UUID, error) { chatdCtx := chatdModelConfigLookupContext(ctx) if modelConfigID != uuid.Nil { - if _, err := store.GetChatModelConfigByID(chatdCtx, modelConfigID); err == nil { + if _, err := store.GetEnabledChatModelConfigByID(chatdCtx, modelConfigID); err == nil { return modelConfigID, nil } else if !errors.Is(err, sql.ErrNoRows) { return uuid.Nil, xerrors.Errorf( @@ -1598,6 +1598,8 @@ func resolveFallbackModelConfigID( err, ) } + // The chat's last model or its provider was disabled or + // deleted, fall through to the default. } defaultConfig, err := store.GetDefaultChatModelConfig(chatdCtx) @@ -1607,6 +1609,22 @@ func resolveFallbackModelConfigID( } return uuid.Nil, xerrors.Errorf("get default chat model config: %w", err) } + // A disabled default (or one under a disabled provider) would only + // fail later at generation time, so reject it at admission instead. + if _, err := store.GetEnabledChatModelConfigByID(chatdCtx, defaultConfig.ID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, xerrors.Errorf( + "%w: default model config %s or its provider is disabled", + ErrNoDefaultChatModelConfig, + defaultConfig.ID, + ) + } + return uuid.Nil, xerrors.Errorf( + "get default chat model config %s: %w", + defaultConfig.ID, + err, + ) + } return defaultConfig.ID, nil } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 916df0f80fb..cdb70c0f380 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3483,3 +3483,77 @@ func TestPrepareManualTitleDebugRun_RouteFailureDerivesProviderFromConfig(t *tes require.True(t, gotProvider.Valid, "debug run provider should be populated from the linked config") require.Equal(t, "anthropic", gotProvider.String) } + +// TestResolveFallbackModelConfigID verifies that message admission does +// not reuse a chat's last model when that model or its provider has been +// disabled, and that a disabled default is rejected instead of being +// admitted and failing later at generation time. +func TestResolveFallbackModelConfigID(t *testing.T) { + t.Parallel() + + newProvider := func(t *testing.T, db database.Store, enabled bool) database.AIProvider { + return dbgen.AIProvider(t, db, database.AIProvider{}, func(p *database.InsertAIProviderParams) { + p.Enabled = enabled + }) + } + newModelConfig := func(t *testing.T, db database.Store, providerID uuid.UUID, isDefault bool) database.ChatModelConfig { + return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, + IsDefault: isDefault, + }) + } + + t.Run("EnabledLastModel", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + provider := newProvider(t, db, true) + lastModel := newModelConfig(t, db, provider.ID, false) + + resolved, err := resolveFallbackModelConfigID(ctx, db, lastModel.ID) + require.NoError(t, err) + require.Equal(t, lastModel.ID, resolved) + }) + + t.Run("ProviderDisabledLastModelFallsBackToDefault", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + disabledProvider := newProvider(t, db, false) + lastModel := newModelConfig(t, db, disabledProvider.ID, false) + enabledProvider := newProvider(t, db, true) + defaultModel := newModelConfig(t, db, enabledProvider.ID, true) + + resolved, err := resolveFallbackModelConfigID(ctx, db, lastModel.ID) + require.NoError(t, err) + require.Equal(t, defaultModel.ID, resolved) + }) + + t.Run("NilLastModelUsesDefault", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + provider := newProvider(t, db, true) + defaultModel := newModelConfig(t, db, provider.ID, true) + + resolved, err := resolveFallbackModelConfigID(ctx, db, uuid.Nil) + require.NoError(t, err) + require.Equal(t, defaultModel.ID, resolved) + }) + + t.Run("ProviderDisabledDefaultRejected", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + disabledProvider := newProvider(t, db, false) + lastModel := newModelConfig(t, db, disabledProvider.ID, false) + newModelConfig(t, db, disabledProvider.ID, true) + + _, err := resolveFallbackModelConfigID(ctx, db, lastModel.ID) + require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) + }) +} From 777187e7786a0fa5fce2b77594d078551fdb042e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:37:09 +0000 Subject: [PATCH 07/12] fix(coderd): reject chat creation when the default model is unavailable Creating a chat without model_config_id resolved the default model without checking that it and its provider are enabled, so disabling the default model's provider let POST /chats create a running chat that only failed later at generation time. Validate the resolved default through the enabled-config lookup and reject the request with a 400 at admission, matching the send-message fallback behavior. --- coderd/exp_chats.go | 15 +++++++++++++++ coderd/exp_chats_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index b71aa623d68..7ab2fcb00f9 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4940,6 +4940,21 @@ func (api *API) defaultCreateChatModelConfigID( } } + // A default whose config or provider is disabled would only fail at + // generation time, so reject chat creation at admission instead. + if _, err := lookupEnabledChatModelConfigByID(ctx, api.Database, defaultModelConfig.ID); err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ + Message: "No default chat model config is configured.", + Detail: "The default chat model or its provider is disabled.", + } + } + return uuid.Nil, http.StatusInternalServerError, &codersdk.Response{ + Message: "Failed to resolve chat model config.", + Detail: err.Error(), + } + } + return defaultModelConfig.ID, 0, nil } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index bb9c4a86a1a..437a2024536 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -569,6 +569,33 @@ func TestPostChats(t *testing.T) { require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) }) + t.Run("ProviderDisabledDefaultModelRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + defaultConfig := createChatModelConfig(t, client) + _, err := client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + + // Omitting model_config_id resolves the default model, whose + // provider is now disabled. Admission must reject the request + // instead of creating a chat that fails at generation time. + _, err = client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello", + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + require.Equal(t, "The default chat model or its provider is disabled.", sdkErr.Detail) + }) + t.Run("WithPerChatSystemPrompt", func(t *testing.T) { t.Parallel() From b8f2c7ca9f75779250c995813b25b6938debf16d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:46:18 +0000 Subject: [PATCH 08/12] fix(coderd): map disabled-default fallback to a 400 on send message Sending a message without an explicit model can now fail with ErrNoDefaultChatModelConfig when the chat's last model and the default are both unavailable. The send handler did not map that error, so users got a generic 500 instead of the admission error chat creation returns for the same state. --- coderd/exp_chats.go | 8 ++++++++ coderd/exp_chats_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 7ab2fcb00f9..ff2664f43b0 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3406,6 +3406,14 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { }) return } + // The fallback model resolution rejects a disabled default (or + // one under a disabled provider), mirroring chat creation. + if xerrors.Is(sendErr, chatd.ErrNoDefaultChatModelConfig) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "No default chat model config is configured.", + }) + return + } if errors.Is(sendErr, chatstate.ErrChatNotFound) { httpapi.ResourceNotFound(rw) return diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 437a2024536..b78836cb29e 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -7028,6 +7028,41 @@ func TestPostChatMessages(t *testing.T) { require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) }) + t.Run("ProviderDisabledDefaultFallbackRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + defaultConfig := createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "initial message before provider disable", + }}, + }) + require.NoError(t, err) + + _, err = client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + + // Without an explicit model the fallback resolution walks + // last model -> default, both owned by the now-disabled + // provider, and must surface a 400 instead of a generic 500. + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "message after provider disable", + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + }) + t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { t.Parallel() From 423d7ede8a77c5896fe7044392c5c12237716890 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:58:44 +0000 Subject: [PATCH 09/12] fix(coderd): validate preserved edit-message models at admission Editing a message without model_config_id preserved the edited message's original model without checking that it is still usable, so edits kept routing through disabled models or providers and failed at generation time. Resolve the preserved model through the send-message fallback (disabled model falls back to the default, disabled default is rejected) and map the rejection to a 400 in the edit handler. --- coderd/exp_chats.go | 7 ++++++ coderd/exp_chats_test.go | 47 ++++++++++++++++++++++++++++++++++++++++ coderd/x/chatd/chatd.go | 17 +++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index ff2664f43b0..28a5fe01142 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3573,6 +3573,13 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid model config ID.", }) + // The nil-override edit path resolves the preserved message + // model through the send-message fallback, which rejects a + // disabled default. + case xerrors.Is(editErr, chatd.ErrNoDefaultChatModelConfig): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "No default chat model config is configured.", + }) case errors.Is(editErr, chatstate.ErrChatNotFound): httpapi.ResourceNotFound(rw) case writeChatInvalidState(ctx, rw, editErr): diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index b78836cb29e..786aedd0f79 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -9190,6 +9190,53 @@ func TestPatchChatMessage(t *testing.T) { sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid model_config_id: provider is not enabled for this model.", sdkErr.Message) }) + + t.Run("ProviderDisabledPreservedModelRejected", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + defaultConfig := createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "hello before provider disable", + }}, + }) + require.NoError(t, err) + + messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + var userMessageID int64 + for _, message := range messagesResult.Messages { + if message.Role == codersdk.ChatMessageRoleUser { + userMessageID = message.ID + break + } + } + require.NotZero(t, userMessageID) + + _, err = client.UpdateAIProvider(ctx, defaultConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: ptr.Ref(false), + }) + require.NoError(t, err) + + // Editing without model_config_id preserves the edited + // message's original model. With that model's provider + // disabled and the default owned by the same provider, the + // fallback resolution must reject the edit with a 400. + _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "edited after provider disable", + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "No default chat model config is configured.", sdkErr.Message) + }) } func TestStreamChat(t *testing.T) { diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 506aa77f412..c27a440eeb7 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1710,6 +1710,23 @@ func (p *Server) EditMessage( ) } modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} + } else { + // Without an explicit override the transition preserves + // the edited message's original model, which may have + // been disabled since. Resolve it like the send-message + // fallback so a disabled model falls back to the default + // instead of being admitted and failing at generation. + preserved := uuid.Nil + if target.ModelConfigID.Valid { + preserved = target.ModelConfigID.UUID + } + resolved, err := resolveFallbackModelConfigID(ctx, store, preserved) + if err != nil { + return err + } + if resolved != preserved { + modelOverride = uuid.NullUUID{UUID: resolved, Valid: true} + } } var reasoningEffortOverride database.NullChatReasoningEffort From 7611114c6a1538befff4bab2b85ca7079307eb54 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:59:24 +0000 Subject: [PATCH 10/12] chore: remove redundant comments from disabled-provider changes --- coderd/exp_chats.go | 21 +++++-------------- coderd/exp_chats_test.go | 19 +++++++---------- coderd/x/chatd/chatd.go | 11 +++------- coderd/x/chatd/chatd_internal_test.go | 6 ++---- site/src/api/queries/chats.ts | 3 +-- .../CoderAgentsPageView.stories.tsx | 3 --- .../components/ModelForm.stories.tsx | 7 ++----- .../components/ModelFormProviderSelect.tsx | 8 +++---- .../AISettingsPage/ModelsPage/testFixtures.ts | 1 - .../AgentsPage/components/AdvisorSettings.tsx | 9 +++----- .../AgentsPage/utils/modelOptions.test.ts | 6 ++---- .../pages/AgentsPage/utils/modelOptions.ts | 16 ++++++-------- 12 files changed, 34 insertions(+), 76 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 28a5fe01142..dd210a8af79 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1193,11 +1193,9 @@ func (api *API) validateUserChatModelConfigAvailable( } } -// validateExplicitChatModelConfigAvailable rejects explicitly requested -// models the user cannot use (for example when the model or its provider -// is disabled) instead of admitting the request and failing later at -// runtime. A nil ID keeps the chat's current model and is validated by -// the daemon's fallback logic. +// validateExplicitChatModelConfigAvailable validates a caller-supplied +// model config ID. A nil ID keeps the chat's current model and is +// validated by the daemon's fallback resolution instead. func (api *API) validateExplicitChatModelConfigAvailable( ctx context.Context, userID uuid.UUID, @@ -3406,8 +3404,6 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { }) return } - // The fallback model resolution rejects a disabled default (or - // one under a disabled provider), mirroring chat creation. if xerrors.Is(sendErr, chatd.ErrNoDefaultChatModelConfig) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "No default chat model config is configured.", @@ -3573,9 +3569,6 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid model config ID.", }) - // The nil-override edit path resolves the preserved message - // model through the send-message fallback, which rejects a - // disabled default. case xerrors.Is(editErr, chatd.ErrNoDefaultChatModelConfig): httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "No default chat model config is configured.", @@ -4955,8 +4948,8 @@ func (api *API) defaultCreateChatModelConfigID( } } - // A default whose config or provider is disabled would only fail at - // generation time, so reject chat creation at admission instead. + // The resolved default may itself be disabled or under a disabled + // provider. if _, err := lookupEnabledChatModelConfigByID(ctx, api.Database, defaultModelConfig.ID); err != nil { if xerrors.Is(err, sql.ErrNoRows) { return uuid.Nil, http.StatusBadRequest, &codersdk.Response{ @@ -5761,10 +5754,6 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) { return } } else { - // Require the referenced model config and its provider to be - // enabled so a deployment-wide advisor override never points at a - // model that cannot serve requests. The lookup also validates any - // selected reasoning effort before persisting deployment config. modelConfig, err := lookupEnabledChatModelConfigByID(ctx, api.Database, req.ModelConfigID) if err != nil { if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) { diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 786aedd0f79..f09beefafbc 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -582,8 +582,7 @@ func TestPostChats(t *testing.T) { require.NoError(t, err) // Omitting model_config_id resolves the default model, whose - // provider is now disabled. Admission must reject the request - // instead of creating a chat that fails at generation time. + // provider is now disabled. _, err = client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, Content: []codersdk.ChatInputPart{{ @@ -7050,9 +7049,8 @@ func TestPostChatMessages(t *testing.T) { }) require.NoError(t, err) - // Without an explicit model the fallback resolution walks - // last model -> default, both owned by the now-disabled - // provider, and must surface a 400 instead of a generic 500. + // Without an explicit model the fallback walks last model -> + // default, both under the now-disabled provider. _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, @@ -9224,10 +9222,8 @@ func TestPatchChatMessage(t *testing.T) { }) require.NoError(t, err) - // Editing without model_config_id preserves the edited - // message's original model. With that model's provider - // disabled and the default owned by the same provider, the - // fallback resolution must reject the edit with a 400. + // Editing without model_config_id preserves the edited message's + // original model; its provider and the default's are now disabled. _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, @@ -12163,9 +12159,8 @@ func createDisabledChatModelConfig( return updated } -// createProviderDisabledChatModelConfig creates an enabled model config and -// then disables its parent AI provider, leaving a config that is enabled at -// the config level but unusable because of provider state. +// createProviderDisabledChatModelConfig creates an enabled model config, +// then disables its parent AI provider. func createProviderDisabledChatModelConfig( t *testing.T, client *codersdk.ExperimentalClient, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index c27a440eeb7..5a5713b4c4a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1598,8 +1598,6 @@ func resolveFallbackModelConfigID( err, ) } - // The chat's last model or its provider was disabled or - // deleted, fall through to the default. } defaultConfig, err := store.GetDefaultChatModelConfig(chatdCtx) @@ -1609,8 +1607,7 @@ func resolveFallbackModelConfigID( } return uuid.Nil, xerrors.Errorf("get default chat model config: %w", err) } - // A disabled default (or one under a disabled provider) would only - // fail later at generation time, so reject it at admission instead. + // The default may itself be disabled or under a disabled provider. if _, err := store.GetEnabledChatModelConfigByID(chatdCtx, defaultConfig.ID); err != nil { if errors.Is(err, sql.ErrNoRows) { return uuid.Nil, xerrors.Errorf( @@ -1712,10 +1709,8 @@ func (p *Server) EditMessage( modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} } else { // Without an explicit override the transition preserves - // the edited message's original model, which may have - // been disabled since. Resolve it like the send-message - // fallback so a disabled model falls back to the default - // instead of being admitted and failing at generation. + // the edited message's original model, which may have been + // disabled since; resolve it like a normal message send. preserved := uuid.Nil if target.ModelConfigID.Valid { preserved = target.ModelConfigID.UUID diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index cdb70c0f380..3216058bcc4 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3484,10 +3484,8 @@ func TestPrepareManualTitleDebugRun_RouteFailureDerivesProviderFromConfig(t *tes require.Equal(t, "anthropic", gotProvider.String) } -// TestResolveFallbackModelConfigID verifies that message admission does -// not reuse a chat's last model when that model or its provider has been -// disabled, and that a disabled default is rejected instead of being -// admitted and failing later at generation time. +// TestResolveFallbackModelConfigID verifies that admission does not reuse +// a disabled last model and rejects a disabled default. func TestResolveFallbackModelConfigID(t *testing.T) { t.Parallel() diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 8a9e045bb4a..f126cbbb6cf 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1873,8 +1873,7 @@ const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => { ]); }; -// Refreshes every chat query derived from AI provider state so provider -// mutations (enable/disable/delete) update open model pickers in-session. +// Called after AI provider mutations so open model pickers refresh. export const invalidateChatProviderDependentQueries = async ( queryClient: QueryClient, ) => { diff --git a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx index 52e93e8ee05..c70db40d48a 100644 --- a/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/CoderAgentsPage/CoderAgentsPageView.stories.tsx @@ -707,8 +707,6 @@ export const DisabledProviderModelsHidden: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); - // The subagent override selectors must not offer the enabled config - // whose provider row is disabled. const generalSection = await getSection(canvasElement, "General model"); const generalTrigger = within(generalSection).getByRole("combobox", { name: "Use chat default", @@ -722,7 +720,6 @@ export const DisabledProviderModelsHidden: Story = { ).not.toBeInTheDocument(); await userEvent.keyboard("{Escape}"); - // The advisor model select must hide it as well. const advisorSection = await getSection(canvasElement, "Advisor"); const advisorTrigger = within(advisorSection).getByRole("combobox", { name: "Use chat model", diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index 1c2d8bdfc3c..5ae23484ad4 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -157,9 +157,8 @@ export const AddBlocksDisabledSelectedProvider: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // A ?provider= query param can select a disabled provider on the - // add page. The form must not render as submittable and the - // disabled provider must not stay selectable. + // A ?provider= query param can preselect a disabled provider on + // the add page. await expect( canvas.getByText(/OpenAI Secondary is disabled/), ).toBeInTheDocument(); @@ -183,8 +182,6 @@ export const EditKeepsDisabledProviderVisible: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Editing a config whose provider was disabled afterwards keeps the - // provider rendered so the form stays usable. await expect( canvas.getByRole("combobox", { name: /provider/i }), ).toHaveTextContent("OpenAI Secondary"); diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx index 7f57489d147..a5375567dbe 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormProviderSelect.tsx @@ -23,11 +23,9 @@ export const ModelFormProviderSelect: FC<{ disabled, isEditing, }) => { - // Hide disabled providers: the backend rejects creating model configs - // under them. When editing, keep the currently selected provider - // visible so a config whose provider was disabled afterwards still - // renders. On the add path the selection can come from a ?provider= - // query param, so a disabled provider must not stay selectable. + // Hide disabled providers; the backend rejects new model configs under + // them. When editing, keep the selected provider visible so a config + // whose provider was disabled afterwards still renders. const selectableProviderStates = providerStates.filter( (ps) => ps.providerConfig?.enabled !== false || diff --git a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts index 6ae471d12ca..cce25d99030 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts +++ b/site/src/pages/AISettingsPage/ModelsPage/testFixtures.ts @@ -113,7 +113,6 @@ const MockDisabledProviderConfig: ChatProviderConfig = { enabled: false, }; -// Enabled model config under a disabled provider. export const mockProviderDisabledModel: ChatModelConfig = { ...mockGPT5, id: "model-provider-disabled", diff --git a/site/src/pages/AgentsPage/components/AdvisorSettings.tsx b/site/src/pages/AgentsPage/components/AdvisorSettings.tsx index 067f49a8bb2..15ad90366ae 100644 --- a/site/src/pages/AgentsPage/components/AdvisorSettings.tsx +++ b/site/src/pages/AgentsPage/components/AdvisorSettings.tsx @@ -26,7 +26,6 @@ interface AdvisorSettingsProps { isAdvisorConfigLoading: boolean; isAdvisorConfigFetching: boolean; isAdvisorConfigLoadError: boolean; - // Subset of modelConfigs whose config and provider are both enabled. enabledModelConfigs: readonly ChatModelConfig[]; providerInfoByID: ReadonlyMap; modelConfigsError: unknown; @@ -162,11 +161,9 @@ export const AdvisorSettings: FC = ({ validate: validateAdvisorConfig, onSubmit: (values, { resetForm }) => { // If the last committed model override references a model config - // that no longer exists or is no longer enabled (including a - // disabled provider), the backend rejects the stale ID with a - // 400. Clear the override so a save stays reliable; the runtime - // already ignores unavailable overrides and falls back to the - // chat model. Only scrub when model configs have loaded + // that is no longer available, the backend rejects the stale ID + // with a 400. Clear the override so a save stays reliable in + // that edge case. Only scrub when model configs have loaded // successfully and no refetch is in flight. let source = values; if ( diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index 265f360af3f..df143b2ee76 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -698,7 +698,6 @@ describe("getModelOptionsFromConfigs", () => { { provider: "openai", available: true, models: [] }, ]); - // providerInfoByID entries here carry no enabled flag. expect( getModelOptionsFromConfigs(configs, catalog, providerInfoByID).map( (option) => option.id, @@ -707,9 +706,8 @@ describe("getModelOptionsFromConfigs", () => { }); it("excludes only the disabled instance for same-type providers", () => { - // Two provider rows of the same type: the catalog marks the type as - // available (because of the enabled instance), so only the - // per-instance enabled flag can exclude the disabled one. + // The catalog marks the type as available because of the enabled + // instance, so only the per-row flag can exclude the disabled one. const configs = [ createConfig({ id: "config-primary", diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index c87f08f4aa3..06fd85fdc73 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -162,8 +162,7 @@ export type ProviderInfo = { readonly provider: string; readonly displayName: string; readonly icon: string; - // Whether the provider row is enabled. Undefined (sources without - // provider state) is treated as enabled for backwards compatibility. + // Absent is treated as enabled. readonly enabled?: boolean; }; @@ -229,11 +228,9 @@ export const providerTypeByIDFromUserConfigs = ( ); /** - * Drops model configs whose provider row is disabled or missing from the - * map. Both provider-info sources (the admin provider list and the user - * provider key configs) always include every enabled provider, so a - * missing row means the provider is disabled or deleted. Provider rows - * lacking an enabled flag are kept for backward compatibility. + * Drops model configs whose provider row is disabled or missing. Both + * provider-info sources include every enabled provider, so a missing row + * means the provider is disabled or deleted. */ export const filterConfigsWithEnabledProvider = ( configs: readonly TypesGen.ChatModelConfig[], @@ -256,9 +253,8 @@ export const getModelOptionsFromConfigs = ( const availableProviders = getAvailableProviders(catalog); const options: ModelSelectorOption[] = []; - // Skip models whose provider row is disabled or unknown. The catalog - // check below is keyed by provider type, so it cannot exclude a - // disabled instance when another provider of the same type is enabled. + // The catalog check below is keyed by provider type, so it cannot + // exclude a disabled provider when another of the same type is enabled. for (const config of filterConfigsWithEnabledProvider( configs, providerInfoByID, From b70bd3705c5daf5c70c886326911d9bdd06c0aec Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:47:41 +0000 Subject: [PATCH 11/12] fix(coderd): recheck disabled models in chatd and default reselection --- coderd/exp_chats.go | 33 +++++++++++++++++++--- coderd/exp_chats_test.go | 40 +++++++++++++++++++++++++++ coderd/x/chatd/chatd.go | 9 ++++-- coderd/x/chatd/chatd_internal_test.go | 27 ++++++++++++++++++ 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index dd210a8af79..2b47d32c567 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7671,7 +7671,20 @@ func ensureDefaultChatModelConfig( return nil } - candidateConfig := modelConfigs[0] + // Prefer a config that can actually serve requests (enabled, under an + // enabled provider) so the promoted default does not reject + // omitted-model chat creation. Fall back to any non-excluded config + // when no usable candidate exists. + //nolint:gocritic // Candidate usability depends on deployment-wide provider state, not the caller's permissions. + enabledRows, err := tx.GetEnabledChatModelConfigs(dbauthz.AsChatd(ctx)) + if err != nil { + return xerrors.Errorf("list enabled chat model configs: %w", err) + } + usable := make(map[uuid.UUID]struct{}, len(enabledRows)) + for _, row := range enabledRows { + usable[row.ChatModelConfig.ID] = struct{}{} + } + excluded := make(map[uuid.UUID]struct{}, len(excludedConfigIDs)) for _, configID := range excludedConfigIDs { if configID == uuid.Nil { @@ -7679,12 +7692,24 @@ func ensureDefaultChatModelConfig( } excluded[configID] = struct{}{} } - for _, config := range modelConfigs { + + candidateConfig := modelConfigs[0] + var selected *database.ChatModelConfig + for i := range modelConfigs { + config := &modelConfigs[i] if _, skip := excluded[config.ID]; skip { continue } - candidateConfig = config - break + if selected == nil { + selected = config + } + if _, ok := usable[config.ID]; ok { + selected = config + break + } + } + if selected != nil { + candidateConfig = *selected } if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil { diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index f09beefafbc..d8beed57310 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -4957,6 +4957,46 @@ func TestDeleteChatModelConfig(t *testing.T) { } }) + // Deleting the default must not promote a config whose provider is + // disabled while a usable candidate exists. + t.Run("PromotesUsableConfigOverDisabledProvider", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + + defaultConfig := createChatModelConfig(t, client) + // Same provider type as the enabled candidate with an + // alphabetically earlier model, so it sorts first in the + // reselection order. + createProviderDisabledChatModelConfig( + t, + client, + coderdtest.TestChatProviderOpenAICompat, + "a-provider-disabled-model", + ) + enabledConfig := createAdditionalChatModelConfig( + t, + client, + coderdtest.TestChatProviderOpenAICompat, + "z-enabled-model", + ) + + err := client.DeleteChatModelConfig(ctx, defaultConfig.ID) + require.NoError(t, err) + + configs, err := client.ListChatModelConfigs(ctx) + require.NoError(t, err) + defaultID := uuid.Nil + for _, config := range configs { + if config.IsDefault { + defaultID = config.ID + } + } + require.Equal(t, enabledConfig.ID, defaultID) + }) + t.Run("NotFound", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 5a5713b4c4a..e89a3e476cd 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1120,7 +1120,8 @@ func (c *turnWorkspaceContext) getWorkspaceConn(ctx context.Context) (workspaces type AgentConnFunc func(ctx context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) var ( - // ErrInvalidModelConfigID indicates the requested model config does not exist. + // ErrInvalidModelConfigID indicates the requested model config does not + // exist, is disabled, or its provider is disabled. ErrInvalidModelConfigID = xerrors.New("invalid model config ID") // ErrEditedMessageNotFound indicates the edited message does not exist // in the target chat. @@ -1564,8 +1565,10 @@ func resolveSendMessageModelConfigID( return resolveFallbackModelConfigID(ctx, store, chat.LastModelConfigID) } + // Recheck enabled state inside the daemon: the coderd preflight can + // race an admin disabling the model or provider. chatdCtx := chatdModelConfigLookupContext(ctx) - if _, err := store.GetChatModelConfigByID(chatdCtx, requested); err != nil { + if _, err := store.GetEnabledChatModelConfigByID(chatdCtx, requested); err != nil { if errors.Is(err, sql.ErrNoRows) { return uuid.Nil, xerrors.Errorf( "%w: %s", @@ -1689,7 +1692,7 @@ func (p *Server) EditMessage( // foreign-key error from the message-insert path. var modelOverride uuid.NullUUID if opts.ModelConfigID != uuid.Nil { - if _, err := store.GetChatModelConfigByID( + if _, err := store.GetEnabledChatModelConfigByID( chatdModelConfigLookupContext(ctx), opts.ModelConfigID, ); err != nil { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 3216058bcc4..2dab4eced04 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3554,4 +3554,31 @@ func TestResolveFallbackModelConfigID(t *testing.T) { _, err := resolveFallbackModelConfigID(ctx, db, lastModel.ID) require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) }) + + t.Run("ExplicitEnabledModel", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + provider := newProvider(t, db, true) + model := newModelConfig(t, db, provider.ID, false) + + resolved, err := resolveSendMessageModelConfigID(ctx, db, database.Chat{}, model.ID) + require.NoError(t, err) + require.Equal(t, model.ID, resolved) + }) + + // An explicit model whose provider was disabled after the coderd + // preflight must still be rejected inside the daemon. + t.Run("ExplicitProviderDisabledRejected", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + disabledProvider := newProvider(t, db, false) + model := newModelConfig(t, db, disabledProvider.ID, false) + + _, err := resolveSendMessageModelConfigID(ctx, db, database.Chat{}, model.ID) + require.ErrorIs(t, err, ErrInvalidModelConfigID) + }) } From 311e96affeb743d5b9427e9439f304beebc4e7ba Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:02:32 +0000 Subject: [PATCH 12/12] fix(coderd): recheck disabled models on the chatd create path --- coderd/exp_chats.go | 7 ++++ coderd/x/chatd/chatd.go | 51 ++++++++++++++------------- coderd/x/chatd/chatd_internal_test.go | 24 +++++++++++++ 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 2b47d32c567..56d0337da0d 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1446,6 +1446,13 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { if maybeWriteLimitErr(ctx, rw, err) { return } + if xerrors.Is(err, chatd.ErrInvalidModelConfigID) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid model config ID.", + Detail: err.Error(), + }) + return + } if database.IsForeignKeyViolation( err, database.ForeignKeyChatsLastModelConfigID, diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index e89a3e476cd..1bb1209c1ba 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1342,6 +1342,12 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, opts.ModelConfigID)) initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, opts.ModelConfigID, opts.OwnerID, opts.APIKeyID, opts.ReasoningEffort)) + if opts.ModelConfigID != uuid.Nil { + if err := requireEnabledChatModelConfig(ctx, p.db, opts.ModelConfigID); err != nil { + return database.Chat{}, err + } + } + result, err := chatstate.CreateChat(ctx, p.db, p.pubsub, chatstate.CreateChatInput{ OrganizationID: opts.OrganizationID, OwnerID: opts.OwnerID, @@ -1565,24 +1571,35 @@ func resolveSendMessageModelConfigID( return resolveFallbackModelConfigID(ctx, store, chat.LastModelConfigID) } - // Recheck enabled state inside the daemon: the coderd preflight can - // race an admin disabling the model or provider. + if err := requireEnabledChatModelConfig(ctx, store, requested); err != nil { + return uuid.Nil, err + } + return requested, nil +} + +// requireEnabledChatModelConfig rechecks enabled state inside the daemon: +// the coderd preflight can race an admin disabling the model or provider. +func requireEnabledChatModelConfig( + ctx context.Context, + store database.Store, + modelConfigID uuid.UUID, +) error { chatdCtx := chatdModelConfigLookupContext(ctx) - if _, err := store.GetEnabledChatModelConfigByID(chatdCtx, requested); err != nil { + if _, err := store.GetEnabledChatModelConfigByID(chatdCtx, modelConfigID); err != nil { if errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, xerrors.Errorf( + return xerrors.Errorf( "%w: %s", ErrInvalidModelConfigID, - requested, + modelConfigID, ) } - return uuid.Nil, xerrors.Errorf( + return xerrors.Errorf( "get requested model config %s: %w", - requested, + modelConfigID, err, ) } - return requested, nil + return nil } func resolveFallbackModelConfigID( @@ -1692,22 +1709,8 @@ func (p *Server) EditMessage( // foreign-key error from the message-insert path. var modelOverride uuid.NullUUID if opts.ModelConfigID != uuid.Nil { - if _, err := store.GetEnabledChatModelConfigByID( - chatdModelConfigLookupContext(ctx), - opts.ModelConfigID, - ); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return xerrors.Errorf( - "%w: %s", - ErrInvalidModelConfigID, - opts.ModelConfigID, - ) - } - return xerrors.Errorf( - "get requested model config %s: %w", - opts.ModelConfigID, - err, - ) + if err := requireEnabledChatModelConfig(ctx, store, opts.ModelConfigID); err != nil { + return err } modelOverride = uuid.NullUUID{UUID: opts.ModelConfigID, Valid: true} } else { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 2dab4eced04..a2d72ac069a 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3581,4 +3581,28 @@ func TestResolveFallbackModelConfigID(t *testing.T) { _, err := resolveSendMessageModelConfigID(ctx, db, database.Chat{}, model.ID) require.ErrorIs(t, err, ErrInvalidModelConfigID) }) + + // The create path performs the same daemon-side recheck before + // inserting the chat and its initial messages. + t.Run("CreateChatProviderDisabledRejected", func(t *testing.T) { + t.Parallel() + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + disabledProvider := newProvider(t, db, false) + model := newModelConfig(t, db, disabledProvider.ID, false) + server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) + + _, err := server.CreateChat(ctx, CreateOptions{ + OrganizationID: uuid.New(), + OwnerID: uuid.New(), + Title: "provider disabled create", + ModelConfigID: model.ID, + APIKeyID: "test-api-key-id", + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("hello"), + }, + }) + require.ErrorIs(t, err, ErrInvalidModelConfigID) + }) }