mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
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>
196 lines
7.7 KiB
Go
196 lines
7.7 KiB
Go
package chatd
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/mock/gomock"
|
|
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func compactionOverrideParams(chat database.Chat) database.GetChatOrganizationModelOverrideParams {
|
|
return database.GetChatOrganizationModelOverrideParams{
|
|
OrganizationID: chat.OrganizationID,
|
|
Context: compactionOverrideContext,
|
|
}
|
|
}
|
|
|
|
func orgModelOverride(chat database.Chat, context string, modelConfigID uuid.UUID, effort string) database.ChatOrganizationModelOverride {
|
|
override := database.ChatOrganizationModelOverride{
|
|
OrganizationID: chat.OrganizationID,
|
|
Context: context,
|
|
ModelConfigID: modelConfigID,
|
|
}
|
|
if effort != "" {
|
|
override.ReasoningEffort = sql.NullString{String: effort, Valid: true}
|
|
}
|
|
return override
|
|
}
|
|
|
|
func TestResolveCompactionOverrideConfig_Unset(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
chat, _ := titleOverrideTestChatAndMessages(t)
|
|
|
|
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows)
|
|
|
|
server := titleOverrideTestServer(db, logger)
|
|
override, err := server.resolveCompactionOverrideConfig(ctx, chat)
|
|
require.NoError(t, err)
|
|
require.Nil(t, override)
|
|
}
|
|
|
|
func TestResolveCompactionOverrideConfig_ReadDBError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
chat, _ := titleOverrideTestChatAndMessages(t)
|
|
|
|
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrConnDone)
|
|
|
|
server := titleOverrideTestServer(db, logger)
|
|
override, err := server.resolveCompactionOverrideConfig(ctx, chat)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, "read compaction model override")
|
|
require.Nil(t, override)
|
|
}
|
|
|
|
func TestResolveCompactionOverrideConfig_DeletedConfigFallsBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
chat, _ := titleOverrideTestChatAndMessages(t)
|
|
missingID := uuid.New()
|
|
|
|
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, missingID, ""), nil)
|
|
db.EXPECT().GetChatModelConfigByID(gomock.Any(), missingID).Return(database.ChatModelConfig{}, sql.ErrNoRows)
|
|
|
|
server := titleOverrideTestServer(db, logger)
|
|
override, err := server.resolveCompactionOverrideConfig(ctx, chat)
|
|
require.NoError(t, err)
|
|
require.Nil(t, override)
|
|
}
|
|
|
|
func TestResolveCompactionOverrideConfig_DisabledConfigFallsBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
chat, _ := titleOverrideTestChatAndMessages(t)
|
|
overrideConfig := titleOverrideModelConfig("gpt-4.1", false)
|
|
|
|
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, overrideConfig.ID, ""), nil)
|
|
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
|
|
|
|
server := titleOverrideTestServer(db, logger)
|
|
override, err := server.resolveCompactionOverrideConfig(ctx, chat)
|
|
require.NoError(t, err)
|
|
require.Nil(t, override)
|
|
}
|
|
|
|
func TestResolveCompactionOverrideConfig_MissingCredentialsFallsBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
chat, _ := titleOverrideTestChatAndMessages(t)
|
|
overrideConfig := titleOverrideModelConfig("gpt-4.1", true)
|
|
providerID := uuid.New()
|
|
overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true}
|
|
|
|
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, overrideConfig.ID, ""), nil)
|
|
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
|
|
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
|
|
ID: providerID,
|
|
Type: database.AIProviderTypeOpenai,
|
|
Enabled: true,
|
|
}, nil).AnyTimes()
|
|
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return(nil, nil).AnyTimes()
|
|
|
|
server := titleOverrideTestServer(db, logger)
|
|
override, err := server.resolveCompactionOverrideConfig(ctx, chat)
|
|
require.NoError(t, err)
|
|
require.Nil(t, override)
|
|
}
|
|
|
|
func TestCompactionOverride_SetUsable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
ctrl := gomock.NewController(t)
|
|
db := dbmock.NewMockStore(ctrl)
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
chat, _ := titleOverrideTestChatAndMessages(t)
|
|
overrideConfig := titleOverrideModelConfig("gpt-4.1", true)
|
|
providerID := uuid.New()
|
|
overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true}
|
|
options, err := json.Marshal(codersdk.ChatModelCallConfig{
|
|
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
|
|
Default: ptr.Ref("low"),
|
|
Max: ptr.Ref("high"),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
overrideConfig.Options = options
|
|
|
|
db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), compactionOverrideParams(chat)).Return(orgModelOverride(chat, compactionOverrideContext, overrideConfig.ID, "high"), nil)
|
|
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
|
|
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes()
|
|
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
|
|
ProviderID: providerID,
|
|
APIKey: "test-key",
|
|
}}, nil).AnyTimes()
|
|
|
|
server := titleOverrideTestServer(db, logger)
|
|
resolved, err := server.resolveCompactionOverrideConfig(ctx, chat)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resolved)
|
|
require.Equal(t, overrideConfig.ID, resolved.Config.ID)
|
|
// The override effort travels as spec data instead of being pinned
|
|
// into the config's options.
|
|
require.Equal(t, ptr.Ref("high"), resolved.ReasoningEffort)
|
|
require.Equal(t, options, []byte(resolved.Config.Options))
|
|
|
|
override, err := server.resolveModelCall(ctx, modelCallSpec{
|
|
purpose: "compaction",
|
|
chat: chat,
|
|
explicitConfig: &resolved.Config,
|
|
requestedEffort: resolved.ReasoningEffort,
|
|
chatdScopedRoute: true,
|
|
buildOptions: modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, override.model.Valid())
|
|
require.Equal(t, overrideConfig.ID, override.dbConfig.ID)
|
|
require.Equal(t, "openai", override.resolvedProvider)
|
|
require.Equal(t, "gpt-4.1", override.resolvedModel)
|
|
// Prepare-time identity must match the built client's so
|
|
// still-over-limit metrics land on the same series.
|
|
require.Equal(t, override.resolvedProvider, resolved.ResolvedProvider)
|
|
require.Equal(t, override.resolvedModel, resolved.ResolvedModel)
|
|
requireOpenAIReasoningEffort(t, override.providerOptions, "high")
|
|
}
|