Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ futures = "0.3"
async-stream = "0.3"
bytes = "1.5"
axum = { version = "0.7", features = ["ws"] }
multer = "3"
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] }
Expand Down
153 changes: 129 additions & 24 deletions src-tauri/src/proxy/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,15 @@ pub async fn handle_raw_openai_passthrough(
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let route_body = parse_raw_openai_passthrough_route_body(&headers, body_bytes.clone());
let endpoint = raw_openai_passthrough_endpoint_with_query(&uri);
let is_image_request =
method == axum::http::Method::POST && codex_image_edit_endpoint(&endpoint);
let is_external_openai_client = !should_handle_as_codex_client(&headers);

let route_body = if is_image_request && !is_external_openai_client {
parse_image_passthrough_route_body(&headers, body_bytes.clone()).await?
} else {
parse_raw_openai_passthrough_route_body(&headers, body_bytes.clone())
};
let mut ctx = if is_external_openai_client {
let external_api_profile = match external_openai_api::validate_request(&state.db, &headers)
{
Expand Down Expand Up @@ -229,6 +234,16 @@ pub async fn handle_raw_openai_passthrough(
};

let is_stream = raw_openai_passthrough_request_is_streaming(&route_body, &headers);
// Image edits use raw forwarding to preserve JSON/multipart uploads, but need the
// same endpoint-specific official fallback as image generations. Do not relax
// schema-v2 fail-closed routing for unrelated raw endpoints or external clients.
let providers = if is_image_request && !is_external_openai_client {
resolve_codex_image_generation_provider(&state, &ctx.provider, &route_body)?
.map(|provider| vec![provider])
.unwrap_or_else(|| ctx.get_providers())
} else {
ctx.get_providers()
};
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
.forward_raw_with_retry(
Expand All @@ -239,7 +254,7 @@ pub async fn handle_raw_openai_passthrough(
body_bytes,
headers,
extensions,
ctx.get_providers(),
providers,
)
.await
{
Expand Down Expand Up @@ -421,6 +436,62 @@ fn parse_raw_openai_passthrough_route_body(headers: &HeaderMap, body_bytes: Byte
}
}

/// Read only the multipart routing fields; never rebuild the uploaded image or mask.
/// Reject ambiguous model fields instead of sending an explicitly routed image to
/// the official fallback because its multipart model was invisible to JSON parsing.
async fn parse_image_passthrough_route_body(
headers: &HeaderMap,
body_bytes: Bytes,
) -> Result<Value, ProxyError> {
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if !content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.eq_ignore_ascii_case("multipart/form-data")
{
return Ok(parse_raw_openai_passthrough_route_body(headers, body_bytes));
}
let invalid =
|| ProxyError::InvalidRequest("Invalid image multipart routing fields".to_string());
let boundary = multer::parse_boundary(content_type).map_err(|_| invalid())?;
let mut route_headers = headers.clone();
let decoded = decode_codex_request_body(&mut route_headers, body_bytes)?;
let stream = futures::stream::once(async move { Ok::<_, std::convert::Infallible>(decoded) });
let constraints = multer::Constraints::new().size_limit(
multer::SizeLimit::new()
.for_field("model", 1024)
.for_field("stream", 16),
);
let mut multipart = multer::Multipart::with_constraints(stream, boundary, constraints);
let mut route_body = json!({});
while let Some(field) = multipart.next_field().await.map_err(|_| invalid())? {
let name = match field.name() {
Some("model") => "model",
Some("stream") => "stream",
_ => continue,
};
if field.file_name().is_some() || route_body.get(name).is_some() {
return Err(invalid());
}
let value = field.bytes().await.map_err(|_| invalid())?;
let value = std::str::from_utf8(&value).map_err(|_| invalid())?.trim();
if name == "model" {
if value.is_empty() {
return Err(invalid());
}
route_body[name] = json!(value);
} else {
route_body[name] = json!(value.parse::<bool>().map_err(|_| invalid())?);
}
}
Ok(route_body)
}

/// 判断 raw passthrough 是否请求 SSE 流式响应。
fn raw_openai_passthrough_request_is_streaming(route_body: &Value, headers: &HeaderMap) -> bool {
route_body
Expand All @@ -444,7 +515,10 @@ fn raw_openai_passthrough_endpoint_with_query(uri: &axum::http::Uri) -> String {
.strip_prefix("/codex/v1/")
.map(|suffix| format!("/v1/{suffix}"))
.or_else(|| (path == "/codex/v1").then(|| "/v1".to_string()))
.unwrap_or_else(|| path.to_string());
.unwrap_or_else(|| match path {
"/v1/v1/images/edits" => "/v1/images/edits".to_string(),
_ => path.to_string(),
});
match uri.query() {
Some(query) => format!("{normalized_path}?{query}"),
None => normalized_path,
Expand Down Expand Up @@ -1873,6 +1947,11 @@ fn codex_route_provider_matched_request_model(provider: &crate::provider::Provid

/// 判断 provider 是否能代表 ChatGPT/Codex 官方 OAuth 图片通道。
fn provider_is_codex_image_generation_oauth_target(provider: &crate::provider::Provider) -> bool {
// Managed authentication also covers xAI and Copilot; neither is an official
// OpenAI Images fallback, even when exposed under an OpenAI-looking alias.
if provider.is_xai_oauth() || provider.is_github_copilot() {
return false;
}
provider.is_codex_oauth()
|| provider.uses_managed_account_auth()
|| is_codex_official_managed_oauth_provider(provider)
Expand Down Expand Up @@ -3583,6 +3662,13 @@ async fn handle_codex_xai_native_responses_rewrite(
})
}

/// Client-facing and upstream model identities for a Codex chat request.
#[derive(Clone, Copy)]
struct CodexChatModelNames<'a> {
public: &'a str,
upstream: &'a str,
}

#[cfg(test)]
fn create_codex_chat_sse_stream_from_verified_profile<E: std::error::Error + Send + 'static>(
stream: impl futures::Stream<Item = Result<Bytes, E>> + Send + 'static,
Expand All @@ -3597,8 +3683,10 @@ fn create_codex_chat_sse_stream_from_verified_profile<E: std::error::Error + Sen
stream,
tool_context,
provider,
public_model,
upstream_model,
CodexChatModelNames {
public: public_model,
upstream: upstream_model,
},
db,
now,
false,
Expand All @@ -3611,25 +3699,29 @@ fn create_codex_chat_sse_stream_from_verified_profile_for_client<
stream: impl futures::Stream<Item = Result<Bytes, E>> + Send + 'static,
tool_context: transform_codex_chat::CodexToolContext,
provider: &crate::provider::Provider,
public_model: &str,
upstream_model: &str,
models: CodexChatModelNames<'_>,
db: std::sync::Arc<crate::database::Database>,
now: i64,
desktop_client: bool,
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send {
let reasoning_projection = super::providers::adapt_codex_reasoning_projection_for_desktop(
super::providers::resolve_codex_chat_reasoning_projection(
provider,
public_model,
upstream_model,
models.public,
models.upstream,
db.as_ref(),
now,
),
desktop_client,
);
let observation =
load_runtime_observation_profile(provider, public_model, upstream_model, db.as_ref(), now)
.map(|(target, profile)| (target, profile, db));
let observation = load_runtime_observation_profile(
provider,
models.public,
models.upstream,
db.as_ref(),
now,
)
.map(|(target, profile)| (target, profile, db));
let stream = capture_chat_sse_stream(stream, move |observed| {
if let Some((target, profile, db)) = observation {
observe_and_expire_protocol_profile(db.as_ref(), &target, &profile, &observed, now);
Expand All @@ -3656,8 +3748,10 @@ fn chat_completion_to_response_from_verified_profile(
body,
tool_context,
provider,
public_model,
upstream_model,
CodexChatModelNames {
public: public_model,
upstream: upstream_model,
},
db,
now,
false,
Expand All @@ -3668,18 +3762,17 @@ fn chat_completion_to_response_from_verified_profile_for_client(
body: Value,
tool_context: &transform_codex_chat::CodexToolContext,
provider: &crate::provider::Provider,
public_model: &str,
upstream_model: &str,
models: CodexChatModelNames<'_>,
db: &crate::database::Database,
now: i64,
desktop_client: bool,
) -> Result<Value, ProxyError> {
observe_codex_chat_json_profile(provider, public_model, upstream_model, db, &body, now);
observe_codex_chat_json_profile(provider, models.public, models.upstream, db, &body, now);
let reasoning_projection = super::providers::adapt_codex_reasoning_projection_for_desktop(
super::providers::resolve_codex_chat_reasoning_projection(
provider,
public_model,
upstream_model,
models.public,
models.upstream,
db,
now,
),
Expand Down Expand Up @@ -3969,8 +4062,10 @@ async fn handle_codex_chat_to_responses_transform(
stream,
tool_context,
&ctx.provider,
&ctx.request_model,
upstream_model,
CodexChatModelNames {
public: &ctx.request_model,
upstream: upstream_model,
},
state.db.clone(),
projection_now,
ctx.codex_desktop_reasoning,
Expand Down Expand Up @@ -4111,8 +4206,10 @@ async fn handle_codex_chat_to_responses_transform(
chat_response,
&tool_context,
&ctx.provider,
&ctx.request_model,
upstream_model,
CodexChatModelNames {
public: &ctx.request_model,
upstream: upstream_model,
},
state.db.as_ref(),
projection_now,
ctx.codex_desktop_reasoning,
Expand Down Expand Up @@ -4954,6 +5051,10 @@ fn codex_image_endpoint(endpoint: &str) -> bool {
path.ends_with("/images/generations") || path.ends_with("/images/edits")
}

fn codex_image_edit_endpoint(endpoint: &str) -> bool {
endpoint.split('?').next() == Some("/v1/images/edits")
}

fn codex_proxy_error_json(
provider_name: &str,
request_model: &str,
Expand Down Expand Up @@ -6059,6 +6160,10 @@ async fn log_usage(
}
}

#[cfg(test)]
#[path = "handlers/image_edits_tests.rs"]
mod image_edits_tests;

#[cfg(test)]
mod tests {
use super::{
Expand Down
Loading
Loading