1use serde::{Deserialize, Serialize};
13
14use crate::forward_compat::dispatch_known_or_other;
15use crate::messages::content::ContentBlock;
16use crate::types::{ModelId, Role, StopReason, Usage};
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[non_exhaustive]
25pub struct Message {
26 pub id: String,
28 #[serde(rename = "type", default = "default_message_kind")]
31 pub kind: String,
32 #[serde(default = "default_assistant_role")]
34 pub role: Role,
35 #[serde(default)]
37 pub content: Vec<ContentBlock>,
38 pub model: ModelId,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub stop_reason: Option<StopReason>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub stop_sequence: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub stop_details: Option<StopDetails>,
51 #[serde(default)]
53 pub usage: Usage,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub context_management: Option<ResponseContextManagement>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub container: Option<ContainerInfo>,
63}
64
65#[derive(Debug, Clone, PartialEq)]
76pub enum StopDetails {
77 Known(KnownStopDetails),
79 Other(serde_json::Value),
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum KnownStopDetails {
89 Refusal(RefusalStopDetails),
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct RefusalStopDetails {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub category: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub explanation: Option<String>,
105}
106
107const KNOWN_STOP_DETAILS_TAGS: &[&str] = &["refusal"];
108
109impl Serialize for StopDetails {
110 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
111 match self {
112 StopDetails::Known(k) => k.serialize(s),
113 StopDetails::Other(v) => v.serialize(s),
114 }
115 }
116}
117
118impl<'de> Deserialize<'de> for StopDetails {
119 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
120 let raw = serde_json::Value::deserialize(d)?;
121 dispatch_known_or_other(
122 raw,
123 KNOWN_STOP_DETAILS_TAGS,
124 StopDetails::Known,
125 StopDetails::Other,
126 )
127 .map_err(serde::de::Error::custom)
128 }
129}
130
131impl From<KnownStopDetails> for StopDetails {
132 fn from(k: KnownStopDetails) -> Self {
133 StopDetails::Known(k)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
148#[non_exhaustive]
149pub struct ResponseContextManagement {
150 #[serde(default)]
152 pub applied_edits: Vec<ContextEdit>,
153}
154
155#[derive(Debug, Clone, PartialEq)]
160pub enum ContextEdit {
161 Known(KnownContextEdit),
163 Other(serde_json::Value),
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170#[non_exhaustive]
171pub enum KnownContextEdit {
172 #[serde(rename = "clear_thinking_20251015")]
174 ClearThinking(ClearThinkingEdit),
175 #[serde(rename = "clear_tool_uses_20250919")]
177 ClearToolUses(ClearToolUsesEdit),
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
182#[non_exhaustive]
183pub struct ClearThinkingEdit {
184 pub cleared_input_tokens: u64,
186 pub cleared_thinking_turns: u64,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
192#[non_exhaustive]
193pub struct ClearToolUsesEdit {
194 pub cleared_input_tokens: u64,
196 pub cleared_tool_uses: u64,
198}
199
200const KNOWN_CONTEXT_EDIT_TAGS: &[&str] = &["clear_thinking_20251015", "clear_tool_uses_20250919"];
201
202impl Serialize for ContextEdit {
203 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
204 match self {
205 ContextEdit::Known(k) => k.serialize(s),
206 ContextEdit::Other(v) => v.serialize(s),
207 }
208 }
209}
210
211impl<'de> Deserialize<'de> for ContextEdit {
212 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
213 let raw = serde_json::Value::deserialize(d)?;
214 dispatch_known_or_other(
215 raw,
216 KNOWN_CONTEXT_EDIT_TAGS,
217 ContextEdit::Known,
218 ContextEdit::Other,
219 )
220 .map_err(serde::de::Error::custom)
221 }
222}
223
224impl From<KnownContextEdit> for ContextEdit {
225 fn from(k: KnownContextEdit) -> Self {
226 ContextEdit::Known(k)
227 }
228}
229
230fn default_message_kind() -> String {
233 "message".to_owned()
234}
235
236fn default_assistant_role() -> Role {
237 Role::Assistant
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
243#[non_exhaustive]
244pub struct ContainerInfo {
245 pub id: String,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub expires_at: Option<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
254#[non_exhaustive]
255pub struct CountTokensResponse {
256 pub input_tokens: u32,
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::messages::content::KnownBlock;
264 use pretty_assertions::assert_eq;
265 use serde_json::json;
266
267 #[test]
268 fn realistic_message_response_round_trips() {
269 let raw = json!({
270 "id": "msg_01ABCDEF",
271 "type": "message",
272 "role": "assistant",
273 "content": [
274 {"type": "text", "text": "Hello!"}
275 ],
276 "model": "claude-sonnet-4-6",
277 "stop_reason": "end_turn",
278 "stop_sequence": null,
279 "usage": {
280 "input_tokens": 10,
281 "output_tokens": 5
282 }
283 });
284
285 let msg: Message = serde_json::from_value(raw).expect("deserialize");
286 assert_eq!(msg.id, "msg_01ABCDEF");
287 assert_eq!(msg.kind, "message");
288 assert_eq!(msg.role, Role::Assistant);
289 assert_eq!(msg.model, ModelId::SONNET_4_6);
290 assert_eq!(msg.stop_reason, Some(StopReason::EndTurn));
291 assert_eq!(msg.usage.input_tokens, 10);
292 assert_eq!(msg.usage.output_tokens, 5);
293 assert_eq!(msg.content.len(), 1);
294 assert_eq!(msg.content[0].type_tag(), Some("text"));
295
296 let reserialized = serde_json::to_value(&msg).expect("serialize");
297 let parsed_again: Message = serde_json::from_value(reserialized).expect("re-deserialize");
298 assert_eq!(parsed_again, msg, "round-trip mismatch");
299 }
300
301 #[test]
302 fn message_with_unknown_content_block_round_trips() {
303 let raw = json!({
304 "id": "msg_X",
305 "type": "message",
306 "role": "assistant",
307 "content": [
308 {"type": "text", "text": "hi"},
309 {"type": "future_block", "payload": 42}
310 ],
311 "model": "claude-opus-4-7",
312 "usage": {"input_tokens": 1, "output_tokens": 1}
313 });
314
315 let msg: Message = serde_json::from_value(raw.clone()).expect("deserialize");
316 assert_eq!(msg.content.len(), 2);
317 assert_eq!(msg.content[0].type_tag(), Some("text"));
318 assert_eq!(msg.content[1].type_tag(), Some("future_block"));
319 assert!(msg.content[1].other().is_some());
320
321 let reserialized = serde_json::to_value(&msg).expect("serialize");
323 let blocks = reserialized.get("content").unwrap().as_array().unwrap();
324 assert_eq!(blocks[1], json!({"type": "future_block", "payload": 42}));
325 }
326
327 #[test]
328 fn message_kind_defaults_when_missing() {
329 let raw = json!({
331 "id": "msg_1",
332 "role": "assistant",
333 "content": [],
334 "model": "claude-sonnet-4-6",
335 "usage": {"input_tokens": 0, "output_tokens": 0}
336 });
337 let msg: Message = serde_json::from_value(raw).expect("deserialize");
338 assert_eq!(msg.kind, "message");
339 }
340
341 #[test]
342 fn message_with_tool_use_block_round_trips() {
343 let msg = Message {
344 id: "msg_tool".into(),
345 kind: "message".into(),
346 role: Role::Assistant,
347 content: vec![ContentBlock::Known(KnownBlock::ToolUse {
348 id: "toolu_1".into(),
349 name: "lookup".into(),
350 input: json!({"q": "rust"}),
351 })],
352 model: ModelId::HAIKU_4_5,
353 stop_reason: Some(StopReason::ToolUse),
354 stop_sequence: None,
355 stop_details: None,
356 usage: Usage {
357 input_tokens: 7,
358 output_tokens: 3,
359 ..Usage::default()
360 },
361 context_management: None,
362 container: None,
363 };
364
365 let v = serde_json::to_value(&msg).expect("serialize");
366 let parsed: Message = serde_json::from_value(v).expect("deserialize");
367 assert_eq!(parsed, msg);
368 }
369
370 #[test]
371 fn stop_details_refusal_round_trips() {
372 let raw = json!({
373 "type": "refusal",
374 "category": "cyber",
375 "explanation": "Request involves offensive cyber techniques."
376 });
377 let sd: StopDetails = serde_json::from_value(raw.clone()).unwrap();
378 match &sd {
379 StopDetails::Known(KnownStopDetails::Refusal(r)) => {
380 assert_eq!(r.category.as_deref(), Some("cyber"));
381 assert!(r.explanation.is_some());
382 }
383 other => panic!("expected Refusal, got {other:?}"),
384 }
385 assert_eq!(serde_json::to_value(&sd).unwrap(), raw);
386 }
387
388 #[test]
389 fn stop_details_null_category_round_trips() {
390 let raw = json!({"type": "refusal", "category": null, "explanation": null});
391 let sd: StopDetails = serde_json::from_value(raw).unwrap();
392 if let StopDetails::Known(KnownStopDetails::Refusal(r)) = &sd {
393 assert!(r.category.is_none());
394 } else {
395 panic!("expected Refusal");
396 }
397 }
398
399 #[test]
400 fn stop_details_unknown_type_falls_through_to_other() {
401 let raw = json!({"type": "future_stop_reason", "detail": 42});
402 let sd: StopDetails = serde_json::from_value(raw.clone()).unwrap();
403 assert!(matches!(sd, StopDetails::Other(_)));
404 assert_eq!(serde_json::to_value(&sd).unwrap(), raw);
405 }
406
407 #[test]
408 fn context_edit_clear_thinking_round_trips() {
409 let raw = json!({
410 "type": "clear_thinking_20251015",
411 "cleared_input_tokens": 1500,
412 "cleared_thinking_turns": 3
413 });
414 let edit: ContextEdit = serde_json::from_value(raw.clone()).unwrap();
415 match &edit {
416 ContextEdit::Known(KnownContextEdit::ClearThinking(e)) => {
417 assert_eq!(e.cleared_input_tokens, 1500);
418 assert_eq!(e.cleared_thinking_turns, 3);
419 }
420 other => panic!("expected ClearThinking, got {other:?}"),
421 }
422 assert_eq!(serde_json::to_value(&edit).unwrap(), raw);
423 }
424
425 #[test]
426 fn context_edit_clear_tool_uses_round_trips() {
427 let raw = json!({
428 "type": "clear_tool_uses_20250919",
429 "cleared_input_tokens": 800,
430 "cleared_tool_uses": 2
431 });
432 let edit: ContextEdit = serde_json::from_value(raw.clone()).unwrap();
433 if let ContextEdit::Known(KnownContextEdit::ClearToolUses(e)) = &edit {
434 assert_eq!(e.cleared_tool_uses, 2);
435 } else {
436 panic!("expected ClearToolUses");
437 }
438 assert_eq!(serde_json::to_value(&edit).unwrap(), raw);
439 }
440
441 #[test]
442 fn context_edit_unknown_type_falls_through_to_other() {
443 let raw = json!({"type": "compact_20260112", "summary": "..."});
444 let edit: ContextEdit = serde_json::from_value(raw.clone()).unwrap();
445 assert!(matches!(edit, ContextEdit::Other(_)));
446 assert_eq!(serde_json::to_value(&edit).unwrap(), raw);
447 }
448
449 #[test]
450 fn response_context_management_round_trips() {
451 let raw = json!({
452 "applied_edits": [
453 {"type": "clear_thinking_20251015", "cleared_input_tokens": 500, "cleared_thinking_turns": 1},
454 {"type": "clear_tool_uses_20250919", "cleared_input_tokens": 200, "cleared_tool_uses": 1}
455 ]
456 });
457 let cm: ResponseContextManagement = serde_json::from_value(raw.clone()).unwrap();
458 assert_eq!(cm.applied_edits.len(), 2);
459 assert!(matches!(
460 &cm.applied_edits[0],
461 ContextEdit::Known(KnownContextEdit::ClearThinking(_))
462 ));
463 assert_eq!(serde_json::to_value(&cm).unwrap(), raw);
464 }
465
466 #[test]
467 fn message_with_stop_details_and_context_management_round_trips() {
468 let raw = json!({
469 "id": "msg_refusal",
470 "type": "message",
471 "role": "assistant",
472 "content": [],
473 "model": "claude-sonnet-4-6",
474 "stop_reason": "refusal",
475 "usage": {"input_tokens": 5, "output_tokens": 0},
476 "stop_details": {"type": "refusal", "category": "bio", "explanation": "Biosecurity policy."},
477 "context_management": {
478 "applied_edits": [
479 {"type": "clear_thinking_20251015", "cleared_input_tokens": 300, "cleared_thinking_turns": 2}
480 ]
481 }
482 });
483 let msg: Message = serde_json::from_value(raw).unwrap();
484 assert!(msg.stop_details.is_some());
485 assert!(msg.context_management.is_some());
486 let cm = msg.context_management.as_ref().unwrap();
487 assert_eq!(cm.applied_edits.len(), 1);
488 }
489
490 #[test]
491 fn count_tokens_response_round_trips() {
492 let r = CountTokensResponse { input_tokens: 42 };
493 let v = serde_json::to_value(&r).expect("serialize");
494 assert_eq!(v, json!({"input_tokens": 42}));
495 let parsed: CountTokensResponse = serde_json::from_value(v).expect("deserialize");
496 assert_eq!(parsed, r);
497 }
498
499 #[test]
500 fn container_info_round_trips() {
501 let c = ContainerInfo {
502 id: "cnt_01".into(),
503 expires_at: Some("2026-01-01T00:00:00Z".into()),
504 };
505 let v = serde_json::to_value(&c).expect("serialize");
506 assert_eq!(
507 v,
508 json!({"id": "cnt_01", "expires_at": "2026-01-01T00:00:00Z"})
509 );
510 let parsed: ContainerInfo = serde_json::from_value(v).expect("deserialize");
511 assert_eq!(parsed, c);
512 }
513
514 #[test]
515 fn message_with_container_round_trips() {
516 let raw = json!({
517 "id": "msg_with_container",
518 "type": "message",
519 "role": "assistant",
520 "content": [],
521 "model": "claude-opus-4-7",
522 "usage": {"input_tokens": 0, "output_tokens": 0},
523 "container": {"id": "cnt_42"}
524 });
525 let msg: Message = serde_json::from_value(raw).expect("deserialize");
526 assert_eq!(msg.container.as_ref().unwrap().id, "cnt_42");
527 }
528}