diff --git a/crates/aether-ai/formats/src/formats/claude/messages/stream.rs b/crates/aether-ai/formats/src/formats/claude/messages/stream.rs index ea5156b57..af816734c 100644 --- a/crates/aether-ai/formats/src/formats/claude/messages/stream.rs +++ b/crates/aether-ai/formats/src/formats/claude/messages/stream.rs @@ -721,6 +721,7 @@ impl ClaudeClientEmitter { out.extend(self.ensure_tool_block(index, &call_id, &name)?); Ok(out) } + CanonicalStreamEvent::ToolCallSignature { .. } => Ok(Vec::new()), CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { let (call_id, name) = { let state = self.tool_states.entry(index).or_default(); diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs index 8be83d2dc..0d372ca87 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs @@ -371,10 +371,16 @@ fn canonical_blocks_to_gemini_parts( tool_name_by_id: &mut BTreeMap, ) -> Option> { let mut parts = Vec::new(); + let mut saw_tool_use = false; for block in blocks { - if let Some(part) = canonical_block_to_gemini_part(block, tool_name_by_id)? { + let is_first_tool_use = + matches!(block, CanonicalContentBlock::ToolUse { .. }) && !saw_tool_use; + if let Some(part) = + canonical_block_to_gemini_part(block, tool_name_by_id, is_first_tool_use)? + { parts.push(part); } + saw_tool_use |= matches!(block, CanonicalContentBlock::ToolUse { .. }); } Some(parts) } @@ -382,6 +388,7 @@ fn canonical_blocks_to_gemini_parts( fn canonical_block_to_gemini_part( block: &CanonicalContentBlock, tool_name_by_id: &mut BTreeMap, + is_first_tool_use: bool, ) -> Option> { match block { CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))), @@ -433,16 +440,37 @@ fn canonical_block_to_gemini_part( }) })), CanonicalContentBlock::ToolUse { - id, name, input, .. + id, + name, + input, + extensions, } => { tool_name_by_id.insert(id.clone(), name.clone()); - Some(Some(json!({ + let mut part = json!({ "functionCall": { "id": id, "name": name, "args": gemini_function_args(input), } - }))) + }); + let signature = extensions + .get("gemini") + .and_then(Value::as_object) + .and_then(|gemini| { + gemini + .get("thoughtSignature") + .or_else(|| gemini.get("thought_signature")) + }) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .or_else(|| is_first_tool_use.then_some("skip_thought_signature_validator")); + if let Some(signature) = signature { + part.as_object_mut()?.insert( + "thoughtSignature".to_string(), + Value::String(signature.to_string()), + ); + } + Some(Some(part)) } CanonicalContentBlock::ToolResult { tool_use_id, @@ -932,6 +960,7 @@ mod tests { extensions: BTreeMap::new(), }, &mut tool_name_by_id, + false, ) .expect("part should be representable") .expect("part should not be omitted"); diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs index 01e954666..8b890cacb 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs @@ -12,6 +12,7 @@ struct GeminiProviderToolState { call_id: String, name: String, arguments: String, + thought_signature: String, started_emitted: bool, } @@ -254,6 +255,16 @@ impl GeminiProviderState { .and_then(Value::as_str) .unwrap_or(tool_state.name.as_str()) .to_string(); + if let Some(signature) = reasoning_signature { + if tool_state.thought_signature != signature { + tool_state.thought_signature = signature.clone(); + out.push(CanonicalStreamFrame { + id: id.clone(), + model: model.clone(), + event: CanonicalStreamEvent::ToolCallSignature { index, signature }, + }); + } + } if !tool_state.started_emitted { out.push(CanonicalStreamFrame { id: id.clone(), @@ -360,6 +371,7 @@ struct GeminiClientToolState { call_id: String, name: String, arguments: String, + thought_signature: String, emitted: bool, } @@ -434,7 +446,7 @@ impl GeminiClientEmitter { let args_value = parse_json_arguments_value(&tool_call.arguments) .unwrap_or_else(|| Value::Object(Map::new())); tool_call.emitted = true; - pending.push(json!({ + let mut part = json!({ "functionCall": { "id": if tool_call.call_id.is_empty() { build_generated_tool_call_id(*index) @@ -448,7 +460,11 @@ impl GeminiClientEmitter { }, "args": args_value, } - })); + }); + if !tool_call.thought_signature.is_empty() { + part["thoughtSignature"] = Value::String(tool_call.thought_signature.clone()); + } + pending.push(part); } for part in pending { out.extend(self.emit_candidate(vec![part], None, None)?); @@ -505,6 +521,10 @@ impl GeminiClientEmitter { state.name = name; Ok(Vec::new()) } + CanonicalStreamEvent::ToolCallSignature { index, signature } => { + self.tool_calls.entry(index).or_default().thought_signature = signature; + Ok(Vec::new()) + } CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { let emitted_part = { let state = self.tool_calls.entry(index).or_default(); @@ -515,7 +535,7 @@ impl GeminiClientEmitter { let args_value = parse_json_arguments_value(&state.arguments); args_value.map(|args_value| { state.emitted = true; - json!({ + let mut part = json!({ "functionCall": { "id": if state.call_id.is_empty() { build_generated_tool_call_id(index) @@ -529,7 +549,12 @@ impl GeminiClientEmitter { }, "args": args_value, } - }) + }); + if !state.thought_signature.is_empty() { + part["thoughtSignature"] = + Value::String(state.thought_signature.clone()); + } + part }) } }; @@ -972,6 +997,57 @@ mod tests { ))); } + #[test] + fn gemini_provider_state_preserves_function_call_thought_signature() { + let mut state = GeminiProviderState::default(); + let report_context = json!({}); + let frames = state + .push_line( + &report_context, + data_line(json!({ + "responseId": "resp_signed_tool_123", + "modelVersion": "gemini-3-flash-preview", + "candidates": [{ + "index": 0, + "content": { + "parts": [{ + "functionCall": { + "id": "call_123", + "name": "lookup", + "args": {"query": "rust"} + }, + "thoughtSignature": "opaque-tool-signature" + }] + } + }] + })), + ) + .expect("signed function call should parse"); + + let signature_index = frames + .iter() + .position(|frame| { + matches!( + frame.event, + CanonicalStreamEvent::ToolCallSignature { + index: 0, + ref signature, + } if signature == "opaque-tool-signature" + ) + }) + .expect("tool signature event"); + let call_index = frames + .iter() + .position(|frame| { + matches!( + frame.event, + CanonicalStreamEvent::ToolCallStart { index: 0, .. } + ) + }) + .expect("tool call start event"); + assert!(signature_index < call_index); + } + #[test] fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() { let mut emitter = GeminiClientEmitter::default(); diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index bb2365e0e..a84363cdd 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -4,10 +4,12 @@ use serde_json::{json, Map, Value}; use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::responses::{ + encode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction, openai_responses_synthetic_reasoning_item_id, response::{ ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, }, + GeminiToolSignatureCarrierDirection, }; use crate::formats::shared::response::build_generated_tool_call_id; use crate::formats::shared::sse::{encode_done_sse, encode_json_sse}; @@ -1932,6 +1934,8 @@ struct OpenAIResponsesClientToolState { name: String, namespace: Option, arguments: String, + thought_signature_carrier: Option, + thought_signature_output_index: Option, output_index: Option, web_search: bool, } @@ -2174,6 +2178,7 @@ impl OpenAIChatClientEmitter { ); Ok(out) } + CanonicalStreamEvent::ToolCallSignature { .. } => Ok(Vec::new()), CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { let mut out = self.ensure_started()?; let chat_index = self.chat_tool_call_index(index); @@ -2918,6 +2923,24 @@ impl OpenAIResponsesClientEmitter { )); } for (index, state) in &self.tool_calls { + if let (Some(output_index), Some(carrier)) = ( + state.thought_signature_output_index, + state.thought_signature_carrier.as_ref(), + ) { + ordered_output.push(( + output_index, + json!({ + "type": "reasoning", + "id": openai_responses_synthetic_reasoning_item_id( + self.response_id(), + output_index, + ), + "status": "completed", + "encrypted_content": carrier, + "summary": [], + }), + )); + } if let Some(output_index) = state.output_index { let call_id = if state.call_id.is_empty() { build_generated_tool_call_id(*index) @@ -3290,6 +3313,69 @@ impl OpenAIResponsesClientEmitter { )?); Ok(out) } + CanonicalStreamEvent::ToolCallSignature { index, signature } => { + let direction = if self + .tool_calls + .get(&index) + .and_then(|state| state.output_index) + .is_some() + { + GeminiToolSignatureCarrierDirection::Previous + } else { + GeminiToolSignatureCarrierDirection::Next + }; + let Some(carrier) = + encode_gemini_tool_signature_carrier_with_direction(&signature, direction) + else { + return Ok(Vec::new()); + }; + if self + .tool_calls + .get(&index) + .and_then(|state| state.thought_signature_carrier.as_deref()) + == Some(carrier.as_str()) + { + return Ok(Vec::new()); + } + + let mut out = self.ensure_started()?; + let output_index = self.allocate_output_index(); + let item = json!({ + "type": "reasoning", + "id": openai_responses_synthetic_reasoning_item_id( + self.response_id(), + output_index, + ), + "status": "completed", + "encrypted_content": carrier, + "summary": [], + }); + let state = self.tool_calls.entry(index).or_default(); + state.thought_signature_carrier = item + .get("encrypted_content") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + state.thought_signature_output_index = Some(output_index); + out.extend(self.encode_response_event( + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "response_id": self.response_id(), + "output_index": output_index, + "item": item, + }), + )?); + out.extend(self.encode_response_event( + "response.output_item.done", + json!({ + "type": "response.output_item.done", + "response_id": self.response_id(), + "output_index": output_index, + "item": item, + }), + )?); + Ok(out) + } CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { let mut out = self.ensure_started()?; let output_index = self.ensure_tool_output_index(index); @@ -5329,6 +5415,124 @@ mod tests { ); } + #[test] + fn openai_responses_client_emitter_carries_gemini_tool_signature() { + let mut emitter = OpenAIResponsesClientEmitter::default(); + let mut bytes = emitter + .emit(CanonicalStreamFrame { + id: "resp_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::ToolCallSignature { + index: 0, + signature: "opaque-tool-signature".to_string(), + }, + }) + .expect("tool signature should encode"); + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::ToolCallStart { + index: 0, + call_id: "call_123".to_string(), + name: "lookup".to_string(), + }, + }) + .expect("tool call should encode"), + ); + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::ToolCallArgumentsDelta { + index: 0, + arguments: "{\"query\":\"rust\"}".to_string(), + }, + }) + .expect("tool arguments should encode"), + ); + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::Finish { + finish_reason: Some("tool_calls".to_string()), + usage: None, + }, + }) + .expect("tool finish should encode"), + ); + + let sse = String::from_utf8(bytes).expect("sse should be utf8"); + let carrier = encode_gemini_tool_signature_carrier("opaque-tool-signature") + .expect("signature carrier"); + let carrier_index = sse.find(&carrier).expect("carrier in Responses stream"); + let call_index = sse + .find("\"call_id\":\"call_123\"") + .expect("function call in Responses stream"); + assert!(carrier_index < call_index); + assert!(sse.contains("\"encrypted_content\"")); + assert!(sse.contains("event: response.completed\n")); + } + + #[test] + fn openai_responses_client_emitter_carries_late_gemini_tool_signature() { + let mut emitter = OpenAIResponsesClientEmitter::default(); + let mut bytes = emitter + .emit(CanonicalStreamFrame { + id: "resp_late_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::ToolCallStart { + index: 0, + call_id: "call_123".to_string(), + name: "lookup".to_string(), + }, + }) + .expect("tool call should encode"); + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_late_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::ToolCallSignature { + index: 0, + signature: "opaque-late-tool-signature".to_string(), + }, + }) + .expect("late tool signature should encode"), + ); + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_late_signed_tool_123".to_string(), + model: "gemini-3-flash-preview".to_string(), + event: CanonicalStreamEvent::Finish { + finish_reason: Some("tool_calls".to_string()), + usage: None, + }, + }) + .expect("tool finish should encode"), + ); + + let sse = String::from_utf8(bytes).expect("sse should be utf8"); + let carrier = encode_gemini_tool_signature_carrier_with_direction( + "opaque-late-tool-signature", + GeminiToolSignatureCarrierDirection::Previous, + ) + .expect("late signature carrier"); + let call_index = sse + .find("\"call_id\":\"call_123\"") + .expect("function call in Responses stream"); + let carrier_index = sse + .find(&carrier) + .expect("late carrier in Responses stream"); + assert!(call_index < carrier_index); + assert!(sse.contains("event: response.completed\n")); + } + #[test] fn openai_responses_client_emitter_emits_web_search_call_item() { let mut emitter = OpenAIResponsesClientEmitter::default(); diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 5be47f610..dd42a6484 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -1,3 +1,4 @@ +use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; use serde_json::Value; pub mod codex; @@ -9,6 +10,69 @@ pub mod stream; const TOOL_ERROR_PREFIX: &str = "[tool error]"; const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_"; +const GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX: &str = "cpa-gemini-responses-carrier-v1:"; +const MAX_GEMINI_THOUGHT_SIGNATURE_LEN: usize = 32 * 1024 * 1024; +const MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN: usize = + MAX_GEMINI_THOUGHT_SIGNATURE_LEN.div_ceil(3) * 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GeminiToolSignatureCarrierDirection { + Next, + Previous, +} + +impl GeminiToolSignatureCarrierDirection { + fn as_str(self) -> &'static str { + match self { + Self::Next => "next", + Self::Previous => "previous", + } + } +} + +pub(crate) fn encode_gemini_tool_signature_carrier(signature: &str) -> Option { + encode_gemini_tool_signature_carrier_with_direction( + signature, + GeminiToolSignatureCarrierDirection::Next, + ) +} + +pub(crate) fn encode_gemini_tool_signature_carrier_with_direction( + signature: &str, + direction: GeminiToolSignatureCarrierDirection, +) -> Option { + (!signature.trim().is_empty() && signature.len() <= MAX_GEMINI_THOUGHT_SIGNATURE_LEN).then( + || { + format!( + "{GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX}{}:function:{}", + direction.as_str(), + STANDARD_NO_PAD.encode(signature) + ) + }, + ) +} + +pub(crate) fn decode_gemini_tool_signature_carrier( + carrier: &str, +) -> Option<(String, GeminiToolSignatureCarrierDirection)> { + let payload = carrier.strip_prefix(GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX)?; + let (direction, encoded) = payload.split_once(":function:")?; + let direction = match direction { + "next" => GeminiToolSignatureCarrierDirection::Next, + "previous" => GeminiToolSignatureCarrierDirection::Previous, + _ => return None, + }; + if encoded.len() > MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN { + return None; + } + let decoded = STANDARD_NO_PAD.decode(encoded).ok()?; + if decoded.len() > MAX_GEMINI_THOUGHT_SIGNATURE_LEN { + return None; + } + let signature = String::from_utf8(decoded).ok()?; + (!signature.trim().is_empty() && !signature.starts_with(GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX)) + .then_some((signature, direction)) +} /// Controls which provider-owned reasoning items may be replayed on a Responses request. /// @@ -190,12 +254,58 @@ mod tests { use serde_json::json; use super::{ + decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction, openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, strip_incompatible_openai_responses_reasoning_items, strip_incompatible_openai_responses_reasoning_items_with_policy, - OpenAiResponsesReasoningReplayPolicy, OPENAI_RESPONSES_OPERATION_COMPACT, + GeminiToolSignatureCarrierDirection, OpenAiResponsesReasoningReplayPolicy, + MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN, MAX_GEMINI_THOUGHT_SIGNATURE_LEN, + OPENAI_RESPONSES_OPERATION_COMPACT, }; + #[test] + fn gemini_tool_signature_carrier_roundtrips_direction_and_exact_value() { + let signature = " opaque-signature-with-padding== "; + for direction in [ + GeminiToolSignatureCarrierDirection::Next, + GeminiToolSignatureCarrierDirection::Previous, + ] { + let carrier = encode_gemini_tool_signature_carrier_with_direction(signature, direction) + .expect("signature carrier"); + assert_eq!( + decode_gemini_tool_signature_carrier(&carrier), + Some((signature.to_string(), direction)) + ); + } + } + + #[test] + fn gemini_tool_signature_carrier_rejects_nested_and_oversized_values() { + let nested = encode_gemini_tool_signature_carrier_with_direction( + "opaque-signature", + GeminiToolSignatureCarrierDirection::Next, + ) + .expect("inner carrier"); + let nested = encode_gemini_tool_signature_carrier_with_direction( + &nested, + GeminiToolSignatureCarrierDirection::Previous, + ) + .expect("outer carrier"); + assert_eq!(decode_gemini_tool_signature_carrier(&nested), None); + assert_eq!( + encode_gemini_tool_signature_carrier_with_direction( + &"x".repeat(MAX_GEMINI_THOUGHT_SIGNATURE_LEN + 1), + GeminiToolSignatureCarrierDirection::Next, + ), + None + ); + let oversized = format!( + "cpa-gemini-responses-carrier-v1:next:function:{}", + "A".repeat(MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN + 1) + ); + assert_eq!(decode_gemini_tool_signature_carrier(&oversized), None); + } + #[test] fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() { assert_eq!( diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 7703cdefd..977d8b8f6 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -6,8 +6,8 @@ use std::{ use serde_json::{json, Map, Value}; use super::{ - encode_tool_result_error, history::record_converted_response_history, - openai_responses_synthetic_reasoning_item_id, + encode_gemini_tool_signature_carrier, encode_tool_result_error, + history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id, }; use crate::{ @@ -240,6 +240,28 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo &response_id, &mut message_index, ); + if let Some(signature) = extensions + .get("gemini") + .and_then(Value::as_object) + .and_then(|gemini| { + gemini + .get("thoughtSignature") + .or_else(|| gemini.get("thought_signature")) + }) + .and_then(Value::as_str) + .and_then(encode_gemini_tool_signature_carrier) + { + output.push(json!({ + "type": "reasoning", + "id": openai_responses_synthetic_reasoning_item_id( + &response_id, + output.len(), + ), + "status": "completed", + "encrypted_content": signature, + "summary": [], + })); + } let namespaced_tool = namespace_tool_aliases.responses_name(name); if namespaced_tool.is_none() && is_responses_web_search_tool(name) { output.push(json!({ diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index 8428c8a09..430d06d65 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -1401,8 +1401,10 @@ fn mapped_namespace_tool_use_extensions( if !matches!( source, FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact - ) || target != FormatId::OpenAiChat - { + ) || !matches!( + target, + FormatId::OpenAiChat | FormatId::GeminiGenerateContent + ) { return Ok(extensions.clone()); } @@ -1414,36 +1416,37 @@ fn mapped_namespace_tool_use_extensions( else { continue; }; - let Some(namespace) = provider_fields.get("namespace") else { - continue; - }; - let Some(namespace) = namespace - .as_str() - .map(str::trim) - .filter(|namespace| !namespace.is_empty()) - else { - return Err(FormatError::LossyConversionBlocked { - source_format: source.as_str().to_string(), - target_format: target.as_str().to_string(), - field: format!("messages[].content[].{provider_namespace}.namespace"), - reason: "Responses namespace tool call has an invalid namespace identity" - .to_string(), - }); - }; - if aliases.chat_name(namespace, name).is_none() { - return Err(FormatError::LossyConversionBlocked { - source_format: source.as_str().to_string(), - target_format: target.as_str().to_string(), - field: format!("messages[].content[].{provider_namespace}.namespace"), - reason: "Responses namespace tool call does not match an expanded namespace child" - .to_string(), - }); + if target == FormatId::OpenAiChat { + if let Some(namespace) = provider_fields.get("namespace") { + let Some(namespace) = namespace + .as_str() + .map(str::trim) + .filter(|namespace| !namespace.is_empty()) + else { + return Err(FormatError::LossyConversionBlocked { + source_format: source.as_str().to_string(), + target_format: target.as_str().to_string(), + field: format!("messages[].content[].{provider_namespace}.namespace"), + reason: "Responses namespace tool call has an invalid namespace identity" + .to_string(), + }); + }; + if aliases.chat_name(namespace, name).is_none() { + return Err(FormatError::LossyConversionBlocked { + source_format: source.as_str().to_string(), + target_format: target.as_str().to_string(), + field: format!("messages[].content[].{provider_namespace}.namespace"), + reason: "Responses namespace tool call does not match an expanded namespace child" + .to_string(), + }); + } + provider_fields.remove("namespace"); + } } - provider_fields.remove("namespace"); // Responses item IDs are distinct from executable call IDs, but Chat - // has only the latter. A completed history item is fully represented - // by the assistant tool call itself, so these transport/completion - // sidecars can be discarded after the namespace identity is proven. + // and Gemini pair tools by the latter. A completed history item is + // fully represented by the model function call, so these transport + // sidecars can be discarded after any namespace identity is proven. provider_fields.remove("item_id"); if provider_fields.get("status").and_then(Value::as_str) == Some("completed") { provider_fields.remove("status"); @@ -1543,6 +1546,19 @@ fn request_extension_key_is_cross_format_safe( { return true; } + if location == "messages[].content[]" + && matches!( + (source, target, namespace, key), + ( + FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact, + FormatId::GeminiGenerateContent, + "gemini", + "thoughtSignature", + ) + ) + { + return true; + } if location == "tools[]" { return tool_extension_key_is_cross_format_safe(source, target, namespace, key); } @@ -4381,7 +4397,15 @@ mod tests { let parts = converted["contents"][1]["parts"] .as_array() .expect("tool response parts"); + let calls = converted["contents"][0]["parts"] + .as_array() + .expect("parallel function call parts"); + assert_eq!( + calls[0]["thoughtSignature"], + "skip_thought_signature_validator" + ); + assert!(calls[1].get("thoughtSignature").is_none()); assert_eq!(parts[0]["functionResponse"]["id"], "call_1"); assert_eq!(parts[0]["functionResponse"]["response"]["result"], "one"); assert_eq!(parts[1]["functionResponse"]["id"], "call_2"); @@ -4439,6 +4463,127 @@ mod tests { assert_eq!(parts[3]["text"], "Continue."); } + #[test] + fn pure_openai_responses_to_gemini_signs_synthetic_tool_history() { + let body = json!({ + "model": "gemini-3-flash-preview", + "input": [{ + "type": "function_call", + "call_id": "call_weather", + "name": "get_weather", + "arguments": "{\"city\":\"Shanghai\"}" + }, { + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + }] + }); + + let converted = convert_request_pure("openai:responses", "gemini:generate_content", &body) + .expect("synthetic Responses tool history should be compatible with Gemini") + .value; + + assert_eq!( + converted["contents"][0]["parts"][0]["thoughtSignature"], + "skip_thought_signature_validator" + ); + } + + #[test] + fn gemini_tool_signature_roundtrips_through_openai_responses_history() { + let signature = "opaque-gemini-tool-signature"; + let gemini_response = json!({ + "responseId": "resp_gemini_tool", + "modelVersion": "gemini-3-flash-preview", + "candidates": [{ + "content": { + "role": "model", + "parts": [{ + "functionCall": { + "id": "call_weather", + "name": "get_weather", + "args": {"city": "Shanghai"} + }, + "thoughtSignature": signature + }] + }, + "finishReason": "STOP" + }] + }); + + let responses = convert_response_pure( + "gemini:generate_content", + "openai:responses", + &gemini_response, + ) + .expect("Gemini tool response should convert to Responses") + .value; + let mut input = responses["output"] + .as_array() + .expect("Responses output items") + .clone(); + input.push(json!({ + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + })); + + let converted = convert_request_pure( + "openai:responses", + "gemini:generate_content", + &json!({ + "model": "gemini-3-flash-preview", + "input": input + }), + ) + .expect("Responses tool history should convert back to Gemini") + .value; + + assert_eq!( + converted["contents"][0]["parts"][0]["thoughtSignature"], + signature + ); + } + + #[test] + fn post_call_gemini_signature_carrier_replays_to_previous_function_call() { + let signature = "opaque-late-tool-signature"; + let carrier = + crate::formats::openai::responses::encode_gemini_tool_signature_carrier_with_direction( + signature, + crate::formats::openai::responses::GeminiToolSignatureCarrierDirection::Previous, + ) + .expect("signature carrier"); + let body = json!({ + "model": "gemini-3-flash-preview", + "input": [{ + "type": "function_call", + "call_id": "call_weather", + "name": "get_weather", + "arguments": "{\"city\":\"Shanghai\"}" + }, { + "type": "reasoning", + "id": "rs_aether_late_signature", + "status": "completed", + "encrypted_content": carrier, + "summary": [] + }, { + "type": "function_call_output", + "call_id": "call_weather", + "output": "sunny" + }] + }); + + let converted = convert_request_pure("openai:responses", "gemini:generate_content", &body) + .expect("post-call signature carrier should replay") + .value; + + assert_eq!( + converted["contents"][0]["parts"][0]["thoughtSignature"], + signature + ); + } + #[test] fn pure_gemini_to_openai_responses_blocks_thought_part_loss() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 57d512eb6..4f8a954f6 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -3269,6 +3269,7 @@ struct GeminiSyncToolState { call_id: String, name: String, arguments: String, + thought_signature: String, part_index: Option, } @@ -3596,6 +3597,13 @@ fn try_aggregate_gemini_stream_sync_response( parts[part_index] = sync_gemini_function_call_part(state); } } + CanonicalStreamEvent::ToolCallSignature { index, signature } => { + let state = tool_states.entry(index).or_default(); + state.thought_signature = signature; + if let Some(part_index) = state.part_index { + parts[part_index] = sync_gemini_function_call_part(state); + } + } CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { let state = tool_states.entry(index).or_default(); state.arguments.push_str(&arguments); @@ -3790,7 +3798,7 @@ fn is_mergeable_gemini_text_part(part: &Map, thought: bool) -> bo } fn sync_gemini_function_call_part(state: &GeminiSyncToolState) -> Value { - json!({ + let mut part = json!({ "functionCall": { "id": if state.call_id.trim().is_empty() { "call_auto_0".to_string() @@ -3804,7 +3812,11 @@ fn sync_gemini_function_call_part(state: &GeminiSyncToolState) -> Value { }, "args": sync_gemini_function_args_value(&state.arguments), } - }) + }); + if !state.thought_signature.is_empty() { + part["thoughtSignature"] = Value::String(state.thought_signature.clone()); + } + part } fn sync_gemini_function_response_part( diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index 20db96ae1..e16d1c7e4 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -3,6 +3,9 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use crate::formats::openai::responses::{ + decode_gemini_tool_signature_carrier, GeminiToolSignatureCarrierDirection, +}; use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort; use crate::formats::shared::model_directives::ReasoningEffort; use crate::formats::shared::response::remove_empty_pages_from_tool_input_value; @@ -2066,7 +2069,31 @@ pub(crate) fn openai_responses_input_to_canonical_messages( .to_ascii_lowercase(); match item_type.as_str() { "reasoning" => { - pending_reasoning = openai_responses_reasoning_block_from_item(item_object); + let reasoning = openai_responses_reasoning_block_from_item(item_object); + let previous_signature = reasoning.as_ref().and_then(|block| match block { + CanonicalContentBlock::Thinking { + text, + encrypted_content: Some(carrier), + .. + } if text.trim().is_empty() => decode_gemini_tool_signature_carrier( + carrier, + ) + .and_then(|(signature, direction)| { + (direction == GeminiToolSignatureCarrierDirection::Previous) + .then_some(signature) + }), + _ => None, + }); + if let Some(signature) = previous_signature { + if attach_gemini_signature_to_previous_tool_use( + &mut messages, + signature, + ) { + pending_reasoning = None; + continue; + } + } + pending_reasoning = reasoning; } "message" => { let role = openai_role_to_canonical( @@ -2277,10 +2304,28 @@ fn openai_responses_opaque_input_item_message(item: &Value, raw_type: String) -> fn append_openai_responses_tool_use( messages: &mut Vec, - tool_use: CanonicalContentBlock, + mut tool_use: CanonicalContentBlock, pending_reasoning: &mut Option, ) { - let reasoning = pending_reasoning.take(); + let mut reasoning = pending_reasoning.take(); + if let Some(CanonicalContentBlock::Thinking { + text, + encrypted_content: Some(carrier), + .. + }) = reasoning.as_ref() + { + if text.trim().is_empty() { + if let Some((signature, GeminiToolSignatureCarrierDirection::Next)) = + decode_gemini_tool_signature_carrier(carrier) + { + if let CanonicalContentBlock::ToolUse { extensions, .. } = &mut tool_use { + canonical_extension_object_mut(extensions, "gemini") + .insert("thoughtSignature".to_string(), Value::String(signature)); + reasoning = None; + } + } + } + } if let Some(last_message) = messages.last_mut() { if last_message.role == CanonicalRole::Assistant && (!is_openai_responses_input_message(&last_message.extensions) @@ -2306,6 +2351,24 @@ fn append_openai_responses_tool_use( }); } +fn attach_gemini_signature_to_previous_tool_use( + messages: &mut [CanonicalMessage], + signature: String, +) -> bool { + let Some(message) = messages.last_mut() else { + return false; + }; + if message.role != CanonicalRole::Assistant { + return false; + } + let Some(CanonicalContentBlock::ToolUse { extensions, .. }) = message.content.last_mut() else { + return false; + }; + canonical_extension_object_mut(extensions, "gemini") + .insert("thoughtSignature".to_string(), Value::String(signature)); + true +} + fn canonical_assistant_message_has_visible_content(message: &CanonicalMessage) -> bool { message.content.iter().any(|block| match block { CanonicalContentBlock::Text { text, .. } | CanonicalContentBlock::Thinking { text, .. } => { diff --git a/crates/aether-ai/formats/src/protocol/stream.rs b/crates/aether-ai/formats/src/protocol/stream.rs index c003de412..9c6c5823d 100644 --- a/crates/aether-ai/formats/src/protocol/stream.rs +++ b/crates/aether-ai/formats/src/protocol/stream.rs @@ -60,6 +60,10 @@ pub enum CanonicalStreamEvent { call_id: String, name: String, }, + ToolCallSignature { + index: usize, + signature: String, + }, ToolCallArgumentsDelta { index: usize, arguments: String,