diff --git a/app/src/ai/blocklist/block.rs b/app/src/ai/blocklist/block.rs index 5d9db9b2..78636d8c 100644 --- a/app/src/ai/blocklist/block.rs +++ b/app/src/ai/blocklist/block.rs @@ -1720,7 +1720,7 @@ impl AIBlock { me.finish(FinishReason::Complete, ctx); } AIBlockOutputStatus::Failed { error, .. } => { - me.maybe_create_aws_bedrock_credentials_error_view(&error, ctx); + me.maybe_create_aws_bedrock_credentials_error_view(&error, false, ctx); me.finish(FinishReason::Error, ctx); } AIBlockOutputStatus::Cancelled { .. } => { @@ -2119,7 +2119,7 @@ impl AIBlock { }, ctx ); - self.maybe_create_aws_bedrock_credentials_error_view(&error, ctx); + self.maybe_create_aws_bedrock_credentials_error_view(&error, true, ctx); // There are no actions to be taken in this block, it is finished. self.finish(FinishReason::Error, ctx); } @@ -4372,6 +4372,7 @@ impl AIBlock { fn maybe_create_aws_bedrock_credentials_error_view( &mut self, error: &RenderableAIError, + attempt_auto_login: bool, ctx: &mut ViewContext, ) { // Only create the view for AWS Bedrock credentials errors @@ -4386,7 +4387,8 @@ impl AIBlock { let ai_settings = AISettings::as_ref(ctx); let login_command = ai_settings.bedrock_auth_refresh_command.value().clone(); - let auto_login_enabled = *ai_settings.bedrock_auto_login.value(); + // Recreating a historical error view must not restart authentication. + let auto_login_enabled = attempt_auto_login && *ai_settings.bedrock_auto_login.value(); // If auto-login is enabled, run the login command automatically if auto_login_enabled { diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index a5415298..d9798ea5 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -1046,7 +1046,9 @@ impl LLMPreferences { configured_models }; - if !single_provider_models.is_empty() { + // The registry replaces legacy settings. Otherwise stale legacy credentials + // win duplicate model IDs before the configured provider is considered. + if settings.openai_providers.value().is_empty() && !single_provider_models.is_empty() { let base_url = settings.openai_base_url.value().clone(); let api_key = { let key = settings.openai_api_key.value().clone(); @@ -1170,7 +1172,7 @@ impl LLMPreferences { location: provider_location.clone(), model: Some(model.model_id.clone()), reasoning_effort: reasoning_effort.clone(), - max_input_tokens: Some(openai_model_context_size(model)), + max_input_tokens: Some(openai_model_context_size(model, provider_kind)), max_output_tokens: model.max_output_tokens, supports_system_messages: model.supports_system_messages(), }; @@ -1487,7 +1489,7 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] pub fn fetch_openai_models_from_endpoint(&mut self, ctx: &mut ModelContext) { let settings = AISettings::as_ref(ctx); - if !settings.is_openai_provider_enabled() { + if !settings.is_openai_provider_enabled() || !settings.openai_providers.value().is_empty() { return; } @@ -1833,6 +1835,7 @@ impl LLMPreferences { vision_supported: true, context_size: model.context_size.unwrap_or(128_000), max_input_tokens: model.context_size, + default_context_size: None, max_output_tokens: None, provider: None, supports_system_messages: Some(true), @@ -2626,8 +2629,18 @@ fn get_new_agent_mode_choices( } #[cfg(not(target_family = "wasm"))] -fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 { - model.max_input_tokens.unwrap_or(model.context_size) +fn openai_model_context_size(model: &OpenAIModelConfig, provider_kind: OpenAIProviderKind) -> u32 { + // Older saved ChatGPT catalogs stored the default window in max_input_tokens. + // Preserve their larger usable window until the catalog refresh separates them. + if provider_kind == OpenAIProviderKind::ChatGPTSubscription + && model.default_context_size.is_none() + { + return model.context_size; + } + model + .max_input_tokens + .unwrap_or(model.context_size) + .min(model.context_size) } /// Merges endpoint metadata into a provider's configured models without @@ -2721,25 +2734,24 @@ fn openai_model_context_window( model: &OpenAIModelConfig, provider_kind: OpenAIProviderKind, ) -> LLMContextWindow { - let default_context_size = openai_model_context_size(model); - let max_context_size = if provider_kind == OpenAIProviderKind::ChatGPTSubscription { - model.context_size.max(default_context_size) - } else { - default_context_size - }; + let max_context_size = openai_model_context_size(model, provider_kind); + let default_context_size = model + .default_context_size + .or_else(|| { + // Compatibility with saved catalogs from before the fields were separated. + (provider_kind == OpenAIProviderKind::ChatGPTSubscription) + .then_some(model.max_input_tokens) + .flatten() + }) + .unwrap_or(max_context_size) + .min(max_context_size); LLMContextWindow { is_configurable: max_context_size > default_context_size, min: default_context_size, max: max_context_size, - // ChatGPT's catalog exposes `context_window` as the effective window - // and `max_context_window` as the model's actual maximum. Use the - // latter as the default so model refreshes do not reset the profile - // to the smaller effective window (for example, 272k). - default_max: if provider_kind == OpenAIProviderKind::ChatGPTSubscription { - max_context_size - } else { - default_context_size - }, + // Use the full usable window; the catalog default remains available as + // the smaller configurable budget, not as a hard input ceiling. + default_max: max_context_size, } } @@ -2888,7 +2900,9 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec Vec OpenAIModelConfig { vision_supported: false, context_size: 200_000, max_input_tokens: None, + default_context_size: None, max_output_tokens: None, provider: None, supports_system_messages: None, @@ -210,8 +211,18 @@ fn acp_agent( } fn empty_preferences() -> LLMPreferences { + // Production defaults include a UI placeholder; inventory tests start empty. + let mut models_by_feature = ModelsByFeature::default(); + models_by_feature.agent_mode.choices.clear(); + models_by_feature.coding.choices.clear(); + if let Some(cli) = &mut models_by_feature.cli_agent { + cli.choices.clear(); + } + if let Some(computer_use) = &mut models_by_feature.computer_use { + computer_use.choices.clear(); + } LLMPreferences { - models_by_feature: ModelsByFeature::default(), + models_by_feature, last_update: None, base_llm_for_terminal_view: HashMap::new(), custom_llms: Vec::new(), @@ -791,7 +802,8 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() { assert_eq!(models[0].display_name, "GPT-5.6-Sol"); assert!(models[0].vision_supported); assert_eq!(models[0].context_size, 828_400); - assert_eq!(models[0].max_input_tokens, Some(258_400)); + assert_eq!(models[0].max_input_tokens, None); + assert_eq!(models[0].default_context_size, Some(258_400)); assert_eq!(models[0].reasoning_efforts, ["low", "xhigh", "ultra"]); assert_eq!(models[0].provider.as_deref(), Some("openai")); assert_eq!(models[0].supports_system_messages, Some(true)); @@ -799,7 +811,8 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() { assert_eq!(models[1].model_id, "gpt-text-only"); assert!(!models[1].vision_supported); assert_eq!(models[1].context_size, 115_200); - assert_eq!(models[1].max_input_tokens, Some(115_200)); + assert_eq!(models[1].max_input_tokens, None); + assert_eq!(models[1].default_context_size, Some(115_200)); let configurable = openai_model_context_window( &models[0], @@ -812,10 +825,10 @@ fn chatgpt_codex_models_parse_visible_catalog_entries() { let fixed = openai_model_context_window(&models[0], crate::settings::OpenAIProviderKind::LiteLLM); - assert!(!fixed.is_configurable); + assert!(fixed.is_configurable); assert_eq!(fixed.min, 258_400); - assert_eq!(fixed.default_max, 258_400); - assert_eq!(fixed.max, 258_400); + assert_eq!(fixed.default_max, 828_400); + assert_eq!(fixed.max, 828_400); } #[test] @@ -834,17 +847,100 @@ fn chatgpt_catalog_merge_drops_stale_models_but_preserves_overrides() { discovered.display_name = "GPT-5.6-Sol".to_string(); discovered.vision_supported = true; discovered.context_size = 828_400; - discovered.max_input_tokens = Some(258_400); + discovered.default_context_size = Some(258_400); let merged = merge_discovered_chatgpt_subscription_models(&[existing, stale], vec![discovered]); assert_eq!(merged.len(), 1); assert_eq!(merged[0].model_id, "gpt-5.6-sol"); assert_eq!(merged[0].context_size, 828_400); - assert_eq!(merged[0].max_input_tokens, Some(258_400)); + assert_eq!(merged[0].max_input_tokens, None); + assert_eq!(merged[0].default_context_size, Some(258_400)); assert!(!merged[0].enabled); assert_eq!( merged[0].capability_override("vision"), crate::settings::ModelCapabilityOverride::Unsupported ); } + +#[test] +fn provider_context_budget_respects_hard_input_and_total_limits() { + let mut model = openai_model("limited-model"); + model.context_size = 872_000; + model.max_input_tokens = Some(272_000); + let window = openai_model_context_window(&model, OpenAIProviderKind::LiteLLM); + assert_eq!(window.max, 272_000); + assert_eq!(window.default_max, 272_000); + assert!(!window.is_configurable); + + model.max_input_tokens = Some(1_000_000); + assert_eq!( + openai_model_context_size(&model, OpenAIProviderKind::LiteLLM), + 872_000 + ); + + model.default_context_size = Some(272_000); + model.max_input_tokens = Some(400_000); + let window = openai_model_context_window(&model, OpenAIProviderKind::ChatGPTSubscription); + assert_eq!(window.min, 272_000); + assert_eq!(window.max, 400_000); + assert_eq!( + openai_model_context_size(&model, OpenAIProviderKind::ChatGPTSubscription), + window.max + ); +} + +#[test] +fn legacy_chatgpt_default_does_not_limit_routing() { + let mut model = openai_model("legacy-model"); + model.context_size = 872_000; + model.max_input_tokens = Some(272_000); + let window = openai_model_context_window(&model, OpenAIProviderKind::ChatGPTSubscription); + assert_eq!(window.min, 272_000); + assert_eq!(window.max, 872_000); + assert_eq!( + openai_model_context_size(&model, OpenAIProviderKind::ChatGPTSubscription), + window.max + ); +} + +#[test] +fn provider_registry_overrides_legacy_endpoint_for_duplicate_models() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + AISettings::handle(&app).update(&mut app, |settings, ctx| { + settings.openai_enabled.set_value(true, ctx).unwrap(); + settings + .openai_base_url + .set_value("https://stale.test/v1".to_owned(), ctx) + .unwrap(); + settings + .openai_models + .set_value(vec![openai_model("shared-model")], ctx) + .unwrap(); + settings + .openai_providers + .set_value( + vec![OpenAIProviderConfig { + kind: OpenAIProviderKind::LiteLLM, + enabled: true, + name: "Current LiteLLM".to_owned(), + base_url: "https://current.test/v1".to_owned(), + api_key: Some("current-key".to_owned()), + project_id: None, + location: None, + models: vec![openai_model("shared-model")], + }], + ctx, + ) + .unwrap(); + }); + let mut preferences = empty_preferences(); + app.read(|ctx| preferences.inject_openai_models(ctx)); + let config = preferences + .openai_client_config_for_model("shared-model") + .unwrap(); + assert_eq!(config.base_url, "https://current.test/v1"); + assert_eq!(config.api_key.as_deref(), Some("current-key")); + }); +} diff --git a/app/src/ai/runtime/event_translator.rs b/app/src/ai/runtime/event_translator.rs index c8d6bd23..739bed4f 100644 --- a/app/src/ai/runtime/event_translator.rs +++ b/app/src/ai/runtime/event_translator.rs @@ -43,6 +43,8 @@ pub(crate) struct RuntimeResponseTranslator { /// Usage for the most recent model call. usage: Usage, context_usage: Option<(u64, u64)>, + // Set before starting each model call, including failures before TurnStarted. + authentication_target: Option<(api::LlmProvider, String)>, } /// Projects a multi-turn provider run into one existing Galaxy response stream. @@ -98,8 +100,16 @@ impl ProviderRunResponseProjector { ProviderRunProjection::ModelRetry { .. } => { Ok(self.translator.discard_failed_turn_output()) } - ProviderRunProjection::ModelTurnRequested { .. } - | ProviderRunProjection::ModelTurnFinished { .. } => Ok(Vec::new()), + ProviderRunProjection::ModelTurnRequested { + runtime_id, + model_id, + .. + } => { + self.translator.authentication_target = + Some((authentication_provider_for_runtime(&runtime_id), model_id)); + Ok(Vec::new()) + } + ProviderRunProjection::ModelTurnFinished { .. } => Ok(Vec::new()), ProviderRunProjection::ToolBatchReady { batch } => { if !self.plan_tasks_projected { if let Some(todos) = todos_from_plan_batch(&batch) { @@ -149,6 +159,19 @@ impl ProviderRunResponseProjector { } } +// Runtime IDs identify the transport, unlike model IDs: a Bedrock-backed model +// behind LiteLLM still authenticates against the OpenAI-compatible endpoint. +fn authentication_provider_for_runtime(runtime_id: &str) -> api::LlmProvider { + match runtime_id.split_once(':').map(|(runtime, _)| runtime) { + Some("rig-bedrock") => api::LlmProvider::AwsBedrock, + Some("rig-openai-compatible" | "rig-chatgpt-subscription") => api::LlmProvider::Openai, + Some("rig-anthropic") => api::LlmProvider::Anthropic, + Some("rig-gemini" | "rig-vertex-ai") => api::LlmProvider::Google, + // Unrecognized runtimes must never trigger provider-specific login commands. + Some(_) | None => api::LlmProvider::Unknown, + } +} + // Only explicit multi-step checklists in a plan become tasks. Other document bullets // (risks, examples, requirements) are not executable tasks. fn todos_from_plan_batch( @@ -251,6 +274,7 @@ impl RuntimeResponseTranslator { has_visible_output: false, usage: Usage::default(), context_usage: None, + authentication_target: None, } } @@ -528,8 +552,15 @@ impl RuntimeResponseTranslator { .is_some_and(|error| error.kind == galaxy_agent_core::AgentErrorKind::Authentication) { stream_finished::Reason::InvalidApiKey(stream_finished::InvalidApiKey { - provider: warp_multi_agent_api::LlmProvider::AwsBedrock as i32, - model_name: self.config.model_id.clone(), + provider: self + .authentication_target + .as_ref() + .map_or(api::LlmProvider::Unknown, |(provider, _)| *provider) + as i32, + model_name: self + .authentication_target + .as_ref() + .map_or_else(|| self.config.model_id.clone(), |(_, model)| model.clone()), }) } else { stream_finished::Reason::InternalError(stream_finished::InternalError { diff --git a/app/src/ai/runtime/event_translator_tests.rs b/app/src/ai/runtime/event_translator_tests.rs index d25bd524..39180670 100644 --- a/app/src/ai/runtime/event_translator_tests.rs +++ b/app/src/ai/runtime/event_translator_tests.rs @@ -640,3 +640,63 @@ fn plan_tasks_require_multiple_explicit_checklist_items_in_a_task_section() { assert_eq!(todos[1].title, "Test stale callbacks"); assert_ne!(todos[0].id, todos[1].id); } + +#[test] +fn authentication_failures_identify_the_requested_transport_before_stream_start() { + for (runtime, expected) in [ + ( + "rig-openai-compatible", + warp_multi_agent_api::LlmProvider::Openai, + ), + ( + "rig-chatgpt-subscription", + warp_multi_agent_api::LlmProvider::Openai, + ), + ("rig-bedrock", warp_multi_agent_api::LlmProvider::AwsBedrock), + ( + "rig-anthropic", + warp_multi_agent_api::LlmProvider::Anthropic, + ), + ("rig-gemini", warp_multi_agent_api::LlmProvider::Google), + ("rig-vertex-ai", warp_multi_agent_api::LlmProvider::Google), + ("unrecognized", warp_multi_agent_api::LlmProvider::Unknown), + ] { + let mut projector = ProviderRunResponseProjector::new(provider_translator().config); + // A previous Bedrock turn must not leak its identity into the next request. + for runtime_id in [ + "rig-bedrock:old-model".to_owned(), + format!("{runtime}:new-model"), + ] { + projector + .project(ProviderRunProjection::ModelTurnRequested { + work_id: galaxy_agent_core::ExternalWorkId { + run_id: galaxy_agent_core::ProviderRunId::new("run"), + epoch: galaxy_agent_core::RunEpoch::new(1), + }, + profile: galaxy_agent_core::ProviderRequestProfile::new("cli"), + runtime_id, + model_id: "new-model".to_owned(), + retry_attempt: 0, + }) + .expect("requested turn projection"); + } + let error = galaxy_agent_core::AgentError::new( + galaxy_agent_core::AgentErrorKind::Authentication, + "Unauthorized", + ); + let events = + projector + .translator + .provider_failure("Unauthorized", Some(&error), &Usage::default()); + let Some(response_event::Type::Finished(finished)) = &events.last().unwrap().r#type else { + panic!("expected a finished event"); + }; + let Some(response_event::stream_finished::Reason::InvalidApiKey(details)) = + &finished.reason + else { + panic!("expected authentication failure"); + }; + assert_eq!(details.provider, expected as i32, "{runtime}"); + assert_eq!(details.model_name, "new-model"); + } +} diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 7848451e..317cce5b 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -857,13 +857,19 @@ pub struct OpenAIModelConfig { default = "default_context_size", alias = "token_size", alias = "max_model_len", - alias = "context_window" + alias = "context_window", + alias = "max_context_window" )] #[schemars(description = "Maximum context window size in tokens.")] pub context_size: u32, #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(description = "Optional maximum input tokens supported by this model.")] pub max_input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars( + description = "Optional default context budget, distinct from the hard input limit." + )] + pub default_context_size: Option, #[serde( default, alias = "output_token_limit", diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 2ee3c93e..7abed1ad 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -7242,6 +7242,9 @@ impl ProviderSettingsWidget { format!("Context: {}", model.context_size), ]; + if let Some(default_context_size) = model.default_context_size { + details.push(format!("Default context: {default_context_size}")); + } if let Some(max_input_tokens) = model.max_input_tokens { details.push(format!("Max input: {max_input_tokens}")); } diff --git a/app/src/settings_view/provider_setup_view.rs b/app/src/settings_view/provider_setup_view.rs index 575d2b27..7eb67d8f 100644 --- a/app/src/settings_view/provider_setup_view.rs +++ b/app/src/settings_view/provider_setup_view.rs @@ -782,6 +782,7 @@ impl ProviderSetupView { if let Ok(context_size) = editor.as_ref(ctx).buffer_text(ctx).parse() { model.context_size = context_size; model.max_input_tokens = Some(context_size); + model.default_context_size = Some(context_size); } } me.update_next_button(ctx);