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