mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user