mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
fix(formats): preserve Gemini tool thought signatures
This commit is contained in:
@@ -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!({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user