Skip to main content

codewhale_core/
request.rs

1//! Provider-neutral outbound model-request boundary.
2//!
3//! The request DTOs in this module are consumed by the TUI transport today
4//! and are intentionally free of terminal, HTTP, or provider-client state.
5//! Keeping the logical request in `codewhale-core` lets a headless session
6//! prepare the same serializable value before the existing TUI client applies
7//! provider-specific wire shaping.
8
9use serde::{Deserialize, Serialize};
10
11/// Request payload handed to the model-client preparation seam.
12#[derive(Debug, Serialize, Deserialize, Clone)]
13pub struct MessageRequest {
14    pub model: String,
15    pub messages: Vec<Message>,
16    pub max_tokens: u32,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub system: Option<SystemPrompt>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub tools: Option<Vec<Tool>>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub tool_choice: Option<serde_json::Value>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub metadata: Option<serde_json::Value>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub thinking: Option<serde_json::Value>,
27    /// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max".
28    /// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub reasoning_effort: Option<String>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub stream: Option<bool>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub temperature: Option<f32>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub top_p: Option<f32>,
37}
38
39/// Inputs that distinguish a primary agent-turn request.
40///
41/// Provider-neutral defaults (`stream = true`, no metadata, no provider-side
42/// thinking object, and no sampling overrides) are applied once by
43/// [`prepare_primary_turn_request`]. Both the production turn loop and its
44/// read-only preview use this input so those defaults cannot drift.
45#[derive(Debug, Clone)]
46pub struct PrimaryTurnRequest {
47    pub model: String,
48    pub messages: Vec<Message>,
49    pub max_tokens: u32,
50    pub system: Option<SystemPrompt>,
51    pub tools: Option<Vec<Tool>>,
52    pub tool_choice: Option<serde_json::Value>,
53    pub reasoning_effort: Option<String>,
54}
55
56/// Prepare the provider-neutral request for a primary agent turn.
57///
58/// This function performs no I/O and no provider-specific transformation.
59/// The existing client transport remains responsible for secret redaction,
60/// protocol binding, dialect shaping, and endpoint selection.
61#[must_use]
62pub fn prepare_primary_turn_request(input: PrimaryTurnRequest) -> MessageRequest {
63    MessageRequest {
64        model: input.model,
65        messages: input.messages,
66        max_tokens: input.max_tokens,
67        system: input.system,
68        tools: input.tools,
69        tool_choice: input.tool_choice,
70        metadata: None,
71        thinking: None,
72        reasoning_effort: input.reasoning_effort,
73        stream: Some(true),
74        temperature: None,
75        top_p: None,
76    }
77}
78
79/// System prompt representation (plain text or structured blocks).
80#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
81#[serde(untagged)]
82pub enum SystemPrompt {
83    Text(String),
84    Blocks(Vec<SystemBlock>),
85}
86
87/// A structured system prompt block.
88#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
89pub struct SystemBlock {
90    #[serde(rename = "type")]
91    pub block_type: String,
92    pub text: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub cache_control: Option<CacheControl>,
95}
96
97/// OpenAI-compatible image URL payload inside a multimodal message.
98#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
99pub struct ImageUrlContent {
100    pub url: String,
101}
102
103/// A chat message with role and content blocks.
104#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
105pub struct Message {
106    pub role: String,
107    pub content: Vec<ContentBlock>,
108}
109
110/// Internal role used for assistant text that was visible before a turn was interrupted.
111pub const INTERRUPTED_ASSISTANT_ROLE: &str = "assistant_interrupted";
112/// Prefix attached to interrupted assistant output when it is replayed as context.
113pub const INTERRUPTED_ASSISTANT_CONTEXT_PREFIX: &str = "[The following assistant output was interrupted before completion and may be incomplete or wrong]\n";
114
115/// Provider-owned reasoning continuity that is safe to replay only on the
116/// exact originating API and model. The encrypted payload is deliberately
117/// separate from readable [`ContentBlock::Thinking`] text.
118#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
119pub struct OpaqueReasoningState {
120    pub provider: String,
121    pub api: String,
122    pub model: String,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub id: Option<String>,
125    pub encrypted_content: String,
126}
127
128/// A single content block inside a message.
129#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
130#[serde(tag = "type")]
131pub enum ContentBlock {
132    #[serde(rename = "text")]
133    Text {
134        text: String,
135        #[serde(skip_serializing_if = "Option::is_none")]
136        cache_control: Option<CacheControl>,
137    },
138    #[serde(rename = "image_url")]
139    ImageUrl { image_url: ImageUrlContent },
140    #[serde(rename = "thinking")]
141    Thinking {
142        thinking: String,
143        /// Anthropic signed-thinking signature (#3014). Only populated on the
144        /// native Messages dialect and serde-skipped when absent so OpenAI
145        /// dialects are unaffected. Anthropic rejects tool loops that drop or
146        /// modify signed thinking blocks, so replay this verbatim.
147        #[serde(skip_serializing_if = "Option::is_none", default)]
148        signature: Option<String>,
149        /// Opaque Responses-style continuity. Never synthesize this from the
150        /// readable `thinking` text or carry it across a route/model switch.
151        #[serde(skip_serializing_if = "Option::is_none", default)]
152        state: Option<OpaqueReasoningState>,
153    },
154    #[serde(rename = "tool_use")]
155    ToolUse {
156        id: String,
157        name: String,
158        input: serde_json::Value,
159        #[serde(skip_serializing_if = "Option::is_none")]
160        caller: Option<ToolCaller>,
161        /// Google thought signature captured from the OpenAI-compat route's
162        /// `extra_content.google.thought_signature` on the tool call. Google
163        /// requires replaying it with the tool result for thinking models;
164        /// skipped on the wire and in storage for every other provider.
165        #[serde(skip_serializing_if = "Option::is_none", default)]
166        thought_signature: Option<String>,
167    },
168    #[serde(rename = "tool_result")]
169    ToolResult {
170        tool_use_id: String,
171        content: String,
172        #[serde(skip_serializing_if = "Option::is_none")]
173        is_error: Option<bool>,
174        #[serde(skip_serializing_if = "Option::is_none")]
175        content_blocks: Option<Vec<serde_json::Value>>,
176    },
177    #[serde(rename = "server_tool_use")]
178    ServerToolUse {
179        id: String,
180        name: String,
181        input: serde_json::Value,
182    },
183    #[serde(rename = "tool_search_tool_result")]
184    ToolSearchToolResult {
185        tool_use_id: String,
186        content: serde_json::Value,
187    },
188    #[serde(rename = "code_execution_tool_result")]
189    CodeExecutionToolResult {
190        tool_use_id: String,
191        content: serde_json::Value,
192    },
193}
194
195impl ContentBlock {
196    /// Build readable reasoning with no provider-owned continuity state.
197    #[must_use]
198    pub fn thinking(thinking: impl Into<String>) -> Self {
199        Self::Thinking {
200            thinking: thinking.into(),
201            signature: None,
202            state: None,
203        }
204    }
205}
206
207/// Cache control metadata for tool definitions and blocks.
208#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
209pub struct CacheControl {
210    #[serde(rename = "type")]
211    pub cache_type: String,
212}
213
214/// Metadata describing who invoked a tool call.
215#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
216pub struct ToolCaller {
217    #[serde(rename = "type")]
218    pub caller_type: String,
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub tool_id: Option<String>,
221}
222
223/// Tool definition exposed to the model.
224#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
225pub struct Tool {
226    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
227    pub tool_type: Option<String>,
228    pub name: String,
229    pub description: String,
230    pub input_schema: serde_json::Value,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub allowed_callers: Option<Vec<String>>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub defer_loading: Option<bool>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub input_examples: Option<Vec<serde_json::Value>>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub strict: Option<bool>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub cache_control: Option<CacheControl>,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use serde_json::json;
247
248    fn primary_turn() -> PrimaryTurnRequest {
249        PrimaryTurnRequest {
250            model: "deepseek-v4-flash".to_string(),
251            messages: vec![Message {
252                role: "user".to_string(),
253                content: vec![ContentBlock::Text {
254                    text: "inspect the request".to_string(),
255                    cache_control: None,
256                }],
257            }],
258            max_tokens: 4096,
259            system: Some(SystemPrompt::Text("system".to_string())),
260            tools: Some(vec![Tool {
261                tool_type: None,
262                name: "read_file".to_string(),
263                description: "Read a file".to_string(),
264                input_schema: json!({"zeta": 1, "alpha": 2, "type": "object"}),
265                allowed_callers: None,
266                defer_loading: None,
267                input_examples: None,
268                strict: None,
269                cache_control: None,
270            }]),
271            tool_choice: Some(json!({"type": "auto"})),
272            reasoning_effort: Some("high".to_string()),
273        }
274    }
275
276    #[test]
277    fn primary_turn_preparation_has_stable_serialized_bytes() {
278        let first = prepare_primary_turn_request(primary_turn());
279        let second = prepare_primary_turn_request(primary_turn());
280        let first_bytes = serde_json::to_vec(&first).expect("serialize first request");
281        let second_bytes = serde_json::to_vec(&second).expect("serialize second request");
282
283        assert_eq!(first_bytes, second_bytes);
284        assert_eq!(
285            first_bytes,
286            br#"{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"text","text":"inspect the request"}]}],"max_tokens":4096,"system":"system","tools":[{"name":"read_file","description":"Read a file","input_schema":{"zeta":1,"alpha":2,"type":"object"}}],"tool_choice":{"type":"auto"},"reasoning_effort":"high","stream":true}"#
287        );
288    }
289
290    #[test]
291    fn primary_turn_preparation_owns_shared_defaults() {
292        let request = prepare_primary_turn_request(primary_turn());
293        assert_eq!(request.stream, Some(true));
294        assert!(request.metadata.is_none());
295        assert!(request.thinking.is_none());
296        assert!(request.temperature.is_none());
297        assert!(request.top_p.is_none());
298    }
299}