diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b1f67f..3cc71d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so slow local or OpenAI-compatible model servers can extend the SSE idle timeout without mutating process environment. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). +- Added dormant `fallback_providers = [...]` config parsing plus a provider-chain + helper for future fallback routing. This preserves the requested contract + without enabling silent runtime provider switches yet (#2574, #2777). Thanks + @hsdbeebou for the request and @idling11 for the data-model draft. ### Changed diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 0d42a0f8..46ce8e67 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -464,6 +464,11 @@ pub struct ConfigToml { pub tools: Option, #[serde(default)] pub providers: ProvidersToml, + /// Dormant provider fallback chain (#2574). This is parsed and preserved + /// for future provider-routing work; current runtime resolution still uses + /// the selected primary provider and does not auto-switch routes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallback_providers: Vec, /// Per-domain network policy (#135). When absent, network tools fall back /// to a permissive default that mirrors pre-v0.7.0 behavior. #[serde(default)] @@ -493,6 +498,71 @@ pub struct ConfigToml { pub extras: BTreeMap, } +/// Ordered primary-plus-fallback provider list for future provider routing. +/// +/// The helper is intentionally dormant: constructing or parsing a chain does +/// not change [`ConfigToml::resolve_runtime_options`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderChain { + providers: Vec, + position: usize, +} + +impl ProviderChain { + #[must_use] + pub fn new(active: ProviderKind, fallbacks: &[ProviderKind]) -> Self { + let mut providers = vec![active]; + for fallback in fallbacks { + if *fallback != active && !providers.contains(fallback) { + providers.push(*fallback); + } + } + Self { + providers, + position: 0, + } + } + + #[must_use] + pub fn providers(&self) -> &[ProviderKind] { + &self.providers + } + + #[must_use] + pub fn position(&self) -> usize { + self.position + } + + #[must_use] + pub fn current(&self) -> ProviderKind { + self.providers[self.position] + } + + #[must_use] + pub fn has_next(&self) -> bool { + self.position + 1 < self.providers.len() + } + + pub fn advance(&mut self) -> Option { + if !self.has_next() { + return None; + } + self.position += 1; + Some(self.current()) + } + + #[must_use] + pub fn is_fallback_active(&self) -> bool { + self.position > 0 + } + + /// Count the current provider plus untried chain entries. + #[must_use] + pub fn remaining(&self) -> usize { + self.providers.len() - self.position + } +} + /// On-disk schema for the `[hook_sinks]` table. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSinksToml { @@ -5227,6 +5297,114 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); } + #[test] + fn provider_chain_initial_current_is_active() { + let chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert_eq!(chain.current(), ProviderKind::NvidiaNim); + assert_eq!(chain.position(), 0); + assert_eq!( + chain.providers(), + &[ + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ProviderKind::Openrouter, + ] + ); + assert!(!chain.is_fallback_active()); + } + + #[test] + fn provider_chain_advance_switches_to_fallback() { + let mut chain = ProviderChain::new( + ProviderKind::NvidiaNim, + &[ProviderKind::Deepseek, ProviderKind::Openrouter], + ); + + assert!(chain.has_next()); + assert_eq!(chain.advance(), Some(ProviderKind::Deepseek)); + assert_eq!(chain.current(), ProviderKind::Deepseek); + assert!(chain.is_fallback_active()); + } + + #[test] + fn provider_chain_exhausts_returns_none() { + let mut chain = ProviderChain::new(ProviderKind::Deepseek, &[ProviderKind::Openrouter]); + + assert_eq!(chain.advance(), Some(ProviderKind::Openrouter)); + assert!(!chain.has_next()); + assert_eq!(chain.advance(), None); + } + + #[test] + fn provider_chain_skips_duplicates() { + let chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ + ProviderKind::Deepseek, + ProviderKind::NvidiaNim, + ProviderKind::Deepseek, + ], + ); + + assert_eq!( + chain.providers(), + &[ProviderKind::Deepseek, ProviderKind::NvidiaNim] + ); + } + + #[test] + fn provider_chain_remaining_counts_current_and_untried_entries() { + let mut chain = ProviderChain::new( + ProviderKind::Deepseek, + &[ProviderKind::NvidiaNim, ProviderKind::Openrouter], + ); + + assert_eq!(chain.remaining(), 3); + assert_eq!(chain.advance(), Some(ProviderKind::NvidiaNim)); + assert_eq!(chain.remaining(), 2); + } + + #[test] + fn config_toml_parses_fallback_providers() { + let config: ConfigToml = toml::from_str( + r#" +provider = "nvidia-nim" +fallback_providers = ["deepseek", "openrouter"] +"#, + ) + .expect("fallback providers config"); + + assert_eq!(config.provider, ProviderKind::NvidiaNim); + assert_eq!( + config.fallback_providers, + [ProviderKind::Deepseek, ProviderKind::Openrouter] + ); + } + + #[test] + fn empty_fallback_providers_do_not_serialize() { + let serialized = toml::to_string_pretty(&ConfigToml::default()).expect("config serializes"); + + assert!(!serialized.contains("fallback_providers")); + } + + #[test] + fn fallback_providers_do_not_change_runtime_resolution() { + let config = ConfigToml { + provider: ProviderKind::NvidiaNim, + fallback_providers: vec![ProviderKind::Deepseek], + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::NvidiaNim); + } + #[test] fn harness_posture_default_is_standard() { let posture = HarnessPosture::default(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 28b1f67f..3cc71d06 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so slow local or OpenAI-compatible model servers can extend the SSE idle timeout without mutating process environment. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507). +- Added dormant `fallback_providers = [...]` config parsing plus a provider-chain + helper for future fallback routing. This preserves the requested contract + without enabling silent runtime provider switches yet (#2574, #2777). Thanks + @hsdbeebou for the request and @idling11 for the data-model draft. ### Changed