Files
coder/coderd/x/chatd/advisor_internal_test.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

725 lines
24 KiB
Go

package chatd
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"testing"
"time"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd/chatadvisor"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
// advisorOverrideStubStore stubs only the database methods that
// resolveAdvisorModelOverride exercises. The prod code calls
// GetEnabledChatModelConfigByID so the query joins ai_providers and
// filters both enabled flags atomically. Tests simulate that by returning
// configs the stub treats as enabled.
type advisorOverrideStubStore struct {
database.Store
getChatOrganizationModelOverride func(context.Context, database.GetChatOrganizationModelOverrideParams) (database.ChatOrganizationModelOverride, error)
advisorModelConfigID uuid.UUID
advisorReasoningEffort *string
getEnabledChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error)
getChatModelConfigByID func(context.Context, uuid.UUID) (database.ChatModelConfig, error)
getAIProviderByID func(context.Context, uuid.UUID) (database.AIProvider, error)
getAIProviders func(context.Context, database.GetAIProvidersParams) ([]database.AIProvider, error)
getAIProviderKeysByProviderID func(context.Context, uuid.UUID) ([]database.AIProviderKey, error)
getAIProviderKeysByProviderIDs func(context.Context, []uuid.UUID) ([]database.AIProviderKey, error)
}
func (s *advisorOverrideStubStore) GetChatOrganizationModelOverride(
ctx context.Context,
params database.GetChatOrganizationModelOverrideParams,
) (database.ChatOrganizationModelOverride, error) {
if s.getChatOrganizationModelOverride != nil {
return s.getChatOrganizationModelOverride(ctx, params)
}
if s.advisorModelConfigID == uuid.Nil {
return database.ChatOrganizationModelOverride{}, sql.ErrNoRows
}
override := database.ChatOrganizationModelOverride{
OrganizationID: params.OrganizationID,
Context: params.Context,
ModelConfigID: s.advisorModelConfigID,
}
if s.advisorReasoningEffort != nil {
override.ReasoningEffort = sql.NullString{String: *s.advisorReasoningEffort, Valid: true}
}
return override, nil
}
func (s *advisorOverrideStubStore) GetEnabledChatModelConfigByID(
ctx context.Context,
id uuid.UUID,
) (database.ChatModelConfig, error) {
if s.getEnabledChatModelConfigByID == nil {
return database.ChatModelConfig{}, xerrors.New("unexpected GetEnabledChatModelConfigByID call")
}
return s.getEnabledChatModelConfigByID(ctx, id)
}
func (s *advisorOverrideStubStore) GetChatModelConfigByID(
ctx context.Context,
id uuid.UUID,
) (database.ChatModelConfig, error) {
if s.getChatModelConfigByID == nil {
return database.ChatModelConfig{}, xerrors.New("unexpected GetChatModelConfigByID call")
}
return s.getChatModelConfigByID(ctx, id)
}
func (s *advisorOverrideStubStore) GetAIProviderByID(
ctx context.Context,
id uuid.UUID,
) (database.AIProvider, error) {
if s.getAIProviderByID == nil {
return database.AIProvider{}, xerrors.New("unexpected GetAIProviderByID call")
}
return s.getAIProviderByID(ctx, id)
}
func (s *advisorOverrideStubStore) GetAIProviders(
ctx context.Context,
params database.GetAIProvidersParams,
) ([]database.AIProvider, error) {
if s.getAIProviders == nil {
return nil, xerrors.New("unexpected GetAIProviders call")
}
return s.getAIProviders(ctx, params)
}
func (s *advisorOverrideStubStore) GetAIProviderKeysByProviderID(
ctx context.Context,
providerID uuid.UUID,
) ([]database.AIProviderKey, error) {
if s.getAIProviderKeysByProviderID == nil {
return nil, xerrors.New("unexpected GetAIProviderKeysByProviderID call")
}
return s.getAIProviderKeysByProviderID(ctx, providerID)
}
func (s *advisorOverrideStubStore) GetAIProviderKeysByProviderIDs(
ctx context.Context,
providerIDs []uuid.UUID,
) ([]database.AIProviderKey, error) {
if s.getAIProviderKeysByProviderIDs == nil {
return nil, xerrors.New("unexpected GetAIProviderKeysByProviderIDs call")
}
return s.getAIProviderKeysByProviderIDs(ctx, providerIDs)
}
func newAdvisorTestServer(
ctx context.Context,
t *testing.T,
store database.Store,
) *Server {
t.Helper()
clock := quartz.NewMock(t)
return &Server{
db: store,
configCache: newChatConfigCache(ctx, store, clock),
}
}
const advisorTestMaxOutputTokens = int64(16384)
func resolveAdvisorModelOverrideForTest(
ctx context.Context,
p *Server,
chat database.Chat,
modelConfigID uuid.UUID,
reasoningEffort *string,
maxOutputTokens int64,
modelOpts modelBuildOptions,
logger slog.Logger,
) (resolvedModelCall, bool, error) {
if store, ok := p.db.(*advisorOverrideStubStore); ok {
store.advisorModelConfigID = modelConfigID
store.advisorReasoningEffort = reasoningEffort
}
return p.resolveAdvisorModelOverride(ctx, chat, maxOutputTokens, modelOpts, logger)
}
// advisorChatModelFixture wires a chat whose LastModelConfigID resolves
// through the config cache to an enabled, provider-linked model config, so
// the advisor chat-model path can resolve without an override.
func advisorChatModelFixture(t *testing.T, options json.RawMessage) (database.Chat, *advisorOverrideStubStore) {
t.Helper()
configID := uuid.New()
providerID := uuid.New()
organizationID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
Options: options,
DisplayName: "gpt-5.2",
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
OrganizationID: organizationID,
}, nil
},
getAIProviderByID: func(context.Context, uuid.UUID) (database.AIProvider, error) {
return aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil
},
}
return database.Chat{LastModelConfigID: configID, OrganizationID: organizationID}, store
}
func advisorTestTransportFactory() *aibridgeTestFactory {
return &aibridgeTestFactory{rt: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})}
}
// TestResolveAdvisorModelOverride covers the early-return, each
// use-the-chat-model branch, and the success path.
func TestResolveAdvisorModelOverride(t *testing.T) {
t.Parallel()
logger := slog.Make()
requireChatModel := func(t *testing.T, p *Server, modelConfigID uuid.UUID) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitShort)
resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx,
p,
database.Chat{},
modelConfigID,
nil,
advisorTestMaxOutputTokens,
modelBuildOptions{},
logger,
)
require.NoError(t, err)
require.False(t, ok, "unusable override must fall back to the chat model")
require.False(t, resolved.model.Valid())
}
t.Run("NilModelConfigUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Error if the store is consulted; the early return must skip it.
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
requireChatModel(t, p, uuid.Nil)
})
t.Run("ConfigLookupErrorUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{}, xerrors.New("lookup failed")
},
}
p := newAdvisorTestServer(ctx, t, store)
requireChatModel(t, p, uuid.New())
})
// Covers the sql.ErrNoRows branch separately from the generic-error
// branch above. GetEnabledChatModelConfigByID returns ErrNoRows when
// an admin disables the advisor model or its provider, and that case
// has a distinct log message. Without this test, removing the
// errors.Is(err, sql.ErrNoRows) check would still pass the sibling
// test.
t.Run("DisabledProviderUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{}, sql.ErrNoRows
},
}
p := newAdvisorTestServer(ctx, t, store)
requireChatModel(t, p, uuid.New())
})
t.Run("ForeignOrgConfigUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
foreignOrgID := uuid.New()
chatOrgID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
OrganizationID: foreignOrgID,
Enabled: true,
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx,
p,
database.Chat{OrganizationID: chatOrgID},
configID,
nil,
advisorTestMaxOutputTokens,
modelBuildOptions{},
logger,
)
require.NoError(t, err)
require.False(t, ok)
require.False(t, resolved.model.Valid())
})
t.Run("InvalidOptionsJSONUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
Options: []byte("not valid json"),
DisplayName: "gpt-5.2",
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
requireChatModel(t, p, configID)
})
t.Run("InvalidOptionsJSONWithLinkedProviderUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
Options: []byte("not valid json"),
DisplayName: "gpt-5.2",
AIProviderID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
requireChatModel(t, p, configID)
})
t.Run("MissingProviderKeyUsesChatModel", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
providerID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
DisplayName: "gpt-5.2",
}, nil
},
getAIProviders: func(context.Context, database.GetAIProvidersParams) ([]database.AIProvider, error) {
return []database.AIProvider{{
ID: providerID,
Type: database.AIProviderTypeOpenai,
Enabled: true,
}}, nil
},
getAIProviderKeysByProviderIDs: func(context.Context, []uuid.UUID) ([]database.AIProviderKey, error) {
return nil, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
requireChatModel(t, p, configID)
})
t.Run("SuccessReturnsOverrideModelAndConfig", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
providerID := uuid.New()
rawOptions, err := json.Marshal(codersdk.ChatModelCallConfig{
Temperature: func() *float64 { v := 0.42; return &v }(),
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow),
Max: ptr.Ref(codersdk.ChatModelReasoningEffortXHigh),
},
})
require.NoError(t, err)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
Options: rawOptions,
DisplayName: "gpt-5.2",
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
}, nil
},
getAIProviderByID: func(context.Context, uuid.UUID) (database.AIProvider, error) {
return aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil
},
}
p := newAdvisorTestServer(ctx, t, store)
p.aibridgeTransportFactory = aibridgeTestFactoryPointer(advisorTestTransportFactory())
resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx,
p,
database.Chat{},
configID,
ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
advisorTestMaxOutputTokens,
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
logger,
)
require.NoError(t, err)
require.True(t, ok)
require.True(t, resolved.model.Valid())
require.Equal(t, "openai", resolved.model.Provider())
// Guard against ModelFromConfig silently ignoring the model field
// and returning a default. The override is only useful if the
// model name from the config row actually propagates.
require.Equal(t, "gpt-5.2", resolved.model.ModelID())
require.NotNil(t, resolved.callConfig.Temperature)
require.InDelta(t, 0.42, *resolved.callConfig.Temperature, 1e-9)
// The resolver derives provider options from the advisor's
// requested effort instead of pinning it into the call config,
// so the config's own effort bounds stay intact.
require.NotNil(t, resolved.callConfig.ReasoningEffort)
require.Equal(t, codersdk.ChatModelReasoningEffortLow, *resolved.callConfig.ReasoningEffort.Default)
require.Equal(t, codersdk.ChatModelReasoningEffortXHigh, *resolved.callConfig.ReasoningEffort.Max)
require.Equal(t, ptr.Ref(advisorTestMaxOutputTokens), resolved.callConfig.MaxOutputTokens)
requireOpenAIReasoningEffort(t, resolved.providerOptions, codersdk.ChatModelReasoningEffortHigh)
})
t.Run("AIProviderIDResolvesOverrideProviderKeys", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
providerID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
CreatedAt: time.Unix(0, 0).UTC(),
UpdatedAt: time.Unix(0, 0).UTC(),
DisplayName: "gpt-5.2",
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
}, nil
},
getAIProviderByID: func(context.Context, uuid.UUID) (database.AIProvider, error) {
return aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil
},
getAIProviderKeysByProviderID: func(context.Context, uuid.UUID) ([]database.AIProviderKey, error) {
return []database.AIProviderKey{{
ProviderID: providerID,
APIKey: "sk-selected",
}}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
p.aibridgeTransportFactory = aibridgeTestFactoryPointer(advisorTestTransportFactory())
resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx,
p,
database.Chat{},
configID,
nil,
advisorTestMaxOutputTokens,
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
logger,
)
require.NoError(t, err)
require.True(t, ok)
require.True(t, resolved.model.Valid())
require.Equal(t, "openai", resolved.model.Provider())
require.Equal(t, "gpt-5.2", resolved.model.ModelID())
require.Equal(t, codersdk.ChatModelCallConfig{
MaxOutputTokens: ptr.Ref(advisorTestMaxOutputTokens),
}, resolved.callConfig)
})
}
func TestResolveAdvisorModelOverridePromotesAIBridgeErrors(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
configID := uuid.New()
providerID := uuid.New()
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{
ID: configID,
Model: "gpt-5.2",
Enabled: true,
DisplayName: "gpt-5.2",
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
}, nil
},
getAIProviderByID: func(context.Context, uuid.UUID) (database.AIProvider, error) {
return database.AIProvider{ID: providerID, Type: database.AIProviderTypeOpenai, Name: "primary-openai", Enabled: true}, nil
},
getAIProviderKeysByProviderID: func(context.Context, uuid.UUID) ([]database.AIProviderKey, error) {
return []database.AIProviderKey{{ProviderID: providerID, APIKey: "sk-selected"}}, nil
},
}
p := newAdvisorTestServer(ctx, t, store)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, uuid.NewString())
resolved, ok, err := resolveAdvisorModelOverrideForTest(ctx,
p,
database.Chat{ID: uuid.New()},
configID,
nil,
advisorTestMaxOutputTokens,
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
slog.Make(),
)
require.ErrorContains(t, err, "AI Gateway transport factory")
require.False(t, ok)
require.False(t, resolved.model.Valid())
}
// TestStripAdvisorGuidanceBlock exercises the filter that keeps the advisor
// from receiving the parent-facing advisor-guidance instruction in its nested
// context. The block references a tool the advisor cannot use, so forwarding
// it wastes context tokens and risks steering the advisor's reply.
func TestStripAdvisorGuidanceBlock(t *testing.T) {
t.Parallel()
t.Run("RemovesGuidanceSystemMessage", func(t *testing.T) {
t.Parallel()
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "You are a helpful assistant."},
},
},
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: chatadvisor.ParentGuidanceBlock},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "Help me plan."},
},
},
}
filtered := stripAdvisorGuidanceBlock(msgs)
require.Len(t, filtered, 2)
for _, msg := range filtered {
for _, part := range msg.Content {
if text, ok := part.(fantasy.TextPart); ok {
require.NotEqual(t, chatadvisor.ParentGuidanceBlock, text.Text,
"guidance block must not survive the filter")
}
}
}
})
t.Run("LeavesOtherSystemMessagesIntact", func(t *testing.T) {
t.Parallel()
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "instruction file"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hi"},
},
},
}
filtered := stripAdvisorGuidanceBlock(msgs)
require.Len(t, filtered, 2)
})
t.Run("IgnoresNonSystemRoleWithMatchingText", func(t *testing.T) {
t.Parallel()
// A user message echoing the guidance block must not be stripped:
// the filter only targets the system-role injection.
msgs := []fantasy.Message{
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: chatadvisor.ParentGuidanceBlock},
},
},
}
filtered := stripAdvisorGuidanceBlock(msgs)
require.Len(t, filtered, 1)
})
}
// TestNewAdvisorRuntime covers the defensive branches in newAdvisorRuntime
// that gate whether the runtime is created and with what bounds, plus the
// chat-model path that resolves the advisor call when no override is
// configured. Without this coverage a regression in any branch ships
// silently.
func TestNewAdvisorRuntime(t *testing.T) {
t.Parallel()
logger := slog.Make()
newChatModelRuntime := func(t *testing.T, advisorCfg advisorRuntimeConfig, options json.RawMessage) *chatadvisor.Runtime {
t.Helper()
ctx := testutil.Context(t, testutil.WaitShort)
chat, store := advisorChatModelFixture(t, options)
p := newAdvisorTestServer(ctx, t, store)
p.aibridgeTransportFactory = aibridgeTestFactoryPointer(advisorTestTransportFactory())
rt, err := p.newAdvisorRuntime(
ctx,
chat,
advisorCfg,
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
logger,
)
require.NoError(t, err)
return rt
}
t.Run("ZeroMaxUsesDefaultsToMaxChatSteps", func(t *testing.T) {
t.Parallel()
rt := newChatModelRuntime(t, advisorRuntimeConfig{
Enabled: true,
MaxUsesPerRun: 0,
MaxOutputTokens: 16384,
}, nil)
require.NotNil(t, rt, "zero max uses must default rather than bail out")
require.Equal(t, maxChatSteps, rt.RemainingUses(),
"zero max uses must be replaced with maxChatSteps")
})
t.Run("NegativeMaxUsesReturnsNil", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
// Error if any resolution is attempted; the bounds check must
// disable the advisor before model resolution starts.
store := &advisorOverrideStubStore{}
p := newAdvisorTestServer(ctx, t, store)
rt, err := p.newAdvisorRuntime(
ctx,
database.Chat{},
advisorRuntimeConfig{
Enabled: true,
MaxUsesPerRun: -1,
MaxOutputTokens: 16384,
},
modelBuildOptions{},
logger,
)
require.NoError(t, err)
require.Nil(t, rt, "negative max uses must disable the advisor")
})
t.Run("ZeroMaxOutputTokensDefaults", func(t *testing.T) {
t.Parallel()
rt := newChatModelRuntime(t, advisorRuntimeConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 0,
}, nil)
require.NotNil(t, rt,
"zero max output tokens must default to defaultAdvisorMaxOutputTokens, not disable the advisor")
require.Equal(t, 3, rt.RemainingUses())
require.Equal(t, int64(defaultAdvisorMaxOutputTokens), rt.MaxOutputTokens(),
"zero max output tokens must be replaced with defaultAdvisorMaxOutputTokens")
})
t.Run("ChatModelResolutionFailureSkipsAdvisor", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
store := &advisorOverrideStubStore{
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
return database.ChatModelConfig{}, xerrors.New("lookup failed")
},
}
p := newAdvisorTestServer(ctx, t, store)
rt, err := p.newAdvisorRuntime(
ctx,
database.Chat{LastModelConfigID: uuid.New()},
advisorRuntimeConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
},
modelBuildOptions{},
logger,
)
require.NoError(t, err, "a failed chat-model resolution must skip the advisor, not fail the turn")
require.Nil(t, rt)
})
t.Run("AppliesChatModelEffortToProviderOptions", func(t *testing.T) {
t.Parallel()
options, err := json.Marshal(codersdk.ChatModelCallConfig{
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
Max: ptr.Ref(codersdk.ChatModelReasoningEffortXHigh),
},
ProviderOptions: &codersdk.ChatModelProviderOptions{
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
User: ptr.Ref("advisor-user"),
},
},
})
require.NoError(t, err)
rt := newChatModelRuntime(t, advisorRuntimeConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 16384,
}, options)
require.NotNil(t, rt)
requireOpenAIUserOption(t, rt.ProviderOptions(), "advisor-user")
requireOpenAIReasoningEffort(t, rt.ProviderOptions(), codersdk.ChatModelReasoningEffortHigh)
})
}