Compare commits

..

1 Commits

Author SHA1 Message Date
YoVinchen b6bacd6b4a Stop sending prompt cache keys on Claude chat conversions
Responses conversions still use promptCacheKey, but chat completions now stay a pure shape transform. This keeps Claude -> chat requests aligned with providers that do not understand the field and keeps stream checks consistent with production behavior.

Constraint: Issue #1919 requires removing prompt_cache_key from Claude -> OpenAI Chat requests
Rejected: Add a runtime toggle for chat injection | requested behavior is unconditional removal
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep promptCacheKey limited to Claude -> Responses conversions unless a provider-specific contract is proven
Tested: cargo test anthropic_to_openai
Tested: cargo test anthropic_to_responses_with_cache_key
Tested: cargo test transform_claude_request_for_api_format_responses
Not-tested: Full src-tauri test suite
Related: #1919
2026-04-11 16:46:41 +08:00
5 changed files with 40 additions and 121 deletions
+3 -3
View File
@@ -282,9 +282,9 @@ pub struct ProviderMeta {
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径) /// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")] #[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>, pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI-compatible endpoints. /// Prompt cache key for OpenAI Responses-compatible endpoints.
/// When set, injected into converted requests to improve cache hit rate. /// When set, injected into converted Responses requests to improve cache hit rate.
/// If not set, provider ID is used automatically during format conversion. /// If not set, provider ID is used automatically during Claude -> Responses conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>, pub prompt_cache_key: Option<String>,
/// 累加模式应用中,该 provider 是否已写入 live config。 /// 累加模式应用中,该 provider 是否已写入 live config。
+6 -7
View File
@@ -81,14 +81,13 @@ pub fn transform_claude_request_for_api_format(
provider: &Provider, provider: &Provider,
api_format: &str, api_format: &str,
) -> Result<serde_json::Value, ProxyError> { ) -> Result<serde_json::Value, ProxyError> {
let cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
match api_format { match api_format {
"openai_responses" => { "openai_responses" => {
let cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false // Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。 // + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
let is_codex_oauth = provider let is_codex_oauth = provider
@@ -102,7 +101,7 @@ pub fn transform_claude_request_for_api_format(
is_codex_oauth, is_codex_oauth,
) )
} }
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)), "openai_chat" => super::transform::anthropic_to_openai(body),
_ => Ok(body), _ => Ok(body),
} }
} }
+29 -109
View File
@@ -71,10 +71,8 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
} }
} }
/// Anthropic 请求 → OpenAI 请求 /// Anthropic 请求 → OpenAI Chat Completions 请求
/// pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
let mut result = json!({}); let mut result = json!({});
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
@@ -175,11 +173,6 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
result["tool_choice"] = v.clone(); result["tool_choice"] = v.clone();
} }
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
if let Some(key) = cache_key {
result["prompt_cache_key"] = json!(key);
}
Ok(result) Ok(result)
} }
@@ -206,10 +199,6 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
} }
let mut parts = Vec::new(); let mut parts = Vec::new();
let mut inherited_cache_control: Option<Value> = None;
let mut cache_control_conflict = false;
let mut saw_cache_control = false;
let mut saw_missing_cache_control = false;
messages.retain(|message| { messages.retain(|message| {
if message.get("role").and_then(|value| value.as_str()) != Some("system") { if message.get("role").and_then(|value| value.as_str()) != Some("system") {
return true; return true;
@@ -230,28 +219,11 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
_ => {} _ => {}
} }
if let Some(cache_control) = message.get("cache_control") {
saw_cache_control = true;
match &inherited_cache_control {
None => inherited_cache_control = Some(cache_control.clone()),
Some(existing) if existing == cache_control => {}
Some(_) => cache_control_conflict = true,
}
} else {
saw_missing_cache_control = true;
}
false false
}); });
if !parts.is_empty() { if !parts.is_empty() {
let mut merged = json!({"role": "system", "content": parts.join("\n")}); messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
if let Some(cache_control) = inherited_cache_control {
merged["cache_control"] = cache_control;
}
}
messages.insert(0, merged);
} }
} }
@@ -590,7 +562,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "claude-3-opus"); assert_eq!(result["model"], "claude-3-opus");
assert_eq!(result["max_tokens"], 1024); assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user"); assert_eq!(result["messages"][0]["role"], "user");
@@ -606,7 +578,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system"); assert_eq!(result["messages"][0]["role"], "system");
assert_eq!( assert_eq!(
result["messages"][0]["content"], result["messages"][0]["content"],
@@ -628,76 +600,36 @@ mod tests {
}] }]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
} }
#[test] #[test]
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() { fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
let input = json!({ let input = json!({
"model": "claude-3-sonnet", "model": "claude-3-sonnet",
"max_tokens": 1024, "max_tokens": 1024,
"system": [ "system": [
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": "You are Claude Code."},
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral"}} {"type": "text", "text": "Be concise."}
], ],
"messages": [{"role": "user", "content": "Hello"}] "messages": [
{"role": "system", "content": "Follow repo conventions."},
{"role": "user", "content": "Hello"}
]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"].as_array().unwrap().len(), 2); assert_eq!(result["messages"].as_array().unwrap().len(), 2);
assert_eq!(result["messages"][0]["role"], "system"); assert_eq!(result["messages"][0]["role"], "system");
assert_eq!( assert_eq!(
result["messages"][0]["content"], result["messages"][0]["content"],
"You are Claude Code.\nBe concise." "You are Claude Code.\nBe concise.\nFollow repo conventions."
); );
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(result["messages"][1]["role"], "user"); assert_eq!(result["messages"][1]["role"], "user");
} }
#[test]
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Be concise."}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are Claude Code.\nBe concise."
);
assert!(result["messages"][0].get("cache_control").is_none());
}
#[test]
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are Claude Code.\nBe concise."
);
assert!(result["messages"][0].get("cache_control").is_none());
}
#[test] #[test]
fn test_anthropic_to_openai_tool_use() { fn test_anthropic_to_openai_tool_use() {
let input = json!({ let input = json!({
@@ -712,7 +644,7 @@ mod tests {
}] }]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0]; let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant"); assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some()); assert!(msg.get("tool_calls").is_some());
@@ -732,7 +664,7 @@ mod tests {
}] }]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0]; let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool"); assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123"); assert_eq!(msg["tool_call_id"], "call_123");
@@ -804,31 +736,19 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "gpt-4o"); assert_eq!(result["model"], "gpt-4o");
} }
#[test] #[test]
fn test_anthropic_to_openai_with_cache_key() { fn test_anthropic_to_openai_does_not_inject_prompt_cache_key() {
let input = json!({ let input = json!({
"model": "claude-3-opus", "model": "claude-3-opus",
"max_tokens": 1024, "max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, Some("provider-123")).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["prompt_cache_key"], "provider-123");
}
#[test]
fn test_anthropic_to_openai_no_cache_key() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("prompt_cache_key").is_none()); assert!(result.get("prompt_cache_key").is_none());
} }
@@ -854,7 +774,7 @@ mod tests {
}] }]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
// System message cache_control preserved // System message cache_control preserved
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral"); assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
// Text block cache_control preserved // Text block cache_control preserved
@@ -1108,7 +1028,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert!(result.get("reasoning_effort").is_none()); assert!(result.get("reasoning_effort").is_none());
} }
@@ -1121,7 +1041,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "medium"); assert_eq!(result["reasoning_effort"], "medium");
} }
@@ -1134,7 +1054,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh"); assert_eq!(result["reasoning_effort"], "xhigh");
} }
@@ -1147,7 +1067,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "low"); assert_eq!(result["reasoning_effort"], "low");
} }
@@ -1160,7 +1080,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh"); assert_eq!(result["reasoning_effort"], "xhigh");
} }
@@ -1172,7 +1092,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert!(result.get("reasoning_effort").is_none()); assert!(result.get("reasoning_effort").is_none());
} }
@@ -1185,7 +1105,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert!( assert!(
result.get("max_tokens").is_none(), result.get("max_tokens").is_none(),
"{model} should not have max_tokens" "{model} should not have max_tokens"
@@ -1205,7 +1125,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}] "messages": [{"role": "user", "content": "Hello"}]
}); });
let result = anthropic_to_openai(input, None).unwrap(); let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["max_tokens"], 1024); assert_eq!(result["max_tokens"], 1024);
assert!(result.get("max_completion_tokens").is_none()); assert!(result.get("max_completion_tokens").is_none());
} }
+1 -1
View File
@@ -372,7 +372,7 @@ impl StreamCheckService {
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth) anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_openai_chat { } else if is_openai_chat {
anthropic_to_openai(anthropic_body, Some(&provider.id)) anthropic_to_openai(anthropic_body)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else { } else {
anthropic_body anthropic_body
+1 -1
View File
@@ -166,7 +166,7 @@ export interface ProviderMeta {
apiKeyField?: ClaudeApiKeyField; apiKeyField?: ClaudeApiKeyField;
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径) // 是否将 base_url 视为完整 API 端点(代理直接使用此 URL,不拼接路径)
isFullUrl?: boolean; isFullUrl?: boolean;
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate) // Prompt cache key for OpenAI Responses-compatible endpoints (improves cache hit rate)
promptCacheKey?: string; promptCacheKey?: string;
// 供应商类型(用于识别 Copilot 等特殊供应商) // 供应商类型(用于识别 Copilot 等特殊供应商)
providerType?: string; providerType?: string;