1use serde::{Deserialize, Serialize};
27use serde_json::{Map, Value};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "kebab-case")]
36pub enum ReasoningFormat {
37 AnthropicClaudeV1,
42 GoogleGeminiV1,
49 OpenaiResponsesV1,
53 AzureOpenaiResponsesV1,
56 XaiResponsesV1,
59 #[serde(other)]
63 Unknown,
64}
65
66impl ReasoningFormat {
67 pub fn as_wire(&self) -> &'static str {
68 match self {
69 ReasoningFormat::AnthropicClaudeV1 => "anthropic-claude-v1",
70 ReasoningFormat::GoogleGeminiV1 => "google-gemini-v1",
71 ReasoningFormat::OpenaiResponsesV1 => "openai-responses-v1",
72 ReasoningFormat::AzureOpenaiResponsesV1 => "azure-openai-responses-v1",
73 ReasoningFormat::XaiResponsesV1 => "xai-responses-v1",
74 ReasoningFormat::Unknown => "unknown",
75 }
76 }
77
78 pub fn from_wire(s: &str) -> Self {
79 match s {
80 "anthropic-claude-v1" => ReasoningFormat::AnthropicClaudeV1,
81 "google-gemini-v1" => ReasoningFormat::GoogleGeminiV1,
82 "openai-responses-v1" => ReasoningFormat::OpenaiResponsesV1,
83 "azure-openai-responses-v1" => ReasoningFormat::AzureOpenaiResponsesV1,
84 "xai-responses-v1" => ReasoningFormat::XaiResponsesV1,
85 _ => ReasoningFormat::Unknown,
86 }
87 }
88
89 pub fn replay_contract(&self) -> ReplayContract {
97 match self {
98 ReasoningFormat::GoogleGeminiV1 => ReplayContract::RequiredWithTools,
99 ReasoningFormat::AnthropicClaudeV1 => ReplayContract::RequiredWithTools,
100 ReasoningFormat::OpenaiResponsesV1 | ReasoningFormat::AzureOpenaiResponsesV1 => {
101 ReplayContract::RequiredWithTools
102 }
103 ReasoningFormat::XaiResponsesV1 => ReplayContract::Stateless,
104 ReasoningFormat::Unknown => ReplayContract::Stateless,
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(tag = "type")]
120pub enum ReasoningItem {
121 #[serde(rename = "reasoning.text")]
125 Text {
126 #[serde(skip_serializing_if = "Option::is_none")]
127 id: Option<String>,
128 format: ReasoningFormat,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 index: Option<u32>,
131 text: String,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 signature: Option<String>,
134 },
135 #[serde(rename = "reasoning.summary")]
140 Summary {
141 #[serde(skip_serializing_if = "Option::is_none")]
142 id: Option<String>,
143 format: ReasoningFormat,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 index: Option<u32>,
146 summary: String,
147 },
148 #[serde(rename = "reasoning.encrypted")]
155 Encrypted {
156 #[serde(skip_serializing_if = "Option::is_none")]
157 id: Option<String>,
158 format: ReasoningFormat,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 index: Option<u32>,
161 data: String,
162 },
163}
164
165impl ReasoningItem {
166 pub fn format(&self) -> ReasoningFormat {
168 match self {
169 ReasoningItem::Text { format, .. } => *format,
170 ReasoningItem::Summary { format, .. } => *format,
171 ReasoningItem::Encrypted { format, .. } => *format,
172 }
173 }
174
175 pub fn index(&self) -> Option<u32> {
179 match self {
180 ReasoningItem::Text { index, .. } => *index,
181 ReasoningItem::Summary { index, .. } => *index,
182 ReasoningItem::Encrypted { index, .. } => *index,
183 }
184 }
185
186 pub fn carries_signed_payload(&self) -> bool {
192 matches!(
193 self,
194 ReasoningItem::Text {
195 signature: Some(_),
196 ..
197 } | ReasoningItem::Encrypted { .. }
198 )
199 }
200
201 pub fn from_openrouter_value(value: &Value) -> Option<Self> {
205 let obj = value.as_object()?;
206 let kind = obj.get("type").and_then(Value::as_str)?;
207 let format = obj
208 .get("format")
209 .and_then(Value::as_str)
210 .map(ReasoningFormat::from_wire)
211 .unwrap_or(ReasoningFormat::Unknown);
212 let id = obj
213 .get("id")
214 .and_then(Value::as_str)
215 .map(str::to_string)
216 .or_else(|| obj.get("id").and_then(|v| v.as_null()).and(None));
217 let index = obj.get("index").and_then(Value::as_u64).map(|n| n as u32);
218
219 match kind {
220 "reasoning.text" => Some(ReasoningItem::Text {
221 id,
222 format,
223 index,
224 text: obj
225 .get("text")
226 .and_then(Value::as_str)
227 .unwrap_or("")
228 .to_string(),
229 signature: obj
230 .get("signature")
231 .and_then(Value::as_str)
232 .map(str::to_string),
233 }),
234 "reasoning.summary" => Some(ReasoningItem::Summary {
235 id,
236 format,
237 index,
238 summary: obj
239 .get("summary")
240 .and_then(Value::as_str)
241 .unwrap_or("")
242 .to_string(),
243 }),
244 "reasoning.encrypted" => Some(ReasoningItem::Encrypted {
245 id,
246 format,
247 index,
248 data: obj
249 .get("data")
250 .and_then(Value::as_str)
251 .unwrap_or("")
252 .to_string(),
253 }),
254 _ => None,
255 }
256 }
257
258 pub fn to_openrouter_value(&self) -> Value {
262 let mut map = Map::new();
263 match self {
264 ReasoningItem::Text {
265 id,
266 format,
267 index,
268 text,
269 signature,
270 } => {
271 map.insert("type".into(), Value::String("reasoning.text".into()));
272 if let Some(id) = id {
273 map.insert("id".into(), Value::String(id.clone()));
274 }
275 map.insert("format".into(), Value::String(format.as_wire().into()));
276 if let Some(index) = index {
277 map.insert("index".into(), Value::Number((*index).into()));
278 }
279 map.insert("text".into(), Value::String(text.clone()));
280 if let Some(sig) = signature {
281 map.insert("signature".into(), Value::String(sig.clone()));
282 }
283 }
284 ReasoningItem::Summary {
285 id,
286 format,
287 index,
288 summary,
289 } => {
290 map.insert("type".into(), Value::String("reasoning.summary".into()));
291 if let Some(id) = id {
292 map.insert("id".into(), Value::String(id.clone()));
293 }
294 map.insert("format".into(), Value::String(format.as_wire().into()));
295 if let Some(index) = index {
296 map.insert("index".into(), Value::Number((*index).into()));
297 }
298 map.insert("summary".into(), Value::String(summary.clone()));
299 }
300 ReasoningItem::Encrypted {
301 id,
302 format,
303 index,
304 data,
305 } => {
306 map.insert("type".into(), Value::String("reasoning.encrypted".into()));
307 if let Some(id) = id {
308 map.insert("id".into(), Value::String(id.clone()));
309 }
310 map.insert("format".into(), Value::String(format.as_wire().into()));
311 if let Some(index) = index {
312 map.insert("index".into(), Value::Number((*index).into()));
313 }
314 map.insert("data".into(), Value::String(data.clone()));
315 }
316 }
317 Value::Object(map)
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Eq)]
324pub enum ReplayContract {
325 Stateless,
329 RequiredWithTools,
333 AlwaysRequired,
336}
337
338pub trait ReasoningCodec: Send + Sync {
352 fn parse_response(&self, raw: &[Value]) -> Vec<ReasoningItem>;
358
359 fn write_assistant(&self, msg: &mut Value, items: &[ReasoningItem]);
365}
366
367#[derive(Debug, Clone, PartialEq)]
374pub struct ReplayAudit {
375 pub item_count: usize,
377 pub signed_count: usize,
380 pub formats: Vec<ReasoningFormat>,
383 pub violation: Option<ReplayViolation>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum ReplayViolation {
395 MissingSignaturesForStrictProvider {
401 contract: ReplayContract,
402 formats: Vec<ReasoningFormat>,
403 },
404}
405
406pub fn audit_replay(
413 items: &[ReasoningItem],
414 contract: ReplayContract,
415 next_turn_carries_tools: bool,
416) -> ReplayAudit {
417 let signed_count = items.iter().filter(|i| i.carries_signed_payload()).count();
418 let mut formats: Vec<ReasoningFormat> = items.iter().map(ReasoningItem::format).collect();
419 formats.sort_by_key(|f| f.as_wire());
420 formats.dedup();
421
422 let violation = match contract {
423 ReplayContract::Stateless => None,
424 ReplayContract::RequiredWithTools if !next_turn_carries_tools => None,
425 ReplayContract::RequiredWithTools | ReplayContract::AlwaysRequired => {
426 if signed_count == 0 {
427 Some(ReplayViolation::MissingSignaturesForStrictProvider {
428 contract,
429 formats: formats.clone(),
430 })
431 } else {
432 None
433 }
434 }
435 };
436
437 ReplayAudit {
438 item_count: items.len(),
439 signed_count,
440 formats,
441 violation,
442 }
443}
444
445#[derive(Debug, Clone, Default)]
451pub struct OpenRouterReasoningCodec;
452
453impl OpenRouterReasoningCodec {
454 pub fn new() -> Self {
455 Self
456 }
457}
458
459impl ReasoningCodec for OpenRouterReasoningCodec {
460 fn parse_response(&self, raw: &[Value]) -> Vec<ReasoningItem> {
461 raw.iter()
462 .filter_map(ReasoningItem::from_openrouter_value)
463 .collect()
464 }
465
466 fn write_assistant(&self, msg: &mut Value, items: &[ReasoningItem]) {
467 if items.is_empty() {
468 return;
469 }
470 let arr = items
471 .iter()
472 .map(ReasoningItem::to_openrouter_value)
473 .collect::<Vec<_>>();
474 if let Some(obj) = msg.as_object_mut() {
475 obj.insert("reasoning_details".into(), Value::Array(arr));
476 }
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use serde_json::json;
484
485 #[test]
486 fn format_wire_roundtrip_covers_every_variant() {
487 for fmt in [
488 ReasoningFormat::AnthropicClaudeV1,
489 ReasoningFormat::GoogleGeminiV1,
490 ReasoningFormat::OpenaiResponsesV1,
491 ReasoningFormat::AzureOpenaiResponsesV1,
492 ReasoningFormat::XaiResponsesV1,
493 ReasoningFormat::Unknown,
494 ] {
495 assert_eq!(ReasoningFormat::from_wire(fmt.as_wire()), fmt);
496 }
497 }
498
499 #[test]
500 fn unknown_format_falls_back() {
501 assert_eq!(
502 ReasoningFormat::from_wire("future-provider-v9"),
503 ReasoningFormat::Unknown
504 );
505 }
506
507 #[test]
508 fn item_text_roundtrip() {
509 let v = json!({
510 "type": "reasoning.text",
511 "id": "rs-1",
512 "format": "anthropic-claude-v1",
513 "index": 0,
514 "text": "First, compare the decimals.",
515 "signature": "sig-abc"
516 });
517 let item = ReasoningItem::from_openrouter_value(&v).unwrap();
518 match &item {
519 ReasoningItem::Text {
520 format, signature, ..
521 } => {
522 assert_eq!(*format, ReasoningFormat::AnthropicClaudeV1);
523 assert_eq!(signature.as_deref(), Some("sig-abc"));
524 }
525 _ => panic!("expected Text variant"),
526 }
527 assert_eq!(item.to_openrouter_value(), v);
528 }
529
530 #[test]
531 fn item_encrypted_roundtrip_for_gemini() {
532 let v = json!({
533 "type": "reasoning.encrypted",
534 "id": "rs-2",
535 "format": "google-gemini-v1",
536 "index": 3,
537 "data": "BASE64BLOB"
538 });
539 let item = ReasoningItem::from_openrouter_value(&v).unwrap();
540 match &item {
541 ReasoningItem::Encrypted { format, data, .. } => {
542 assert_eq!(*format, ReasoningFormat::GoogleGeminiV1);
543 assert_eq!(data, "BASE64BLOB");
544 }
545 _ => panic!("expected Encrypted variant"),
546 }
547 assert!(item.carries_signed_payload());
548 assert_eq!(item.to_openrouter_value(), v);
549 }
550
551 #[test]
552 fn item_summary_roundtrip() {
553 let v = json!({
554 "type": "reasoning.summary",
555 "id": "rs-3",
556 "format": "openai-responses-v1",
557 "index": 1,
558 "summary": "Compared 9.9 and 9.11 numerically."
559 });
560 let item = ReasoningItem::from_openrouter_value(&v).unwrap();
561 assert!(matches!(item, ReasoningItem::Summary { .. }));
562 assert!(!item.carries_signed_payload());
563 assert_eq!(item.to_openrouter_value(), v);
564 }
565
566 #[test]
567 fn unknown_type_returns_none() {
568 let v = json!({"type": "reasoning.future_kind", "data": "..." });
569 assert!(ReasoningItem::from_openrouter_value(&v).is_none());
570 }
571
572 #[test]
573 fn carries_signed_payload_distinguishes_signed_from_unsigned_text() {
574 let signed = ReasoningItem::Text {
575 id: None,
576 format: ReasoningFormat::AnthropicClaudeV1,
577 index: Some(0),
578 text: "thought".into(),
579 signature: Some("sig".into()),
580 };
581 let unsigned = ReasoningItem::Text {
582 id: None,
583 format: ReasoningFormat::Unknown,
584 index: None,
585 text: "thought".into(),
586 signature: None,
587 };
588 assert!(signed.carries_signed_payload());
589 assert!(!unsigned.carries_signed_payload());
590 }
591
592 #[test]
593 fn openrouter_codec_parses_and_writes_assistant() {
594 let codec = OpenRouterReasoningCodec::new();
595 let raw = vec![
596 json!({
597 "type": "reasoning.encrypted",
598 "format": "google-gemini-v1",
599 "index": 0,
600 "data": "GBLOB"
601 }),
602 json!({"type": "noise"}),
603 ];
604 let items = codec.parse_response(&raw);
605 assert_eq!(items.len(), 1);
606 assert_eq!(items[0].format(), ReasoningFormat::GoogleGeminiV1);
607
608 let mut msg = json!({"role": "assistant", "content": null});
609 codec.write_assistant(&mut msg, &items);
610 let arr = msg
611 .as_object()
612 .unwrap()
613 .get("reasoning_details")
614 .unwrap()
615 .as_array()
616 .unwrap();
617 assert_eq!(arr.len(), 1);
618 assert_eq!(arr[0]["data"], "GBLOB");
619 }
620
621 #[test]
622 fn openrouter_codec_skips_empty_replay() {
623 let codec = OpenRouterReasoningCodec::new();
624 let mut msg = json!({"role": "assistant", "content": "hi"});
625 codec.write_assistant(&mut msg, &[]);
626 assert!(msg.as_object().unwrap().get("reasoning_details").is_none());
627 }
628
629 #[test]
630 fn format_replay_contract_matches_observed_provider_enforcement() {
631 for fmt in [
634 ReasoningFormat::GoogleGeminiV1,
635 ReasoningFormat::AnthropicClaudeV1,
636 ReasoningFormat::OpenaiResponsesV1,
637 ReasoningFormat::AzureOpenaiResponsesV1,
638 ] {
639 assert_eq!(
640 fmt.replay_contract(),
641 ReplayContract::RequiredWithTools,
642 "{fmt:?} should be RequiredWithTools"
643 );
644 }
645 for fmt in [ReasoningFormat::XaiResponsesV1, ReasoningFormat::Unknown] {
648 assert_eq!(
649 fmt.replay_contract(),
650 ReplayContract::Stateless,
651 "{fmt:?} should be Stateless"
652 );
653 }
654 }
655
656 #[test]
657 fn audit_clean_when_provider_is_stateless() {
658 let audit = audit_replay(&[], ReplayContract::Stateless, true);
659 assert!(audit.violation.is_none());
660 assert_eq!(audit.signed_count, 0);
661 }
662
663 #[test]
664 fn audit_clean_when_required_with_tools_but_next_turn_has_no_tools() {
665 let audit = audit_replay(&[], ReplayContract::RequiredWithTools, false);
666 assert!(audit.violation.is_none());
667 }
668
669 #[test]
670 fn audit_flags_missing_signatures_for_required_with_tools() {
671 let items = vec![ReasoningItem::Text {
672 id: None,
673 format: ReasoningFormat::GoogleGeminiV1,
674 index: Some(0),
675 text: "thinking out loud".into(),
676 signature: None,
677 }];
678 let audit = audit_replay(&items, ReplayContract::RequiredWithTools, true);
679 match audit.violation {
680 Some(ReplayViolation::MissingSignaturesForStrictProvider {
681 contract, formats, ..
682 }) => {
683 assert_eq!(contract, ReplayContract::RequiredWithTools);
684 assert_eq!(formats, vec![ReasoningFormat::GoogleGeminiV1]);
685 }
686 _ => panic!("expected MissingSignaturesForStrictProvider violation"),
687 }
688 }
689
690 #[test]
691 fn audit_passes_when_signed_payload_present() {
692 let items = vec![
693 ReasoningItem::Text {
694 id: None,
695 format: ReasoningFormat::GoogleGeminiV1,
696 index: Some(0),
697 text: "summary".into(),
698 signature: None,
699 },
700 ReasoningItem::Encrypted {
701 id: None,
702 format: ReasoningFormat::GoogleGeminiV1,
703 index: Some(1),
704 data: "BASE64".into(),
705 },
706 ];
707 let audit = audit_replay(&items, ReplayContract::RequiredWithTools, true);
708 assert!(audit.violation.is_none());
709 assert_eq!(audit.signed_count, 1);
710 assert_eq!(audit.item_count, 2);
711 }
712
713 #[test]
714 fn audit_always_required_fires_even_without_tools() {
715 let audit = audit_replay(&[], ReplayContract::AlwaysRequired, false);
716 assert!(matches!(
717 audit.violation,
718 Some(ReplayViolation::MissingSignaturesForStrictProvider { .. })
719 ));
720 }
721}