diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c99c01d65..9017658c4 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index a64eb4db4..d69977935 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -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 } diff --git a/internal/ai/chat/service_test.go b/internal/ai/chat/service_test.go index f0505eafe..1e64e3fdf 100644 --- a/internal/ai/chat/service_test.go +++ b/internal/ai/chat/service_test.go @@ -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) diff --git a/internal/ai/model_resolution_test.go b/internal/ai/model_resolution_test.go index 1c010ccef..a3d8930de 100644 --- a/internal/ai/model_resolution_test.go +++ b/internal/ai/model_resolution_test.go @@ -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() diff --git a/internal/ai/modelresolution/model_resolution.go b/internal/ai/modelresolution/model_resolution.go index 363185253..e4d168dfc 100644 --- a/internal/ai/modelresolution/model_resolution.go +++ b/internal/ai/modelresolution/model_resolution.go @@ -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) { diff --git a/internal/ai/modelresolution/model_resolution_offline_test.go b/internal/ai/modelresolution/model_resolution_offline_test.go index b2782f771..d7d541d26 100644 --- a/internal/ai/modelresolution/model_resolution_offline_test.go +++ b/internal/ai/modelresolution/model_resolution_offline_test.go @@ -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) } }