fix(proxy): align shadow turns to tail on client history truncation

This commit is contained in:
YoVinchen
2026-04-06 23:42:47 +08:00
parent 7cf554c011
commit 44ac926210
3 changed files with 124 additions and 5 deletions
+21 -1
View File
@@ -65,7 +65,7 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool {
let path = path.trim_end_matches('/');
path.contains("/v1beta/models/")
|| path.contains("/v1/models/")
|| path.contains("/models/")
|| matches_bare_gemini_models_path(path)
|| path.ends_with("/v1beta")
|| path.ends_with("/v1")
|| path.ends_with("/v1beta/models")
@@ -150,6 +150,15 @@ fn normalize_prefix(prefix: &str) -> String {
}
}
fn matches_bare_gemini_models_path(path: &str) -> bool {
if let Some(idx) = path.find("/models/") {
let after = &path[idx + "/models/".len()..];
after.contains(":generateContent") || after.contains(":streamGenerateContent")
} else {
false
}
}
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
let parts: Vec<&str> = [base_query, endpoint_query]
.into_iter()
@@ -245,4 +254,15 @@ mod tests {
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn preserves_opaque_full_url_containing_models_segment() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/models/invoke",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/models/invoke?alt=sse");
}
}
@@ -268,12 +268,17 @@ fn convert_messages_to_contents(
shadow_turns: &[GeminiAssistantTurn],
) -> Result<Vec<Value>, ProxyError> {
let mut contents = Vec::new();
let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns);
let total_assistant_messages = messages
.iter()
.filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant"))
.count();
let shadow_start_index = total_assistant_messages.saturating_sub(shadow_turns.len());
let effective_shadow_turns = if shadow_turns.len() > total_assistant_messages {
&shadow_turns[shadow_turns.len() - total_assistant_messages..]
} else {
shadow_turns
};
let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns);
let shadow_start_index = total_assistant_messages.saturating_sub(effective_shadow_turns.len());
let mut assistant_seen_index = 0usize;
for message in messages {
@@ -287,11 +292,11 @@ fn convert_messages_to_contents(
let parts = if role == "assistant" {
let shadow_index = assistant_seen_index
.checked_sub(shadow_start_index)
.filter(|index| *index < shadow_turns.len());
.filter(|index| *index < effective_shadow_turns.len());
assistant_seen_index += 1;
if let Some(index) = shadow_index {
let shadow_turn = &shadow_turns[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) {
parts
@@ -1124,4 +1129,71 @@ mod tests {
.unwrap()
.contains("SAFETY"));
}
#[test]
fn shadow_replay_aligns_to_latest_turns_after_client_truncation() {
let store = GeminiShadowStore::with_limits(8, 4);
// Record 3 shadow turns (assistant messages 0, 1, 2)
for i in 0..3 {
store.record_assistant_turn(
"prov",
"sess",
json!({
"parts": [{
"functionCall": {
"id": format!("call_{i}"),
"name": format!("tool_{i}"),
"args": {}
}
}]
}),
vec![],
);
}
// Client truncates history: only sends assistant messages 1 and 2
let input = json!({
"messages": [
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_1", "name": "tool_1", "input": {} }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_1", "content": "ok" }
]
},
{
"role": "assistant",
"content": [
{ "type": "tool_use", "id": "call_2", "name": "tool_2", "input": {} }
]
},
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "call_2", "content": "ok" }
]
}
]
});
let result =
anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess"))
.unwrap();
// Shadow turns[1] (tool_1) should align with first assistant message,
// shadow turns[2] (tool_2) with the second — not turns[0] and turns[1].
assert_eq!(
result["contents"][0]["parts"][0]["functionCall"]["name"],
"tool_1"
);
assert_eq!(
result["contents"][2]["parts"][0]["functionCall"]["name"],
"tool_2"
);
}
}
+27
View File
@@ -52,6 +52,14 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景1b: Gemini/第三方渠道返回 "Thought signature is not valid"
// 错误示例: "Unable to submit request because Thought signature is not valid"
if lower.contains("thought signature")
&& (lower.contains("not valid") || lower.contains("invalid"))
{
return true;
}
// 场景2: assistant 消息必须以 thinking block 开头
// 错误示例: "must start with a thinking block"
if lower.contains("must start with a thinking block") {
@@ -280,6 +288,16 @@ mod tests {
));
}
#[test]
fn test_detect_invalid_thought_signature_message() {
assert!(should_rectify_thinking_signature(
Some(
"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help"
),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_nested_json() {
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
@@ -290,6 +308,15 @@ mod tests {
));
}
#[test]
fn test_detect_invalid_thought_signature_nested_json() {
let nested_error = r#"{"error":{"message":"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help","type":"upstream_error","param":"","code":400}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_expected() {
assert!(should_rectify_thinking_signature(