1use std::collections::BTreeSet;
3
4use artifact_api::{ArtifactKind, ArtifactReference};
5use serde::{Deserialize, Serialize};
6
7use crate::{ContentPart, Message, ModelConstraints};
8
9pub const INPUT_IMAGE: &str = "image";
11pub const INPUT_VIDEO: &str = "video";
13pub const INPUT_AUDIO: &str = "audio";
15pub const INPUT_FILE: &str = "file";
17
18#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20pub struct ModelCapabilities {
21 #[serde(default)]
23 pub input: Vec<String>,
24 pub tool_calling: bool,
25 pub structured_output: bool,
26}
27
28impl ModelConstraints {
29 pub fn validate(&self, capabilities: &ModelCapabilities) -> Result<(), &'static str> {
31 let required = normalize_constraint_input(&self.input)?;
32 let supported = normalize_capability_input(&capabilities.input)?;
33 for token in &required {
34 if !supported.iter().any(|item| item == token) {
35 return Err(static_input_token(token));
36 }
37 }
38 if self.tool_calling && !capabilities.tool_calling {
39 return Err("tool_calling");
40 }
41 if self.structured_output && !capabilities.structured_output {
42 return Err("structured_output");
43 }
44 Ok(())
45 }
46
47 pub fn validate_payload<'a>(
49 &self,
50 messages: impl IntoIterator<Item = &'a Message>,
51 ) -> Result<(), &'static str> {
52 let declared = normalize_constraint_input(&self.input)?;
53 for token in payload_input_modalities(messages)? {
54 if !declared.iter().any(|item| item == &token) {
55 return Err(static_input_token(&token));
56 }
57 }
58 Ok(())
59 }
60}
61
62pub const REASONING_EFFORT_LADDER: &[&str] =
65 &["minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
66
67pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 32_768;
70
71#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct GenerationParameters {
78 pub reasoning_effort: Option<String>,
79 pub temperature: Option<f64>,
80 pub thinking: Option<bool>,
81 pub fast_mode: Option<bool>,
82}
83
84fn clamp_reasoning_effort(desired: Option<&str>, supported: &[impl AsRef<str>]) -> Option<String> {
88 let desired = desired?;
89 let supported: Vec<&str> = supported
90 .iter()
91 .map(AsRef::as_ref)
92 .filter(|value| !value.is_empty())
93 .collect();
94 if supported.is_empty() {
95 return None;
96 }
97 if supported.contains(&desired) {
98 return Some(desired.to_string());
99 }
100 let want = effort_rank(desired)?;
101 let mut best: Option<(&str, usize, usize)> = None;
102 for item in supported {
103 let Some(rank) = effort_rank(item) else {
104 continue;
105 };
106 let dist = rank.abs_diff(want);
107 match best {
108 None => best = Some((item, dist, rank)),
109 Some((_, best_dist, best_rank))
110 if dist < best_dist || (dist == best_dist && rank < best_rank) =>
111 {
112 best = Some((item, dist, rank));
113 }
114 _ => {}
115 }
116 }
117 best.map(|(item, _, _)| item.to_string())
118}
119
120fn effort_rank(effort: &str) -> Option<usize> {
121 REASONING_EFFORT_LADDER
122 .iter()
123 .position(|item| *item == effort)
124}
125
126impl GenerationParameters {
127 pub fn validate(&self) -> Result<(), &'static str> {
128 if let Some(effort) = self.reasoning_effort.as_deref() {
129 if effort_rank(effort).is_none() {
130 return Err("invalid reasoning_effort");
131 }
132 }
133 if self
134 .temperature
135 .is_some_and(|value| !value.is_finite() || value < 0.0)
136 {
137 return Err("temperature must be finite and nonnegative");
138 }
139 Ok(())
140 }
141
142 #[must_use]
145 pub fn normalize_stored(mut self) -> Self {
146 if self.reasoning_effort.as_deref() == Some("none") {
147 self.reasoning_effort = None;
148 if self.thinking.is_none() {
149 self.thinking = Some(false);
150 }
151 }
152 if self
153 .reasoning_effort
154 .as_deref()
155 .is_some_and(|effort| effort_rank(effort).is_none())
156 {
157 self.reasoning_effort = None;
158 }
159 if self
160 .temperature
161 .is_some_and(|value| !value.is_finite() || value < 0.0)
162 {
163 self.temperature = None;
164 }
165 self
166 }
167}
168
169#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct GenerationSupport {
175 pub temperature: Option<bool>,
176 pub max_tokens: Option<bool>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub thinking: Option<bool>,
180 pub reasoning_efforts: Option<Vec<String>>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub reasoning_effort_default: Option<String>,
184 pub temperature_with_reasoning: Option<bool>,
185 pub temperature_max: Option<f64>,
186 pub max_output_tokens: Option<u32>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub fast_mode: Option<bool>,
189}
190
191impl GenerationSupport {
192 pub fn validate(&self) -> Result<(), &'static str> {
193 if self
194 .temperature_max
195 .is_some_and(|value| !value.is_finite() || value < 0.0)
196 || self.max_output_tokens == Some(0)
197 {
198 return Err("invalid generation support limits");
199 }
200 for effort in self.efforts() {
201 if effort_rank(effort).is_none() {
202 return Err("reasoningEfforts must list reasoning intensities");
203 }
204 }
205 if let Some(default) = self.reasoning_effort_default.as_deref() {
206 if !self.efforts().any(|effort| effort == default) {
207 return Err("reasoningEffortDefault must be listed in reasoningEfforts");
208 }
209 }
210 Ok(())
211 }
212
213 fn efforts(&self) -> impl Iterator<Item = &str> {
214 self.reasoning_efforts.iter().flatten().map(String::as_str)
215 }
216
217 fn thinking_supported(&self) -> bool {
218 self.thinking == Some(true)
219 }
220
221 fn reasons(&self) -> bool {
222 self.thinking_supported() || self.efforts().next().is_some()
223 }
224
225 fn thinking_on(&self, desired: Option<bool>) -> bool {
226 if self.thinking_supported() {
227 desired.unwrap_or(true)
228 } else {
229 self.efforts().next().is_some()
230 }
231 }
232
233 fn effective_max_output_tokens(&self) -> Option<u32> {
234 if self.max_tokens == Some(false) {
235 None
236 } else {
237 Some(
238 self.max_output_tokens
239 .map_or(DEFAULT_MAX_OUTPUT_TOKENS, |max| {
240 DEFAULT_MAX_OUTPUT_TOKENS.min(max)
241 }),
242 )
243 }
244 }
245}
246
247#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub struct BackendCapability {
252 pub token_cap: bool,
254 pub temperature: bool,
256 pub thinking_switch: bool,
258 pub intensity: bool,
260 pub fast: bool,
262}
263
264#[derive(Clone, Debug, Default, PartialEq, Serialize)]
267pub struct EffectiveGeneration {
268 pub temperature: Option<f64>,
269 pub max_output_tokens: Option<u32>,
270 pub thinking: Option<bool>,
271 pub reasoning_effort: Option<String>,
272 pub fast_mode: Option<bool>,
273}
274
275impl EffectiveGeneration {
276 #[must_use]
278 #[allow(clippy::cast_possible_truncation)]
279 pub fn temperature_f32(&self) -> Option<f32> {
280 self.temperature.map(|value| value as f32)
281 }
282
283 #[must_use]
286 pub fn output_cap(&self, budget: Option<u32>) -> Option<u32> {
287 self.max_output_tokens
288 .map(|ceiling| budget.unwrap_or(ceiling).min(ceiling))
289 }
290}
291
292#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297pub struct GenerationControls {
298 pub temperature: bool,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub temperature_max: Option<f64>,
301 pub thinking: bool,
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
303 pub reasoning_efforts: Vec<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub reasoning_effort_default: Option<String>,
306 pub fast_mode: bool,
307}
308
309#[must_use]
312pub fn controls(support: &GenerationSupport, backend: &BackendCapability) -> GenerationControls {
313 let thinking = backend.thinking_switch && support.thinking_supported();
314 let reasoning_efforts: Vec<String> = if backend.intensity {
315 support.efforts().map(str::to_owned).collect()
316 } else {
317 Vec::new()
318 };
319 let can_use_temperature_while_reasoning = support.temperature_with_reasoning.unwrap_or(false);
320 let temperature = backend.temperature
321 && support.temperature == Some(true)
322 && (can_use_temperature_while_reasoning || thinking || !support.reasons());
323 GenerationControls {
324 temperature_max: temperature.then_some(support.temperature_max).flatten(),
325 temperature,
326 thinking,
327 reasoning_effort_default: reasoning_efforts
328 .iter()
329 .find(|effort| Some(effort.as_str()) == support.reasoning_effort_default.as_deref())
330 .cloned(),
331 reasoning_efforts,
332 fast_mode: backend.fast && support.fast_mode == Some(true),
333 }
334}
335
336pub fn resolve(
345 desired: &GenerationParameters,
346 support: &GenerationSupport,
347 backend: &BackendCapability,
348) -> Result<EffectiveGeneration, &'static str> {
349 support.validate()?;
350 let desired = desired.clone().normalize_stored();
351
352 let thinking_on = support.thinking_on(desired.thinking);
353 let thinking = (backend.thinking_switch && support.thinking_supported()).then_some(thinking_on);
354
355 let reasoning_effort = if thinking_on && backend.intensity {
356 let intensities: Vec<&str> = support.efforts().collect();
357 clamp_reasoning_effort(
358 desired
359 .reasoning_effort
360 .as_deref()
361 .or(support.reasoning_effort_default.as_deref()),
362 &intensities,
363 )
364 } else {
365 None
366 };
367
368 let temperature = desired
369 .temperature
370 .filter(|_| {
371 backend.temperature
372 && support.temperature == Some(true)
373 && (support.temperature_with_reasoning.unwrap_or(false) || !thinking_on)
374 })
375 .map(|value| support.temperature_max.map_or(value, |max| value.min(max)));
376
377 let max_output_tokens = backend
378 .token_cap
379 .then(|| support.effective_max_output_tokens())
380 .flatten();
381
382 let fast_mode =
383 (backend.fast && support.fast_mode == Some(true) && desired.fast_mode == Some(true))
384 .then_some(true);
385
386 Ok(EffectiveGeneration {
387 temperature,
388 max_output_tokens,
389 thinking,
390 reasoning_effort,
391 fast_mode,
392 })
393}
394
395pub fn normalize_input_token(raw: &str) -> Result<Option<&'static str>, &'static str> {
397 let token = raw.trim().to_ascii_lowercase();
398 if token.is_empty() || token == "text" {
399 return Ok(None);
400 }
401 match token.as_str() {
402 INPUT_IMAGE => Ok(Some(INPUT_IMAGE)),
403 INPUT_VIDEO => Ok(Some(INPUT_VIDEO)),
404 INPUT_AUDIO => Ok(Some(INPUT_AUDIO)),
405 INPUT_FILE => Ok(Some(INPUT_FILE)),
406 "vision" => Err("vision"),
407 _ => Err("unknown input modality"),
408 }
409}
410
411pub fn normalize_capability_input<I, S>(raw: I) -> Result<Vec<String>, &'static str>
413where
414 I: IntoIterator<Item = S>,
415 S: AsRef<str>,
416{
417 let mut set = BTreeSet::new();
418 for item in raw {
419 if let Some(token) = normalize_input_token(item.as_ref())? {
420 set.insert(token.to_string());
421 }
422 }
423 Ok(set.into_iter().collect())
424}
425
426pub fn normalize_constraint_input<I, S>(raw: I) -> Result<Vec<String>, &'static str>
428where
429 I: IntoIterator<Item = S>,
430 S: AsRef<str>,
431{
432 let mut set = BTreeSet::new();
433 for item in raw {
434 match normalize_input_token(item.as_ref())? {
435 None => return Err("text"),
436 Some(token) => {
437 set.insert(token.to_string());
438 }
439 }
440 }
441 Ok(set.into_iter().collect())
442}
443
444#[must_use]
446pub const fn input_modality_for_kind(kind: ArtifactKind) -> &'static str {
447 match kind {
448 ArtifactKind::Image => INPUT_IMAGE,
449 ArtifactKind::Video => INPUT_VIDEO,
450 ArtifactKind::Audio => INPUT_AUDIO,
451 ArtifactKind::File => INPUT_FILE,
452 }
453}
454
455#[must_use]
457pub fn input_modality_for_mime(mime_type: &str) -> &'static str {
458 let mime = mime_type.trim().to_ascii_lowercase();
459 if mime.starts_with("image/") {
460 INPUT_IMAGE
461 } else if mime.starts_with("video/") {
462 INPUT_VIDEO
463 } else if mime.starts_with("audio/") {
464 INPUT_AUDIO
465 } else {
466 INPUT_FILE
467 }
468}
469
470pub fn payload_input_modalities<'a>(
472 messages: impl IntoIterator<Item = &'a Message>,
473) -> Result<Vec<String>, &'static str> {
474 let mut set = BTreeSet::new();
475 for message in messages {
476 for part in &message.content {
477 if let Some(token) = part_input_modality(part)? {
478 set.insert(token.to_string());
479 }
480 }
481 }
482 Ok(set.into_iter().collect())
483}
484
485fn part_input_modality(part: &ContentPart) -> Result<Option<&'static str>, &'static str> {
486 match part {
487 ContentPart::Image { .. } => Ok(Some(INPUT_IMAGE)),
488 ContentPart::Artifact { uri, mime_type } => {
489 artifact_input_modality(uri, mime_type).map(Some)
490 }
491 _ => Ok(None),
492 }
493}
494
495fn artifact_input_modality(uri: &str, mime_type: &str) -> Result<&'static str, &'static str> {
496 let reference = ArtifactReference::parse(uri).map_err(|_| "invalid artifact reference")?;
497 if reference.metadata().mime_type() != mime_type {
498 return Err("artifact mime type does not match the message");
499 }
500 let from_kind = input_modality_for_kind(reference.metadata().kind());
501 let from_mime = input_modality_for_mime(mime_type);
502 if from_kind != from_mime {
503 return Err("artifact kind does not match mime type");
504 }
505 Ok(from_kind)
506}
507
508fn static_input_token(token: &str) -> &'static str {
509 match token {
510 INPUT_IMAGE => INPUT_IMAGE,
511 INPUT_VIDEO => INPUT_VIDEO,
512 INPUT_AUDIO => INPUT_AUDIO,
513 INPUT_FILE => INPUT_FILE,
514 "text" => "text",
515 "vision" => "vision",
516 "unknown input modality" => "unknown input modality",
517 _ => "undeclared input",
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::{MessageRole, ModelConstraints};
525 use artifact_api::ArtifactMetadata;
526
527 fn image_uri() -> (String, String) {
528 let artifact = ArtifactReference::new(
529 "tenant-1",
530 "scope-1",
531 "a".repeat(64),
532 ArtifactMetadata::image("image/png", 100, 10, 10).expect("valid image metadata"),
533 )
534 .expect("valid image artifact");
535 (artifact.uri().expect("uri"), "image/png".to_owned())
536 }
537
538 fn audio_uri() -> (String, String) {
539 let artifact = ArtifactReference::new(
540 "tenant-1",
541 "scope-1",
542 "b".repeat(64),
543 ArtifactMetadata::audio("audio/mpeg", 100, Some(1_000)).expect("valid audio metadata"),
544 )
545 .expect("valid audio artifact");
546 (artifact.uri().expect("uri"), "audio/mpeg".to_owned())
547 }
548
549 #[test]
550 fn declarations_are_requirements_not_prohibitions() {
551 let supported = ModelCapabilities {
552 input: vec![INPUT_IMAGE.into()],
553 tool_calling: true,
554 structured_output: true,
555 };
556 assert!(ModelConstraints::default().validate(&supported).is_ok());
557 assert!(ModelConstraints::default()
558 .validate(&ModelCapabilities::default())
559 .is_ok());
560 for constraints in [
561 ModelConstraints {
562 input: vec![INPUT_IMAGE.into()],
563 ..Default::default()
564 },
565 ModelConstraints {
566 tool_calling: true,
567 ..Default::default()
568 },
569 ModelConstraints {
570 structured_output: true,
571 ..Default::default()
572 },
573 ] {
574 assert!(constraints.validate(&supported).is_ok());
575 assert!(constraints.validate(&ModelCapabilities::default()).is_err());
576 }
577 }
578
579 #[test]
580 fn input_lists_use_closed_vocabulary_and_ignore_catalog_text() {
581 assert_eq!(
582 normalize_capability_input(["text", "IMAGE", "image"]).unwrap(),
583 vec![INPUT_IMAGE.to_string()]
584 );
585 assert!(normalize_constraint_input(["text"]).is_err());
586 assert!(normalize_input_token("vision").is_err());
587 assert!(normalize_input_token("unknown").is_err());
588 let constraints = ModelConstraints {
589 input: vec![INPUT_VIDEO.into()],
590 ..Default::default()
591 };
592 assert_eq!(
593 constraints
594 .validate(&ModelCapabilities {
595 input: vec![INPUT_IMAGE.into()],
596 ..Default::default()
597 })
598 .unwrap_err(),
599 INPUT_VIDEO
600 );
601 }
602
603 #[test]
604 fn payload_must_be_declared_and_kind_must_match_mime() {
605 let (image_uri, image_mime) = image_uri();
606 let (audio_uri, audio_mime) = audio_uri();
607 let image_message = Message {
608 role: MessageRole::User,
609 content: vec![ContentPart::Artifact {
610 uri: image_uri,
611 mime_type: image_mime,
612 }],
613 continuation: None,
614 };
615 let audio_message = Message {
616 role: MessageRole::User,
617 content: vec![ContentPart::Artifact {
618 uri: audio_uri.clone(),
619 mime_type: audio_mime,
620 }],
621 continuation: None,
622 };
623 assert_eq!(
624 payload_input_modalities([&image_message]).unwrap(),
625 vec![INPUT_IMAGE.to_string()]
626 );
627 let declared = ModelConstraints {
628 input: vec![INPUT_IMAGE.into()],
629 ..Default::default()
630 };
631 assert!(declared.validate_payload([&image_message]).is_ok());
632 assert_eq!(
633 declared.validate_payload([&audio_message]).unwrap_err(),
634 INPUT_AUDIO
635 );
636 let mismatched = Message {
637 role: MessageRole::User,
638 content: vec![ContentPart::Artifact {
639 uri: audio_uri,
640 mime_type: "image/png".to_owned(),
641 }],
642 continuation: None,
643 };
644 assert!(payload_input_modalities([&mismatched]).is_err());
645 }
646
647 #[test]
648 fn retired_generation_control_is_not_silently_ignored() {
649 assert!(
650 serde_json::from_value::<ModelConstraints>(serde_json::json!({
651 "vision": false, "tool_calling": false, "structured_output": false
652 }))
653 .is_err()
654 );
655 assert!(serde_json::from_value::<ModelConstraints>(serde_json::json!({
656 "input": ["image"], "tool_calling": false, "structured_output": false, "reasoning": "high"
657 }))
658 .is_err());
659 }
660
661 #[test]
662 fn clamps_desired_effort_onto_supported_list() {
663 let flash = ["max", "high", "low"];
664 assert_eq!(
665 clamp_reasoning_effort(Some("low"), &flash).as_deref(),
666 Some("low")
667 );
668 assert_eq!(
669 clamp_reasoning_effort(Some("ultra"), &flash).as_deref(),
670 Some("max")
671 );
672 assert_eq!(
673 clamp_reasoning_effort(Some("medium"), &["low", "high"]).as_deref(),
674 Some("low")
675 );
676 assert_eq!(
677 clamp_reasoning_effort(Some("minimal"), &["high", "max"]).as_deref(),
678 Some("high")
679 );
680 assert_eq!(clamp_reasoning_effort(Some("low"), &[] as &[&str]), None);
681 assert_eq!(clamp_reasoning_effort(None, &flash), None);
682 }
683
684 #[test]
685 fn validates_explicit_generation_settings() {
686 for effort in ["minimal", "low", "high", "max", "ultra"] {
687 assert!(GenerationParameters {
688 reasoning_effort: Some(effort.into()),
689 ..Default::default()
690 }
691 .validate()
692 .is_ok());
693 }
694 for effort in ["unknown", "none"] {
695 assert!(GenerationParameters {
696 reasoning_effort: Some(effort.into()),
697 ..Default::default()
698 }
699 .validate()
700 .is_err());
701 }
702 for temperature in [-1.0, f64::NAN, f64::INFINITY] {
703 assert!(GenerationParameters {
704 temperature: Some(temperature),
705 ..Default::default()
706 }
707 .validate()
708 .is_err());
709 }
710 }
711
712 const FULL: BackendCapability = BackendCapability {
713 token_cap: true,
714 temperature: true,
715 thinking_switch: true,
716 intensity: true,
717 fast: true,
718 };
719
720 fn switchable() -> GenerationSupport {
721 GenerationSupport {
722 temperature: Some(true),
723 max_tokens: Some(true),
724 thinking: Some(true),
725 reasoning_efforts: Some(vec!["low".into(), "medium".into(), "high".into()]),
726 ..Default::default()
727 }
728 }
729
730 #[test]
731 fn thinking_defaults_on_and_the_switch_turns_it_off() {
732 let support = switchable();
733 let on = resolve(&GenerationParameters::default(), &support, &FULL).unwrap();
734 assert_eq!(on.thinking, Some(true));
735 let off = resolve(
736 &GenerationParameters {
737 thinking: Some(false),
738 ..Default::default()
739 },
740 &support,
741 &FULL,
742 )
743 .unwrap();
744 assert_eq!(off.thinking, Some(false));
745 assert_eq!(off.reasoning_effort, None);
746 }
747
748 #[test]
749 fn intensity_comes_from_the_request_or_the_catalog_default_and_is_never_invented() {
750 let support = switchable();
751 assert_eq!(
752 resolve(&GenerationParameters::default(), &support, &FULL)
753 .unwrap()
754 .reasoning_effort,
755 None
756 );
757 assert_eq!(
758 resolve(
759 &GenerationParameters {
760 reasoning_effort: Some("ultra".into()),
761 ..Default::default()
762 },
763 &support,
764 &FULL
765 )
766 .unwrap()
767 .reasoning_effort
768 .as_deref(),
769 Some("high")
770 );
771 let defaulted = GenerationSupport {
772 reasoning_effort_default: Some("medium".into()),
773 ..switchable()
774 };
775 assert_eq!(
776 resolve(&GenerationParameters::default(), &defaulted, &FULL)
777 .unwrap()
778 .reasoning_effort
779 .as_deref(),
780 Some("medium")
781 );
782 }
783
784 #[test]
785 fn a_protocol_that_cannot_express_a_field_omits_it() {
786 let support = GenerationSupport {
787 fast_mode: Some(true),
788 ..switchable()
789 };
790 let desired = GenerationParameters {
791 temperature: Some(0.7),
792 reasoning_effort: Some("low".into()),
793 thinking: Some(false),
794 fast_mode: Some(true),
795 };
796 let full = resolve(&desired, &support, &FULL).unwrap();
797 assert_eq!(
798 (full.thinking, full.fast_mode, full.max_output_tokens),
799 (Some(false), Some(true), Some(DEFAULT_MAX_OUTPUT_TOKENS))
800 );
801 let bare = resolve(
802 &desired,
803 &support,
804 &BackendCapability {
805 token_cap: false,
806 temperature: false,
807 thinking_switch: false,
808 intensity: false,
809 fast: false,
810 },
811 )
812 .unwrap();
813 assert_eq!(bare, EffectiveGeneration::default());
814 }
815
816 #[test]
817 fn undeclared_support_omits_instead_of_guessing() {
818 let silent = GenerationSupport::default();
819 let effective = resolve(
820 &GenerationParameters {
821 temperature: Some(0.7),
822 reasoning_effort: Some("high".into()),
823 thinking: Some(false),
824 fast_mode: Some(true),
825 },
826 &silent,
827 &FULL,
828 )
829 .unwrap();
830 assert_eq!(
831 effective,
832 EffectiveGeneration {
833 max_output_tokens: Some(DEFAULT_MAX_OUTPUT_TOKENS),
834 ..Default::default()
835 }
836 );
837 }
838
839 #[test]
840 fn temperature_needs_declared_support_and_coexistence_with_thinking() {
841 let hot = GenerationSupport {
842 temperature_max: Some(1.0),
843 ..switchable()
844 };
845 let desired = GenerationParameters {
846 temperature: Some(1.5),
847 ..Default::default()
848 };
849 assert_eq!(resolve(&desired, &hot, &FULL).unwrap().temperature, None);
850 let coexists = GenerationSupport {
851 temperature_with_reasoning: Some(true),
852 ..hot.clone()
853 };
854 assert_eq!(
855 resolve(&desired, &coexists, &FULL).unwrap().temperature,
856 Some(1.0)
857 );
858 assert_eq!(
859 resolve(
860 &GenerationParameters {
861 thinking: Some(false),
862 ..desired
863 },
864 &hot,
865 &FULL
866 )
867 .unwrap()
868 .temperature,
869 Some(1.0)
870 );
871 }
872
873 #[test]
874 fn non_switchable_models_reason_whenever_they_declare_intensities() {
875 let always = GenerationSupport {
876 reasoning_efforts: Some(vec!["low".into(), "high".into()]),
877 max_tokens: Some(true),
878 ..Default::default()
879 };
880 let effective = resolve(
881 &GenerationParameters {
882 thinking: Some(false),
883 ..Default::default()
884 },
885 &always,
886 &FULL,
887 )
888 .unwrap();
889 assert_eq!(effective.thinking, None);
890 assert_eq!(effective.reasoning_effort, None);
891 let none = GenerationSupport::default();
892 assert_eq!(
893 resolve(&GenerationParameters::default(), &none, &FULL)
894 .unwrap()
895 .thinking,
896 None
897 );
898 }
899
900 #[test]
901 fn resolution_is_stable_when_replayed_against_the_same_facts() {
902 let support = GenerationSupport {
903 fast_mode: Some(true),
904 temperature_with_reasoning: Some(true),
905 reasoning_effort_default: Some("medium".into()),
906 ..switchable()
907 };
908 let desired = GenerationParameters {
909 temperature: Some(0.4),
910 reasoning_effort: Some("ultra".into()),
911 thinking: Some(true),
912 fast_mode: Some(true),
913 };
914 let first = resolve(&desired, &support, &FULL).unwrap();
915 let replay = resolve(
916 &GenerationParameters {
917 temperature: first.temperature,
918 reasoning_effort: first.reasoning_effort.clone(),
919 thinking: first.thinking,
920 fast_mode: first.fast_mode,
921 },
922 &support,
923 &FULL,
924 )
925 .unwrap();
926 assert_eq!(first, replay);
927 }
928
929 #[test]
930 fn support_rejects_disable_as_an_intensity_and_unlisted_defaults() {
931 assert!(GenerationSupport {
932 reasoning_efforts: Some(vec!["none".into()]),
933 ..Default::default()
934 }
935 .validate()
936 .is_err());
937 assert!(GenerationSupport {
938 reasoning_effort_default: Some("max".into()),
939 ..switchable()
940 }
941 .validate()
942 .is_err());
943 assert!(GenerationSupport {
944 max_output_tokens: Some(0),
945 ..Default::default()
946 }
947 .validate()
948 .is_err());
949 }
950
951 #[test]
952 fn the_internal_output_cap_is_a_product_default_not_a_capability() {
953 assert_eq!(
954 GenerationSupport::default().effective_max_output_tokens(),
955 Some(DEFAULT_MAX_OUTPUT_TOKENS)
956 );
957 assert_eq!(
958 GenerationSupport {
959 max_output_tokens: Some(4_096),
960 ..Default::default()
961 }
962 .effective_max_output_tokens(),
963 Some(4_096)
964 );
965 assert_eq!(
966 GenerationSupport {
967 max_tokens: Some(false),
968 ..Default::default()
969 }
970 .effective_max_output_tokens(),
971 None
972 );
973 assert_eq!(
974 EffectiveGeneration {
975 max_output_tokens: Some(4_096),
976 ..Default::default()
977 }
978 .output_cap(Some(1_024)),
979 Some(1_024)
980 );
981 assert_eq!(
982 EffectiveGeneration {
983 max_output_tokens: Some(4_096),
984 ..Default::default()
985 }
986 .output_cap(Some(100_000)),
987 Some(4_096)
988 );
989 }
990
991 #[test]
992 fn controls_match_what_resolve_can_put_on_the_wire() {
993 let switchable = GenerationSupport {
994 temperature: Some(true),
995 temperature_max: Some(1.0),
996 fast_mode: Some(true),
997 ..switchable()
998 };
999 let shown = controls(&switchable, &FULL);
1000 assert!(shown.temperature);
1001 assert_eq!(shown.temperature_max, Some(1.0));
1002 assert!(shown.thinking);
1003 assert!(shown.fast_mode);
1004 assert_eq!(
1005 shown.reasoning_efforts,
1006 vec!["low".to_string(), "medium".to_string(), "high".to_string()]
1007 );
1008 let always = GenerationSupport {
1009 temperature: Some(true),
1010 reasoning_efforts: Some(vec!["low".into(), "high".into()]),
1011 ..Default::default()
1012 };
1013 let hidden = controls(&always, &FULL);
1014 assert!(!hidden.temperature);
1015 assert!(!hidden.thinking);
1016 assert_eq!(hidden.reasoning_efforts, vec!["low", "high"]);
1017 let protocol = controls(
1018 &switchable,
1019 &BackendCapability {
1020 token_cap: true,
1021 temperature: true,
1022 thinking_switch: false,
1023 intensity: true,
1024 fast: false,
1025 },
1026 );
1027 assert!(!protocol.thinking);
1028 assert!(!protocol.temperature);
1029 assert!(!protocol.fast_mode);
1030 }
1031
1032 #[test]
1033 fn retired_and_illegal_preferences_are_dropped_instead_of_failing_the_request() {
1034 let support = switchable();
1035 let from_none = resolve(
1036 &GenerationParameters {
1037 reasoning_effort: Some("none".into()),
1038 ..Default::default()
1039 },
1040 &support,
1041 &FULL,
1042 )
1043 .unwrap();
1044 assert_eq!(from_none.thinking, Some(false));
1045 assert_eq!(from_none.reasoning_effort, None);
1046 let garbage = resolve(
1047 &GenerationParameters {
1048 reasoning_effort: Some("not-a-ladder".into()),
1049 temperature: Some(f64::NAN),
1050 thinking: Some(true),
1051 ..Default::default()
1052 },
1053 &support,
1054 &FULL,
1055 )
1056 .unwrap();
1057 assert_eq!(garbage.thinking, Some(true));
1058 assert_eq!(garbage.reasoning_effort, None);
1059 assert_eq!(garbage.temperature, None);
1060 assert!(GenerationParameters {
1061 reasoning_effort: Some("none".into()),
1062 ..Default::default()
1063 }
1064 .validate()
1065 .is_err());
1066 }
1067
1068 #[test]
1069 fn catalog_support_ignores_unknown_keys() {
1070 let support = serde_json::from_value::<GenerationSupport>(serde_json::json!({
1071 "temperature": true,
1072 "thinking": true,
1073 "reasoningEfforts": ["low"],
1074 "futureFlag": true
1075 }))
1076 .unwrap();
1077 assert_eq!(support.temperature, Some(true));
1078 assert!(support.validate().is_ok());
1079 }
1080}