mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
refactor: drop chat_model_configs provider column (#26877)
The provider type already lives authoritatively in ai_providers.type, reachable on every active row through ai_provider_id, which the chat_model_configs_ai_provider_required_when_active CHECK makes mandatory. The stored provider string was a denormalized copy the system kept in sync with a startup backfill and no longer needs. Every surface now derives provider type from the linked ai_providers row. Telemetry is the one exception: it keeps emitting provider, now sourced from ai_providers.type via a JOIN, so the BigQuery column and the Nexus dashboards that read it are unaffected. The experimental HTTP/SDK response drops provider and makes ai_provider_id required, since those endpoints return only active configs; consumers resolve provider type from ai_provider_id and the AI providers listing. This ships in a single release with no compatibility window: production reads the table via SELECT *, so a pre-drop binary fails config reads the moment the column is gone. Operators must scale to zero before upgrading, and there is no rollback. Closes CODAGT-599
This commit is contained in:
@@ -248,7 +248,6 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
|
||||
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
|
||||
return database.ChatModelConfig{
|
||||
ID: configID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.2",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Unix(0, 0).UTC(),
|
||||
@@ -283,7 +282,6 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
|
||||
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
|
||||
return database.ChatModelConfig{
|
||||
ID: configID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.2",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Unix(0, 0).UTC(),
|
||||
@@ -322,6 +320,7 @@ func TestResolveAdvisorModelOverride(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 }(),
|
||||
})
|
||||
@@ -329,16 +328,29 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
|
||||
store := &advisorOverrideStubStore{
|
||||
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
|
||||
return database.ChatModelConfig{
|
||||
ID: configID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.2",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Unix(0, 0).UTC(),
|
||||
UpdatedAt: time.Unix(0, 0).UTC(),
|
||||
Options: rawOptions,
|
||||
DisplayName: "gpt-5.2",
|
||||
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 database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
}, nil
|
||||
},
|
||||
getAIProviderKeysByProviderID: func(context.Context, uuid.UUID) ([]database.AIProviderKey, error) {
|
||||
return []database.AIProviderKey{{
|
||||
ProviderID: providerID,
|
||||
APIKey: "sk-test",
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
p := newAdvisorTestServer(ctx, t, store)
|
||||
|
||||
@@ -373,7 +385,6 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
|
||||
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
|
||||
return database.ChatModelConfig{
|
||||
ID: configID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.2",
|
||||
Enabled: true,
|
||||
CreatedAt: time.Unix(0, 0).UTC(),
|
||||
@@ -426,7 +437,6 @@ func TestResolveAdvisorModelOverridePromotesAIBridgeErrors(t *testing.T) {
|
||||
getEnabledChatModelConfigByID: func(context.Context, uuid.UUID) (database.ChatModelConfig, error) {
|
||||
return database.ChatModelConfig{
|
||||
ID: configID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.2",
|
||||
Enabled: true,
|
||||
DisplayName: "gpt-5.2",
|
||||
|
||||
+12
-7
@@ -2588,6 +2588,16 @@ func (p *Server) prepareManualTitleDebugRun(
|
||||
finishDebugRun := func(error) {}
|
||||
|
||||
route, routeErr := p.resolveModelRouteForConfig(ctx, chat.OwnerID, modelConfig, keys)
|
||||
var routeProvider string
|
||||
if routeErr == nil {
|
||||
routeProvider, _ = route.providerHint()
|
||||
} else if modelConfig.AIProviderID.Valid {
|
||||
// Route resolution failed, but the linked provider still identifies the
|
||||
// type for the debug run record. Best-effort: leave empty if disabled.
|
||||
if provider, err := p.enabledAIProviderByID(ctx, modelConfig.AIProviderID.UUID); err == nil {
|
||||
routeProvider = string(provider.Type)
|
||||
}
|
||||
}
|
||||
debugOpts := modelOpts
|
||||
debugOpts.RecordHTTP = true
|
||||
var debugModelErr error
|
||||
@@ -2606,21 +2616,19 @@ func (p *Server) prepareManualTitleDebugRun(
|
||||
case debugModelErr != nil:
|
||||
p.logger.Warn(ctx, "failed to create debug-aware manual title model",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", modelConfig.Provider),
|
||||
slog.F("model", modelConfig.Model),
|
||||
slog.Error(debugModelErr),
|
||||
)
|
||||
case debugModel == nil:
|
||||
p.logger.Warn(ctx, "manual title debug model creation returned nil",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", modelConfig.Provider),
|
||||
slog.F("model", modelConfig.Model),
|
||||
)
|
||||
default:
|
||||
titleModel = chatdebug.WrapModel(debugModel, debugSvc, chatdebug.RecorderOptions{
|
||||
ChatID: chat.ID,
|
||||
OwnerID: chat.OwnerID,
|
||||
Provider: modelConfig.Provider,
|
||||
Provider: routeProvider,
|
||||
Model: modelConfig.Model,
|
||||
})
|
||||
}
|
||||
@@ -2651,7 +2659,7 @@ func (p *Server) prepareManualTitleDebugRun(
|
||||
debugRun, createRunErr := debugSvc.CreateRun(createRunCtx, chatdebug.CreateRunParams{
|
||||
ChatID: chat.ID,
|
||||
ModelConfigID: modelConfig.ID,
|
||||
Provider: modelConfig.Provider,
|
||||
Provider: routeProvider,
|
||||
Model: modelConfig.Model,
|
||||
Kind: chatdebug.KindTitleGeneration,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
@@ -2663,7 +2671,6 @@ func (p *Server) prepareManualTitleDebugRun(
|
||||
if createRunErr != nil {
|
||||
p.logger.Warn(ctx, "failed to create manual title debug run",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", modelConfig.Provider),
|
||||
slog.F("model", modelConfig.Model),
|
||||
slog.Error(createRunErr),
|
||||
)
|
||||
@@ -2789,7 +2796,6 @@ func (p *Server) resolveManualTitleModel(
|
||||
if err != nil {
|
||||
p.logger.Debug(ctx, "manual title preferred model unavailable",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", config.Provider),
|
||||
slog.F("model", config.Model),
|
||||
slog.Error(err),
|
||||
)
|
||||
@@ -2804,7 +2810,6 @@ func (p *Server) resolveManualTitleModel(
|
||||
if err != nil {
|
||||
p.logger.Debug(ctx, "manual title preferred model unavailable",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", config.Provider),
|
||||
slog.F("model", config.Model),
|
||||
slog.Error(err),
|
||||
)
|
||||
|
||||
@@ -326,7 +326,6 @@ func seedAnthropicChatDependencies(t *testing.T, db database.Store, baseURL stri
|
||||
})
|
||||
dbgen.AIProviderKey(t, db, database.AIProviderKey{ProviderID: provider.ID})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "anthropic",
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
IsDefault: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
@@ -453,7 +452,6 @@ func updateModelForChainMode(t *testing.T, db database.Store, model database.Cha
|
||||
ID: model.ID,
|
||||
DisplayName: model.DisplayName,
|
||||
Model: model.Model,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
ContextLimit: model.ContextLimit,
|
||||
CompressionThreshold: model.CompressionThreshold,
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/workspacestats"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
|
||||
openaicomputeruse "github.com/coder/coder/v2/coderd/x/chatd/chatopenai/computeruse"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
@@ -794,11 +795,12 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
|
||||
Title: fallbackChatTitle(userPrompt),
|
||||
}
|
||||
providerID := uuid.New()
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
}
|
||||
updatedChat := chat
|
||||
updatedChat.Title = wantTitle
|
||||
@@ -832,14 +834,20 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
providerID := uuid.New()
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}}, nil)
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), []uuid.UUID{providerID}).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil)
|
||||
}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows)
|
||||
db.EXPECT().GetChatMessagesByChatIDAscPaginated(
|
||||
gomock.Any(),
|
||||
@@ -957,11 +965,12 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
lockedChat := chat
|
||||
lockedChat.WorkerID = uuid.NullUUID{UUID: manualTitleLockWorkerID, Valid: true}
|
||||
lockedChat.StartedAt = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
providerID := uuid.New()
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: modelConfigID,
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
ContextLimit: 8192,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
}
|
||||
updatedChat := lockedChat
|
||||
updatedChat.Title = wantTitle
|
||||
@@ -998,14 +1007,20 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts_IdleChatReleasesManualLock(t
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil)
|
||||
providerID := uuid.New()
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}}, nil)
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), []uuid.UUID{providerID}).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil)
|
||||
}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes()
|
||||
db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows)
|
||||
db.EXPECT().GetChatMessagesByChatIDAscPaginated(
|
||||
gomock.Any(),
|
||||
@@ -3495,3 +3510,73 @@ func TestServer_inflightContext(t *testing.T) {
|
||||
t.Fatal("inflight context not canceled on server shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareManualTitleDebugRun_RouteFailureDerivesProviderFromConfig drives
|
||||
// the fallback branch in prepareManualTitleDebugRun: AI-gateway route
|
||||
// resolution fails (the BYOK key lookup returns a non-ErrNoRows error) while
|
||||
// the linked provider stays enabled, so the debug run records the provider
|
||||
// type derived from modelConfig.AIProviderID instead of an empty string.
|
||||
func TestPrepareManualTitleDebugRun_RouteFailureDerivesProviderFromConfig(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})
|
||||
|
||||
ownerID := uuid.New()
|
||||
providerID := uuid.New()
|
||||
chat := database.Chat{ID: uuid.New(), OwnerID: ownerID}
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Model: "claude-sonnet-4",
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
}
|
||||
provider := database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeAnthropic,
|
||||
Name: "anthropic",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
// Resolved twice: once by gatewayProviderForConfig during route resolution,
|
||||
// once by the fallback's own enabledAIProviderByID lookup.
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
|
||||
// A non-ErrNoRows BYOK error fails route resolution while the provider stays
|
||||
// enabled, which is exactly the gap the fallback covers.
|
||||
db.EXPECT().GetUserAIProviderKeyByProviderID(gomock.Any(), database.GetUserAIProviderKeyByProviderIDParams{
|
||||
UserID: ownerID,
|
||||
AIProviderID: providerID,
|
||||
}).Return(database.UserAIProviderKey{}, sql.ErrConnDone)
|
||||
|
||||
var gotProvider sql.NullString
|
||||
db.EXPECT().InsertChatDebugRun(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(_ context.Context, params database.InsertChatDebugRunParams) (database.ChatDebugRun, error) {
|
||||
gotProvider = params.Provider
|
||||
return database.ChatDebugRun{ChatID: params.ChatID, Provider: params.Provider}, nil
|
||||
},
|
||||
)
|
||||
|
||||
server := &Server{
|
||||
db: db,
|
||||
logger: logger,
|
||||
aiGatewayRoutingEnabled: true,
|
||||
allowBYOK: true,
|
||||
}
|
||||
debugSvc := chatdebug.NewService(db, logger, nil)
|
||||
fallbackModel := &chattest.FakeModel{ProviderName: "stub", ModelName: "stub"}
|
||||
|
||||
server.prepareManualTitleDebugRun(
|
||||
ctx,
|
||||
debugSvc,
|
||||
chat,
|
||||
modelConfig,
|
||||
chatprovider.ProviderAPIKeys{},
|
||||
modelBuildOptions{},
|
||||
nil,
|
||||
fallbackModel,
|
||||
)
|
||||
|
||||
require.True(t, gotProvider.Valid, "debug run provider should be populated from the linked config")
|
||||
require.Equal(t, "anthropic", gotProvider.String)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ func TestActiveServer_RetryStreamSilenceTimeoutAndClassification(t *testing.T) {
|
||||
})
|
||||
user, org, _ := seedChatDependenciesWithProvider(t, db, "openai", openAIURL)
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o",
|
||||
Enabled: true,
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
|
||||
@@ -7713,7 +7713,6 @@ func updateChatModelCompressionThreshold(t *testing.T, db database.Store, model
|
||||
ID: model.ID,
|
||||
DisplayName: model.DisplayName,
|
||||
Model: model.Model,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
ContextLimit: model.ContextLimit,
|
||||
CompressionThreshold: model.CompressionThreshold,
|
||||
@@ -7730,7 +7729,6 @@ func updateChatModelContextLimit(t *testing.T, db database.Store, model database
|
||||
ID: model.ID,
|
||||
DisplayName: model.DisplayName,
|
||||
Model: model.Model,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
ContextLimit: model.ContextLimit,
|
||||
CompressionThreshold: model.CompressionThreshold,
|
||||
@@ -7749,7 +7747,6 @@ func updateChatModelCallConfig(t *testing.T, db database.Store, model database.C
|
||||
ID: model.ID,
|
||||
DisplayName: model.DisplayName,
|
||||
Model: model.Model,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
ContextLimit: model.ContextLimit,
|
||||
CompressionThreshold: model.CompressionThreshold,
|
||||
@@ -8333,6 +8330,8 @@ func TestProposeChatTitle_DebugRun(t *testing.T) {
|
||||
if tt.wantTitleGenerationRuns > 0 {
|
||||
require.Equal(t, string(codersdk.ChatDebugRunKindTitleGeneration), runs[0].Kind)
|
||||
require.Equal(t, string(tt.wantDebugStatus), runs[0].Status)
|
||||
require.True(t, runs[0].Provider.Valid)
|
||||
require.Equal(t, "openai", runs[0].Provider.String)
|
||||
require.True(t, runs[0].FinishedAt.Valid)
|
||||
require.True(t, runs[0].HistoryTipMessageID.Valid)
|
||||
require.Equal(t, message.ID, runs[0].HistoryTipMessageID.Int64)
|
||||
@@ -8378,14 +8377,14 @@ func seedChatDependenciesWithProvider(
|
||||
UserID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
providerConfig := dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: provider,
|
||||
DisplayName: provider,
|
||||
BaseUrl: baseURL,
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: provider,
|
||||
IsDefault: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerConfig.ID, Valid: true},
|
||||
IsDefault: true,
|
||||
})
|
||||
return user, org, model
|
||||
}
|
||||
@@ -8423,8 +8422,8 @@ func seedChatDependenciesWithProviderPolicy(
|
||||
})
|
||||
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: provider,
|
||||
IsDefault: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerConfig.ID, Valid: true},
|
||||
IsDefault: true,
|
||||
})
|
||||
|
||||
return user, org, providerConfig, model
|
||||
@@ -8482,13 +8481,32 @@ func insertChatModelConfigWithCallConfig(
|
||||
options, err := json.Marshal(callConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reuse the newest AI provider of this type (creating a bare one only when
|
||||
// none exists) so the config links the seeded provider carrying the mock
|
||||
// base URL and API key rather than a fresh credential-less one.
|
||||
providers, err := db.GetAIProviders(context.Background(), database.GetAIProvidersParams{IncludeDisabled: true})
|
||||
require.NoError(t, err)
|
||||
var aiProvider database.AIProvider
|
||||
for _, candidate := range providers {
|
||||
if candidate.Type != database.AIProviderType(provider) {
|
||||
continue
|
||||
}
|
||||
if aiProvider.ID == uuid.Nil || candidate.CreatedAt.After(aiProvider.CreatedAt) {
|
||||
aiProvider = candidate
|
||||
}
|
||||
}
|
||||
if aiProvider.ID == uuid.Nil {
|
||||
aiProvider = dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderType(provider),
|
||||
})
|
||||
}
|
||||
return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Options: options,
|
||||
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
Options: options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9671,7 +9689,6 @@ func seedAIGatewayOpenAITestDependencies(
|
||||
BaseUrl: openAIURL,
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: string(database.AIProviderTypeOpenai),
|
||||
Model: "gpt-4o-mini",
|
||||
IsDefault: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
@@ -12548,14 +12565,12 @@ func TestProviderSwitchSanitizesAndRestoresPEToolHistory(t *testing.T) {
|
||||
})
|
||||
|
||||
mA := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai-compat",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Model A",
|
||||
Enabled: true,
|
||||
AIProviderID: uuid.NullUUID{UUID: cpA.ID, Valid: true},
|
||||
})
|
||||
mB := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai-compat",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Model B",
|
||||
Enabled: true,
|
||||
|
||||
@@ -25,13 +25,14 @@ import (
|
||||
)
|
||||
|
||||
type testFixture struct {
|
||||
ctx context.Context
|
||||
db database.Store
|
||||
svc *chatdebug.Service
|
||||
org database.Organization
|
||||
owner database.User
|
||||
chat database.Chat
|
||||
model database.ChatModelConfig
|
||||
ctx context.Context
|
||||
db database.Store
|
||||
svc *chatdebug.Service
|
||||
org database.Organization
|
||||
owner database.User
|
||||
chat database.Chat
|
||||
model database.ChatModelConfig
|
||||
provider string
|
||||
}
|
||||
|
||||
func TestService_IsEnabled(t *testing.T) {
|
||||
@@ -115,7 +116,7 @@ func TestService_CreateRun(t *testing.T) {
|
||||
HistoryTipMessageID: historyTipMsg.ID,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: fixture.model.Provider,
|
||||
Provider: fixture.provider,
|
||||
Model: fixture.model.Model,
|
||||
Summary: map[string]any{
|
||||
"phase": "create",
|
||||
@@ -126,7 +127,7 @@ func TestService_CreateRun(t *testing.T) {
|
||||
assertRunMatches(t, run, fixture.chat.ID, rootChat.ID, parentChat.ID,
|
||||
fixture.model.ID, triggerMsg.ID, historyTipMsg.ID,
|
||||
chatdebug.KindChatTurn, chatdebug.StatusInProgress,
|
||||
fixture.model.Provider, fixture.model.Model,
|
||||
fixture.provider, fixture.model.Model,
|
||||
`{"count":1,"phase":"create"}`)
|
||||
|
||||
stored, err := fixture.db.GetChatDebugRunByID(fixture.ctx, run.ID)
|
||||
@@ -470,7 +471,7 @@ func TestService_UpdateStep(t *testing.T) {
|
||||
ResponseStatus: 200,
|
||||
DurationMs: 25,
|
||||
}},
|
||||
Metadata: map[string]any{"provider": fixture.model.Provider},
|
||||
Metadata: map[string]any{"provider": fixture.provider},
|
||||
FinishedAt: finishedAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -487,7 +488,7 @@ func TestService_UpdateStep(t *testing.T) {
|
||||
`[{"number":1,"response_status":200,"duration_ms":25}]`,
|
||||
string(updated.Attempts),
|
||||
)
|
||||
require.JSONEq(t, `{"provider":"`+fixture.model.Provider+`"}`,
|
||||
require.JSONEq(t, `{"provider":"`+fixture.provider+`"}`,
|
||||
string(updated.Metadata))
|
||||
require.True(t, updated.FinishedAt.Valid)
|
||||
storedSteps, err := fixture.db.GetChatDebugStepsByRunID(fixture.ctx, run.ID)
|
||||
@@ -1070,14 +1071,17 @@ func newFixture(t *testing.T) testFixture {
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org, owner, chat, model := seedChat(t, db)
|
||||
prov, err := db.GetAIProviderByID(ctx, model.AIProviderID.UUID)
|
||||
require.NoError(t, err)
|
||||
return testFixture{
|
||||
ctx: ctx,
|
||||
db: db,
|
||||
svc: chatdebug.NewService(db, testutil.Logger(t), nil),
|
||||
org: org,
|
||||
owner: owner,
|
||||
chat: chat,
|
||||
model: model,
|
||||
ctx: ctx,
|
||||
db: db,
|
||||
svc: chatdebug.NewService(db, testutil.Logger(t), nil),
|
||||
org: org,
|
||||
owner: owner,
|
||||
chat: chat,
|
||||
model: model,
|
||||
provider: string(prov.Type),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,7 +1162,7 @@ func createRun(t *testing.T, fixture testFixture) database.ChatDebugRun {
|
||||
ModelConfigID: fixture.model.ID,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: fixture.model.Provider,
|
||||
Provider: fixture.provider,
|
||||
Model: fixture.model.Model,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -2516,12 +2516,12 @@ func TestMediaToolResultRoundTrip(t *testing.T) {
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
|
||||
dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
anthropicProvider := dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "anthropic",
|
||||
})
|
||||
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "anthropic",
|
||||
AIProviderID: uuid.NullUUID{UUID: anthropicProvider.ID, Valid: true},
|
||||
Model: "test-model",
|
||||
IsDefault: true,
|
||||
ContextLimit: 200000,
|
||||
|
||||
@@ -211,7 +211,6 @@ func seedFamilyDeps(t *testing.T, db database.Store) (database.User, database.Or
|
||||
BaseUrl: "http://example.invalid",
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
IsDefault: true,
|
||||
})
|
||||
return user, org, model
|
||||
|
||||
@@ -58,7 +58,6 @@ func newTestFixture(t *testing.T) *testFixture {
|
||||
BaseUrl: "http://example.invalid",
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
IsDefault: true,
|
||||
})
|
||||
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
|
||||
|
||||
@@ -38,7 +38,6 @@ func newTriggerFixture(t *testing.T) *triggerFixture {
|
||||
BaseUrl: "http://example.invalid",
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
IsDefault: true,
|
||||
})
|
||||
f := &testFixture{
|
||||
|
||||
@@ -683,7 +683,6 @@ func testAIProvider(name string) database.AIProvider {
|
||||
func testChatModelConfig(id uuid.UUID, model string) database.ChatModelConfig {
|
||||
return database.ChatModelConfig{
|
||||
ID: id,
|
||||
Provider: "openai",
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
Enabled: true,
|
||||
|
||||
@@ -262,7 +262,11 @@ func (server *Server) prepareGeneration(
|
||||
acceptsFilePart := func(mediaType string) bool {
|
||||
return chatprovider.AcceptsFilePartMediaType(model.Provider(), model.Model(), mediaType)
|
||||
}
|
||||
prompt, err = chatprompt.ConvertMessagesWithFiles(ctx, promptRows, server.chatFileResolver(modelConfig.Provider), logger, acceptsFilePart)
|
||||
providerType, err := modelRoute.providerHint()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve provider type: %w", err)
|
||||
}
|
||||
prompt, err = chatprompt.ConvertMessagesWithFiles(ctx, promptRows, server.chatFileResolver(providerType), logger, acceptsFilePart)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("build chat prompt: %w", err)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@ func TestDeriveFinalTurnRunResult(t *testing.T) {
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
})
|
||||
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "gpt-4o-mini",
|
||||
Options: json.RawMessage(`{}`),
|
||||
@@ -233,7 +232,6 @@ func TestDeriveFinalTurnRunResult(t *testing.T) {
|
||||
// degraded path that still returns the re-derived text and IDs.
|
||||
provider := insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "provider-api-key", false)
|
||||
modelCfg := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "gpt-4o-mini",
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
|
||||
@@ -133,7 +133,6 @@ func newWorkerTestFixture(t *testing.T) *workerTestFixture {
|
||||
BaseUrl: "http://example.invalid",
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
IsDefault: true,
|
||||
})
|
||||
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
|
||||
|
||||
@@ -98,7 +98,6 @@ func TestAnthropicWebSearchRoundTrip(t *testing.T) {
|
||||
contextLimit := int64(200000)
|
||||
isDefault := true
|
||||
_, err := expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
Provider: string(provider.Type),
|
||||
AIProviderID: &provider.ID,
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
ContextLimit: &contextLimit,
|
||||
@@ -358,7 +357,6 @@ func TestOpenAIReasoningRoundTrip(t *testing.T) {
|
||||
isDefault := true
|
||||
reasoningSummary := "auto"
|
||||
_, err := expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
Provider: string(provider.Type),
|
||||
AIProviderID: &provider.ID,
|
||||
Model: "o4-mini",
|
||||
ContextLimit: &contextLimit,
|
||||
@@ -508,7 +506,6 @@ func TestOpenAIReasoningRoundTripStoreFalse(t *testing.T) {
|
||||
isDefault := true
|
||||
reasoningSummary := "auto"
|
||||
_, err := expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
|
||||
Provider: string(provider.Type),
|
||||
AIProviderID: &provider.ID,
|
||||
Model: "o4-mini",
|
||||
ContextLimit: &contextLimit,
|
||||
|
||||
@@ -2,6 +2,7 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -83,7 +84,7 @@ func (p *Server) directProviderHintAndProviderForConfig(
|
||||
modelConfig database.ChatModelConfig,
|
||||
) (string, *database.AIProvider, error) {
|
||||
if !modelConfig.AIProviderID.Valid {
|
||||
return modelConfig.Provider, nil, nil
|
||||
return "", nil, sql.ErrNoRows
|
||||
}
|
||||
provider, err := p.enabledAIProviderByID(ctx, modelConfig.AIProviderID.UUID)
|
||||
if err != nil {
|
||||
|
||||
@@ -125,7 +125,6 @@ func TestResolveModelRouteForConfigPreservesBaseURL(t *testing.T) {
|
||||
|
||||
server := &Server{db: db}
|
||||
route, err := server.resolveModelRouteForConfig(ctx, ownerID, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
}, chatprovider.ProviderAPIKeys{})
|
||||
require.NoError(t, err)
|
||||
@@ -238,7 +237,6 @@ func TestResolveModelRouteForConfigAIGatewayProviderAuth(t *testing.T) {
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Model: "gpt-4",
|
||||
Provider: "openai",
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
}
|
||||
|
||||
|
||||
@@ -102,17 +102,17 @@ func (p *Server) preferredShortTextCandidates(
|
||||
}
|
||||
|
||||
func selectPreferredConfiguredShortTextModelConfig(
|
||||
configs []database.ChatModelConfig,
|
||||
configs []database.GetEnabledChatModelConfigsRow,
|
||||
) (database.ChatModelConfig, bool) {
|
||||
for _, preferred := range preferredTitleModels {
|
||||
for _, config := range configs {
|
||||
if chatprovider.NormalizeProvider(config.Provider) != preferred.provider {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(config.Model), preferred.model) {
|
||||
if !strings.EqualFold(strings.TrimSpace(config.ChatModelConfig.Model), preferred.model) {
|
||||
continue
|
||||
}
|
||||
return config, true
|
||||
return config.ChatModelConfig, true
|
||||
}
|
||||
}
|
||||
return database.ChatModelConfig{}, false
|
||||
@@ -181,11 +181,18 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat)
|
||||
)
|
||||
return
|
||||
}
|
||||
providerType, err := route.providerHint()
|
||||
if err != nil {
|
||||
logger.Debug(titleCtx, "failed to resolve provider type for automatic title generation",
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
p.maybeGenerateChatTitle(
|
||||
turnCtx,
|
||||
chat,
|
||||
messages,
|
||||
modelConfig.Provider,
|
||||
providerType,
|
||||
modelConfig.Model,
|
||||
model,
|
||||
route,
|
||||
@@ -259,8 +266,16 @@ func (p *Server) maybeGenerateChatTitle(
|
||||
|
||||
var candidates []shortTextCandidate
|
||||
if overrideSet {
|
||||
overrideProvider, err := overrideRoute.providerHint()
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "failed to resolve provider type for title generation override",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
candidates = []shortTextCandidate{{
|
||||
provider: overrideConfig.Provider,
|
||||
provider: overrideProvider,
|
||||
model: overrideConfig.Model,
|
||||
route: overrideRoute,
|
||||
lm: overrideModel,
|
||||
|
||||
@@ -391,8 +391,7 @@ func TestMaybeGenerateChatTitlePreservesUpdatedAt(t *testing.T) {
|
||||
CentralApiKeyEnabled: true,
|
||||
})
|
||||
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "test-model",
|
||||
Model: "test-model",
|
||||
})
|
||||
|
||||
userPrompt := "summarize failed workspace build logs"
|
||||
@@ -586,24 +585,23 @@ func Test_selectPreferredConfiguredShortTextModelConfig(t *testing.T) {
|
||||
t.Run("chooses the highest-priority configured lightweight model", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configs := []database.ChatModelConfig{
|
||||
{Provider: preferredTitleModels[2].provider, Model: preferredTitleModels[2].model},
|
||||
{Provider: preferredTitleModels[1].provider, Model: preferredTitleModels[1].model},
|
||||
{Provider: "openai", Model: "gpt-4.1"},
|
||||
configs := []database.GetEnabledChatModelConfigsRow{
|
||||
{ChatModelConfig: database.ChatModelConfig{Model: preferredTitleModels[2].model}, Provider: preferredTitleModels[2].provider},
|
||||
{ChatModelConfig: database.ChatModelConfig{Model: preferredTitleModels[1].model}, Provider: preferredTitleModels[1].provider},
|
||||
{ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1"}, Provider: "openai"},
|
||||
}
|
||||
|
||||
got, ok := selectPreferredConfiguredShortTextModelConfig(configs)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, preferredTitleModels[1].provider, got.Provider)
|
||||
require.Equal(t, preferredTitleModels[1].model, got.Model)
|
||||
})
|
||||
|
||||
t.Run("returns false when no preferred lightweight model is configured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, ok := selectPreferredConfiguredShortTextModelConfig([]database.ChatModelConfig{{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4.1",
|
||||
got, ok := selectPreferredConfiguredShortTextModelConfig([]database.GetEnabledChatModelConfigsRow{{
|
||||
ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1"},
|
||||
Provider: "openai",
|
||||
}})
|
||||
require.False(t, ok)
|
||||
require.Equal(t, database.ChatModelConfig{}, got)
|
||||
|
||||
@@ -161,39 +161,6 @@ func personalModelOverrideContextForSubagent(
|
||||
}
|
||||
}
|
||||
|
||||
func validateModelConfigAndResolveProvider(
|
||||
modelConfig database.ChatModelConfig,
|
||||
) (database.ChatModelConfig, string, error) {
|
||||
if !modelConfig.Enabled {
|
||||
return database.ChatModelConfig{}, "", sql.ErrNoRows
|
||||
}
|
||||
providerName, _, err := chatprovider.ResolveModelWithProviderHint(
|
||||
modelConfig.Model,
|
||||
modelConfig.Provider,
|
||||
)
|
||||
if err != nil {
|
||||
return database.ChatModelConfig{}, "", xerrors.Errorf(
|
||||
"%w: %v",
|
||||
errInvalidModelOverrideMetadata,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return modelConfig, providerName, nil
|
||||
}
|
||||
|
||||
func enabledProviderContainsName(
|
||||
providers []database.AIProvider,
|
||||
providerName string,
|
||||
) bool {
|
||||
normalizedProviderName := chatprovider.NormalizeProvider(providerName)
|
||||
for _, provider := range providers {
|
||||
if chatprovider.NormalizeProvider(string(provider.Type)) == normalizedProviderName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func userCanUseProviderKeys(
|
||||
providerKeys chatprovider.ProviderAPIKeys,
|
||||
providerName string,
|
||||
@@ -542,18 +509,8 @@ func (p *Server) resolveModelConfigAndNormalizedProvider(
|
||||
}
|
||||
return modelConfig, providerName, nil
|
||||
}
|
||||
modelConfig, providerName, err := validateModelConfigAndResolveProvider(modelConfig)
|
||||
if err != nil {
|
||||
return database.ChatModelConfig{}, "", err
|
||||
}
|
||||
enabledProviders, err := p.configCache.EnabledProviders(ctx)
|
||||
if err != nil {
|
||||
return database.ChatModelConfig{}, "", err
|
||||
}
|
||||
if !enabledProviderContainsName(enabledProviders, providerName) {
|
||||
return database.ChatModelConfig{}, "", sql.ErrNoRows
|
||||
}
|
||||
return modelConfig, providerName, nil
|
||||
// Active configs carry a provider FK; resolved above. Missing FK means no usable config.
|
||||
return database.ChatModelConfig{}, "", sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (p *Server) subagentTools(
|
||||
|
||||
@@ -213,7 +213,6 @@ func seedInternalChatDeps(
|
||||
})
|
||||
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
IsDefault: true,
|
||||
})
|
||||
@@ -580,8 +579,7 @@ func TestResolveChatModel_AIProviderDisabled(t *testing.T) {
|
||||
user, org, _ := seedInternalChatDeps(t, db)
|
||||
provider := insertInternalAIProvider(t, db, database.AIProviderTypeOpenai, "provider-api-key", false)
|
||||
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
Model: "gpt-4o-mini",
|
||||
AIProviderID: uuid.NullUUID{
|
||||
UUID: provider.ID,
|
||||
Valid: true,
|
||||
@@ -714,11 +712,30 @@ func insertInternalChatModelConfigWithOptions(
|
||||
) database.ChatModelConfig {
|
||||
t.Helper()
|
||||
|
||||
// Reuse the newest AI provider of this type (creating a bare credential-less
|
||||
// one only when none exists) so the config links the provider already
|
||||
// carrying the test's credentials, or lack thereof, rather than a fresh one.
|
||||
providers, err := db.GetAIProviders(context.Background(), database.GetAIProvidersParams{IncludeDisabled: true})
|
||||
require.NoError(t, err)
|
||||
var aiProvider database.AIProvider
|
||||
for _, candidate := range providers {
|
||||
if candidate.Type != database.AIProviderType(provider) {
|
||||
continue
|
||||
}
|
||||
if aiProvider.ID == uuid.Nil || candidate.CreatedAt.After(aiProvider.CreatedAt) {
|
||||
aiProvider = candidate
|
||||
}
|
||||
}
|
||||
if aiProvider.ID == uuid.Nil {
|
||||
aiProvider = dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderType(provider),
|
||||
})
|
||||
}
|
||||
modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
Options: options,
|
||||
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
Options: options,
|
||||
}, func(p *database.InsertChatModelConfigParams) {
|
||||
p.Enabled = enabled
|
||||
})
|
||||
@@ -1448,7 +1465,6 @@ func TestResolveConfiguredModelOverride_AcceptsAmbientCredentialsProvider(
|
||||
ownerID := uuid.New()
|
||||
modelConfig := database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Provider: "bedrock",
|
||||
Model: "anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
DisplayName: "Ambient Bedrock Override",
|
||||
Enabled: true,
|
||||
@@ -2115,7 +2131,7 @@ func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *tes
|
||||
currentTurnModel := insertInternalChatModelConfig(
|
||||
t, db, "explore-missing-user-key-current-"+uuid.NewString(), true,
|
||||
)
|
||||
dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
overrideProvider := dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "openai-compat",
|
||||
DisplayName: "OpenAI Compat",
|
||||
}, func(p *database.InsertChatProviderParams) {
|
||||
@@ -2126,9 +2142,9 @@ func TestSpawnAgent_ExploreFallsBackWhenOverrideCredentialsAreUnavailable(t *tes
|
||||
})
|
||||
|
||||
overrideModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai-compat",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Explore Override Missing User Key",
|
||||
AIProviderID: uuid.NullUUID{UUID: overrideProvider.ID, Valid: true},
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Explore Override Missing User Key",
|
||||
})
|
||||
require.NoError(t, db.UpsertChatExploreModelOverride(ctx, overrideModel.ID.String()))
|
||||
parentChat := createInternalParentChat(
|
||||
@@ -2764,7 +2780,9 @@ func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) {
|
||||
insertEnabledAnthropicProvider(t, db, user.ID)
|
||||
workspace, build, agent := seedWorkspaceBinding(t, db, user.ID)
|
||||
|
||||
require.Equal(t, "openai", model.Provider, "seed helper must create an OpenAI model")
|
||||
seedProvider, err := db.GetAIProviderByID(ctx, model.AIProviderID.UUID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "openai", string(seedProvider.Type), "seed helper must create an OpenAI model")
|
||||
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
@@ -2806,7 +2824,7 @@ func TestSpawnAgent_ComputerUseUsesComputerUseModelNotParent(t *testing.T) {
|
||||
assert.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
|
||||
computerUseModelProvider, computerUseModelName, ok := chattool.DefaultComputerUseModel(chattool.ComputerUseProviderAnthropic)
|
||||
require.True(t, ok)
|
||||
assert.NotEqual(t, model.Provider, computerUseModelProvider,
|
||||
assert.NotEqual(t, string(seedProvider.Type), computerUseModelProvider,
|
||||
"computer use model provider must differ from parent model provider")
|
||||
assert.Equal(t, "anthropic", computerUseModelProvider)
|
||||
assert.NotEmpty(t, computerUseModelName)
|
||||
@@ -3668,7 +3686,6 @@ func TestAwaitSubagentCompletion(t *testing.T) {
|
||||
BaseUrl: providerServer.URL,
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
})
|
||||
|
||||
@@ -873,7 +873,7 @@ func newTaskTestFixture(t *testing.T) *taskTestFixture {
|
||||
DisplayName: "openai",
|
||||
BaseUrl: "http://example.invalid",
|
||||
})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{Provider: "openai", IsDefault: true})
|
||||
model := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{IsDefault: true})
|
||||
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: user.ID})
|
||||
return &taskTestFixture{db: db, pubsub: newTaskRecordingPubsub(ps), sqlDB: sqlDB, user: user, org: org, model: model, apiKey: apiKey}
|
||||
}
|
||||
|
||||
@@ -348,6 +348,8 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
chat, messages := titleOverrideTestChatAndMessages(t)
|
||||
overrideConfig := titleOverrideModelConfig("gpt-4.1", true)
|
||||
providerID := uuid.New()
|
||||
overrideConfig.AIProviderID = uuid.NullUUID{UUID: providerID, Valid: true}
|
||||
|
||||
var requestCount atomic.Int32
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
@@ -355,6 +357,12 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(
|
||||
require.Equal(t, overrideConfig.Model, req.Model)
|
||||
return chattest.OpenAINonStreamingResponse(`{"title":""}`)
|
||||
})
|
||||
provider := database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}
|
||||
keys := titleOverrideOpenAIKeys(serverURL)
|
||||
fallbackModel := &chattest.FakeModel{
|
||||
GenerateObjectFn: func(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
@@ -365,8 +373,11 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback(
|
||||
|
||||
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{Type: database.AIProviderTypeOpenai, Enabled: true}}, nil)
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), []uuid.UUID{uuid.Nil}).Return(nil, nil)
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
|
||||
ProviderID: providerID,
|
||||
APIKey: "test-key",
|
||||
}}, nil).AnyTimes()
|
||||
|
||||
generated := &generatedChatTitle{}
|
||||
server := titleOverrideTestServer(db, logger)
|
||||
@@ -398,18 +409,34 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnset(t *testing.T) {
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
chat, _ := titleOverrideTestChatAndMessages(t)
|
||||
providerID := uuid.New()
|
||||
preferredConfig := database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Provider: preferredTitleModels[1].provider,
|
||||
Model: preferredTitleModels[1].model,
|
||||
Enabled: true,
|
||||
ID: uuid.New(),
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
Model: preferredTitleModels[1].model,
|
||||
Enabled: true,
|
||||
}
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
t.Fatal("model construction should not call the provider")
|
||||
return chattest.OpenAIResponse{}
|
||||
})
|
||||
provider := database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil)
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.ChatModelConfig{
|
||||
{Provider: "openai", Model: "gpt-4.1", Enabled: true},
|
||||
preferredConfig,
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{
|
||||
{ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1", Enabled: true}, Provider: "openai"},
|
||||
{ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider},
|
||||
}, nil)
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
|
||||
ProviderID: providerID,
|
||||
APIKey: "test-key",
|
||||
}}, nil).AnyTimes()
|
||||
|
||||
server := titleOverrideTestServer(db, logger)
|
||||
model, gotConfig, _, err := server.resolveManualTitleModel(
|
||||
@@ -435,7 +462,6 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnsetAIProvider(t *testi
|
||||
providerID := uuid.New()
|
||||
preferredConfig := database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Provider: preferredTitleModels[1].provider,
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
Model: preferredTitleModels[1].model,
|
||||
Enabled: true,
|
||||
@@ -452,8 +478,8 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnsetAIProvider(t *testi
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", nil)
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.ChatModelConfig{
|
||||
preferredConfig,
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{
|
||||
{ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider},
|
||||
}, nil)
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil)
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
|
||||
@@ -483,18 +509,34 @@ func TestResolveManualTitleModel_TitleGenerationOverrideReadDBError(t *testing.T
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
chat, _ := titleOverrideTestChatAndMessages(t)
|
||||
providerID := uuid.New()
|
||||
preferredConfig := database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Provider: preferredTitleModels[1].provider,
|
||||
Model: preferredTitleModels[1].model,
|
||||
Enabled: true,
|
||||
ID: uuid.New(),
|
||||
AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true},
|
||||
Model: preferredTitleModels[1].model,
|
||||
Enabled: true,
|
||||
}
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
t.Fatal("model construction should not call the provider")
|
||||
return chattest.OpenAIResponse{}
|
||||
})
|
||||
provider := database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return("", sql.ErrConnDone)
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.ChatModelConfig{
|
||||
{Provider: "openai", Model: "gpt-4.1", Enabled: true},
|
||||
preferredConfig,
|
||||
db.EXPECT().GetEnabledChatModelConfigs(gomock.Any()).Return([]database.GetEnabledChatModelConfigsRow{
|
||||
{ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1", Enabled: true}, Provider: "openai"},
|
||||
{ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider},
|
||||
}, nil)
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
|
||||
ProviderID: providerID,
|
||||
APIKey: "test-key",
|
||||
}}, nil).AnyTimes()
|
||||
|
||||
server := titleOverrideTestServer(db, logger)
|
||||
model, gotConfig, _, err := server.resolveManualTitleModel(
|
||||
@@ -518,11 +560,26 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUsable(t *testing.T)
|
||||
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}
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
t.Fatal("model construction should not call the provider")
|
||||
return chattest.OpenAIResponse{}
|
||||
})
|
||||
provider := database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
BaseUrl: serverURL,
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{Type: database.AIProviderTypeOpenai, Enabled: true}}, nil)
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{
|
||||
ProviderID: providerID,
|
||||
APIKey: "test-key",
|
||||
}}, nil).AnyTimes()
|
||||
|
||||
server := titleOverrideTestServer(db, logger)
|
||||
model, gotConfig, _, err := server.resolveManualTitleModel(
|
||||
@@ -546,11 +603,18 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te
|
||||
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}
|
||||
provider := database.AIProvider{
|
||||
ID: providerID,
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
db.EXPECT().GetChatTitleGenerationModelOverride(gomock.Any()).Return(overrideConfig.ID.String(), nil)
|
||||
db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil)
|
||||
db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{Type: database.AIProviderTypeOpenai, Enabled: true}}, nil)
|
||||
db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(provider, nil).AnyTimes()
|
||||
db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return(nil, nil).AnyTimes()
|
||||
|
||||
server := titleOverrideTestServer(db, logger)
|
||||
model, gotConfig, _, err := server.resolveManualTitleModel(
|
||||
@@ -730,10 +794,9 @@ func titleOverrideTestServer(db database.Store, logger slog.Logger) *Server {
|
||||
|
||||
func titleOverrideModelConfig(model string, enabled bool) database.ChatModelConfig {
|
||||
return database.ChatModelConfig{
|
||||
ID: uuid.New(),
|
||||
Provider: "openai",
|
||||
Model: model,
|
||||
Enabled: enabled,
|
||||
ID: uuid.New(),
|
||||
Model: model,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) {
|
||||
|
||||
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
Provider: "openai",
|
||||
Model: "test-model",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
@@ -138,7 +137,6 @@ func TestPendingChatPersistsSummaryButSkipsWebPush(t *testing.T) {
|
||||
|
||||
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
Provider: "openai",
|
||||
Model: "test-model",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
@@ -222,7 +220,6 @@ func TestSuccessfulChildChatOutcomeSkipsSummaryAndWebPush(t *testing.T) {
|
||||
|
||||
modelCfg, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
Provider: "openai",
|
||||
Model: "test-model",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true},
|
||||
|
||||
Reference in New Issue
Block a user