@@ -114,11 +114,12 @@ impl ProviderHealthMap {
114114 }
115115 }
116116
117- // Update circuit breakers from probe results
117+ // Update circuit breakers from probe results (keyed by provider:model)
118118 for result in & results {
119+ let key = format ! ( "{}:{}" , result. provider, result. model) ;
119120 let breaker = self
120121 . breakers
121- . entry ( result . provider . clone ( ) )
122+ . entry ( key )
122123 . or_insert_with ( || CircuitBreaker :: new ( self . cb_config . clone ( ) ) ) ;
123124
124125 match result. status {
@@ -140,106 +141,197 @@ impl ProviderHealthMap {
140141 self . probed_at = Some ( Instant :: now ( ) ) ;
141142 }
142143
143- /// Get health status for a provider.
144+ /// Get health status for a specific provider+model combination .
144145 ///
145- /// Uses **probe results first**: if the latest probe for this provider
146- /// failed or timed out, it's unhealthy regardless of circuit breaker state.
147- /// Falls back to circuit breaker for providers not recently probed.
148- pub fn provider_health ( & self , provider : & str ) -> HealthStatus {
149- // Check latest probe results (most authoritative)
150- if let Some ( status) = self . latest_probe_status ( provider) {
151- return match status {
146+ /// Uses **probe results first** at the model level, then falls back to
147+ /// circuit breaker. This is per-model, not per-provider aggregate.
148+ pub fn model_health ( & self , provider : & str , model : & str ) -> HealthStatus {
149+ let key = format ! ( "{provider}:{model}" ) ;
150+
151+ // Check latest probe result for this exact model
152+ if let Some ( result) = self
153+ . results
154+ . iter ( )
155+ . find ( |r| r. provider == provider && r. model == model)
156+ {
157+ return match result. status {
152158 ProbeStatus :: Success => HealthStatus :: Healthy ,
153159 ProbeStatus :: Error => HealthStatus :: Unhealthy ,
154160 ProbeStatus :: Timeout => HealthStatus :: Unhealthy ,
155161 } ;
156162 }
157163
158- // Fall back to circuit breaker for unprobed providers
159- match self . breakers . get ( provider ) {
164+ // Fall back to circuit breaker (keyed by provider:model)
165+ match self . breakers . get ( & key ) {
160166 Some ( breaker) => match breaker. state ( ) {
161167 CircuitState :: Closed => HealthStatus :: Healthy ,
162168 CircuitState :: HalfOpen => HealthStatus :: Degraded ,
163169 CircuitState :: Open => HealthStatus :: Unhealthy ,
164170 } ,
165- None => HealthStatus :: Healthy , // Unknown providers assumed healthy
171+ None => HealthStatus :: Healthy ,
172+ }
173+ }
174+
175+ /// Get aggregate health status for a provider (any model healthy = provider healthy).
176+ pub fn provider_health ( & self , provider : & str ) -> HealthStatus {
177+ // Check probe results at model level
178+ let provider_results: Vec < _ > = self
179+ . results
180+ . iter ( )
181+ . filter ( |r| r. provider == provider)
182+ . collect ( ) ;
183+
184+ if !provider_results. is_empty ( ) {
185+ if provider_results
186+ . iter ( )
187+ . any ( |r| r. status == ProbeStatus :: Success )
188+ {
189+ return HealthStatus :: Healthy ;
190+ }
191+ return HealthStatus :: Unhealthy ;
192+ }
193+
194+ // Fall back to circuit breakers for this provider
195+ let provider_breakers: Vec < _ > = self
196+ . breakers
197+ . iter ( )
198+ . filter ( |( k, _) | k. starts_with ( & format ! ( "{provider}:" ) ) )
199+ . collect ( ) ;
200+
201+ if provider_breakers. is_empty ( ) {
202+ return HealthStatus :: Healthy ; // Unknown
203+ }
204+
205+ if provider_breakers. iter ( ) . any ( |( _, b) | b. should_allow ( ) ) {
206+ HealthStatus :: Healthy
207+ } else {
208+ HealthStatus :: Unhealthy
166209 }
167210 }
168211
169212 /// Check if a provider is healthy enough to dispatch to.
170- ///
171- /// A provider is healthy if its latest probe succeeded OR it wasn't probed
172- /// and the circuit breaker allows requests.
173213 pub fn is_healthy ( & self , provider : & str ) -> bool {
174214 matches ! (
175215 self . provider_health( provider) ,
176216 HealthStatus :: Healthy | HealthStatus :: Degraded
177217 )
178218 }
179219
180- /// List all unhealthy provider names (from probe results + circuit breakers).
220+ /// Check if a specific model is healthy.
221+ pub fn is_model_healthy ( & self , provider : & str , model : & str ) -> bool {
222+ matches ! (
223+ self . model_health( provider, model) ,
224+ HealthStatus :: Healthy | HealthStatus :: Degraded
225+ )
226+ }
227+
228+ /// List all unhealthy provider names.
229+ ///
230+ /// A provider is unhealthy only if ALL its models are unhealthy.
181231 pub fn unhealthy_providers ( & self ) -> Vec < String > {
182- let mut unhealthy : Vec < String > = Vec :: new ( ) ;
232+ let mut providers : HashMap < String , ( usize , usize ) > = HashMap :: new ( ) ; // (total, healthy)
183233
184- // From probe results: any provider with failed/timeout probe
185234 for result in & self . results {
186- if result. status != ProbeStatus :: Success && !unhealthy. contains ( & result. provider ) {
187- unhealthy. push ( result. provider . clone ( ) ) ;
235+ let entry = providers. entry ( result. provider . clone ( ) ) . or_insert ( ( 0 , 0 ) ) ;
236+ entry. 0 += 1 ;
237+ if result. status == ProbeStatus :: Success {
238+ entry. 1 += 1 ;
188239 }
189240 }
190241
191- // From circuit breakers: any open circuit not already in list
192- for ( name, breaker) in & self . breakers {
193- if !breaker. should_allow ( ) && !unhealthy. contains ( name) {
194- unhealthy. push ( name. clone ( ) ) ;
195- }
196- }
197-
198- unhealthy
199- }
200-
201- /// Get the latest probe status for a provider (best result across all models).
202- fn latest_probe_status ( & self , provider : & str ) -> Option < ProbeStatus > {
203- let provider_results: Vec < _ > = self
204- . results
205- . iter ( )
206- . filter ( |r| r. provider == provider)
242+ let mut unhealthy: Vec < String > = providers
243+ . into_iter ( )
244+ . filter ( |( _, ( total, healthy) ) | * total > 0 && * healthy == 0 )
245+ . map ( |( name, _) | name)
207246 . collect ( ) ;
208247
209- if provider_results. is_empty ( ) {
210- return None ;
248+ // Also check circuit breakers for providers not in probe results
249+ let mut cb_providers: HashMap < String , ( usize , usize ) > = HashMap :: new ( ) ;
250+ for ( key, breaker) in & self . breakers {
251+ if let Some ( provider) = key. split ( ':' ) . next ( ) {
252+ if !unhealthy. contains ( & provider. to_string ( ) ) {
253+ let entry = cb_providers. entry ( provider. to_string ( ) ) . or_insert ( ( 0 , 0 ) ) ;
254+ entry. 0 += 1 ;
255+ if breaker. should_allow ( ) {
256+ entry. 1 += 1 ;
257+ }
258+ }
259+ }
211260 }
212261
213- // If ANY model for this provider succeeded, provider is healthy
214- if provider_results
215- . iter ( )
216- . any ( |r| r. status == ProbeStatus :: Success )
217- {
218- Some ( ProbeStatus :: Success )
219- } else {
220- // All models failed -- use the "least bad" status
221- Some ( provider_results[ 0 ] . status )
262+ for ( name, ( total, healthy) ) in cb_providers {
263+ if total > 0 && healthy == 0 && !unhealthy. contains ( & name) {
264+ unhealthy. push ( name) ;
265+ }
222266 }
267+
268+ unhealthy
223269 }
224270
225271 /// Record a success for a provider (e.g., from ExitClassifier).
272+ /// Record a success for a provider+model (e.g., from ExitClassifier).
226273 pub fn record_success ( & mut self , provider : & str ) {
227- if let Some ( breaker) = self . breakers . get_mut ( provider) {
274+ // Update all circuit breakers for this provider
275+ let prefix = format ! ( "{provider}:" ) ;
276+ for ( key, breaker) in & mut self . breakers {
277+ if key. starts_with ( & prefix) {
278+ breaker. record_success ( ) ;
279+ }
280+ }
281+ }
282+
283+ /// Record a success for a specific model.
284+ pub fn record_model_success ( & mut self , provider : & str , model : & str ) {
285+ let key = format ! ( "{provider}:{model}" ) ;
286+ if let Some ( breaker) = self . breakers . get_mut ( & key) {
228287 breaker. record_success ( ) ;
229288 }
230289 }
231290
232- /// Record a failure for a provider (e.g., from ExitClassifier ModelError ).
291+ /// Record a failure for a provider (affects all models for that provider ).
233292 pub fn record_failure ( & mut self , provider : & str ) {
293+ let prefix = format ! ( "{provider}:" ) ;
294+ let keys: Vec < String > = self
295+ . breakers
296+ . keys ( )
297+ . filter ( |k| k. starts_with ( & prefix) )
298+ . cloned ( )
299+ . collect ( ) ;
300+
301+ for key in keys {
302+ let breaker = self . breakers . get_mut ( & key) . unwrap ( ) ;
303+ breaker. record_failure ( ) ;
304+ }
305+
306+ // If no breakers exist for this provider, create one at provider level
307+ if !self . breakers . keys ( ) . any ( |k| k. starts_with ( & prefix) ) {
308+ let key = format ! ( "{provider}:*" ) ;
309+ let breaker = self
310+ . breakers
311+ . entry ( key)
312+ . or_insert_with ( || CircuitBreaker :: new ( self . cb_config . clone ( ) ) ) ;
313+ breaker. record_failure ( ) ;
314+ }
315+
316+ warn ! (
317+ provider = provider,
318+ "provider failure recorded (all models)"
319+ ) ;
320+ }
321+
322+ /// Record a failure for a specific model.
323+ pub fn record_model_failure ( & mut self , provider : & str , model : & str ) {
324+ let key = format ! ( "{provider}:{model}" ) ;
234325 let breaker = self
235326 . breakers
236- . entry ( provider . to_string ( ) )
327+ . entry ( key )
237328 . or_insert_with ( || CircuitBreaker :: new ( self . cb_config . clone ( ) ) ) ;
238329 breaker. record_failure ( ) ;
239330 warn ! (
240331 provider = provider,
332+ model = model,
241333 state = %breaker. state( ) ,
242- "provider failure recorded"
334+ "model failure recorded"
243335 ) ;
244336 }
245337
@@ -489,5 +581,41 @@ mod tests {
489581 ] ;
490582 // One model succeeded -> provider is healthy
491583 assert ! ( map. is_healthy( "minimax" ) ) ;
584+ // But the timed-out model is individually unhealthy
585+ assert ! ( !map. is_model_healthy( "minimax" , "opencode-go/minimax-m2.5" ) ) ;
586+ assert ! ( map. is_model_healthy( "minimax" , "minimax-coding-plan/MiniMax-M2.5" ) ) ;
587+ }
588+
589+ #[ test]
590+ fn per_model_failure_does_not_affect_other_models ( ) {
591+ let mut map = ProviderHealthMap :: new ( Duration :: from_secs ( 300 ) ) ;
592+ // Opus fails but sonnet works
593+ map. results = vec ! [
594+ ProbeResult {
595+ provider: "anthropic" . to_string( ) ,
596+ model: "claude-opus-4-6" . to_string( ) ,
597+ cli_tool: "claude" . to_string( ) ,
598+ status: ProbeStatus :: Error ,
599+ latency_ms: Some ( 5000 ) ,
600+ error: Some ( "rate limited" . to_string( ) ) ,
601+ timestamp: String :: new( ) ,
602+ } ,
603+ ProbeResult {
604+ provider: "anthropic" . to_string( ) ,
605+ model: "claude-sonnet-4-6" . to_string( ) ,
606+ cli_tool: "claude" . to_string( ) ,
607+ status: ProbeStatus :: Success ,
608+ latency_ms: Some ( 8000 ) ,
609+ error: None ,
610+ timestamp: String :: new( ) ,
611+ } ,
612+ ] ;
613+ // Provider healthy (sonnet works)
614+ assert ! ( map. is_healthy( "anthropic" ) ) ;
615+ // But opus individually unhealthy
616+ assert ! ( !map. is_model_healthy( "anthropic" , "claude-opus-4-6" ) ) ;
617+ assert ! ( map. is_model_healthy( "anthropic" , "claude-sonnet-4-6" ) ) ;
618+ // Provider NOT in unhealthy list (sonnet keeps it alive)
619+ assert ! ( !map. unhealthy_providers( ) . contains( & "anthropic" . to_string( ) ) ) ;
492620 }
493621}
0 commit comments