mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6bacd6b4a |
@@ -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。
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user