mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-27 02:35:30 +08:00
fix(codex): proxy image edits and Images API follow-ups (#7177)
* fix(codex): proxy image edits and Images API follow-ups Follow-ups to #7036 for Codex's built-in ImageGen over the local proxy: - Register `/images/edits` aliases (bare, `/v1`, `/v1/v1`, `/codex/v1`) and forward them through the shared Codex standalone passthrough. ImageGen posts to `images/edits` whenever a call references existing images, and the router returned an empty 404 for it. - Add `CodexStandaloneEndpoint::ImagesEdits`. Both Images endpoints share one full-URL suffix table, so a complete Responses / Compact / Chat Completions / Images URL derives the sibling endpoint (and vice versa) while opaque full URLs keep failing closed. - Apply the standalone sibling rewrite when the base URL is pasted as a complete endpoint with the full-URL switch off, mirroring `codex_chat_base_is_full_endpoint`; standalone requests no longer become `.../chat/completions/images/generations`. - Parse Images API streaming usage: `image_generation.completed` carries `usage` on the event top level, so scan for `usage.input_tokens` before falling back to the Chat Completions stream parser. Related #7036 * fix(codex): match standalone URL suffixes case-insensitively Keep endpoint detection and sibling rewriting consistent while preserving the original URL prefix and query. Cover mixed-case Images and Alpha Search endpoints through URL and proxy regression cases. --------- Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
3 files changed
+247
-1
No files matched your search
@@ -1501,6 +1501,13 @@ impl RequestForwarder {
|
||||
} else {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
}
|
||||
} else if let Some(endpoint) = codex_standalone_endpoint
|
||||
.filter(|endpoint| endpoint.base_url_is_source_endpoint(&base_url))
|
||||
{
|
||||
// Same tolerance as `codex_chat_base_is_full_endpoint` below: a base URL
|
||||
// pasted as a complete endpoint with the full-URL switch off would
|
||||
// otherwise become `.../chat/completions/images/generations`.
|
||||
rewrite_codex_standalone_full_url(&base_url, passthrough_query.as_deref(), endpoint)?
|
||||
} else if codex_chat_base_is_full_endpoint || codex_anthropic_base_is_full_endpoint {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
@@ -3374,11 +3381,22 @@ impl CodexStandaloneEndpoint {
|
||||
}
|
||||
|
||||
fn source_suffix(self, parsed_path: &str) -> Option<&'static str> {
|
||||
// Match the case-insensitive pasted-endpoint check. Only normalize for
|
||||
// matching; the rewrite keeps the original URL prefix and query intact.
|
||||
let parsed_path = parsed_path.to_ascii_lowercase();
|
||||
self.source_suffixes()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|suffix| parsed_path.ends_with(suffix))
|
||||
}
|
||||
|
||||
/// Whether a base URL (full-URL switch off) already ends in one of this
|
||||
/// endpoint's source suffixes, i.e. was pasted as a complete endpoint URL.
|
||||
fn base_url_is_source_endpoint(self, base_url: &str) -> bool {
|
||||
self.source_suffixes()
|
||||
.iter()
|
||||
.any(|suffix| base_url_is_full_endpoint(base_url, suffix))
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive Codex standalone sibling endpoints from a provider configured with a
|
||||
@@ -4828,6 +4846,10 @@ mod tests {
|
||||
#[test]
|
||||
fn alpha_search_rewrites_known_full_responses_urls() {
|
||||
let cases = [
|
||||
(
|
||||
"https://relay.example/Gateway/%2F/v1/Responses/Compact/?api-version=CaseValue#fragment",
|
||||
"https://relay.example/Gateway/%2F/v1/alpha/search?api-version=CaseValue&client_version=0.144.6",
|
||||
),
|
||||
(
|
||||
"https://relay.example/v1/responses",
|
||||
"https://relay.example/v1/alpha/search?client_version=0.144.6",
|
||||
@@ -4858,6 +4880,10 @@ mod tests {
|
||||
#[test]
|
||||
fn images_generations_rewrites_known_full_codex_urls() {
|
||||
let cases = [
|
||||
(
|
||||
"https://relay.example/Gateway/v1/Images/Edits/?api-version=CaseValue#fragment",
|
||||
"https://relay.example/Gateway/v1/images/generations?api-version=CaseValue&client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"https://relay.example/v1/responses",
|
||||
"https://relay.example/v1/images/generations?client_version=0.145.0",
|
||||
@@ -4939,6 +4965,10 @@ mod tests {
|
||||
#[test]
|
||||
fn images_edits_rewrites_known_full_codex_urls() {
|
||||
let cases = [
|
||||
(
|
||||
"https://relay.example/Gateway/v1/Chat/Completions/?api-version=CaseValue#fragment",
|
||||
"https://relay.example/Gateway/v1/images/edits?api-version=CaseValue&client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"https://relay.example/v1/responses",
|
||||
"https://relay.example/v1/images/edits?client_version=0.145.0",
|
||||
|
||||
@@ -454,6 +454,169 @@ mod tests {
|
||||
body: Value,
|
||||
}
|
||||
|
||||
/// A base URL pasted as a complete endpoint with the full-URL switch left off
|
||||
/// must derive the sibling standalone endpoint instead of having the
|
||||
/// standalone path appended to it.
|
||||
#[tokio::test]
|
||||
async fn codex_standalone_endpoints_derive_from_pasted_full_base_url() {
|
||||
let captured = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let capture_handler = {
|
||||
let captured = captured.clone();
|
||||
move |request: axum::extract::Request| {
|
||||
let captured = captured.clone();
|
||||
async move {
|
||||
let (parts, _body) = request.into_parts();
|
||||
captured.lock().await.push(CapturedRequest {
|
||||
path_and_query: parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str().to_string())
|
||||
.unwrap_or_else(|| parts.uri.path().to_string()),
|
||||
authorization: parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToString::to_string),
|
||||
body: Value::Null,
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
r#"{"created":1,"data":[{"b64_json":"aW1hZ2U="}],"usage":{"input_tokens":7,"output_tokens":11,"total_tokens":18}}"#,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
let mock_app = Router::new()
|
||||
.route("/v1/images/generations", post(capture_handler.clone()))
|
||||
.route("/v1/images/edits", post(capture_handler.clone()))
|
||||
.route("/Gateway/v1/images/edits", post(capture_handler.clone()))
|
||||
.route("/v1/alpha/search", post(capture_handler));
|
||||
let mock_listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind mock upstream");
|
||||
let mock_addr = mock_listener.local_addr().expect("mock upstream address");
|
||||
let mock_handle = tokio::spawn(async move {
|
||||
axum::serve(mock_listener, mock_app)
|
||||
.await
|
||||
.expect("serve mock upstream");
|
||||
});
|
||||
|
||||
let db = Arc::new(Database::memory().expect("memory database"));
|
||||
let proxy = ProxyServer::new(
|
||||
ProxyConfig {
|
||||
listen_port: 0,
|
||||
non_streaming_timeout: 10,
|
||||
..ProxyConfig::default()
|
||||
},
|
||||
db.clone(),
|
||||
None,
|
||||
);
|
||||
let proxy_info = proxy.start().await.expect("start test proxy");
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"pasted-mixed-case-chat-completions",
|
||||
format!("http://{mock_addr}/v1/Chat/Completions?api-version=CaseValue"),
|
||||
"/v1/images/edits",
|
||||
"/v1/images/edits?api-version=CaseValue&client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-mixed-case-images-generations",
|
||||
format!("http://{mock_addr}/Gateway/v1/Images/Generations/?api-version=CaseValue#fragment"),
|
||||
"/v1/images/edits",
|
||||
"/Gateway/v1/images/edits?api-version=CaseValue&client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-mixed-case-images-edits",
|
||||
format!("http://{mock_addr}/v1/Images/Edits/"),
|
||||
"/v1/images/generations",
|
||||
"/v1/images/generations?client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-mixed-case-responses-compact",
|
||||
format!("http://{mock_addr}/v1/Responses/Compact/"),
|
||||
"/v1/alpha/search",
|
||||
"/v1/alpha/search?client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-chat-completions",
|
||||
format!("http://{mock_addr}/v1/chat/completions"),
|
||||
"/v1/images/generations",
|
||||
"/v1/images/generations?client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-chat-completions",
|
||||
format!("http://{mock_addr}/v1/chat/completions"),
|
||||
"/v1/images/edits",
|
||||
"/v1/images/edits?client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-images-generations",
|
||||
format!("http://{mock_addr}/v1/images/generations?api-version=test"),
|
||||
"/v1/images/edits",
|
||||
"/v1/images/edits?api-version=test&client_version=0.145.0",
|
||||
),
|
||||
(
|
||||
"pasted-responses",
|
||||
format!("http://{mock_addr}/v1/responses"),
|
||||
"/v1/alpha/search",
|
||||
"/v1/alpha/search?client_version=0.145.0",
|
||||
),
|
||||
];
|
||||
|
||||
for (provider_id, base_url, local_path, expected_upstream) in cases {
|
||||
let provider = Provider::with_id(
|
||||
provider_id.to_string(),
|
||||
provider_id.to_string(),
|
||||
json!({
|
||||
"base_url": base_url,
|
||||
"auth": {"OPENAI_API_KEY": "upstream-secret"}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("codex", &provider)
|
||||
.expect("save pasted base URL provider");
|
||||
db.set_current_provider("codex", &provider.id)
|
||||
.expect("select pasted base URL provider");
|
||||
|
||||
let response = client
|
||||
.post(format!(
|
||||
"http://127.0.0.1:{}{local_path}?client_version=0.145.0",
|
||||
proxy_info.port
|
||||
))
|
||||
.header(header::AUTHORIZATION, "Bearer client-secret")
|
||||
.json(&json!({"model": "gpt-image-1", "prompt": "pasted base URL"}))
|
||||
.send()
|
||||
.await
|
||||
.expect("send images request");
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{local_path} via {base_url}"
|
||||
);
|
||||
let request = captured
|
||||
.lock()
|
||||
.await
|
||||
.pop()
|
||||
.expect("upstream request captured");
|
||||
assert_eq!(
|
||||
request.path_and_query, expected_upstream,
|
||||
"{local_path} via {base_url}"
|
||||
);
|
||||
assert_eq!(
|
||||
request.authorization.as_deref(),
|
||||
Some("Bearer upstream-secret")
|
||||
);
|
||||
}
|
||||
|
||||
proxy.stop().await.expect("stop test proxy");
|
||||
mock_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_images_generation_aliases_forward_and_record_usage() {
|
||||
let captured = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
|
||||
@@ -308,7 +308,7 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式
|
||||
/// 智能 Codex 流式响应解析 - 自动检测 Codex Responses / Images / OpenAI 格式
|
||||
pub fn from_codex_stream_events_auto(events: &[Value]) -> Option<Self> {
|
||||
log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len());
|
||||
|
||||
@@ -324,6 +324,20 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
// Images API 流式格式 (image_generation.completed 事件):usage 直接挂在
|
||||
// 事件顶层,字段形态与 Codex 非流式响应一致;倒序取最后一个能按该形态
|
||||
// 解析的事件,跳过前面不含 usage 的 partial_image 事件。解析不成立时
|
||||
// 继续走下面的 OpenAI 回退,不改变既有路径
|
||||
if let Some(usage) = events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| event.pointer("/usage/input_tokens").is_some())
|
||||
.find_map(Self::from_codex_response)
|
||||
{
|
||||
log::debug!("[Codex] 找到顶层 usage.input_tokens 事件");
|
||||
return Some(usage);
|
||||
}
|
||||
|
||||
// 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage)
|
||||
log::debug!("[Codex] 尝试 OpenAI 流式格式");
|
||||
Self::from_openai_stream_events(events)
|
||||
@@ -1177,4 +1191,43 @@ mod tests {
|
||||
assert_eq!(usage.output_tokens, 50);
|
||||
assert_eq!(usage.model, Some("gpt-4o".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_stream_events_auto_image_generation_completed() {
|
||||
// Images API 流式格式:usage 挂在 image_generation.completed 事件顶层,
|
||||
// 字段形态与 Codex 非流式响应一致 (input_tokens / output_tokens)
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "image_generation.partial_image",
|
||||
"b64_json": "cGFydGlhbA==",
|
||||
"partial_image_index": 0
|
||||
}),
|
||||
json!({
|
||||
"type": "image_generation.completed",
|
||||
"b64_json": "aW1hZ2U=",
|
||||
"created_at": 1778832973,
|
||||
"usage": {
|
||||
"input_tokens": 1474,
|
||||
"input_tokens_details": {
|
||||
"image_tokens": 1457,
|
||||
"text_tokens": 17
|
||||
},
|
||||
"output_tokens": 1372,
|
||||
"output_tokens_details": {
|
||||
"image_tokens": 1372,
|
||||
"text_tokens": 0
|
||||
},
|
||||
"total_tokens": 2846
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_codex_stream_events_auto(&events)
|
||||
.expect("image_generation.completed usage should be parsed");
|
||||
assert_eq!(usage.input_tokens, 1474);
|
||||
assert_eq!(usage.output_tokens, 1372);
|
||||
assert_eq!(usage.cache_read_tokens, 0);
|
||||
assert_eq!(usage.cache_creation_tokens, 0);
|
||||
assert_eq!(usage.model, None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user