Keep Gemini tool replay stable across Claude request boundaries

Claude Code follow-up requests were still falling back to locally reconstructed functionCall parts, which dropped Gemini thought signatures and triggered INVALID_ARGUMENT errors from the official Gemini API. The replay path needed to survive real Claude request boundaries, not just idealized in-process test flows.

This change makes Claude requests reuse X-Claude-Code-Session-Id as the shadow session key, records streamed Gemini tool turns before tool_use events are fully drained, and matches assistant tool_use turns to shadow state by tool_use id and normalized tool name before positional fallback. Together these fixes keep thoughtSignature-bearing Gemini tool calls available for the next request in the loop.

Constraint: Claude Code sends a stable X-Claude-Code-Session-Id header while metadata.session_id may be absent on follow-up requests
Rejected: Rely on metadata-only Claude session extraction | generated fresh session ids and broke cross-request shadow replay
Rejected: Record Gemini shadow only after streaming completes | loses the race when the client sends the next request immediately after tool_use
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve Gemini shadow continuity across requests by keying Claude sessions from the header first and persisting tool-call shadow before yielding tool_use events downstream
Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header_precedes_metadata; cargo test --manifest-path src-tauri/Cargo.toml stores_tool_shadow_before_tool_use_events_are_fully_drained; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_matches_tool_use_turn_by_id_when_position_drifts; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_aligns_to_latest_turns_after_client_truncation
Not-tested: Full src-tauri test suite without test filters; live end-to-end Gemini relay after this exact commit hash
This commit is contained in:
YoVinchen
2026-04-16 11:31:23 +08:00
parent 8bdc4d6326
commit 2309b49ed5
3 changed files with 268 additions and 27 deletions
@@ -408,6 +408,32 @@ pub fn create_anthropic_sse_stream_from_gemini<E: std::error::Error + Send + 'st
}
}
if let (Some(store), Some(provider_id), Some(session_id)) = (
shadow_store.as_ref(),
provider_id.as_deref(),
session_id.as_deref(),
) {
let tool_calls = tool_call_snapshots.clone();
let shadow_text = if accumulated_text.is_empty() {
blocked_text.as_deref()
} else {
Some(accumulated_text.as_str())
};
let shadow_parts = build_shadow_assistant_parts(
shadow_text,
text_thought_signature.as_deref(),
&tool_calls,
);
if !shadow_parts.is_empty() {
store.record_assistant_turn(
provider_id,
session_id,
json!({ "parts": shadow_parts }),
tool_calls.clone(),
);
}
}
let tool_calls = tool_call_snapshots;
for tool_call in &tool_calls {
let index = next_content_index;
@@ -441,31 +467,6 @@ pub fn create_anthropic_sse_stream_from_gemini<E: std::error::Error + Send + 'st
yield Ok(encode_sse("content_block_stop", &stop_event));
}
if let (Some(store), Some(provider_id), Some(session_id)) = (
shadow_store.as_ref(),
provider_id.as_deref(),
session_id.as_deref(),
) {
let shadow_text = if accumulated_text.is_empty() {
blocked_text.as_deref()
} else {
Some(accumulated_text.as_str())
};
let shadow_parts = build_shadow_assistant_parts(
shadow_text,
text_thought_signature.as_deref(),
&tool_calls,
);
if !shadow_parts.is_empty() {
store.record_assistant_turn(
provider_id,
session_id,
json!({ "parts": shadow_parts }),
tool_calls.clone(),
);
}
}
let stop_reason = map_finish_reason(
latest_finish_reason.as_deref(),
!tool_calls.is_empty(),
@@ -666,6 +667,41 @@ mod tests {
);
}
#[test]
fn stores_tool_shadow_before_tool_use_events_are_fully_drained() {
let store = Arc::new(GeminiShadowStore::with_limits(8, 4));
let chunks = vec![
"data: {\"responseId\":\"resp_tool_shadow\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"Bash\",\"args\":{\"command\":\"ls -R\"}},\"thoughtSignature\":\"sig-tool-1\"}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n".to_string(),
];
let stream = futures::stream::iter(
chunks
.into_iter()
.map(|chunk| Ok::<Bytes, std::io::Error>(Bytes::from(chunk))),
);
let mut converted = Box::pin(create_anthropic_sse_stream_from_gemini(
stream,
Some(store.clone()),
Some("provider-a".to_string()),
Some("session-1".to_string()),
None,
));
futures::executor::block_on(async {
while let Some(item) = converted.next().await {
let event = String::from_utf8(item.unwrap().to_vec()).unwrap();
if event.contains("\"type\":\"tool_use\"") {
break;
}
}
});
let shadow = store
.latest_assistant_content("provider-a", "session-1")
.unwrap();
assert_eq!(shadow["parts"][0]["functionCall"]["name"], "Bash");
assert_eq!(shadow["parts"][0]["thoughtSignature"], "sig-tool-1");
}
#[test]
fn rectifies_streamed_tool_call_args_from_tool_schema_hints() {
let owned_chunks = vec![
@@ -268,6 +268,7 @@ fn convert_messages_to_contents(
shadow_turns: &[GeminiAssistantTurn],
) -> Result<Vec<Value>, ProxyError> {
let mut contents = Vec::new();
let mut used_shadow_indices = HashSet::new();
let total_assistant_messages = messages
.iter()
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant"))
@@ -290,12 +291,20 @@ fn convert_messages_to_contents(
let gemini_role = if role == "assistant" { "model" } else { "user" };
let parts = if role == "assistant" {
let shadow_index = assistant_seen_index
let positional_shadow_index = assistant_seen_index
.checked_sub(shadow_start_index)
.filter(|index| *index < effective_shadow_turns.len());
.filter(|index| *index < effective_shadow_turns.len())
.filter(|index| !used_shadow_indices.contains(index));
let tool_use_match_index = find_matching_shadow_turn_for_assistant_message(
message.get("content"),
effective_shadow_turns,
)
.filter(|index| !used_shadow_indices.contains(index));
assistant_seen_index += 1;
let shadow_index = tool_use_match_index.or(positional_shadow_index);
if let Some(index) = shadow_index {
used_shadow_indices.insert(index);
let shadow_turn = &effective_shadow_turns[index];
merge_tool_names_from_shadow(shadow_turn, &mut tool_name_by_id);
if let Some(parts) = shadow_parts(&shadow_turn.assistant_content) {
@@ -331,6 +340,67 @@ fn convert_messages_to_contents(
Ok(contents)
}
fn find_matching_shadow_turn_for_assistant_message(
content: Option<&Value>,
shadow_turns: &[GeminiAssistantTurn],
) -> Option<usize> {
let (tool_use_ids, tool_use_names) = extract_assistant_tool_use_keys(content);
if tool_use_ids.is_empty() && tool_use_names.is_empty() {
return None;
}
shadow_turns.iter().enumerate().find_map(|(index, turn)| {
turn.tool_calls
.iter()
.any(|tool_call| {
tool_call
.id
.as_deref()
.is_some_and(|id| tool_use_ids.contains(id))
|| tool_use_names.contains(tool_call.name.as_str())
|| tool_use_names.contains(normalize_tool_name(&tool_call.name))
})
.then_some(index)
})
}
fn extract_assistant_tool_use_keys(content: Option<&Value>) -> (HashSet<String>, HashSet<String>) {
let mut tool_use_ids = HashSet::new();
let mut tool_use_names = HashSet::new();
let Some(blocks) = content.and_then(|value| value.as_array()) else {
return (tool_use_ids, tool_use_names);
};
for block in blocks {
if block.get("type").and_then(|value| value.as_str()) != Some("tool_use") {
continue;
}
if let Some(id) = block
.get("id")
.and_then(|value| value.as_str())
.filter(|id| !id.is_empty())
{
tool_use_ids.insert(id.to_string());
}
if let Some(name) = block
.get("name")
.and_then(|value| value.as_str())
.filter(|name| !name.is_empty())
{
tool_use_names.insert(name.to_string());
tool_use_names.insert(normalize_tool_name(name).to_string());
}
}
(tool_use_ids, tool_use_names)
}
fn normalize_tool_name(name: &str) -> &str {
name.rsplit(':').next().unwrap_or(name)
}
fn convert_message_content_to_parts(
content: Option<&Value>,
role: &str,
@@ -1259,4 +1329,70 @@ mod tests {
"tool_2"
);
}
#[test]
fn shadow_replay_matches_tool_use_turn_by_id_when_position_drifts() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": "call_1",
"name": "Bash",
"args": { "command": "ls -R" }
},
"thoughtSignature": "sig-tool-1"
}]
}),
vec![GeminiToolCallMeta::new(
Some("call_1"),
"Bash",
json!({ "command": "ls -R" }),
Some("sig-tool-1"),
)],
);
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "default_api:Bash",
"input": { "command": "ls -R" }
}
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "ok" }
]
},
{
"role": "assistant",
"content": [
{ "type": "text", "text": "local-only assistant turn without Gemini shadow" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"Bash"
);
assert_eq!(
result["contents"][0]["parts"][0]["thoughtSignature"],
"sig-tool-1"
);
}
}
+69
View File
@@ -242,6 +242,12 @@ pub fn extract_session_id(
body: &serde_json::Value,
client_format: &str,
) -> SessionIdResult {
if client_format == "claude" {
if let Some(result) = extract_claude_session(headers, body) {
return result;
}
}
// Codex 请求特殊处理
if client_format == "codex" || client_format == "openai" {
if let Some(result) = extract_codex_session(headers, body) {
@@ -258,6 +264,28 @@ pub fn extract_session_id(
generate_new_session_id()
}
/// 提取 Claude Session ID
fn extract_claude_session(
headers: &HeaderMap,
body: &serde_json::Value,
) -> Option<SessionIdResult> {
for header_name in &["x-claude-code-session-id", "claude-code-session-id"] {
if let Some(value) = headers.get(*header_name) {
if let Ok(session_id) = value.to_str() {
if !session_id.is_empty() {
return Some(SessionIdResult {
session_id: session_id.to_string(),
source: SessionIdSource::Header,
client_provided: true,
});
}
}
}
}
extract_from_metadata(body)
}
/// 提取 Codex Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
// 1. 从 headers 提取
@@ -515,6 +543,47 @@ mod tests {
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_claude_header() {
let mut headers = HeaderMap::new();
headers.insert(
"x-claude-code-session-id",
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
);
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = extract_session_id(&headers, &body, "claude");
assert_eq!(result.session_id, "d937243f-2702-4f20-97b6-c9682235ab81");
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_claude_header_precedes_metadata() {
let mut headers = HeaderMap::new();
headers.insert(
"x-claude-code-session-id",
"header-session-123".parse().unwrap(),
);
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"session_id": "my-session-123"
}
});
let result = extract_session_id(&headers, &body, "claude");
assert_eq!(result.session_id, "header-session-123");
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_codex_previous_response_id() {
let headers = HeaderMap::new();