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