Skip to main content

mentra_provider/
request.rs

1use serde::Deserialize;
2use serde::Serialize;
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5
6use crate::ContentBlock;
7use crate::Message;
8use crate::ProviderError;
9use crate::model::ToolChoice;
10use crate::tool::ToolSpec;
11
12/// Provider-neutral reasoning controls supported across multiple providers.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ReasoningOptions {
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub effort: Option<ReasoningEffort>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub summary: Option<ReasoningSummary>,
19}
20
21/// Shared reasoning effort levels supported by Mentra's public API.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum ReasoningEffort {
26    Low,
27    Medium,
28    High,
29    #[serde(rename = "xhigh")]
30    XHigh,
31    Max,
32}
33
34/// Shared reasoning summary levels used by Responses-family providers.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ReasoningSummary {
38    Auto,
39    Concise,
40    Detailed,
41}
42
43/// Provider-neutral tool search behavior requested for a model call.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[serde(rename_all = "snake_case")]
46pub enum ToolSearchMode {
47    #[default]
48    Disabled,
49    Hosted,
50}
51
52/// Responses-compatible verbosity controls for text output.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "lowercase")]
55pub enum ResponsesVerbosity {
56    Low,
57    #[default]
58    Medium,
59    High,
60}
61
62/// Transport-level request compression supported by Responses-family HTTP calls.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
64#[serde(rename_all = "lowercase")]
65pub enum ResponsesRequestCompression {
66    #[default]
67    None,
68    Zstd,
69}
70
71/// Provider-side conversation state strategy for Responses-family providers.
72///
73/// The default is [`ReplayOnly`](Self::ReplayOnly), because a request built by
74/// this crate carries its whole transcript in `input` and nothing here removes
75/// the items a chain would repeat. Both modes that chain therefore *add* the
76/// chained response's context to a request that already states it, and the
77/// chain head is a property of the session scope rather than of the
78/// conversation: a caller that shares one scope between conversations chains
79/// each request from whichever one answered last. A caller states
80/// [`Hybrid`](Self::Hybrid) or [`Stateful`](Self::Stateful) when it knows both
81/// — that it sends only what the chain does not already hold, and that its
82/// scope follows the conversation.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
84#[serde(rename_all = "snake_case")]
85pub enum ResponsesStateMode {
86    /// Send the complete local transcript and do not attach provider-side state.
87    #[default]
88    ReplayOnly,
89    /// Keep local replay as the source of truth while opportunistically chaining provider state.
90    ///
91    /// An unknown HTTP endpoint may receive one capability probe before Hybrid
92    /// learns that `previous_response_id` is unsupported. Hosts that already
93    /// know this can disable the probe on [`crate::responses::ResponsesProvider`].
94    Hybrid,
95    /// Require provider-side state chaining once a previous response id is available.
96    Stateful,
97}
98
99impl ResponsesStateMode {
100    pub fn uses_provider_state(self) -> bool {
101        matches!(self, Self::Hybrid | Self::Stateful)
102    }
103}
104
105/// Streaming transport used by Responses-family providers.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
107#[serde(rename_all = "snake_case")]
108pub enum ResponsesTransport {
109    /// Standard HTTP request with Server-Sent Events response streaming.
110    #[default]
111    HttpSse,
112    /// Long-lived WebSocket connection driven by `response.create` frames.
113    WebSocket,
114}
115
116/// Responses-compatible format discriminator for structured text output.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
118#[serde(rename_all = "snake_case")]
119pub enum ResponsesTextFormatType {
120    #[default]
121    JsonSchema,
122}
123
124/// Structured text output format controls for Responses-family providers.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
126pub struct ResponsesTextFormat {
127    #[serde(default)]
128    pub r#type: ResponsesTextFormatType,
129    #[serde(default)]
130    pub strict: bool,
131    pub schema: serde_json::Value,
132    pub name: String,
133}
134
135/// Responses-compatible text controls combining verbosity and output schemas.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
137pub struct ResponsesTextControls {
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub verbosity: Option<ResponsesVerbosity>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub format: Option<ResponsesTextFormat>,
142}
143
144/// Shared Responses-family request options.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct ResponsesRequestOptions {
147    #[serde(default)]
148    pub parallel_tool_calls: Option<bool>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub previous_response_id: Option<String>,
151    #[serde(default)]
152    pub state_mode: ResponsesStateMode,
153    #[serde(default)]
154    pub transport: ResponsesTransport,
155    #[serde(default)]
156    pub store: Option<bool>,
157    #[serde(default)]
158    pub stream: Option<bool>,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub include: Vec<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub service_tier: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub prompt_cache_key: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub text: Option<ResponsesTextControls>,
167    #[serde(default)]
168    pub compression: ResponsesRequestCompression,
169}
170
171impl Default for ResponsesRequestOptions {
172    fn default() -> Self {
173        Self {
174            parallel_tool_calls: None,
175            previous_response_id: None,
176            state_mode: ResponsesStateMode::ReplayOnly,
177            transport: ResponsesTransport::HttpSse,
178            store: None,
179            stream: Some(true),
180            include: Vec::new(),
181            service_tier: None,
182            prompt_cache_key: None,
183            text: None,
184            compression: ResponsesRequestCompression::None,
185        }
186    }
187}
188
189/// Anthropic-specific request options.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
191pub struct AnthropicRequestOptions {
192    #[serde(default)]
193    pub disable_parallel_tool_use: Option<bool>,
194}
195
196/// Gemini-specific request options.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
198pub struct GeminiRequestOptions {
199    #[serde(default)]
200    pub thoughts: Option<bool>,
201}
202
203/// Provider-neutral session metadata and affinity hints.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
205pub struct SessionRequestOptions {
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub sticky_turn_state: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub turn_metadata: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub subagent: Option<String>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub prefer_connection_reuse: Option<bool>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub session_affinity: Option<String>,
216    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
217    pub extra_headers: BTreeMap<String, String>,
218}
219
220/// Provider-specific request options that should be forwarded on the wire.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
222pub struct ProviderRequestOptions {
223    #[serde(default)]
224    pub tool_search_mode: ToolSearchMode,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub reasoning: Option<ReasoningOptions>,
227    #[serde(default)]
228    pub responses: ResponsesRequestOptions,
229    #[serde(default)]
230    pub anthropic: AnthropicRequestOptions,
231    #[serde(default)]
232    pub gemini: GeminiRequestOptions,
233    #[serde(default)]
234    pub session: SessionRequestOptions,
235}
236
237/// Provider request assembled by the runtime before dispatch.
238#[derive(Debug, Clone)]
239pub struct Request<'a> {
240    pub model: Cow<'a, str>,
241    pub system: Option<Cow<'a, str>>,
242    pub messages: Cow<'a, [Message]>,
243    pub tools: Cow<'a, [ToolSpec]>,
244    pub tool_choice: Option<ToolChoice>,
245    pub temperature: Option<f32>,
246    pub max_output_tokens: Option<u32>,
247    pub metadata: Cow<'a, BTreeMap<String, String>>,
248    pub provider_request_options: ProviderRequestOptions,
249}
250
251impl Request<'_> {
252    pub fn into_owned(self) -> Request<'static> {
253        Request {
254            model: Cow::Owned(self.model.into_owned()),
255            system: self.system.map(|system| Cow::Owned(system.into_owned())),
256            messages: Cow::Owned(self.messages.into_owned()),
257            tools: Cow::Owned(self.tools.into_owned()),
258            tool_choice: self.tool_choice,
259            temperature: self.temperature,
260            max_output_tokens: self.max_output_tokens,
261            metadata: Cow::Owned(self.metadata.into_owned()),
262            provider_request_options: self.provider_request_options,
263        }
264    }
265}
266
267/// Provider-neutral transcript item used for history compaction.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(tag = "type", rename_all = "snake_case")]
270pub enum CompactionInputItem {
271    UserTurn {
272        content: String,
273    },
274    AssistantTurn {
275        content: String,
276    },
277    ToolExchange {
278        #[serde(default, skip_serializing_if = "Option::is_none")]
279        request: Option<String>,
280        result: String,
281        is_error: bool,
282    },
283    CanonicalContext {
284        content: String,
285    },
286    MemoryRecall {
287        content: String,
288    },
289    DelegationResult {
290        agent_id: String,
291        agent_name: String,
292        #[serde(default, skip_serializing_if = "Option::is_none")]
293        role: Option<String>,
294        status: String,
295        content: String,
296    },
297    CompactionSummary {
298        content: String,
299    },
300}
301
302/// Provider-neutral request assembled for history compaction.
303#[derive(Debug, Clone)]
304pub struct CompactionRequest<'a> {
305    pub model: Cow<'a, str>,
306    pub instructions: Cow<'a, str>,
307    pub input: Cow<'a, [CompactionInputItem]>,
308    pub metadata: Cow<'a, BTreeMap<String, String>>,
309    pub provider_request_options: ProviderRequestOptions,
310}
311
312impl CompactionRequest<'_> {
313    /// Converts a compaction request into an ordinary model request.
314    pub fn into_model_request(self) -> Result<Request<'static>, ProviderError> {
315        let input_json =
316            serde_json::to_string(self.input.as_ref()).map_err(ProviderError::Serialize)?;
317
318        Ok(Request {
319            model: Cow::Owned(self.model.into_owned()),
320            system: Some(Cow::Owned(self.instructions.into_owned())),
321            messages: Cow::Owned(vec![Message::user(ContentBlock::text(format!(
322                "Compaction input JSON:\n{input_json}"
323            )))]),
324            tools: Cow::Owned(Vec::new()),
325            tool_choice: None,
326            temperature: None,
327            max_output_tokens: None,
328            metadata: Cow::Owned(self.metadata.into_owned()),
329            provider_request_options: self.provider_request_options,
330        })
331    }
332}
333
334/// Canonical raw memory payload used by memory summarization requests.
335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
336pub struct RawMemory {
337    pub id: String,
338    pub metadata: RawMemoryMetadata,
339    pub items: Vec<serde_json::Value>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct RawMemoryMetadata {
344    pub source_path: String,
345}
346
347/// Provider-neutral request assembled for trace memory summarization.
348#[derive(Debug, Clone)]
349pub struct MemorySummarizeRequest<'a> {
350    pub model: Cow<'a, str>,
351    pub raw_memories: Cow<'a, [RawMemory]>,
352    pub reasoning: Option<ReasoningOptions>,
353    pub metadata: Cow<'a, BTreeMap<String, String>>,
354    pub provider_request_options: ProviderRequestOptions,
355}
356
357impl MemorySummarizeRequest<'_> {
358    /// Converts a memory summarize request into an ordinary model request.
359    pub fn into_model_request(self) -> Result<Request<'static>, ProviderError> {
360        let raw_memories_json =
361            serde_json::to_string(self.raw_memories.as_ref()).map_err(ProviderError::Serialize)?;
362
363        Ok(Request {
364            model: Cow::Owned(self.model.into_owned()),
365            system: Some(Cow::Borrowed(MEMORY_SUMMARIZE_SYSTEM_PROMPT)),
366            messages: Cow::Owned(vec![Message::user(ContentBlock::text(format!(
367                "Memory summarize input JSON:\n{raw_memories_json}"
368            )))]),
369            tools: Cow::Owned(Vec::new()),
370            tool_choice: None,
371            temperature: None,
372            max_output_tokens: None,
373            metadata: Cow::Owned(self.metadata.into_owned()),
374            provider_request_options: ProviderRequestOptions {
375                reasoning: self.reasoning,
376                ..self.provider_request_options
377            },
378        })
379    }
380}
381
382const MEMORY_SUMMARIZE_SYSTEM_PROMPT: &str = concat!(
383    "You summarize trace memories for Codex.\n",
384    "Return valid JSON only.\n",
385    "The output must be a JSON array with one object per input trace, in the same order.\n",
386    "Each object must have exactly these string fields: `raw_memory` and `memory_summary`.\n",
387    "`raw_memory` should be a concrete, detailed summary of the trace contents.\n",
388    "`memory_summary` should be a shorter durable takeaway focused on reusable context.\n",
389    "Use empty strings when information is unavailable.\n",
390    "Do not include markdown fences or extra commentary.\n",
391);
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::Value;
397
398    #[test]
399    fn reasoning_effort_uses_the_five_public_spellings() {
400        for (effort, expected) in [
401            (ReasoningEffort::Low, "low"),
402            (ReasoningEffort::Medium, "medium"),
403            (ReasoningEffort::High, "high"),
404            (ReasoningEffort::XHigh, "xhigh"),
405            (ReasoningEffort::Max, "max"),
406        ] {
407            let serialized = serde_json::to_string(&effort).expect("effort should serialize");
408            assert_eq!(serialized, format!("\"{expected}\""));
409            assert_eq!(
410                serde_json::from_str::<ReasoningEffort>(&serialized)
411                    .expect("effort should deserialize"),
412                effort
413            );
414        }
415    }
416
417    #[test]
418    fn compaction_request_into_model_request_serializes_input_as_prompt_text() {
419        let request = CompactionRequest {
420            model: Cow::Borrowed("gpt-5"),
421            instructions: Cow::Borrowed("Summarize the transcript."),
422            input: Cow::Owned(vec![
423                CompactionInputItem::UserTurn {
424                    content: "hello".to_string(),
425                },
426                CompactionInputItem::AssistantTurn {
427                    content: "world".to_string(),
428                },
429            ]),
430            metadata: Cow::Owned(BTreeMap::from([("scope".to_string(), "test".to_string())])),
431            provider_request_options: ProviderRequestOptions {
432                session: SessionRequestOptions {
433                    sticky_turn_state: Some("sticky".to_string()),
434                    turn_metadata: None,
435                    subagent: Some("compact".to_string()),
436                    prefer_connection_reuse: Some(true),
437                    session_affinity: None,
438                    extra_headers: BTreeMap::new(),
439                },
440                ..ProviderRequestOptions::default()
441            },
442        };
443
444        let model_request = request
445            .into_model_request()
446            .expect("compaction request should convert");
447
448        assert_eq!(model_request.model.as_ref(), "gpt-5");
449        assert_eq!(
450            model_request.system.as_deref(),
451            Some("Summarize the transcript.")
452        );
453        assert_eq!(model_request.metadata["scope"], "test");
454        assert_eq!(
455            model_request
456                .provider_request_options
457                .session
458                .sticky_turn_state
459                .as_deref(),
460            Some("sticky")
461        );
462        assert_eq!(
463            model_request
464                .provider_request_options
465                .session
466                .subagent
467                .as_deref(),
468            Some("compact")
469        );
470        assert_eq!(model_request.messages.len(), 1);
471
472        let prompt = model_request.messages[0].text();
473        assert!(prompt.starts_with("Compaction input JSON:\n"));
474        let payload = prompt
475            .strip_prefix("Compaction input JSON:\n")
476            .expect("prompt should contain the compaction prefix");
477        let input: Vec<Value> = serde_json::from_str(payload).expect("prompt should be json");
478        assert_eq!(input[0]["type"], "user_turn");
479        assert_eq!(input[0]["content"], "hello");
480        assert_eq!(input[1]["type"], "assistant_turn");
481        assert_eq!(input[1]["content"], "world");
482    }
483
484    #[test]
485    fn memory_summarize_request_into_model_request_serializes_input_as_prompt_text() {
486        let request = MemorySummarizeRequest {
487            model: Cow::Borrowed("gpt-5"),
488            raw_memories: Cow::Owned(vec![RawMemory {
489                id: "memory-1".to_string(),
490                metadata: RawMemoryMetadata {
491                    source_path: "/tmp/trace.jsonl".to_string(),
492                },
493                items: vec![serde_json::json!({"type":"message","role":"user"})],
494            }]),
495            reasoning: Some(ReasoningOptions {
496                effort: Some(ReasoningEffort::Medium),
497                summary: None,
498            }),
499            metadata: Cow::Owned(BTreeMap::from([("scope".to_string(), "test".to_string())])),
500            provider_request_options: ProviderRequestOptions {
501                session: SessionRequestOptions {
502                    sticky_turn_state: None,
503                    turn_metadata: Some("{\"turn_id\":\"t1\"}".to_string()),
504                    subagent: None,
505                    prefer_connection_reuse: Some(true),
506                    session_affinity: Some("thread-1".to_string()),
507                    extra_headers: BTreeMap::new(),
508                },
509                ..ProviderRequestOptions::default()
510            },
511        };
512
513        let model_request = request
514            .into_model_request()
515            .expect("memory summarize request should convert");
516
517        assert_eq!(model_request.model.as_ref(), "gpt-5");
518        assert_eq!(
519            model_request.system.as_deref(),
520            Some(MEMORY_SUMMARIZE_SYSTEM_PROMPT)
521        );
522        assert_eq!(model_request.metadata["scope"], "test");
523        assert_eq!(
524            model_request
525                .provider_request_options
526                .session
527                .turn_metadata
528                .as_deref(),
529            Some("{\"turn_id\":\"t1\"}")
530        );
531        assert_eq!(
532            model_request
533                .provider_request_options
534                .reasoning
535                .as_ref()
536                .expect("reasoning options")
537                .effort,
538            Some(ReasoningEffort::Medium)
539        );
540        assert_eq!(model_request.messages.len(), 1);
541
542        let prompt = model_request.messages[0].text();
543        assert!(prompt.starts_with("Memory summarize input JSON:\n"));
544        let payload = prompt
545            .strip_prefix("Memory summarize input JSON:\n")
546            .expect("prompt should contain the memory summarize prefix");
547        let input: Vec<RawMemory> = serde_json::from_str(payload).expect("prompt should be json");
548        assert_eq!(input[0].id, "memory-1");
549        assert_eq!(input[0].metadata.source_path, "/tmp/trace.jsonl");
550        assert_eq!(input[0].items[0]["role"], "user");
551    }
552}