Skip to main content

orch/engine/router/
config.rs

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