mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
fix(proxy): align shadow turns to tail on client history truncation
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user