diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 2a28d650..4b2cfcca 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -457,6 +457,10 @@ enabled = true # [router] # enabled = false # opt out of all model routing and publication # publish_models = false # keep manual routing, but do not augment native pickers +# discover_models = true # fetch each target's live model list from its +# # /models endpoint; discovered ids append after the +# # curated ones in pickers, failures fall back to +# # curated. Set false to suppress the requests. # port = 8917 # optional pin. When omitted the daemon reclaims the # # port last bound by this home (runtime_dir/router.port), # # falling back to 8917 and then a free port if busy. @@ -654,6 +658,16 @@ pub struct RouterConfig { /// declare, so this is the only thing there is to configure. #[serde(default)] pub oauth: BTreeMap, + /// Fetch each route target's model list from its listing endpoint + /// (spec 0209). Discovered ids are appended after the curated catalog + /// in pickers; any failure degrades to curated-only. Disable to keep + /// the daemon from making these listing requests at all. + #[serde(default = "default_discover_models")] + pub discover_models: bool, +} + +fn default_discover_models() -> bool { + true } impl Default for RouterConfig { @@ -664,6 +678,7 @@ impl Default for RouterConfig { featured_models: Vec::new(), port: None, oauth: BTreeMap::new(), + discover_models: default_discover_models(), } } } diff --git a/crates/daemon/src/config_supervisor.rs b/crates/daemon/src/config_supervisor.rs index 3e90325c..cd422161 100644 --- a/crates/daemon/src/config_supervisor.rs +++ b/crates/daemon/src/config_supervisor.rs @@ -285,6 +285,7 @@ fn describe_applied(previous: &Config, next: &Config) -> Vec { || previous.router.featured_models != next.router.featured_models || previous.router.oauth != next.router.oauth || previous.router.enabled != next.router.enabled + || previous.router.discover_models != next.router.discover_models { applied.push("[router]".to_string()); } diff --git a/crates/daemon/src/router.rs b/crates/daemon/src/router.rs index 06b334c2..c708b151 100644 --- a/crates/daemon/src/router.rs +++ b/crates/daemon/src/router.rs @@ -25,6 +25,7 @@ pub mod ca; pub mod catalog; +pub mod discovery; pub mod oauth; pub mod proxy; pub mod translate; @@ -628,6 +629,10 @@ pub(crate) struct RouterSettings { profiles: BTreeMap, /// `[router.oauth]` model overrides, keyed by provider name. oauth_models: BTreeMap, + /// Whether route targets with a listing endpoint get their picker + /// models fetched live (spec 0209). On by default; the off switch + /// exists for locked-down networks where the fetch itself is unwanted. + discover_models: bool, } pub struct Router { @@ -662,6 +667,10 @@ pub struct Router { /// credential is served as a plain tunnel: unattributable traffic gets /// the safe path, never a route. tokens: RwLock>>, + /// TTL cache of live-fetched model listings, keyed by endpoint + /// (spec 0209). Refreshed on route-menu opens; consulted by + /// [`Self::profile_model_list`]. + discovered: discovery::DiscoveryCache, } impl Router { @@ -691,6 +700,7 @@ impl Router { featured_models: cfg.featured_models.clone(), profiles, oauth_models: cfg.oauth.clone(), + discover_models: cfg.discover_models, })), preferred_port, port_pinned: cfg.port.is_some(), @@ -704,6 +714,7 @@ impl Router { observed_rx: std::sync::Mutex::new(Some(observed_rx)), sessions: RwLock::new(HashMap::new()), tokens: RwLock::new(HashMap::new()), + discovered: discovery::DiscoveryCache::default(), }) } @@ -773,6 +784,7 @@ impl Router { featured_models: cfg.featured_models.clone(), profiles, oauth_models: cfg.oauth.clone(), + discover_models: cfg.discover_models, }); restart_required } @@ -854,6 +866,12 @@ impl Router { if !claimed_preferred { self.report_fallback(bound, established); } + // Warm the discovered-model cache in the background (spec 0209) so + // catalogs published to native harnesses at attach carry live + // listings without waiting for a route-menu open. Off the start + // path: startup must not block on vendor endpoints. + let warm = self.clone(); + tokio::spawn(async move { warm.refresh_discovered_models().await }); Ok(()) } @@ -1368,9 +1386,52 @@ impl Router { models.push(model); } } + // Live-discovered ids come after the declared model and the curated + // catalog (spec 0209): curation keeps its known-good ordering at the + // front, discovery contributes the endpoint's real tail. An empty or + // failed discovery leaves exactly the curated behavior. + if let Some(base_url) = profile.resolved_base_url() { + let key = discovery::cache_key(&profile.provider, &base_url); + if let Some(discovered) = self.discovered.get(&key) { + for model in discovered.iter() { + if !models.contains(model) { + models.push(model.clone()); + } + } + } + } models } + /// Refresh the discovered-model cache for every route target whose + /// provider has a listing endpoint and whose credential resolves + /// (spec 0209). Called on route-menu opens; fresh entries make this a + /// no-op, so the common cost is zero and the worst case is one bounded + /// wait. Best-effort by design — the caller proceeds with whatever the + /// cache then holds. + pub async fn refresh_discovered_models(&self) { + let settings = self.settings(); + if !settings.discover_models { + return; + } + let specs: Vec = settings + .profiles + .values() + .filter_map(|profile| { + let kind = discovery::list_kind(&profile.provider)?; + let base_url = profile.resolved_base_url()?; + let api_key = profile.resolve_api_key().ok()?; + Some(discovery::FetchSpec { + key: discovery::cache_key(&profile.provider, &base_url), + kind, + base_url, + api_key, + }) + }) + .collect(); + self.discovered.refresh(specs).await; + } + /// Model a subscription target sends when none is chosen explicitly. fn oauth_model(&self, provider: OauthProvider) -> String { self.oauth_model_list(provider) @@ -1625,6 +1686,9 @@ mod tests { featured_models: Vec::new(), port: Some(0), oauth: BTreeMap::new(), + // Tests never fetch listings; discovery is exercised by its + // own module tests. + discover_models: false, } } @@ -1645,6 +1709,40 @@ mod tests { } } + /// Discovered ids append after the declared model and the curated + /// catalog, deduped; an empty discovery leaves exactly the curated + /// behavior (spec 0209). + #[tokio::test] + async fn discovered_models_append_after_curated_in_route_menu() { + let dir = tempfile::tempdir().unwrap(); + let mut p = profile("openrouter", None); + p.base_url = Some("https://openrouter.ai/api/v1".to_string()); + p.model = Some("openrouter/auto".to_string()); + let r = Router::new( + dir.path().to_path_buf(), + dir.path().to_path_buf(), + &cfg_with(true), + profiles(vec![("openrouter", p.clone())]), + ); + let curated: Vec = r.profile_model_list(&p); + assert!(curated.contains(&"openrouter/auto".to_string())); + + let key = discovery::cache_key("openrouter", "https://openrouter.ai/api/v1"); + r.discovered.insert( + &key, + vec![ + "stealth/newer-alpha".to_string(), + // Already curated/declared: must not duplicate. + "openrouter/auto".to_string(), + ], + true, + ); + let merged = r.profile_model_list(&p); + assert_eq!(&merged[..curated.len()], &curated[..], "curated order keeps the front"); + assert_eq!(merged.len(), curated.len() + 1); + assert_eq!(merged.last().map(String::as_str), Some("stealth/newer-alpha")); + } + /// REGRESSION: pins the `||` short-circuit in `Router::start`. When the /// router is disabled, `enabled` is false so `listening.swap(true)` is /// never evaluated and the latch stays unarmed — which is the only reason @@ -1825,6 +1923,7 @@ mod tests { featured_models: Vec::new(), port: None, // auto oauth: BTreeMap::new(), + discover_models: false, }; let r = started(&dir, cfg).await; let bound = r.port(); @@ -1890,6 +1989,7 @@ mod tests { featured_models: Vec::new(), port: None, oauth: BTreeMap::new(), + discover_models: false, }; let r = started(&dir, cfg).await; assert_eq!( @@ -1914,6 +2014,7 @@ mod tests { featured_models: Vec::new(), port: Some(busy), oauth: BTreeMap::new(), + discover_models: false, }; let r = Router::new( dir.path().to_path_buf(), diff --git a/crates/daemon/src/router/discovery.rs b/crates/daemon/src/router/discovery.rs new file mode 100644 index 00000000..3ef30be7 --- /dev/null +++ b/crates/daemon/src/router/discovery.rs @@ -0,0 +1,405 @@ +//! Dynamic model discovery for route targets (spec 0209). +//! +//! Providers that expose a model-listing endpoint get their picker rows +//! fetched live instead of relying only on the curated catalog. Discovery +//! is additive and best-effort: the curated list keeps its place at the +//! front (stable, known-good ordering), discovered ids are appended, and +//! any failure — endpoint down, key invalid, unsupported provider — +//! degrades to exactly the curated behavior. The menus never gate what a +//! user can request, so nothing here is load-bearing for routing itself. +//! +//! Fetches happen on demand when a client asks for the route menu, through +//! a TTL cache so repeated opens are instant and a dead endpoint is not +//! hammered. All fetches for one refresh run concurrently under a single +//! wall-clock budget: the menu may open a few seconds late once, never +//! hang. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +/// How long a successful listing stays fresh. +const OK_TTL: Duration = Duration::from_secs(300); +/// How long a failure is remembered before the endpoint is tried again. +/// Shorter than [`OK_TTL`] so a just-exported key or recovered endpoint is +/// picked up quickly, long enough that scrolling a menu doesn't retry a +/// dead host on every repaint. +const ERR_TTL: Duration = Duration::from_secs(60); +/// Per-request ceiling and the whole refresh's wall-clock budget. The +/// route menu is interactive; a slow vendor gets cut off, not waited for. +const FETCH_TIMEOUT: Duration = Duration::from_secs(3); +const REFRESH_BUDGET: Duration = Duration::from_secs(4); + +/// The listing dialects we know how to speak. Deliberately fewer than the +/// routable dialects: a provider absent here simply keeps its curated +/// list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListKind { + /// `GET {base}/models` with a bearer token; ids at `data[].id`. + /// OpenAI's own surface and every vendor that mimics it (Grok, + /// DeepSeek, OpenRouter, Responses-only backends). + OpenAiCompatible, + /// `GET {base}/models` with `x-api-key` + `anthropic-version`; ids at + /// `data[].id`. + Anthropic, + /// `GET {base}/models?key=` ; ids at `models[].name` (prefixed + /// `models/`), filtered to those supporting `generateContent`. + Gemini, +} + +/// Which listing dialect a `[smith.models.*]` wire provider speaks, if +/// any. Azure is deliberately absent (its listing path is +/// deployment-scoped, not `{base}/models`), as is Meta (no public listing +/// surface), so both keep curated lists. +pub fn list_kind(provider: &str) -> Option { + match provider.to_ascii_lowercase().as_str() { + "openai" | "openai-responses" | "grok" | "deepseek" | "openrouter" => { + Some(ListKind::OpenAiCompatible) + } + "anthropic" => Some(ListKind::Anthropic), + "gemini" | "google" => Some(ListKind::Gemini), + _ => None, + } +} + +/// Cache key for one endpoint. Keyed by provider + base URL (not route +/// name) so two profiles pointing at the same endpoint share one fetch. +pub fn cache_key(provider: &str, base_url: &str) -> String { + format!("{}|{}", provider.to_ascii_lowercase(), base_url.trim_end_matches('/')) +} + +/// One endpoint to (re)fetch. +#[derive(Debug, Clone)] +pub struct FetchSpec { + pub key: String, + pub kind: ListKind, + pub base_url: String, + pub api_key: String, +} + +struct Entry { + /// Empty on failure — [`DiscoveryCache::get`] hides that from callers + /// so a failed fetch reads as "nothing discovered". + models: Arc>, + fetched_at: Instant, + ok: bool, +} + +/// TTL cache of discovered model lists, keyed by [`cache_key`]. +#[derive(Default)] +pub struct DiscoveryCache { + entries: RwLock>, +} + +impl DiscoveryCache { + /// Discovered ids for an endpoint, or `None` when nothing (usable) is + /// cached. Staleness is not checked here: a stale success keeps + /// serving until the next refresh replaces it, so the menu never + /// regresses to curated-only while a refetch is in flight. + pub fn get(&self, key: &str) -> Option>> { + let entries = self.entries.read().unwrap_or_else(|p| p.into_inner()); + let entry = entries.get(key)?; + if entry.models.is_empty() { + return None; + } + Some(entry.models.clone()) + } + + fn stale(&self, key: &str) -> bool { + let entries = self.entries.read().unwrap_or_else(|p| p.into_inner()); + match entries.get(key) { + None => true, + Some(e) => e.fetched_at.elapsed() > if e.ok { OK_TTL } else { ERR_TTL }, + } + } + + pub(crate) fn insert(&self, key: &str, models: Vec, ok: bool) { + let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner()); + entries.insert( + key.to_string(), + Entry { + models: Arc::new(models), + fetched_at: Instant::now(), + ok, + }, + ); + } + + /// Fetch every stale spec concurrently, within [`REFRESH_BUDGET`]. + /// Errors are recorded (empty entry, short TTL) rather than surfaced: + /// discovery failing must look exactly like discovery not existing. + pub async fn refresh(&self, specs: Vec) { + let stale: Vec = specs.into_iter().filter(|s| self.stale(&s.key)).collect(); + if stale.is_empty() { + return; + } + let client = match reqwest::Client::builder() + .connect_timeout(FETCH_TIMEOUT) + .timeout(FETCH_TIMEOUT) + .build() + { + Ok(c) => c, + Err(_) => return, + }; + let fetches = stale.into_iter().map(|spec| { + let client = client.clone(); + async move { + let fetched = fetch_models(&client, &spec).await; + (spec.key, fetched) + } + }); + let joined = tokio::time::timeout(REFRESH_BUDGET, futures::future::join_all(fetches)); + let Ok(results) = joined.await else { + // Budget exhausted: whatever resolved in time was returned by + // join_all only as a whole, so nothing landed. The stale + // entries stay; the next open retries. + return; + }; + for (key, fetched) in results { + match fetched { + Ok(models) => self.insert(&key, models, true), + Err(e) => { + tracing::debug!(target: "router", key, error = %e, "model discovery failed"); + self.insert(&key, Vec::new(), false); + } + } + } + } +} + +async fn fetch_models(client: &reqwest::Client, spec: &FetchSpec) -> anyhow::Result> { + let base = spec.base_url.trim_end_matches('/'); + let req = match spec.kind { + ListKind::OpenAiCompatible => client + .get(format!("{base}/models")) + .bearer_auth(&spec.api_key), + ListKind::Anthropic => client + .get(format!("{base}/models?limit=1000")) + .header("x-api-key", &spec.api_key) + .header("anthropic-version", "2023-06-01"), + ListKind::Gemini => client.get(format!( + "{base}/models?pageSize=1000&key={}", + spec.api_key + )), + }; + let resp = req.send().await?; + if !resp.status().is_success() { + anyhow::bail!("{} from {base}/models", resp.status()); + } + let body = resp.text().await?; + Ok(parse_models(spec.kind, &body)) +} + +/// Extract usable model ids from a listing response body. Unknown shapes +/// parse to empty (treated as a failed fetch upstream via `ok=true` with +/// no rows — which [`DiscoveryCache::get`] already hides). +pub fn parse_models(kind: ListKind, body: &str) -> Vec { + let Ok(v) = serde_json::from_str::(body) else { + return Vec::new(); + }; + let mut out = Vec::new(); + match kind { + ListKind::OpenAiCompatible | ListKind::Anthropic => { + for item in v.get("data").and_then(|d| d.as_array()).into_iter().flatten() { + let Some(id) = item.get("id").and_then(|s| s.as_str()) else { + continue; + }; + if kind == ListKind::OpenAiCompatible && !is_chatlike_id(id) { + continue; + } + if !out.iter().any(|m| m == id) { + out.push(id.to_string()); + } + } + } + ListKind::Gemini => { + for item in v + .get("models") + .and_then(|d| d.as_array()) + .into_iter() + .flatten() + { + let supports_generate = item + .get("supportedGenerationMethods") + .and_then(|m| m.as_array()) + .map(|m| m.iter().any(|s| s.as_str() == Some("generateContent"))) + .unwrap_or(false); + if !supports_generate { + continue; + } + let Some(name) = item.get("name").and_then(|s| s.as_str()) else { + continue; + }; + let id = name.strip_prefix("models/").unwrap_or(name); + if !out.iter().any(|m| m == id) { + out.push(id.to_string()); + } + } + } + } + out +} + +/// Heuristic filter for OpenAI-compatible listings, which mix chat models +/// with embeddings, speech, image, and moderation ids. Excluding a usable +/// model here only hides a menu row — the id still works typed — so the +/// denylist stays small and obviously non-chat. +fn is_chatlike_id(id: &str) -> bool { + const NON_CHAT: &[&str] = &[ + "embed", + "whisper", + "tts", + "dall-e", + "moderation", + "davinci", + "babbage", + "audio", + "realtime", + "transcribe", + "image", + ]; + let lower = id.to_ascii_lowercase(); + !NON_CHAT.iter().any(|t| lower.contains(t)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn openai_listing_parses_and_filters_non_chat_ids() { + let body = r#"{"object":"list","data":[ + {"id":"gpt-5","object":"model"}, + {"id":"text-embedding-3-small","object":"model"}, + {"id":"whisper-1","object":"model"}, + {"id":"gpt-4o-realtime-preview","object":"model"}, + {"id":"dall-e-3","object":"model"}, + {"id":"gpt-5-mini","object":"model"} + ]}"#; + assert_eq!( + parse_models(ListKind::OpenAiCompatible, body), + vec!["gpt-5".to_string(), "gpt-5-mini".to_string()] + ); + } + + /// OpenRouter's listing uses the same shape with `vendor/model` ids; + /// server order (recency) is preserved so new/stealth ids surface near + /// the top of the discovered tail. + #[test] + fn openrouter_slash_ids_pass_through_in_order() { + let body = r#"{"data":[ + {"id":"stealth/ox-alpha"}, + {"id":"anthropic/claude-sonnet-4.6"}, + {"id":"google/gemini-2.5-flash-image"} + ]}"#; + assert_eq!( + parse_models(ListKind::OpenAiCompatible, body), + vec![ + "stealth/ox-alpha".to_string(), + "anthropic/claude-sonnet-4.6".to_string() + ] + ); + } + + #[test] + fn anthropic_listing_is_not_chat_filtered() { + let body = r#"{"data":[ + {"id":"claude-opus-4-8","display_name":"Claude Opus 4.8"}, + {"id":"claude-haiku-4-5","display_name":"Claude Haiku 4.5"} + ],"has_more":false}"#; + assert_eq!( + parse_models(ListKind::Anthropic, body), + vec!["claude-opus-4-8".to_string(), "claude-haiku-4-5".to_string()] + ); + } + + #[test] + fn gemini_listing_strips_prefix_and_requires_generate_content() { + let body = r#"{"models":[ + {"name":"models/gemini-2.5-pro","supportedGenerationMethods":["generateContent","countTokens"]}, + {"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent"]}, + {"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent"]} + ]}"#; + assert_eq!( + parse_models(ListKind::Gemini, body), + vec!["gemini-2.5-pro".to_string(), "gemini-2.5-flash".to_string()] + ); + } + + #[test] + fn malformed_bodies_parse_to_empty() { + assert!(parse_models(ListKind::OpenAiCompatible, "not json").is_empty()); + assert!(parse_models(ListKind::Anthropic, r#"{"data":"nope"}"#).is_empty()); + assert!(parse_models(ListKind::Gemini, "{}").is_empty()); + } + + #[test] + fn list_kind_covers_routable_listing_providers_only() { + assert_eq!(list_kind("openai"), Some(ListKind::OpenAiCompatible)); + assert_eq!(list_kind("openrouter"), Some(ListKind::OpenAiCompatible)); + assert_eq!(list_kind("grok"), Some(ListKind::OpenAiCompatible)); + assert_eq!(list_kind("deepseek"), Some(ListKind::OpenAiCompatible)); + assert_eq!(list_kind("anthropic"), Some(ListKind::Anthropic)); + assert_eq!(list_kind("gemini"), Some(ListKind::Gemini)); + assert_eq!(list_kind("meta"), None); + assert_eq!(list_kind("azure-openai"), None); + assert_eq!(list_kind("ollama"), None); + } + + #[test] + fn cache_serves_successes_and_hides_failures() { + let cache = DiscoveryCache::default(); + assert!(cache.get("k").is_none()); + assert!(cache.stale("k")); + cache.insert("k", vec!["m1".into()], true); + assert_eq!(cache.get("k").unwrap().as_slice(), ["m1".to_string()]); + assert!(!cache.stale("k")); + cache.insert("k", Vec::new(), false); + assert!(cache.get("k").is_none(), "failed fetches read as nothing discovered"); + } + + /// A local stub server end-to-end: refresh fetches, caches, and a + /// second refresh within TTL does not re-hit the endpoint. + #[tokio::test] + async fn refresh_fetches_once_within_ttl() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let hits = Arc::new(AtomicUsize::new(0)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_hits = hits.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + server_hits.fetch_add(1, Ordering::SeqCst); + let body = r#"{"data":[{"id":"vendor/model-a"},{"id":"vendor/model-b"}]}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 2048]; + let _ = sock.read(&mut buf).await; + let _ = sock.write_all(resp.as_bytes()).await; + } + }); + + let cache = DiscoveryCache::default(); + let spec = FetchSpec { + key: cache_key("openrouter", &format!("http://{addr}")), + kind: ListKind::OpenAiCompatible, + base_url: format!("http://{addr}"), + api_key: "sk-test".into(), + }; + cache.refresh(vec![spec.clone()]).await; + assert_eq!( + cache.get(&spec.key).unwrap().as_slice(), + ["vendor/model-a".to_string(), "vendor/model-b".to_string()] + ); + assert_eq!(hits.load(Ordering::SeqCst), 1); + cache.refresh(vec![spec.clone()]).await; + assert_eq!(hits.load(Ordering::SeqCst), 1, "fresh entry must not refetch"); + } +} diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index dee701dd..dc970cdf 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -6347,6 +6347,10 @@ impl SessionManager { &self, session_id: Option<&str>, ) -> Result { + // Bring live model listings up to date before building the menu + // (spec 0209). Fresh cache entries make this free; a cold cache + // costs one bounded fetch round, never a hang. + self.router.refresh_discovered_models().await; let Some(session_id) = session_id else { return Ok(self.router.list_routes("", false, None, false)); }; diff --git a/specs/0209-route-model-lists-are-discovered-live.md b/specs/0209-route-model-lists-are-discovered-live.md new file mode 100644 index 00000000..e7b7e5a7 --- /dev/null +++ b/specs/0209-route-model-lists-are-discovered-live.md @@ -0,0 +1,73 @@ +# 0209-route-model-lists-are-discovered-live + +Status: accepted +Date: 2026-08-22 +Area: architecture +Scope: Route targets whose provider exposes a model-listing endpoint get their picker rows fetched live, additively over the curated catalog. + +## Decision + +For every route target whose wire provider has a known model-listing +endpoint, the daemon fetches the live model list and appends the +discovered ids to that target's picker rows. Four rules hold: + +1. **Additive, never replacing.** The declared model and the curated + catalog keep their order at the front of the list; discovered ids + follow, deduplicated, in the order the endpoint returned them. +2. **Best-effort, never load-bearing.** A failed, slow, empty, or + unsupported listing degrades to exactly the curated behavior. Menus + never gate what can be requested, so discovery failing must be + indistinguishable from discovery not existing. +3. **Bounded and cached.** Listings are fetched on demand (menu opens, + plus a background warm at router start) through a TTL cache keyed by + endpoint, under a fixed wall-clock budget. Opening a menu may be + seconds late once; it never hangs, and repeated opens are free. + Failures are cached on a shorter TTL than successes. +4. **Switchable off.** A single router setting suppresses all listing + requests, for environments where the daemon must not originate them. + +## Reason + +The curated catalog goes stale on human timescales — every vendor model +launch needed a source edit before its id appeared in a picker, while the +id itself already worked when typed. Most routable providers expose a +listing endpoint; using it makes new models (aggregator and stealth ids +especially) appear in menus the day they exist. But listings are remote, +slow, and sometimes wrong, and pickers are interactive — hence additive +merging over a stable curated front, strict time budgets, and failure +behavior identical to the pre-discovery world. + +## Consequences + +- The curated catalog remains authoritative for ordering and for + providers without a listing surface (subscription logins, vendors with + no public endpoint); it must not be removed on the theory that + discovery replaces it. +- OpenAI-compatible listings mix chat models with embeddings/speech/image + ids; a small heuristic denylist hides obviously non-chat ids from + menus. Over-filtering only hides a row (typed ids still work), so the + filter must stay conservative. +- Discovered lists can be long; picker surfaces must tolerate hundreds of + rows for aggregator targets. +- Anything consuming a target's model list (route menus, native-harness + catalogs) inherits discovered ids from the shared list builder; new + consumers should reuse it rather than re-fetch. + +## Non-Goals + +- Discovery does not extend to metadata (context windows, pricing, + capabilities) — ids only. Metadata sync is a separate decision. +- Subscription (OAuth) targets keep their seeded/configured lists; their + backends have no equivalent public listing surface. +- No periodic background polling beyond the start-time warm: refresh is + driven by menu opens so an idle daemon originates no listing traffic. + +## Examples + +- A vendor ships a new model at noon; opening the route menu after the + cache TTL shows it under that vendor's target with no Construct change. +- An aggregator's stealth model appears in the discovered tail of the + aggregator target while the curated front (`auto`, pinned ids) stays in + place. +- Pulling the network cable leaves menus exactly as they were with + curation alone, after at most one bounded wait.