Skip to main content

harness/
model_catalog.rs

1//! Model capabilities sourced exclusively from `models.dev["opencode"]`.
2//!
3//! Hosts initialize the catalog explicitly during startup, resolve a short
4//! model id once, and keep the resulting [`ResolvedModelConfig`] for the
5//! lifetime of the session. There are deliberately no built-in model profiles
6//! or cross-provider merges. A model id absent from the catalog (renamed or
7//! gateway-local ids) resolves against the conservative built-in defaults and
8//! is marked [`LimitsSource::Default`] — callers must surface that marker
9//! instead of treating the limits as verified facts, and capability
10//! assertions (output cap, reasoning/temperature support, deprecation) are
11//! skipped for such models so a conservative guess can never veto a declared
12//! request; the provider stays the final authority.
13
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::Arc;
18use std::time::{Duration, SystemTime};
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use thiserror::Error;
23use tokio::sync::{Mutex, RwLock};
24use tracing::{debug, warn};
25
26const DEFAULT_MODELS_URL: &str = "https://models.dev/api.json";
27const MODELS_URL_ENV: &str = "AGENT_HARNESS_MODELS_URL";
28const CACHE_PATH_ENV: &str = "AGENT_HARNESS_MODELS_CACHE_PATH";
29const PROVIDER: &str = "opencode";
30const CACHE_TTL: Duration = Duration::from_secs(5 * 60);
31const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum WireProtocol {
35    #[serde(rename = "openai_compatible")]
36    OpenAiCompatible,
37    #[serde(rename = "anthropic")]
38    Anthropic,
39    #[serde(rename = "openai_responses")]
40    OpenAiResponses,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum ReasoningMode {
46    #[default]
47    Default,
48    Enabled,
49    Disabled,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct ReasoningConfig {
55    #[serde(default)]
56    pub mode: ReasoningMode,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub effort: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub budget_tokens: Option<u64>,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ModelRequestConfig {
66    pub model: String,
67    pub max_output_tokens: u64,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub temperature: Option<f64>,
70    #[serde(default)]
71    pub reasoning: ReasoningConfig,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ModelLimits {
76    pub context: u64,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub input: Option<u64>,
79    pub output: u64,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum ReasoningOption {
85    Toggle,
86    Effort {
87        values: Vec<String>,
88    },
89    BudgetTokens {
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        min: Option<u64>,
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        max: Option<u64>,
94    },
95}
96
97impl ReasoningOption {
98    /// Lenient decode of one `reasoning_options` entry.
99    ///
100    /// The live models.dev payload carries data-quality defects that must not
101    /// poison the catalog (see the crate-level tolerance contract):
102    ///
103    /// * `budget_tokens.min`/`max` may be negative, which upstream uses to
104    ///   mean "no bound" — decoded as `None`.
105    /// * `effort.values` may contain `null` or non-string entries — dropped.
106    ///
107    /// Anything structurally wrong (missing `type`, non-array `values`, a
108    /// bound that is neither null nor an integer) is an error so the
109    /// *containing model* is skipped by [`parse_snapshot`] instead of failing
110    /// the whole payload.
111    fn from_value(value: &Value) -> Result<Self, String> {
112        let type_ = value
113            .get("type")
114            .and_then(Value::as_str)
115            .ok_or_else(|| "reasoning option is missing `type`".to_owned())?;
116        match type_ {
117            "toggle" => Ok(Self::Toggle),
118            "effort" => {
119                let raw = value
120                    .get("values")
121                    .ok_or_else(|| "effort option is missing `values`".to_owned())?;
122                let entries = raw
123                    .as_array()
124                    .ok_or_else(|| "effort option `values` is not an array".to_owned())?;
125                let values = entries
126                    .iter()
127                    .filter_map(Value::as_str)
128                    .map(str::to_owned)
129                    .collect();
130                Ok(Self::Effort { values })
131            }
132            "budget_tokens" => {
133                let malformed = |value: &Value, key: &str| -> bool {
134                    matches!(
135                        value.get(key),
136                        Some(v) if !v.is_null() && v.as_i64().is_none() && v.as_u64().is_none()
137                    )
138                };
139                if malformed(value, "min") || malformed(value, "max") {
140                    return Err("budget_tokens bounds must be integers".into());
141                }
142                let bound = |value: &Value, key: &str| -> Option<u64> {
143                    match value.get(key) {
144                        None | Some(Value::Null) => None,
145                        Some(v) => match v.as_i64() {
146                            Some(n) if n >= 0 => Some(n as u64),
147                            // Negative = "no bound" in upstream data.
148                            Some(_) => None,
149                            None => v.as_u64(),
150                        },
151                    }
152                };
153                Ok(Self::BudgetTokens {
154                    min: bound(value, "min"),
155                    max: bound(value, "max"),
156                })
157            }
158            other => Err(format!("unknown reasoning option type `{other}`")),
159        }
160    }
161}
162
163impl<'de> Deserialize<'de> for ReasoningOption {
164    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
165    where
166        D: serde::Deserializer<'de>,
167    {
168        let value = Value::deserialize(deserializer)?;
169        Self::from_value(&value).map_err(serde::de::Error::custom)
170    }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct InterleavedReasoning {
175    pub enabled: bool,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub field: Option<String>,
178}
179
180/// Where a model's capabilities came from.
181///
182/// [`LimitsSource::Default`] marks the conservative fallback applied to model
183/// ids that are absent from the catalog. It is an observability signal, not a
184/// quality grade: limits under this marker are a guess and must be surfaced
185/// by the caller (log / status event), never treated as verified model facts.
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum LimitsSource {
189    /// Verified against the `models.dev["opencode"]` catalog.
190    #[default]
191    Catalog,
192    /// Conservative built-in defaults; the model id was not in the catalog.
193    Default,
194}
195
196impl std::fmt::Display for LimitsSource {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            LimitsSource::Catalog => f.write_str("catalog"),
200            LimitsSource::Default => f.write_str("default"),
201        }
202    }
203}
204
205/// Conservative context window for models absent from the catalog. Deliberately
206/// the low end of modern agent-model windows: an under-estimate compacts early
207/// (safe), an over-estimate overflows the real provider window mid-turn (the
208/// one direction that must never be guessed).
209pub const DEFAULT_CONTEXT_LIMIT: u64 = 128_000;
210/// Conservative output limit for models absent from the catalog; matches the
211/// bound RD's bootstrap layer applies to legacy sessions (safely below the
212/// tightest limit in the deployed legacy model set).
213pub const DEFAULT_OUTPUT_LIMIT: u64 = 32_768;
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ModelCapabilities {
217    pub id: String,
218    pub limits: ModelLimits,
219    pub reasoning: bool,
220    pub reasoning_options: Vec<ReasoningOption>,
221    pub temperature: bool,
222    pub tool_call: bool,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub interleaved: Option<InterleavedReasoning>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub status: Option<String>,
227    #[serde(default)]
228    pub limits_source: LimitsSource,
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct ResolvedModelConfig {
233    pub model: String,
234    pub wire_protocol: WireProtocol,
235    pub max_output_tokens: u64,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub temperature: Option<f64>,
238    pub reasoning: ReasoningConfig,
239    pub capabilities: ModelCapabilities,
240}
241
242impl ResolvedModelConfig {
243    /// Maximum input budget after reserving the requested completion budget.
244    pub fn max_input_tokens(&self) -> u64 {
245        self.capabilities.limits.input.unwrap_or_else(|| {
246            self.capabilities
247                .limits
248                .context
249                .saturating_sub(self.max_output_tokens)
250        })
251    }
252}
253
254#[derive(Debug, Error)]
255pub enum ModelCatalogError {
256    #[error("models.dev request failed: {0}")]
257    Request(String),
258    #[error("models.dev response is invalid: {0}")]
259    Decode(String),
260    #[error("models.dev has no `{PROVIDER}` provider")]
261    ProviderMissing,
262    /// Retained for API stability. Since the fail-open default resolution,
263    /// `resolve` no longer produces this error: unknown ids resolve against
264    /// conservative defaults marked [`LimitsSource::Default`] instead.
265    #[error("model `{model}` is not present in models.dev[{PROVIDER}]{suggestions}")]
266    ModelNotFound { model: String, suggestions: String },
267    #[error("model id `{model}` is ambiguous in models.dev[{PROVIDER}]: {matches:?}")]
268    AmbiguousModel { model: String, matches: Vec<String> },
269    #[error("model `{model}` is deprecated")]
270    DeprecatedModel { model: String },
271    #[error("invalid model request for `{model}`: {message}")]
272    InvalidRequest { model: String, message: String },
273}
274
275// The root payload is parsed as a `Value` in [`parse_snapshot`] and only the
276// `{PROVIDER}` provider's `models` object is consumed, per-model.
277
278#[derive(Debug, Clone, Deserialize)]
279struct ModelEntry {
280    #[serde(default)]
281    id: Option<String>,
282    limit: ModelLimits,
283    #[serde(default)]
284    reasoning: bool,
285    #[serde(default)]
286    reasoning_options: Vec<ReasoningOption>,
287    #[serde(default)]
288    temperature: bool,
289    #[serde(default)]
290    tool_call: bool,
291    #[serde(default)]
292    interleaved: Option<Value>,
293    #[serde(default)]
294    status: Option<String>,
295}
296
297impl ModelEntry {
298    fn into_capabilities(self, map_id: String) -> ModelCapabilities {
299        let interleaved = match self.interleaved {
300            Some(Value::Bool(enabled)) => Some(InterleavedReasoning {
301                enabled,
302                field: None,
303            }),
304            Some(Value::Object(object)) => Some(InterleavedReasoning {
305                enabled: true,
306                field: object
307                    .get("field")
308                    .and_then(Value::as_str)
309                    .map(str::to_owned),
310            }),
311            _ => None,
312        };
313        ModelCapabilities {
314            id: self.id.unwrap_or(map_id),
315            limits: self.limit,
316            reasoning: self.reasoning,
317            reasoning_options: self.reasoning_options,
318            temperature: self.temperature,
319            tool_call: self.tool_call,
320            interleaved,
321            status: self.status,
322            limits_source: LimitsSource::Catalog,
323        }
324    }
325}
326
327/// Conservative capabilities for a model id that is absent from the catalog.
328/// The id is kept exactly as requested (only trimmed) — it is echoed back to
329/// the provider as the wire model name, and gateway-local ids may be
330/// case-sensitive, unlike the lower-cased catalog keys.
331fn default_capabilities(id: String) -> ModelCapabilities {
332    ModelCapabilities {
333        id,
334        limits: ModelLimits {
335            context: DEFAULT_CONTEXT_LIMIT,
336            input: None,
337            output: DEFAULT_OUTPUT_LIMIT,
338        },
339        reasoning: false,
340        reasoning_options: Vec::new(),
341        temperature: true,
342        tool_call: true,
343        interleaved: None,
344        status: None,
345        limits_source: LimitsSource::Default,
346    }
347}
348
349/// Decoded, lower-cased model ids of the `{PROVIDER}` provider. Built by
350/// [`parse_snapshot`], which parses model entries individually: a single
351/// malformed entry is skipped (with a warn) instead of failing the whole
352/// payload, so one dirty model in any provider can never take the catalog
353/// down.
354#[derive(Debug, Clone)]
355struct CatalogSnapshot {
356    models: HashMap<String, ModelCapabilities>,
357}
358
359/// Shared, refreshable view of the fixed `models.dev["opencode"]` catalog.
360#[derive(Debug, Clone)]
361pub struct ModelCatalog {
362    snapshot: Arc<RwLock<CatalogSnapshot>>,
363    refresh_lock: Arc<Mutex<()>>,
364    refresh_generation: Arc<AtomicU64>,
365    cache_path: Option<PathBuf>,
366    models_url: String,
367}
368
369impl ModelCatalog {
370    /// Load the disk cache and refresh it when needed. A fresh cache returns
371    /// immediately. A stale cache remains usable while one background refresh
372    /// runs; with no usable cache, the network fetch is awaited and required.
373    pub async fn initialize() -> Result<Self, ModelCatalogError> {
374        let cache_path = cache_path();
375        let cached = cache_path
376            .as_deref()
377            .and_then(read_cache)
378            .and_then(|bytes| parse_snapshot(&bytes).ok());
379        let cache_fresh = cache_path.as_deref().map(cache_is_fresh).unwrap_or(false);
380        let catalog = Self {
381            snapshot: Arc::new(RwLock::new(cached.as_ref().cloned().unwrap_or_else(|| {
382                CatalogSnapshot {
383                    models: HashMap::new(),
384                }
385            }))),
386            refresh_lock: Arc::new(Mutex::new(())),
387            refresh_generation: Arc::new(AtomicU64::new(0)),
388            cache_path,
389            models_url: std::env::var(MODELS_URL_ENV)
390                .ok()
391                .filter(|value| !value.trim().is_empty())
392                .unwrap_or_else(|| DEFAULT_MODELS_URL.to_owned()),
393        };
394
395        if cached.is_some() && cache_fresh {
396            return Ok(catalog);
397        }
398        if cached.is_some() {
399            let refresh_catalog = catalog.clone();
400            tokio::spawn(async move {
401                if let Err(error) = refresh_catalog.refresh().await {
402                    warn!(%error, "models.dev background refresh failed; retaining stale cache");
403                }
404            });
405            return Ok(catalog);
406        }
407
408        catalog.refresh().await?;
409        Ok(catalog)
410    }
411
412    /// Force a refresh. Concurrent callers share a single in-flight fetch.
413    pub async fn refresh(&self) -> Result<(), ModelCatalogError> {
414        let observed_generation = self.refresh_generation.load(Ordering::Acquire);
415        let _guard = self.refresh_lock.lock().await;
416        if self.refresh_generation.load(Ordering::Acquire) != observed_generation {
417            return Ok(());
418        }
419        let bytes = fetch_catalog(&self.models_url).await?;
420        let snapshot = parse_snapshot(&bytes)?;
421        if let Some(path) = &self.cache_path {
422            if let Err(error) = write_cache_atomic(path, &bytes).await {
423                warn!(%error, ?path, "failed to write models.dev disk cache");
424            }
425        }
426        let count = snapshot.models.len();
427        *self.snapshot.write().await = snapshot;
428        self.refresh_generation.fetch_add(1, Ordering::Release);
429        debug!(count, provider = PROVIDER, "models.dev catalog refreshed");
430        Ok(())
431    }
432
433    /// Resolve and validate a short model id for one of the three wire
434    /// protocols. Exact id match wins; a path-like id may also match by a
435    /// unique basename. Contains/fuzzy matches are suggestions only. An id
436    /// with no match at all does not error: it resolves against
437    /// [`default_capabilities`]-style conservative limits marked
438    /// [`LimitsSource::Default`] (see the module docs for the validation
439    /// split that follows from that).
440    pub async fn resolve(
441        &self,
442        request: ModelRequestConfig,
443        wire_protocol: WireProtocol,
444    ) -> Result<ResolvedModelConfig, ModelCatalogError> {
445        let capabilities = self.capabilities(&request.model).await?;
446        validate_request(&request, &capabilities, wire_protocol)?;
447        Ok(ResolvedModelConfig {
448            model: capabilities.id.clone(),
449            wire_protocol,
450            max_output_tokens: request.max_output_tokens,
451            temperature: request.temperature,
452            reasoning: normalize_reasoning(request.reasoning, &capabilities),
453            capabilities,
454        })
455    }
456
457    /// Look up one model's capabilities without validating a request. Lets
458    /// callers clamp `max_output_tokens` or drop unsupported temperature /
459    /// reasoning knobs *before* [`Self::resolve`], instead of recovering
460    /// from `InvalidRequest` errors after the fact.
461    pub async fn capabilities(&self, model: &str) -> Result<ModelCapabilities, ModelCatalogError> {
462        let snapshot = self.snapshot.read().await;
463        let original = model.trim();
464        let requested = original.to_ascii_lowercase();
465        match snapshot.models.get(&requested) {
466            Some(model) => Ok(model.clone()),
467            None => match resolve_unique_basename(&snapshot.models, &requested) {
468                Ok(model) => Ok(model),
469                Err(ModelCatalogError::ModelNotFound { suggestions, .. }) => {
470                    warn!(
471                        model = %original,
472                        suggestions = %suggestions,
473                        context_limit = DEFAULT_CONTEXT_LIMIT,
474                        output_limit = DEFAULT_OUTPUT_LIMIT,
475                        "model absent from models.dev[{}]; resolving with conservative defaults (limits_source=default)",
476                        PROVIDER
477                    );
478                    Ok(default_capabilities(original.to_owned()))
479                }
480                Err(error) => Err(error),
481            },
482        }
483    }
484
485    /// Build a catalog from a models.dev-shaped JSON payload — no network
486    /// fetch, no disk cache. For embedding a fixed catalog: tests,
487    /// air-gapped deployments, and custom gateways whose model ids are
488    /// absent from models.dev["opencode"].
489    pub fn from_json(bytes: &[u8]) -> Result<Self, ModelCatalogError> {
490        Ok(Self {
491            snapshot: Arc::new(RwLock::new(parse_snapshot(bytes)?)),
492            refresh_lock: Arc::new(Mutex::new(())),
493            refresh_generation: Arc::new(AtomicU64::new(0)),
494            cache_path: None,
495            models_url: String::new(),
496        })
497    }
498
499    pub async fn model_count(&self) -> usize {
500        self.snapshot.read().await.models.len()
501    }
502}
503
504fn parse_snapshot(bytes: &[u8]) -> Result<CatalogSnapshot, ModelCatalogError> {
505    let root: Value = serde_json::from_slice(bytes)
506        .map_err(|error| ModelCatalogError::Decode(error.to_string()))?;
507    let root = root
508        .as_object()
509        .ok_or_else(|| ModelCatalogError::Decode("payload root is not a JSON object".into()))?;
510    let provider = root
511        .get(PROVIDER)
512        .ok_or(ModelCatalogError::ProviderMissing)?;
513    let models = provider
514        .get("models")
515        .and_then(Value::as_object)
516        .ok_or_else(|| {
517            ModelCatalogError::Decode(format!(
518                "{PROVIDER} provider is missing a usable `models` object"
519            ))
520        })?;
521    let mut parsed: HashMap<String, ModelCapabilities> = HashMap::new();
522    let mut skipped: Vec<String> = Vec::new();
523    for (id, entry) in models {
524        match serde_json::from_value::<ModelEntry>(entry.clone()) {
525            Ok(entry) => {
526                parsed.insert(id.to_ascii_lowercase(), entry.into_capabilities(id.clone()));
527            }
528            Err(_) => skipped.push(id.clone()),
529        }
530    }
531    if parsed.is_empty() && !models.is_empty() {
532        return Err(ModelCatalogError::Decode(format!(
533            "no model in {PROVIDER} could be decoded ({} unusable entries)",
534            skipped.len()
535        )));
536    }
537    if !skipped.is_empty() {
538        let preview: Vec<String> = skipped.iter().take(5).cloned().collect();
539        warn!(
540            skipped = %skipped.len(),
541            first_skipped = %preview.join(", "),
542            "models.dev entries with unusable data were skipped"
543        );
544    }
545    Ok(CatalogSnapshot { models: parsed })
546}
547
548fn resolve_unique_basename(
549    models: &HashMap<String, ModelCapabilities>,
550    requested: &str,
551) -> Result<ModelCapabilities, ModelCatalogError> {
552    let basename = requested.rsplit('/').next().unwrap_or(requested);
553    let mut matches = models
554        .iter()
555        .filter(|(id, _)| id.rsplit('/').next() == Some(basename))
556        .map(|(_, model)| model.clone())
557        .collect::<Vec<_>>();
558    match matches.len() {
559        1 => Ok(matches.remove(0)),
560        count if count > 1 => Err(ModelCatalogError::AmbiguousModel {
561            model: requested.to_owned(),
562            matches: matches.into_iter().map(|model| model.id).collect(),
563        }),
564        _ => {
565            let mut suggestions = models
566                .keys()
567                .filter(|id| id.contains(requested) || requested.contains(id.as_str()))
568                .take(5)
569                .cloned()
570                .collect::<Vec<_>>();
571            suggestions.sort();
572            let suggestions = if suggestions.is_empty() {
573                String::new()
574            } else {
575                format!("; did you mean {}?", suggestions.join(", "))
576            };
577            Err(ModelCatalogError::ModelNotFound {
578                model: requested.to_owned(),
579                suggestions,
580            })
581        }
582    }
583}
584
585fn validate_request(
586    request: &ModelRequestConfig,
587    capabilities: &ModelCapabilities,
588    wire_protocol: WireProtocol,
589) -> Result<(), ModelCatalogError> {
590    let invalid = |message: String| ModelCatalogError::InvalidRequest {
591        model: capabilities.id.clone(),
592        message,
593    };
594    // Structural invariants — enforced for every capability source, including
595    // [`LimitsSource::Default`]: these describe well-formed requests, not
596    // model capabilities.
597    if request.max_output_tokens == 0 {
598        return Err(invalid(
599            "max_output_tokens must be greater than zero".into(),
600        ));
601    }
602    if request.reasoning.effort.is_some() && request.reasoning.budget_tokens.is_some() {
603        return Err(invalid(
604            "reasoning.effort and reasoning.budget_tokens are mutually exclusive".into(),
605        ));
606    }
607    if matches!(request.reasoning.mode, ReasoningMode::Default)
608        && (request.reasoning.effort.is_some() || request.reasoning.budget_tokens.is_some())
609    {
610        return Err(invalid(
611            "reasoning.mode must be enabled when effort or budget_tokens is set".into(),
612        ));
613    }
614    if matches!(request.reasoning.mode, ReasoningMode::Disabled) {
615        if request.reasoning.budget_tokens.is_some() {
616            return Err(invalid(
617                "reasoning.budget_tokens cannot be set when reasoning is disabled".into(),
618            ));
619        }
620        if request
621            .reasoning
622            .effort
623            .as_deref()
624            .is_some_and(|effort| !effort.eq_ignore_ascii_case("none"))
625        {
626            return Err(invalid(
627                "reasoning.effort must be `none` when reasoning is disabled".into(),
628            ));
629        }
630    }
631    if matches!(request.reasoning.mode, ReasoningMode::Enabled)
632        && request
633            .reasoning
634            .effort
635            .as_deref()
636            .is_some_and(|effort| effort.eq_ignore_ascii_case("none"))
637    {
638        return Err(invalid(
639            "reasoning.effort `none` conflicts with reasoning.mode `enabled`".into(),
640        ));
641    }
642
643    // Wire-protocol constraints — enforced for every capability source: they
644    // describe the protocol, not the model.
645    if matches!(wire_protocol, WireProtocol::OpenAiResponses)
646        && request.reasoning.budget_tokens.is_some()
647    {
648        return Err(invalid(
649            "openai_responses cannot express reasoning budget_tokens".into(),
650        ));
651    }
652
653    // Capability assertions — only when the catalog actually knows this model.
654    // Under [`LimitsSource::Default`] the capabilities are a conservative
655    // guess; asserting against them would let the guess veto the session
656    // manager's declaration (e.g. a real 65K output cap rejected by our 32K
657    // guess). The provider is the final authority for such models: an
658    // over-declared limit surfaces as an explicit provider error at turn time.
659    if capabilities.limits_source != LimitsSource::Catalog {
660        return Ok(());
661    }
662
663    if capabilities.status.as_deref() == Some("deprecated") {
664        return Err(ModelCatalogError::DeprecatedModel {
665            model: capabilities.id.clone(),
666        });
667    }
668    if request.max_output_tokens > capabilities.limits.output {
669        return Err(invalid(format!(
670            "max_output_tokens {} exceeds models.dev output limit {}",
671            request.max_output_tokens, capabilities.limits.output
672        )));
673    }
674    if request.temperature.is_some() && !capabilities.temperature {
675        return Err(invalid("temperature is not supported".into()));
676    }
677    if !capabilities.reasoning && !matches!(request.reasoning.mode, ReasoningMode::Default) {
678        return Err(invalid("reasoning is not supported".into()));
679    }
680
681    let effort_values = capabilities
682        .reasoning_options
683        .iter()
684        .find_map(|option| match option {
685            ReasoningOption::Effort { values } => Some(values),
686            _ => None,
687        });
688    let toggle = capabilities
689        .reasoning_options
690        .iter()
691        .any(|option| matches!(option, ReasoningOption::Toggle));
692    let budget = capabilities
693        .reasoning_options
694        .iter()
695        .find_map(|option| match option {
696            ReasoningOption::BudgetTokens { min, max } => Some((*min, *max)),
697            _ => None,
698        });
699
700    if let Some(effort) = request.reasoning.effort.as_deref() {
701        let values =
702            effort_values.ok_or_else(|| invalid("reasoning effort is not supported".into()))?;
703        if !values
704            .iter()
705            .any(|value| value.eq_ignore_ascii_case(effort))
706        {
707            return Err(invalid(format!(
708                "reasoning effort `{effort}` is unsupported; allowed values: {}",
709                values.join(", ")
710            )));
711        }
712    }
713    if let Some(tokens) = request.reasoning.budget_tokens {
714        let (min, max) =
715            budget.ok_or_else(|| invalid("reasoning budget_tokens is not supported".into()))?;
716        if min.is_some_and(|minimum| tokens < minimum)
717            || max.is_some_and(|maximum| tokens > maximum)
718        {
719            return Err(invalid(format!(
720                "reasoning budget_tokens {tokens} is outside models.dev range {}..{}",
721                min.map(|value| value.to_string())
722                    .unwrap_or_else(|| "0".into()),
723                max.map(|value| value.to_string())
724                    .unwrap_or_else(|| "unbounded".into())
725            )));
726        }
727    }
728
729    match request.reasoning.mode {
730        ReasoningMode::Default => {}
731        ReasoningMode::Enabled
732            if request.reasoning.effort.is_none()
733                && request.reasoning.budget_tokens.is_none()
734                && !toggle =>
735        {
736            return Err(invalid(
737                "reasoning cannot be explicitly enabled without an effort or toggle capability"
738                    .into(),
739            ));
740        }
741        ReasoningMode::Disabled if !toggle && !supports_none(effort_values) => {
742            return Err(invalid("reasoning cannot be explicitly disabled".into()));
743        }
744        _ => {}
745    }
746
747    if matches!(wire_protocol, WireProtocol::OpenAiResponses)
748        && !matches!(request.reasoning.mode, ReasoningMode::Default)
749        && request.reasoning.effort.is_none()
750        && !supports_none(effort_values)
751    {
752        return Err(invalid(
753            "openai_responses requires an effort-based reasoning control".into(),
754        ));
755    }
756    Ok(())
757}
758
759fn supports_none(values: Option<&Vec<String>>) -> bool {
760    values.is_some_and(|values| {
761        values
762            .iter()
763            .any(|value| value.eq_ignore_ascii_case("none"))
764    })
765}
766
767fn normalize_reasoning(
768    mut reasoning: ReasoningConfig,
769    capabilities: &ModelCapabilities,
770) -> ReasoningConfig {
771    if matches!(reasoning.mode, ReasoningMode::Disabled) && reasoning.effort.is_none() {
772        let supports_none = capabilities
773            .reasoning_options
774            .iter()
775            .any(|option| match option {
776                ReasoningOption::Effort { values } => values.iter().any(|value| value == "none"),
777                _ => false,
778            });
779        if supports_none {
780            reasoning.effort = Some("none".into());
781        }
782    }
783    reasoning
784}
785
786async fn fetch_catalog(url: &str) -> Result<Vec<u8>, ModelCatalogError> {
787    let client = reqwest::Client::builder()
788        .timeout(FETCH_TIMEOUT)
789        .build()
790        .map_err(|error| ModelCatalogError::Request(error.to_string()))?;
791    let response = client
792        .get(url)
793        .send()
794        .await
795        .map_err(|error| ModelCatalogError::Request(error.to_string()))?
796        .error_for_status()
797        .map_err(|error| ModelCatalogError::Request(error.to_string()))?;
798    response
799        .bytes()
800        .await
801        .map(|bytes| bytes.to_vec())
802        .map_err(|error| ModelCatalogError::Request(error.to_string()))
803}
804
805fn cache_path() -> Option<PathBuf> {
806    if let Ok(path) = std::env::var(CACHE_PATH_ENV) {
807        if !path.trim().is_empty() {
808            return Some(PathBuf::from(path));
809        }
810    }
811    dirs::cache_dir().map(|base| base.join("agent-harness-rs").join("models.dev.json"))
812}
813
814fn read_cache(path: &Path) -> Option<Vec<u8>> {
815    std::fs::read(path).ok()
816}
817
818fn cache_is_fresh(path: &Path) -> bool {
819    std::fs::metadata(path)
820        .and_then(|metadata| metadata.modified())
821        .ok()
822        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
823        .is_some_and(|age| age < CACHE_TTL)
824}
825
826async fn write_cache_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
827    if let Some(parent) = path.parent() {
828        tokio::fs::create_dir_all(parent).await?;
829    }
830    let suffix = SystemTime::now()
831        .duration_since(SystemTime::UNIX_EPOCH)
832        .map(|duration| duration.as_nanos())
833        .unwrap_or(0);
834    let temporary = path.with_extension(format!("tmp.{}.{}", std::process::id(), suffix));
835    tokio::fs::write(&temporary, bytes).await?;
836    tokio::fs::rename(temporary, path).await
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    #[test]
844    fn wire_protocol_uses_stable_external_names() {
845        for (protocol, external) in [
846            (WireProtocol::OpenAiCompatible, "openai_compatible"),
847            (WireProtocol::Anthropic, "anthropic"),
848            (WireProtocol::OpenAiResponses, "openai_responses"),
849        ] {
850            assert_eq!(
851                serde_json::to_string(&protocol).unwrap(),
852                format!("\"{external}\"")
853            );
854            assert_eq!(
855                serde_json::from_str::<WireProtocol>(&format!("\"{external}\"")).unwrap(),
856                protocol
857            );
858        }
859    }
860
861    fn catalog(json: &str) -> ModelCatalog {
862        ModelCatalog::from_json(json.as_bytes()).unwrap()
863    }
864
865    const FIXTURE: &str = r#"{
866      "opencode": {"models": {
867        "deepseek-v4-pro": {
868          "id":"deepseek-v4-pro", "reasoning":true, "temperature":true,
869          "tool_call":true, "interleaved":{"field":"reasoning_content"},
870          "reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],
871          "limit":{"context":1000000,"output":384000}
872        },
873        "gpt-5.5": {
874          "reasoning":true, "temperature":false, "tool_call":true,
875          "reasoning_options":[{"type":"effort","values":["none","low","high"]}],
876          "limit":{"context":1050000,"input":922000,"output":128000}
877        },
878        "old-model": {
879          "status":"deprecated", "limit":{"context":1000,"output":100}
880        }
881      }}
882    }"#;
883
884    fn request(model: &str) -> ModelRequestConfig {
885        ModelRequestConfig {
886            model: model.into(),
887            max_output_tokens: 65_536,
888            temperature: None,
889            reasoning: ReasoningConfig::default(),
890        }
891    }
892
893    #[tokio::test]
894    async fn exact_short_id_resolves_and_preserves_limits() {
895        let resolved = catalog(FIXTURE)
896            .resolve(request("deepseek-v4-pro"), WireProtocol::OpenAiCompatible)
897            .await
898            .unwrap();
899        assert_eq!(resolved.model, "deepseek-v4-pro");
900        assert_eq!(resolved.capabilities.limits.context, 1_000_000);
901        assert_eq!(resolved.max_input_tokens(), 934_464);
902        assert_eq!(
903            resolved.capabilities.interleaved.unwrap().field.as_deref(),
904            Some("reasoning_content")
905        );
906    }
907
908    #[tokio::test]
909    async fn unique_path_basename_resolves_but_contains_does_not() {
910        let resolved = catalog(FIXTURE)
911            .resolve(
912                request("vendor/deepseek-v4-pro"),
913                WireProtocol::OpenAiCompatible,
914            )
915            .await
916            .unwrap();
917        assert_eq!(resolved.model, "deepseek-v4-pro");
918        // A contains-only match is NOT a catalog resolution: it falls through
919        // to the conservative defaults, clearly marked, instead of resolving
920        // to the wrong model or failing the session.
921        let resolved = catalog(FIXTURE)
922            .resolve(request("deepseek-v4"), WireProtocol::OpenAiCompatible)
923            .await
924            .unwrap();
925        assert_eq!(resolved.model, "deepseek-v4");
926        assert_eq!(resolved.capabilities.limits_source, LimitsSource::Default);
927    }
928
929    #[tokio::test]
930    async fn unknown_model_resolves_with_conservative_defaults() {
931        let resolved = catalog(FIXTURE)
932            .resolve(request("glm-5.1-rdclaw"), WireProtocol::OpenAiCompatible)
933            .await
934            .unwrap();
935        // The id is echoed back trimmed but NOT lower-cased: it is the wire
936        // model name sent to the provider, and gateway-local ids may be
937        // case-sensitive.
938        assert_eq!(resolved.model, "glm-5.1-rdclaw");
939        assert_eq!(resolved.capabilities.limits_source, LimitsSource::Default);
940        assert_eq!(resolved.capabilities.limits.context, DEFAULT_CONTEXT_LIMIT);
941        assert_eq!(resolved.capabilities.limits.output, DEFAULT_OUTPUT_LIMIT);
942        assert_eq!(resolved.capabilities.limits.input, None);
943        assert!(!resolved.capabilities.reasoning);
944        assert!(resolved.capabilities.temperature);
945        assert!(resolved.capabilities.tool_call);
946        assert_eq!(resolved.capabilities.status, None);
947    }
948
949    #[tokio::test]
950    async fn unknown_model_keeps_original_case() {
951        let resolved = catalog(FIXTURE)
952            .resolve(request("My-Glm-5.1"), WireProtocol::OpenAiCompatible)
953            .await
954            .unwrap();
955        assert_eq!(resolved.model, "My-Glm-5.1");
956        assert_eq!(resolved.capabilities.limits_source, LimitsSource::Default);
957    }
958
959    #[tokio::test]
960    async fn default_source_never_vetoes_a_declared_request() {
961        // SM-declared 65K output must not be rejected by our 32K guess.
962        let resolved = catalog(FIXTURE)
963            .resolve(request("glm-5.1-rdclaw"), WireProtocol::OpenAiCompatible)
964            .await
965            .unwrap();
966        assert_eq!(resolved.max_output_tokens, 65_536);
967
968        // Explicitly enabled reasoning passes; the provider decides.
969        let mut req = request("glm-5.1-rdclaw");
970        req.reasoning = ReasoningConfig {
971            mode: ReasoningMode::Enabled,
972            effort: Some("high".into()),
973            budget_tokens: None,
974        };
975        catalog(FIXTURE)
976            .resolve(req, WireProtocol::OpenAiCompatible)
977            .await
978            .unwrap();
979    }
980
981    #[tokio::test]
982    async fn default_source_still_enforces_structural_invariants() {
983        let mut req = request("glm-5.1-rdclaw");
984        req.max_output_tokens = 0;
985        assert!(matches!(
986            catalog(FIXTURE)
987                .resolve(req, WireProtocol::OpenAiCompatible)
988                .await
989                .unwrap_err(),
990            ModelCatalogError::InvalidRequest { .. }
991        ));
992
993        let mut req = request("glm-5.1-rdclaw");
994        req.reasoning = ReasoningConfig {
995            mode: ReasoningMode::Enabled,
996            effort: Some("high".into()),
997            budget_tokens: Some(1_000),
998        };
999        assert!(matches!(
1000            catalog(FIXTURE)
1001                .resolve(req, WireProtocol::OpenAiCompatible)
1002                .await
1003                .unwrap_err(),
1004            ModelCatalogError::InvalidRequest { .. }
1005        ));
1006
1007        // Wire-protocol constraints still apply to unknown models too.
1008        let mut req = request("glm-5.1-rdclaw");
1009        req.reasoning = ReasoningConfig {
1010            mode: ReasoningMode::Enabled,
1011            effort: None,
1012            budget_tokens: Some(1_000),
1013        };
1014        assert!(matches!(
1015            catalog(FIXTURE)
1016                .resolve(req, WireProtocol::OpenAiResponses)
1017                .await
1018                .unwrap_err(),
1019            ModelCatalogError::InvalidRequest { .. }
1020        ));
1021    }
1022
1023    #[tokio::test]
1024    async fn catalog_hit_still_marks_catalog_source() {
1025        let resolved = catalog(FIXTURE)
1026            .resolve(request("deepseek-v4-pro"), WireProtocol::OpenAiCompatible)
1027            .await
1028            .unwrap();
1029        assert_eq!(resolved.capabilities.limits_source, LimitsSource::Catalog);
1030        // And catalog-source requests are still capped by the real limit.
1031        let mut req = request("gpt-5.5");
1032        req.max_output_tokens = u64::MAX;
1033        assert!(matches!(
1034            catalog(FIXTURE)
1035                .resolve(req, WireProtocol::OpenAiCompatible)
1036                .await
1037                .unwrap_err(),
1038            ModelCatalogError::InvalidRequest { .. }
1039        ));
1040    }
1041
1042    #[tokio::test]
1043    async fn capabilities_looks_up_without_request_validation() {
1044        let source = catalog(FIXTURE);
1045        let caps = source.capabilities("gpt-5.5").await.unwrap();
1046        assert_eq!(caps.limits.output, 128_000);
1047        assert!(!caps.temperature);
1048        // Deprecated models still resolve at the lookup layer — deprecation
1049        // is a request-validation concern, not a lookup one.
1050        let caps = source.capabilities("old-model").await.unwrap();
1051        assert_eq!(caps.status.as_deref(), Some("deprecated"));
1052        // Unknown ids no longer error at the lookup layer: they resolve to
1053        // conservative defaults, marked as such.
1054        let caps = source.capabilities("missing-model").await.unwrap();
1055        assert_eq!(caps.limits_source, LimitsSource::Default);
1056        assert_eq!(caps.id, "missing-model");
1057    }
1058
1059    #[test]
1060    fn from_json_rejects_payloads_without_the_trusted_provider() {
1061        let error = ModelCatalog::from_json(br#"{"other": {"models": {}}}"#).unwrap_err();
1062        assert!(matches!(error, ModelCatalogError::ProviderMissing));
1063        let error = ModelCatalog::from_json(b"garbage").unwrap_err();
1064        assert!(matches!(error, ModelCatalogError::Decode(_)));
1065    }
1066
1067    #[tokio::test]
1068    async fn validates_limits_temperature_and_deprecation() {
1069        let source = catalog(FIXTURE);
1070        let mut too_large = request("gpt-5.5");
1071        too_large.max_output_tokens = 128_001;
1072        assert!(matches!(
1073            source
1074                .resolve(too_large, WireProtocol::OpenAiResponses)
1075                .await,
1076            Err(ModelCatalogError::InvalidRequest { .. })
1077        ));
1078        let mut temperature = request("gpt-5.5");
1079        temperature.temperature = Some(0.2);
1080        assert!(matches!(
1081            source
1082                .resolve(temperature, WireProtocol::OpenAiResponses)
1083                .await,
1084            Err(ModelCatalogError::InvalidRequest { .. })
1085        ));
1086        let mut old = request("old-model");
1087        old.max_output_tokens = 10;
1088        assert!(matches!(
1089            source.resolve(old, WireProtocol::OpenAiCompatible).await,
1090            Err(ModelCatalogError::DeprecatedModel { .. })
1091        ));
1092    }
1093
1094    #[tokio::test]
1095    async fn validates_and_normalizes_reasoning_controls() {
1096        let source = catalog(FIXTURE);
1097        let mut deepseek = request("deepseek-v4-pro");
1098        deepseek.reasoning = ReasoningConfig {
1099            mode: ReasoningMode::Enabled,
1100            effort: Some("high".into()),
1101            budget_tokens: None,
1102        };
1103        let resolved = source
1104            .resolve(deepseek, WireProtocol::OpenAiCompatible)
1105            .await
1106            .unwrap();
1107        assert_eq!(resolved.reasoning.effort.as_deref(), Some("high"));
1108
1109        let mut disabled = request("gpt-5.5");
1110        disabled.reasoning.mode = ReasoningMode::Disabled;
1111        let resolved = source
1112            .resolve(disabled, WireProtocol::OpenAiResponses)
1113            .await
1114            .unwrap();
1115        assert_eq!(resolved.reasoning.effort.as_deref(), Some("none"));
1116
1117        let mut invalid = request("deepseek-v4-pro");
1118        invalid.reasoning = ReasoningConfig {
1119            mode: ReasoningMode::Enabled,
1120            effort: Some("medium".into()),
1121            budget_tokens: None,
1122        };
1123        assert!(matches!(
1124            source
1125                .resolve(invalid, WireProtocol::OpenAiCompatible)
1126                .await,
1127            Err(ModelCatalogError::InvalidRequest { .. })
1128        ));
1129
1130        let mut contradictory = request("gpt-5.5");
1131        contradictory.reasoning = ReasoningConfig {
1132            mode: ReasoningMode::Disabled,
1133            effort: Some("high".into()),
1134            budget_tokens: None,
1135        };
1136        assert!(matches!(
1137            source
1138                .resolve(contradictory, WireProtocol::OpenAiResponses)
1139                .await,
1140            Err(ModelCatalogError::InvalidRequest { .. })
1141        ));
1142    }
1143
1144    // ------------------------------------------------------------------
1145    // Lenient-decode contract: live models.dev data-quality defects must not
1146    // poison the catalog. See the crate-level tolerance contract and
1147    // `parse_snapshot` / `ReasoningOption::from_value`.
1148    // ------------------------------------------------------------------
1149
1150    #[tokio::test]
1151    async fn live_payload_negative_budget_min_is_tolerated() {
1152        // Reproduces the real `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning`
1153        // defect: `budget_tokens.min = -1` must decode as "no bound".
1154        let json = r#"{
1155          "opencode": {"models": {
1156            "nemotron": {
1157              "reasoning": true,
1158              "reasoning_options": [
1159                {"type":"budget_tokens","min":-1,"max":32768}
1160              ],
1161              "limit": {"context": 200000, "output": 32768}
1162            }
1163          }}
1164        }"#;
1165        let source = catalog(json);
1166        let mut req = request("nemotron");
1167        req.max_output_tokens = 1024;
1168        let resolved = source
1169            .resolve(req, WireProtocol::OpenAiCompatible)
1170            .await
1171            .unwrap();
1172        let option = resolved.capabilities.reasoning_options.first().unwrap();
1173        match option {
1174            ReasoningOption::BudgetTokens { min, max } => {
1175                assert_eq!(*min, None);
1176                assert_eq!(*max, Some(32_768));
1177            }
1178            other => panic!("expected budget_tokens, got {other:?}"),
1179        }
1180    }
1181
1182    #[tokio::test]
1183    async fn live_payload_null_effort_values_are_filtered() {
1184        // Reproduces the real `sarvam-105b` / `sarvam-30b` defect:
1185        // `effort.values = [null, "low", "medium"]` must drop the null.
1186        let json = r#"{
1187          "opencode": {"models": {
1188            "sarvam-105b": {
1189              "reasoning": true,
1190              "reasoning_options": [
1191                {"type":"effort","values":[null,"low","medium",123]}
1192              ],
1193              "limit": {"context": 4096, "output": 1024}
1194            }
1195          }}
1196        }"#;
1197        let source = catalog(json);
1198        let mut req = request("sarvam-105b");
1199        req.max_output_tokens = 1024;
1200        let resolved = source
1201            .resolve(req, WireProtocol::OpenAiCompatible)
1202            .await
1203            .unwrap();
1204        let option = resolved.capabilities.reasoning_options.first().unwrap();
1205        assert_eq!(
1206            option,
1207            &ReasoningOption::Effort {
1208                values: vec!["low".into(), "medium".into()]
1209            }
1210        );
1211    }
1212
1213    #[tokio::test]
1214    async fn one_unusable_entry_is_skipped_not_fatal() {
1215        // A single structurally broken model (negative `context`, which is
1216        // genuinely unusable) must be skipped while its siblings survive.
1217        let json = r#"{
1218          "opencode": {"models": {
1219            "good-a": {
1220              "limit": {"context": 1000, "output": 100},
1221              "reasoning_options": []
1222            },
1223            "broken": {
1224              "limit": {"context": -1, "output": 100}
1225            },
1226            "good-b": {
1227              "limit": {"context": 2000, "output": 200}
1228            }
1229          }}
1230        }"#;
1231        let source = catalog(json);
1232        // Both good models remain resolvable...
1233        for name in ["good-a", "good-b"] {
1234            let mut req = request(name);
1235            req.max_output_tokens = 100;
1236            source
1237                .resolve(req, WireProtocol::OpenAiCompatible)
1238                .await
1239                .unwrap();
1240        }
1241        // ...and the broken one is a clean per-model miss that resolves to
1242        // the conservative defaults, not a catalog failure.
1243        let mut broken = request("broken");
1244        broken.max_output_tokens = 100;
1245        let resolved = source
1246            .resolve(broken, WireProtocol::OpenAiCompatible)
1247            .await
1248            .unwrap();
1249        assert_eq!(resolved.capabilities.limits_source, LimitsSource::Default);
1250    }
1251
1252    #[test]
1253    fn opencode_without_models_object_is_a_data_failure() {
1254        // `opencode` present but structurally unusable: this IS a data failure
1255        // and must fail closed (empty catalog would hide the defect).
1256        let json = r#"{"opencode": {"name": "opencode"}, "other": {"models": {"x":{"limit":{"context":1,"output":1}}}}}"#;
1257        assert!(matches!(
1258            ModelCatalog::from_json(json.as_bytes()),
1259            Err(ModelCatalogError::Decode(_))
1260        ));
1261    }
1262
1263    #[test]
1264    fn missing_opencode_provider_still_missing() {
1265        let json = r#"{"other": {"models": {"x":{"limit":{"context":1,"output":1}}}}}"#;
1266        assert!(matches!(
1267            ModelCatalog::from_json(json.as_bytes()),
1268            Err(ModelCatalogError::ProviderMissing)
1269        ));
1270    }
1271
1272    #[test]
1273    fn all_entries_unusable_is_a_data_failure() {
1274        let json = r#"{
1275          "opencode": {"models": {
1276            "a": {"limit": {"context": -1, "output": 1}},
1277            "b": {"limit": {"context": -2, "output": 2}}
1278          }}
1279        }"#;
1280        assert!(matches!(
1281            ModelCatalog::from_json(json.as_bytes()),
1282            Err(ModelCatalogError::Decode(_))
1283        ));
1284    }
1285}