1use 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum LimitsSource {
189 #[default]
191 Catalog,
192 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
205pub const DEFAULT_CONTEXT_LIMIT: u64 = 128_000;
210pub 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 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 #[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#[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
327fn 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#[derive(Debug, Clone)]
355struct CatalogSnapshot {
356 models: HashMap<String, ModelCapabilities>,
357}
358
359#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let caps = source.capabilities("old-model").await.unwrap();
1051 assert_eq!(caps.status.as_deref(), Some("deprecated"));
1052 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 #[tokio::test]
1151 async fn live_payload_negative_budget_min_is_tolerated() {
1152 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 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 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 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 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 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}