Keep Assistant chat model routes explicit

Fail unusable selected chat routes instead of substituting a same-provider default so retries and errors stay tied to the chosen model.
This commit is contained in:
rcourtman 2026-06-08 07:21:38 +01:00
parent d89257f9d8
commit 091ffc2f3d
6 changed files with 55 additions and 21 deletions

View file

@ -508,11 +508,14 @@ deriving an older display status from `workflowStatusHistory`.
`packages/opencode/src/session/prompt.ts`. Pulse adapts that behavior for the
drawer instead of implementing automatic cross-provider route switching.
Primary interactive chat model resolution must use the explicit configured
chat route or a stable provider default without calling provider model
catalogs before the selected stream starts. Catalog-backed recommendation
belongs to settings/model-list and explicit route-recovery flows, not
`/api/ai/chat` first-response startup. Once the selected provider route
starts, transient pre-output transport failures may retry the same route and
chat route or, when no explicit chat route exists, a stable provider default
without calling provider model catalogs before the selected stream starts. If
an explicit chat route is unconfigured, retired, or recognized as a
specialized non-chat endpoint, `/api/ai/chat` must fail that selected route
visibly instead of substituting a same-provider default. Catalog-backed
recommendation belongs to settings/model-list and explicit route-recovery
flows, not `/api/ai/chat` first-response startup. Once the selected provider
route starts, transient pre-output transport failures may retry the same route and
must surface a `provider_retry` workflow state with `attempt`,
`max_attempts`, and `retry_after_ms` before sleeping. If same-route retry is
exhausted before visible output, Pulse must emit a normal provider error and

View file

@ -3688,7 +3688,7 @@ func (s *Service) createProvider() (providers.StreamingProvider, error) {
if chatModel == "" {
return nil, fmt.Errorf("no chat model configured")
}
if strings.TrimSpace(s.cfg.ChatModel) == "" || !modelresolution.IsModelUsableForChatWithConfig(s.cfg, s.cfg.GetChatModel()) {
if strings.TrimSpace(s.cfg.ChatModel) == "" {
s.cfg.ChatModel = chatModel
}

View file

@ -220,6 +220,20 @@ func TestService_CreateProvider(t *testing.T) {
}
}
func TestServiceCreateProviderRejectsUnusableConfiguredChatModelWithoutRewrite(t *testing.T) {
cfg := &config.AIConfig{
OpenAIAPIKey: "sk-test",
ChatModel: "openai:gpt-realtime-2",
}
service := &Service{cfg: cfg}
provider, err := service.createProvider()
assert.Error(t, err)
assert.Nil(t, provider)
assert.Contains(t, err.Error(), "selected chat model route")
assert.Equal(t, "openai:gpt-realtime-2", cfg.ChatModel)
}
func TestService_ExecuteStream_Failures(t *testing.T) {
service := &Service{}
err := service.ExecuteStream(context.Background(), ExecuteRequest{}, nil)

View file

@ -149,6 +149,24 @@ func TestResolveConfiguredChatProviderModel_SkipsSpecializedPreferredModel(t *te
}
}
func TestResolveConfiguredChatModel_RejectsSpecializedExplicitChatModel(t *testing.T) {
t.Parallel()
cfg := &config.AIConfig{
Enabled: true,
OpenAIAPIKey: "sk-test",
ChatModel: "openai:gpt-realtime-2",
}
got, err := ResolveConfiguredChatModel(context.Background(), cfg)
if err == nil {
t.Fatalf("ResolveConfiguredChatModel() error = nil, got model %q", got)
}
if !strings.Contains(err.Error(), "selected chat model route") || !strings.Contains(err.Error(), "gpt-realtime-2") {
t.Fatalf("ResolveConfiguredChatModel() error = %q, want selected chat model route error", err)
}
}
func TestResolveConfiguredModel_RejectsExplicitUnconfiguredRoute(t *testing.T) {
t.Parallel()

View file

@ -46,11 +46,7 @@ func ResolveConfiguredChatModel(ctx context.Context, cfg *config.AIConfig) (stri
return explicit, nil
}
if explicit != "" {
provider, err := selectedModelProvider(cfg, explicit)
if err != nil {
return "", err
}
return ResolveConfiguredChatProviderModel(ctx, cfg, provider)
return "", selectedChatModelProviderError(cfg, explicit)
}
configuredProviders := cfg.GetConfiguredProviders()
@ -75,11 +71,7 @@ func ResolveConfiguredChatModelOffline(cfg *config.AIConfig) (string, error) {
return explicit, nil
}
if explicit != "" {
provider, err := selectedModelProvider(cfg, explicit)
if err != nil {
return "", err
}
return ResolveConfiguredChatProviderModelOffline(cfg, provider)
return "", selectedChatModelProviderError(cfg, explicit)
}
configuredProviders := cfg.GetConfiguredProviders()
@ -116,6 +108,13 @@ func selectedModelProviderError(cfg *config.AIConfig, model string) error {
return fmt.Errorf("selected model route %q is not usable with the current Pulse Assistant config", strings.TrimSpace(model))
}
func selectedChatModelProviderError(cfg *config.AIConfig, model string) error {
if _, err := selectedModelProvider(cfg, model); err != nil {
return err
}
return fmt.Errorf("selected chat model route %q is not usable for Assistant chat with the current Pulse Assistant config", strings.TrimSpace(model))
}
// ResolveConfiguredChatProviderModelOffline resolves a chat-suitable model for
// a configured provider using only explicit config and stable provider defaults.
func ResolveConfiguredChatProviderModelOffline(cfg *config.AIConfig, provider string) (string, error) {

View file

@ -40,7 +40,7 @@ func TestResolveConfiguredChatModelOffline_UsesStableProviderDefault(t *testing.
}
}
func TestResolveConfiguredChatModelOffline_ReplacesSpecializedChatModelWithStableDefault(t *testing.T) {
func TestResolveConfiguredChatModelOffline_RejectsSpecializedExplicitChatModel(t *testing.T) {
t.Parallel()
cfg := &config.AIConfig{
@ -49,11 +49,11 @@ func TestResolveConfiguredChatModelOffline_ReplacesSpecializedChatModelWithStabl
}
got, err := ResolveConfiguredChatModelOffline(cfg)
if err != nil {
t.Fatalf("ResolveConfiguredChatModelOffline() error = %v", err)
if err == nil {
t.Fatalf("ResolveConfiguredChatModelOffline() error = nil, got model %q", got)
}
if want := config.DefaultModelForProvider(config.AIProviderOpenAI); got != want {
t.Fatalf("ResolveConfiguredChatModelOffline() = %q, want %q", got, want)
if !strings.Contains(err.Error(), "selected chat model route") || !strings.Contains(err.Error(), "gpt-realtime-2") {
t.Fatalf("ResolveConfiguredChatModelOffline() error = %q, want selected chat model route error", err)
}
}