-
Notifications
You must be signed in to change notification settings - Fork 3.2k
feat(provider): complete provider fallback chain (#2574) #2773
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b771dee
a15bc22
5f23992
0107dd9
7caf65b
1f30bab
62c74a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -28,6 +28,11 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { | |||||||||||||||||||||||||||||||
| let name = parts.next().unwrap_or(""); | ||||||||||||||||||||||||||||||||
| let model_arg = parts.next(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // `/provider fallback` — show or reset the fallback chain. | ||||||||||||||||||||||||||||||||
| if name == "fallback" { | ||||||||||||||||||||||||||||||||
| return provider_fallback(app, model_arg); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| let Some(target) = ApiProvider::parse(name) else { | ||||||||||||||||||||||||||||||||
| return CommandResult::error(format!( | ||||||||||||||||||||||||||||||||
| "Unknown provider '{name}'. Expected: {}.", | ||||||||||||||||||||||||||||||||
|
|
@@ -70,6 +75,40 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { | |||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// `/provider fallback` — shows the current fallback chain and status, | ||||||||||||||||||||||||||||||||
| /// or resets it with `/provider fallback reset`. | ||||||||||||||||||||||||||||||||
| fn provider_fallback(app: &mut App, sub: Option<&str>) -> CommandResult { | ||||||||||||||||||||||||||||||||
| match sub { | ||||||||||||||||||||||||||||||||
| Some("reset") => { | ||||||||||||||||||||||||||||||||
| app.reset_fallback(); | ||||||||||||||||||||||||||||||||
| CommandResult::message("Fallback chain reset to primary provider.") | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| _ => { | ||||||||||||||||||||||||||||||||
| if app.fallback_providers.is_empty() { | ||||||||||||||||||||||||||||||||
| return CommandResult::message( | ||||||||||||||||||||||||||||||||
| "No fallback providers configured. Add `fallback_providers` to your config.", | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| let active = app.api_provider.as_str().to_string(); | ||||||||||||||||||||||||||||||||
| let current_fallback = app.fallback_depth; | ||||||||||||||||||||||||||||||||
| let mut lines = vec![format!("Active: {active}")]; | ||||||||||||||||||||||||||||||||
| for (i, name) in app.fallback_providers.iter().enumerate() { | ||||||||||||||||||||||||||||||||
| let marker = if current_fallback == Some(i) { | ||||||||||||||||||||||||||||||||
| " ◀ current" | ||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||
| "" | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
| lines.push(format!(" [{i}] {name}{marker}")); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+92
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When no fallback is active (
Suggested change
|
||||||||||||||||||||||||||||||||
| if let Some(ref reason) = app.last_fallback_reason { | ||||||||||||||||||||||||||||||||
| lines.push(format!("Last fallback: {reason}")); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| lines.push("Use `/provider fallback reset` to return to the primary provider.".into()); | ||||||||||||||||||||||||||||||||
| CommandResult::message(lines.join("\n")) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| fn expand_model_alias_for_provider(provider: ApiProvider, name: &str) -> String { | ||||||||||||||||||||||||||||||||
| let lower = name.trim().to_ascii_lowercase(); | ||||||||||||||||||||||||||||||||
| if matches!(provider, ApiProvider::XiaomiMimo) { | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1212,6 +1212,14 @@ pub struct App { | |
| /// Updated by `/provider` switches so the UI/commands can read the | ||
| /// active backend without re-deriving it from the live config. | ||
| pub api_provider: ApiProvider, | ||
| /// Provider fallback providers in route-name form (#2574). | ||
| /// e.g. `["deepseek", "openrouter"]`. Empty when no fallbacks configured. | ||
| pub fallback_providers: Vec<String>, | ||
| /// Current position in the fallback chain. 0 = active provider, | ||
| /// 1+ = fallback provider. `None` when fallback is not active. | ||
| pub fallback_depth: Option<usize>, | ||
| /// Human-readable description of the last fallback event (for UI display). | ||
| pub last_fallback_reason: Option<String>, | ||
| /// True when the active provider/base URL accepts arbitrary model IDs | ||
| /// verbatim rather than DeepSeek-only aliases. | ||
| pub model_ids_passthrough: bool, | ||
|
|
@@ -2002,6 +2010,9 @@ impl App { | |
| auto_model, | ||
| last_effective_model: None, | ||
| api_provider: provider, | ||
| fallback_providers: Vec::new(), | ||
| fallback_depth: None, | ||
| last_fallback_reason: None, | ||
| model_ids_passthrough, | ||
| reasoning_effort, | ||
| last_effective_reasoning_effort: None, | ||
|
|
@@ -4938,6 +4949,65 @@ pub enum AppAction { | |
| }, | ||
| } | ||
|
|
||
| // ── Provider Fallback helpers (#2574) ──────────────────────────── | ||
|
|
||
| impl App { | ||
| /// Advance to the next provider in the fallback chain. Call this when | ||
| /// a retryable error (429, 5xx, timeout) exhausts per-request retries. | ||
| /// Returns `true` if fallback executed. | ||
| #[allow(dead_code)] // Called by runtime integration (follow-up PR) | ||
| pub fn advance_fallback(&mut self, reason: impl Into<String>) -> bool { | ||
| if self.fallback_providers.is_empty() { | ||
| return false; | ||
| } | ||
| // When fallback_depth is None, the primary provider is active. | ||
| // The first fallback goes to index 0, not index 1. | ||
| let next_depth = match self.fallback_depth { | ||
| None => 0, | ||
| Some(d) => d + 1, | ||
| }; | ||
| if next_depth >= self.fallback_providers.len() { | ||
| self.last_fallback_reason = Some(format!( | ||
| "Fallback chain exhausted after {}: {}", | ||
| self.fallback_providers.len(), | ||
| reason.into() | ||
| )); | ||
| return false; | ||
| } | ||
| let next_name = &self.fallback_providers[next_depth]; | ||
| if let Some(next_provider) = ApiProvider::parse(next_name) { | ||
| self.fallback_depth = Some(next_depth); | ||
| self.last_fallback_reason = | ||
| Some(format!("Fell back to {}: {}", next_name, reason.into())); | ||
| self.api_provider = next_provider; | ||
| true | ||
| } else { | ||
| self.last_fallback_reason = Some(format!("Unknown fallback provider: {next_name}")); | ||
| false | ||
| } | ||
| } | ||
|
Comment on lines
+4959
to
+4988
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For
The fix is to treat |
||
|
|
||
| /// Reset the fallback chain to the primary provider. | ||
| pub fn reset_fallback(&mut self) { | ||
| self.fallback_depth = None; | ||
| self.last_fallback_reason = None; | ||
| } | ||
|
Comment on lines
+4990
to
+4994
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /// Whether a fallback provider is currently active. | ||
| pub fn is_fallback_active(&self) -> bool { | ||
| self.fallback_depth.unwrap_or(0) > 0 | ||
| } | ||
|
|
||
| /// Initialize fallback providers from the on-disk ConfigToml. | ||
| #[allow(dead_code)] // Called at startup (follow-up PR) | ||
| pub fn load_fallback_from_toml(&mut self, raw_providers: &[codewhale_config::ProviderKind]) { | ||
| self.fallback_providers = raw_providers | ||
| .iter() | ||
| .map(|k| k.as_str().to_string()) | ||
| .collect(); | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum ShellJobAction { | ||
| List, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Robustness: Safe Indexing in
current()Since
ProviderChainhas public fieldsprovidersandposition, external code can mutate them and potentially break the invariantposition < providers.len(). To prevent out-of-bounds panics, use safe indexing with.get().copied()and fall back to the first provider (which is guaranteed to exist).