Merge pull request #2779 from Hmbown/codex/harvest-2777-fallback-chain-data
feat(config): add dormant provider fallback chain
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -464,6 +464,11 @@ pub struct ConfigToml {
|
||||
pub tools: Option<ToolsToml>,
|
||||
#[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<ProviderKind>,
|
||||
/// 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<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// 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<ProviderKind>,
|
||||
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<ProviderKind> {
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user