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//! cross-provider merges, or fallback limits.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, SystemTime};
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use thiserror::Error;
17use tokio::sync::{Mutex, RwLock};
18use tracing::{debug, warn};
19
20const DEFAULT_MODELS_URL: &str = "https://models.dev/api.json";
21const MODELS_URL_ENV: &str = "AGENT_HARNESS_MODELS_URL";
22const CACHE_PATH_ENV: &str = "AGENT_HARNESS_MODELS_CACHE_PATH";
23const PROVIDER: &str = "opencode";
24const CACHE_TTL: Duration = Duration::from_secs(5 * 60);
25const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum WireProtocol {
29    #[serde(rename = "openai_compatible")]
30    OpenAiCompatible,
31    #[serde(rename = "anthropic")]
32    Anthropic,
33    #[serde(rename = "openai_responses")]
34    OpenAiResponses,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ReasoningMode {
40    #[default]
41    Default,
42    Enabled,
43    Disabled,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ReasoningConfig {
49    #[serde(default)]
50    pub mode: ReasoningMode,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub effort: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub budget_tokens: Option<u64>,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ModelRequestConfig {
60    pub model: String,
61    pub max_output_tokens: u64,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub temperature: Option<f64>,
64    #[serde(default)]
65    pub reasoning: ReasoningConfig,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ModelLimits {
70    pub context: u64,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub input: Option<u64>,
73    pub output: u64,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(tag = "type", rename_all = "snake_case")]
78pub enum ReasoningOption {
79    Toggle,
80    Effort {
81        values: Vec<String>,
82    },
83    BudgetTokens {
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        min: Option<u64>,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        max: Option<u64>,
88    },
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct InterleavedReasoning {
93    pub enabled: bool,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub field: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ModelCapabilities {
100    pub id: String,
101    pub limits: ModelLimits,
102    pub reasoning: bool,
103    pub reasoning_options: Vec<ReasoningOption>,
104    pub temperature: bool,
105    pub tool_call: bool,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub interleaved: Option<InterleavedReasoning>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub status: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct ResolvedModelConfig {
114    pub model: String,
115    pub wire_protocol: WireProtocol,
116    pub max_output_tokens: u64,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub temperature: Option<f64>,
119    pub reasoning: ReasoningConfig,
120    pub capabilities: ModelCapabilities,
121}
122
123impl ResolvedModelConfig {
124    /// Maximum input budget after reserving the requested completion budget.
125    pub fn max_input_tokens(&self) -> u64 {
126        self.capabilities.limits.input.unwrap_or_else(|| {
127            self.capabilities
128                .limits
129                .context
130                .saturating_sub(self.max_output_tokens)
131        })
132    }
133}
134
135#[derive(Debug, Error)]
136pub enum ModelCatalogError {
137    #[error("models.dev request failed: {0}")]
138    Request(String),
139    #[error("models.dev response is invalid: {0}")]
140    Decode(String),
141    #[error("models.dev has no `{PROVIDER}` provider")]
142    ProviderMissing,
143    #[error("model `{model}` is not present in models.dev[{PROVIDER}]{suggestions}")]
144    ModelNotFound { model: String, suggestions: String },
145    #[error("model id `{model}` is ambiguous in models.dev[{PROVIDER}]: {matches:?}")]
146    AmbiguousModel { model: String, matches: Vec<String> },
147    #[error("model `{model}` is deprecated")]
148    DeprecatedModel { model: String },
149    #[error("invalid model request for `{model}`: {message}")]
150    InvalidRequest { model: String, message: String },
151}
152
153#[derive(Debug, Clone, Deserialize)]
154struct CatalogRoot(HashMap<String, ProviderEntry>);
155
156#[derive(Debug, Clone, Deserialize)]
157struct ProviderEntry {
158    models: HashMap<String, ModelEntry>,
159}
160
161#[derive(Debug, Clone, Deserialize)]
162struct ModelEntry {
163    #[serde(default)]
164    id: Option<String>,
165    limit: ModelLimits,
166    #[serde(default)]
167    reasoning: bool,
168    #[serde(default)]
169    reasoning_options: Vec<ReasoningOption>,
170    #[serde(default)]
171    temperature: bool,
172    #[serde(default)]
173    tool_call: bool,
174    #[serde(default)]
175    interleaved: Option<Value>,
176    #[serde(default)]
177    status: Option<String>,
178}
179
180impl ModelEntry {
181    fn into_capabilities(self, map_id: String) -> ModelCapabilities {
182        let interleaved = match self.interleaved {
183            Some(Value::Bool(enabled)) => Some(InterleavedReasoning {
184                enabled,
185                field: None,
186            }),
187            Some(Value::Object(object)) => Some(InterleavedReasoning {
188                enabled: true,
189                field: object
190                    .get("field")
191                    .and_then(Value::as_str)
192                    .map(str::to_owned),
193            }),
194            _ => None,
195        };
196        ModelCapabilities {
197            id: self.id.unwrap_or(map_id),
198            limits: self.limit,
199            reasoning: self.reasoning,
200            reasoning_options: self.reasoning_options,
201            temperature: self.temperature,
202            tool_call: self.tool_call,
203            interleaved,
204            status: self.status,
205        }
206    }
207}
208
209#[derive(Debug, Clone)]
210struct CatalogSnapshot {
211    models: HashMap<String, ModelCapabilities>,
212}
213
214/// Shared, refreshable view of the fixed `models.dev["opencode"]` catalog.
215#[derive(Debug, Clone)]
216pub struct ModelCatalog {
217    snapshot: Arc<RwLock<CatalogSnapshot>>,
218    refresh_lock: Arc<Mutex<()>>,
219    refresh_generation: Arc<AtomicU64>,
220    cache_path: Option<PathBuf>,
221    models_url: String,
222}
223
224impl ModelCatalog {
225    /// Load the disk cache and refresh it when needed. A fresh cache returns
226    /// immediately. A stale cache remains usable while one background refresh
227    /// runs; with no usable cache, the network fetch is awaited and required.
228    pub async fn initialize() -> Result<Self, ModelCatalogError> {
229        let cache_path = cache_path();
230        let cached = cache_path
231            .as_deref()
232            .and_then(read_cache)
233            .and_then(|bytes| parse_snapshot(&bytes).ok());
234        let cache_fresh = cache_path.as_deref().map(cache_is_fresh).unwrap_or(false);
235        let catalog = Self {
236            snapshot: Arc::new(RwLock::new(cached.as_ref().cloned().unwrap_or_else(|| {
237                CatalogSnapshot {
238                    models: HashMap::new(),
239                }
240            }))),
241            refresh_lock: Arc::new(Mutex::new(())),
242            refresh_generation: Arc::new(AtomicU64::new(0)),
243            cache_path,
244            models_url: std::env::var(MODELS_URL_ENV)
245                .ok()
246                .filter(|value| !value.trim().is_empty())
247                .unwrap_or_else(|| DEFAULT_MODELS_URL.to_owned()),
248        };
249
250        if cached.is_some() && cache_fresh {
251            return Ok(catalog);
252        }
253        if cached.is_some() {
254            let refresh_catalog = catalog.clone();
255            tokio::spawn(async move {
256                if let Err(error) = refresh_catalog.refresh().await {
257                    warn!(%error, "models.dev background refresh failed; retaining stale cache");
258                }
259            });
260            return Ok(catalog);
261        }
262
263        catalog.refresh().await?;
264        Ok(catalog)
265    }
266
267    /// Force a refresh. Concurrent callers share a single in-flight fetch.
268    pub async fn refresh(&self) -> Result<(), ModelCatalogError> {
269        let observed_generation = self.refresh_generation.load(Ordering::Acquire);
270        let _guard = self.refresh_lock.lock().await;
271        if self.refresh_generation.load(Ordering::Acquire) != observed_generation {
272            return Ok(());
273        }
274        let bytes = fetch_catalog(&self.models_url).await?;
275        let snapshot = parse_snapshot(&bytes)?;
276        if let Some(path) = &self.cache_path {
277            if let Err(error) = write_cache_atomic(path, &bytes).await {
278                warn!(%error, ?path, "failed to write models.dev disk cache");
279            }
280        }
281        let count = snapshot.models.len();
282        *self.snapshot.write().await = snapshot;
283        self.refresh_generation.fetch_add(1, Ordering::Release);
284        debug!(count, provider = PROVIDER, "models.dev catalog refreshed");
285        Ok(())
286    }
287
288    /// Resolve and validate a short model id for one of the three wire
289    /// protocols. Exact id match wins; a path-like id may also match by a
290    /// unique basename. Contains/fuzzy matches are suggestions only.
291    pub async fn resolve(
292        &self,
293        request: ModelRequestConfig,
294        wire_protocol: WireProtocol,
295    ) -> Result<ResolvedModelConfig, ModelCatalogError> {
296        let snapshot = self.snapshot.read().await;
297        let requested = request.model.trim().to_ascii_lowercase();
298        let capabilities = match snapshot.models.get(&requested) {
299            Some(model) => model.clone(),
300            None => resolve_unique_basename(&snapshot.models, &requested)?,
301        };
302        drop(snapshot);
303        validate_request(&request, &capabilities, wire_protocol)?;
304        Ok(ResolvedModelConfig {
305            model: capabilities.id.clone(),
306            wire_protocol,
307            max_output_tokens: request.max_output_tokens,
308            temperature: request.temperature,
309            reasoning: normalize_reasoning(request.reasoning, &capabilities),
310            capabilities,
311        })
312    }
313
314    pub async fn model_count(&self) -> usize {
315        self.snapshot.read().await.models.len()
316    }
317}
318
319fn parse_snapshot(bytes: &[u8]) -> Result<CatalogSnapshot, ModelCatalogError> {
320    let root: CatalogRoot = serde_json::from_slice(bytes)
321        .map_err(|error| ModelCatalogError::Decode(error.to_string()))?;
322    let provider = root
323        .0
324        .get(PROVIDER)
325        .ok_or(ModelCatalogError::ProviderMissing)?;
326    let models = provider
327        .models
328        .clone()
329        .into_iter()
330        .map(|(id, entry)| {
331            let key = id.to_ascii_lowercase();
332            (key, entry.into_capabilities(id))
333        })
334        .collect();
335    Ok(CatalogSnapshot { models })
336}
337
338fn resolve_unique_basename(
339    models: &HashMap<String, ModelCapabilities>,
340    requested: &str,
341) -> Result<ModelCapabilities, ModelCatalogError> {
342    let basename = requested.rsplit('/').next().unwrap_or(requested);
343    let mut matches = models
344        .iter()
345        .filter(|(id, _)| id.rsplit('/').next() == Some(basename))
346        .map(|(_, model)| model.clone())
347        .collect::<Vec<_>>();
348    match matches.len() {
349        1 => Ok(matches.remove(0)),
350        count if count > 1 => Err(ModelCatalogError::AmbiguousModel {
351            model: requested.to_owned(),
352            matches: matches.into_iter().map(|model| model.id).collect(),
353        }),
354        _ => {
355            let mut suggestions = models
356                .keys()
357                .filter(|id| id.contains(requested) || requested.contains(id.as_str()))
358                .take(5)
359                .cloned()
360                .collect::<Vec<_>>();
361            suggestions.sort();
362            let suggestions = if suggestions.is_empty() {
363                String::new()
364            } else {
365                format!("; did you mean {}?", suggestions.join(", "))
366            };
367            Err(ModelCatalogError::ModelNotFound {
368                model: requested.to_owned(),
369                suggestions,
370            })
371        }
372    }
373}
374
375fn validate_request(
376    request: &ModelRequestConfig,
377    capabilities: &ModelCapabilities,
378    wire_protocol: WireProtocol,
379) -> Result<(), ModelCatalogError> {
380    let invalid = |message: String| ModelCatalogError::InvalidRequest {
381        model: capabilities.id.clone(),
382        message,
383    };
384    if capabilities.status.as_deref() == Some("deprecated") {
385        return Err(ModelCatalogError::DeprecatedModel {
386            model: capabilities.id.clone(),
387        });
388    }
389    if request.max_output_tokens == 0 {
390        return Err(invalid(
391            "max_output_tokens must be greater than zero".into(),
392        ));
393    }
394    if request.max_output_tokens > capabilities.limits.output {
395        return Err(invalid(format!(
396            "max_output_tokens {} exceeds models.dev output limit {}",
397            request.max_output_tokens, capabilities.limits.output
398        )));
399    }
400    if request.temperature.is_some() && !capabilities.temperature {
401        return Err(invalid("temperature is not supported".into()));
402    }
403    if request.reasoning.effort.is_some() && request.reasoning.budget_tokens.is_some() {
404        return Err(invalid(
405            "reasoning.effort and reasoning.budget_tokens are mutually exclusive".into(),
406        ));
407    }
408    if matches!(request.reasoning.mode, ReasoningMode::Default)
409        && (request.reasoning.effort.is_some() || request.reasoning.budget_tokens.is_some())
410    {
411        return Err(invalid(
412            "reasoning.mode must be enabled when effort or budget_tokens is set".into(),
413        ));
414    }
415    if !capabilities.reasoning && !matches!(request.reasoning.mode, ReasoningMode::Default) {
416        return Err(invalid("reasoning is not supported".into()));
417    }
418    if matches!(request.reasoning.mode, ReasoningMode::Disabled) {
419        if request.reasoning.budget_tokens.is_some() {
420            return Err(invalid(
421                "reasoning.budget_tokens cannot be set when reasoning is disabled".into(),
422            ));
423        }
424        if request
425            .reasoning
426            .effort
427            .as_deref()
428            .is_some_and(|effort| !effort.eq_ignore_ascii_case("none"))
429        {
430            return Err(invalid(
431                "reasoning.effort must be `none` when reasoning is disabled".into(),
432            ));
433        }
434    }
435    if matches!(request.reasoning.mode, ReasoningMode::Enabled)
436        && request
437            .reasoning
438            .effort
439            .as_deref()
440            .is_some_and(|effort| effort.eq_ignore_ascii_case("none"))
441    {
442        return Err(invalid(
443            "reasoning.effort `none` conflicts with reasoning.mode `enabled`".into(),
444        ));
445    }
446
447    let effort_values = capabilities
448        .reasoning_options
449        .iter()
450        .find_map(|option| match option {
451            ReasoningOption::Effort { values } => Some(values),
452            _ => None,
453        });
454    let toggle = capabilities
455        .reasoning_options
456        .iter()
457        .any(|option| matches!(option, ReasoningOption::Toggle));
458    let budget = capabilities
459        .reasoning_options
460        .iter()
461        .find_map(|option| match option {
462            ReasoningOption::BudgetTokens { min, max } => Some((*min, *max)),
463            _ => None,
464        });
465
466    if let Some(effort) = request.reasoning.effort.as_deref() {
467        let values =
468            effort_values.ok_or_else(|| invalid("reasoning effort is not supported".into()))?;
469        if !values
470            .iter()
471            .any(|value| value.eq_ignore_ascii_case(effort))
472        {
473            return Err(invalid(format!(
474                "reasoning effort `{effort}` is unsupported; allowed values: {}",
475                values.join(", ")
476            )));
477        }
478    }
479    if let Some(tokens) = request.reasoning.budget_tokens {
480        let (min, max) =
481            budget.ok_or_else(|| invalid("reasoning budget_tokens is not supported".into()))?;
482        if min.is_some_and(|minimum| tokens < minimum)
483            || max.is_some_and(|maximum| tokens > maximum)
484        {
485            return Err(invalid(format!(
486                "reasoning budget_tokens {tokens} is outside models.dev range {}..{}",
487                min.map(|value| value.to_string())
488                    .unwrap_or_else(|| "0".into()),
489                max.map(|value| value.to_string())
490                    .unwrap_or_else(|| "unbounded".into())
491            )));
492        }
493    }
494
495    match request.reasoning.mode {
496        ReasoningMode::Default => {}
497        ReasoningMode::Enabled
498            if request.reasoning.effort.is_none()
499                && request.reasoning.budget_tokens.is_none()
500                && !toggle =>
501        {
502            return Err(invalid(
503                "reasoning cannot be explicitly enabled without an effort or toggle capability"
504                    .into(),
505            ));
506        }
507        ReasoningMode::Disabled if !toggle && !supports_none(effort_values) => {
508            return Err(invalid("reasoning cannot be explicitly disabled".into()));
509        }
510        _ => {}
511    }
512
513    if matches!(wire_protocol, WireProtocol::OpenAiResponses) {
514        if request.reasoning.budget_tokens.is_some() {
515            return Err(invalid(
516                "openai_responses cannot express reasoning budget_tokens".into(),
517            ));
518        }
519        if !matches!(request.reasoning.mode, ReasoningMode::Default)
520            && request.reasoning.effort.is_none()
521            && !supports_none(effort_values)
522        {
523            return Err(invalid(
524                "openai_responses requires an effort-based reasoning control".into(),
525            ));
526        }
527    }
528    Ok(())
529}
530
531fn supports_none(values: Option<&Vec<String>>) -> bool {
532    values.is_some_and(|values| {
533        values
534            .iter()
535            .any(|value| value.eq_ignore_ascii_case("none"))
536    })
537}
538
539fn normalize_reasoning(
540    mut reasoning: ReasoningConfig,
541    capabilities: &ModelCapabilities,
542) -> ReasoningConfig {
543    if matches!(reasoning.mode, ReasoningMode::Disabled) && reasoning.effort.is_none() {
544        let supports_none = capabilities
545            .reasoning_options
546            .iter()
547            .any(|option| match option {
548                ReasoningOption::Effort { values } => values.iter().any(|value| value == "none"),
549                _ => false,
550            });
551        if supports_none {
552            reasoning.effort = Some("none".into());
553        }
554    }
555    reasoning
556}
557
558async fn fetch_catalog(url: &str) -> Result<Vec<u8>, ModelCatalogError> {
559    let client = reqwest::Client::builder()
560        .timeout(FETCH_TIMEOUT)
561        .build()
562        .map_err(|error| ModelCatalogError::Request(error.to_string()))?;
563    let response = client
564        .get(url)
565        .send()
566        .await
567        .map_err(|error| ModelCatalogError::Request(error.to_string()))?
568        .error_for_status()
569        .map_err(|error| ModelCatalogError::Request(error.to_string()))?;
570    response
571        .bytes()
572        .await
573        .map(|bytes| bytes.to_vec())
574        .map_err(|error| ModelCatalogError::Request(error.to_string()))
575}
576
577fn cache_path() -> Option<PathBuf> {
578    if let Ok(path) = std::env::var(CACHE_PATH_ENV) {
579        if !path.trim().is_empty() {
580            return Some(PathBuf::from(path));
581        }
582    }
583    dirs::cache_dir().map(|base| base.join("agent-harness-rs").join("models.dev.json"))
584}
585
586fn read_cache(path: &Path) -> Option<Vec<u8>> {
587    std::fs::read(path).ok()
588}
589
590fn cache_is_fresh(path: &Path) -> bool {
591    std::fs::metadata(path)
592        .and_then(|metadata| metadata.modified())
593        .ok()
594        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
595        .is_some_and(|age| age < CACHE_TTL)
596}
597
598async fn write_cache_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
599    if let Some(parent) = path.parent() {
600        tokio::fs::create_dir_all(parent).await?;
601    }
602    let suffix = SystemTime::now()
603        .duration_since(SystemTime::UNIX_EPOCH)
604        .map(|duration| duration.as_nanos())
605        .unwrap_or(0);
606    let temporary = path.with_extension(format!("tmp.{}.{}", std::process::id(), suffix));
607    tokio::fs::write(&temporary, bytes).await?;
608    tokio::fs::rename(temporary, path).await
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn wire_protocol_uses_stable_external_names() {
617        for (protocol, external) in [
618            (WireProtocol::OpenAiCompatible, "openai_compatible"),
619            (WireProtocol::Anthropic, "anthropic"),
620            (WireProtocol::OpenAiResponses, "openai_responses"),
621        ] {
622            assert_eq!(
623                serde_json::to_string(&protocol).unwrap(),
624                format!("\"{external}\"")
625            );
626            assert_eq!(
627                serde_json::from_str::<WireProtocol>(&format!("\"{external}\"")).unwrap(),
628                protocol
629            );
630        }
631    }
632
633    fn catalog(json: &str) -> ModelCatalog {
634        ModelCatalog {
635            snapshot: Arc::new(RwLock::new(parse_snapshot(json.as_bytes()).unwrap())),
636            refresh_lock: Arc::new(Mutex::new(())),
637            refresh_generation: Arc::new(AtomicU64::new(0)),
638            cache_path: None,
639            models_url: "unused".into(),
640        }
641    }
642
643    const FIXTURE: &str = r#"{
644      "opencode": {"models": {
645        "deepseek-v4-pro": {
646          "id":"deepseek-v4-pro", "reasoning":true, "temperature":true,
647          "tool_call":true, "interleaved":{"field":"reasoning_content"},
648          "reasoning_options":[{"type":"toggle"},{"type":"effort","values":["high","max"]}],
649          "limit":{"context":1000000,"output":384000}
650        },
651        "gpt-5.5": {
652          "reasoning":true, "temperature":false, "tool_call":true,
653          "reasoning_options":[{"type":"effort","values":["none","low","high"]}],
654          "limit":{"context":1050000,"input":922000,"output":128000}
655        },
656        "old-model": {
657          "status":"deprecated", "limit":{"context":1000,"output":100}
658        }
659      }}
660    }"#;
661
662    fn request(model: &str) -> ModelRequestConfig {
663        ModelRequestConfig {
664            model: model.into(),
665            max_output_tokens: 65_536,
666            temperature: None,
667            reasoning: ReasoningConfig::default(),
668        }
669    }
670
671    #[tokio::test]
672    async fn exact_short_id_resolves_and_preserves_limits() {
673        let resolved = catalog(FIXTURE)
674            .resolve(request("deepseek-v4-pro"), WireProtocol::OpenAiCompatible)
675            .await
676            .unwrap();
677        assert_eq!(resolved.model, "deepseek-v4-pro");
678        assert_eq!(resolved.capabilities.limits.context, 1_000_000);
679        assert_eq!(resolved.max_input_tokens(), 934_464);
680        assert_eq!(
681            resolved.capabilities.interleaved.unwrap().field.as_deref(),
682            Some("reasoning_content")
683        );
684    }
685
686    #[tokio::test]
687    async fn unique_path_basename_resolves_but_contains_does_not() {
688        let resolved = catalog(FIXTURE)
689            .resolve(
690                request("vendor/deepseek-v4-pro"),
691                WireProtocol::OpenAiCompatible,
692            )
693            .await
694            .unwrap();
695        assert_eq!(resolved.model, "deepseek-v4-pro");
696        let error = catalog(FIXTURE)
697            .resolve(request("deepseek-v4"), WireProtocol::OpenAiCompatible)
698            .await
699            .unwrap_err();
700        assert!(matches!(error, ModelCatalogError::ModelNotFound { .. }));
701    }
702
703    #[tokio::test]
704    async fn validates_limits_temperature_and_deprecation() {
705        let source = catalog(FIXTURE);
706        let mut too_large = request("gpt-5.5");
707        too_large.max_output_tokens = 128_001;
708        assert!(matches!(
709            source
710                .resolve(too_large, WireProtocol::OpenAiResponses)
711                .await,
712            Err(ModelCatalogError::InvalidRequest { .. })
713        ));
714        let mut temperature = request("gpt-5.5");
715        temperature.temperature = Some(0.2);
716        assert!(matches!(
717            source
718                .resolve(temperature, WireProtocol::OpenAiResponses)
719                .await,
720            Err(ModelCatalogError::InvalidRequest { .. })
721        ));
722        let mut old = request("old-model");
723        old.max_output_tokens = 10;
724        assert!(matches!(
725            source.resolve(old, WireProtocol::OpenAiCompatible).await,
726            Err(ModelCatalogError::DeprecatedModel { .. })
727        ));
728    }
729
730    #[tokio::test]
731    async fn validates_and_normalizes_reasoning_controls() {
732        let source = catalog(FIXTURE);
733        let mut deepseek = request("deepseek-v4-pro");
734        deepseek.reasoning = ReasoningConfig {
735            mode: ReasoningMode::Enabled,
736            effort: Some("high".into()),
737            budget_tokens: None,
738        };
739        let resolved = source
740            .resolve(deepseek, WireProtocol::OpenAiCompatible)
741            .await
742            .unwrap();
743        assert_eq!(resolved.reasoning.effort.as_deref(), Some("high"));
744
745        let mut disabled = request("gpt-5.5");
746        disabled.reasoning.mode = ReasoningMode::Disabled;
747        let resolved = source
748            .resolve(disabled, WireProtocol::OpenAiResponses)
749            .await
750            .unwrap();
751        assert_eq!(resolved.reasoning.effort.as_deref(), Some("none"));
752
753        let mut invalid = request("deepseek-v4-pro");
754        invalid.reasoning = ReasoningConfig {
755            mode: ReasoningMode::Enabled,
756            effort: Some("medium".into()),
757            budget_tokens: None,
758        };
759        assert!(matches!(
760            source
761                .resolve(invalid, WireProtocol::OpenAiCompatible)
762                .await,
763            Err(ModelCatalogError::InvalidRequest { .. })
764        ));
765
766        let mut contradictory = request("gpt-5.5");
767        contradictory.reasoning = ReasoningConfig {
768            mode: ReasoningMode::Disabled,
769            effort: Some("high".into()),
770            budget_tokens: None,
771        };
772        assert!(matches!(
773            source
774                .resolve(contradictory, WireProtocol::OpenAiResponses)
775                .await,
776            Err(ModelCatalogError::InvalidRequest { .. })
777        ));
778    }
779}