mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
feat(proxy): session-based prompt_cache_key routing for Codex Chat bridge
Codex always sends prompt_cache_key in its Responses requests, but the Responses -> Chat Completions conversion dropped it, breaking session cache affinity on upstreams that route by key (e.g. Kimi Coding). - Re-inject prompt_cache_key after conversion in the forwarder: an explicit client key wins, otherwise a client-provided session ID; generated per-request UUIDs are never sent upstream. - Provider-aware gating: "auto" enables only known-compatible upstreams (api.openai.com, api.kimi.com/coding) because strict gateways reject unknown fields with HTTP 400 (e.g. Fireworks); an advanced Auto/Enabled/Disabled override is available on the Codex form in all four locales. - Kimi For Coding preset opts in explicitly.
This commit is contained in:
@@ -457,6 +457,10 @@ pub struct ProviderMeta {
|
||||
/// identity when available; generated session IDs are not sent upstream.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// Session-based prompt-cache routing for Codex Responses -> Chat conversions.
|
||||
/// "auto" enables known-compatible upstreams; "enabled" / "disabled" are overrides.
|
||||
#[serde(rename = "promptCacheRouting", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_routing: Option<String>,
|
||||
/// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests.
|
||||
#[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")]
|
||||
pub codex_fast_mode: Option<bool>,
|
||||
|
||||
@@ -1384,6 +1384,10 @@ impl RequestForwarder {
|
||||
// 转换请求体(如果需要)
|
||||
let mut request_body = if codex_responses_to_chat {
|
||||
let mut mapped_body = mapped_body;
|
||||
let explicit_prompt_cache_key = mapped_body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let restored = self
|
||||
.codex_chat_history
|
||||
.enrich_request(&mut mapped_body)
|
||||
@@ -1396,10 +1400,18 @@ impl RequestForwarder {
|
||||
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
|
||||
let reasoning_config =
|
||||
super::providers::resolve_codex_chat_reasoning_config(provider, &mapped_body);
|
||||
super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning(
|
||||
let mut chat_body = super::providers::transform_codex_chat::responses_to_chat_completions_with_reasoning(
|
||||
mapped_body,
|
||||
reasoning_config.as_ref(),
|
||||
)?
|
||||
)?;
|
||||
super::providers::inject_codex_chat_prompt_cache_key(
|
||||
provider,
|
||||
&mut chat_body,
|
||||
explicit_prompt_cache_key.as_deref(),
|
||||
self.session_client_provided
|
||||
.then_some(self.session_id.as_str()),
|
||||
);
|
||||
chat_body
|
||||
} else if codex_responses_to_anthropic {
|
||||
let mut mapped_body = mapped_body;
|
||||
super::providers::apply_codex_upstream_model(provider, &mut mapped_body);
|
||||
|
||||
@@ -84,6 +84,81 @@ pub fn should_convert_codex_responses_to_chat(provider: &Provider, endpoint: &st
|
||||
) && codex_provider_uses_chat_completions(provider)
|
||||
}
|
||||
|
||||
/// Whether a converted Codex Responses request may send `prompt_cache_key` to
|
||||
/// its Chat Completions upstream. Unknown OpenAI-compatible gateways default to
|
||||
/// false because many reject unsupported request fields with HTTP 400.
|
||||
pub fn should_send_codex_chat_prompt_cache_key(provider: &Provider) -> bool {
|
||||
match provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.prompt_cache_routing.as_deref())
|
||||
.unwrap_or("auto")
|
||||
{
|
||||
"enabled" => return true,
|
||||
"disabled" => return false,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let base_url = provider
|
||||
.settings_config
|
||||
.get("base_url")
|
||||
.or_else(|| provider.settings_config.get("baseURL"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| {
|
||||
provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(extract_codex_base_url_from_toml)
|
||||
});
|
||||
|
||||
let Some(base_url) = base_url else {
|
||||
return false;
|
||||
};
|
||||
let Ok(url) = url::Url::parse(&base_url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match url.host_str() {
|
||||
Some("api.openai.com") => true,
|
||||
Some("api.kimi.com") => {
|
||||
let path = url.path().trim_end_matches('/');
|
||||
path == "/coding" || path.starts_with("/coding/")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a stable cache-routing key after Responses -> Chat conversion. An
|
||||
/// explicit client key wins; otherwise only a real client-provided session ID
|
||||
/// is eligible. Generated per-request UUIDs must never be used here.
|
||||
pub fn inject_codex_chat_prompt_cache_key(
|
||||
provider: &Provider,
|
||||
chat_body: &mut JsonValue,
|
||||
explicit_key: Option<&str>,
|
||||
client_session_id: Option<&str>,
|
||||
) -> bool {
|
||||
if !should_send_codex_chat_prompt_cache_key(provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let key = explicit_key
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty())
|
||||
.or_else(|| {
|
||||
client_session_id
|
||||
.map(str::trim)
|
||||
.filter(|session_id| !session_id.is_empty())
|
||||
});
|
||||
let Some(key) = key else {
|
||||
return false;
|
||||
};
|
||||
|
||||
chat_body["prompt_cache_key"] = JsonValue::String(key.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether this Codex provider's real upstream speaks the native Anthropic
|
||||
/// Messages protocol (`/v1/messages`). The local Codex client always talks to CC
|
||||
/// Switch through the Responses API, so CC Switch bridges Responses ⇄ Anthropic.
|
||||
@@ -706,6 +781,100 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_routing_auto_enables_known_upstreams_only() {
|
||||
let kimi = create_provider(json!({
|
||||
"config": r#"
|
||||
model_provider = "custom"
|
||||
[model_providers.custom]
|
||||
base_url = "https://api.kimi.com/coding/v1"
|
||||
wire_api = "responses"
|
||||
"#
|
||||
}));
|
||||
let openai = create_provider(json!({
|
||||
"base_url": "https://api.openai.com/v1"
|
||||
}));
|
||||
let unknown = create_provider(json!({
|
||||
"base_url": "https://strict.example.com/v1"
|
||||
}));
|
||||
|
||||
assert!(should_send_codex_chat_prompt_cache_key(&kimi));
|
||||
assert!(should_send_codex_chat_prompt_cache_key(&openai));
|
||||
assert!(!should_send_codex_chat_prompt_cache_key(&unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_routing_user_override_wins_over_auto_detection() {
|
||||
let mut kimi = create_provider(json!({
|
||||
"base_url": "https://api.kimi.com/coding/v1"
|
||||
}));
|
||||
kimi.meta = Some(crate::provider::ProviderMeta {
|
||||
prompt_cache_routing: Some("disabled".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!should_send_codex_chat_prompt_cache_key(&kimi));
|
||||
|
||||
let mut unknown = create_provider(json!({
|
||||
"base_url": "https://strict.example.com/v1"
|
||||
}));
|
||||
unknown.meta = Some(crate::provider::ProviderMeta {
|
||||
prompt_cache_routing: Some("enabled".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(should_send_codex_chat_prompt_cache_key(&unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_prefers_explicit_key_then_real_session() {
|
||||
let provider = create_provider(json!({
|
||||
"base_url": "https://api.kimi.com/coding/v1"
|
||||
}));
|
||||
let mut explicit_body = json!({ "model": "kimi-for-coding" });
|
||||
assert!(inject_codex_chat_prompt_cache_key(
|
||||
&provider,
|
||||
&mut explicit_body,
|
||||
Some("request-key"),
|
||||
Some("session-key"),
|
||||
));
|
||||
assert_eq!(explicit_body["prompt_cache_key"], "request-key");
|
||||
|
||||
let mut session_body = json!({ "model": "kimi-for-coding" });
|
||||
assert!(inject_codex_chat_prompt_cache_key(
|
||||
&provider,
|
||||
&mut session_body,
|
||||
None,
|
||||
Some("session-key"),
|
||||
));
|
||||
assert_eq!(session_body["prompt_cache_key"], "session-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_is_not_injected_without_real_session_or_support() {
|
||||
let kimi = create_provider(json!({
|
||||
"base_url": "https://api.kimi.com/coding/v1"
|
||||
}));
|
||||
let mut no_session_body = json!({ "model": "kimi-for-coding" });
|
||||
assert!(!inject_codex_chat_prompt_cache_key(
|
||||
&kimi,
|
||||
&mut no_session_body,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
assert!(no_session_body.get("prompt_cache_key").is_none());
|
||||
|
||||
let unknown = create_provider(json!({
|
||||
"base_url": "https://strict.example.com/v1"
|
||||
}));
|
||||
let mut unsupported_body = json!({ "model": "other" });
|
||||
assert!(!inject_codex_chat_prompt_cache_key(
|
||||
&unknown,
|
||||
&mut unsupported_body,
|
||||
Some("request-key"),
|
||||
Some("session-key"),
|
||||
));
|
||||
assert!(unsupported_body.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url_direct() {
|
||||
let adapter = CodexAdapter::new();
|
||||
|
||||
@@ -52,8 +52,9 @@ pub use claude::{
|
||||
pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
apply_codex_chat_upstream_model, apply_codex_upstream_model, codex_provider_upstream_model,
|
||||
resolve_codex_catalog_tool_profile, resolve_codex_chat_reasoning_config,
|
||||
should_convert_codex_responses_to_anthropic, should_convert_codex_responses_to_chat,
|
||||
inject_codex_chat_prompt_cache_key, resolve_codex_catalog_tool_profile,
|
||||
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_anthropic,
|
||||
should_convert_codex_responses_to_chat,
|
||||
};
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user