use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReasoningFormat {
AnthropicClaudeV1,
GoogleGeminiV1,
OpenaiResponsesV1,
AzureOpenaiResponsesV1,
XaiResponsesV1,
#[serde(other)]
Unknown,
}
impl ReasoningFormat {
pub fn as_wire(&self) -> &'static str {
match self {
ReasoningFormat::AnthropicClaudeV1 => "anthropic-claude-v1",
ReasoningFormat::GoogleGeminiV1 => "google-gemini-v1",
ReasoningFormat::OpenaiResponsesV1 => "openai-responses-v1",
ReasoningFormat::AzureOpenaiResponsesV1 => "azure-openai-responses-v1",
ReasoningFormat::XaiResponsesV1 => "xai-responses-v1",
ReasoningFormat::Unknown => "unknown",
}
}
pub fn from_wire(s: &str) -> Self {
match s {
"anthropic-claude-v1" => ReasoningFormat::AnthropicClaudeV1,
"google-gemini-v1" => ReasoningFormat::GoogleGeminiV1,
"openai-responses-v1" => ReasoningFormat::OpenaiResponsesV1,
"azure-openai-responses-v1" => ReasoningFormat::AzureOpenaiResponsesV1,
"xai-responses-v1" => ReasoningFormat::XaiResponsesV1,
_ => ReasoningFormat::Unknown,
}
}
pub fn replay_contract(&self) -> ReplayContract {
match self {
ReasoningFormat::GoogleGeminiV1 => ReplayContract::RequiredWithTools,
ReasoningFormat::AnthropicClaudeV1 => ReplayContract::RequiredWithTools,
ReasoningFormat::OpenaiResponsesV1 | ReasoningFormat::AzureOpenaiResponsesV1 => {
ReplayContract::RequiredWithTools
}
ReasoningFormat::XaiResponsesV1 => ReplayContract::Stateless,
ReasoningFormat::Unknown => ReplayContract::Stateless,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ReasoningItem {
#[serde(rename = "reasoning.text")]
Text {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
format: ReasoningFormat,
#[serde(skip_serializing_if = "Option::is_none")]
index: Option<u32>,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
#[serde(rename = "reasoning.summary")]
Summary {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
format: ReasoningFormat,
#[serde(skip_serializing_if = "Option::is_none")]
index: Option<u32>,
summary: String,
},
#[serde(rename = "reasoning.encrypted")]
Encrypted {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
format: ReasoningFormat,
#[serde(skip_serializing_if = "Option::is_none")]
index: Option<u32>,
data: String,
},
}
impl ReasoningItem {
pub fn format(&self) -> ReasoningFormat {
match self {
ReasoningItem::Text { format, .. } => *format,
ReasoningItem::Summary { format, .. } => *format,
ReasoningItem::Encrypted { format, .. } => *format,
}
}
pub fn index(&self) -> Option<u32> {
match self {
ReasoningItem::Text { index, .. } => *index,
ReasoningItem::Summary { index, .. } => *index,
ReasoningItem::Encrypted { index, .. } => *index,
}
}
pub fn carries_signed_payload(&self) -> bool {
matches!(
self,
ReasoningItem::Text {
signature: Some(_),
..
} | ReasoningItem::Encrypted { .. }
)
}
pub fn from_openrouter_value(value: &Value) -> Option<Self> {
let obj = value.as_object()?;
let kind = obj.get("type").and_then(Value::as_str)?;
let format = obj
.get("format")
.and_then(Value::as_str)
.map(ReasoningFormat::from_wire)
.unwrap_or(ReasoningFormat::Unknown);
let id = obj
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| obj.get("id").and_then(|v| v.as_null()).and(None));
let index = obj.get("index").and_then(Value::as_u64).map(|n| n as u32);
match kind {
"reasoning.text" => Some(ReasoningItem::Text {
id,
format,
index,
text: obj
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
signature: obj
.get("signature")
.and_then(Value::as_str)
.map(str::to_string),
}),
"reasoning.summary" => Some(ReasoningItem::Summary {
id,
format,
index,
summary: obj
.get("summary")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
}),
"reasoning.encrypted" => Some(ReasoningItem::Encrypted {
id,
format,
index,
data: obj
.get("data")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
}),
_ => None,
}
}
pub fn to_openrouter_value(&self) -> Value {
let mut map = Map::new();
match self {
ReasoningItem::Text {
id,
format,
index,
text,
signature,
} => {
map.insert("type".into(), Value::String("reasoning.text".into()));
if let Some(id) = id {
map.insert("id".into(), Value::String(id.clone()));
}
map.insert("format".into(), Value::String(format.as_wire().into()));
if let Some(index) = index {
map.insert("index".into(), Value::Number((*index).into()));
}
map.insert("text".into(), Value::String(text.clone()));
if let Some(sig) = signature {
map.insert("signature".into(), Value::String(sig.clone()));
}
}
ReasoningItem::Summary {
id,
format,
index,
summary,
} => {
map.insert("type".into(), Value::String("reasoning.summary".into()));
if let Some(id) = id {
map.insert("id".into(), Value::String(id.clone()));
}
map.insert("format".into(), Value::String(format.as_wire().into()));
if let Some(index) = index {
map.insert("index".into(), Value::Number((*index).into()));
}
map.insert("summary".into(), Value::String(summary.clone()));
}
ReasoningItem::Encrypted {
id,
format,
index,
data,
} => {
map.insert("type".into(), Value::String("reasoning.encrypted".into()));
if let Some(id) = id {
map.insert("id".into(), Value::String(id.clone()));
}
map.insert("format".into(), Value::String(format.as_wire().into()));
if let Some(index) = index {
map.insert("index".into(), Value::Number((*index).into()));
}
map.insert("data".into(), Value::String(data.clone()));
}
}
Value::Object(map)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplayContract {
Stateless,
RequiredWithTools,
AlwaysRequired,
}
pub trait ReasoningCodec: Send + Sync {
fn parse_response(&self, raw: &[Value]) -> Vec<ReasoningItem>;
fn write_assistant(&self, msg: &mut Value, items: &[ReasoningItem]);
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReplayAudit {
pub item_count: usize,
pub signed_count: usize,
pub formats: Vec<ReasoningFormat>,
pub violation: Option<ReplayViolation>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplayViolation {
MissingSignaturesForStrictProvider {
contract: ReplayContract,
formats: Vec<ReasoningFormat>,
},
}
pub fn audit_replay(
items: &[ReasoningItem],
contract: ReplayContract,
next_turn_carries_tools: bool,
) -> ReplayAudit {
let signed_count = items.iter().filter(|i| i.carries_signed_payload()).count();
let mut formats: Vec<ReasoningFormat> = items.iter().map(ReasoningItem::format).collect();
formats.sort_by_key(|f| f.as_wire());
formats.dedup();
let violation = match contract {
ReplayContract::Stateless => None,
ReplayContract::RequiredWithTools if !next_turn_carries_tools => None,
ReplayContract::RequiredWithTools | ReplayContract::AlwaysRequired => {
if signed_count == 0 {
Some(ReplayViolation::MissingSignaturesForStrictProvider {
contract,
formats: formats.clone(),
})
} else {
None
}
}
};
ReplayAudit {
item_count: items.len(),
signed_count,
formats,
violation,
}
}
#[derive(Debug, Clone, Default)]
pub struct OpenRouterReasoningCodec;
impl OpenRouterReasoningCodec {
pub fn new() -> Self {
Self
}
}
impl ReasoningCodec for OpenRouterReasoningCodec {
fn parse_response(&self, raw: &[Value]) -> Vec<ReasoningItem> {
raw.iter()
.filter_map(ReasoningItem::from_openrouter_value)
.collect()
}
fn write_assistant(&self, msg: &mut Value, items: &[ReasoningItem]) {
if items.is_empty() {
return;
}
let arr = items
.iter()
.map(ReasoningItem::to_openrouter_value)
.collect::<Vec<_>>();
if let Some(obj) = msg.as_object_mut() {
obj.insert("reasoning_details".into(), Value::Array(arr));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn format_wire_roundtrip_covers_every_variant() {
for fmt in [
ReasoningFormat::AnthropicClaudeV1,
ReasoningFormat::GoogleGeminiV1,
ReasoningFormat::OpenaiResponsesV1,
ReasoningFormat::AzureOpenaiResponsesV1,
ReasoningFormat::XaiResponsesV1,
ReasoningFormat::Unknown,
] {
assert_eq!(ReasoningFormat::from_wire(fmt.as_wire()), fmt);
}
}
#[test]
fn unknown_format_falls_back() {
assert_eq!(
ReasoningFormat::from_wire("future-provider-v9"),
ReasoningFormat::Unknown
);
}
#[test]
fn item_text_roundtrip() {
let v = json!({
"type": "reasoning.text",
"id": "rs-1",
"format": "anthropic-claude-v1",
"index": 0,
"text": "First, compare the decimals.",
"signature": "sig-abc"
});
let item = ReasoningItem::from_openrouter_value(&v).unwrap();
match &item {
ReasoningItem::Text {
format, signature, ..
} => {
assert_eq!(*format, ReasoningFormat::AnthropicClaudeV1);
assert_eq!(signature.as_deref(), Some("sig-abc"));
}
_ => panic!("expected Text variant"),
}
assert_eq!(item.to_openrouter_value(), v);
}
#[test]
fn item_encrypted_roundtrip_for_gemini() {
let v = json!({
"type": "reasoning.encrypted",
"id": "rs-2",
"format": "google-gemini-v1",
"index": 3,
"data": "BASE64BLOB"
});
let item = ReasoningItem::from_openrouter_value(&v).unwrap();
match &item {
ReasoningItem::Encrypted { format, data, .. } => {
assert_eq!(*format, ReasoningFormat::GoogleGeminiV1);
assert_eq!(data, "BASE64BLOB");
}
_ => panic!("expected Encrypted variant"),
}
assert!(item.carries_signed_payload());
assert_eq!(item.to_openrouter_value(), v);
}
#[test]
fn item_summary_roundtrip() {
let v = json!({
"type": "reasoning.summary",
"id": "rs-3",
"format": "openai-responses-v1",
"index": 1,
"summary": "Compared 9.9 and 9.11 numerically."
});
let item = ReasoningItem::from_openrouter_value(&v).unwrap();
assert!(matches!(item, ReasoningItem::Summary { .. }));
assert!(!item.carries_signed_payload());
assert_eq!(item.to_openrouter_value(), v);
}
#[test]
fn unknown_type_returns_none() {
let v = json!({"type": "reasoning.future_kind", "data": "..." });
assert!(ReasoningItem::from_openrouter_value(&v).is_none());
}
#[test]
fn carries_signed_payload_distinguishes_signed_from_unsigned_text() {
let signed = ReasoningItem::Text {
id: None,
format: ReasoningFormat::AnthropicClaudeV1,
index: Some(0),
text: "thought".into(),
signature: Some("sig".into()),
};
let unsigned = ReasoningItem::Text {
id: None,
format: ReasoningFormat::Unknown,
index: None,
text: "thought".into(),
signature: None,
};
assert!(signed.carries_signed_payload());
assert!(!unsigned.carries_signed_payload());
}
#[test]
fn openrouter_codec_parses_and_writes_assistant() {
let codec = OpenRouterReasoningCodec::new();
let raw = vec![
json!({
"type": "reasoning.encrypted",
"format": "google-gemini-v1",
"index": 0,
"data": "GBLOB"
}),
json!({"type": "noise"}),
];
let items = codec.parse_response(&raw);
assert_eq!(items.len(), 1);
assert_eq!(items[0].format(), ReasoningFormat::GoogleGeminiV1);
let mut msg = json!({"role": "assistant", "content": null});
codec.write_assistant(&mut msg, &items);
let arr = msg
.as_object()
.unwrap()
.get("reasoning_details")
.unwrap()
.as_array()
.unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["data"], "GBLOB");
}
#[test]
fn openrouter_codec_skips_empty_replay() {
let codec = OpenRouterReasoningCodec::new();
let mut msg = json!({"role": "assistant", "content": "hi"});
codec.write_assistant(&mut msg, &[]);
assert!(msg.as_object().unwrap().get("reasoning_details").is_none());
}
#[test]
fn format_replay_contract_matches_observed_provider_enforcement() {
for fmt in [
ReasoningFormat::GoogleGeminiV1,
ReasoningFormat::AnthropicClaudeV1,
ReasoningFormat::OpenaiResponsesV1,
ReasoningFormat::AzureOpenaiResponsesV1,
] {
assert_eq!(
fmt.replay_contract(),
ReplayContract::RequiredWithTools,
"{fmt:?} should be RequiredWithTools"
);
}
for fmt in [ReasoningFormat::XaiResponsesV1, ReasoningFormat::Unknown] {
assert_eq!(
fmt.replay_contract(),
ReplayContract::Stateless,
"{fmt:?} should be Stateless"
);
}
}
#[test]
fn audit_clean_when_provider_is_stateless() {
let audit = audit_replay(&[], ReplayContract::Stateless, true);
assert!(audit.violation.is_none());
assert_eq!(audit.signed_count, 0);
}
#[test]
fn audit_clean_when_required_with_tools_but_next_turn_has_no_tools() {
let audit = audit_replay(&[], ReplayContract::RequiredWithTools, false);
assert!(audit.violation.is_none());
}
#[test]
fn audit_flags_missing_signatures_for_required_with_tools() {
let items = vec![ReasoningItem::Text {
id: None,
format: ReasoningFormat::GoogleGeminiV1,
index: Some(0),
text: "thinking out loud".into(),
signature: None,
}];
let audit = audit_replay(&items, ReplayContract::RequiredWithTools, true);
match audit.violation {
Some(ReplayViolation::MissingSignaturesForStrictProvider {
contract, formats, ..
}) => {
assert_eq!(contract, ReplayContract::RequiredWithTools);
assert_eq!(formats, vec![ReasoningFormat::GoogleGeminiV1]);
}
_ => panic!("expected MissingSignaturesForStrictProvider violation"),
}
}
#[test]
fn audit_passes_when_signed_payload_present() {
let items = vec![
ReasoningItem::Text {
id: None,
format: ReasoningFormat::GoogleGeminiV1,
index: Some(0),
text: "summary".into(),
signature: None,
},
ReasoningItem::Encrypted {
id: None,
format: ReasoningFormat::GoogleGeminiV1,
index: Some(1),
data: "BASE64".into(),
},
];
let audit = audit_replay(&items, ReplayContract::RequiredWithTools, true);
assert!(audit.violation.is_none());
assert_eq!(audit.signed_count, 1);
assert_eq!(audit.item_count, 2);
}
#[test]
fn audit_always_required_fires_even_without_tools() {
let audit = audit_replay(&[], ReplayContract::AlwaysRequired, false);
assert!(matches!(
audit.violation,
Some(ReplayViolation::MissingSignaturesForStrictProvider { .. })
));
}
}