Files
coder/coderd/x/chatd/compaction_override.go
T
Michael SuchaczandEthan Dickson 0ef331fcd0 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>
2026-08-24 11:20:26 +01:00

104 lines
3.3 KiB
Go

package chatd
import (
"context"
"database/sql"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
)
const compactionOverrideContext = "compaction"
func readCompactionModelOverride(
ctx context.Context,
db database.Store,
organizationID uuid.UUID,
) (database.ChatOrganizationModelOverride, error) {
//nolint:gocritic // Chatd is internal, not a user, so this read uses AsChatd.
chatdCtx := dbauthz.AsChatd(ctx)
override, err := db.GetChatOrganizationModelOverride(chatdCtx, database.GetChatOrganizationModelOverrideParams{
OrganizationID: organizationID,
Context: compactionOverrideContext,
})
if err != nil {
return database.ChatOrganizationModelOverride{}, xerrors.Errorf(
"get chat compaction model override: %w",
err,
)
}
return override, nil
}
// resolvedCompactionOverride is the compaction override resolved at
// prepare time. The provider/model identity is resolved without building
// the model client so metrics recorded before the client exists
// (still-over-limit) attribute to the same model as the compact action's.
type resolvedCompactionOverride struct {
Config database.ChatModelConfig
// ReasoningEffort is the override's requested effort, passed to the
// model-call resolver when the compact action builds the client.
ReasoningEffort *string
ResolvedProvider string
ResolvedModel string
}
// resolveCompactionOverrideConfig resolves the stored organization-scoped
// compaction model override. Unset, stale, and credential-less
// overrides fall back to the chat model (nil override). This runs on every
// generation prepare because the override's context limit feeds the
// compaction trigger; the model client is built only when compaction runs.
func (p *Server) resolveCompactionOverrideConfig(
ctx context.Context,
chat database.Chat,
) (*resolvedCompactionOverride, error) {
override, err := readCompactionModelOverride(ctx, p.db, chat.OrganizationID)
if err != nil {
if xerrors.Is(err, sql.ErrNoRows) {
return nil, nil //nolint:nilnil // No row means use the chat model.
}
return nil, xerrors.Errorf(
"read compaction model override: %w",
err,
)
}
modelConfig, providerName, overrideEffort, overrideSet, err := p.resolveOrganizationModelOverride(
ctx,
compactionOverrideContext,
override,
chat.OwnerID,
func(ctx context.Context, modelConfigID uuid.UUID) (database.ChatModelConfig, string, error) {
return p.resolveModelConfigAndNormalizedProvider(ctx, chat.OwnerID, modelConfigID)
},
func(ctx context.Context, ownerID uuid.UUID, aiProviderID uuid.UUID) (chatprovider.ProviderAPIKeys, error) {
return p.resolveUserProviderAPIKeys(ctx, ownerID, aiProviderID)
},
modelOverrideFailureModeSoft,
)
if err != nil || !overrideSet {
return nil, err
}
// Already validated by the shared resolver; failure is unreachable.
resolvedProvider, resolvedModel, err := chatprovider.ResolveModelWithProviderHint(
modelConfig.Model,
providerName,
)
if err != nil {
return nil, xerrors.Errorf(
"resolve compaction model override identity: %w",
err,
)
}
return &resolvedCompactionOverride{
Config: modelConfig,
ReasoningEffort: overrideEffort,
ResolvedProvider: resolvedProvider,
ResolvedModel: resolvedModel,
}, nil
}