fix(formats): preserve Gemini tool thought signatures

This commit is contained in:
ZheFox
2026-08-28 12:47:34 +08:00
parent 8cdfa338e5
commit c4b4dfa996
10 changed files with 712 additions and 46 deletions
@@ -721,6 +721,7 @@ impl ClaudeClientEmitter {
out.extend(self.ensure_tool_block(index, &call_id, &name)?); out.extend(self.ensure_tool_block(index, &call_id, &name)?);
Ok(out) Ok(out)
} }
CanonicalStreamEvent::ToolCallSignature { .. } => Ok(Vec::new()),
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let (call_id, name) = { let (call_id, name) = {
let state = self.tool_states.entry(index).or_default(); let state = self.tool_states.entry(index).or_default();
@@ -371,10 +371,16 @@ fn canonical_blocks_to_gemini_parts(
tool_name_by_id: &mut BTreeMap<String, String>, tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Vec<Value>> { ) -> Option<Vec<Value>> {
let mut parts = Vec::new(); let mut parts = Vec::new();
let mut saw_tool_use = false;
for block in blocks { 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); parts.push(part);
} }
saw_tool_use |= matches!(block, CanonicalContentBlock::ToolUse { .. });
} }
Some(parts) Some(parts)
} }
@@ -382,6 +388,7 @@ fn canonical_blocks_to_gemini_parts(
fn canonical_block_to_gemini_part( fn canonical_block_to_gemini_part(
block: &CanonicalContentBlock, block: &CanonicalContentBlock,
tool_name_by_id: &mut BTreeMap<String, String>, tool_name_by_id: &mut BTreeMap<String, String>,
is_first_tool_use: bool,
) -> Option<Option<Value>> { ) -> Option<Option<Value>> {
match block { match block {
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))), CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
@@ -433,16 +440,37 @@ fn canonical_block_to_gemini_part(
}) })
})), })),
CanonicalContentBlock::ToolUse { CanonicalContentBlock::ToolUse {
id, name, input, .. id,
name,
input,
extensions,
} => { } => {
tool_name_by_id.insert(id.clone(), name.clone()); tool_name_by_id.insert(id.clone(), name.clone());
Some(Some(json!({ let mut part = json!({
"functionCall": { "functionCall": {
"id": id, "id": id,
"name": name, "name": name,
"args": gemini_function_args(input), "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 { CanonicalContentBlock::ToolResult {
tool_use_id, tool_use_id,
@@ -932,6 +960,7 @@ mod tests {
extensions: BTreeMap::new(), extensions: BTreeMap::new(),
}, },
&mut tool_name_by_id, &mut tool_name_by_id,
false,
) )
.expect("part should be representable") .expect("part should be representable")
.expect("part should not be omitted"); .expect("part should not be omitted");
@@ -12,6 +12,7 @@ struct GeminiProviderToolState {
call_id: String, call_id: String,
name: String, name: String,
arguments: String, arguments: String,
thought_signature: String,
started_emitted: bool, started_emitted: bool,
} }
@@ -254,6 +255,16 @@ impl GeminiProviderState {
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or(tool_state.name.as_str()) .unwrap_or(tool_state.name.as_str())
.to_string(); .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 { if !tool_state.started_emitted {
out.push(CanonicalStreamFrame { out.push(CanonicalStreamFrame {
id: id.clone(), id: id.clone(),
@@ -360,6 +371,7 @@ struct GeminiClientToolState {
call_id: String, call_id: String,
name: String, name: String,
arguments: String, arguments: String,
thought_signature: String,
emitted: bool, emitted: bool,
} }
@@ -434,7 +446,7 @@ impl GeminiClientEmitter {
let args_value = parse_json_arguments_value(&tool_call.arguments) let args_value = parse_json_arguments_value(&tool_call.arguments)
.unwrap_or_else(|| Value::Object(Map::new())); .unwrap_or_else(|| Value::Object(Map::new()));
tool_call.emitted = true; tool_call.emitted = true;
pending.push(json!({ let mut part = json!({
"functionCall": { "functionCall": {
"id": if tool_call.call_id.is_empty() { "id": if tool_call.call_id.is_empty() {
build_generated_tool_call_id(*index) build_generated_tool_call_id(*index)
@@ -448,7 +460,11 @@ impl GeminiClientEmitter {
}, },
"args": args_value, "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 { for part in pending {
out.extend(self.emit_candidate(vec![part], None, None)?); out.extend(self.emit_candidate(vec![part], None, None)?);
@@ -505,6 +521,10 @@ impl GeminiClientEmitter {
state.name = name; state.name = name;
Ok(Vec::new()) Ok(Vec::new())
} }
CanonicalStreamEvent::ToolCallSignature { index, signature } => {
self.tool_calls.entry(index).or_default().thought_signature = signature;
Ok(Vec::new())
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let emitted_part = { let emitted_part = {
let state = self.tool_calls.entry(index).or_default(); let state = self.tool_calls.entry(index).or_default();
@@ -515,7 +535,7 @@ impl GeminiClientEmitter {
let args_value = parse_json_arguments_value(&state.arguments); let args_value = parse_json_arguments_value(&state.arguments);
args_value.map(|args_value| { args_value.map(|args_value| {
state.emitted = true; state.emitted = true;
json!({ let mut part = json!({
"functionCall": { "functionCall": {
"id": if state.call_id.is_empty() { "id": if state.call_id.is_empty() {
build_generated_tool_call_id(index) build_generated_tool_call_id(index)
@@ -529,7 +549,12 @@ impl GeminiClientEmitter {
}, },
"args": args_value, "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] #[test]
fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() { fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() {
let mut emitter = GeminiClientEmitter::default(); let mut emitter = GeminiClientEmitter::default();
@@ -4,10 +4,12 @@ use serde_json::{json, Map, Value};
use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{ use crate::formats::openai::responses::{
encode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction,
openai_responses_synthetic_reasoning_item_id, openai_responses_synthetic_reasoning_item_id,
response::{ response::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, 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::response::build_generated_tool_call_id;
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse}; use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
@@ -1932,6 +1934,8 @@ struct OpenAIResponsesClientToolState {
name: String, name: String,
namespace: Option<String>, namespace: Option<String>,
arguments: String, arguments: String,
thought_signature_carrier: Option<String>,
thought_signature_output_index: Option<usize>,
output_index: Option<usize>, output_index: Option<usize>,
web_search: bool, web_search: bool,
} }
@@ -2174,6 +2178,7 @@ impl OpenAIChatClientEmitter {
); );
Ok(out) Ok(out)
} }
CanonicalStreamEvent::ToolCallSignature { .. } => Ok(Vec::new()),
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?; let mut out = self.ensure_started()?;
let chat_index = self.chat_tool_call_index(index); let chat_index = self.chat_tool_call_index(index);
@@ -2918,6 +2923,24 @@ impl OpenAIResponsesClientEmitter {
)); ));
} }
for (index, state) in &self.tool_calls { 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 { if let Some(output_index) = state.output_index {
let call_id = if state.call_id.is_empty() { let call_id = if state.call_id.is_empty() {
build_generated_tool_call_id(*index) build_generated_tool_call_id(*index)
@@ -3290,6 +3313,69 @@ impl OpenAIResponsesClientEmitter {
)?); )?);
Ok(out) 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 } => { CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?; let mut out = self.ensure_started()?;
let output_index = self.ensure_tool_output_index(index); 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] #[test]
fn openai_responses_client_emitter_emits_web_search_call_item() { fn openai_responses_client_emitter_emits_web_search_call_item() {
let mut emitter = OpenAIResponsesClientEmitter::default(); let mut emitter = OpenAIResponsesClientEmitter::default();
@@ -1,3 +1,4 @@
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use serde_json::Value; use serde_json::Value;
pub mod codex; pub mod codex;
@@ -9,6 +10,69 @@ pub mod stream;
const TOOL_ERROR_PREFIX: &str = "[tool error]"; const TOOL_ERROR_PREFIX: &str = "[tool error]";
const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_"; 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<String> {
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<String> {
(!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. /// Controls which provider-owned reasoning items may be replayed on a Responses request.
/// ///
@@ -190,12 +254,58 @@ mod tests {
use serde_json::json; use serde_json::json;
use super::{ use super::{
decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction,
openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items, strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy, 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] #[test]
fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() { fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() {
assert_eq!( assert_eq!(
@@ -6,8 +6,8 @@ use std::{
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use super::{ use super::{
encode_tool_result_error, history::record_converted_response_history, encode_gemini_tool_signature_carrier, encode_tool_result_error,
openai_responses_synthetic_reasoning_item_id, history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id,
}; };
use crate::{ use crate::{
@@ -240,6 +240,28 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
&response_id, &response_id,
&mut message_index, &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); let namespaced_tool = namespace_tool_aliases.responses_name(name);
if namespaced_tool.is_none() && is_responses_web_search_tool(name) { if namespaced_tool.is_none() && is_responses_web_search_tool(name) {
output.push(json!({ output.push(json!({
+175 -30
View File
@@ -1401,8 +1401,10 @@ fn mapped_namespace_tool_use_extensions(
if !matches!( if !matches!(
source, source,
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
) || target != FormatId::OpenAiChat ) || !matches!(
{ target,
FormatId::OpenAiChat | FormatId::GeminiGenerateContent
) {
return Ok(extensions.clone()); return Ok(extensions.clone());
} }
@@ -1414,36 +1416,37 @@ fn mapped_namespace_tool_use_extensions(
else { else {
continue; continue;
}; };
let Some(namespace) = provider_fields.get("namespace") else { if target == FormatId::OpenAiChat {
continue; if let Some(namespace) = provider_fields.get("namespace") {
}; let Some(namespace) = namespace
let Some(namespace) = namespace .as_str()
.as_str() .map(str::trim)
.map(str::trim) .filter(|namespace| !namespace.is_empty())
.filter(|namespace| !namespace.is_empty()) else {
else { return Err(FormatError::LossyConversionBlocked {
return Err(FormatError::LossyConversionBlocked { source_format: source.as_str().to_string(),
source_format: source.as_str().to_string(), target_format: target.as_str().to_string(),
target_format: target.as_str().to_string(), field: format!("messages[].content[].{provider_namespace}.namespace"),
field: format!("messages[].content[].{provider_namespace}.namespace"), reason: "Responses namespace tool call has an invalid namespace identity"
reason: "Responses namespace tool call has an invalid namespace identity" .to_string(),
.to_string(), });
}); };
}; if aliases.chat_name(namespace, name).is_none() {
if aliases.chat_name(namespace, name).is_none() { return Err(FormatError::LossyConversionBlocked {
return Err(FormatError::LossyConversionBlocked { source_format: source.as_str().to_string(),
source_format: source.as_str().to_string(), target_format: target.as_str().to_string(),
target_format: target.as_str().to_string(), field: format!("messages[].content[].{provider_namespace}.namespace"),
field: format!("messages[].content[].{provider_namespace}.namespace"), reason: "Responses namespace tool call does not match an expanded namespace child"
reason: "Responses namespace tool call does not match an expanded namespace child" .to_string(),
.to_string(), });
}); }
provider_fields.remove("namespace");
}
} }
provider_fields.remove("namespace");
// Responses item IDs are distinct from executable call IDs, but Chat // Responses item IDs are distinct from executable call IDs, but Chat
// has only the latter. A completed history item is fully represented // and Gemini pair tools by the latter. A completed history item is
// by the assistant tool call itself, so these transport/completion // fully represented by the model function call, so these transport
// sidecars can be discarded after the namespace identity is proven. // sidecars can be discarded after any namespace identity is proven.
provider_fields.remove("item_id"); provider_fields.remove("item_id");
if provider_fields.get("status").and_then(Value::as_str) == Some("completed") { if provider_fields.get("status").and_then(Value::as_str) == Some("completed") {
provider_fields.remove("status"); provider_fields.remove("status");
@@ -1543,6 +1546,19 @@ fn request_extension_key_is_cross_format_safe(
{ {
return true; return true;
} }
if location == "messages[].content[]"
&& matches!(
(source, target, namespace, key),
(
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact,
FormatId::GeminiGenerateContent,
"gemini",
"thoughtSignature",
)
)
{
return true;
}
if location == "tools[]" { if location == "tools[]" {
return tool_extension_key_is_cross_format_safe(source, target, namespace, key); return tool_extension_key_is_cross_format_safe(source, target, namespace, key);
} }
@@ -4381,7 +4397,15 @@ mod tests {
let parts = converted["contents"][1]["parts"] let parts = converted["contents"][1]["parts"]
.as_array() .as_array()
.expect("tool response parts"); .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"]["id"], "call_1");
assert_eq!(parts[0]["functionResponse"]["response"]["result"], "one"); assert_eq!(parts[0]["functionResponse"]["response"]["result"], "one");
assert_eq!(parts[1]["functionResponse"]["id"], "call_2"); assert_eq!(parts[1]["functionResponse"]["id"], "call_2");
@@ -4439,6 +4463,127 @@ mod tests {
assert_eq!(parts[3]["text"], "Continue."); 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] #[test]
fn pure_gemini_to_openai_responses_blocks_thought_part_loss() { fn pure_gemini_to_openai_responses_blocks_thought_part_loss() {
let body = json!({ let body = json!({
@@ -3269,6 +3269,7 @@ struct GeminiSyncToolState {
call_id: String, call_id: String,
name: String, name: String,
arguments: String, arguments: String,
thought_signature: String,
part_index: Option<usize>, part_index: Option<usize>,
} }
@@ -3596,6 +3597,13 @@ fn try_aggregate_gemini_stream_sync_response(
parts[part_index] = sync_gemini_function_call_part(state); 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 } => { CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let state = tool_states.entry(index).or_default(); let state = tool_states.entry(index).or_default();
state.arguments.push_str(&arguments); state.arguments.push_str(&arguments);
@@ -3790,7 +3798,7 @@ fn is_mergeable_gemini_text_part(part: &Map<String, Value>, thought: bool) -> bo
} }
fn sync_gemini_function_call_part(state: &GeminiSyncToolState) -> Value { fn sync_gemini_function_call_part(state: &GeminiSyncToolState) -> Value {
json!({ let mut part = json!({
"functionCall": { "functionCall": {
"id": if state.call_id.trim().is_empty() { "id": if state.call_id.trim().is_empty() {
"call_auto_0".to_string() "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), "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( fn sync_gemini_function_response_part(
@@ -3,6 +3,9 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; 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::openai::shared::map_thinking_budget_to_openai_reasoning_effort;
use crate::formats::shared::model_directives::ReasoningEffort; use crate::formats::shared::model_directives::ReasoningEffort;
use crate::formats::shared::response::remove_empty_pages_from_tool_input_value; 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(); .to_ascii_lowercase();
match item_type.as_str() { match item_type.as_str() {
"reasoning" => { "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" => { "message" => {
let role = openai_role_to_canonical( 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( fn append_openai_responses_tool_use(
messages: &mut Vec<CanonicalMessage>, messages: &mut Vec<CanonicalMessage>,
tool_use: CanonicalContentBlock, mut tool_use: CanonicalContentBlock,
pending_reasoning: &mut Option<CanonicalContentBlock>, pending_reasoning: &mut Option<CanonicalContentBlock>,
) { ) {
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 let Some(last_message) = messages.last_mut() {
if last_message.role == CanonicalRole::Assistant if last_message.role == CanonicalRole::Assistant
&& (!is_openai_responses_input_message(&last_message.extensions) && (!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 { fn canonical_assistant_message_has_visible_content(message: &CanonicalMessage) -> bool {
message.content.iter().any(|block| match block { message.content.iter().any(|block| match block {
CanonicalContentBlock::Text { text, .. } | CanonicalContentBlock::Thinking { text, .. } => { CanonicalContentBlock::Text { text, .. } | CanonicalContentBlock::Thinking { text, .. } => {
@@ -60,6 +60,10 @@ pub enum CanonicalStreamEvent {
call_id: String, call_id: String,
name: String, name: String,
}, },
ToolCallSignature {
index: usize,
signature: String,
},
ToolCallArgumentsDelta { ToolCallArgumentsDelta {
index: usize, index: usize,
arguments: String, arguments: String,