mirror of
https://github.com/coder/coder.git
synced 2026-11-03 14:21:04 +08:00
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:
co-authored by
Ethan Dickson
parent
3809314b31
commit
0ef331fcd0
@@ -0,0 +1,5 @@
|
||||
DROP TABLE chat_user_model_overrides;
|
||||
DROP TABLE chat_organization_model_overrides;
|
||||
|
||||
ALTER TABLE chat_model_configs
|
||||
DROP CONSTRAINT chat_model_configs_organization_id_id_key;
|
||||
@@ -0,0 +1,54 @@
|
||||
ALTER TABLE chat_model_configs
|
||||
ADD CONSTRAINT chat_model_configs_organization_id_id_key
|
||||
UNIQUE (organization_id, id);
|
||||
|
||||
CREATE TABLE chat_organization_model_overrides (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
context text NOT NULL,
|
||||
model_config_id uuid NOT NULL,
|
||||
reasoning_effort text,
|
||||
CONSTRAINT chat_organization_model_overrides_context_check
|
||||
CHECK (context IN ('general', 'explore', 'title_generation', 'compaction', 'advisor')),
|
||||
CONSTRAINT chat_organization_model_overrides_organization_id_context_key
|
||||
UNIQUE (organization_id, context),
|
||||
CONSTRAINT chat_organization_model_overrides_organization_model_config_fkey
|
||||
FOREIGN KEY (organization_id, model_config_id)
|
||||
REFERENCES chat_model_configs (organization_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE chat_user_model_overrides (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
context text NOT NULL,
|
||||
mode text NOT NULL,
|
||||
model_config_id uuid,
|
||||
reasoning_effort text,
|
||||
CONSTRAINT chat_user_model_overrides_context_check
|
||||
CHECK (context IN ('root', 'general', 'explore')),
|
||||
CONSTRAINT chat_user_model_overrides_mode_check
|
||||
CHECK (mode IN ('model', 'chat_default', 'deployment_default')),
|
||||
CONSTRAINT chat_user_model_overrides_model_requires_config_check
|
||||
CHECK ((mode = 'model') = (model_config_id IS NOT NULL)),
|
||||
CONSTRAINT chat_user_model_overrides_user_organization_context_key
|
||||
UNIQUE (user_id, organization_id, context),
|
||||
CONSTRAINT chat_user_model_overrides_organization_model_config_fkey
|
||||
FOREIGN KEY (organization_id, model_config_id)
|
||||
REFERENCES chat_model_configs (organization_id, id)
|
||||
);
|
||||
|
||||
-- Legacy serialized overrides are dropped rather than migrated; admins and
|
||||
-- users re-select models in the organization-scoped settings. Stale advisor
|
||||
-- model fields inside agents_advisor_config are left as-is: reads ignore
|
||||
-- unknown JSON keys and the next settings write rewrites the blob.
|
||||
DELETE FROM site_configs
|
||||
WHERE key IN (
|
||||
'agents_chat_general_model_override',
|
||||
'agents_chat_explore_model_override',
|
||||
'agents_chat_title_generation_model_override',
|
||||
'agents_chat_compaction_model_override'
|
||||
);
|
||||
|
||||
DELETE FROM user_configs
|
||||
WHERE key LIKE 'chat\_personal\_model\_override:%';
|
||||
@@ -3255,3 +3255,131 @@ func mustJSON(t *testing.T, v any) []byte {
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestMigration000583ChatModelOverrideOrgScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const migrationVersion = 583
|
||||
|
||||
db := testSQLDB(t)
|
||||
next, err := migrations.Stepper(db)
|
||||
require.NoError(t, err)
|
||||
last := uint(0)
|
||||
for {
|
||||
version, more, err := next()
|
||||
require.NoError(t, err)
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
last = version
|
||||
}
|
||||
require.GreaterOrEqual(t, last, uint(migrationVersion))
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitSuperLong)
|
||||
upSQL, err := os.ReadFile("000583_chat_model_override_org_scope.up.sql")
|
||||
require.NoError(t, err)
|
||||
downSQL, err := os.ReadFile("000583_chat_model_override_org_scope.down.sql")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.ExecContext(ctx, string(downSQL))
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
userID := uuid.New()
|
||||
providerID := uuid.New()
|
||||
modelID := uuid.New()
|
||||
|
||||
var orgID uuid.UUID
|
||||
require.NoError(t, db.QueryRowContext(ctx,
|
||||
"SELECT id FROM organizations WHERE is_default = true").Scan(&orgID))
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $5, 'active', '{}', 'password')`,
|
||||
userID, "model-override-"+userID.String(), userID.String()+"@example.com", []byte{}, now)
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO ai_providers (id, type, name, enabled, base_url, created_at, updated_at)
|
||||
VALUES ($1, 'openai', $2, true, 'https://example.com', $3, $3)`,
|
||||
providerID, "model-override-"+providerID.String(), now)
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO chat_model_configs (
|
||||
id, model, display_name, enabled, is_default, deleted,
|
||||
context_limit, compression_threshold, ai_provider_id, organization_id,
|
||||
group_acl, user_acl, created_at, updated_at
|
||||
) VALUES ($1, $2, $2, true, false, false, 128000, 70, $3, $4, '{}', '{}', $5, $5)`,
|
||||
modelID, "model-override-"+modelID.String(), providerID, orgID, now)
|
||||
require.NoError(t, err)
|
||||
|
||||
advisorConfig := fmt.Sprintf(
|
||||
`{"enabled":true,"max_uses_per_run":2,"model_config_id":%q,"reasoning_effort":"low"}`, modelID)
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO site_configs (key, value) VALUES
|
||||
('agents_chat_general_model_override', $1),
|
||||
('agents_chat_title_generation_model_override', 'not-a-uuid'),
|
||||
('agents_advisor_config', $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
modelID.String()+":high", advisorConfig)
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO user_configs (user_id, key, value) VALUES
|
||||
($1, 'chat_personal_model_override:root', $2),
|
||||
($1, 'chat_personal_model_override:general', 'chat_default')`,
|
||||
userID, "model:"+modelID.String()+":max")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.ExecContext(ctx, string(upSQL))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Legacy overrides are dropped rather than migrated: the new tables start
|
||||
// empty and the serialized keys are gone.
|
||||
var count int
|
||||
require.NoError(t, db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM chat_organization_model_overrides").Scan(&count))
|
||||
require.Zero(t, count)
|
||||
require.NoError(t, db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM chat_user_model_overrides").Scan(&count))
|
||||
require.Zero(t, count)
|
||||
require.NoError(t, db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM site_configs
|
||||
WHERE key IN (
|
||||
'agents_chat_general_model_override',
|
||||
'agents_chat_explore_model_override',
|
||||
'agents_chat_title_generation_model_override',
|
||||
'agents_chat_compaction_model_override'
|
||||
)`).Scan(&count))
|
||||
require.Zero(t, count)
|
||||
require.NoError(t, db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM user_configs
|
||||
WHERE user_id = $1 AND key LIKE 'chat\_personal\_model\_override:%'`, userID).Scan(&count))
|
||||
require.Zero(t, count)
|
||||
|
||||
// The migration does not parse the advisor runtime config; stale model
|
||||
// fields stay in the stored JSON and are ignored by readers.
|
||||
var value string
|
||||
require.NoError(t, db.QueryRowContext(ctx,
|
||||
"SELECT value FROM site_configs WHERE key = 'agents_advisor_config'").Scan(&value))
|
||||
require.Equal(t, advisorConfig, value)
|
||||
|
||||
// The composite foreign key rejects model references from another
|
||||
// organization.
|
||||
otherOrgID := uuid.New()
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO organizations (
|
||||
id, name, display_name, description, created_at, updated_at,
|
||||
is_default, default_org_member_roles
|
||||
) VALUES ($1, $2, $2, '', $3, $3, false, '{}')`,
|
||||
otherOrgID, "model-override-"+otherOrgID.String(), now)
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO chat_organization_model_overrides (organization_id, context, model_config_id)
|
||||
VALUES ($1, 'explore', $2)`, otherOrgID, modelID)
|
||||
require.Error(t, err)
|
||||
require.True(t, database.IsForeignKeyViolation(err))
|
||||
|
||||
_, err = db.ExecContext(ctx, string(downSQL))
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, string(upSQL))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
INSERT INTO chat_organization_model_overrides (
|
||||
organization_id,
|
||||
context,
|
||||
model_config_id,
|
||||
reasoning_effort
|
||||
)
|
||||
SELECT
|
||||
organization_id,
|
||||
'general',
|
||||
id,
|
||||
'high'
|
||||
FROM chat_model_configs
|
||||
WHERE id = '580c0001-0000-4000-8000-000000000001';
|
||||
|
||||
INSERT INTO chat_user_model_overrides (
|
||||
user_id,
|
||||
organization_id,
|
||||
context,
|
||||
mode
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
o.id,
|
||||
'root',
|
||||
'chat_default'
|
||||
FROM users u
|
||||
CROSS JOIN organizations o
|
||||
WHERE o.is_default
|
||||
ORDER BY u.id
|
||||
LIMIT 1;
|
||||
Reference in New Issue
Block a user