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