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    },
162    #[serde(rename = "tool_result")]
163    ToolResult {
164        tool_use_id: String,
165        content: String,
166        #[serde(skip_serializing_if = "Option::is_none")]
167        is_error: Option<bool>,
168        #[serde(skip_serializing_if = "Option::is_none")]
169        content_blocks: Option<Vec<serde_json::Value>>,
170    },
171    #[serde(rename = "server_tool_use")]
172    ServerToolUse {
173        id: String,
174        name: String,
175        input: serde_json::Value,
176    },
177    #[serde(rename = "tool_search_tool_result")]
178    ToolSearchToolResult {
179        tool_use_id: String,
180        content: serde_json::Value,
181    },
182    #[serde(rename = "code_execution_tool_result")]
183    CodeExecutionToolResult {
184        tool_use_id: String,
185        content: serde_json::Value,
186    },
187}
188
189impl ContentBlock {
190    /// Build readable reasoning with no provider-owned continuity state.
191    #[must_use]
192    pub fn thinking(thinking: impl Into<String>) -> Self {
193        Self::Thinking {
194            thinking: thinking.into(),
195            signature: None,
196            state: None,
197        }
198    }
199}
200
201/// Cache control metadata for tool definitions and blocks.
202#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
203pub struct CacheControl {
204    #[serde(rename = "type")]
205    pub cache_type: String,
206}
207
208/// Metadata describing who invoked a tool call.
209#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
210pub struct ToolCaller {
211    #[serde(rename = "type")]
212    pub caller_type: String,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub tool_id: Option<String>,
215}
216
217/// Tool definition exposed to the model.
218#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
219pub struct Tool {
220    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
221    pub tool_type: Option<String>,
222    pub name: String,
223    pub description: String,
224    pub input_schema: serde_json::Value,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub allowed_callers: Option<Vec<String>>,
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub defer_loading: Option<bool>,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub input_examples: Option<Vec<serde_json::Value>>,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub strict: Option<bool>,
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub cache_control: Option<CacheControl>,
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use serde_json::json;
241
242    fn primary_turn() -> PrimaryTurnRequest {
243        PrimaryTurnRequest {
244            model: "deepseek-v4-flash".to_string(),
245            messages: vec![Message {
246                role: "user".to_string(),
247                content: vec![ContentBlock::Text {
248                    text: "inspect the request".to_string(),
249                    cache_control: None,
250                }],
251            }],
252            max_tokens: 4096,
253            system: Some(SystemPrompt::Text("system".to_string())),
254            tools: Some(vec![Tool {
255                tool_type: None,
256                name: "read_file".to_string(),
257                description: "Read a file".to_string(),
258                input_schema: json!({"zeta": 1, "alpha": 2, "type": "object"}),
259                allowed_callers: None,
260                defer_loading: None,
261                input_examples: None,
262                strict: None,
263                cache_control: None,
264            }]),
265            tool_choice: Some(json!({"type": "auto"})),
266            reasoning_effort: Some("high".to_string()),
267        }
268    }
269
270    #[test]
271    fn primary_turn_preparation_has_stable_serialized_bytes() {
272        let first = prepare_primary_turn_request(primary_turn());
273        let second = prepare_primary_turn_request(primary_turn());
274        let first_bytes = serde_json::to_vec(&first).expect("serialize first request");
275        let second_bytes = serde_json::to_vec(&second).expect("serialize second request");
276
277        assert_eq!(first_bytes, second_bytes);
278        assert_eq!(
279            first_bytes,
280            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}"#
281        );
282    }
283
284    #[test]
285    fn primary_turn_preparation_owns_shared_defaults() {
286        let request = prepare_primary_turn_request(primary_turn());
287        assert_eq!(request.stream, Some(true));
288        assert!(request.metadata.is_none());
289        assert!(request.thinking.is_none());
290        assert!(request.temperature.is_none());
291        assert!(request.top_p.is_none());
292    }
293}