1use 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 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#[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 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 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 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}