Skip to main content

turnframe_provider/
request.rs

1//! The normalized model request (spec §20.1).
2//!
3//! One [`ModelRequest`] describes one model call in vocabulary no vendor owns: its
4//! [`ModelPurpose`], its [`Message`]s, the [`OutputSpec`] the answer must meet, the
5//! read-only [`ToolSpec`]s on the table and its limits. An adapter translates it to its wire
6//! format and back; nothing above the provider layer sees the wire (spec §0 rule 8).
7//!
8//! It holds no credentials: keys live in the adapter's configuration as
9//! [`ApiKey`](crate::secret::ApiKey) (spec §25.2). And [`RequestMetadata`] takes short
10//! label-shaped values only, refusing anything else, because metadata ends up in a
11//! provider's dashboard and in metric tags (spec §25.5).
12//!
13//! ```
14//! use turnframe_provider::prelude::*;
15//!
16//! let request = ModelRequest::new(ModelPurpose::Acknowledge)
17//!     .with_system("Answer in the user's language.")
18//!     .with_message(Message::user("Quanto manca alla partenza?"))
19//!     .with_max_output_tokens(400);
20//!
21//! assert_eq!(request.messages.len(), 1);
22//! assert!(matches!(request.output, OutputSpec::FreeText));
23//! ```
24
25use std::collections::BTreeMap;
26use std::fmt;
27use std::time::Duration;
28
29use serde::{Deserialize, Serialize};
30
31use crate::ids::{CallId, RequestId};
32use crate::purpose::ModelPurpose;
33
34/// Default per-call deadline when the caller sets none.
35pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
36
37/// Longest metadata value [`RequestMetadata`] accepts, in bytes.
38pub const MAX_METADATA_VALUE_LEN: usize = 64;
39
40/// Who a message comes from.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Role {
44    /// Framing instructions from the application. Most adapters hoist a
45    /// [`ModelRequest::system`] here instead.
46    System,
47    /// The end user.
48    User,
49    /// The model's own previous output.
50    Assistant,
51    /// The result of a tool the model asked for.
52    Tool,
53}
54
55impl Role {
56    /// Stable snake-case label.
57    #[must_use]
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::System => "system",
61            Self::User => "user",
62            Self::Assistant => "assistant",
63            Self::Tool => "tool",
64        }
65    }
66}
67
68impl fmt::Display for Role {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74/// Where the bytes of an image come from.
75#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum ImageSource {
79    /// An HTTPS URL the provider fetches itself.
80    Url {
81        /// The location.
82        url: String,
83    },
84    /// Base64-encoded bytes carried inline.
85    Base64 {
86        /// IANA media type, e.g. `"image/png"`.
87        media_type: String,
88        /// Base64 payload without a data-URI prefix.
89        data: String,
90    },
91}
92
93/// Where the bytes of a document come from.
94///
95/// A document is not an image with a different media type, which is why it has
96/// its own source: every form states the media type, including the referenced
97/// one. A provider needs it — Bedrock wants a format enum, Anthropic wants a
98/// `document` block, OpenAI wants a data URI with the type in it — and guessing
99/// it from a URL's suffix is how a PDF ends up announced as a PNG.
100#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(tag = "kind", rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum DocumentSource {
104    /// A URL the provider fetches itself.
105    Url {
106        /// The location.
107        url: String,
108        /// IANA media type of what the URL serves, e.g. `"application/pdf"`.
109        /// Stated rather than derived from the path.
110        media_type: String,
111    },
112    /// Base64-encoded bytes carried inline.
113    Base64 {
114        /// IANA media type, e.g. `"application/pdf"`.
115        media_type: String,
116        /// Base64 payload without a data-URI prefix.
117        data: String,
118    },
119}
120
121impl DocumentSource {
122    /// The declared media type, whichever form this is.
123    #[must_use]
124    pub fn media_type(&self) -> &str {
125        match self {
126            Self::Url { media_type, .. } | Self::Base64 { media_type, .. } => media_type,
127        }
128    }
129}
130
131/// One tool call the model asked for.
132///
133/// `arguments` is a [`serde_json::Value`] on purpose: it is model-facing data
134/// awaiting schema validation, not a domain API.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ToolCall {
138    /// Provider-assigned identifier, echoed by the matching [`ToolResult`].
139    pub id: CallId,
140    /// Name of the tool, as declared in a [`ToolSpec`].
141    pub name: String,
142    /// Arguments, still untrusted.
143    pub arguments: serde_json::Value,
144}
145
146impl ToolCall {
147    /// Builds a tool call.
148    #[must_use]
149    pub fn new(
150        id: impl Into<CallId>,
151        name: impl Into<String>,
152        arguments: serde_json::Value,
153    ) -> Self {
154        Self {
155            id: id.into(),
156            name: name.into(),
157            arguments,
158        }
159    }
160}
161
162/// The result of a tool call, sent back to the model.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(deny_unknown_fields)]
165pub struct ToolResult {
166    /// The call this answers.
167    pub call_id: CallId,
168    /// Serialized result. Carries a source label from the read layer, so the
169    /// model treats it as data, not as instructions (spec §25.3).
170    pub content: String,
171    /// Whether the tool failed.
172    #[serde(default)]
173    pub is_error: bool,
174}
175
176impl ToolResult {
177    /// A successful result.
178    #[must_use]
179    pub fn ok(call_id: impl Into<CallId>, content: impl Into<String>) -> Self {
180        Self {
181            call_id: call_id.into(),
182            content: content.into(),
183            is_error: false,
184        }
185    }
186
187    /// A failed result.
188    #[must_use]
189    pub fn error(call_id: impl Into<CallId>, content: impl Into<String>) -> Self {
190        Self {
191            call_id: call_id.into(),
192            content: content.into(),
193            is_error: true,
194        }
195    }
196}
197
198/// One piece of a message.
199///
200/// A message is a sequence of parts rather than a string because a turn can
201/// legitimately mix prose, an attachment and the tool exchange that preceded it.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(tag = "type", rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum ContentPart {
206    /// Plain text.
207    Text {
208        /// The text.
209        text: String,
210    },
211    /// An image. Requires
212    /// [`vision`](crate::capabilities::ProviderCapabilities::vision).
213    Image {
214        /// Where the bytes come from.
215        source: ImageSource,
216    },
217    /// A document — a PDF, a spreadsheet, a plain-text attachment. Requires
218    /// [`documents`](crate::capabilities::ProviderCapabilities::documents),
219    /// which is declared separately from `vision` because accepting a PNG says
220    /// nothing about accepting a PDF.
221    Document {
222        /// Where the bytes come from, and what they are.
223        source: DocumentSource,
224    },
225    /// A call the model made (in an assistant message).
226    ToolCall(ToolCall),
227    /// A result the runtime returned (in a tool message).
228    ToolResult(ToolResult),
229}
230
231impl ContentPart {
232    /// A text part.
233    #[must_use]
234    pub fn text(text: impl Into<String>) -> Self {
235        Self::Text { text: text.into() }
236    }
237
238    /// An image fetched by the provider from a URL.
239    #[must_use]
240    pub fn image_url(url: impl Into<String>) -> Self {
241        Self::Image {
242            source: ImageSource::Url { url: url.into() },
243        }
244    }
245
246    /// An inline image.
247    #[must_use]
248    pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
249        Self::Image {
250            source: ImageSource::Base64 {
251                media_type: media_type.into(),
252                data: data.into(),
253            },
254        }
255    }
256
257    /// An inline part built from raw bytes, encoded here.
258    ///
259    /// Which of the two it becomes is decided by the media type, because the
260    /// distinction the provider layer draws is exactly that one: `image/*` is an
261    /// image, everything else is a document. A caller holding bytes should not
262    /// have to know that accepting a PNG says nothing about accepting a PDF —
263    /// the capability requirements are derived from the part, so the router does
264    /// that part.
265    ///
266    /// Every inline form in this module takes base64 without a data-URI prefix,
267    /// which is a rule a caller can only get wrong. So the encoding lives here,
268    /// beside the contract that asks for it, rather than in every application
269    /// that has a file.
270    #[must_use]
271    pub fn inline_bytes(media_type: impl Into<String>, bytes: &[u8]) -> Self {
272        use base64::Engine as _;
273        let media_type = media_type.into();
274        let data = base64::engine::general_purpose::STANDARD.encode(bytes);
275        if media_type.starts_with("image/") {
276            Self::Image {
277                source: ImageSource::Base64 { media_type, data },
278            }
279        } else {
280            Self::Document {
281                source: DocumentSource::Base64 { media_type, data },
282            }
283        }
284    }
285
286    /// A document the provider fetches from a URL, with its media type stated.
287    #[must_use]
288    pub fn document_url(url: impl Into<String>, media_type: impl Into<String>) -> Self {
289        Self::Document {
290            source: DocumentSource::Url {
291                url: url.into(),
292                media_type: media_type.into(),
293            },
294        }
295    }
296
297    /// An inline document.
298    #[must_use]
299    pub fn document_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
300        Self::Document {
301            source: DocumentSource::Base64 {
302                media_type: media_type.into(),
303                data: data.into(),
304            },
305        }
306    }
307
308    /// Borrows the text, when this is a text part.
309    #[must_use]
310    pub fn as_text(&self) -> Option<&str> {
311        match self {
312            Self::Text { text } => Some(text),
313            _ => None,
314        }
315    }
316
317    /// Borrows the call, when this is a tool call.
318    #[must_use]
319    pub fn as_tool_call(&self) -> Option<&ToolCall> {
320        match self {
321            Self::ToolCall(call) => Some(call),
322            _ => None,
323        }
324    }
325
326    /// Borrows the source, when this is a document part.
327    #[must_use]
328    pub fn as_document(&self) -> Option<&DocumentSource> {
329        match self {
330            Self::Document { source } => Some(source),
331            _ => None,
332        }
333    }
334
335    /// Stable snake-case label of the part kind.
336    #[must_use]
337    pub const fn kind(&self) -> &'static str {
338        match self {
339            Self::Text { .. } => "text",
340            Self::Image { .. } => "image",
341            Self::Document { .. } => "document",
342            Self::ToolCall(_) => "tool_call",
343            Self::ToolResult(_) => "tool_result",
344        }
345    }
346}
347
348/// One message in the conversation sent to the model.
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct Message {
352    /// Who is speaking.
353    pub role: Role,
354    /// What they said, in order.
355    pub content: Vec<ContentPart>,
356}
357
358impl Message {
359    /// A message with explicit parts.
360    #[must_use]
361    pub fn new(role: Role, content: Vec<ContentPart>) -> Self {
362        Self { role, content }
363    }
364
365    /// A user message holding one text part.
366    #[must_use]
367    pub fn user(text: impl Into<String>) -> Self {
368        Self::new(Role::User, vec![ContentPart::text(text)])
369    }
370
371    /// An assistant message holding one text part.
372    #[must_use]
373    pub fn assistant(text: impl Into<String>) -> Self {
374        Self::new(Role::Assistant, vec![ContentPart::text(text)])
375    }
376
377    /// A system message holding one text part.
378    #[must_use]
379    pub fn system(text: impl Into<String>) -> Self {
380        Self::new(Role::System, vec![ContentPart::text(text)])
381    }
382
383    /// A tool message carrying one result.
384    #[must_use]
385    pub fn tool_result(result: ToolResult) -> Self {
386        Self::new(Role::Tool, vec![ContentPart::ToolResult(result)])
387    }
388
389    /// Appends a part.
390    #[must_use]
391    pub fn with_part(mut self, part: ContentPart) -> Self {
392        self.content.push(part);
393        self
394    }
395
396    /// Concatenates every text part of the message.
397    #[must_use]
398    pub fn text(&self) -> String {
399        let mut out = String::new();
400        for part in &self.content {
401            if let Some(text) = part.as_text() {
402                out.push_str(text);
403            }
404        }
405        out
406    }
407
408    /// Returns `true` when the message carries an image part.
409    #[must_use]
410    pub fn has_image(&self) -> bool {
411        self.content
412            .iter()
413            .any(|part| matches!(part, ContentPart::Image { .. }))
414    }
415
416    /// Returns `true` when the message carries a document part.
417    #[must_use]
418    pub fn has_document(&self) -> bool {
419        self.content
420            .iter()
421            .any(|part| matches!(part, ContentPart::Document { .. }))
422    }
423}
424
425/// The shape the answer must have (spec §20.3, §20.4).
426///
427/// `Json` is what a critical stage asks for. Whether the provider *enforces*
428/// the schema on the wire or only sees it in a prompt is the difference between
429/// [`StructuredOutputCapability::NativeJsonSchema`](crate::capabilities::StructuredOutputCapability::NativeJsonSchema)
430/// and
431/// [`PromptOnly`](crate::capabilities::StructuredOutputCapability::PromptOnly);
432/// the router refuses to serve an understanding task with the
433/// latter, and the adapter must never pretend otherwise.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(tag = "kind", rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum OutputSpec {
438    /// Prose, for a request that needs no document back.
439    FreeText,
440    /// A single JSON document matching `schema`.
441    Json {
442        /// The JSON Schema the answer must satisfy. Also the schema
443        /// [`parse_structured`](crate::structured::parse_structured) validates
444        /// against, so it is never merely decorative.
445        schema: serde_json::Value,
446        /// Name the provider labels the schema with.
447        name: String,
448        /// Whether the provider must reject non-conforming output rather than
449        /// producing a best effort. Adapters that cannot honour `strict` must
450        /// declare a weaker
451        /// [`StructuredOutputCapability`](crate::capabilities::StructuredOutputCapability).
452        strict: bool,
453    },
454    /// One or more tool calls, taken as the answer.
455    ToolCalls,
456}
457
458impl OutputSpec {
459    /// A strict JSON-schema output.
460    #[must_use]
461    pub fn json(name: impl Into<String>, schema: serde_json::Value) -> Self {
462        Self::Json {
463            schema,
464            name: name.into(),
465            strict: true,
466        }
467    }
468
469    /// Borrows the schema, when there is one.
470    #[must_use]
471    pub fn schema(&self) -> Option<&serde_json::Value> {
472        match self {
473            Self::Json { schema, .. } => Some(schema),
474            _ => None,
475        }
476    }
477
478    /// Returns `true` when the answer is expected to be JSON.
479    #[must_use]
480    pub const fn is_structured(&self) -> bool {
481        matches!(self, Self::Json { .. })
482    }
483
484    /// Stable snake-case label.
485    #[must_use]
486    pub const fn as_str(&self) -> &'static str {
487        match self {
488            Self::FreeText => "free_text",
489            Self::Json { .. } => "json",
490            Self::ToolCalls => "tool_calls",
491        }
492    }
493}
494
495/// Declaration of a tool the model may call.
496///
497/// Only read-only tools reach a model (spec §11.3, §21.1): an understanding
498/// task never sees a write tool, and a declaration is never authorization to
499/// execute anything (spec §21.4).
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(deny_unknown_fields)]
502pub struct ToolSpec {
503    /// Name the model calls.
504    pub name: String,
505    /// What the tool does, in the model's language.
506    pub description: String,
507    /// JSON Schema of the arguments.
508    pub parameters: serde_json::Value,
509}
510
511impl ToolSpec {
512    /// Declares a tool.
513    #[must_use]
514    pub fn new(
515        name: impl Into<String>,
516        description: impl Into<String>,
517        parameters: serde_json::Value,
518    ) -> Self {
519        Self {
520            name: name.into(),
521            description: description.into(),
522            parameters,
523        }
524    }
525}
526
527/// How free the model is to call tools.
528#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
529#[serde(tag = "kind", rename_all = "snake_case")]
530#[non_exhaustive]
531pub enum ToolChoice {
532    /// The model decides.
533    #[default]
534    Auto,
535    /// The model must not call a tool.
536    None,
537    /// The model must call some tool.
538    Required,
539    /// The model must call this tool. Used to force a structured payload
540    /// through a function schema.
541    Named {
542        /// Tool name.
543        name: String,
544    },
545}
546
547impl ToolChoice {
548    /// Forces one named tool.
549    #[must_use]
550    pub fn named(name: impl Into<String>) -> Self {
551        Self::Named { name: name.into() }
552    }
553
554    /// Stable snake-case label.
555    #[must_use]
556    pub const fn as_str(&self) -> &'static str {
557        match self {
558            Self::Auto => "auto",
559            Self::None => "none",
560            Self::Required => "required",
561            Self::Named { .. } => "named",
562        }
563    }
564}
565
566/// How much a reasoning model may think before it answers.
567///
568/// Sent only to a profile declaring
569/// [`reasoning_controls`](crate::capabilities::ProviderCapabilities::reasoning_controls);
570/// elsewhere it is dropped with a warning, like any parameter a profile does not take.
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573#[non_exhaustive]
574pub enum ReasoningEffort {
575    /// The least the provider offers.
576    Minimal,
577    /// A little.
578    Low,
579    /// The provider's usual amount.
580    Medium,
581    /// As much as the provider allows.
582    High,
583}
584
585impl ReasoningEffort {
586    /// Stable snake-case label.
587    #[must_use]
588    pub const fn as_str(self) -> &'static str {
589        match self {
590            Self::Minimal => "minimal",
591            Self::Low => "low",
592            Self::Medium => "medium",
593            Self::High => "high",
594        }
595    }
596}
597
598/// The sampling parameters a profile accepts, and the names of those it does not.
599///
600/// Every adapter maps the kept values to its own wire fields and reports each dropped
601/// name as a warning, so a per-task setting never makes a call fail.
602#[derive(Debug, Clone, PartialEq)]
603#[non_exhaustive]
604pub struct Sampling {
605    /// The temperature to send.
606    pub temperature: Option<f32>,
607    /// The seed to send.
608    pub seed: Option<u64>,
609    /// The reasoning effort to send.
610    pub reasoning_effort: Option<ReasoningEffort>,
611    /// Parameters the request set and the profile does not take.
612    pub dropped: Vec<&'static str>,
613}
614
615/// A hint that a prefix of the prompt is worth caching provider-side.
616///
617/// Only honoured when the profile declares
618/// [`prompt_caching`](crate::capabilities::ProviderCapabilities::prompt_caching);
619/// an adapter that cannot cache ignores it silently, because caching changes
620/// cost and latency but never meaning.
621#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
622#[serde(tag = "kind", rename_all = "snake_case")]
623#[non_exhaustive]
624pub enum CacheHint {
625    /// No hint.
626    #[default]
627    None,
628    /// Cache the system prompt.
629    System,
630    /// Cache the system prompt and the first `messages` messages.
631    Prefix {
632        /// How many leading messages belong to the stable prefix.
633        messages: usize,
634    },
635}
636
637/// Non-personal labels attached to a call.
638///
639/// Values are validated at insertion: at most [`MAX_METADATA_VALUE_LEN`] bytes,
640/// no control characters and no whitespace. That is enough for a turn id, a
641/// workflow key or an experiment label, and not enough for a sentence a user
642/// typed (spec §25.5).
643#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
644#[serde(transparent)]
645pub struct RequestMetadata(BTreeMap<String, String>);
646
647impl<'de> Deserialize<'de> for RequestMetadata {
648    /// Deserialization runs the same validation as [`RequestMetadata::insert`],
649    /// so a request read back from a fixture cannot carry a label the builder
650    /// would have refused.
651    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
652        let raw = BTreeMap::<String, String>::deserialize(deserializer)?;
653        let mut metadata = Self::new();
654        for (key, value) in raw {
655            metadata
656                .insert(key, value)
657                .map_err(serde::de::Error::custom)?;
658        }
659        Ok(metadata)
660    }
661}
662
663/// A metadata value was rejected.
664#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
665#[non_exhaustive]
666pub enum MetadataError {
667    /// The key was empty.
668    #[error("metadata key is empty")]
669    EmptyKey,
670    /// The value was longer than [`MAX_METADATA_VALUE_LEN`].
671    #[error("metadata value for key {key} is {len} bytes, over the label limit")]
672    ValueTooLong {
673        /// The offending key.
674        key: String,
675        /// Its length.
676        len: usize,
677    },
678    /// The value held whitespace or a control character, which is how free
679    /// user text looks.
680    #[error("metadata value for key {key} is not a label")]
681    ValueNotALabel {
682        /// The offending key.
683        key: String,
684    },
685}
686
687impl RequestMetadata {
688    /// Empty metadata.
689    #[must_use]
690    pub fn new() -> Self {
691        Self::default()
692    }
693
694    /// Inserts a label, replacing any previous value for `key`.
695    ///
696    /// ```
697    /// use turnframe_provider::request::RequestMetadata;
698    ///
699    /// let mut metadata = RequestMetadata::new();
700    /// metadata.insert("workflow", "trip").unwrap();
701    /// // Free user text cannot be smuggled through metadata.
702    /// assert!(metadata.insert("note", "the traveler said hello").is_err());
703    /// ```
704    pub fn insert(
705        &mut self,
706        key: impl Into<String>,
707        value: impl Into<String>,
708    ) -> Result<(), MetadataError> {
709        let key = key.into();
710        let value = value.into();
711        if key.is_empty() {
712            return Err(MetadataError::EmptyKey);
713        }
714        if value.len() > MAX_METADATA_VALUE_LEN {
715            return Err(MetadataError::ValueTooLong {
716                key,
717                len: value.len(),
718            });
719        }
720        if value
721            .chars()
722            .any(|ch| ch.is_whitespace() || ch.is_control())
723        {
724            return Err(MetadataError::ValueNotALabel { key });
725        }
726        self.0.insert(key, value);
727        Ok(())
728    }
729
730    /// Looks a label up.
731    #[must_use]
732    pub fn get(&self, key: &str) -> Option<&str> {
733        self.0.get(key).map(String::as_str)
734    }
735
736    /// Iterates over the labels in key order.
737    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
738        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
739    }
740
741    /// How many labels are set.
742    #[must_use]
743    pub fn len(&self) -> usize {
744        self.0.len()
745    }
746
747    /// Returns `true` when no label is set.
748    #[must_use]
749    pub fn is_empty(&self) -> bool {
750        self.0.is_empty()
751    }
752}
753
754/// One model call (spec §20.1).
755///
756/// Build it with [`ModelRequest::new`] and the `with_*` methods; every field is
757/// public so an adapter can read it without ceremony.
758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
759#[serde(deny_unknown_fields)]
760pub struct ModelRequest {
761    /// Stable id of the logical call, unchanged across retries and fallbacks
762    /// (spec §20.7).
763    pub request_id: RequestId,
764    /// Why the call is being made. Decides capability requirements and logging
765    /// policy; adapters do not branch on it.
766    pub purpose: ModelPurpose,
767    /// The conversation, oldest first.
768    pub messages: Vec<Message>,
769    /// Framing instructions, hoisted out of `messages` because most providers
770    /// carry them in a dedicated field.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub system: Option<String>,
773    /// The shape of the answer.
774    pub output: OutputSpec,
775    /// Read-only tools the model may call.
776    #[serde(default, skip_serializing_if = "Vec::is_empty")]
777    pub tools: Vec<ToolSpec>,
778    /// How free the model is to call them.
779    #[serde(default)]
780    pub tool_choice: ToolChoice,
781    /// Cap on generated tokens.
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    pub max_output_tokens: Option<u32>,
784    /// Sampling temperature. `None` leaves the provider's default in place.
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub temperature: Option<f32>,
787    /// How much a reasoning model may think. `None` leaves the provider's default.
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub reasoning_effort: Option<ReasoningEffort>,
790    /// Sampling seed, for providers that honour one.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub seed: Option<u64>,
793    /// Stop sequences.
794    #[serde(default, skip_serializing_if = "Vec::is_empty")]
795    pub stop: Vec<String>,
796    /// Non-personal labels.
797    #[serde(default, skip_serializing_if = "RequestMetadata::is_empty")]
798    pub metadata: RequestMetadata,
799    /// Deadline for the whole call, including reading a streamed body.
800    pub timeout: Duration,
801    /// Prompt-caching hint.
802    #[serde(default)]
803    pub cache_hint: CacheHint,
804}
805
806impl ModelRequest {
807    /// A free-text request for `purpose` with a fresh
808    /// [`RequestId`] and the [`DEFAULT_TIMEOUT`].
809    #[must_use]
810    pub fn new(purpose: ModelPurpose) -> Self {
811        Self {
812            request_id: RequestId::new(),
813            purpose,
814            messages: Vec::new(),
815            system: None,
816            output: OutputSpec::FreeText,
817            tools: Vec::new(),
818            tool_choice: ToolChoice::Auto,
819            max_output_tokens: None,
820            temperature: None,
821            reasoning_effort: None,
822            seed: None,
823            stop: Vec::new(),
824            metadata: RequestMetadata::new(),
825            timeout: DEFAULT_TIMEOUT,
826            cache_hint: CacheHint::None,
827        }
828    }
829
830    /// Pins the request id, so a retry or a fallback reuses it.
831    #[must_use]
832    pub fn with_request_id(mut self, request_id: RequestId) -> Self {
833        self.request_id = request_id;
834        self
835    }
836
837    /// Sets the system prompt.
838    #[must_use]
839    pub fn with_system(mut self, system: impl Into<String>) -> Self {
840        self.system = Some(system.into());
841        self
842    }
843
844    /// Appends one message.
845    #[must_use]
846    pub fn with_message(mut self, message: Message) -> Self {
847        self.messages.push(message);
848        self
849    }
850
851    /// Replaces the conversation.
852    #[must_use]
853    pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
854        self.messages = messages;
855        self
856    }
857
858    /// Sets the expected output shape.
859    #[must_use]
860    pub fn with_output(mut self, output: OutputSpec) -> Self {
861        self.output = output;
862        self
863    }
864
865    /// Declares the read-only tools.
866    #[must_use]
867    pub fn with_tools(mut self, tools: Vec<ToolSpec>) -> Self {
868        self.tools = tools;
869        self
870    }
871
872    /// Sets the tool-choice mode.
873    #[must_use]
874    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
875        self.tool_choice = tool_choice;
876        self
877    }
878
879    /// Caps generated tokens.
880    #[must_use]
881    pub fn with_max_output_tokens(mut self, tokens: u32) -> Self {
882        self.max_output_tokens = Some(tokens);
883        self
884    }
885
886    /// Sets the sampling temperature.
887    #[must_use]
888    pub fn with_temperature(mut self, temperature: f32) -> Self {
889        self.temperature = Some(temperature);
890        self
891    }
892
893    /// The sampling parameters `capabilities` accepts, with the rest named as dropped.
894    #[must_use]
895    pub fn sampling_for(
896        &self,
897        capabilities: &crate::capabilities::ProviderCapabilities,
898    ) -> Sampling {
899        let mut dropped = Vec::new();
900        let mut accept = |accepted: bool, name: &'static str| {
901            if !accepted {
902                dropped.push(name);
903            }
904            accepted
905        };
906        let temperature = self
907            .temperature
908            .filter(|_| accept(capabilities.temperature, "temperature"));
909        let seed = self.seed.filter(|_| accept(capabilities.seed, "seed"));
910        let reasoning_effort = self
911            .reasoning_effort
912            .filter(|_| accept(capabilities.reasoning_controls, "reasoning_effort"));
913        Sampling {
914            temperature,
915            seed,
916            reasoning_effort,
917            dropped,
918        }
919    }
920
921    /// Sets how much a reasoning model may think.
922    #[must_use]
923    pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
924        self.reasoning_effort = Some(effort);
925        self
926    }
927
928    /// Sets the sampling seed.
929    #[must_use]
930    pub fn with_seed(mut self, seed: u64) -> Self {
931        self.seed = Some(seed);
932        self
933    }
934
935    /// Sets the stop sequences.
936    #[must_use]
937    pub fn with_stop(mut self, stop: Vec<String>) -> Self {
938        self.stop = stop;
939        self
940    }
941
942    /// Sets the deadline.
943    #[must_use]
944    pub fn with_timeout(mut self, timeout: Duration) -> Self {
945        self.timeout = timeout;
946        self
947    }
948
949    /// Sets the caching hint.
950    #[must_use]
951    pub fn with_cache_hint(mut self, cache_hint: CacheHint) -> Self {
952        self.cache_hint = cache_hint;
953        self
954    }
955
956    /// Adds a metadata label.
957    ///
958    /// # Errors
959    ///
960    /// Returns [`MetadataError`] when the value is not label-shaped.
961    pub fn with_metadata(
962        mut self,
963        key: impl Into<String>,
964        value: impl Into<String>,
965    ) -> Result<Self, MetadataError> {
966        self.metadata.insert(key, value)?;
967        Ok(self)
968    }
969
970    /// The capability requirements this request implies on its own, before the
971    /// purpose's own requirements are folded in.
972    ///
973    /// It asks for tools when tools are declared, vision when any message
974    /// carries an image, document input when any message carries a document,
975    /// and the structured-output transports the purpose accepts when
976    /// [`OutputSpec::Json`] is requested.
977    #[must_use]
978    pub fn requirements(&self) -> crate::capabilities::CapabilityRequirements {
979        let mut requirements = self.purpose.requirements();
980        if !self.tools.is_empty() || matches!(self.output, OutputSpec::ToolCalls) {
981            requirements.needs_tools = true;
982        }
983        if self.messages.iter().any(Message::has_image) {
984            requirements.needs_vision = true;
985        }
986        if self.messages.iter().any(Message::has_document) {
987            requirements.needs_documents = true;
988        }
989        requirements
990    }
991}
992
993#[cfg(test)]
994mod tests {
995    #[test]
996    fn sampling_keeps_what_the_profile_takes_and_names_the_rest() {
997        use crate::capabilities::ProviderCapabilities;
998        let request = ModelRequest::new(ModelPurpose::Extract)
999            .with_temperature(0.0)
1000            .with_seed(7)
1001            .with_reasoning_effort(ReasoningEffort::Minimal);
1002        let reasoning_model = ProviderCapabilities::minimal().with_reasoning_controls(true);
1003        let sampling = request.sampling_for(&reasoning_model);
1004        assert_eq!(sampling.temperature, None);
1005        assert_eq!(sampling.seed, None);
1006        assert_eq!(sampling.reasoning_effort, Some(ReasoningEffort::Minimal));
1007        assert_eq!(sampling.dropped, vec!["temperature", "seed"]);
1008
1009        let chat_model = ProviderCapabilities::minimal()
1010            .with_temperature(true)
1011            .with_seed(true);
1012        let sampling = request.sampling_for(&chat_model);
1013        assert_eq!(sampling.temperature, Some(0.0));
1014        assert_eq!(sampling.seed, Some(7));
1015        assert_eq!(sampling.dropped, vec!["reasoning_effort"]);
1016
1017        let nothing_set = ModelRequest::new(ModelPurpose::Extract).sampling_for(&chat_model);
1018        assert!(
1019            nothing_set.dropped.is_empty(),
1020            "an unset parameter is never dropped"
1021        );
1022    }
1023
1024    use super::*;
1025    use serde_json::json;
1026
1027    #[test]
1028    fn builder_defaults_are_conservative() {
1029        let request = ModelRequest::new(ModelPurpose::Extract);
1030        assert_eq!(request.timeout, DEFAULT_TIMEOUT);
1031        assert_eq!(request.tool_choice, ToolChoice::Auto);
1032        assert_eq!(request.cache_hint, CacheHint::None);
1033        assert!(request.temperature.is_none());
1034        assert!(request.metadata.is_empty());
1035        assert_eq!(request.output.as_str(), "free_text");
1036    }
1037
1038    #[test]
1039    fn request_id_is_stable_when_pinned() {
1040        let id = RequestId::nil();
1041        let request = ModelRequest::new(ModelPurpose::Acknowledge).with_request_id(id);
1042        let retried = request.clone().with_temperature(0.7);
1043        assert_eq!(request.request_id, retried.request_id);
1044    }
1045
1046    #[test]
1047    fn metadata_rejects_anything_that_is_not_a_label() {
1048        let mut metadata = RequestMetadata::new();
1049        metadata.insert("turn", "0192f0aa-1b2c-7def").unwrap();
1050        assert_eq!(metadata.get("turn"), Some("0192f0aa-1b2c-7def"));
1051        assert_eq!(metadata.len(), 1);
1052
1053        assert!(matches!(
1054            metadata.insert("", "x"),
1055            Err(MetadataError::EmptyKey)
1056        ));
1057        assert!(matches!(
1058            metadata.insert("note", "hello world"),
1059            Err(MetadataError::ValueNotALabel { .. })
1060        ));
1061        assert!(matches!(
1062            metadata.insert("note", "x".repeat(MAX_METADATA_VALUE_LEN + 1)),
1063            Err(MetadataError::ValueTooLong { .. })
1064        ));
1065        assert!(matches!(
1066            metadata.insert("note", "line\nbreak"),
1067            Err(MetadataError::ValueNotALabel { .. })
1068        ));
1069        // Nothing invalid was stored.
1070        assert_eq!(metadata.len(), 1);
1071        let collected: Vec<_> = metadata.iter().collect();
1072        assert_eq!(collected, vec![("turn", "0192f0aa-1b2c-7def")]);
1073    }
1074
1075    #[test]
1076    fn requirements_follow_the_content_of_the_request() {
1077        let plain = ModelRequest::new(ModelPurpose::Acknowledge);
1078        let requirements = plain.requirements();
1079        assert!(!requirements.needs_tools);
1080        assert!(!requirements.needs_vision);
1081
1082        let with_tools = ModelRequest::new(ModelPurpose::Investigate)
1083            .with_tools(vec![ToolSpec::new("case.get", "load a case", json!({}))])
1084            .with_message(
1085                Message::user("look at this").with_part(ContentPart::image_url("https://x.test/a")),
1086            );
1087        let requirements = with_tools.requirements();
1088        assert!(requirements.needs_tools);
1089        assert!(requirements.needs_vision);
1090        assert!(!requirements.structured_output.is_empty());
1091    }
1092
1093    #[test]
1094    fn messages_flatten_their_text_parts() {
1095        let message = Message::user("hello ").with_part(ContentPart::text("world"));
1096        assert_eq!(message.text(), "hello world");
1097        assert!(!message.has_image());
1098        assert_eq!(message.role, Role::User);
1099        assert_eq!(message.content[0].kind(), "text");
1100    }
1101
1102    #[test]
1103    fn a_document_states_its_media_type_on_both_forms() {
1104        let inline = ContentPart::document_base64("application/pdf", "JVBERi0=");
1105        let referenced = ContentPart::document_url("https://x.test/a", "application/pdf");
1106        for part in [&inline, &referenced] {
1107            assert_eq!(part.kind(), "document");
1108            assert_eq!(
1109                part.as_document().map(DocumentSource::media_type),
1110                Some("application/pdf"),
1111                "a document never leaves its media type to be guessed"
1112            );
1113            assert!(part.as_text().is_none());
1114        }
1115
1116        let message = Message::new(Role::User, vec![inline]);
1117        assert!(message.has_document());
1118        assert!(!message.has_image(), "a document is not an image");
1119
1120        // And the requirement follows the content, so a profile that accepts
1121        // images but not PDFs is refused rather than tried.
1122        let request = ModelRequest::new(ModelPurpose::Acknowledge).with_message(message);
1123        let requirements = request.requirements();
1124        assert!(requirements.needs_documents);
1125        assert!(!requirements.needs_vision);
1126    }
1127
1128    #[test]
1129    fn output_spec_exposes_its_schema() {
1130        let spec = OutputSpec::json("user_turn_plan", json!({"type": "object"}));
1131        assert!(spec.is_structured());
1132        assert_eq!(spec.schema(), Some(&json!({"type": "object"})));
1133        assert!(matches!(spec, OutputSpec::Json { strict: true, .. }));
1134        assert_eq!(OutputSpec::FreeText.schema(), None);
1135    }
1136
1137    #[test]
1138    fn requests_round_trip_through_serde() {
1139        let request = ModelRequest::new(ModelPurpose::Extract)
1140            .with_request_id(RequestId::nil())
1141            .with_system("be precise")
1142            .with_message(Message::user("ciao"))
1143            .with_message(Message::tool_result(ToolResult::ok("call_1", "{}")))
1144            .with_output(OutputSpec::json("plan", json!({"type": "object"})))
1145            .with_tools(vec![ToolSpec::new("case.get", "load", json!({}))])
1146            .with_tool_choice(ToolChoice::named("case.get"))
1147            .with_cache_hint(CacheHint::Prefix { messages: 1 })
1148            .with_metadata("workflow", "trip")
1149            .unwrap();
1150        let json = serde_json::to_string(&request).unwrap();
1151        let back: ModelRequest = serde_json::from_str(&json).unwrap();
1152        assert_eq!(back, request);
1153        assert!(!json.contains("\"stop\""), "empty vectors are skipped");
1154    }
1155
1156    #[test]
1157    fn unknown_fields_are_rejected() {
1158        let json = json!({
1159            "request_id": "00000000-0000-0000-0000-000000000000",
1160            "purpose": "acknowledge",
1161            "messages": [],
1162            "output": {"kind": "free_text"},
1163            "timeout": {"secs": 1, "nanos": 0},
1164            "surprise": true
1165        });
1166        assert!(serde_json::from_value::<ModelRequest>(json).is_err());
1167    }
1168
1169    #[test]
1170    fn tool_calls_and_results_pair_by_id() {
1171        let call = ToolCall::new("call_7", "case.get", json!({"id": "c1"}));
1172        let result = ToolResult::ok(call.id.clone(), "{\"ok\":true}");
1173        assert_eq!(result.call_id, call.id);
1174        assert!(!result.is_error);
1175        assert!(ToolResult::error("call_7", "boom").is_error);
1176        let part = ContentPart::ToolCall(call);
1177        assert_eq!(part.kind(), "tool_call");
1178        assert!(part.as_tool_call().is_some());
1179        assert!(part.as_text().is_none());
1180    }
1181}