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:
authored and GitHub committed 2026-09-10 22:57:52 +08:00
1 parent 11317c628f
commit e098279934
3 files changed
+247 -1

No files matched your search

+30
View File
@@ -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",
+163
View File
@@ -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()));
+54 -1
View File
@@ -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);
}
}