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