返回 CodeWhale
request.rs
根目录 / crates / protocol / 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-protocol` 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 /// Host-owned execution correlation, distinct from the provider's id.
167 /// Persisted with history; provider adapters project only wire fields.
168 /// Missing identifies legacy/provider-only history, never a new grant.
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 execution_id: Option<String>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 caller: Option<ToolCaller>,
173 /// Google thought signature captured from the OpenAI-compat route's
174 /// `extra_content.google.thought_signature` on the tool call. Google
175 /// requires replaying it with the tool result for thinking models;
176 /// skipped on the wire and in storage for every other provider.
177 #[serde(skip_serializing_if = "Option::is_none", default)]
178 thought_signature: Option<String>,
179 },
180 #[serde(rename = "tool_result")]
181 ToolResult {
182 tool_use_id: String,
183 content: String,
184 /// The exact host execution that produced this result, when recorded.
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 execution_id: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 is_error: Option<bool>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 content_blocks: Option<Vec<serde_json::Value>>,
191 },
192 #[serde(rename = "server_tool_use")]
193 ServerToolUse {
194 id: String,
195 name: String,
196 input: serde_json::Value,
197 },
198 #[serde(rename = "tool_search_tool_result")]
199 ToolSearchToolResult {
200 tool_use_id: String,
201 content: serde_json::Value,
202 },
203 #[serde(rename = "code_execution_tool_result")]
204 CodeExecutionToolResult {
205 tool_use_id: String,
206 content: serde_json::Value,
207 },
208 }
209
210 /// Identity for pairing host history without conflating a provider's reused
211 /// string with a local execution. Legacy identity cannot establish a grant.
212 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
213 pub enum ToolCallKey<'a> {
214 Execution(&'a str),
215 LegacyProvider(&'a str),
216 }
217
218 impl<'a> ToolCallKey<'a> {
219 #[must_use]
220 pub fn as_str(self) -> &'a str {
221 match self {
222 Self::Execution(id) | Self::LegacyProvider(id) => id,
223 }
224 }
225 }
226
227 impl ContentBlock {
228 /// Pairing identity in persisted host history. An explicit execution id
229 /// never falls back to provider correlation, even if malformed or mismatched.
230 /// Wire adapters must continue using the original id/tool_use_id instead.
231 #[must_use]
232 pub fn tool_call_key(&self) -> Option<ToolCallKey<'_>> {
233 match self {
234 Self::ToolUse {
235 id, execution_id, ..
236 }
237 | Self::ToolResult {
238 tool_use_id: id,
239 execution_id,
240 ..
241 } => Some(
242 execution_id
243 .as_deref()
244 .map_or(ToolCallKey::LegacyProvider(id), ToolCallKey::Execution),
245 ),
246 Self::ServerToolUse { id, .. }
247 | Self::ToolSearchToolResult {
248 tool_use_id: id, ..
249 }
250 | Self::CodeExecutionToolResult {
251 tool_use_id: id, ..
252 } => Some(ToolCallKey::LegacyProvider(id)),
253 _ => None,
254 }
255 }
256
257 /// Build readable reasoning with no provider-owned continuity state.
258 #[must_use]
259 pub fn thinking(thinking: impl Into<String>) -> Self {
260 Self::Thinking {
261 thinking: thinking.into(),
262 signature: None,
263 state: None,
264 }
265 }
266 }
267
268 /// Cache control metadata for tool definitions and blocks.
269 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
270 pub struct CacheControl {
271 #[serde(rename = "type")]
272 pub cache_type: String,
273 }
274
275 /// Metadata describing who invoked a tool call.
276 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
277 pub struct ToolCaller {
278 #[serde(rename = "type")]
279 pub caller_type: String,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub tool_id: Option<String>,
282 }
283
284 /// Tool definition exposed to the model.
285 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
286 pub struct Tool {
287 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
288 pub tool_type: Option<String>,
289 pub name: String,
290 pub description: String,
291 pub input_schema: serde_json::Value,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 pub allowed_callers: Option<Vec<String>>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub defer_loading: Option<bool>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 pub input_examples: Option<Vec<serde_json::Value>>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub strict: Option<bool>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 pub cache_control: Option<CacheControl>,
302 }
303
304 #[cfg(test)]
305 mod tests {
306 use super::*;
307 use serde_json::json;
308
309 fn primary_turn() -> PrimaryTurnRequest {
310 PrimaryTurnRequest {
311 model: "deepseek-v4-flash".to_string(),
312 messages: vec![Message {
313 role: Role::User,
314 content: vec![ContentBlock::Text {
315 text: "inspect the request".to_string(),
316 cache_control: None,
317 }],
318 }],
319 max_tokens: 4096,
320 system: Some(SystemPrompt::Text("system".to_string())),
321 tools: Some(vec![Tool {
322 tool_type: None,
323 name: "read_file".to_string(),
324 description: "Read a file".to_string(),
325 input_schema: json!({"zeta": 1, "alpha": 2, "type": "object"}),
326 allowed_callers: None,
327 defer_loading: None,
328 input_examples: None,
329 strict: None,
330 cache_control: None,
331 }]),
332 tool_choice: Some(json!({"type": "auto"})),
333 reasoning_effort: Some("high".to_string()),
334 }
335 }
336
337 #[test]
338 fn primary_turn_preparation_has_stable_serialized_bytes() {
339 let first = prepare_primary_turn_request(primary_turn());
340 let second = prepare_primary_turn_request(primary_turn());
341 let first_bytes = serde_json::to_vec(&first).expect("serialize first request");
342 let second_bytes = serde_json::to_vec(&second).expect("serialize second request");
343
344 assert_eq!(first_bytes, second_bytes);
345 assert_eq!(
346 first_bytes,
347 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}"#
348 );
349 }
350
351 #[test]
352 fn persisted_messages_keep_their_pre_typed_role_bytes() {
353 // `Message::role` became a closed `Role` enum. Saved transcripts are
354 // plain JSON with a free-form role string, so the typed field has to
355 // produce byte-identical output and accept every string it used to —
356 // otherwise every session on disk would need a schema bump, and
357 // `session_manager` refuses a session whose schema_version exceeds
358 // CURRENT with no migration ladder to climb back down.
359 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"}]}]"#;
360 let decoded: Vec<Message> = serde_json::from_slice(persisted).expect("load transcript");
361 assert_eq!(
362 decoded.iter().map(|m| m.role.clone()).collect::<Vec<_>>(),
363 vec![
364 Role::User,
365 Role::Assistant,
366 Role::System,
367 Role::InterruptedAssistant,
368 Role::Developer,
369 ]
370 );
371 assert_eq!(
372 serde_json::to_vec(&decoded).expect("re-save transcript"),
373 persisted.to_vec(),
374 "re-saving a loaded transcript must not change a single byte",
375 );
376 }
377
378 #[test]
379 fn tool_execution_identity_round_trips_without_changing_legacy_bytes() {
380 let legacy = br#"[{"role":"assistant","content":[{"type":"tool_use","id":"wire","name":"read","input":{},"caller":{"type":"code_execution","tool_id":"parent-wire"},"thought_signature":"signature"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","content":"result"}]}]"#;
381 let mut messages: Vec<Message> = serde_json::from_slice(legacy).unwrap();
382 assert_eq!(serde_json::to_vec(&messages).unwrap(), legacy);
383 for block in messages.iter_mut().flat_map(|message| &mut message.content) {
384 match block {
385 ContentBlock::ToolUse { execution_id, .. }
386 | ContentBlock::ToolResult { execution_id, .. } => {
387 assert!(execution_id.is_none());
388 *execution_id = Some("local-execution".to_string());
389 }
390 _ => unreachable!(),
391 }
392 }
393 let persisted = serde_json::to_vec(&messages).unwrap();
394 let restored: Vec<Message> = serde_json::from_slice(&persisted).unwrap();
395 assert_eq!(restored, messages);
396 assert_eq!(
397 restored[0].content[0].tool_call_key(),
398 restored[1].content[0].tool_call_key()
399 );
400 let ContentBlock::ToolUse {
401 id,
402 caller,
403 thought_signature,
404 ..
405 } = &restored[0].content[0]
406 else {
407 unreachable!()
408 };
409 assert_eq!(id, "wire");
410 assert_eq!(
411 caller.as_ref().unwrap().tool_id.as_deref(),
412 Some("parent-wire")
413 );
414 assert_eq!(thought_signature.as_deref(), Some("signature"));
415 }
416
417 #[test]
418 fn tool_history_keys_never_fall_back_or_cross_identity_domains() {
419 let call = |execution_id: Option<&str>| ContentBlock::ToolUse {
420 id: "same-string".to_string(),
421 name: "read".to_string(),
422 input: json!({}),
423 execution_id: execution_id.map(str::to_string),
424 caller: None,
425 thought_signature: None,
426 };
427 let legacy = call(None);
428 let local = call(Some("same-string"));
429 let different = call(Some("other-execution"));
430 let malformed = call(Some(""));
431 assert_eq!(
432 legacy.tool_call_key(),
433 Some(ToolCallKey::LegacyProvider("same-string"))
434 );
435 assert_eq!(
436 local.tool_call_key(),
437 Some(ToolCallKey::Execution("same-string"))
438 );
439 assert_ne!(legacy.tool_call_key(), local.tool_call_key());
440 assert_ne!(local.tool_call_key(), different.tool_call_key());
441 assert_eq!(malformed.tool_call_key(), Some(ToolCallKey::Execution("")));
442 assert_eq!(
443 std::collections::HashSet::from([
444 legacy.tool_call_key().unwrap(),
445 local.tool_call_key().unwrap(),
446 ])
447 .len(),
448 2
449 );
450 }
451
452 #[test]
453 fn primary_turn_preparation_owns_shared_defaults() {
454 let request = prepare_primary_turn_request(primary_turn());
455 assert_eq!(request.stream, Some(true));
456 assert!(request.metadata.is_none());
457 assert!(request.thinking.is_none());
458 assert!(request.temperature.is_none());
459 assert!(request.top_p.is_none());
460 }
461 }
462
462 lines RUST