diff --git a/CHANGELOG.md b/CHANGELOG.md index bf812d6469..13991ce8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Prefab `/provider` templates for OpenCode Zen, OpenCode Go, Agnes, and + SenseNova (#5350). First-class routes keep their existing key-only setup; + Agnes and SenseNova persist as named OpenAI-compatible tables with a fixed + URL and a common model list so the user only enters an API key. `P` opens + the template list; `T` tests the connection by probing `/models` and + refreshes status without treating a 2xx as model-ready. + ### Fixed +- `/model` no longer labels a failed Models.dev refresh as `cache failed`. + The picker keeps bundled or template rows and says + `refresh failed; catalog available`, matching `/provider`. OpenCode Zen's + fallback list is the full curated roster instead of only the default + model. + - Wide terminals and tmux panes fill the full available width again for the transcript and composer (#5322). The brief v0.9 session-shell side gutter is gone so expanding a pane rematerializes layout the same way shrinking does. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 446a6deea5..d59cb13aa7 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -11,6 +11,7 @@ pub mod pricing; pub mod provider; mod provider_defaults; mod provider_kind; +pub mod provider_templates; pub mod route; pub mod setup_state; pub mod user_constitution; @@ -26,6 +27,12 @@ pub use harness::{ pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase}; pub(crate) use provider_defaults::*; pub use provider_kind::ProviderKind; +pub use provider_templates::{ + AGNES_API_KEY_ENV, AGNES_BASE_URL, AGNES_DEFAULT_MODEL, AGNES_MODELS, AGNES_TEMPLATE_ID, + ProviderSetupTemplate, SENSENOVA_API_KEY_ENV, SENSENOVA_BASE_URL, SENSENOVA_DEFAULT_MODEL, + SENSENOVA_MODELS, SENSENOVA_TEMPLATE_ID, custom_provider_setup_templates, + provider_setup_template, provider_setup_templates, +}; pub use setup_state::{ ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity, InheritedConfigFacts, RuntimePostureSource, SetupState, SetupStep, StepEntry, StepStatus, diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs new file mode 100644 index 0000000000..b2b53e50cd --- /dev/null +++ b/crates/config/src/provider_templates.rs @@ -0,0 +1,222 @@ +//! Prefab third-party provider setup templates (#5350). +//! +//! First-class providers already have a default URL and catalog. These +//! templates exist so `/provider` can offer a key-only path for: +//! - first-class gateways users still treat as "paste a Base URL" (OpenCode +//! Zen / Go), and +//! - named OpenAI-compatible custom routes that are not ProviderKind variants +//! (Agnes, SenseNova). +//! +//! A `/models` 2xx from Test Connection is reachability only. It is not model +//! readiness. + +use crate::provider_kind::ProviderKind; +use crate::{ + DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENCODE_ZEN_BASE_URL, + DEFAULT_OPENCODE_ZEN_MODEL, OPENCODE_GO_CHAT_MODELS, +}; + +/// Agnes AI OpenAI-compatible gateway. +pub const AGNES_TEMPLATE_ID: &str = "agnes"; +pub const AGNES_BASE_URL: &str = "https://apihub.agnes-ai.com/v1"; +pub const AGNES_DEFAULT_MODEL: &str = "agnes-2.5-flash"; +pub const AGNES_API_KEY_ENV: &str = "AGNES_API_KEY"; +pub const AGNES_MODELS: &[&str] = &[AGNES_DEFAULT_MODEL, "agnes-2.0-flash", "agnes-1.5-flash"]; + +/// SenseTime SenseNova Token Plan (the issue's "Meituan Sensenova" target). +/// Chat models only — `sensenova-u1-fast` is image generation and is omitted. +pub const SENSENOVA_TEMPLATE_ID: &str = "sensenova"; +pub const SENSENOVA_BASE_URL: &str = "https://token.sensenova.cn/v1"; +pub const SENSENOVA_DEFAULT_MODEL: &str = "sensenova-6.7-flash-lite"; +pub const SENSENOVA_API_KEY_ENV: &str = "SENSENOVA_API_KEY"; +pub const SENSENOVA_MODELS: &[&str] = &[SENSENOVA_DEFAULT_MODEL, "deepseek-v4-flash", "glm-5.2"]; + +/// A built-in setup template: fixed URL + common models. The user supplies +/// only an API key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderSetupTemplate { + pub id: &'static str, + pub display_name: &'static str, + pub base_url: &'static str, + pub default_model: &'static str, + pub models: &'static [&'static str], + pub api_key_env: &'static str, + pub docs_url: Option<&'static str>, + pub credential_url: Option<&'static str>, + /// When set, selecting this template starts the existing first-class + /// key-only guided setup instead of creating a custom table. + pub first_class: Option, +} + +impl ProviderSetupTemplate { + /// Models shown in setup / `/model` when the live catalog is empty or + /// Models.dev refresh failed. + #[must_use] + pub fn picker_models(self) -> Vec<&'static str> { + match self.first_class { + Some(ProviderKind::OpencodeZen) => crate::route::opencode_zen_picker_models(), + Some(ProviderKind::OpencodeGo) => OPENCODE_GO_CHAT_MODELS.to_vec(), + _ => self.models.to_vec(), + } + } + + #[must_use] + pub fn is_custom(self) -> bool { + self.first_class.is_none() + } + + #[must_use] + pub fn is_first_class(self) -> bool { + self.first_class.is_some() + } +} + +const TEMPLATES: &[ProviderSetupTemplate] = &[ + ProviderSetupTemplate { + id: "opencode-zen", + display_name: "OpenCode Zen", + base_url: DEFAULT_OPENCODE_ZEN_BASE_URL, + default_model: DEFAULT_OPENCODE_ZEN_MODEL, + models: &[DEFAULT_OPENCODE_ZEN_MODEL], + api_key_env: "OPENCODE_ZEN_API_KEY", + docs_url: Some("https://opencode.ai/docs/zen/"), + credential_url: Some("https://opencode.ai/zen/"), + first_class: Some(ProviderKind::OpencodeZen), + }, + ProviderSetupTemplate { + id: "opencode-go", + display_name: "OpenCode Go", + base_url: DEFAULT_OPENCODE_GO_BASE_URL, + default_model: DEFAULT_OPENCODE_GO_MODEL, + models: OPENCODE_GO_CHAT_MODELS, + api_key_env: "OPENCODE_GO_API_KEY", + docs_url: Some("https://opencode.ai/docs/go/"), + credential_url: Some("https://opencode.ai/zen/"), + first_class: Some(ProviderKind::OpencodeGo), + }, + ProviderSetupTemplate { + id: AGNES_TEMPLATE_ID, + display_name: "Agnes", + base_url: AGNES_BASE_URL, + default_model: AGNES_DEFAULT_MODEL, + models: AGNES_MODELS, + api_key_env: AGNES_API_KEY_ENV, + docs_url: Some("https://agnes-ai.com/en/docs/overview"), + credential_url: Some("https://platform.agnes-ai.com/"), + first_class: None, + }, + ProviderSetupTemplate { + id: SENSENOVA_TEMPLATE_ID, + display_name: "Meituan Sensenova", + base_url: SENSENOVA_BASE_URL, + default_model: SENSENOVA_DEFAULT_MODEL, + models: SENSENOVA_MODELS, + api_key_env: SENSENOVA_API_KEY_ENV, + docs_url: Some("https://platform.sensenova.cn/token-plan"), + credential_url: Some("https://platform.sensenova.cn/token-plan"), + first_class: None, + }, +]; + +/// Every built-in setup template, first-class then custom. +#[must_use] +pub fn provider_setup_templates() -> &'static [ProviderSetupTemplate] { + TEMPLATES +} + +/// Look up a template by id or a documented alias. +#[must_use] +pub fn provider_setup_template(id: &str) -> Option<&'static ProviderSetupTemplate> { + let needle = id.trim().to_ascii_lowercase().replace('_', "-"); + TEMPLATES.iter().find(|template| { + template.id == needle + || template + .first_class + .is_some_and(|kind| kind.as_str() == needle) + || match needle.as_str() { + "zen" | "opencodezen" => template.id == "opencode-zen", + "opencodego" => template.id == "opencode-go", + "sense-nova" | "meituan-sensenova" | "meituan-sensenova-cn" => { + template.id == SENSENOVA_TEMPLATE_ID + } + _ => false, + } + }) +} + +/// Templates that persist as named `[providers.] kind = "openai-compatible"` +/// tables rather than a first-class ProviderKind. +#[must_use] +pub fn custom_provider_setup_templates() -> impl Iterator { + TEMPLATES.iter().filter(|template| template.is_custom()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn templates_have_fixed_urls_and_models() { + for template in provider_setup_templates() { + assert!( + template.base_url.starts_with("https://"), + "{} base URL must be https: {}", + template.id, + template.base_url + ); + assert!( + !template.picker_models().is_empty(), + "{} must list at least one model", + template.id + ); + assert!( + template + .picker_models() + .iter() + .any(|model| *model == template.default_model), + "{} default {} missing from picker models {:?}", + template.id, + template.default_model, + template.picker_models() + ); + assert!( + !template.api_key_env.is_empty(), + "{} must name an API key env", + template.id + ); + } + } + + #[test] + fn custom_template_ids_do_not_shadow_built_ins() { + for template in custom_provider_setup_templates() { + assert!( + ProviderKind::parse(template.id).is_none(), + "custom template '{}' shadows ProviderKind", + template.id + ); + } + } + + #[test] + fn first_class_templates_map_to_existing_kinds() { + let zen = provider_setup_template("opencode-zen").expect("zen"); + assert_eq!(zen.first_class, Some(ProviderKind::OpencodeZen)); + assert_eq!(zen.base_url, DEFAULT_OPENCODE_ZEN_BASE_URL); + assert!(zen.picker_models().len() > 1); + + let go = provider_setup_template("opencode-go").expect("go"); + assert_eq!(go.first_class, Some(ProviderKind::OpencodeGo)); + assert_eq!(go.base_url, DEFAULT_OPENCODE_GO_BASE_URL); + assert_eq!(go.picker_models(), OPENCODE_GO_CHAT_MODELS); + + let agnes = provider_setup_template("agnes").expect("agnes"); + assert!(agnes.is_custom()); + assert_eq!(agnes.base_url, AGNES_BASE_URL); + + let sense = provider_setup_template("meituan-sensenova").expect("sensenova alias"); + assert_eq!(sense.id, SENSENOVA_TEMPLATE_ID); + assert_eq!(sense.base_url, SENSENOVA_BASE_URL); + assert!(!sense.models.contains(&"sensenova-u1-fast")); + } +} diff --git a/crates/config/src/route/mod.rs b/crates/config/src/route/mod.rs index beda0f52ed..032437f978 100644 --- a/crates/config/src/route/mod.rs +++ b/crates/config/src/route/mod.rs @@ -44,7 +44,9 @@ pub use capabilities::{CapabilityState, RouteCapabilities}; pub use descriptor::{EndpointDescriptor, ProviderDescriptor}; pub use errors::RouteError; pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, WireModelId}; -pub use offering::{ProviderModelOffering, RouteLimits, bundled_offerings}; +pub use offering::{ + ProviderModelOffering, RouteLimits, bundled_offerings, opencode_zen_picker_models, +}; pub use resolver::{RouteRequest, RouteResolver}; #[cfg(test)] diff --git a/crates/config/src/route/offering.rs b/crates/config/src/route/offering.rs index f9b64fe79f..bf905c5ab3 100644 --- a/crates/config/src/route/offering.rs +++ b/crates/config/src/route/offering.rs @@ -143,6 +143,27 @@ pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[ "qwen3.5-plus", ]; +/// Logical default plus every documented Zen wire id, for picker fallbacks +/// when Models.dev is stale or failed. `gpt-5.6` is the user-facing default; +/// `gpt-5.6-sol` is the proven Responses wire id. +#[must_use] +pub fn opencode_zen_picker_models() -> Vec<&'static str> { + let mut models = vec![crate::DEFAULT_OPENCODE_ZEN_MODEL]; + for model in OPENCODE_ZEN_RESPONSES_MODELS + .iter() + .chain(OPENCODE_ZEN_MESSAGES_MODELS) + .chain(OPENCODE_ZEN_CHAT_MODELS) + { + if !models + .iter() + .any(|existing| existing.eq_ignore_ascii_case(model)) + { + models.push(*model); + } + } + models +} + pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[ "deepseek-v4-pro", "deepseek-v4-flash", diff --git a/crates/tui/src/commands/groups/core/provider.rs b/crates/tui/src/commands/groups/core/provider.rs index f7322f3e1f..ffdfcaf3ef 100644 --- a/crates/tui/src/commands/groups/core/provider.rs +++ b/crates/tui/src/commands/groups/core/provider.rs @@ -50,24 +50,13 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { return provider_fallback(app, model_arg); } if name.eq_ignore_ascii_case("setup") { - if model_arg.is_some_and(|raw| { - raw.eq_ignore_ascii_case("ds4") || raw.eq_ignore_ascii_case("dwarfstar") - }) { - return CommandResult::action(AppAction::OpenDs4Setup); - } - let provider = match model_arg { - None => None, - Some(raw) => match ApiProvider::parse(raw) { - Some(provider) => Some(provider), - None => { - return CommandResult::error(format!( - "Unknown provider '{raw}'. Expected: {}.", - ApiProvider::names_hint() - )); - } + return match model_arg { + None => CommandResult::action(AppAction::OpenProviderSetup { provider: None }), + Some(raw) => match provider_setup_action_for_name(raw) { + Ok(action) => CommandResult::action(action), + Err(message) => CommandResult::error(message), }, }; - return CommandResult::action(AppAction::OpenProviderSetup { provider }); } let Some(target) = ApiProvider::parse(name) else { @@ -118,6 +107,31 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { }) } +pub(in crate::commands) fn provider_setup_action_for_name(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("ds4") || raw.eq_ignore_ascii_case("dwarfstar") { + return Ok(AppAction::OpenDs4Setup); + } + if let Some(template) = codewhale_config::provider_setup_template(raw) { + if let Some(kind) = template.first_class { + return Ok(AppAction::OpenProviderSetup { + provider: Some(ApiProvider::from_kind(kind)), + }); + } + return Ok(AppAction::OpenTemplateSetup { + template_id: template.id.to_string(), + }); + } + match ApiProvider::parse(raw) { + Some(provider) => Ok(AppAction::OpenProviderSetup { + provider: Some(provider), + }), + None => Err(format!( + "Unknown provider '{raw}'. Expected: {}, or a template (agnes, sensenova, opencode-zen, opencode-go).", + ApiProvider::names_hint() + )), + } +} + fn is_route_ambiguous_deepseek_alias(provider: ApiProvider, model: &str) -> bool { matches!( provider, @@ -267,6 +281,31 @@ mod tests { assert!(result.message.is_none()); } + #[test] + fn setup_subcommand_opens_agnes_template() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("setup agnes")); + assert_eq!( + result.action, + Some(AppAction::OpenTemplateSetup { + template_id: "agnes".to_string(), + }) + ); + assert!(result.message.is_none()); + } + + #[test] + fn setup_subcommand_opens_first_class_zen_template() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("setup opencode-zen")); + assert_eq!( + result.action, + Some(AppAction::OpenProviderSetup { + provider: Some(ApiProvider::OpencodeZen), + }) + ); + } + #[test] fn setup_subcommand_rejects_unknown_provider() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/groups/core/setup.rs b/crates/tui/src/commands/groups/core/setup.rs index 5778dccba6..a84141bed5 100644 --- a/crates/tui/src/commands/groups/core/setup.rs +++ b/crates/tui/src/commands/groups/core/setup.rs @@ -1,6 +1,7 @@ //! `/setup` command. use crate::commands::traits::{CommandInfo, RegisterCommand}; +#[cfg(test)] use crate::config::ApiProvider; use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; @@ -34,20 +35,10 @@ impl RegisterCommand for SetupCmd { "Usage: /setup provider [provider-name]".to_string(), ); } - if raw_provider.eq_ignore_ascii_case("ds4") - || raw_provider.eq_ignore_ascii_case("dwarfstar") - { - return CommandResult::action(AppAction::OpenDs4Setup); - } - let Some(provider) = ApiProvider::parse(raw_provider) else { - return CommandResult::error(format!( - "Unknown provider '{raw_provider}'. Expected: {}.", - ApiProvider::names_hint() - )); + return match super::provider::provider_setup_action_for_name(raw_provider) { + Ok(action) => CommandResult::action(action), + Err(message) => CommandResult::error(message), }; - return CommandResult::action(AppAction::OpenProviderSetup { - provider: Some(provider), - }); } } @@ -231,6 +222,21 @@ mod tests { assert!(result.message.is_none()); } + #[test] + fn setup_provider_agnes_opens_key_only_template() { + let mut app = test_app(); + + let result = SetupCmd::execute(&mut app, Some("provider agnes")); + + assert_eq!( + result.action, + Some(AppAction::OpenTemplateSetup { + template_id: "agnes".to_string(), + }) + ); + assert!(result.message.is_none()); + } + #[test] fn setup_provider_named_rejects_unknown_provider() { let mut app = test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ea9f7e9910..46ba892e95 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1507,7 +1507,9 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Sakana => vec![DEFAULT_SAKANA_MODEL, SAKANA_FUGU_ULTRA_MODEL], ApiProvider::LongCat => vec![DEFAULT_LONGCAT_MODEL], ApiProvider::OpencodeGo => OPENCODE_GO_CHAT_MODELS.to_vec(), - ApiProvider::OpencodeZen => vec![DEFAULT_OPENCODE_ZEN_MODEL], + // Full curated Zen roster so `/model` still lists models when + // Models.dev is stale or the live cache failed (#5350). + ApiProvider::OpencodeZen => codewhale_config::route::opencode_zen_picker_models(), ApiProvider::Meta => vec![ DEFAULT_META_MODEL, "muse-spark-1.1", diff --git a/crates/tui/src/provider_readiness.rs b/crates/tui/src/provider_readiness.rs index 4fd0c8fe73..b673771119 100644 --- a/crates/tui/src/provider_readiness.rs +++ b/crates/tui/src/provider_readiness.rs @@ -565,7 +565,6 @@ impl ProviderReadinessSnapshot { ); } - #[cfg(test)] pub(crate) fn record_failure_message( &mut self, config: &crate::config::Config, diff --git a/crates/tui/src/tui/app/types.rs b/crates/tui/src/tui/app/types.rs index 8850272e35..9617fd8406 100644 --- a/crates/tui/src/tui/app/types.rs +++ b/crates/tui/src/tui/app/types.rs @@ -997,6 +997,10 @@ pub enum AppAction { }, /// Open the named, keyless DS4 local-runtime preset for review and save. OpenDs4Setup, + /// Open key-only setup for a prefab custom template (`agnes`, `sensenova`). + OpenTemplateSetup { + template_id: String, + }, /// Run the xAI/Grok device-code flow with the TUI temporarily suspended. StartXaiDeviceLogin, /// Open the `/mode` picker modal for Act / Plan / Operate. diff --git a/crates/tui/src/tui/model_picker.rs b/crates/tui/src/tui/model_picker.rs index 6269af1539..52b60b952a 100644 --- a/crates/tui/src/tui/model_picker.rs +++ b/crates/tui/src/tui/model_picker.rs @@ -1565,12 +1565,23 @@ fn push_configured_provider_model( } fn provider_catalog_model_ids(provider: ApiProvider) -> Vec { + provider_catalog_model_ids_for(provider, provider.as_str()) +} + +fn provider_catalog_model_ids_for(provider: ApiProvider, provider_id: &str) -> Vec { let mut models = Vec::new(); for id in all_catalog_models_for_provider(provider) { // The catalog describes the built-in provider route. A custom route's // endpoint-owned current/configured model is appended separately. push_model_id(&mut models, picker_visible_model_id(provider, &id, false)); } + if provider == ApiProvider::Custom + && let Some(template) = codewhale_config::provider_setup_template(provider_id) + { + for id in template.picker_models() { + push_model_id(&mut models, id); + } + } models } @@ -1579,7 +1590,12 @@ fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) -> // separate custom/current-model row. let mut models = Vec::new(); push_model_id(&mut models, "auto"); - for id in provider_catalog_model_ids(app.api_provider) { + let catalog_id = if app.api_provider == ApiProvider::Custom { + app.provider_identity_for_persistence() + } else { + app.api_provider.as_str() + }; + for id in provider_catalog_model_ids_for(app.api_provider, catalog_id) { push_model_id(&mut models, &id); } @@ -1689,9 +1705,14 @@ fn push_model_row( /// Fresh/live rows stay unmarked; stale and failed caches get an explicit /// suffix so users know the live layer is still visible but not current. fn catalog_freshness_title_suffix() -> &'static str { - match models_dev_live::status().freshness { + catalog_freshness_title_suffix_for(models_dev_live::status().freshness) +} + +fn catalog_freshness_title_suffix_for(freshness: ModelsDevFreshness) -> &'static str { + match freshness { ModelsDevFreshness::Stale => " · stale", - ModelsDevFreshness::Failed => " · cache failed", + // A failed optional refresh keeps bundled / template rows available. + ModelsDevFreshness::Failed => " · refresh failed; catalog available", ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "", } } @@ -3013,6 +3034,29 @@ mod tests { /// nothing but their near-identical ids. This asserts the two failure modes /// that produced: rows that are byte-identical to each other, and rows that /// carry no metadata at all. + #[test] + fn failed_live_catalog_refresh_names_the_working_fallback() { + assert_eq!( + catalog_freshness_title_suffix_for(ModelsDevFreshness::Failed), + " · refresh failed; catalog available" + ); + assert!( + !catalog_freshness_title_suffix_for(ModelsDevFreshness::Failed) + .contains("cache failed") + ); + } + + #[test] + fn custom_agnes_template_seeds_model_list() { + let ids = provider_catalog_model_ids_for(ApiProvider::Custom, "agnes"); + assert!( + ids.iter() + .any(|id| id == codewhale_config::AGNES_DEFAULT_MODEL), + "{ids:?}" + ); + assert!(ids.iter().any(|id| id == "agnes-2.0-flash"), "{ids:?}"); + } + #[test] fn deepseek_rows_render_distinguishably() { let (app, mut config, _lock) = create_test_app(); diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index ae21563d9c..ab821e38ab 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -88,6 +88,8 @@ enum Stage { /// Confirmation summary before any secret or model is persisted (#3875). Confirm, CustomForm, + /// Prefab third-party templates (#5350): fixed URL + common models. + TemplatePick, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -459,6 +461,47 @@ impl ProviderDashboardRow { ) } + fn from_setup_template( + template: &codewhale_config::ProviderSetupTemplate, + active: ApiProvider, + config: &Config, + runtime_status: Option<&ProviderRuntimeStatus>, + ) -> Self { + let mut scoped = config.clone(); + scoped.provider = Some(template.id.to_string()); + let entry = scoped + .providers + .get_or_insert_with(crate::config::ProvidersConfig::default) + .custom + .entry(template.id.to_string()) + .or_default(); + entry.kind = Some("openai-compatible".to_string()); + entry.base_url = Some(template.base_url.to_string()); + entry.model = Some(template.default_model.to_string()); + entry.api_key_env = Some(template.api_key_env.to_string()); + let mut row = Self::from_custom_config_with_runtime_status( + template.id, + active, + &scoped, + runtime_status, + ); + row.is_configured = false; + row.display_name = template.display_name.to_string(); + row.available_model_count = template.picker_models().len(); + row.catalog_status = ProviderCatalogStatus::Bundled; + row.messages.insert( + 0, + format!( + "prefab template · URL fixed · enter API key only ({})", + template.api_key_env + ), + ); + if let Some(docs) = template.docs_url { + row.messages.push(format!("Docs: {docs}")); + } + row + } + fn from_custom_config_with_runtime_status( provider_id: &str, active: ApiProvider, @@ -1531,6 +1574,21 @@ impl ProviderPickerView { }) .collect(); rows.extend(custom_rows); + let existing_ids: std::collections::HashSet = rows + .iter() + .map(|row| row.provider_id.to_ascii_lowercase()) + .collect(); + for template in codewhale_config::custom_provider_setup_templates() { + if existing_ids.contains(&template.id.to_ascii_lowercase()) { + continue; + } + rows.push(ProviderDashboardRow::from_setup_template( + template, + catalog_active, + config, + runtime_status, + )); + } rows.sort_by(|a, b| { a.display_name .to_ascii_lowercase() @@ -1666,6 +1724,23 @@ impl ProviderPickerView { picker } + /// Open catalog setup on a prefab template (#5350). First-class templates + /// jump into the existing key-only flow; custom templates only ask for a key. + #[must_use] + pub fn new_for_template_setup( + active: ApiProvider, + template_id: &str, + config: &Config, + runtime_status: Option, + ) -> Option { + let template = codewhale_config::provider_setup_template(template_id)?; + let mut picker = Self::new_with_runtime_status(active, config, runtime_status); + picker.setup_mode = true; + picker.view = ProviderListView::Catalog; + picker.begin_template_setup(template); + Some(picker) + } + /// Open the setup catalog for first-run/recovery onboarding (#4763). /// Identical to [`Self::new_for_setup`] except that a missing-auth /// `target` is only *focused*: onboarding must show the navigable @@ -1841,6 +1916,7 @@ impl ProviderPickerView { self.enter_stepfun_billing_route(); } else { self.enter_key_entry(); + self.apply_row_template_url(); } } @@ -1939,9 +2015,21 @@ impl ProviderPickerView { config: &Config, runtime_status: Option, error: String, + ) -> Option { + Self::new_for_key_entry_with_error_on(active, target, None, config, runtime_status, error) + } + + #[must_use] + pub fn new_for_key_entry_with_error_on( + active: ApiProvider, + target: ApiProvider, + target_id: Option<&str>, + config: &Config, + runtime_status: Option, + error: String, ) -> Option { let mut picker = Self::new_with_runtime_status(active, config, runtime_status); - let idx = picker.rows.iter().position(|row| row.provider == target)?; + let idx = Self::row_index_for(&picker.rows, target, target_id)?; picker.selected_idx = idx; picker.view = ProviderListView::Catalog; picker.stage = Stage::KeyEntry; @@ -1959,9 +2047,30 @@ impl ProviderPickerView { runtime_status: Option, api_key: String, base_url: Option, + ) -> Option { + Self::new_for_validated_model_pick( + active, + target, + None, + config, + runtime_status, + api_key, + base_url, + ) + } + + #[must_use] + pub fn new_for_validated_model_pick( + active: ApiProvider, + target: ApiProvider, + target_id: Option<&str>, + config: &Config, + runtime_status: Option, + api_key: String, + base_url: Option, ) -> Option { let mut picker = Self::new_with_runtime_status(active, config, runtime_status); - let idx = picker.rows.iter().position(|row| row.provider == target)?; + let idx = Self::row_index_for(&picker.rows, target, target_id)?; picker.selected_idx = idx; picker.view = ProviderListView::Catalog; picker.pending_api_key = Some(api_key); @@ -1997,6 +2106,18 @@ impl ProviderPickerView { route.logical_model.clone() }; let mut models = crate::provider_lake::all_catalog_models_for_provider(provider); + if let Some(template) = + codewhale_config::provider_setup_template(&self.rows[self.selected_idx].provider_id) + { + for model in template.picker_models() { + if !models + .iter() + .any(|existing| existing.eq_ignore_ascii_case(model)) + { + models.push(model.to_string()); + } + } + } if kimi_code_k3 && !preferred.trim().is_empty() && !models @@ -2101,6 +2222,77 @@ impl ProviderPickerView { self.custom_provider_api_key_env.clear(); } + fn enter_template_pick(&mut self) { + self.stage = Stage::TemplatePick; + self.model_selected_idx = 0; + } + + fn selected_setup_template(&self) -> Option<&'static codewhale_config::ProviderSetupTemplate> { + codewhale_config::provider_setup_templates().get(self.model_selected_idx) + } + + fn row_setup_template(&self) -> Option<&'static codewhale_config::ProviderSetupTemplate> { + let row = self.rows.get(self.selected_idx)?; + if let Some(template) = codewhale_config::provider_setup_template(&row.provider_id) { + return Some(template); + } + let kind = row.provider.kind()?; + codewhale_config::provider_setup_templates() + .iter() + .find(|template| template.first_class == Some(kind)) + } + + fn apply_row_template_url(&mut self) { + let Some(template) = self + .row_setup_template() + .filter(|template| template.is_custom()) + else { + return; + }; + self.pending_base_url = Some(template.base_url.to_string()); + if let Some(row) = self.rows.get_mut(self.selected_idx) { + row.base_url = template.base_url.to_string(); + } + } + + fn begin_template_setup(&mut self, template: &codewhale_config::ProviderSetupTemplate) { + if let Some(kind) = template.first_class { + let provider = ApiProvider::from_kind(kind); + if let Some(idx) = self.rows.iter().position(|row| row.provider == provider) { + self.selected_idx = idx; + self.view = ProviderListView::Catalog; + self.begin_setup(); + return; + } + } + if let Some(idx) = self + .rows + .iter() + .position(|row| row.provider_id.eq_ignore_ascii_case(template.id)) + { + self.selected_idx = idx; + self.view = ProviderListView::Catalog; + } + self.enter_key_entry(); + self.apply_row_template_url(); + } + + fn apply_selected_template(&mut self) { + if let Some(template) = self.selected_setup_template() { + self.begin_template_setup(template); + } + } + + fn row_index_for( + rows: &[ProviderDashboardRow], + target: ApiProvider, + target_id: Option<&str>, + ) -> Option { + rows.iter().position(|row| { + row.provider == target && target_id.is_none_or(|id| row.provider_id == id) + }) + } + fn custom_form_field_mut(&mut self) -> &mut String { match self.custom_provider_field { CustomProviderField::Name => &mut self.custom_provider_id, @@ -2159,6 +2351,9 @@ impl ProviderPickerView { fn env_var_for_selected_row(&self) -> String { let row = &self.rows[self.selected_idx]; + if let Some(template) = self.row_setup_template() { + return template.api_key_env.to_string(); + } if row.provider == ApiProvider::Custom { return row .messages @@ -2258,6 +2453,7 @@ impl ProviderPickerView { ActionHint::new("A", view_action.clone()), ActionHint::new("C", self.tr(MessageId::PickerActionCustom)), ActionHint::new("D", "DS4"), + ActionHint::new("P", "templates"), ], ) } else { @@ -2271,6 +2467,8 @@ impl ProviderPickerView { ActionHint::new("A", view_action), ActionHint::new("C", self.tr(MessageId::PickerActionCustom)), ActionHint::new("D", "DS4"), + ActionHint::new("P", "templates"), + ActionHint::new("T", "test"), ActionHint::new("R", self.tr(MessageId::PickerActionEditKey)), ActionHint::new("X", self.tr(MessageId::ProviderExternalActionRevoke)), ActionHint::new("M", self.tr(MessageId::PickerActionModels)), @@ -2688,6 +2886,18 @@ impl ProviderPickerView { Style::default().fg(palette::TEXT_MUTED), ))] }; + if let Some(template) = self.row_setup_template() { + hint_lines.push(Line::from(Span::styled( + format!("Base URL is fixed: {}", template.base_url), + Style::default().fg(palette::TEXT_MUTED), + ))); + if let Some(docs) = template.docs_url { + hint_lines.push(Line::from(Span::styled( + format!("Docs: {docs}"), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + } if !oauth_provider { if row.provider == ApiProvider::Moonshot && crate::config::moonshot_base_url_is_exact_kimi_code(&row.base_url) @@ -3123,6 +3333,68 @@ impl ProviderPickerView { Paragraph::new(lines).render(content, buf); } + fn render_template_pick(&self, area: Rect, buf: &mut Buffer) { + let outer = Block::default() + .title(Line::from(Span::styled( + " Provider templates ", + Style::default() + .fg(palette::WHALE_INFO) + .add_modifier(Modifier::BOLD), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::WHALE_BG)); + let inner = outer.inner(area); + outer.render(area, buf); + + let content = render_modal_footer( + inner, + buf, + &[ + ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)), + ActionHint::new("Enter", "apply"), + ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)), + ], + ); + + let templates = codewhale_config::provider_setup_templates(); + let selected = self + .model_selected_idx + .min(templates.len().saturating_sub(1)); + let mut lines = vec![ + Line::from(Span::styled( + "Pick a template. First-class routes keep their own URL; custom templates lock Base URL so you only enter an API key.", + Style::default().fg(palette::TEXT_MUTED), + )), + Line::from(""), + ]; + for (idx, template) in templates.iter().enumerate() { + let marker = crate::tui::glyphs::selection_marker(idx == selected); + let kind = if template.is_first_class() { + "first-class" + } else { + "custom" + }; + let style = if idx == selected { + Style::default() + .fg(palette::TEXT_PRIMARY) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + lines.push(Line::from(Span::styled( + format!( + "{marker} {} ({kind}) {} default {}", + template.display_name, template.base_url, template.default_model + ), + style, + ))); + } + Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .render(content, buf); + } + fn render_custom_form(&self, area: Rect, buf: &mut Buffer) { let outer = Block::default() .title(Line::from(Span::styled( @@ -3285,7 +3557,8 @@ impl ModalView for ProviderPickerView { | Stage::ModelPick | Stage::PlanTier | Stage::StepfunBillingRoute - | Stage::Confirm => false, + | Stage::Confirm + | Stage::TemplatePick => false, } } @@ -3321,7 +3594,14 @@ impl ModalView for ProviderPickerView { if provider == ApiProvider::Custom && !self.rows[self.selected_idx].is_configured { - self.enter_custom_form(); + if let Some(template) = codewhale_config::provider_setup_template( + &provider_id.unwrap_or_default(), + ) && template.is_custom() + { + self.begin_template_setup(template); + } else { + self.enter_custom_form(); + } ViewAction::None } else if !self.selected_route_is_valid() { ViewAction::None @@ -3410,6 +3690,25 @@ impl ModalView for ProviderPickerView { self.enter_ds4_form(); ViewAction::None } + KeyCode::Char(c) + if key.modifiers.is_empty() + && self.query.is_empty() + && c.eq_ignore_ascii_case(&'p') => + { + self.enter_template_pick(); + ViewAction::None + } + KeyCode::Char(c) + if key.modifiers.is_empty() + && self.query.is_empty() + && c.eq_ignore_ascii_case(&'t') + && self.row_visible(self.selected_idx) => + { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerTestConnection { + provider: self.selected_provider(), + provider_id: self.selected_provider_id(), + }) + } // Jump to the `/model` picker pre-filtered to this provider // (#3083). Handled before the type-ahead arm so `m`/`M` opens // models instead of seeking a provider whose name starts with m. @@ -3743,6 +4042,31 @@ impl ModalView for ProviderPickerView { } _ => ViewAction::None, }, + Stage::TemplatePick => match key.code { + KeyCode::Esc => { + self.stage = Stage::List; + ViewAction::None + } + KeyCode::Up => { + let len = codewhale_config::provider_setup_templates().len(); + if len > 0 { + self.model_selected_idx = (self.model_selected_idx + len - 1) % len; + } + ViewAction::None + } + KeyCode::Down => { + let len = codewhale_config::provider_setup_templates().len(); + if len > 0 { + self.model_selected_idx = (self.model_selected_idx + 1) % len; + } + ViewAction::None + } + KeyCode::Enter => { + self.apply_selected_template(); + ViewAction::None + } + _ => ViewAction::None, + }, } } @@ -3758,6 +4082,21 @@ impl ModalView for ProviderPickerView { MouseEventKind::ScrollDown => self.move_model_selection(1), _ => {} }, + Stage::TemplatePick => match mouse.kind { + MouseEventKind::ScrollUp => { + let len = codewhale_config::provider_setup_templates().len(); + if len > 0 { + self.model_selected_idx = (self.model_selected_idx + len - 1) % len; + } + } + MouseEventKind::ScrollDown => { + let len = codewhale_config::provider_setup_templates().len(); + if len > 0 { + self.model_selected_idx = (self.model_selected_idx + 1) % len; + } + } + _ => {} + }, Stage::PlanTier | Stage::StepfunBillingRoute | Stage::XaiAuthChoice @@ -3785,6 +4124,7 @@ impl ModalView for ProviderPickerView { Stage::StepfunBillingRoute => 11, Stage::Confirm => 10, Stage::CustomForm => 12, + Stage::TemplatePick => 14, }; let popup_area = centered_modal_area(area, 120, preferred_height, 64, 8); @@ -3801,6 +4141,7 @@ impl ModalView for ProviderPickerView { Stage::StepfunBillingRoute => self.render_stepfun_billing_route(popup_area, buf), Stage::Confirm => self.render_confirm(popup_area, buf), Stage::CustomForm => self.render_custom_form(popup_area, buf), + Stage::TemplatePick => self.render_template_pick(popup_area, buf), } } } @@ -5365,6 +5706,139 @@ mod tests { } } + fn move_to_provider_id(picker: &mut ProviderPickerView, provider_id: &str) { + if let Some(idx) = picker + .rows + .iter() + .position(|row| row.provider_id.eq_ignore_ascii_case(provider_id)) + && !picker.row_visible(idx) + { + picker.toggle_view(); + } + if let Some(idx) = picker + .rows + .iter() + .position(|row| row.provider_id.eq_ignore_ascii_case(provider_id)) + { + picker.selected_idx = idx; + return; + } + panic!("provider id {provider_id} not found in picker"); + } + + #[test] + fn catalog_injects_unconfigured_custom_templates() { + let picker = ProviderPickerView::new(ApiProvider::Deepseek, &Config::default()); + let ids: Vec<_> = picker + .rows + .iter() + .map(|row| row.provider_id.as_str()) + .collect(); + assert!(ids.contains(&"agnes"), "{ids:?}"); + assert!(ids.contains(&"sensenova"), "{ids:?}"); + let agnes = picker + .rows + .iter() + .find(|row| row.provider_id == "agnes") + .expect("agnes row"); + assert!(!agnes.is_configured); + assert_eq!(agnes.base_url, codewhale_config::AGNES_BASE_URL); + assert!( + agnes + .messages + .iter() + .any(|message| message.contains("prefab template")) + ); + } + + #[test] + fn enter_on_agnes_template_starts_key_only_setup() { + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &Config::default()); + move_to_provider_id(&mut picker, "agnes"); + let action = picker.handle_key(key(KeyCode::Enter)); + assert!(matches!(action, ViewAction::None), "{action:?}"); + assert_eq!(picker.stage, Stage::KeyEntry); + assert_eq!( + picker.pending_base_url.as_deref(), + Some(codewhale_config::AGNES_BASE_URL) + ); + let text = render_text(&picker, 90, 16); + assert!( + text.contains("Base URL is fixed") && text.contains(codewhale_config::AGNES_BASE_URL), + "{text}" + ); + } + + #[test] + fn p_opens_template_pick_and_zen_starts_first_class_setup() { + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &Config::default()); + picker.handle_key(key(KeyCode::Char('p'))); + assert_eq!(picker.stage, Stage::TemplatePick); + let text = render_text(&picker, 100, 16); + assert!(text.contains("OpenCode Zen"), "{text}"); + assert!(text.contains("Agnes"), "{text}"); + assert!(text.contains("Meituan Sensenova"), "{text}"); + picker.handle_key(key(KeyCode::Enter)); + assert_eq!(picker.stage, Stage::KeyEntry); + assert_eq!(picker.selected_provider(), ApiProvider::OpencodeZen); + } + + #[test] + fn t_emits_test_connection_for_highlighted_row() { + let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &Config::default()); + move_to_provider_id(&mut picker, "agnes"); + match picker.handle_key(key(KeyCode::Char('t'))) { + ViewAction::EmitAndClose(ViewEvent::ProviderPickerTestConnection { + provider, + provider_id, + }) => { + assert_eq!(provider, ApiProvider::Custom); + assert_eq!(provider_id.as_deref(), Some("agnes")); + } + other => panic!("expected test-connection event, got {other:?}"), + } + } + + #[test] + fn template_setup_constructor_opens_agnes_key_entry() { + let picker = ProviderPickerView::new_for_template_setup( + ApiProvider::Deepseek, + "agnes", + &Config::default(), + None, + ) + .expect("agnes template"); + assert_eq!(picker.stage, Stage::KeyEntry); + assert_eq!(picker.rows[picker.selected_idx].provider_id, "agnes"); + assert_eq!( + picker.pending_base_url.as_deref(), + Some(codewhale_config::AGNES_BASE_URL) + ); + } + + #[test] + fn validated_model_pick_for_agnes_lists_template_models() { + let picker = ProviderPickerView::new_for_validated_model_pick( + ApiProvider::Deepseek, + ApiProvider::Custom, + Some("agnes"), + &Config::default(), + None, + "sk-agnes".to_string(), + Some(codewhale_config::AGNES_BASE_URL.to_string()), + ) + .expect("agnes model pick"); + assert_eq!(picker.stage, Stage::ModelPick); + assert!( + picker + .model_options + .iter() + .any(|model| model == codewhale_config::AGNES_DEFAULT_MODEL), + "{:?}", + picker.model_options + ); + } + #[test] fn ds4_preset_is_keyless_and_ready_to_save() { let mut picker = diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e63e1340ac..9092dabd83 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3444,6 +3444,119 @@ mod provider_key_validation_tests { ); } + #[test] + fn picker_identity_resolves_unconfigured_agnes_template() { + let config = Config::default(); + let identity = picker_provider_identity(&config, ApiProvider::Custom, Some("agnes")) + .expect("agnes template identity"); + assert_eq!(identity.provider, ApiProvider::Custom); + assert_eq!(identity.key, "agnes"); + assert_eq!(identity.exact_id.as_deref(), Some("agnes")); + } + + #[tokio::test] + async fn test_connection_records_models_probe_not_ready() { + let _config_env = ConfigPathEnvGuard::new(); + let mut app = create_test_app(); + let mut engine = mock_engine_handle(); + let mut config = openrouter_config("https://mock.openrouter.test/v1"); + if let Some(providers) = config.providers.as_mut() { + providers.openrouter.api_key = Some("sk-saved".to_string()); + } + let verifier = MockProviderKeyVerifier::new(Ok(())); + let identity = picker_provider_identity(&config, ApiProvider::Openrouter, None) + .expect("OpenRouter identity"); + + apply_provider_picker_test_connection_with_verifier( + &mut app, + &mut engine.handle, + &mut config, + identity, + &verifier, + ) + .await; + + assert_eq!( + verifier.calls(), + vec![( + ApiProvider::Openrouter, + "sk-saved".to_string(), + "https://mock.openrouter.test/v1".to_string() + )] + ); + assert!( + app.status_message.as_deref().is_some_and(|status| { + status.contains("Connection checked (/models returned 2xx)") + && status.contains("Model availability is not checked") + }), + "status names connection-probe success: {:?}", + app.status_message + ); + let verified_route = crate::provider_readiness::route_identity_for_model( + &config, + ApiProvider::Openrouter, + crate::config::DEFAULT_OPENROUTER_MODEL, + ); + assert_eq!( + crate::provider_readiness::resolve_with_identity( + &verified_route, + crate::provider_readiness::CredentialState::Saved, + true, + &app.provider_health, + ), + crate::provider_readiness::ResolvedProviderReadiness::ConnectionCheckedModelUnchecked, + "Test Connection must not report the model as ready", + ); + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker)); + let picker = app.view_stack.pop().expect("provider picker reopened"); + let area = Rect::new(0, 0, 90, 16); + let mut buf = Buffer::empty(area); + picker.render(area, &mut buf); + let rendered = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| buf[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + !rendered.contains("Pick a default model"), + "Test Connection must reopen the list, not model pick:\n{rendered}" + ); + } + + #[tokio::test] + async fn test_connection_without_key_does_not_probe() { + let _config_env = ConfigPathEnvGuard::new(); + let _agnes = crate::test_support::EnvVarGuard::remove("AGNES_API_KEY"); + let mut app = create_test_app(); + let mut engine = mock_engine_handle(); + let mut config = Config::default(); + let verifier = MockProviderKeyVerifier::new(Ok(())); + let identity = picker_provider_identity(&config, ApiProvider::Custom, Some("agnes")) + .expect("agnes identity"); + + apply_provider_picker_test_connection_with_verifier( + &mut app, + &mut engine.handle, + &mut config, + identity, + &verifier, + ) + .await; + + assert!(verifier.calls().is_empty(), "{:?}", verifier.calls()); + assert!( + app.status_message + .as_deref() + .is_some_and(|status| status.contains("No API key saved")), + "{:?}", + app.status_message + ); + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ProviderPicker)); + } + /// #4526: the wizard's StepFun billing-route choice must be the endpoint /// the key is probed against, and it must reach disk only once the user /// confirms — never as a side effect of validation. diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 2a9a6f8a93..5e3bba5cb8 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -1410,6 +1410,31 @@ pub(crate) async fn apply_command_result( ); } } + AppAction::OpenTemplateSetup { template_id } => { + if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) { + let runtime_status = query_provider_runtime_status(engine_handle).await; + if let Some(picker) = + crate::tui::provider_picker::ProviderPickerView::new_for_template_setup( + app.api_provider, + &template_id, + config, + runtime_status, + ) + { + app.view_stack.push( + picker + .with_locale(app.ui_locale) + .with_provider_health(&app.provider_health), + ); + app.status_message = Some(format!( + "Provider template '{template_id}' opened. Enter an API key only." + )); + } else { + app.status_message = + Some(format!("Unknown provider template '{template_id}'.")); + } + } + } AppAction::StartXaiDeviceLogin => { let _switched = run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?; @@ -2099,6 +2124,7 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier( let provider = identity.provider; let mut scoped_config = config.clone(); scoped_config.provider = Some(identity.key.clone()); + seed_custom_template_route(&mut scoped_config, &identity, base_url_override.as_deref()); // #4526: a billing route chosen in the wizard is applied to the scoped // clone only, so the key is probed against the endpoint it will be saved // for without touching the on-disk config before the user confirms. @@ -2131,7 +2157,17 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier( // Key is valid — continue the guided flow at model pick without // writing the secret yet. let runtime_status = query_provider_runtime_status(engine_handle).await; - if let Some(picker) = + let picker = if provider == ApiProvider::Custom { + crate::tui::provider_picker::ProviderPickerView::new_for_validated_model_pick( + app.api_provider, + provider, + Some(identity.key.as_str()), + &scoped_config, + runtime_status, + api_key, + base_url_override, + ) + } else { crate::tui::provider_picker::ProviderPickerView::new_for_model_pick_after_validation( app.api_provider, provider, @@ -2140,12 +2176,12 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier( api_key, base_url_override, ) - .map(|picker| { - picker - .with_locale(app.ui_locale) - .with_provider_health(&app.provider_health) - }) - { + }; + if let Some(picker) = picker.map(|picker| { + picker + .with_locale(app.ui_locale) + .with_provider_health(&app.provider_health) + }) { app.view_stack.push(picker); app.status_message = Some( "Connection checked (/models returned 2xx). Pick a default model; model availability is not checked." @@ -2164,7 +2200,16 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier( // stage with the provider's actual error so the user can fix // the key instead of dead-ending with a status toast. let runtime_status = query_provider_runtime_status(engine_handle).await; - if let Some(picker) = + let picker = if provider == ApiProvider::Custom { + crate::tui::provider_picker::ProviderPickerView::new_for_key_entry_with_error_on( + app.api_provider, + provider, + Some(identity.key.as_str()), + &scoped_config, + runtime_status, + reason, + ) + } else { crate::tui::provider_picker::ProviderPickerView::new_for_key_entry_with_error( app.api_provider, provider, @@ -2172,12 +2217,12 @@ pub(crate) async fn apply_provider_picker_api_key_with_verifier( runtime_status, reason, ) - .map(|picker| { - picker - .with_locale(app.ui_locale) - .with_provider_health(&app.provider_health) - }) - { + }; + if let Some(picker) = picker.map(|picker| { + picker + .with_locale(app.ui_locale) + .with_provider_health(&app.provider_health) + }) { app.view_stack.push(picker); app.status_message = Some(format!( "{} API key verification failed - check the key and try again.", @@ -2223,6 +2268,19 @@ pub(crate) async fn apply_provider_picker_setup_confirmed( return false; } + if provider == ApiProvider::Custom + && let Err(err) = + persist_custom_template_table(app, config, &identity, base_url.as_deref(), &model) + { + app.add_message(HistoryCell::System { + content: format!( + "Failed to save custom provider {}: {err}\nProvider unchanged.", + identity.key + ), + }); + return false; + } + // #4526: the wizard's billing-route choice is written before the key so the // credential is saved onto the route it was verified against. It lands only // in that provider's own `base_url`; failing here aborts before any secret @@ -2309,6 +2367,194 @@ pub(crate) async fn apply_provider_picker_setup_confirmed( switched } +/// Prefill a named custom template onto a cloned config so `/models` can be +/// probed before `[providers.]` exists. Do not set `kind` here: that would +/// make the picker treat the unpersisted table as a configured named custom. +fn seed_custom_template_route( + config: &mut Config, + identity: &crate::config::ProviderIdentity, + base_url_override: Option<&str>, +) { + if identity.provider != ApiProvider::Custom { + return; + } + let Some(template) = codewhale_config::provider_setup_template(&identity.key) + .filter(|template| template.is_custom()) + else { + return; + }; + config.provider = Some(template.id.to_string()); + let entry = config + .providers + .get_or_insert_with(ProvidersConfig::default) + .custom + .entry(template.id.to_string()) + .or_default(); + if let Some(url) = base_url_override + .map(str::trim) + .filter(|url| !url.is_empty()) + { + entry.base_url = Some(url.to_string()); + } else if entry + .base_url + .as_deref() + .map(str::trim) + .is_none_or(|url| url.is_empty()) + { + entry.base_url = Some(template.base_url.to_string()); + } + if entry + .model + .as_deref() + .map(str::trim) + .is_none_or(|model| model.is_empty()) + { + entry.model = Some(template.default_model.to_string()); + } + if entry + .api_key_env + .as_deref() + .map(str::trim) + .is_none_or(|env| env.is_empty()) + { + entry.api_key_env = Some(template.api_key_env.to_string()); + } +} + +fn persist_custom_template_table( + app: &App, + config: &mut Config, + identity: &crate::config::ProviderIdentity, + base_url: Option<&str>, + model: &str, +) -> anyhow::Result<()> { + let Some(template) = codewhale_config::provider_setup_template(&identity.key) + .filter(|template| template.is_custom()) + else { + return Ok(()); + }; + let persist_url = base_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .unwrap_or(template.base_url); + crate::config_persistence::persist_custom_provider( + app.config_path.as_deref(), + &identity.key, + persist_url, + Some(model), + Some(template.api_key_env), + )?; + config.provider = Some(identity.key.clone()); + let entry = config + .providers + .get_or_insert_with(ProvidersConfig::default) + .custom + .entry(identity.key.clone()) + .or_default(); + entry.kind = Some("openai-compatible".to_string()); + entry.base_url = Some(persist_url.trim().trim_end_matches('/').to_string()); + entry.model = Some(model.to_string()); + entry.api_key_env = Some(template.api_key_env.to_string()); + Ok(()) +} + +async fn reopen_provider_picker_list( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &Config, + selected_provider_id: Option, +) { + let runtime_status = query_provider_runtime_status(engine_handle).await; + app.provider_picker_memory = Some(crate::tui::app::ProviderPickerMemory { + catalog_view: true, + selected_provider_id, + }); + app.view_stack.push( + crate::tui::provider_picker::ProviderPickerView::new_with_runtime_status_and_memory( + app.api_provider, + config, + runtime_status, + app.provider_picker_memory.as_ref(), + ) + .with_locale(app.ui_locale) + .with_provider_health(&app.provider_health), + ); + app.needs_redraw = true; +} + +pub(crate) async fn apply_provider_picker_test_connection( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + identity: crate::config::ProviderIdentity, +) { + apply_provider_picker_test_connection_with_verifier( + app, + engine_handle, + config, + identity, + &LiveProviderKeyVerifier, + ) + .await; +} + +pub(crate) async fn apply_provider_picker_test_connection_with_verifier( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + identity: crate::config::ProviderIdentity, + verifier: &dyn ProviderKeyVerifier, +) { + let provider = identity.provider; + let mut scoped_config = config.clone(); + scoped_config.provider = Some(identity.key.clone()); + seed_custom_template_route(&mut scoped_config, &identity, None); + let selected_id = if provider == ApiProvider::Custom { + Some(identity.key.clone()) + } else { + Some(provider.as_str().to_string()) + }; + let api_key = match scoped_config.deepseek_api_key() { + Ok(key) if !key.trim().is_empty() => key, + _ => { + app.status_message = Some(format!( + "No API key saved for {}. Enter a key first, then press T to test the connection.", + identity.key + )); + reopen_provider_picker_list(app, engine_handle, config, selected_id).await; + return; + } + }; + let base_url = scoped_config.deepseek_base_url(); + let model = scoped_config.default_model(); + match verifier.verify(provider, &api_key, &base_url).await { + Ok(()) => { + if crate::client::provider_api_key_verification_is_observed(provider) { + app.provider_health + .record_models_probe_success(&scoped_config, provider, &model); + } + app.status_message = Some( + "Connection checked (/models returned 2xx). Model availability is not checked." + .to_string(), + ); + } + Err(reason) => { + app.provider_health.record_failure_message( + &scoped_config, + provider, + &model, + provider_verification_error_category(&reason), + &reason, + ); + app.status_message = Some(format!( + "{} connection check failed: {reason}", + identity.key + )); + } + } + reopen_provider_picker_list(app, engine_handle, config, selected_id).await; +} + pub(crate) async fn apply_codewhale_owned_xai_login( app: &mut App, engine_handle: &mut EngineHandle, diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index 3253fc25bd..ebd055facb 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -1954,6 +1954,15 @@ pub(crate) async fn handle_view_events( } open_model_picker_for_provider(app, config, provider); } + ViewEvent::ProviderPickerTestConnection { + provider, + provider_id, + } => { + let identity = picker_provider_identity(config, provider, provider_id.as_deref()) + .map_err(anyhow::Error::msg)?; + apply_provider_picker_test_connection(app, engine_handle, config, identity).await; + refresh_config_view_if_open(app, "provider"); + } ViewEvent::ModeSelected { mode } => { let prior_mode = app.mode; let msg = commands::switch_mode(app, mode); diff --git a/crates/tui/src/tui/ui/provider_routes.rs b/crates/tui/src/tui/ui/provider_routes.rs index f094db0627..a2e54a2692 100644 --- a/crates/tui/src/tui/ui/provider_routes.rs +++ b/crates/tui/src/tui/ui/provider_routes.rs @@ -811,14 +811,30 @@ pub(crate) fn picker_provider_identity( provider: ApiProvider, provider_id: Option<&str>, ) -> Result { - let identity = match provider_id { - Some(provider_id) => config - .resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider_id))?, - None if provider == ApiProvider::Custom => config.active_provider_identity(provider)?, - None => config.resolve_persisted_provider_identity( - Some(provider.as_str()), - Some(provider.as_str()), - )?, + let resolved = match provider_id { + Some(provider_id) => { + config.resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider_id)) + } + None if provider == ApiProvider::Custom => config.active_provider_identity(provider), + None => config + .resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider.as_str())), + }; + let identity = match resolved { + Ok(identity) => identity, + Err(err) => { + if provider == ApiProvider::Custom + && let Some(raw) = provider_id + && let Some(template) = codewhale_config::provider_setup_template(raw) + .filter(|template| template.is_custom()) + { + return Ok(crate::config::ProviderIdentity { + provider: ApiProvider::Custom, + key: template.id.to_string(), + exact_id: Some(template.id.to_string()), + }); + } + return Err(err); + } }; if identity.provider != provider { return Err(format!( @@ -831,7 +847,6 @@ pub(crate) fn picker_provider_identity( Ok(identity) } -#[cfg(test)] pub(crate) fn provider_verification_error_category( reason: &str, ) -> crate::error_taxonomy::ErrorCategory { diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 8ddab290ad..8b2c4ac113 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -854,6 +854,12 @@ pub enum ViewEvent { provider: crate::config::ApiProvider, provider_id: Option, }, + /// Emitted by the `/provider` picker (`T`) to probe `/models` and refresh + /// readiness. A 2xx is reachability only — never model ready (#5350). + ProviderPickerTestConnection { + provider: crate::config::ApiProvider, + provider_id: Option, + }, /// Emitted by the `/mode` picker when the user chooses a mode. ModeSelected { mode: crate::tui::app::AppMode, diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index eabb1f9750..6e63d53047 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -304,6 +304,30 @@ environment. Project-local config overlays intentionally cannot set those keys, so a repository cannot silently redirect prompts or credentials to another endpoint. +## Prefab third-party templates + +`/provider` can start a key-only setup for a few third-party gateways so the +user does not have to paste a Base URL. Press `P` in the picker, or run +`/provider setup ` / `/setup provider `. + +| Template | Persisted as | Base URL | Default model | API key env | +| --- | --- | --- | --- | --- | +| `opencode-zen` | first-class `opencode-zen` | `https://opencode.ai/zen/v1` | `gpt-5.6` | `OPENCODE_ZEN_API_KEY` | +| `opencode-go` | first-class `opencode-go` | `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` | `OPENCODE_GO_API_KEY` | +| `agnes` | `[providers.agnes] kind = "openai-compatible"` | `https://apihub.agnes-ai.com/v1` | `agnes-2.5-flash` | `AGNES_API_KEY` | +| `sensenova` | `[providers.sensenova] kind = "openai-compatible"` | `https://token.sensenova.cn/v1` | `sensenova-6.7-flash-lite` | `SENSENOVA_API_KEY` | + +`agnes` and `sensenova` are named custom tables, not `ProviderKind` variants. +Aliases `sense-nova` and `meituan-sensenova` resolve to `sensenova`. Meituan +LongCat remains the first-class `longcat` route; SenseNova is SenseTime's +Token Plan, not LongCat. + +`T` (Test Connection) probes `/models` and refreshes the picker status. A 2xx +means the endpoint accepted the key. It does not mean the selected model is +ready. When Models.dev refresh fails, `/model` keeps the bundled or template +catalog and says `refresh failed; catalog available` instead of leaving the +list stuck at `not checked` / `cache failed`. + ## Local Models (DS4, Ollama, vLLM, SGLang) Self-hosted OpenAI-compatible runtimes are first-class routes and are keyless