mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
Merge pull request #836 from dalamudx/fix/gemini-thought-signature-replay
fix(gemini): preserve tool thought signatures
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||||
|
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -6,8 +6,9 @@ use sha2::{Digest, Sha256};
|
|||||||
use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
|
use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
|
||||||
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_with_direction, openai_responses_message_item_id,
|
decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction,
|
||||||
openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id,
|
openai_responses_message_item_id, openai_responses_reasoning_text_parts,
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
@@ -78,6 +79,8 @@ pub struct OpenAIResponsesProviderState {
|
|||||||
tool_index_by_key: BTreeMap<String, usize>,
|
tool_index_by_key: BTreeMap<String, usize>,
|
||||||
image_item_keys: BTreeSet<String>,
|
image_item_keys: BTreeSet<String>,
|
||||||
opaque_completed_item_keys: BTreeSet<OpenAIResponsesOutputItemKey>,
|
opaque_completed_item_keys: BTreeSet<OpenAIResponsesOutputItemKey>,
|
||||||
|
seen_tool_signature_carriers: BTreeSet<String>,
|
||||||
|
pending_tool_signatures: VecDeque<String>,
|
||||||
last_tool_index: Option<usize>,
|
last_tool_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,6 +873,7 @@ impl OpenAIResponsesProviderState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.emit_pending_tool_signature(report_context, out, index);
|
||||||
let state = self.tool_calls.entry(index).or_default();
|
let state = self.tool_calls.entry(index).or_default();
|
||||||
state.call_id = item
|
state.call_id = item
|
||||||
.get("call_id")
|
.get("call_id")
|
||||||
@@ -1204,6 +1208,68 @@ impl OpenAIResponsesProviderState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_tool_signature_carrier(
|
||||||
|
&mut self,
|
||||||
|
report_context: &Value,
|
||||||
|
out: &mut Vec<CanonicalStreamFrame>,
|
||||||
|
item: &Map<String, Value>,
|
||||||
|
) {
|
||||||
|
if self.terminal_only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(carrier) = item
|
||||||
|
.get("encrypted_content")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some((signature, direction)) = decode_gemini_tool_signature_carrier(carrier) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !self
|
||||||
|
.seen_tool_signature_carriers
|
||||||
|
.insert(Self::output_item_key(item))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match direction {
|
||||||
|
GeminiToolSignatureCarrierDirection::Next => {
|
||||||
|
self.pending_tool_signatures.push_back(signature);
|
||||||
|
}
|
||||||
|
GeminiToolSignatureCarrierDirection::Previous => {
|
||||||
|
let Some(index) = self.last_tool_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.ensure_started(report_context, out);
|
||||||
|
let (id, model) = self.identity(report_context);
|
||||||
|
out.push(CanonicalStreamFrame {
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
event: CanonicalStreamEvent::ToolCallSignature { index, signature },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_pending_tool_signature(
|
||||||
|
&mut self,
|
||||||
|
report_context: &Value,
|
||||||
|
out: &mut Vec<CanonicalStreamFrame>,
|
||||||
|
index: usize,
|
||||||
|
) {
|
||||||
|
let Some(signature) = self.pending_tool_signatures.pop_front() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.ensure_started(report_context, out);
|
||||||
|
let (id, model) = self.identity(report_context);
|
||||||
|
out.push(CanonicalStreamFrame {
|
||||||
|
id,
|
||||||
|
model,
|
||||||
|
event: CanonicalStreamEvent::ToolCallSignature { index, signature },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn emit_reasoning_item(
|
fn emit_reasoning_item(
|
||||||
&mut self,
|
&mut self,
|
||||||
report_context: &Value,
|
report_context: &Value,
|
||||||
@@ -1336,7 +1402,14 @@ impl OpenAIResponsesProviderState {
|
|||||||
output_index: Option<usize>,
|
output_index: Option<usize>,
|
||||||
final_item: bool,
|
final_item: bool,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if item_type == "reasoning" {
|
||||||
|
// Aether carries Gemini function-call signatures through Responses as
|
||||||
|
// encrypted reasoning items. Recover the carrier before the following
|
||||||
|
// function item is emitted so Gemini clients can replay it verbatim.
|
||||||
|
self.capture_tool_signature_carrier(report_context, out, item);
|
||||||
|
}
|
||||||
|
match item_type {
|
||||||
"function_call" => {
|
"function_call" => {
|
||||||
self.emit_tool_call_item(report_context, out, item, output_index);
|
self.emit_tool_call_item(report_context, out, item, output_index);
|
||||||
true
|
true
|
||||||
@@ -3931,6 +4004,7 @@ fn openai_responses_incomplete_finish_reason(payload: &Value) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
|
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
|
||||||
|
use crate::formats::gemini::generate_content::stream::GeminiClientEmitter;
|
||||||
use crate::formats::openai::responses::encode_gemini_tool_signature_carrier;
|
use crate::formats::openai::responses::encode_gemini_tool_signature_carrier;
|
||||||
|
|
||||||
fn data_line(value: Value) -> Vec<u8> {
|
fn data_line(value: Value) -> Vec<u8> {
|
||||||
@@ -5394,6 +5468,100 @@ mod tests {
|
|||||||
assert_eq!(text, "First message.Second message.");
|
assert_eq!(text, "First message.Second message.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_responses_provider_state_replays_gemini_signature_carrier_to_client() {
|
||||||
|
let mut state = OpenAIResponsesProviderState::default();
|
||||||
|
let report_context = json!({});
|
||||||
|
let signature = "skip_thought_signature_validator";
|
||||||
|
let carrier = encode_gemini_tool_signature_carrier(signature)
|
||||||
|
.expect("signature carrier should encode");
|
||||||
|
let reasoning_item = json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"id": "rs_signature_0",
|
||||||
|
"status": "completed",
|
||||||
|
"encrypted_content": carrier,
|
||||||
|
"summary": []
|
||||||
|
});
|
||||||
|
let events = [
|
||||||
|
json!({
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"response_id": "resp_signed_fallback",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": reasoning_item
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "response.output_item.done",
|
||||||
|
"response_id": "resp_signed_fallback",
|
||||||
|
"output_index": 0,
|
||||||
|
"item": reasoning_item
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "response.output_item.added",
|
||||||
|
"response_id": "resp_signed_fallback",
|
||||||
|
"output_index": 1,
|
||||||
|
"item": {
|
||||||
|
"type": "function_call",
|
||||||
|
"id": "fc_signed_1",
|
||||||
|
"call_id": "call_signed_1",
|
||||||
|
"name": "fabric_exec",
|
||||||
|
"arguments": "{\"code\":\"return 1\"}",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
for event in events {
|
||||||
|
frames.extend(
|
||||||
|
state
|
||||||
|
.push_line(&report_context, data_line(event))
|
||||||
|
.expect("Responses event should parse"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let signature_index = frames
|
||||||
|
.iter()
|
||||||
|
.position(|frame| {
|
||||||
|
matches!(
|
||||||
|
frame.event,
|
||||||
|
CanonicalStreamEvent::ToolCallSignature {
|
||||||
|
index: 1,
|
||||||
|
ref signature
|
||||||
|
} if signature == "skip_thought_signature_validator"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("signature event should be restored");
|
||||||
|
let call_index = frames
|
||||||
|
.iter()
|
||||||
|
.position(|frame| {
|
||||||
|
matches!(
|
||||||
|
frame.event,
|
||||||
|
CanonicalStreamEvent::ToolCallStart { index: 1, .. }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.expect("tool call should be emitted");
|
||||||
|
assert!(signature_index < call_index);
|
||||||
|
assert_eq!(
|
||||||
|
frames
|
||||||
|
.iter()
|
||||||
|
.filter(|frame| matches!(
|
||||||
|
frame.event,
|
||||||
|
CanonicalStreamEvent::ToolCallSignature { .. }
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"added and done snapshots must not duplicate the signature"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut emitter = GeminiClientEmitter::default();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for frame in frames {
|
||||||
|
bytes.extend(emitter.emit(frame).expect("Gemini frame should encode"));
|
||||||
|
}
|
||||||
|
let sse = String::from_utf8(bytes).expect("Gemini SSE should be UTF-8");
|
||||||
|
assert!(sse.contains("\"name\":\"fabric_exec\""));
|
||||||
|
assert!(sse.contains("\"thoughtSignature\":\"skip_thought_signature_validator\""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn openai_responses_provider_state_delays_arguments_until_tool_name_is_known() {
|
fn openai_responses_provider_state_delays_arguments_until_tool_name_is_known() {
|
||||||
let mut state = OpenAIResponsesProviderState::default();
|
let mut state = OpenAIResponsesProviderState::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user