Skip to main content

llm_api/
policy.rs

1//! Pure invocation policy shared by deployments. No discovery or provider routing.
2use std::collections::BTreeSet;
3
4use artifact_api::{ArtifactKind, ArtifactReference};
5use serde::{Deserialize, Serialize};
6
7use crate::{ContentPart, Message, ModelConstraints};
8
9/// Closed vocabulary for non-text model input. `text` is implied and must not be listed.
10pub const INPUT_IMAGE: &str = "image";
11/// Video input.
12pub const INPUT_VIDEO: &str = "video";
13/// Audio input.
14pub const INPUT_AUDIO: &str = "audio";
15/// Generic file input.
16pub const INPUT_FILE: &str = "file";
17
18/// Confirmed model capabilities. Missing information never establishes support.
19#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20pub struct ModelCapabilities {
21    /// Closed vocabulary: `image`, `video`, `audio`, `file`. `text` is implied.
22    #[serde(default)]
23    pub input: Vec<String>,
24    pub tool_calling: bool,
25    pub structured_output: bool,
26}
27
28impl ModelConstraints {
29    /// Checks declarations only, including requirements on requests with no attachments/tools.
30    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    /// Payload modalities must be a subset of the caller-declared `input` list.
48    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
62/// Reasoning intensity ordered from least to most. Turning thinking off is the `thinking`
63/// switch, never a listed intensity, so `none` is not on this ladder.
64pub const REASONING_EFFORT_LADDER: &[&str] =
65    &["minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
66
67/// Internal per-response output cap. Not a user setting. Adapters omit it when
68/// the model snapshot says `maxTokens` is false.
69pub const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 32_768;
70
71/// Deployment-owned generation settings, never supplied through Agent constraints.
72/// None leaves the setting unspecified. These are desired preferences. Adapters
73/// omit unsupported fields and clamp reasoning intensity onto the model's list.
74/// Syntax validation does not establish model/provider support.
75#[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
84/// Maps a desired effort onto `supported`. Exact matches are kept. Otherwise the nearest
85/// ladder neighbor is used; ties pick the lower effort. An empty supported list, or no
86/// desired value, omits the field. Values not on the ladder are ignored.
87fn 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    /// Stored preferences are wishes, not a contract. Retired `none` means thinking off;
143    /// any other illegal value is dropped so a request can still run.
144    #[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/// Confirmed per-model parameter support from catalog `metadata.generationSupport`.
170/// Missing information never establishes support; it is never inferred from preferences.
171/// Unknown catalog keys are ignored so a newer catalog cannot break an older client.
172#[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    /// True when the model can run with thinking turned off.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub thinking: Option<bool>,
180    /// Selectable intensities. `none` is not an intensity; see `thinking`.
181    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/// What one backend protocol can express, independent of any model. These are wire facts
248/// owned by the adapter, not catalog facts: a protocol that cannot carry a field makes the
249/// field unusable even when the model supports it.
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub struct BackendCapability {
252    /// Accepts a per-response output token cap.
253    pub token_cap: bool,
254    /// Accepts a sampling temperature.
255    pub temperature: bool,
256    /// Can encode the thinking switch, and therefore can turn thinking off.
257    pub thinking_switch: bool,
258    /// Can encode a reasoning intensity.
259    pub intensity: bool,
260    /// Can request the fast service tier.
261    pub fast: bool,
262}
263
264/// Generation parameters that may reach the wire. Fields left `None` are omitted from the
265/// payload, which leaves the provider default in effect.
266#[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    /// Provider payloads carry temperature as f32.
277    #[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    /// The internal cap, lowered by an optional per-call output budget. A budget never raises
284    /// the cap, and it stays out of the wire identity so it can vary between steps of one run.
285    #[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/// What a settings UI may expose after intersecting catalog facts with the backend protocol.
293/// This is the same judgment `resolve` uses; a hidden control cannot appear on the wire, and
294/// a shown control is one the current backend can actually carry.
295#[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/// Settings that can take effect on this backend for this model. Callers must not infer
310/// controls from catalog fields alone.
311#[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
336/// The single resolution of desired generation parameters against model and protocol facts.
337/// Every caller resolves exactly once, against facts frozen for the request, so replaying a
338/// frozen route always yields the same wire payload.
339///
340/// An unsupported or undeclared setting is omitted rather than guessed, except for the
341/// internal output cap, which is a product default and applies unless the protocol or the
342/// model rejects a cap. Illegal *desired* values are dropped the same way; illegal catalog
343/// support still fails, because that data is ours.
344pub 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
395/// Maps one catalog/constraint token. `text` is dropped. `vision` and unknown values fail.
396pub 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
411/// Normalizes catalog capability tokens. `text` is ignored; unknown tokens fail.
412pub 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
426/// Normalizes caller-declared constraint tokens. `text` is illegal here.
427pub 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/// Closed-vocabulary token for an artifact kind.
445#[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/// Closed-vocabulary token for a MIME type. Non-media types map to `file`.
456#[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
470/// Distinct payload modalities required by `Artifact` and `Image` parts.
471pub 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}