Skip to main content

orch/engine/router/
config.rs

1//! Router configuration — loading, defaults, and model map.
2
3use std::collections::HashMap;
4
5/// Minimum healthy agents threshold for graceful degradation.
6/// When healthy agents fall below this number, dispatch switches to sequential mode.
7pub fn min_healthy_agents_threshold() -> usize {
8    crate::config::get("dispatch.min_healthy_agents")
9        .ok()
10        .and_then(|v| v.parse().ok())
11        .unwrap_or(2)
12}
13
14/// Base delay in milliseconds between dispatches in sequential (degraded) mode.
15pub fn sequential_dispatch_delay_ms() -> u64 {
16    crate::config::get("dispatch.sequential_delay_ms")
17        .ok()
18        .and_then(|v| v.parse().ok())
19        .unwrap_or(1000)
20}
21
22/// Lookback window (in hours) for the pre-emptive agent health check.
23///
24/// The health check queries `rate_limits` for events within this window.
25/// Configurable via `dispatch.health_check_window_hours` (default: 6).
26pub fn health_check_window_hours() -> u32 {
27    crate::config::get("dispatch.health_check_window_hours")
28        .ok()
29        .and_then(|v| v.parse().ok())
30        .unwrap_or(crate::engine::cooldown::DEFAULT_HEALTH_CHECK_WINDOW_HOURS)
31}
32
33/// Minimum number of rate-limit events within the health check window to
34/// consider an agent degraded.
35///
36/// A single transient 429 error shouldn't mark an agent degraded; we need
37/// a pattern of repeated failures to detect genuine degradation. This
38/// threshold is compared against the count of rate_limit events in the
39/// `rate_limits` table within the health check window.
40///
41/// IMPORTANT: An agent with `rate_limit_count == 0` is NOT degraded by this
42/// criterion — it must meet or exceed the threshold. The threshold should
43/// be set to a value >= 2 to avoid false positives when there are no events.
44///
45/// Configurable via `dispatch.degraded_rate_limit_threshold` (default: 3).
46pub fn degraded_rate_limit_threshold() -> i64 {
47    crate::config::get("dispatch.degraded_rate_limit_threshold")
48        .ok()
49        .and_then(|v| v.parse().ok())
50        .unwrap_or(crate::engine::cooldown::DEFAULT_DEGRADED_RATE_LIMIT_THRESHOLD)
51}
52
53/// Base delay in milliseconds for exponential backoff between fallback retries.
54pub fn retry_base_delay_ms() -> u64 {
55    crate::config::get("dispatch.retry_base_delay_ms")
56        .ok()
57        .and_then(|v| v.parse().ok())
58        .unwrap_or(10_000)
59}
60
61/// Maximum delay in milliseconds for exponential backoff between fallback retries.
62pub fn retry_max_delay_ms() -> u64 {
63    crate::config::get("dispatch.retry_max_delay_ms")
64        .ok()
65        .and_then(|v| v.parse().ok())
66        .unwrap_or(120_000)
67}
68
69/// Upper bound for router LLM timeout (seconds).
70///
71/// Routing is a short classification step and should fail fast so a single
72/// slow model does not stall fallback to other healthy agents.
73pub const MAX_ROUTER_TIMEOUT_SECS: u64 = 45;
74
75/// With weighted round robin, an agent whose routing weight decays below this
76/// value is skipped during pre-emptive routability checks.
77pub const SKIP_LIMITED_THRESHOLD: f64 = 0.3;
78
79/// Return the configured base backoff (seconds) for an agent.
80///
81/// Returns the value of `router.backoff_base.{agent}` if set, otherwise
82/// the default BACKOFF_BASE_SECS (5 minutes).
83///
84/// This lets opencode and other higher-failure-rate agents use a longer
85/// initial backoff to reduce wasted retry attempts against consistently
86/// failing models.
87pub fn get_agent_backoff_base(agent: &str) -> i64 {
88    let key = format!("router.backoff_base.{agent}");
89    crate::config::get(&key)
90        .ok()
91        .and_then(|v| v.parse().ok())
92        .unwrap_or(crate::engine::cooldown::BACKOFF_BASE_SECS)
93}
94
95/// Maximum number of tasks to route per tick. Prevents blocking the tick loop
96/// when multiple tasks are queued simultaneously (e.g., cron jobs). Each routing
97/// operation involves an LLM call taking 10-45 seconds, so routing N tasks
98/// sequentially would block for N×LLM-latency seconds.
99///
100/// Configurable via `router.max_tasks_per_tick` (default: 1).
101pub fn max_tasks_per_routing_tick() -> usize {
102    crate::config::get("router.max_tasks_per_tick")
103        .ok()
104        .and_then(|v| v.parse().ok())
105        .unwrap_or(1)
106}
107
108/// Default agents to check in PATH.
109///
110/// All 7 agents are listed, but availability is checked at runtime via
111/// `which::which()`. Agents not installed (e.g. kimi, minimax, glm) are
112/// automatically skipped during routing. Users can customize this list
113/// in their `config.yml` under `agents:`.
114pub const DEFAULT_AGENTS: &[&str] = &[
115    "claude", "codex", "opencode", "kimi", "minimax", "glm", "grok",
116];
117
118/// Router configuration.
119#[derive(Debug, Clone)]
120pub struct RouterConfig {
121    /// Routing mode: "llm", "local", or "round_robin"
122    ///
123    /// - "llm": Use cloud LLMs for routing (default, supports pool-based routing)
124    /// - "local": Use local Ollama model for routing
125    /// - "round_robin": Distribute tasks evenly across available agents
126    pub mode: String,
127    /// Which agent performs routing (default: "claude")
128    pub router_agent: String,
129    /// Model for routing (default: "haiku")
130    pub router_model: String,
131    /// Timeout for routing LLM call in seconds
132    pub timeout_seconds: u64,
133    /// Fallback executor if routing fails
134    pub fallback_executor: String,
135    /// Configurable agent list (checked against PATH at runtime)
136    pub agents: Vec<String>,
137    /// Max LLM routing attempts before falling back to round-robin
138    pub max_route_attempts: u32,
139    /// Default tools allowed
140    pub allowed_tools: Vec<String>,
141    /// Default skills to always include
142    pub default_skills: Vec<String>,
143    /// Model map for complexity levels.
144    /// Each entry is a pool of models; one is selected randomly (skipping cooled ones) per dispatch.
145    /// A single-model pool `vec!["model"]` behaves identically to the old string format.
146    pub model_map: HashMap<String, HashMap<String, Vec<String>>>,
147    /// Enable weighted round-robin routing based on rate limit capacity.
148    /// When true, agents that hit rate limits get fewer tasks.
149    pub weighted_round_robin: bool,
150    /// Pool of `agent:model` entries to round-robin the router LLM call.
151    /// When empty, a single-entry pool is derived from `router_agent:router_model`.
152    /// Special value `opencode:free` is expanded at startup via `opencode models`.
153    pub pool: Vec<String>,
154    /// Fallback `agent:model` when all pool entries are cooled or fail.
155    /// When empty, defaults to `router_agent:router_model`.
156    pub fallback: String,
157    /// Penalty weight applied when the LLM routes a task to itself (the routing agent).
158    ///
159    /// Range: `0.0` (always redirect away from router agent) to `1.0` (no penalty, default).
160    /// When `< 1.0` and the LLM selects its own agent, the router probabilistically
161    /// redirects to another available agent, reducing self-routing bias.
162    ///
163    /// Example: `0.5` means ~50% of self-routed tasks are redirected to another agent.
164    pub self_routing_penalty: f64,
165    /// Configurable base weights per agent.
166    ///
167    /// Higher weight = more tasks routed to this agent. Agents without an explicit
168    /// weight default to 1.0. The weighted selection normalizes these into
169    /// probabilities, so `{claude: 0.6, minimax: 0.03}` means claude gets ~95%
170    /// of tasks when both are available.
171    ///
172    /// Set in `config.yml` under `router.weights`:
173    /// ```yaml
174    /// router:
175    ///   weights:
176    ///     claude: 0.6
177    ///     codex: 0.2
178    ///     opencode: 0.15
179    ///     minimax: 0.03
180    ///     kimi: 0.02
181    /// ```
182    pub weights: HashMap<String, f64>,
183    /// Per-agent base backoff overrides (seconds). Agents not listed use the
184    /// default BACKOFF_BASE_SECS (5 minutes). Configure as `router.backoff_base.{agent}`.
185    ///
186    /// Example: set `router.backoff_base.opencode: 600` for a 10-minute base
187    /// instead of the default 5-minute base (useful for agents with higher
188    /// failure rates like opencode at 24.8%).
189    pub agent_backoff_bases: HashMap<String, i64>,
190    /// Maximum number of tasks to route per tick. Prevents blocking the tick loop
191    /// when multiple tasks are queued simultaneously (e.g., cron jobs). Each routing
192    /// operation involves an LLM call taking 10-45 seconds, so routing N tasks
193    /// sequentially would block for N×LLM-latency seconds.
194    ///
195    /// Configurable via `router.max_tasks_per_tick` (default: 1).
196    pub max_tasks_per_tick: usize,
197    /// Ollama base URL for local routing (default: "http://localhost:11434").
198    pub ollama_url: String,
199    /// Ollama model name for routing (default: "qwen2.5-coder:3b-instruct").
200    pub ollama_model: String,
201    /// Timeout for Ollama HTTP call in seconds (default: 30).
202    pub ollama_timeout_seconds: u64,
203}
204
205/// Parse an `agent:model` pool entry string, splitting on the first colon.
206///
207/// Examples:
208/// - `"claude:haiku"` → `("claude", "haiku")`
209/// - `"opencode:github-copilot/gpt-5-mini"` → `("opencode", "github-copilot/gpt-5-mini")`
210/// - `"claude"` → `("claude", "")`
211pub fn parse_pool_entry(entry: &str) -> (String, String) {
212    if let Some(colon_pos) = entry.find(':') {
213        let agent = entry[..colon_pos].to_string();
214        let model = entry[colon_pos + 1..].to_string();
215        // Normalize trailing slashes in model part (defense-in-depth for bug #1507)
216        let model = RouterConfig::normalize_model_identifier(&model);
217        (agent, model)
218    } else {
219        (entry.to_string(), String::new())
220    }
221}
222
223impl Default for RouterConfig {
224    fn default() -> Self {
225        Self {
226            mode: "round_robin".to_string(),
227            router_agent: "claude".to_string(),
228            router_model: "haiku".to_string(),
229            timeout_seconds: 45,
230            fallback_executor: "codex".to_string(),
231            agents: DEFAULT_AGENTS.iter().map(|s| s.to_string()).collect(),
232            max_route_attempts: 3,
233            pool: vec![],
234            fallback: String::new(),
235            self_routing_penalty: 1.0,
236            weights: HashMap::new(),
237            allowed_tools: vec![
238                "yq".to_string(),
239                "jq".to_string(),
240                "bash".to_string(),
241                "just".to_string(),
242                "git".to_string(),
243                "rg".to_string(),
244                "sed".to_string(),
245                "awk".to_string(),
246                "python3".to_string(),
247                "node".to_string(),
248                "npm".to_string(),
249                "bun".to_string(),
250            ],
251            default_skills: vec!["gh".to_string(), "git-worktree".to_string()],
252            // model_map is intentionally empty — config.yml is the sole source of truth.
253            // Hardcoded models cause "Model not found" failures when the model does not
254            // exist for a particular agent (e.g. anthropic/ prefixed models for opencode).
255            model_map: HashMap::new(),
256            weighted_round_robin: false,
257            agent_backoff_bases: HashMap::new(),
258            max_tasks_per_tick: 1,
259            ollama_url: "http://localhost:11434".to_string(),
260            ollama_model: "qwen2.5-coder:3b-instruct".to_string(),
261            ollama_timeout_seconds: 30,
262        }
263    }
264}
265
266impl RouterConfig {
267    /// Load configuration from config files.
268    pub fn from_config() -> Self {
269        let mut config = Self::default();
270
271        // Try to load from config
272        if let Ok(mode) = crate::config::get("router.mode") {
273            if mode == "round_robin" || mode == "llm" || mode == "local" {
274                config.mode = mode;
275            }
276        }
277
278        if let Ok(agent) = crate::config::get("router.agent") {
279            if !agent.is_empty() {
280                config.router_agent = agent;
281            }
282        }
283
284        if let Ok(model) = crate::config::get("router.model") {
285            if !model.is_empty() {
286                config.router_model = RouterConfig::normalize_model_identifier(&model);
287            }
288        }
289
290        if let Ok(timeout) = crate::config::get("router.timeout_seconds") {
291            if let Ok(secs) = timeout.parse::<u64>() {
292                let clamped = secs.min(MAX_ROUTER_TIMEOUT_SECS);
293                if clamped != secs {
294                    static TIMEOUT_CLAMP_WARNED: std::sync::OnceLock<()> =
295                        std::sync::OnceLock::new();
296                    TIMEOUT_CLAMP_WARNED.get_or_init(|| {
297                        tracing::warn!(
298                            configured_secs = secs,
299                            applied_secs = clamped,
300                            max_secs = MAX_ROUTER_TIMEOUT_SECS,
301                            "router.timeout_seconds is too high; clamping to keep routing responsive"
302                        );
303                    });
304                }
305                config.timeout_seconds = clamped;
306            }
307        }
308
309        if let Ok(fallback) = crate::config::get("router.fallback_executor") {
310            if !fallback.is_empty() {
311                config.fallback_executor = fallback;
312            }
313        }
314
315        // Parse agents list from top-level `agents:` config key
316        config.agents = crate::engine::configured_agents();
317
318        // Temporary operational toggle: allow disabling agents without editing
319        // config files. Controlled by ORCH_EXCLUDE_AGENTS env var (comma-separated
320        // list of agent names to exclude from routing). This keeps changes reversible
321        // (unset the env var to restore behavior) and avoids editing user config files.
322        // Example: ORCH_EXCLUDE_AGENTS=glm,some-other-agent
323        let exclude_agents = std::env::var("ORCH_EXCLUDE_AGENTS")
324            .map(|v| {
325                v.split(',')
326                    .map(|s| s.trim().to_string())
327                    .filter(|s| !s.is_empty())
328                    .collect::<Vec<_>>()
329            })
330            .unwrap_or_default();
331        if !exclude_agents.is_empty() {
332            let original_len = config.agents.len();
333            config.agents.retain(|a| !exclude_agents.contains(a));
334            let removed_count = original_len - config.agents.len();
335            if removed_count > 0 {
336                tracing::warn!(
337                    "ORCH_EXCLUDE_AGENTS={}: temporarily removing {} agent(s) from router",
338                    exclude_agents.join(","),
339                    removed_count
340                );
341            }
342        }
343
344        if let Ok(max_attempts) = crate::config::get("router.max_route_attempts") {
345            if let Ok(n) = max_attempts.parse::<u32>() {
346                config.max_route_attempts = n;
347            }
348        }
349
350        // Parse allowed_tools as comma-separated or YAML array
351        if let Ok(tools_str) = crate::config::get("router.allowed_tools") {
352            if !tools_str.is_empty() && tools_str != "[]" {
353                // Try to parse as JSON/YAML array first
354                if let Ok(tools_arr) = serde_json::from_str::<Vec<String>>(&tools_str) {
355                    config.allowed_tools = tools_arr;
356                } else {
357                    // Fall back to comma-separated
358                    config.allowed_tools = tools_str
359                        .split(',')
360                        .map(|s| s.trim().to_string())
361                        .filter(|s| !s.is_empty())
362                        .collect();
363                }
364            }
365        }
366
367        // Parse weighted_round_robin
368        if let Ok(val) = crate::config::get("router.weighted_round_robin") {
369            config.weighted_round_robin = val == "true" || val == "1";
370        }
371
372        // Parse self_routing_penalty
373        if let Ok(val) = crate::config::get("router.self_routing_penalty") {
374            if let Ok(penalty) = val.parse::<f64>() {
375                config.self_routing_penalty = penalty.clamp(0.0, 1.0);
376            }
377        }
378
379        // Parse per-agent weights from router.weights.<agent>
380        for agent in &config.agents {
381            let key = format!("router.weights.{agent}");
382            if let Ok(val) = crate::config::get(&key) {
383                if let Ok(w) = val.parse::<f64>() {
384                    config.weights.insert(agent.clone(), w.max(0.0));
385                }
386            }
387        }
388
389        // Parse per-agent backoff base seconds from router.backoff_base.<agent>
390        for agent in &config.agents {
391            let key = format!("router.backoff_base.{agent}");
392            if let Ok(val) = crate::config::get(&key) {
393                if let Ok(secs) = val.parse::<i64>() {
394                    config.agent_backoff_bases.insert(agent.clone(), secs);
395                }
396            }
397        }
398
399        // Parse max_tasks_per_tick
400        if let Ok(val) = crate::config::get("router.max_tasks_per_tick") {
401            if let Ok(n) = val.parse::<usize>() {
402                if n > 0 {
403                    config.max_tasks_per_tick = n;
404                }
405            }
406        }
407
408        // Parse pool: list of "agent:model" entries.
409        // `config::get_list()` returns Ok(vec![]) when the key is missing, so we
410        // can't rely on `is_ok()` to indicate presence.
411        match crate::config::get_list("router.pool") {
412            Ok(pool_list) if !pool_list.is_empty() => {
413                config.pool = pool_list;
414            }
415            _ => {
416                // Accept comma-separated string lists: "a,b,c"
417                if let Ok(pool_raw) = crate::config::get("router.pool") {
418                    let parsed = pool_raw
419                        .split(',')
420                        .map(|s| s.trim().to_string())
421                        .filter(|s| !s.is_empty())
422                        .collect::<Vec<_>>();
423                    if !parsed.is_empty() {
424                        config.pool = parsed;
425                    }
426                }
427            }
428        }
429
430        // Parse fallback: single "agent:model" entry
431        if let Ok(fallback) = crate::config::get("router.fallback") {
432            if !fallback.is_empty() {
433                config.fallback = fallback;
434            }
435        }
436
437        // Load top-level model shorthand from config (models.{agent}).
438        //
439        // These candidates apply to every complexity tier by default. More
440        // specific model_map.{complexity}.{agent} entries below override the
441        // shorthand for that one tier.
442        let known_agents = crate::engine::configured_agents();
443        let known_complexities = ["simple", "medium", "complex", "review"];
444        for agent in &known_agents {
445            let key = format!("models.{agent}");
446            if let Some(models) = Self::read_model_pool_from_config(&key) {
447                for complexity in known_complexities {
448                    config
449                        .model_map
450                        .entry(complexity.to_string())
451                        .or_default()
452                        .insert(agent.to_string(), models.clone());
453                }
454            }
455        }
456
457        // Load model_map overrides from config (model_map.{complexity}.{agent})
458        // Value may be a single string ("model-name") or a JSON array (["m1","m2"]).
459        // Iterate over all known complexity tiers regardless of what is in model_map —
460        // the Default impl has no hardcoded entries, so iterating over keys() would be a no-op.
461        for complexity in known_complexities {
462            for agent in &known_agents {
463                let key = format!("model_map.{complexity}.{agent}");
464                if let Some(models) = Self::read_model_pool_from_config(&key) {
465                    config
466                        .model_map
467                        .entry(complexity.to_string())
468                        .or_default()
469                        .insert(agent.to_string(), models);
470                }
471            }
472        }
473
474        // Startup config hygiene: warn about and prune opencode model_map entries that
475        // the provider catalog does not currently list.  Running this at load time avoids
476        // dispatching unavailable models during routing — the dynamic filter in
477        // `expanded_model_pool` also catches these, but fails open when discovery returns
478        // empty (e.g. during a background cache refresh), allowing stale models through.
479        // Reading the catalog costs an `opencode models` run, so only configs that can
480        // reach opencode pay for it
481        let discovered_opencode = if config.uses_opencode() {
482            crate::engine::runner::agents::opencode::discover_opencode_models()
483        } else {
484            Vec::new()
485        };
486        if !discovered_opencode.is_empty() {
487            let discovered: std::collections::HashSet<String> =
488                discovered_opencode.into_iter().collect();
489            for complexity in known_complexities {
490                if let Some(agent_models) = config.model_map.get(complexity) {
491                    if let Some(models) = agent_models.get("opencode") {
492                        for model in models {
493                            if model != "opencode:free" && !discovered.contains(model) {
494                                tracing::warn!(
495                                    complexity,
496                                    model,
497                                    "opencode model from config not present in provider model list; pruning from dispatch pool"
498                                );
499                            }
500                        }
501                    }
502                }
503            }
504            let pruned =
505                Self::prune_unavailable_opencode_models(&mut config.model_map, &discovered);
506            if pruned > 0 {
507                tracing::info!(
508                    pruned_count = pruned,
509                    "model_pruned_unavailable: removed opencode models absent from provider catalog"
510                );
511            }
512        }
513
514        // Parse default_skills
515        if let Ok(skills_str) = crate::config::get("router.default_skills") {
516            if !skills_str.is_empty() && skills_str != "[]" {
517                if let Ok(skills_arr) = serde_json::from_str::<Vec<String>>(&skills_str) {
518                    config.default_skills = skills_arr;
519                } else {
520                    config.default_skills = skills_str
521                        .split(',')
522                        .map(|s| s.trim().to_string())
523                        .filter(|s| !s.is_empty())
524                        .collect();
525                }
526            }
527        }
528
529        // Parse Ollama-specific config for local routing mode
530        if let Ok(url) = crate::config::get("router.ollama_url") {
531            if !url.is_empty() {
532                config.ollama_url = url;
533            }
534        }
535        if let Ok(model) = crate::config::get("router.ollama_model") {
536            if !model.is_empty() {
537                config.ollama_model = model;
538            }
539        }
540        if let Ok(timeout) = crate::config::get("router.ollama_timeout_seconds") {
541            if let Ok(secs) = timeout.parse::<u64>() {
542                if secs > 0 {
543                    config.ollama_timeout_seconds = secs;
544                }
545            }
546        }
547
548        config
549    }
550
551    /// Return the effective pool — the configured pool, or a single-entry pool from `router_agent:router_model`.
552    pub fn effective_pool(&self) -> Vec<String> {
553        if !self.pool.is_empty() {
554            self.pool.clone()
555        } else {
556            vec![format!("{}:{}", self.router_agent, self.router_model)]
557        }
558    }
559
560    /// Return the effective fallback entry — the configured fallback, or `router_agent:router_model`.
561    pub fn effective_fallback(&self) -> String {
562        if !self.fallback.is_empty() {
563            self.fallback.clone()
564        } else {
565            format!("{}:{}", self.router_agent, self.router_model)
566        }
567    }
568
569    /// Check if a model identifier looks syntactically valid.
570    /// Rejects empty strings, strings ending with slash, and strings with whitespace.
571    fn is_valid_model_identifier(model: &str) -> bool {
572        if model.is_empty() {
573            return false;
574        }
575        // Reject trailing slash (e.g., "opus/")
576        if model.ends_with('/') {
577            return false;
578        }
579        // Reject whitespace
580        if model.chars().any(char::is_whitespace) {
581            return false;
582        }
583        // Ensure that if there is a slash, both parts are non-empty
584        if let Some(slash_pos) = model.find('/') {
585            let before = &model[..slash_pos];
586            let after = &model[slash_pos + 1..];
587            if before.is_empty() || after.is_empty() {
588                return false;
589            }
590        }
591        true
592    }
593
594    /// Normalize a model identifier by stripping trailing slashes.
595    /// This is a defense-in-depth measure to handle edge cases where
596    /// a model string might have been incorrectly formatted.
597    pub fn normalize_model_identifier(model: &str) -> String {
598        model.trim_end_matches('/').to_string()
599    }
600
601    fn read_model_pool_from_config(key: &str) -> Option<Vec<String>> {
602        if let Ok(list) = crate::config::get_list(key) {
603            if !list.is_empty() {
604                return Some(
605                    list.iter()
606                        .map(|m| RouterConfig::normalize_model_identifier(m))
607                        .collect(),
608                );
609            }
610        } else if let Ok(val) = crate::config::get(key) {
611            if !val.is_empty() {
612                return Some(vec![RouterConfig::normalize_model_identifier(&val)]);
613            }
614        }
615
616        None
617    }
618
619    fn expanded_model_pool(&self, agent: &str, complexity: &str) -> Option<Vec<String>> {
620        let pool = self.model_map.get(complexity)?.get(agent)?;
621        if pool.is_empty() {
622            return Some(Vec::new());
623        }
624
625        let mut expanded_pool = Vec::new();
626        let mut has_free = false;
627
628        for model in pool {
629            // Normalize the model identifier first to handle edge cases like trailing slashes
630            let normalized = Self::normalize_model_identifier(model);
631            if normalized == "opencode:free" {
632                has_free = true;
633            } else if Self::is_valid_model_identifier(&normalized) {
634                expanded_pool.push(normalized);
635            } else {
636                tracing::debug!(agent, model, "skipping invalid model identifier in config");
637            }
638        }
639
640        if has_free {
641            expanded_pool
642                .extend(crate::engine::runner::agents::opencode::discover_free_opencode_models());
643        }
644
645        // Also filter out invalid discovered free models (should be valid but just in case)
646        expanded_pool.retain(|m| Self::is_valid_model_identifier(m));
647
648        // For opencode, filter configured entries against the provider model catalog
649        // when available. This proactively avoids dispatching non-existent model IDs.
650        if agent == "opencode" {
651            let discovered = crate::engine::runner::agents::opencode::discover_opencode_models();
652            if !discovered.is_empty() {
653                let discovered_set: std::collections::HashSet<String> =
654                    discovered.into_iter().collect();
655                expanded_pool.retain(|m| discovered_set.contains(m));
656            }
657        }
658
659        Some(expanded_pool)
660    }
661
662    pub(crate) fn model_pool_for_complexity(
663        &self,
664        agent: &str,
665        complexity: &str,
666    ) -> Option<Vec<String>> {
667        self.expanded_model_pool(agent, complexity)
668    }
669
670    pub fn has_available_model_for_complexity(&self, agent: &str, complexity: &str) -> bool {
671        match self.expanded_model_pool(agent, complexity) {
672            None => false,
673            Some(pool) => pool
674                .iter()
675                .any(|model| !crate::engine::cooldown::is_model_in_cooldown(agent, model)),
676        }
677    }
678
679    /// Get the model for a given agent and complexity level.
680    ///
681    /// When the complexity tier has a pool of models, selects randomly using
682    /// `task_id` as an entropy source and skips models currently in cooldown.
683    /// Falls back to `pool[0]` if all models are cooled.
684    ///
685    /// Backward-compatible: a single-model pool always returns that model.
686    pub fn model_for_complexity(
687        &self,
688        agent: &str,
689        complexity: &str,
690        task_id: &str,
691    ) -> Option<String> {
692        let expanded_pool = self.expanded_model_pool(agent, complexity)?;
693        if expanded_pool.is_empty() {
694            return None;
695        }
696        // Random starting index — varies per task_id to distribute across the pool
697        let start =
698            crate::engine::router::selection::simple_hash_index_for(expanded_pool.len(), task_id);
699        // Walk the pool from start, skipping cooled and invalid models
700        for i in 0..expanded_pool.len() {
701            let model = &expanded_pool[(start + i) % expanded_pool.len()];
702            // Safety check: skip invalid identifiers (should already be filtered)
703            if !Self::is_valid_model_identifier(model) {
704                tracing::debug!(
705                    agent,
706                    model,
707                    "invalid model identifier slipped through validation"
708                );
709                continue;
710            }
711            if !crate::engine::cooldown::is_model_in_cooldown(agent, model) {
712                // Normalize the model identifier to handle edge cases like trailing slashes
713                return Some(Self::normalize_model_identifier(model));
714            }
715        }
716        // All models cooled — return None so the caller can fall back to a different agent
717        None
718    }
719
720    /// True when this config can reach opencode: a `model_map` tier lists it, or the
721    /// effective router pool or fallback names it. Those are the only entries read out of
722    /// `opencode models`, so every other config can skip the call
723    pub(crate) fn uses_opencode(&self) -> bool {
724        fn names_opencode(entry: &str) -> bool {
725            entry == "opencode" || entry.starts_with("opencode:")
726        }
727
728        if self
729            .model_map
730            .values()
731            .any(|by_agent| by_agent.contains_key("opencode"))
732        {
733            return true;
734        }
735
736        self.effective_pool().iter().any(|e| names_opencode(e))
737            || names_opencode(&self.effective_fallback())
738    }
739
740    /// Remove `model_map` entries for the `opencode` agent whose identifiers do not
741    /// appear in `discovered`.  The sentinel value `"opencode:free"` is never pruned
742    /// because it is expanded at routing time and is not dispatched as a literal model ID.
743    ///
744    /// Returns the total number of model entries removed across all complexity tiers.
745    fn prune_unavailable_opencode_models(
746        model_map: &mut HashMap<String, HashMap<String, Vec<String>>>,
747        discovered: &std::collections::HashSet<String>,
748    ) -> usize {
749        let mut pruned = 0;
750        for agent_models in model_map.values_mut() {
751            if let Some(models) = agent_models.get_mut("opencode") {
752                let before = models.len();
753                models.retain(|m| m == "opencode:free" || discovered.contains(m));
754                pruned += before - models.len();
755            }
756        }
757        pruned
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::{RouterConfig, MAX_ROUTER_TIMEOUT_SECS};
764    use serial_test::serial;
765    use std::sync::{Mutex, OnceLock};
766
767    struct CurrentDirGuard {
768        original: std::path::PathBuf,
769    }
770
771    impl CurrentDirGuard {
772        fn set(path: &std::path::Path) -> Self {
773            let original = std::env::current_dir().unwrap();
774            std::env::set_current_dir(path).unwrap();
775            Self { original }
776        }
777    }
778
779    impl Drop for CurrentDirGuard {
780        fn drop(&mut self) {
781            std::env::set_current_dir(&self.original).unwrap();
782        }
783    }
784
785    fn cwd_mutex() -> &'static Mutex<()> {
786        static CWD_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
787        CWD_MUTEX.get_or_init(|| Mutex::new(()))
788    }
789
790    #[serial(cooldown_state)]
791    #[tokio::test]
792    async fn model_for_complexity_returns_none_when_all_models_cooled() {
793        crate::engine::cooldown::reset_global_state().await;
794        let mut config = RouterConfig::default();
795        // Inject a two-model pool for a unique agent name to avoid cross-test pollution
796        config
797            .model_map
798            .entry("complex".to_string())
799            .or_default()
800            .insert(
801                "testagent_allcooled".to_string(),
802                vec![
803                    "cooldown-model-a".to_string(),
804                    "cooldown-model-b".to_string(),
805                ],
806            );
807
808        // Put both models into cooldown
809        crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-a")
810            .await;
811        crate::engine::cooldown::record_model_failure("testagent_allcooled", "cooldown-model-b")
812            .await;
813
814        let result = config.model_for_complexity("testagent_allcooled", "complex", "task-0");
815        assert!(
816            result.is_none(),
817            "should return None when all models in pool are in cooldown, got {result:?}"
818        );
819    }
820
821    #[serial(cooldown_state)]
822    #[tokio::test]
823    async fn model_for_complexity_returns_none_when_single_model_cooled() {
824        crate::engine::cooldown::reset_global_state().await;
825        let mut config = RouterConfig::default();
826        config
827            .model_map
828            .entry("complex".to_string())
829            .or_default()
830            .insert(
831                "testagent_singlecooled".to_string(),
832                vec!["single-cooled-model".to_string()],
833            );
834
835        crate::engine::cooldown::record_model_failure(
836            "testagent_singlecooled",
837            "single-cooled-model",
838        )
839        .await;
840
841        let result = config.model_for_complexity("testagent_singlecooled", "complex", "task-0");
842        assert!(
843            result.is_none(),
844            "should return None when the only model in pool is in cooldown, got {result:?}"
845        );
846    }
847
848    #[serial(cooldown_state)]
849    #[test]
850    fn model_for_complexity_returns_model_when_not_cooled() {
851        let mut config = RouterConfig::default();
852        config
853            .model_map
854            .entry("complex".to_string())
855            .or_default()
856            .insert(
857                "testagent_notcooled".to_string(),
858                vec!["healthy-model".to_string()],
859            );
860
861        // Ensure no cooldown is active for this specific model
862        let result = config.model_for_complexity("testagent_notcooled", "complex", "task-0");
863        assert_eq!(
864            result.as_deref(),
865            Some("healthy-model"),
866            "should return the model when it is not in cooldown"
867        );
868    }
869
870    #[serial(cooldown_state)]
871    #[test]
872    fn has_available_model_returns_false_when_no_pool_configured() {
873        let mut config = RouterConfig::default();
874        // Simulate olm agent with only simple and medium configured (missing complex and review)
875        config
876            .model_map
877            .entry("simple".to_string())
878            .or_default()
879            .insert("olm".to_string(), vec!["qwen3.5".to_string()]);
880        config
881            .model_map
882            .entry("medium".to_string())
883            .or_default()
884            .insert("olm".to_string(), vec!["gemma4".to_string()]);
885        // Note: complex and review tiers are NOT configured for olm
886
887        // has_available_model_for_complexity should return false when no pool exists
888        assert!(
889            config.has_available_model_for_complexity("olm", "simple"),
890            "should return true when model pool exists and is configured"
891        );
892        assert!(
893            config.has_available_model_for_complexity("olm", "medium"),
894            "should return true when model pool exists and is configured"
895        );
896        assert!(
897            !config.has_available_model_for_complexity("olm", "complex"),
898            "should return false when no model pool is configured for agent+complexity"
899        );
900        assert!(
901            !config.has_available_model_for_complexity("olm", "review"),
902            "should return false when no model pool is configured for agent+complexity"
903        );
904    }
905
906    #[serial(cooldown_state)]
907    #[test]
908    fn default_self_routing_penalty_is_one() {
909        let config = RouterConfig::default();
910        assert_eq!(
911            config.self_routing_penalty, 1.0,
912            "default penalty must be 1.0 (no penalty = full self-routing allowed)"
913        );
914    }
915
916    #[serial(cooldown_state)]
917    #[test]
918    fn from_config_reads_self_routing_penalty() {
919        let _lock = cwd_mutex().lock().unwrap();
920        let dir = tempfile::tempdir().unwrap();
921        std::fs::write(
922            dir.path().join(".orch.yml"),
923            "router:\n  self_routing_penalty: 0.5\n",
924        )
925        .unwrap();
926        let _guard = CurrentDirGuard::set(dir.path());
927
928        let config = RouterConfig::from_config();
929        assert_eq!(config.self_routing_penalty, 0.5);
930    }
931
932    #[serial(cooldown_state)]
933    #[test]
934    fn default_config_model_map_is_empty() {
935        // model_map must be empty — config.yml is the sole source of truth for models.
936        // Hardcoded defaults (especially anthropic/ prefixed opencode models) cause
937        // "Model not found" failures when the model does not exist for a given agent.
938        let config = RouterConfig::default();
939        assert!(
940            config.model_map.is_empty(),
941            "Default RouterConfig must have no hardcoded models; got {:?}",
942            config.model_map
943        );
944    }
945
946    #[serial(cooldown_state)]
947    #[test]
948    fn from_config_reads_router_pool_yaml_array() {
949        let _lock = cwd_mutex().lock().unwrap();
950        let dir = tempfile::tempdir().unwrap();
951        std::fs::write(
952            dir.path().join(".orch.yml"),
953            "router:\n  pool:\n    - opencode:free\n    - opencode:github-copilot/gpt-5-mini\n    - kimi:k2p5\n    - claude:haiku\n",
954        )
955        .unwrap();
956        let _guard = CurrentDirGuard::set(dir.path());
957
958        let config = RouterConfig::from_config();
959
960        assert_eq!(
961            config.pool,
962            vec![
963                "opencode:free",
964                "opencode:github-copilot/gpt-5-mini",
965                "kimi:k2p5",
966                "claude:haiku",
967            ]
968        );
969    }
970
971    #[serial(cooldown_state)]
972    #[test]
973    fn from_config_clamps_router_timeout_to_max() {
974        let _lock = cwd_mutex().lock().unwrap();
975        let dir = tempfile::tempdir().unwrap();
976        std::fs::write(
977            dir.path().join(".orch.yml"),
978            "router:\n  timeout_seconds: 90\n",
979        )
980        .unwrap();
981        let _guard = CurrentDirGuard::set(dir.path());
982
983        let config = RouterConfig::from_config();
984        assert_eq!(config.timeout_seconds, MAX_ROUTER_TIMEOUT_SECS);
985    }
986
987    #[serial(cooldown_state)]
988    #[test]
989    fn normalize_model_identifier_strips_trailing_slash() {
990        // Bug #1507: model assignment was producing "opus/" instead of "opus"
991        assert_eq!(
992            RouterConfig::normalize_model_identifier("opus/"),
993            "opus",
994            "trailing slash should be stripped from model identifier"
995        );
996        assert_eq!(
997            RouterConfig::normalize_model_identifier("sonnet/"),
998            "sonnet",
999            "trailing slash should be stripped from model identifier"
1000        );
1001        assert_eq!(
1002            RouterConfig::normalize_model_identifier("github-copilot/gpt-5-mini/"),
1003            "github-copilot/gpt-5-mini",
1004            "trailing slash should be stripped from model identifier with internal slash"
1005        );
1006        // Edge cases: multiple trailing slashes
1007        assert_eq!(
1008            RouterConfig::normalize_model_identifier("opus//"),
1009            "opus",
1010            "multiple trailing slashes should be stripped"
1011        );
1012        // Edge cases: no trailing slash
1013        assert_eq!(
1014            RouterConfig::normalize_model_identifier("opus"),
1015            "opus",
1016            "model without trailing slash should remain unchanged"
1017        );
1018    }
1019
1020    #[serial(cooldown_state)]
1021    #[test]
1022    fn is_valid_model_identifier_rejects_trailing_slash() {
1023        // Ensure validation correctly rejects models with trailing slashes
1024        assert!(
1025            !RouterConfig::is_valid_model_identifier("opus/"),
1026            "model with trailing slash should be invalid"
1027        );
1028        assert!(
1029            RouterConfig::is_valid_model_identifier("opus"),
1030            "model without trailing slash should be valid"
1031        );
1032        assert!(
1033            RouterConfig::is_valid_model_identifier("github-copilot/gpt-5-mini"),
1034            "model with internal slash should be valid"
1035        );
1036        assert!(
1037            !RouterConfig::is_valid_model_identifier("github-copilot/"),
1038            "model ending with slash after prefix should be invalid"
1039        );
1040    }
1041
1042    #[serial(cooldown_state)]
1043    #[test]
1044    fn from_config_loads_model_map_for_custom_agents() {
1045        let _lock = cwd_mutex().lock().unwrap();
1046        let dir = tempfile::tempdir().unwrap();
1047        // Simulate a config with a custom agent (olm) in both agents and model_map
1048        std::fs::write(
1049            dir.path().join(".orch.yml"),
1050            r#"
1051agents:
1052  - claude
1053  - olm
1054model_map:
1055  simple:
1056    claude: haiku
1057    olm: qwen3.5
1058  medium:
1059    claude: sonnet
1060    olm: gemma4
1061  complex:
1062    claude: opus
1063    olm: gemma4
1064"#,
1065        )
1066        .unwrap();
1067        let _guard = CurrentDirGuard::set(dir.path());
1068
1069        let config = RouterConfig::from_config();
1070
1071        // olm models should be loaded from the model_map
1072        assert_eq!(
1073            config.model_for_complexity("olm", "simple", "test-task"),
1074            Some("qwen3.5".to_string()),
1075            "custom agent olm should have simple model from config"
1076        );
1077        assert_eq!(
1078            config.model_for_complexity("olm", "medium", "test-task"),
1079            Some("gemma4".to_string()),
1080            "custom agent olm should have medium model from config"
1081        );
1082        // claude should also work
1083        assert_eq!(
1084            config.model_for_complexity("claude", "simple", "test-task"),
1085            Some("haiku".to_string()),
1086        );
1087    }
1088
1089    #[serial(cooldown_state)]
1090    #[test]
1091    fn from_config_loads_models_shorthand_for_all_complexities() {
1092        let _lock = cwd_mutex().lock().unwrap();
1093        let dir = tempfile::tempdir().unwrap();
1094        std::fs::write(
1095            dir.path().join(".orch.yml"),
1096            r#"
1097agents:
1098  - shorthand
1099models:
1100  shorthand:
1101    - gpt-5.2
1102    - gpt-5.5
1103"#,
1104        )
1105        .unwrap();
1106        let _guard = CurrentDirGuard::set(dir.path());
1107
1108        let config = RouterConfig::from_config();
1109
1110        for complexity in ["simple", "medium", "complex", "review"] {
1111            assert_eq!(
1112                config.model_map[complexity]["shorthand"],
1113                vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()],
1114                "models.shorthand should populate {complexity}"
1115            );
1116        }
1117    }
1118
1119    #[serial(cooldown_state)]
1120    #[test]
1121    fn from_config_loads_single_model_shorthand_for_all_complexities() {
1122        let _lock = cwd_mutex().lock().unwrap();
1123        let dir = tempfile::tempdir().unwrap();
1124        std::fs::write(
1125            dir.path().join(".orch.yml"),
1126            r#"
1127agents:
1128  - shorthand
1129models:
1130  shorthand: gpt-5.2
1131"#,
1132        )
1133        .unwrap();
1134        let _guard = CurrentDirGuard::set(dir.path());
1135
1136        let config = RouterConfig::from_config();
1137
1138        for complexity in ["simple", "medium", "complex", "review"] {
1139            assert_eq!(
1140                config.model_map[complexity]["shorthand"],
1141                vec!["gpt-5.2".to_string()],
1142                "models.shorthand should populate {complexity}"
1143            );
1144        }
1145    }
1146
1147    #[serial(cooldown_state)]
1148    #[test]
1149    fn from_config_model_map_overrides_models_shorthand_per_tier() {
1150        let _lock = cwd_mutex().lock().unwrap();
1151        let dir = tempfile::tempdir().unwrap();
1152        std::fs::write(
1153            dir.path().join(".orch.yml"),
1154            r#"
1155agents:
1156  - shorthand
1157models:
1158  shorthand:
1159    - gpt-5.2
1160    - gpt-5.5
1161model_map:
1162  review:
1163    shorthand: gpt-5.6-review
1164"#,
1165        )
1166        .unwrap();
1167        let _guard = CurrentDirGuard::set(dir.path());
1168
1169        let config = RouterConfig::from_config();
1170
1171        assert_eq!(
1172            config.model_map["simple"]["shorthand"],
1173            vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1174        );
1175        assert_eq!(
1176            config.model_map["medium"]["shorthand"],
1177            vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1178        );
1179        assert_eq!(
1180            config.model_map["complex"]["shorthand"],
1181            vec!["gpt-5.2".to_string(), "gpt-5.5".to_string()]
1182        );
1183        assert_eq!(
1184            config.model_map["review"]["shorthand"],
1185            vec!["gpt-5.6-review".to_string()]
1186        );
1187    }
1188
1189    #[serial(cooldown_state)]
1190    #[test]
1191    fn model_for_complexity_normalizes_trailing_slash_from_pool() {
1192        // Bug #1507: full path test - model identifiers with trailing slashes
1193        // should be normalized (not dropped) when loading from model_map pool
1194        let mut config = RouterConfig::default();
1195        config
1196            .model_map
1197            .entry("complex".to_string())
1198            .or_default()
1199            .insert(
1200                "testagent_trailing".to_string(),
1201                vec![
1202                    "opus/".to_string(),
1203                    "sonnet//".to_string(),
1204                    "github-copilot/gpt-5-mini/".to_string(),
1205                ],
1206            );
1207
1208        // Should return normalized "opus" instead of dropping it as invalid
1209        let result = config.model_for_complexity("testagent_trailing", "complex", "task-0");
1210        assert!(
1211            result.is_some(),
1212            "should return a model from pool with trailing slashes normalized, got None"
1213        );
1214        let model = result.unwrap();
1215        // The model returned should be one of the normalized versions
1216        assert!(
1217            model == "opus" || model == "sonnet" || model == "github-copilot/gpt-5-mini",
1218            "returned model should be normalized (no trailing slash), got: {}",
1219            model
1220        );
1221        // Verify no trailing slashes in the returned model
1222        assert!(
1223            !model.ends_with('/'),
1224            "returned model should not have trailing slash, got: {}",
1225            model
1226        );
1227    }
1228
1229    // --- prune_unavailable_opencode_models regression tests (bug #3169) ---
1230
1231    #[serial(cooldown_state)]
1232    #[test]
1233    fn prune_unavailable_opencode_models_removes_missing_models() {
1234        use std::collections::{HashMap, HashSet};
1235
1236        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1237        model_map.entry("complex".to_string()).or_default().insert(
1238            "opencode".to_string(),
1239            vec![
1240                "available-model".to_string(),
1241                "unavailable-model".to_string(),
1242                "opencode:free".to_string(), // sentinel — must survive pruning
1243            ],
1244        );
1245
1246        let mut discovered = HashSet::new();
1247        discovered.insert("available-model".to_string());
1248        // "unavailable-model" intentionally absent
1249
1250        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1251
1252        assert_eq!(pruned, 1, "should prune exactly the one unavailable model");
1253
1254        let remaining = model_map["complex"]["opencode"].as_slice();
1255        assert!(
1256            remaining.contains(&"available-model".to_string()),
1257            "available model should remain"
1258        );
1259        assert!(
1260            remaining.contains(&"opencode:free".to_string()),
1261            "opencode:free sentinel should never be pruned"
1262        );
1263        assert!(
1264            !remaining.contains(&"unavailable-model".to_string()),
1265            "unavailable model should be removed"
1266        );
1267    }
1268
1269    #[serial(cooldown_state)]
1270    #[test]
1271    fn prune_unavailable_opencode_models_noop_when_all_available() {
1272        use std::collections::{HashMap, HashSet};
1273
1274        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1275        model_map.entry("simple".to_string()).or_default().insert(
1276            "opencode".to_string(),
1277            vec!["model-a".to_string(), "model-b".to_string()],
1278        );
1279
1280        let discovered: HashSet<String> = ["model-a", "model-b"]
1281            .iter()
1282            .map(|s| s.to_string())
1283            .collect();
1284
1285        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1286
1287        assert_eq!(pruned, 0, "nothing to prune when all models are available");
1288        assert_eq!(
1289            model_map["simple"]["opencode"].len(),
1290            2,
1291            "both models should remain"
1292        );
1293    }
1294
1295    #[serial(cooldown_state)]
1296    #[test]
1297    fn prune_unavailable_opencode_models_preserves_non_opencode_agents() {
1298        use std::collections::{HashMap, HashSet};
1299
1300        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1301        model_map
1302            .entry("complex".to_string())
1303            .or_default()
1304            .insert("claude".to_string(), vec!["some-model".to_string()]);
1305
1306        // discovered is empty — irrelevant for non-opencode agents
1307        let discovered: HashSet<String> = HashSet::new();
1308
1309        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1310
1311        assert_eq!(pruned, 0, "non-opencode agents must not be touched");
1312        assert_eq!(
1313            model_map["complex"]["claude"].len(),
1314            1,
1315            "claude model should be unchanged"
1316        );
1317    }
1318
1319    #[test]
1320    fn default_config_does_not_use_opencode() {
1321        assert!(!RouterConfig::default().uses_opencode());
1322    }
1323
1324    #[test]
1325    fn uses_opencode_when_a_model_map_tier_lists_it() {
1326        let mut config = RouterConfig::default();
1327        config
1328            .model_map
1329            .entry("simple".to_string())
1330            .or_default()
1331            .insert("opencode".to_string(), vec!["some-model".to_string()]);
1332        assert!(config.uses_opencode());
1333    }
1334
1335    #[test]
1336    fn uses_opencode_follows_the_effective_pool_and_fallback() {
1337        let config = RouterConfig {
1338            pool: vec!["opencode:free".to_string()],
1339            ..RouterConfig::default()
1340        };
1341        assert!(config.uses_opencode());
1342
1343        let config = RouterConfig {
1344            pool: vec!["claude:haiku".to_string()],
1345            ..RouterConfig::default()
1346        };
1347        assert!(!config.uses_opencode());
1348
1349        // An empty pool derives its single entry from router_agent:router_model
1350        let config = RouterConfig {
1351            pool: vec![],
1352            router_agent: "opencode".to_string(),
1353            router_model: "free".to_string(),
1354            ..RouterConfig::default()
1355        };
1356        assert!(config.uses_opencode());
1357
1358        let config = RouterConfig {
1359            pool: vec!["claude:haiku".to_string()],
1360            fallback: "opencode:some/model".to_string(),
1361            ..RouterConfig::default()
1362        };
1363        assert!(config.uses_opencode());
1364    }
1365
1366    #[serial(cooldown_state)]
1367    #[test]
1368    fn prune_unavailable_opencode_models_covers_all_complexity_tiers() {
1369        use std::collections::{HashMap, HashSet};
1370
1371        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1372        for tier in ["simple", "medium", "complex", "review"] {
1373            model_map
1374                .entry(tier.to_string())
1375                .or_default()
1376                .insert("opencode".to_string(), vec!["gone-model".to_string()]);
1377        }
1378
1379        // "gone-model" is not in discovered
1380        let discovered: HashSet<String> = HashSet::new();
1381
1382        let pruned = RouterConfig::prune_unavailable_opencode_models(&mut model_map, &discovered);
1383
1384        assert_eq!(pruned, 4, "should prune one entry per complexity tier");
1385        for tier in ["simple", "medium", "complex", "review"] {
1386            assert!(
1387                model_map[tier]["opencode"].is_empty(),
1388                "all entries for {} should be removed",
1389                tier
1390            );
1391        }
1392    }
1393}