feat!: scope chat model override settings to organizations (#28442)

Moves chat model override settings from deployment scope to organization
scope, following the org-scoping of chat model configs in #28440.

Depends on #28440 (based on
`ethan/chat-models-availability-consolidation`).

Remote dogfood UAT passed on this change, including multi-organization
scenarios on a licensed deployment with real models.

## Problem

Chat model configs are now owned by organizations, but the override
settings that reference them stayed deployment-wide: admin
subagent/title/compaction overrides in `site_configs`, personal
overrides in `user_configs`, and the advisor model embedded in the
advisor runtime JSON. A single deployment-level override can only point
at one organization's model config, so overrides effectively worked for
the default organization only, and title generation had to soften its
hard-failure contract to tolerate unusable overrides.

## Fix

Admin overrides (general, explore, title generation, compaction,
advisor) and personal overrides (root, general, explore) become
organization-scoped rows in two new typed tables,
`chat_organization_model_overrides` and `chat_user_model_overrides`.
Composite foreign keys on `(organization_id, model_config_id)` make
cross-organization model references unrepresentable. The migration
deletes the legacy serialized `site_configs`/`user_configs` override
keys instead of migrating them; admins and users re-select models in the
new organization-scoped settings.

New endpoints live under
`/api/experimental/organizations/{organization}/chats/model-overrides`
(admin defaults) and
`/api/experimental/organizations/{organization}/members/{user}/chats/model-overrides`
(personal overrides); the legacy deployment-level routes are removed.
chatd resolves overrides via the chat's organization, restoring the
hard-failure contract for configured-but-unusable title generation
overrides. The deployment-level `AllowUsers` switch and advisor runtime
limits stay deployment-scoped.

The Coder Agents settings UI gains a per-organization "Defaults &
overrides" page under AI Settings > Models, and the personal overrides
page gains an organization selector for users in multiple organizations.
Advisor telemetry now reports per-organization override rows.

**BREAKING CHANGE**: Existing deployment-level overrides are dropped,
not migrated. Every organization starts with no overrides after upgrade,
and model selections must be re-applied in the new settings. The legacy
deployment-level override endpoints are removed.

Closes https://linear.app/coder/issue/CODAGT-872

> [!NOTE]
> Xum (AI agent) authored this PR on behalf of @ibetitsmike.

<!-- xum-attribution: model=claude-opus-4-6 thinking=high -->

---------

Co-authored-by: Ethan Dickson <ethan@coder.com>
This commit is contained in:
Michael Suchacz
2026-08-24 11:20:26 +01:00
committed by GitHub
co-authored by Ethan Dickson
parent 3809314b31
commit 0ef331fcd0
75 changed files with 4800 additions and 4952 deletions
+79
View File
@@ -6491,6 +6491,85 @@ func TestGetUserStatusCounts(t *testing.T) {
}
}
func TestGetChatOrganizationModelOverridesByContext(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
availableOrg := dbgen.Organization(t, db, database.Organization{})
availableModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "bulk-available-" + uuid.NewString(),
Enabled: true,
OrganizationID: availableOrg.ID,
})
require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{
OrganizationID: availableOrg.ID,
Context: "general",
ModelConfigID: availableModel.ID,
}))
// An override referencing a disabled model reports the model unavailable.
// dbgen coerces Enabled=false to true, so disable it after insertion.
disabledOrg := dbgen.Organization(t, db, database.Organization{})
disabledModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "bulk-disabled-" + uuid.NewString(),
OrganizationID: disabledOrg.ID,
})
_, err := db.UpdateChatModelConfig(ctx, database.UpdateChatModelConfigParams{
ID: disabledModel.ID,
Model: disabledModel.Model,
DisplayName: disabledModel.DisplayName,
Enabled: false,
IsDefault: disabledModel.IsDefault,
ContextLimit: disabledModel.ContextLimit,
CompressionThreshold: disabledModel.CompressionThreshold,
Options: disabledModel.Options,
AIProviderID: disabledModel.AIProviderID,
})
require.NoError(t, err)
require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{
OrganizationID: disabledOrg.ID,
Context: "general",
ModelConfigID: disabledModel.ID,
}))
// Deleted organizations are excluded from the bulk read.
deletedOrg := dbgen.Organization(t, db, database.Organization{})
deletedOrgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Model: "bulk-deleted-org-" + uuid.NewString(),
Enabled: true,
OrganizationID: deletedOrg.ID,
})
require.NoError(t, db.UpsertChatOrganizationModelOverride(ctx, database.UpsertChatOrganizationModelOverrideParams{
OrganizationID: deletedOrg.ID,
Context: "general",
ModelConfigID: deletedOrgModel.ID,
}))
require.NoError(t, db.UpdateOrganizationDeletedByID(ctx, database.UpdateOrganizationDeletedByIDParams{
ID: deletedOrg.ID,
UpdatedAt: dbtime.Now(),
}))
rows, err := db.GetChatOrganizationModelOverridesByContext(ctx, "general")
require.NoError(t, err)
require.Len(t, rows, 2)
byOrg := make(map[uuid.UUID]database.GetChatOrganizationModelOverridesByContextRow, len(rows))
for _, row := range rows {
byOrg[row.OrganizationID] = row
}
available := byOrg[availableOrg.ID]
require.True(t, available.ModelAvailable)
require.Equal(t, availableModel.Model, available.Model)
require.NotEmpty(t, available.ProviderType)
unavailable := byOrg[disabledOrg.ID]
require.False(t, unavailable.ModelAvailable)
require.Empty(t, unavailable.Model)
otherContext, err := db.GetChatOrganizationModelOverridesByContext(ctx, "advisor")
require.NoError(t, err)
require.Empty(t, otherContext)
}
func TestOrganizationDeleteTrigger(t *testing.T) {
t.Parallel()