mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-08 20:20:19 +08:00
fix(gateway): support current Codex Realtime live routes
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::Request;
|
||||
use axum::http::{header, HeaderValue, Response, StatusCode};
|
||||
use axum::extract::ws::WebSocketUpgrade;
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode, Uri};
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::Router;
|
||||
|
||||
@@ -23,6 +26,7 @@ const AI_POST_ROUTE_PATTERNS: &[&str] = &[
|
||||
"/v1/responses",
|
||||
"/v1/responses/compact",
|
||||
"/v1/live",
|
||||
"/v1/realtime/calls",
|
||||
"/v1/alpha/search",
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits",
|
||||
@@ -65,7 +69,7 @@ pub(crate) fn mount_ai_routes(mut router: Router<AppState>) -> Router<AppState>
|
||||
};
|
||||
}
|
||||
router = router.route("/v1/live/{call_id}", get(live_websocket));
|
||||
router = router.route("/v1/realtime", get(realtime_websocket));
|
||||
router = router.route("/v1/realtime", get(dispatch_realtime_websocket));
|
||||
for path in CLAUDE_POST_ROUTE_PATTERNS {
|
||||
router = router.route(
|
||||
path,
|
||||
@@ -78,6 +82,61 @@ pub(crate) fn mount_ai_routes(mut router: Router<AppState>) -> Router<AppState>
|
||||
router
|
||||
}
|
||||
|
||||
async fn dispatch_realtime_websocket(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
|
||||
ws: WebSocketUpgrade,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if realtime_query_is_codex_live(uri.query(), &headers) {
|
||||
live_websocket(State(state), ConnectInfo(remote_addr), ws, headers, uri).await
|
||||
} else {
|
||||
realtime_websocket(State(state), ConnectInfo(remote_addr), ws, headers, uri).await
|
||||
}
|
||||
}
|
||||
|
||||
fn realtime_query_is_codex_live(query: Option<&str>, headers: &HeaderMap) -> bool {
|
||||
let mut has_call_id = false;
|
||||
let mut has_live_intent = false;
|
||||
let mut duplicate_or_conflicting_intent = false;
|
||||
for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if key.eq_ignore_ascii_case("call_id") {
|
||||
has_call_id = true;
|
||||
} else if key.eq_ignore_ascii_case("intent") {
|
||||
if has_live_intent || !value.eq_ignore_ascii_case("quicksilver") {
|
||||
duplicate_or_conflicting_intent = true;
|
||||
}
|
||||
has_live_intent = true;
|
||||
}
|
||||
}
|
||||
if has_live_intent {
|
||||
return !duplicate_or_conflicting_intent;
|
||||
}
|
||||
// `call_id` is part of the ordinary OpenAI Realtime WebRTC sideband
|
||||
// contract too. Without Codex's explicit v1 intent it must remain on the
|
||||
// generic Realtime handler instead of being authorized as `codex:live`.
|
||||
if has_call_id {
|
||||
return false;
|
||||
}
|
||||
// Realtime v2 has no intent selector. A malformed or conflicting intent
|
||||
// must not be reclassified as v2 merely because a Codex originator is
|
||||
// present.
|
||||
let has_model = url::form_urlencoded::parse(query.unwrap_or_default().as_bytes())
|
||||
.any(|(key, value)| key.eq_ignore_ascii_case("model") && !value.trim().is_empty());
|
||||
let Some(originator) = crate::headers::header_value_str(headers, "originator") else {
|
||||
return false;
|
||||
};
|
||||
has_model
|
||||
&& originator.split_whitespace().next().is_some_and(|value| {
|
||||
value.eq_ignore_ascii_case("codex_cli_rs")
|
||||
|| value.to_ascii_lowercase().starts_with("codex_cli_rs/")
|
||||
|| value.eq_ignore_ascii_case("codex_work_desktop")
|
||||
|| value.eq_ignore_ascii_case("codex_work_web")
|
||||
|| value.eq_ignore_ascii_case("codex_work_mobile")
|
||||
})
|
||||
}
|
||||
|
||||
async fn claude_method_not_allowed(request: Request) -> Result<Response<Body>, GatewayError> {
|
||||
let trace_id = extract_or_generate_trace_id(request.headers());
|
||||
let mut response = build_local_http_error_response_with_request_path(
|
||||
@@ -133,7 +192,72 @@ pub(crate) fn admin_default_body_rules_for_signature(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{admin_endpoint_signature_parts, public_api_format_local_path};
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
use super::{
|
||||
admin_endpoint_signature_parts, public_api_format_local_path, realtime_query_is_codex_live,
|
||||
AI_POST_ROUTE_PATTERNS,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn registers_and_dispatches_realtime_live_aliases() {
|
||||
assert!(AI_POST_ROUTE_PATTERNS.contains(&"/v1/realtime/calls"));
|
||||
let no_headers = HeaderMap::new();
|
||||
assert!(realtime_query_is_codex_live(
|
||||
Some("intent=quicksilver&call_id=rtc_opaque"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(realtime_query_is_codex_live(
|
||||
Some("intent=quicksilver&c%61ll_id=rtc_encoded"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("c%61ll_id=rtc_encoded"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(None, &no_headers));
|
||||
assert!(!realtime_query_is_codex_live(Some("call_id="), &no_headers));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("call_id=%20"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(realtime_query_is_codex_live(
|
||||
Some("intent=quicksilver&model=gpt-realtime-1.5"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("model=gpt-realtime-1.5"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("intent=other&model=gpt-realtime-1.5"),
|
||||
&no_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("intent=quicksilver&intent=other&model=gpt-realtime-1.5"),
|
||||
&no_headers
|
||||
));
|
||||
let mut codex_v2_headers = HeaderMap::new();
|
||||
codex_v2_headers.insert("originator", HeaderValue::from_static("codex_work_desktop"));
|
||||
assert!(realtime_query_is_codex_live(
|
||||
Some("model=gpt-live-1-codex"),
|
||||
&codex_v2_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("call_id=rtc_ordinary&model=gpt-live-1-codex"),
|
||||
&codex_v2_headers
|
||||
));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("call_id=rtc_one&call_id=rtc_two"),
|
||||
&codex_v2_headers
|
||||
));
|
||||
let mut ordinary_headers = HeaderMap::new();
|
||||
ordinary_headers.insert("originator", HeaderValue::from_static("openai-python"));
|
||||
assert!(!realtime_query_is_codex_live(
|
||||
Some("model=gpt-realtime-1.5"),
|
||||
&ordinary_headers
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_data_api_endpoint_signatures_and_public_paths() {
|
||||
|
||||
@@ -126,6 +126,7 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[
|
||||
"/v1/responses",
|
||||
"/v1/responses/compact",
|
||||
"/v1/realtime",
|
||||
"/v1/realtime/calls",
|
||||
"/v1/live",
|
||||
"/v1/live/{call_id}",
|
||||
"/v1/alpha/search",
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::ai_serving::ApiOperation;
|
||||
pub(super) fn classify_ai_public_route(
|
||||
method: &http::Method,
|
||||
normalized_path: &str,
|
||||
query: Option<&str>,
|
||||
headers: &http::HeaderMap,
|
||||
) -> Option<ClassifiedRoute> {
|
||||
if let Some(route) = classify_antigravity_v1internal_route(method, normalized_path) {
|
||||
@@ -35,10 +36,28 @@ pub(super) fn classify_ai_public_route(
|
||||
"openai:rerank",
|
||||
true,
|
||||
))
|
||||
} else if (method == http::Method::POST && normalized_path == "/v1/live")
|
||||
|| (method == http::Method::POST
|
||||
&& normalized_path == "/v1/realtime/calls"
|
||||
&& realtime_query_has_codex_live_intent(query))
|
||||
|| (method == http::Method::GET
|
||||
&& ((normalized_path == "/v1/live" || normalized_path.starts_with("/v1/live/"))
|
||||
|| (normalized_path == "/v1/realtime"
|
||||
&& (realtime_query_has_codex_live_intent(query)
|
||||
|| realtime_query_is_codex_v2(query, headers))))
|
||||
&& is_websocket_upgrade_request(headers))
|
||||
{
|
||||
// Codex Live has an independent wire contract and permission surface;
|
||||
// it must never be authorized as an OpenAI Responses request.
|
||||
Some(classified("ai_public", "codex", "live", "codex:live", true))
|
||||
} else if method == http::Method::GET
|
||||
&& normalized_path == "/v1/realtime"
|
||||
&& is_websocket_upgrade_request(headers)
|
||||
{
|
||||
// Ordinary OpenAI Realtime WebSockets retain their independent
|
||||
// permission surface. The GA WebRTC call-creation endpoint is not
|
||||
// handled by this WebSocket-only implementation; only Codex AVAS is
|
||||
// accepted above when it carries the explicit quicksilver intent.
|
||||
Some(classified(
|
||||
"ai_public",
|
||||
"openai",
|
||||
@@ -46,14 +65,6 @@ pub(super) fn classify_ai_public_route(
|
||||
"openai:realtime",
|
||||
true,
|
||||
))
|
||||
} else if (method == http::Method::POST && normalized_path == "/v1/live")
|
||||
|| (method == http::Method::GET
|
||||
&& (normalized_path == "/v1/live" || normalized_path.starts_with("/v1/live/"))
|
||||
&& is_websocket_upgrade_request(headers))
|
||||
{
|
||||
// Codex Live has an independent wire contract and permission surface;
|
||||
// it must never be authorized as an OpenAI Responses request.
|
||||
Some(classified("ai_public", "codex", "live", "codex:live", true))
|
||||
} else if (method == http::Method::POST
|
||||
|| (method == http::Method::GET
|
||||
&& normalized_path == "/v1/responses"
|
||||
@@ -207,6 +218,56 @@ pub(super) fn classify_ai_public_route(
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex's standalone realtime WebSocket is distinguished from the ordinary
|
||||
/// OpenAI Realtime API by its `intent=quicksilver` selector. Keep this check
|
||||
/// deliberately narrow: `call_id` is also used by ordinary OpenAI Realtime
|
||||
/// sideband sockets, and neither it nor a model query may select the
|
||||
/// `codex:live` permission surface on its own.
|
||||
fn realtime_query_has_codex_live_intent(query: Option<&str>) -> bool {
|
||||
let mut seen = false;
|
||||
for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if key.eq_ignore_ascii_case("intent") {
|
||||
if seen || !value.eq_ignore_ascii_case("quicksilver") {
|
||||
return false;
|
||||
}
|
||||
seen = true;
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Codex realtime v2 intentionally omits the v1 `intent=quicksilver` query
|
||||
/// marker. Its default headers still carry the stable Codex originator, so
|
||||
/// use that identity plus a model query to select the Codex Live permission
|
||||
/// surface without stealing ordinary OpenAI Realtime sockets.
|
||||
fn realtime_query_is_codex_v2(query: Option<&str>, headers: &http::HeaderMap) -> bool {
|
||||
let mut has_model = false;
|
||||
for (key, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
// V2 has no intent marker. If one is present, let the normal
|
||||
// Realtime/V1 classifier handle it instead of silently treating a
|
||||
// conflicting request as Codex V2.
|
||||
if key.eq_ignore_ascii_case("intent") || key.eq_ignore_ascii_case("call_id") {
|
||||
return false;
|
||||
}
|
||||
if key.eq_ignore_ascii_case("model") && !value.trim().is_empty() {
|
||||
has_model = true;
|
||||
}
|
||||
}
|
||||
if !has_model {
|
||||
return false;
|
||||
}
|
||||
let Some(originator) = crate::headers::header_value_str(headers, "originator") else {
|
||||
return false;
|
||||
};
|
||||
originator.split_whitespace().next().is_some_and(|value| {
|
||||
value.eq_ignore_ascii_case("codex_cli_rs")
|
||||
|| value.to_ascii_lowercase().starts_with("codex_cli_rs/")
|
||||
|| value.eq_ignore_ascii_case("codex_work_desktop")
|
||||
|| value.eq_ignore_ascii_case("codex_work_web")
|
||||
|| value.eq_ignore_ascii_case("codex_work_mobile")
|
||||
})
|
||||
}
|
||||
|
||||
fn claude_request_auth_channel(headers: &http::HeaderMap) -> &'static str {
|
||||
if crate::headers::header_value_str(headers, "x-api-key").is_some()
|
||||
|| crate::headers::header_value_str(headers, "api-key").is_some()
|
||||
@@ -296,7 +357,7 @@ mod tests {
|
||||
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive, Upgrade"));
|
||||
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
|
||||
|
||||
let route = classify_ai_public_route(&Method::GET, "/v1/responses", &headers)
|
||||
let route = classify_ai_public_route(&Method::GET, "/v1/responses", None, &headers)
|
||||
.expect("Responses WebSocket should be an AI public route");
|
||||
assert_eq!(route.route_class, "ai_public");
|
||||
assert_eq!(route.route_family, "openai");
|
||||
@@ -307,7 +368,8 @@ mod tests {
|
||||
#[test]
|
||||
fn does_not_classify_plain_get_as_responses_websocket() {
|
||||
assert!(
|
||||
classify_ai_public_route(&Method::GET, "/v1/responses", &HeaderMap::new()).is_none()
|
||||
classify_ai_public_route(&Method::GET, "/v1/responses", None, &HeaderMap::new())
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,42 +379,192 @@ mod tests {
|
||||
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive, Upgrade"));
|
||||
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
|
||||
|
||||
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", &headers)
|
||||
.expect("Realtime WebSocket should be an AI public route");
|
||||
let route = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("model=gpt-realtime-2.1"),
|
||||
&headers,
|
||||
)
|
||||
.expect("Realtime WebSocket should be an AI public route");
|
||||
assert_eq!(route.route_class, "ai_public");
|
||||
assert_eq!(route.route_family, "openai");
|
||||
assert_eq!(route.route_kind, "realtime");
|
||||
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
|
||||
assert!(route.execution_runtime_candidate);
|
||||
|
||||
assert!(
|
||||
classify_ai_public_route(&Method::GET, "/v1/realtime", &HeaderMap::new()).is_none()
|
||||
);
|
||||
assert!(classify_ai_public_route(&Method::POST, "/v1/realtime", &headers).is_none());
|
||||
let mut codex_v2_headers = headers.clone();
|
||||
codex_v2_headers.insert("originator", HeaderValue::from_static("codex_work_desktop"));
|
||||
let codex_v2 = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("model=gpt-live-1-codex"),
|
||||
&codex_v2_headers,
|
||||
)
|
||||
.expect("Codex realtime v2 should use the Live route");
|
||||
assert_eq!(codex_v2.route_family, "codex");
|
||||
assert_eq!(codex_v2.auth_endpoint_signature, "codex:live");
|
||||
|
||||
let mut codex_cli_headers = headers.clone();
|
||||
codex_cli_headers.insert("originator", HeaderValue::from_static("codex_cli_rs"));
|
||||
let codex_cli_v2 = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("model=gpt-live-1-codex"),
|
||||
&codex_cli_headers,
|
||||
)
|
||||
.expect("Codex CLI realtime v2 should use the Live route");
|
||||
assert_eq!(codex_cli_v2.auth_endpoint_signature, "codex:live");
|
||||
|
||||
let mut ordinary_headers = headers.clone();
|
||||
ordinary_headers.insert("originator", HeaderValue::from_static("openai-python"));
|
||||
let ordinary = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("model=gpt-realtime-2.1"),
|
||||
&ordinary_headers,
|
||||
)
|
||||
.expect("ordinary Realtime should remain available");
|
||||
assert_eq!(ordinary.auth_endpoint_signature, "openai:realtime");
|
||||
|
||||
assert!(classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("model=gpt-realtime-2.1"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.is_none());
|
||||
assert!(classify_ai_public_route(&Method::POST, "/v1/realtime", None, &headers).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_live_http_and_websocket_routes_as_codex_live() {
|
||||
let post = classify_ai_public_route(&Method::POST, "/v1/live", &HeaderMap::new())
|
||||
.expect("Live WebRTC call creation should be an AI public route");
|
||||
assert_eq!(post.route_family, "codex");
|
||||
assert_eq!(post.route_kind, "live");
|
||||
assert_eq!(post.auth_endpoint_signature, "codex:live");
|
||||
let legacy_post =
|
||||
classify_ai_public_route(&Method::POST, "/v1/live", None, &HeaderMap::new())
|
||||
.expect("legacy Live call creation should be an AI public route");
|
||||
assert_eq!(legacy_post.route_family, "codex");
|
||||
assert_eq!(legacy_post.route_kind, "live");
|
||||
assert_eq!(legacy_post.auth_endpoint_signature, "codex:live");
|
||||
|
||||
let avas_post = classify_ai_public_route(
|
||||
&Method::POST,
|
||||
"/v1/realtime/calls",
|
||||
Some("intent=quicksilver&architecture=avas"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.expect("Codex AVAS call creation should be an AI public route");
|
||||
assert_eq!(avas_post.route_family, "codex");
|
||||
assert_eq!(avas_post.route_kind, "live");
|
||||
assert_eq!(avas_post.auth_endpoint_signature, "codex:live");
|
||||
|
||||
// The same endpoint is part of the ordinary OpenAI Realtime API, but
|
||||
// Aether's OpenAI Realtime implementation currently supports direct
|
||||
// WebSockets only. A request without Codex's explicit AVAS intent must
|
||||
// neither be captured by the Codex Live planner nor be advertised as a
|
||||
// supported ordinary call-create request.
|
||||
for query in [None, Some("model=gpt-realtime"), Some("architecture=avas")] {
|
||||
assert!(classify_ai_public_route(
|
||||
&Method::POST,
|
||||
"/v1/realtime/calls",
|
||||
query,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
for query in [
|
||||
Some("intent=other&architecture=avas"),
|
||||
Some("intent=quicksilver&intent=other&architecture=avas"),
|
||||
] {
|
||||
assert!(classify_ai_public_route(
|
||||
&Method::POST,
|
||||
"/v1/realtime/calls",
|
||||
query,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONNECTION, HeaderValue::from_static("Upgrade"));
|
||||
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
|
||||
for path in ["/v1/live", "/v1/live/rtc_opaque"] {
|
||||
let route = classify_ai_public_route(&Method::GET, path, &headers)
|
||||
let route = classify_ai_public_route(&Method::GET, path, None, &headers)
|
||||
.expect("Live WebSocket should be an AI public route");
|
||||
assert_eq!(route.route_family, "codex");
|
||||
assert_eq!(route.route_kind, "live");
|
||||
assert_eq!(route.auth_endpoint_signature, "codex:live");
|
||||
}
|
||||
|
||||
assert!(
|
||||
classify_ai_public_route(&Method::GET, "/v1/live/rtc_opaque", &HeaderMap::new())
|
||||
.is_none()
|
||||
assert!(classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/live/rtc_opaque",
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.is_none());
|
||||
|
||||
let sideband = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("intent=quicksilver&call_id=rtc_opaque"),
|
||||
&headers,
|
||||
)
|
||||
.expect("Realtime sideband WebSocket should be a Codex Live route");
|
||||
assert_eq!(sideband.route_family, "codex");
|
||||
assert_eq!(sideband.route_kind, "live");
|
||||
assert_eq!(sideband.auth_endpoint_signature, "codex:live");
|
||||
|
||||
for query in [None, Some("model=gpt-realtime")] {
|
||||
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", query, &headers)
|
||||
.expect("Realtime WebSocket without a call_id key should remain OpenAI Realtime");
|
||||
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
|
||||
}
|
||||
let codex_direct = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("intent=quicksilver&model=gpt-realtime-1.5"),
|
||||
&headers,
|
||||
)
|
||||
.expect("Codex direct realtime WebSocket should use the Live route");
|
||||
assert_eq!(codex_direct.route_family, "codex");
|
||||
assert_eq!(codex_direct.route_kind, "live");
|
||||
assert_eq!(codex_direct.auth_endpoint_signature, "codex:live");
|
||||
for query in [
|
||||
Some("intent=other&model=gpt-realtime-1.5"),
|
||||
Some("intent=quicksilver&intent=other&model=gpt-realtime-1.5"),
|
||||
] {
|
||||
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", query, &headers)
|
||||
.expect("non-Codex realtime intent should remain OpenAI Realtime");
|
||||
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
|
||||
}
|
||||
// `call_id` is shared by ordinary OpenAI Realtime WebRTC sideband
|
||||
// sockets. It must not select Codex Live without Codex's explicit
|
||||
// `intent=quicksilver` signal.
|
||||
for query in [
|
||||
Some("call_id=rtc_ordinary"),
|
||||
Some("c%61ll_id=rtc_encoded"),
|
||||
Some("call_id="),
|
||||
Some("call_id=%20"),
|
||||
Some("call_id=rtc_one&call_id=rtc_two"),
|
||||
Some("call_id=rtc_ordinary&model=gpt-realtime-1.5"),
|
||||
] {
|
||||
let route = classify_ai_public_route(&Method::GET, "/v1/realtime", query, &headers)
|
||||
.expect("ordinary Realtime sideband should remain routable");
|
||||
assert_eq!(route.route_family, "openai");
|
||||
assert_eq!(route.route_kind, "realtime");
|
||||
assert_eq!(route.auth_endpoint_signature, "openai:realtime");
|
||||
}
|
||||
|
||||
let codex_sideband_with_encoded_call_id = classify_ai_public_route(
|
||||
&Method::GET,
|
||||
"/v1/realtime",
|
||||
Some("intent=quicksilver&c%61ll_id=rtc_encoded"),
|
||||
&headers,
|
||||
)
|
||||
.expect("encoded call_id must not hide Codex's explicit Live intent");
|
||||
assert_eq!(
|
||||
codex_sideband_with_encoded_call_id.auth_endpoint_signature,
|
||||
"codex:live"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ pub(crate) fn classify_control_route(
|
||||
.or_else(|| oauth::classify_oauth_route(method, &normalized_path))
|
||||
.or_else(|| admin::classify_admin_route(method, &normalized_path))
|
||||
.or_else(|| internal::classify_internal_route(method, &normalized_path))
|
||||
.or_else(|| ai::classify_ai_public_route(method, &normalized_path, headers))?;
|
||||
.or_else(|| ai::classify_ai_public_route(method, &normalized_path, uri.query(), headers))?;
|
||||
|
||||
let mut decision = classified.into_decision(normalized_path);
|
||||
if let Some(signature) = decision.auth_endpoint_signature.as_deref() {
|
||||
|
||||
@@ -42,6 +42,7 @@ pub(crate) fn frontdoor_self_loop_public_ai_path(path: &str) -> bool {
|
||||
| "/v1/responses"
|
||||
| "/v1/responses/compact"
|
||||
| "/v1/realtime"
|
||||
| "/v1/realtime/calls"
|
||||
| "/v1/live"
|
||||
| "/v1/alpha/search"
|
||||
| "/v1beta/files"
|
||||
@@ -154,6 +155,7 @@ mod tests {
|
||||
#[test]
|
||||
fn realtime_is_protected_from_frontdoor_self_loops() {
|
||||
assert!(frontdoor_self_loop_public_ai_path("/v1/realtime"));
|
||||
assert!(frontdoor_self_loop_public_ai_path("/v1/realtime/calls"));
|
||||
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||
8084,
|
||||
"ws://127.0.0.1:8084/v1/realtime?model=gpt-realtime"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,16 @@ use crate::execution_runtime::execute_execution_runtime_sync_plan_with_report_co
|
||||
use crate::handlers::proxy::websocket::responses::ResponsesWebSocketTurnAdmission;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::audit::mark_live_call_create_report_context;
|
||||
use super::audit::{
|
||||
mark_live_call_create_report_context, LiveCallCreateAuditGuard,
|
||||
LIVE_CALL_CANDIDATE_UNAVAILABLE_MESSAGE,
|
||||
};
|
||||
use super::live_usage_accounting_is_safe;
|
||||
use super::planner::{live_call_url, plan_live_candidate, LiveAuthMode, LivePoolLeaseGuard};
|
||||
use super::protocol::{build_live_multipart, extract_call_id_from_location, parse_live_multipart};
|
||||
use super::protocol::{
|
||||
build_live_multipart, extract_call_id_from_location, parse_live_multipart, validate_model,
|
||||
validate_realtime_call_create_query, LiveRouteDialect,
|
||||
};
|
||||
use super::registry::{LiveCallBinding, LiveCallRegistry};
|
||||
|
||||
const MAX_LIVE_HTTP_BODY_BYTES: usize = 1024 * 1024;
|
||||
@@ -37,36 +43,98 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
body: Option<&Bytes>,
|
||||
remote_addr: &SocketAddr,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if parts.method != http::Method::POST || request_context.request_path != "/v1/live" {
|
||||
let Some(dialect) = LiveRouteDialect::from_call_create_path(&request_context.request_path)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if parts.method != http::Method::POST {
|
||||
return Ok(None);
|
||||
}
|
||||
// `/v1/realtime/calls` is shared with the ordinary OpenAI Realtime API.
|
||||
// The control-plane route classifier is the authority that distinguishes
|
||||
// Codex AVAS (`intent=quicksilver`) from a normal Realtime call. Do not
|
||||
// let this path-only specialized hook steal an `openai:realtime` request
|
||||
// after classification has deliberately kept it on the generic proxy.
|
||||
if dialect == LiveRouteDialect::Realtime
|
||||
&& !request_context
|
||||
.control_decision
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.auth_endpoint_signature.as_deref())
|
||||
.is_some_and(|format| format.eq_ignore_ascii_case("codex:live"))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Box::pin(handle_live_http(
|
||||
state,
|
||||
request_context,
|
||||
parts,
|
||||
body,
|
||||
remote_addr,
|
||||
dialect,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_live_http(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
parts: &http::request::Parts,
|
||||
body: Option<&Bytes>,
|
||||
remote_addr: &SocketAddr,
|
||||
dialect: LiveRouteDialect,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let mut call_audit = LiveCallCreateAuditGuard::new(
|
||||
state,
|
||||
request_context.control_decision.as_ref(),
|
||||
request_context.trace_id.as_str(),
|
||||
request_context.request_path.as_str(),
|
||||
);
|
||||
if dialect == LiveRouteDialect::Realtime {
|
||||
if let Err(error) = validate_realtime_call_create_query(parts.uri.query()) {
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
error.status_code(),
|
||||
error.client_message(),
|
||||
error.code(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(control_decision) = request_context.control_decision.as_ref() else {
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::NOT_FOUND,
|
||||
"Codex Live route is unavailable",
|
||||
)?));
|
||||
"route_unavailable",
|
||||
);
|
||||
};
|
||||
if !live_usage_accounting_is_safe(control_decision) {
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Codex Live is unavailable for finite-balance keys until Frameless usage settlement is supported",
|
||||
)?));
|
||||
"finite_balance_unsupported",
|
||||
);
|
||||
}
|
||||
let Some(body) = body else {
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Codex Live requires a multipart WebRTC offer",
|
||||
)?));
|
||||
"request_body_missing",
|
||||
);
|
||||
};
|
||||
if body.len() > MAX_LIVE_HTTP_BODY_BYTES {
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"Codex Live WebRTC offer exceeds the 1 MiB limit",
|
||||
)?));
|
||||
"request_body_too_large",
|
||||
);
|
||||
}
|
||||
let content_type = parts
|
||||
.headers
|
||||
@@ -76,11 +144,13 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
let offer = match parse_live_multipart(content_type, body.as_ref()) {
|
||||
Ok(offer) => offer,
|
||||
Err(error) => {
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
error.status_code(),
|
||||
error.client_message(),
|
||||
)?))
|
||||
"multipart_parse_failed",
|
||||
)
|
||||
}
|
||||
};
|
||||
let Some(client_model) = offer
|
||||
@@ -88,29 +158,86 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
else {
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Codex Live session.model must be a non-empty model identifier",
|
||||
)?));
|
||||
"model_missing",
|
||||
);
|
||||
};
|
||||
if validate_model(client_model).is_err() {
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Codex Live session.model must be a valid model identifier",
|
||||
"model_invalid",
|
||||
);
|
||||
}
|
||||
call_audit.set_validated_client_model(client_model);
|
||||
|
||||
let Some(mut candidate) = plan_live_candidate(
|
||||
let planning = match plan_live_candidate(
|
||||
state,
|
||||
request_context.trace_id.as_str(),
|
||||
control_decision,
|
||||
&parts.headers,
|
||||
remote_addr,
|
||||
client_model,
|
||||
dialect,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Some(local_live_error(
|
||||
.await
|
||||
{
|
||||
Ok(planning) => planning,
|
||||
Err(error) => {
|
||||
call_audit.fail(gateway_error_status(&error), "planning_failed");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
call_audit.set_runtime_miss(planning.runtime_miss.clone());
|
||||
let runtime_miss = planning.runtime_miss;
|
||||
let Some(mut candidate) = planning.candidate else {
|
||||
let auth_context = control_decision.auth_context.as_ref();
|
||||
warn!(
|
||||
event_name = "codex_live_call_candidate_unavailable",
|
||||
log_type = "ops",
|
||||
trace_id = %request_context.trace_id,
|
||||
transport = "webrtc",
|
||||
mode = "call_create",
|
||||
status_code = StatusCode::SERVICE_UNAVAILABLE.as_u16(),
|
||||
client_model,
|
||||
user_id = auth_context
|
||||
.map(|auth| auth.user_id.as_str())
|
||||
.unwrap_or("-"),
|
||||
api_key_id = auth_context
|
||||
.map(|auth| auth.api_key_id.as_str())
|
||||
.unwrap_or("-"),
|
||||
runtime_miss_reason = runtime_miss
|
||||
.as_ref()
|
||||
.map(|diagnostic| diagnostic.reason.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
candidate_count = runtime_miss
|
||||
.as_ref()
|
||||
.and_then(|diagnostic| diagnostic.candidate_count)
|
||||
.unwrap_or(0),
|
||||
skipped_candidate_count = runtime_miss
|
||||
.as_ref()
|
||||
.and_then(|diagnostic| diagnostic.skipped_candidate_count)
|
||||
.unwrap_or(0),
|
||||
skip_reasons = runtime_miss
|
||||
.as_ref()
|
||||
.and_then(|diagnostic| diagnostic.skip_reasons_summary())
|
||||
.unwrap_or_default(),
|
||||
"Codex Live call creation has no eligible provider mapping"
|
||||
);
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"No eligible Codex Live provider mapping is available",
|
||||
)?));
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
LIVE_CALL_CANDIDATE_UNAVAILABLE_MESSAGE,
|
||||
"candidate_unavailable",
|
||||
);
|
||||
};
|
||||
let lease = LivePoolLeaseGuard::new(state, &candidate);
|
||||
let binding = LiveCallBinding::from_candidate(&candidate);
|
||||
@@ -122,20 +249,32 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"model".to_string(),
|
||||
serde_json::Value::String(candidate.provider_model.clone()),
|
||||
);
|
||||
let upstream_url = match live_call_url(&candidate) {
|
||||
let upstream_url = match live_call_url(&candidate, dialect) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
lease.release().await;
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
error.status_code(),
|
||||
error.client_message(),
|
||||
)?));
|
||||
"upstream_url_invalid",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (provider_content_type, provider_body_base64) =
|
||||
build_live_call_provider_body(candidate.auth_mode, offer.sdp.as_str(), &provider_session)?;
|
||||
let (provider_content_type, provider_body_base64) = match build_live_call_provider_body(
|
||||
candidate.auth_mode,
|
||||
offer.sdp.as_str(),
|
||||
&provider_session,
|
||||
) {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
lease.release().await;
|
||||
call_audit.fail(gateway_error_status(&error), "provider_body_build_failed");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// The standard plan builder requires a JSON body marker even when the exact wire body is
|
||||
// carried as bytes. Keep only the mapped model here: retaining the SDP/session projection in
|
||||
// the decision would unnecessarily widen the surface for future logging or report changes.
|
||||
@@ -159,41 +298,84 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let Some(mut attempt) =
|
||||
build_standard_sync_plan_from_decision(parts, &provider_body_marker, candidate.execution)?
|
||||
else {
|
||||
lease.release().await;
|
||||
return Ok(Some(local_live_error(
|
||||
request_context,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Codex Live provider request could not be built",
|
||||
)?));
|
||||
let mut attempt = match build_standard_sync_plan_from_decision(
|
||||
parts,
|
||||
&provider_body_marker,
|
||||
candidate.execution,
|
||||
) {
|
||||
Ok(Some(attempt)) => attempt,
|
||||
Ok(None) => {
|
||||
lease.release().await;
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Codex Live provider request could not be built",
|
||||
"provider_plan_unavailable",
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
lease.release().await;
|
||||
call_audit.fail(gateway_error_status(&error), "provider_plan_build_failed");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// The synchronous SDP exchange has an ordinary request lifecycle, but it
|
||||
// does not contain the media leg's token/cost usage. Keep the existing row
|
||||
// while making that boundary explicit and non-billable.
|
||||
mark_live_call_create_report_context(&mut attempt.report_context);
|
||||
if let Some(rejection) = execution_plan_balance_capacity_rejection(
|
||||
call_audit.bind_attempt(&attempt);
|
||||
let balance_rejection = match execution_plan_balance_capacity_rejection(
|
||||
state,
|
||||
control_decision,
|
||||
&attempt.plan,
|
||||
attempt.report_context.as_ref(),
|
||||
)
|
||||
.await?
|
||||
.await
|
||||
{
|
||||
Ok(rejection) => rejection,
|
||||
Err(error) => {
|
||||
lease.release().await;
|
||||
call_audit.fail(
|
||||
gateway_error_status(&error),
|
||||
"balance_capacity_check_failed",
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(rejection) = balance_rejection {
|
||||
lease.release().await;
|
||||
return Ok(Some(build_local_auth_rejection_response(
|
||||
let response = match build_local_auth_rejection_response(
|
||||
request_context.trace_id.as_str(),
|
||||
Some(control_decision),
|
||||
&rejection,
|
||||
)?));
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
call_audit.fail(
|
||||
gateway_error_status(&error),
|
||||
"downstream_response_build_failed",
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
call_audit.fail(response.status(), "balance_capacity_rejected");
|
||||
return Ok(Some(response));
|
||||
}
|
||||
let admission = ResponsesWebSocketTurnAdmission::acquire(
|
||||
let admission = match ResponsesWebSocketTurnAdmission::acquire(
|
||||
state,
|
||||
&attempt.plan,
|
||||
request_context.trace_id.as_str(),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(admission) => admission,
|
||||
Err(error) => {
|
||||
lease.release().await;
|
||||
call_audit.fail(gateway_error_status(&error), "admission_failed");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let result = execute_execution_runtime_sync_plan_with_report_context(
|
||||
state,
|
||||
Some(request_context.trace_id.as_str()),
|
||||
@@ -208,9 +390,21 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
admission.release().await;
|
||||
let pool_lease_healthy = lease.is_healthy();
|
||||
lease.release().await;
|
||||
let result = result?;
|
||||
let result = match result {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
call_audit.fail(gateway_error_status(&error), "upstream_execute_failed");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if !(200..300).contains(&result.status_code) {
|
||||
let response_body = execution_result_body(&result)?;
|
||||
let response_body = match execution_result_body(&result) {
|
||||
Ok(body) => body,
|
||||
Err(error) => {
|
||||
call_audit.fail(StatusCode::BAD_GATEWAY, "upstream_error_body_unavailable");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let downstream_headers =
|
||||
sanitized_live_response_headers(&result.headers, response_body.preserves_wire_encoding);
|
||||
warn!(
|
||||
@@ -224,13 +418,24 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
elapsed_ms = result.telemetry.as_ref().and_then(|value| value.elapsed_ms),
|
||||
"Codex Live call creation failed upstream"
|
||||
);
|
||||
return Ok(Some(build_client_response_from_parts(
|
||||
let response = match build_client_response_from_parts(
|
||||
result.status_code,
|
||||
&downstream_headers,
|
||||
Body::from(response_body.bytes),
|
||||
request_context.trace_id.as_str(),
|
||||
Some(control_decision),
|
||||
)?));
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
call_audit.fail(
|
||||
gateway_error_status(&error),
|
||||
"downstream_response_build_failed",
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
call_audit.fail(response.status(), "upstream_rejected");
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if !pool_lease_healthy {
|
||||
warn_live_call_orphaned(
|
||||
@@ -240,11 +445,13 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"pool_lease_lost",
|
||||
None,
|
||||
);
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Codex Live provider lease expired during call creation",
|
||||
)?));
|
||||
"pool_lease_lost",
|
||||
);
|
||||
}
|
||||
let response_body = match execution_result_body(&result) {
|
||||
Ok(body) => body,
|
||||
@@ -256,6 +463,7 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"response_body_unavailable",
|
||||
None,
|
||||
);
|
||||
call_audit.fail(StatusCode::BAD_GATEWAY, "response_body_unavailable");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
@@ -267,11 +475,13 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"location_missing",
|
||||
None,
|
||||
);
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Codex Live upstream response did not include a call location",
|
||||
)?));
|
||||
"location_missing",
|
||||
);
|
||||
};
|
||||
let call_id = match extract_call_id_from_location(location) {
|
||||
Ok(call_id) => call_id,
|
||||
@@ -283,11 +493,13 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"location_invalid",
|
||||
Some(error.code()),
|
||||
);
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
error.client_message(),
|
||||
)?));
|
||||
"location_invalid",
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(auth_context) = control_decision.auth_context.as_ref() else {
|
||||
@@ -298,11 +510,13 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"auth_context_missing",
|
||||
None,
|
||||
);
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Codex Live requires an authenticated gateway API key",
|
||||
)?));
|
||||
"auth_context_missing",
|
||||
);
|
||||
};
|
||||
let registry = LiveCallRegistry::new(std::sync::Arc::clone(&state.runtime_state));
|
||||
if let Err(error) = registry
|
||||
@@ -321,11 +535,13 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
"binding_failed",
|
||||
Some(error.kind()),
|
||||
);
|
||||
return Ok(Some(local_live_error(
|
||||
return audited_local_live_error(
|
||||
&mut call_audit,
|
||||
request_context,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Codex Live sideband binding is temporarily unavailable",
|
||||
)?));
|
||||
"binding_failed",
|
||||
);
|
||||
}
|
||||
info!(
|
||||
event_name = "codex_live_call_created",
|
||||
@@ -340,10 +556,10 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
usage_unavailable = true,
|
||||
"Codex Live created a bound WebRTC call"
|
||||
);
|
||||
let downstream_location = format!("/v1/live/{call_id}");
|
||||
let downstream_location = dialect.downstream_location(call_id.as_str());
|
||||
let downstream_headers =
|
||||
sanitized_live_response_headers(&result.headers, response_body.preserves_wire_encoding);
|
||||
Ok(Some(build_client_response_from_parts_with_mutator(
|
||||
let response = match build_client_response_from_parts_with_mutator(
|
||||
result.status_code,
|
||||
&downstream_headers,
|
||||
Body::from(response_body.bytes),
|
||||
@@ -357,7 +573,58 @@ pub(crate) async fn maybe_handle_live_http(
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)?))
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
warn_live_call_orphaned(
|
||||
request_context,
|
||||
&attempt.plan,
|
||||
&result,
|
||||
"downstream_response_build_failed",
|
||||
None,
|
||||
);
|
||||
call_audit.fail(
|
||||
gateway_error_status(&error),
|
||||
"downstream_response_build_failed",
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
call_audit.complete(response.status().as_u16(), "call_created");
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
fn gateway_error_status(error: &GatewayError) -> StatusCode {
|
||||
match error {
|
||||
GatewayError::UpstreamUnavailable { .. } | GatewayError::ControlUnavailable { .. } => {
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
GatewayError::LocalExecutionPlanningTimeout { .. } => StatusCode::GATEWAY_TIMEOUT,
|
||||
GatewayError::AdmissionTimeout { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||
GatewayError::Client { status, .. } => *status,
|
||||
GatewayError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn audited_local_live_error(
|
||||
audit: &mut LiveCallCreateAuditGuard,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
status: StatusCode,
|
||||
message: &str,
|
||||
termination: &'static str,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let response = match local_live_error(request_context, status, message) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
audit.fail(
|
||||
gateway_error_status(&error),
|
||||
"downstream_response_build_failed",
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
audit.fail(response.status(), termination);
|
||||
Ok(Some(response))
|
||||
}
|
||||
|
||||
fn local_live_error(
|
||||
@@ -368,7 +635,7 @@ fn local_live_error(
|
||||
build_local_http_error_response_with_request_path(
|
||||
request_context.trace_id.as_str(),
|
||||
request_context.control_decision.as_ref(),
|
||||
Some("/v1/live"),
|
||||
Some(request_context.request_path.as_str()),
|
||||
status,
|
||||
message,
|
||||
)
|
||||
@@ -497,13 +764,57 @@ fn execution_result_body(result: &ExecutionResult) -> Result<LiveResponseBody, G
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_contracts::{ExecutionPlan, ExecutionResult, ResponseBody};
|
||||
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||
use aether_data_contracts::repository::usage::UsageReadRepository;
|
||||
use axum::body::to_bytes;
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedLogBuffer(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
struct SharedLogWriter(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl SharedLogBuffer {
|
||||
fn lines(&self) -> Vec<serde_json::Value> {
|
||||
String::from_utf8(self.0.lock().expect("log buffer should lock").clone())
|
||||
.expect("logs should be valid UTF-8")
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line).expect("log line should be valid JSON"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for SharedLogWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0
|
||||
.lock()
|
||||
.expect("log buffer should lock")
|
||||
.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for SharedLogBuffer {
|
||||
type Writer = SharedLogWriter;
|
||||
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
SharedLogWriter(Arc::clone(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserved_wire_bytes_win_over_the_json_projection() {
|
||||
let result = ExecutionResult {
|
||||
@@ -680,6 +991,112 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_http_handler_future_stays_stack_bounded() {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
let request_context = GatewayPublicRequestContext {
|
||||
trace_id: "trace-live-future-size".to_string(),
|
||||
request_method: http::Method::POST,
|
||||
request_path: "/v1/live".to_string(),
|
||||
request_query_string: None,
|
||||
request_content_type: None,
|
||||
host_header: None,
|
||||
control_decision: None,
|
||||
};
|
||||
let (parts, _) = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri("/v1/live")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts();
|
||||
let remote_addr = "127.0.0.1:65002"
|
||||
.parse()
|
||||
.expect("remote address should parse");
|
||||
let future = maybe_handle_live_http(&state, &request_context, &parts, None, &remote_addr);
|
||||
let future_size = std::mem::size_of_val(&future);
|
||||
assert!(
|
||||
future_size <= 4 * 1024,
|
||||
"Live HTTP handler future grew to {future_size} bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_openai_realtime_call_is_not_intercepted_by_codex_live() {
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/realtime/calls",
|
||||
Some("ai_public".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("realtime".to_string()),
|
||||
Some("openai:realtime".to_string()),
|
||||
);
|
||||
let request_context = GatewayPublicRequestContext {
|
||||
trace_id: "trace-ordinary-realtime-call".to_string(),
|
||||
request_method: http::Method::POST,
|
||||
request_path: "/v1/realtime/calls".to_string(),
|
||||
request_query_string: None,
|
||||
request_content_type: Some("application/sdp".to_string()),
|
||||
host_header: None,
|
||||
control_decision: Some(decision),
|
||||
};
|
||||
let (parts, _) = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri("/v1/realtime/calls")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts();
|
||||
|
||||
let response = maybe_handle_live_http(
|
||||
&AppState::new().expect("gateway state should build"),
|
||||
&request_context,
|
||||
&parts,
|
||||
None,
|
||||
&"127.0.0.1:65003".parse().unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("ordinary Realtime hook check should succeed");
|
||||
|
||||
assert!(response.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_realtime_call_remains_on_the_live_handler() {
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/realtime/calls",
|
||||
Some("ai_public".to_string()),
|
||||
Some("codex".to_string()),
|
||||
Some("live".to_string()),
|
||||
Some("codex:live".to_string()),
|
||||
);
|
||||
let request_context = GatewayPublicRequestContext {
|
||||
trace_id: "trace-codex-realtime-call".to_string(),
|
||||
request_method: http::Method::POST,
|
||||
request_path: "/v1/realtime/calls".to_string(),
|
||||
request_query_string: Some("intent=quicksilver&architecture=avas".to_string()),
|
||||
request_content_type: Some("multipart/form-data".to_string()),
|
||||
host_header: None,
|
||||
control_decision: Some(decision),
|
||||
};
|
||||
let (parts, _) = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri("/v1/realtime/calls?intent=quicksilver&architecture=avas")
|
||||
.body(())
|
||||
.expect("request should build")
|
||||
.into_parts();
|
||||
|
||||
let response = maybe_handle_live_http(
|
||||
&AppState::new().expect("gateway state should build"),
|
||||
&request_context,
|
||||
&parts,
|
||||
None,
|
||||
&"127.0.0.1:65004".parse().unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("Codex Realtime hook check should succeed")
|
||||
.expect("Codex Realtime call must stay on the Live handler");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finite_balance_post_live_fails_before_parsing_or_upstream_execution() {
|
||||
let mut decision = GatewayControlDecision::synthetic(
|
||||
@@ -733,4 +1150,163 @@ mod tests {
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(body.as_ref()).contains("finite-balance"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_live_without_a_candidate_returns_service_unavailable_and_clears_diagnostic() {
|
||||
let log_buffer = SharedLogBuffer::default();
|
||||
let subscriber = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_current_span(false)
|
||||
.with_span_list(false)
|
||||
.with_writer(log_buffer.clone())
|
||||
.with_filter(LevelFilter::WARN),
|
||||
);
|
||||
let dispatch = tracing::Dispatch::new(subscriber);
|
||||
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||
let mut decision = GatewayControlDecision::synthetic(
|
||||
"/v1/live",
|
||||
Some("ai_public".to_string()),
|
||||
Some("codex".to_string()),
|
||||
Some("live".to_string()),
|
||||
Some("codex:live".to_string()),
|
||||
);
|
||||
decision.auth_context = Some(GatewayControlAuthContext {
|
||||
user_id: "user-live-unmapped".to_string(),
|
||||
api_key_id: "key-live-unmapped".to_string(),
|
||||
username: Some("unmapped".to_string()),
|
||||
api_key_name: Some("unmapped".to_string()),
|
||||
balance_remaining: None,
|
||||
access_allowed: true,
|
||||
user_rate_limit: None,
|
||||
api_key_rate_limit: None,
|
||||
api_key_is_standalone: true,
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: None,
|
||||
ip_rules: None,
|
||||
});
|
||||
let request_context = GatewayPublicRequestContext {
|
||||
trace_id: "trace-live-unmapped".to_string(),
|
||||
request_method: http::Method::POST,
|
||||
request_path: "/v1/live".to_string(),
|
||||
request_query_string: None,
|
||||
request_content_type: Some("multipart/form-data".to_string()),
|
||||
host_header: None,
|
||||
control_decision: Some(decision),
|
||||
};
|
||||
let (content_type, body) = build_live_multipart(
|
||||
"v=0\r\no=unmapped-live-offer",
|
||||
&json!({"model": "gpt-live-unmapped"}),
|
||||
);
|
||||
let (parts, _) = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri("/v1/live")
|
||||
.header(http::header::CONTENT_TYPE, content_type)
|
||||
.body(())
|
||||
.unwrap()
|
||||
.into_parts();
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let state = AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_usage_data_repository_for_tests(Arc::clone(&usage_repository))
|
||||
.with_usage_runtime_for_tests(crate::usage::UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
..crate::usage::UsageRuntimeConfig::default()
|
||||
});
|
||||
state.set_local_execution_runtime_miss_diagnostic(
|
||||
request_context.trace_id.as_str(),
|
||||
crate::LocalExecutionRuntimeMissDiagnostic {
|
||||
reason: "test_sentinel".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let response = maybe_handle_live_http(
|
||||
&state,
|
||||
&request_context,
|
||||
&parts,
|
||||
Some(&Bytes::from(body)),
|
||||
&"127.0.0.1:65001".parse().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("Live HTTP route must reject an unmapped model locally");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(crate::constants::TRACE_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(request_context.trace_id.as_str())
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(body.as_ref())
|
||||
.contains("No eligible Codex Live provider mapping is available"));
|
||||
assert!(state
|
||||
.take_local_execution_runtime_miss_diagnostic(request_context.trace_id.as_str())
|
||||
.is_none());
|
||||
|
||||
let usage = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(usage) = usage_repository
|
||||
.find_by_request_id(request_context.trace_id.as_str())
|
||||
.await
|
||||
.expect("Live preflight usage read should succeed")
|
||||
{
|
||||
break usage;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Live preflight rejection should persist a usage row before timeout");
|
||||
assert_eq!(usage.status, "failed");
|
||||
assert_eq!(usage.billing_status, "void");
|
||||
assert_eq!(usage.status_code, Some(503));
|
||||
assert_eq!(usage.request_type.as_deref(), Some("live"));
|
||||
assert_eq!(usage.api_format.as_deref(), Some("codex:live"));
|
||||
assert_eq!(usage.model, "gpt-live-unmapped");
|
||||
assert!(!usage.is_stream);
|
||||
assert!(!usage.is_websocket());
|
||||
assert_eq!(usage.websocket_transport(), None);
|
||||
assert!(!usage.usage_available());
|
||||
assert!(!usage.usage_pricing_available());
|
||||
assert_eq!(usage.input_tokens, 0);
|
||||
assert_eq!(usage.output_tokens, 0);
|
||||
assert_eq!(usage.total_tokens, 0);
|
||||
assert_eq!(usage.total_cost_usd, 0.0);
|
||||
assert_eq!(usage.actual_total_cost_usd, 0.0);
|
||||
assert!(usage.request_headers.is_none());
|
||||
assert!(usage.request_body.is_none());
|
||||
assert!(usage.provider_request_headers.is_none());
|
||||
assert!(usage.provider_request_body.is_none());
|
||||
assert!(!serde_json::to_string(&usage)
|
||||
.expect("usage should serialize")
|
||||
.contains("unmapped-live-offer"));
|
||||
|
||||
let logs = log_buffer.lines();
|
||||
let unavailable = logs
|
||||
.iter()
|
||||
.find(|entry| entry["event_name"] == "codex_live_call_candidate_unavailable")
|
||||
.expect("candidate miss should emit a dedicated structured log");
|
||||
assert_eq!(unavailable["status_code"], 503);
|
||||
assert_eq!(unavailable["client_model"], "gpt-live-unmapped");
|
||||
assert_eq!(unavailable["user_id"], "user-live-unmapped");
|
||||
assert_eq!(unavailable["api_key_id"], "key-live-unmapped");
|
||||
assert_eq!(unavailable["transport"], "webrtc");
|
||||
assert_eq!(unavailable["mode"], "call_create");
|
||||
assert!(!serde_json::to_string(&logs)
|
||||
.expect("logs should serialize")
|
||||
.contains("unmapped-live-offer"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,25 @@ pub(crate) async fn live_websocket(
|
||||
{
|
||||
AuthenticatedAiWebSocketUpgradePreparation::Rejected(response) => Ok(response),
|
||||
AuthenticatedAiWebSocketUpgradePreparation::Ready(prepared) => {
|
||||
let live =
|
||||
match session::prepare_live_websocket(prepared.state(), prepared.context()).await {
|
||||
Ok(live) => live,
|
||||
Err(rejection) => {
|
||||
return prepared.rejection_response(rejection.status(), rejection.message())
|
||||
let live = match session::prepare_live_websocket(prepared.state(), prepared.context())
|
||||
.await
|
||||
{
|
||||
Ok(live) => live,
|
||||
Err(rejection) => {
|
||||
if !rejection.audit_persisted() {
|
||||
audit::record_live_websocket_preflight_failure(
|
||||
prepared.state(),
|
||||
&prepared.context().decision,
|
||||
prepared.context().trace_id.as_str(),
|
||||
prepared.context().uri.path(),
|
||||
prepared.context().uri.query(),
|
||||
rejection.status(),
|
||||
rejection.termination(),
|
||||
);
|
||||
}
|
||||
};
|
||||
return prepared.rejection_response(rejection.status(), rejection.message());
|
||||
}
|
||||
};
|
||||
Ok(prepared.into_response_with(
|
||||
ws,
|
||||
LIVE_WEBSOCKET_SESSION_LIMITS,
|
||||
|
||||
@@ -24,11 +24,13 @@ use crate::ai_serving::{
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::headers::request_origin_from_headers_and_remote_addr;
|
||||
use crate::privacy::RedactionSessionSlot;
|
||||
use crate::state::LocalExecutionRuntimeMissDiagnostic;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::protocol::{validate_model, LiveProtocolError};
|
||||
use super::protocol::{validate_model, LiveProtocolError, LiveRouteDialect};
|
||||
|
||||
pub(super) const LIVE_ALPHA_HEADER_VALUE: &str = "quicksilver=v2";
|
||||
pub(super) const LEGACY_LIVE_ALPHA_HEADER_VALUE: &str = "quicksilver=v2";
|
||||
pub(super) const REALTIME_LIVE_ALPHA_HEADER_VALUE: &str = "quicksilver=v1";
|
||||
const CHATGPT_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id";
|
||||
const CHATGPT_FEDRAMP_HEADER: &str = "x-openai-fedramp";
|
||||
const CHATGPT_SESSION_ID_HEADER: &str = "x-session-id";
|
||||
@@ -56,6 +58,50 @@ pub(super) struct PlannedLiveCandidate {
|
||||
pub(super) routing_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct LiveCandidatePlanningOutcome {
|
||||
pub(super) candidate: Option<PlannedLiveCandidate>,
|
||||
pub(super) runtime_miss: Option<LocalExecutionRuntimeMissDiagnostic>,
|
||||
}
|
||||
|
||||
/// Owns the request-scoped runtime-miss entry while Live planning is in
|
||||
/// progress. The ordinary HTTP proxy consumes this entry after planning, but
|
||||
/// Live has three specialized call sites and pinned sideband planning can be
|
||||
/// cancelled when its attachment lease is lost. Clearing from `Drop` makes
|
||||
/// that cancellation path safe as well as ordinary errors and early returns.
|
||||
struct LiveRuntimeMissDiagnosticGuard<'a> {
|
||||
state: &'a AppState,
|
||||
trace_id: &'a str,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<'a> LiveRuntimeMissDiagnosticGuard<'a> {
|
||||
fn new(state: &'a AppState, trace_id: &'a str) -> Self {
|
||||
Self {
|
||||
state,
|
||||
trace_id,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn take(mut self) -> Option<LocalExecutionRuntimeMissDiagnostic> {
|
||||
let diagnostic = self
|
||||
.state
|
||||
.take_local_execution_runtime_miss_diagnostic(self.trace_id);
|
||||
self.armed = false;
|
||||
diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LiveRuntimeMissDiagnosticGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
self.state
|
||||
.clear_local_execution_runtime_miss_diagnostic(self.trace_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancellation-safe owner for the scheduler's distributed pool-key lease.
|
||||
/// Live does not enter the ordinary HTTP/Responses attempt lifecycle, so it
|
||||
/// must hold and release the lease explicitly for the call or socket lifetime.
|
||||
@@ -148,6 +194,35 @@ pub(super) async fn plan_live_candidate(
|
||||
headers: &HeaderMap,
|
||||
remote_addr: &SocketAddr,
|
||||
client_model: &str,
|
||||
dialect: LiveRouteDialect,
|
||||
pinned_candidate: Option<&ResponsesWebSocketPinnedCandidate>,
|
||||
) -> Result<LiveCandidatePlanningOutcome, GatewayError> {
|
||||
let diagnostic_guard = LiveRuntimeMissDiagnosticGuard::new(state, trace_id);
|
||||
let candidate = plan_live_candidate_inner(
|
||||
state,
|
||||
trace_id,
|
||||
decision,
|
||||
headers,
|
||||
remote_addr,
|
||||
client_model,
|
||||
dialect,
|
||||
pinned_candidate,
|
||||
)
|
||||
.await?;
|
||||
Ok(LiveCandidatePlanningOutcome {
|
||||
candidate,
|
||||
runtime_miss: diagnostic_guard.take(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn plan_live_candidate_inner(
|
||||
state: &AppState,
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
headers: &HeaderMap,
|
||||
remote_addr: &SocketAddr,
|
||||
client_model: &str,
|
||||
dialect: LiveRouteDialect,
|
||||
pinned_candidate: Option<&ResponsesWebSocketPinnedCandidate>,
|
||||
) -> Result<Option<PlannedLiveCandidate>, GatewayError> {
|
||||
if validate_model(client_model).is_err() || client_model.len() > MAX_LIVE_MODEL_BYTES {
|
||||
@@ -247,7 +322,7 @@ pub(super) async fn plan_live_candidate(
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
apply_live_headers(&mut execution.provider_request_headers, trace_id);
|
||||
apply_live_headers(&mut execution.provider_request_headers, trace_id, dialect);
|
||||
let routing_fingerprint =
|
||||
match live_routing_fingerprint(&execution, effective_auth_type.as_str(), auth_mode) {
|
||||
Ok(fingerprint) => fingerprint,
|
||||
@@ -272,21 +347,92 @@ pub(super) async fn plan_live_candidate(
|
||||
|
||||
pub(super) fn direct_live_websocket_url(
|
||||
candidate: &PlannedLiveCandidate,
|
||||
) -> Result<String, LiveProtocolError> {
|
||||
direct_live_websocket_url_for_dialect(candidate, LiveRouteDialect::LegacyLive)
|
||||
}
|
||||
|
||||
pub(super) fn direct_live_websocket_url_for_dialect(
|
||||
candidate: &PlannedLiveCandidate,
|
||||
dialect: LiveRouteDialect,
|
||||
) -> Result<String, LiveProtocolError> {
|
||||
if candidate.auth_mode == LiveAuthMode::ChatGptOauth {
|
||||
return Err(LiveProtocolError::OauthDirectWebSocketUnsupported);
|
||||
}
|
||||
replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["live"],
|
||||
Some(("model", candidate.provider_model.as_str())),
|
||||
)
|
||||
match dialect {
|
||||
LiveRouteDialect::LegacyLive => replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["live"],
|
||||
Some(("model", candidate.provider_model.as_str())),
|
||||
),
|
||||
LiveRouteDialect::Realtime => {
|
||||
// The endpoint is planned using the canonical codex:live `/live`
|
||||
// URL. Direct Codex WebSocket clients use `/realtime` instead,
|
||||
// with the quicksilver intent and mapped provider model in the
|
||||
// query string. Keep endpoint-owned query parameters while
|
||||
// replacing any duplicate transport selectors.
|
||||
let raw = replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["realtime"],
|
||||
None,
|
||||
)?;
|
||||
let mut url =
|
||||
Url::parse(raw.as_str()).map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?;
|
||||
replace_url_query_pair(&mut url, "intent", "quicksilver");
|
||||
replace_url_query_pair(&mut url, "model", candidate.provider_model.as_str());
|
||||
Ok(url.to_string())
|
||||
}
|
||||
LiveRouteDialect::RealtimeV2 => {
|
||||
// Realtime v2 is the current Codex default. It intentionally
|
||||
// omits the v1 quicksilver intent marker; remove any stale or
|
||||
// duplicate endpoint-owned intent before adding the mapped model.
|
||||
let raw = replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["realtime"],
|
||||
None,
|
||||
)?;
|
||||
let mut url =
|
||||
Url::parse(raw.as_str()).map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?;
|
||||
remove_url_query_pair(&mut url, "intent");
|
||||
replace_url_query_pair(&mut url, "model", candidate.provider_model.as_str());
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn live_call_url(candidate: &PlannedLiveCandidate) -> Result<String, LiveProtocolError> {
|
||||
pub(super) fn live_call_url(
|
||||
candidate: &PlannedLiveCandidate,
|
||||
dialect: LiveRouteDialect,
|
||||
) -> Result<String, LiveProtocolError> {
|
||||
if dialect == LiveRouteDialect::RealtimeV2 {
|
||||
// Codex pins AVAS/WebRTC call creation to Realtime v1. Realtime v2 is
|
||||
// a direct WebSocket transport and has no call-create exchange.
|
||||
return Err(LiveProtocolError::InvalidCallLocation);
|
||||
}
|
||||
match candidate.auth_mode {
|
||||
LiveAuthMode::ApiKey => {
|
||||
replace_live_suffix(candidate.execution.upstream_url.as_deref(), &["live"], None)
|
||||
let raw = replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
match dialect {
|
||||
LiveRouteDialect::LegacyLive => &["live"],
|
||||
LiveRouteDialect::Realtime => &["realtime", "calls"],
|
||||
LiveRouteDialect::RealtimeV2 => unreachable!("handled above"),
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
if dialect == LiveRouteDialect::LegacyLive {
|
||||
return Ok(raw);
|
||||
}
|
||||
|
||||
// Current Codex creates AVAS calls with these two transport
|
||||
// selectors. The scheduler intentionally plans against a synthetic
|
||||
// body and fixed URI, so reconstruct them here rather than trusting
|
||||
// arbitrary downstream query parameters. Replacement is
|
||||
// case-insensitive and preserves endpoint-owned query parameters.
|
||||
let mut url =
|
||||
Url::parse(raw.as_str()).map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?;
|
||||
replace_url_query_pair(&mut url, "intent", "quicksilver");
|
||||
replace_url_query_pair(&mut url, "architecture", "avas");
|
||||
Ok(url.to_string())
|
||||
}
|
||||
LiveAuthMode::ChatGptOauth => {
|
||||
let source =
|
||||
@@ -309,20 +455,77 @@ pub(super) fn live_call_url(candidate: &PlannedLiveCandidate) -> Result<String,
|
||||
pub(super) fn live_sideband_url(
|
||||
candidate: &PlannedLiveCandidate,
|
||||
call_id: &str,
|
||||
dialect: LiveRouteDialect,
|
||||
) -> Result<String, LiveProtocolError> {
|
||||
super::protocol::validate_call_id(call_id)?;
|
||||
match candidate.auth_mode {
|
||||
LiveAuthMode::ApiKey => replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["live", call_id],
|
||||
None,
|
||||
),
|
||||
LiveAuthMode::ApiKey => match dialect {
|
||||
LiveRouteDialect::LegacyLive => replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["live", call_id],
|
||||
None,
|
||||
),
|
||||
LiveRouteDialect::Realtime => {
|
||||
// AVAS sideband sockets use the same Codex quicksilver
|
||||
// discriminator as the call-creation and direct websocket
|
||||
// paths. Keep endpoint-owned query parameters, but replace
|
||||
// any stale/duplicate transport selectors so the upstream
|
||||
// receives exactly one `intent=quicksilver` and `call_id`.
|
||||
let raw = replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["realtime"],
|
||||
None,
|
||||
)?;
|
||||
let mut url =
|
||||
Url::parse(raw.as_str()).map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?;
|
||||
replace_url_query_pair(&mut url, "intent", "quicksilver");
|
||||
replace_url_query_pair(&mut url, "call_id", call_id);
|
||||
Ok(url.to_string())
|
||||
}
|
||||
LiveRouteDialect::RealtimeV2 => {
|
||||
// V2 sideband joins (if used by a future client) carry only
|
||||
// the call id; unlike V1 they must not include quicksilver.
|
||||
let raw = replace_live_suffix(
|
||||
candidate.execution.upstream_url.as_deref(),
|
||||
&["realtime"],
|
||||
None,
|
||||
)?;
|
||||
let mut url =
|
||||
Url::parse(raw.as_str()).map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?;
|
||||
remove_url_query_pair(&mut url, "intent");
|
||||
replace_url_query_pair(&mut url, "call_id", call_id);
|
||||
Ok(url.to_string())
|
||||
}
|
||||
},
|
||||
// The Codex ChatGPT call creation endpoint returns an OpenAI Realtime
|
||||
// call ID. Current Codex connects its sideband to this API origin even
|
||||
// when call creation used the ChatGPT OAuth backend.
|
||||
LiveAuthMode::ChatGptOauth => {
|
||||
validated_official_chatgpt_url(candidate.execution.upstream_url.as_deref())?;
|
||||
Ok(format!("https://api.openai.com/v1/live/{call_id}"))
|
||||
let mut url = Url::parse("https://api.openai.com/v1/live")
|
||||
.map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?;
|
||||
// The sideband intentionally crosses from the official ChatGPT
|
||||
// backend to the OpenAI API origin. Do not copy backend query
|
||||
// parameters across that boundary; only the transport selectors
|
||||
// constructed below are valid on the sideband origin.
|
||||
match dialect {
|
||||
LiveRouteDialect::LegacyLive => {
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| LiveProtocolError::InvalidUpstreamUrl)?
|
||||
.push(call_id);
|
||||
}
|
||||
LiveRouteDialect::Realtime => {
|
||||
url.set_path("/v1/realtime");
|
||||
replace_url_query_pair(&mut url, "intent", "quicksilver");
|
||||
replace_url_query_pair(&mut url, "call_id", call_id);
|
||||
}
|
||||
LiveRouteDialect::RealtimeV2 => {
|
||||
url.set_path("/v1/realtime");
|
||||
remove_url_query_pair(&mut url, "intent");
|
||||
replace_url_query_pair(&mut url, "call_id", call_id);
|
||||
}
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,8 +533,18 @@ pub(super) fn live_sideband_url(
|
||||
pub(super) fn apply_live_headers(
|
||||
headers: &mut std::collections::BTreeMap<String, String>,
|
||||
seed: &str,
|
||||
dialect: LiveRouteDialect,
|
||||
) {
|
||||
replace_header(headers, "openai-alpha", LIVE_ALPHA_HEADER_VALUE);
|
||||
let alpha = match dialect {
|
||||
LiveRouteDialect::LegacyLive => LEGACY_LIVE_ALPHA_HEADER_VALUE,
|
||||
LiveRouteDialect::Realtime => REALTIME_LIVE_ALPHA_HEADER_VALUE,
|
||||
LiveRouteDialect::RealtimeV2 => "",
|
||||
};
|
||||
if dialect == LiveRouteDialect::RealtimeV2 {
|
||||
headers.retain(|name, _| !name.eq_ignore_ascii_case("openai-alpha"));
|
||||
} else {
|
||||
replace_header(headers, "openai-alpha", alpha);
|
||||
}
|
||||
let session_id = find_header(headers, "x-session-id")
|
||||
.or_else(|| find_header(headers, "thread-id"))
|
||||
.or_else(|| find_header(headers, "session-id"))
|
||||
@@ -489,6 +702,11 @@ fn replace_live_suffix(
|
||||
}
|
||||
|
||||
fn replace_url_query_pair(url: &mut Url, name: &str, value: &str) {
|
||||
remove_url_query_pair(url, name);
|
||||
url.query_pairs_mut().append_pair(name, value);
|
||||
}
|
||||
|
||||
fn remove_url_query_pair(url: &mut Url, name: &str) {
|
||||
let retained = url
|
||||
.query_pairs()
|
||||
.filter(|(candidate, _)| !candidate.eq_ignore_ascii_case(name))
|
||||
@@ -499,7 +717,6 @@ fn replace_url_query_pair(url: &mut Url, name: &str, value: &str) {
|
||||
for (key, value) in retained {
|
||||
query.append_pair(key.as_str(), value.as_str());
|
||||
}
|
||||
query.append_pair(name, value);
|
||||
}
|
||||
|
||||
fn canonical_live_route_query(url: &Url) -> String {
|
||||
@@ -637,6 +854,55 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn live_runtime_miss_guard_returns_and_removes_the_diagnostic() {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
let trace_id = "trace-live-planner-diagnostic";
|
||||
let expected = LocalExecutionRuntimeMissDiagnostic {
|
||||
reason: "no_eligible_candidate".to_string(),
|
||||
candidate_count: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
state.set_local_execution_runtime_miss_diagnostic(trace_id, expected.clone());
|
||||
|
||||
let diagnostic = LiveRuntimeMissDiagnosticGuard::new(&state, trace_id).take();
|
||||
|
||||
assert_eq!(diagnostic, Some(expected));
|
||||
assert!(state
|
||||
.take_local_execution_runtime_miss_diagnostic(trace_id)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_live_planning_clears_the_runtime_miss_diagnostic() {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
let trace_id = "trace-live-planner-cancelled";
|
||||
state.set_local_execution_runtime_miss_diagnostic(
|
||||
trace_id,
|
||||
LocalExecutionRuntimeMissDiagnostic {
|
||||
reason: "planning_started".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let task_state = state.clone();
|
||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let _guard = LiveRuntimeMissDiagnosticGuard::new(&task_state, trace_id);
|
||||
let _ = ready_tx.send(());
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
ready_rx
|
||||
.await
|
||||
.expect("planning task should install its diagnostic guard");
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
|
||||
assert!(state
|
||||
.take_local_execution_runtime_miss_diagnostic(trace_id)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
fn candidate(url: &str, auth_mode: LiveAuthMode) -> PlannedLiveCandidate {
|
||||
let execution: AiExecutionDecision = serde_json::from_value(json!({
|
||||
"action": "stream",
|
||||
@@ -794,13 +1060,166 @@ mod tests {
|
||||
);
|
||||
assert!(!direct.as_str().contains("global-model"));
|
||||
assert_eq!(
|
||||
live_call_url(&candidate).unwrap(),
|
||||
live_call_url(&candidate, LiveRouteDialect::LegacyLive).unwrap(),
|
||||
"https://api.example.test/v1/live?api-version=2026-08-01&model=stale&MODEL=duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_abc-123").unwrap(),
|
||||
live_sideband_url(&candidate, "rtc_abc-123", LiveRouteDialect::LegacyLive).unwrap(),
|
||||
"https://api.example.test/v1/live/rtc_abc-123?api-version=2026-08-01&model=stale&MODEL=duplicate"
|
||||
);
|
||||
assert_eq!(
|
||||
live_call_url(&candidate, LiveRouteDialect::Realtime).unwrap(),
|
||||
"https://api.example.test/v1/realtime/calls?api-version=2026-08-01&model=stale&MODEL=duplicate&intent=quicksilver&architecture=avas"
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_abc-123", LiveRouteDialect::Realtime).unwrap(),
|
||||
"https://api.example.test/v1/realtime?api-version=2026-08-01&model=stale&MODEL=duplicate&intent=quicksilver&call_id=rtc_abc-123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_direct_realtime_websocket_url_with_mapped_model_and_intent() {
|
||||
let mut candidate = candidate(
|
||||
"https://api.example.test/v1/live?api-version=2026-08-01&intent=stale&INTENT=duplicate&model=stale&MODEL=duplicate&deployment=primary",
|
||||
LiveAuthMode::ApiKey,
|
||||
);
|
||||
candidate.provider_model = "upstream/model + future".to_string();
|
||||
let direct = Url::parse(
|
||||
direct_live_websocket_url_for_dialect(&candidate, LiveRouteDialect::Realtime)
|
||||
.expect("direct Realtime URL should build")
|
||||
.as_str(),
|
||||
)
|
||||
.expect("direct Realtime URL should parse");
|
||||
assert_eq!(direct.path(), "/v1/realtime");
|
||||
assert_eq!(
|
||||
direct.query_pairs().collect::<Vec<_>>(),
|
||||
vec![
|
||||
("api-version".into(), "2026-08-01".into()),
|
||||
("deployment".into(), "primary".into()),
|
||||
("intent".into(), "quicksilver".into()),
|
||||
("model".into(), "upstream/model + future".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
direct
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("intent"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
direct
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("model"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_direct_realtime_v2_websocket_url_without_intent() {
|
||||
let mut candidate = candidate(
|
||||
"https://api.example.test/v1/live?api-version=2026-08-01&intent=stale&INTENT=duplicate&model=stale&MODEL=duplicate&deployment=primary",
|
||||
LiveAuthMode::ApiKey,
|
||||
);
|
||||
candidate.provider_model = "upstream/model + future".to_string();
|
||||
let direct = Url::parse(
|
||||
direct_live_websocket_url_for_dialect(&candidate, LiveRouteDialect::RealtimeV2)
|
||||
.expect("direct Realtime v2 URL should build")
|
||||
.as_str(),
|
||||
)
|
||||
.expect("direct Realtime v2 URL should parse");
|
||||
assert_eq!(direct.path(), "/v1/realtime");
|
||||
assert_eq!(
|
||||
direct.query_pairs().collect::<Vec<_>>(),
|
||||
vec![
|
||||
("api-version".into(), "2026-08-01".into()),
|
||||
("deployment".into(), "primary".into()),
|
||||
("model".into(), "upstream/model + future".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
direct
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("intent"))
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
direct
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("model"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_api_key_call_url_replaces_duplicate_transport_selectors() {
|
||||
let candidate = candidate(
|
||||
"https://api.example.test/v1/live?intent=stale&INTENT=duplicate&architecture=stale&ARCHITECTURE=duplicate&deployment=primary",
|
||||
LiveAuthMode::ApiKey,
|
||||
);
|
||||
let url = Url::parse(
|
||||
live_call_url(&candidate, LiveRouteDialect::Realtime)
|
||||
.expect("Realtime API-key call URL should build")
|
||||
.as_str(),
|
||||
)
|
||||
.expect("Realtime API-key call URL should parse");
|
||||
assert_eq!(
|
||||
url.query_pairs().collect::<Vec<_>>(),
|
||||
vec![
|
||||
("deployment".into(), "primary".into()),
|
||||
("intent".into(), "quicksilver".into()),
|
||||
("architecture".into(), "avas".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("intent"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("architecture"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_sideband_url_replaces_duplicate_transport_selectors() {
|
||||
let candidate = candidate(
|
||||
"https://api.example.test/v1/live?trace=1&intent=stale&INTENT=duplicate&call_id=old&CALL_ID=duplicate",
|
||||
LiveAuthMode::ApiKey,
|
||||
);
|
||||
let url = Url::parse(
|
||||
live_sideband_url(&candidate, "rtc_current", LiveRouteDialect::Realtime)
|
||||
.expect("Realtime sideband URL should build")
|
||||
.as_str(),
|
||||
)
|
||||
.expect("Realtime sideband URL should parse");
|
||||
assert_eq!(
|
||||
url.query_pairs().collect::<Vec<_>>(),
|
||||
vec![
|
||||
("trace".into(), "1".into()),
|
||||
("intent".into(), "quicksilver".into()),
|
||||
("call_id".into(), "rtc_current".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("intent"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
url.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("call_id"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -809,7 +1228,12 @@ mod tests {
|
||||
"https://chatgpt.com/backend-api/codex/live?api-version=2026-08-01&intent=stale&INTENT=duplicate&architecture=stale&ARCHITECTURE=duplicate",
|
||||
LiveAuthMode::ChatGptOauth,
|
||||
);
|
||||
let call = Url::parse(live_call_url(&candidate).unwrap().as_str()).unwrap();
|
||||
let call = Url::parse(
|
||||
live_call_url(&candidate, LiveRouteDialect::Realtime)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(call.path(), "/backend-api/codex/realtime/calls");
|
||||
assert_eq!(
|
||||
call.query_pairs().collect::<Vec<_>>(),
|
||||
@@ -832,15 +1256,55 @@ mod tests {
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_call_1").unwrap(),
|
||||
live_sideband_url(&candidate, "rtc_call_1", LiveRouteDialect::LegacyLive).unwrap(),
|
||||
"https://api.openai.com/v1/live/rtc_call_1"
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_call_1", LiveRouteDialect::Realtime).unwrap(),
|
||||
"https://api.openai.com/v1/realtime?intent=quicksilver&call_id=rtc_call_1"
|
||||
);
|
||||
let oauth_sideband = Url::parse(
|
||||
live_sideband_url(&candidate, "rtc_call_1", LiveRouteDialect::Realtime)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
oauth_sideband
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("intent"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
oauth_sideband
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| name.eq_ignore_ascii_case("call_id"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
direct_live_websocket_url(&candidate),
|
||||
Err(LiveProtocolError::OauthDirectWebSocketUnsupported)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_oauth_sideband_does_not_copy_cross_origin_query() {
|
||||
let candidate = candidate(
|
||||
"https://chatgpt.com/backend-api/codex/live?api-version=2026-08-01&deployment=backend-only&access_token=must-not-cross-origin&X-Api-Key=must-not-cross-origin",
|
||||
LiveAuthMode::ChatGptOauth,
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_call_1", LiveRouteDialect::LegacyLive).unwrap(),
|
||||
"https://api.openai.com/v1/live/rtc_call_1"
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_call_1", LiveRouteDialect::Realtime).unwrap(),
|
||||
"https://api.openai.com/v1/realtime?intent=quicksilver&call_id=rtc_call_1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_oauth_live_fails_closed_for_custom_backend_origins() {
|
||||
let candidate = candidate(
|
||||
@@ -848,11 +1312,11 @@ mod tests {
|
||||
LiveAuthMode::ChatGptOauth,
|
||||
);
|
||||
assert_eq!(
|
||||
live_call_url(&candidate),
|
||||
live_call_url(&candidate, LiveRouteDialect::Realtime),
|
||||
Err(LiveProtocolError::OauthUpstreamUnsupported)
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&candidate, "rtc_custom_backend"),
|
||||
live_sideband_url(&candidate, "rtc_custom_backend", LiveRouteDialect::Realtime),
|
||||
Err(LiveProtocolError::OauthUpstreamUnsupported)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -973,15 +1437,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_headers_force_quicksilver_and_preserve_a_stable_session_identity() {
|
||||
fn live_headers_force_the_route_dialect_and_preserve_a_stable_session_identity() {
|
||||
let mut headers = BTreeMap::from([
|
||||
("OpenAI-Alpha".to_string(), "wrong".to_string()),
|
||||
("thread-id".to_string(), "thread-stable".to_string()),
|
||||
]);
|
||||
apply_live_headers(&mut headers, "trace-fallback");
|
||||
apply_live_headers(&mut headers, "trace-fallback", LiveRouteDialect::Realtime);
|
||||
assert_eq!(
|
||||
headers.get("openai-alpha").map(String::as_str),
|
||||
Some(LIVE_ALPHA_HEADER_VALUE)
|
||||
Some(REALTIME_LIVE_ALPHA_HEADER_VALUE)
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-session-id").map(String::as_str),
|
||||
@@ -999,7 +1463,17 @@ mod tests {
|
||||
("Session-Id".to_string(), "legacy-stable".to_string()),
|
||||
("chatgpt-account-id".to_string(), "account-1".to_string()),
|
||||
]);
|
||||
apply_live_headers(&mut legacy_session_header, "trace-fallback");
|
||||
apply_live_headers(
|
||||
&mut legacy_session_header,
|
||||
"trace-fallback",
|
||||
LiveRouteDialect::LegacyLive,
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_session_header
|
||||
.get("openai-alpha")
|
||||
.map(String::as_str),
|
||||
Some(LEGACY_LIVE_ALPHA_HEADER_VALUE)
|
||||
);
|
||||
assert_eq!(
|
||||
legacy_session_header
|
||||
.get("x-session-id")
|
||||
@@ -1012,6 +1486,23 @@ mod tests {
|
||||
.map(String::as_str),
|
||||
Some("account-1")
|
||||
);
|
||||
|
||||
let mut v2_headers = BTreeMap::from([
|
||||
("OpenAI-Alpha".to_string(), "stale".to_string()),
|
||||
("thread-id".to_string(), "v2-thread".to_string()),
|
||||
]);
|
||||
apply_live_headers(
|
||||
&mut v2_headers,
|
||||
"trace-fallback",
|
||||
LiveRouteDialect::RealtimeV2,
|
||||
);
|
||||
assert!(v2_headers
|
||||
.keys()
|
||||
.all(|name| !name.eq_ignore_ascii_case("openai-alpha")));
|
||||
assert_eq!(
|
||||
v2_headers.get("x-session-id").map(String::as_str),
|
||||
Some("v2-thread")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1027,11 +1518,11 @@ mod tests {
|
||||
LiveAuthMode::ApiKey,
|
||||
);
|
||||
assert_eq!(
|
||||
live_call_url(&wrong_suffix),
|
||||
live_call_url(&wrong_suffix, LiveRouteDialect::LegacyLive),
|
||||
Err(LiveProtocolError::InvalidUpstreamUrl)
|
||||
);
|
||||
assert_eq!(
|
||||
live_sideband_url(&wrong_suffix, "rtc/escape"),
|
||||
live_sideband_url(&wrong_suffix, "rtc/escape", LiveRouteDialect::LegacyLive),
|
||||
Err(LiveProtocolError::InvalidCallId)
|
||||
);
|
||||
|
||||
@@ -1044,7 +1535,7 @@ mod tests {
|
||||
Err(LiveProtocolError::InvalidUpstreamUrl)
|
||||
);
|
||||
assert_eq!(
|
||||
live_call_url(&fragment),
|
||||
live_call_url(&fragment, LiveRouteDialect::LegacyLive),
|
||||
Err(LiveProtocolError::InvalidUpstreamUrl)
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! only bounded identifiers, multipart framing and the first `session.update`
|
||||
//! check; it intentionally does not copy the evolving Codex event schema.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use serde_json::Value;
|
||||
|
||||
const MAX_MODEL_BYTES: usize = 256;
|
||||
@@ -15,6 +15,41 @@ const MAX_SDP_BYTES: usize = 512 * 1024;
|
||||
const MAX_SESSION_BYTES: usize = 256 * 1024;
|
||||
const MAX_PART_HEADERS_BYTES: usize = 8 * 1024;
|
||||
|
||||
pub(super) const LEGACY_LIVE_CALL_PATH: &str = "/v1/live";
|
||||
pub(super) const REALTIME_CALLS_PATH: &str = "/v1/realtime/calls";
|
||||
pub(super) const REALTIME_SIDEBAND_PATH: &str = "/v1/realtime";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum LiveRouteDialect {
|
||||
LegacyLive,
|
||||
Realtime,
|
||||
/// OpenAI Realtime v2, which is the current Codex default. Unlike the
|
||||
/// legacy Codex realtime dialect it deliberately omits the
|
||||
/// `intent=quicksilver` query selector and `openai-alpha` header.
|
||||
RealtimeV2,
|
||||
}
|
||||
|
||||
impl LiveRouteDialect {
|
||||
pub(super) fn from_call_create_path(path: &str) -> Option<Self> {
|
||||
match path {
|
||||
LEGACY_LIVE_CALL_PATH => Some(Self::LegacyLive),
|
||||
REALTIME_CALLS_PATH => Some(Self::Realtime),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn downstream_location(self, call_id: &str) -> String {
|
||||
match self {
|
||||
Self::LegacyLive => format!("{LEGACY_LIVE_CALL_PATH}/{call_id}"),
|
||||
Self::Realtime => format!("{REALTIME_CALLS_PATH}/{call_id}"),
|
||||
// V2 does not create WebRTC calls through this route today (Codex
|
||||
// pins AVAS call creation to V1). Keep a valid sideband location
|
||||
// for defensive callers rather than synthesising a new path.
|
||||
Self::RealtimeV2 => format!("{REALTIME_SIDEBAND_PATH}?call_id={call_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub(super) enum LiveProtocolError {
|
||||
#[error("missing Live upstream URL")]
|
||||
@@ -27,6 +62,10 @@ pub(super) enum LiveProtocolError {
|
||||
OauthUpstreamUnsupported,
|
||||
#[error("invalid Live model query")]
|
||||
InvalidModelQuery,
|
||||
#[error("invalid Live websocket intent")]
|
||||
InvalidLiveIntent,
|
||||
#[error("invalid Live WebRTC architecture")]
|
||||
InvalidLiveArchitecture,
|
||||
#[error("invalid Live model")]
|
||||
InvalidModel,
|
||||
#[error("invalid Live call ID")]
|
||||
@@ -92,6 +131,8 @@ impl LiveProtocolError {
|
||||
Self::OauthDirectWebSocketUnsupported => "codex_live_oauth_direct_unsupported",
|
||||
Self::OauthUpstreamUnsupported => "codex_live_oauth_upstream_unsupported",
|
||||
Self::InvalidModelQuery => "codex_live_model_query_invalid",
|
||||
Self::InvalidLiveIntent => "codex_live_intent_invalid",
|
||||
Self::InvalidLiveArchitecture => "codex_live_architecture_invalid",
|
||||
Self::InvalidModel => "codex_live_model_invalid",
|
||||
Self::InvalidCallId => "codex_live_call_id_invalid",
|
||||
Self::UnsupportedMediaType => "codex_live_media_type_unsupported",
|
||||
@@ -129,6 +170,12 @@ impl LiveProtocolError {
|
||||
Self::InvalidModelQuery => {
|
||||
"Codex Live WebSocket requires exactly one model query parameter"
|
||||
}
|
||||
Self::InvalidLiveIntent => {
|
||||
"Codex Live WebSocket requires intent=quicksilver"
|
||||
}
|
||||
Self::InvalidLiveArchitecture => {
|
||||
"Codex Live WebRTC call creation requires architecture=avas"
|
||||
}
|
||||
Self::InvalidModel => {
|
||||
"Codex Live model must be a non-empty identifier no longer than 256 bytes"
|
||||
}
|
||||
@@ -205,6 +252,42 @@ pub(super) fn validate_call_id(call_id: &str) -> Result<(), LiveProtocolError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the current Codex AVAS WebRTC call-create selector.
|
||||
///
|
||||
/// The shared `/v1/realtime/calls` route is selected as Codex Live by its
|
||||
/// unique quicksilver intent. Require the accompanying architecture here so a
|
||||
/// malformed client request is rejected instead of being silently rewritten
|
||||
/// into a different upstream transport contract. Unknown non-sensitive query
|
||||
/// fields remain opaque for forward compatibility.
|
||||
pub(super) fn validate_realtime_call_create_query(
|
||||
query: Option<&str>,
|
||||
) -> Result<(), LiveProtocolError> {
|
||||
let mut intent_seen = false;
|
||||
let mut architecture_seen = false;
|
||||
for (name, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if name.eq_ignore_ascii_case("intent") {
|
||||
if intent_seen || !value.eq_ignore_ascii_case("quicksilver") {
|
||||
return Err(LiveProtocolError::InvalidLiveIntent);
|
||||
}
|
||||
intent_seen = true;
|
||||
} else if name.eq_ignore_ascii_case("architecture") {
|
||||
if architecture_seen || !value.eq_ignore_ascii_case("avas") {
|
||||
return Err(LiveProtocolError::InvalidLiveArchitecture);
|
||||
}
|
||||
architecture_seen = true;
|
||||
} else if live_query_parameter_is_sensitive(name.as_ref()) {
|
||||
return Err(LiveProtocolError::InvalidModelQuery);
|
||||
}
|
||||
}
|
||||
if !intent_seen {
|
||||
return Err(LiveProtocolError::InvalidLiveIntent);
|
||||
}
|
||||
if !architecture_seen {
|
||||
return Err(LiveProtocolError::InvalidLiveArchitecture);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_live_call_id_segment(call_id: &str) -> bool {
|
||||
if validate_call_id(call_id).is_err() {
|
||||
return false;
|
||||
@@ -245,6 +328,89 @@ pub(super) fn direct_model_from_query(query: Option<&str>) -> Result<String, Liv
|
||||
model.ok_or(LiveProtocolError::InvalidModelQuery)
|
||||
}
|
||||
|
||||
/// Parse the standalone Codex realtime WebSocket query shape.
|
||||
///
|
||||
/// Current Codex clients use `/v1/realtime?intent=quicksilver&model=...` for
|
||||
/// the direct WebSocket transport. The same path is also used by OpenAI's
|
||||
/// ordinary Realtime API, so the intent marker is part of the trust boundary:
|
||||
/// callers must not be able to select the Codex Live planner merely by
|
||||
/// choosing a model name.
|
||||
pub(super) fn direct_realtime_model_from_query(
|
||||
query: Option<&str>,
|
||||
) -> Result<String, LiveProtocolError> {
|
||||
let mut intent_seen = false;
|
||||
for (name, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if name.eq_ignore_ascii_case("intent") {
|
||||
if intent_seen || !value.eq_ignore_ascii_case("quicksilver") {
|
||||
return Err(LiveProtocolError::InvalidLiveIntent);
|
||||
}
|
||||
intent_seen = true;
|
||||
}
|
||||
}
|
||||
if !intent_seen {
|
||||
return Err(LiveProtocolError::InvalidLiveIntent);
|
||||
}
|
||||
direct_model_from_query(query)
|
||||
}
|
||||
|
||||
/// Parse the Codex Realtime v2 direct WebSocket query shape.
|
||||
///
|
||||
/// Realtime v2 intentionally has no `intent` marker. The caller must use the
|
||||
/// Codex originator header as the additional trust-boundary discriminator
|
||||
/// before invoking this parser; this function only validates the query itself.
|
||||
pub(super) fn direct_realtime_v2_model_from_query(
|
||||
query: Option<&str>,
|
||||
) -> Result<String, LiveProtocolError> {
|
||||
for (name, _) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if name.eq_ignore_ascii_case("intent") {
|
||||
return Err(LiveProtocolError::InvalidLiveIntent);
|
||||
}
|
||||
// A call_id identifies a WebRTC sideband socket, not a standalone
|
||||
// v2 conversation. Reject it here as well as at the session branch
|
||||
// so this parser cannot accidentally be reused as a direct route.
|
||||
if name.eq_ignore_ascii_case("call_id") {
|
||||
return Err(LiveProtocolError::InvalidModelQuery);
|
||||
}
|
||||
}
|
||||
direct_model_from_query(query)
|
||||
}
|
||||
|
||||
/// Return whether the request carries a first-party Codex originator.
|
||||
///
|
||||
/// The default Codex CLI sends `codex_cli_rs` (optionally with a version),
|
||||
/// while Desktop/Web/Mobile use their stable `codex_work_*` values. Keep the
|
||||
/// allowlist narrow so a normal OpenAI Realtime v2 socket is not routed into
|
||||
/// the Codex Live planner merely because it has a `model` query parameter.
|
||||
pub(super) fn is_codex_realtime_originator(headers: &HeaderMap) -> bool {
|
||||
let Some(originator) = headers
|
||||
.get("originator")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
originator.split_whitespace().next().is_some_and(|value| {
|
||||
value.eq_ignore_ascii_case("codex_cli_rs")
|
||||
|| value.to_ascii_lowercase().starts_with("codex_cli_rs/")
|
||||
|| value.eq_ignore_ascii_case("codex_work_desktop")
|
||||
|| value.eq_ignore_ascii_case("codex_work_web")
|
||||
|| value.eq_ignore_ascii_case("codex_work_mobile")
|
||||
})
|
||||
}
|
||||
|
||||
/// Query + header discriminator for a Codex Realtime v2 direct socket.
|
||||
pub(super) fn realtime_v2_request_is_codex(query: Option<&str>, headers: &HeaderMap) -> bool {
|
||||
let mut has_model = false;
|
||||
for (name, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if name.eq_ignore_ascii_case("intent") || name.eq_ignore_ascii_case("call_id") {
|
||||
return false;
|
||||
}
|
||||
if name.eq_ignore_ascii_case("model") && !value.trim().is_empty() {
|
||||
has_model = true;
|
||||
}
|
||||
}
|
||||
has_model && is_codex_realtime_originator(headers)
|
||||
}
|
||||
|
||||
fn live_query_parameter_is_sensitive(name: &str) -> bool {
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
@@ -275,6 +441,38 @@ pub(super) fn call_id_from_path(path: &str) -> Result<String, LiveProtocolError>
|
||||
Ok(call_id.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn realtime_sideband_query_has_call_id(query: Option<&str>) -> bool {
|
||||
url::form_urlencoded::parse(query.unwrap_or_default().as_bytes())
|
||||
.any(|(name, _)| name.eq_ignore_ascii_case("call_id"))
|
||||
}
|
||||
|
||||
pub(super) fn sideband_call_from_request(
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<(LiveRouteDialect, String), LiveProtocolError> {
|
||||
if path != REALTIME_SIDEBAND_PATH {
|
||||
return call_id_from_path(path).map(|call_id| (LiveRouteDialect::LegacyLive, call_id));
|
||||
}
|
||||
|
||||
let mut call_id = None;
|
||||
for (name, value) in url::form_urlencoded::parse(query.unwrap_or_default().as_bytes()) {
|
||||
if name.eq_ignore_ascii_case("call_id") {
|
||||
if call_id.is_some() {
|
||||
return Err(LiveProtocolError::InvalidCallId);
|
||||
}
|
||||
validate_call_id(value.as_ref())?;
|
||||
call_id = Some(value.into_owned());
|
||||
continue;
|
||||
}
|
||||
if live_query_parameter_is_sensitive(name.as_ref()) {
|
||||
return Err(LiveProtocolError::InvalidCallId);
|
||||
}
|
||||
}
|
||||
call_id
|
||||
.map(|call_id| (LiveRouteDialect::Realtime, call_id))
|
||||
.ok_or(LiveProtocolError::InvalidCallId)
|
||||
}
|
||||
|
||||
pub(super) fn validate_initial_session_update(raw: &str) -> Result<(), LiveProtocolError> {
|
||||
if raw.len() > MAX_SESSION_BYTES {
|
||||
return Err(LiveProtocolError::SessionTooLarge);
|
||||
@@ -376,19 +574,37 @@ pub(super) fn extract_call_id_from_location(location: &str) -> Result<String, Li
|
||||
return Err(LiveProtocolError::InvalidCallLocation);
|
||||
}
|
||||
let path = if let Ok(url) = url::Url::parse(location) {
|
||||
if url.fragment().is_some() {
|
||||
return Err(LiveProtocolError::InvalidCallLocation);
|
||||
}
|
||||
url.path().to_string()
|
||||
} else {
|
||||
if !location.starts_with('/') || location.contains('#') {
|
||||
return Err(LiveProtocolError::InvalidCallLocation);
|
||||
}
|
||||
location
|
||||
.split_once('?')
|
||||
.map_or(location, |(path, _)| path)
|
||||
.to_string()
|
||||
};
|
||||
let call_id = path
|
||||
.trim_end_matches('/')
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
let segments = path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let (call_id, resource) = segments
|
||||
.split_last()
|
||||
.ok_or(LiveProtocolError::InvalidCallLocation)?;
|
||||
let valid_resource = matches!(
|
||||
resource,
|
||||
["v1", "live"]
|
||||
| ["v1", "realtime", "calls"]
|
||||
| ["v1", "realtime", "calls", "calls"]
|
||||
| ["backend-api", "codex", "realtime", "calls"]
|
||||
| ["backend-api", "codex", "realtime", "calls", "calls"]
|
||||
);
|
||||
if !valid_resource {
|
||||
return Err(LiveProtocolError::InvalidCallLocation);
|
||||
}
|
||||
if !is_live_call_id_segment(call_id) {
|
||||
return Err(LiveProtocolError::InvalidCallLocation);
|
||||
}
|
||||
@@ -593,6 +809,136 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_realtime_query_requires_quicksilver_and_preserves_model_validation() {
|
||||
assert_eq!(
|
||||
direct_realtime_model_from_query(Some(
|
||||
"intent=quicksilver&model=gpt-realtime%2Ffuture&client=codex",
|
||||
))
|
||||
.unwrap(),
|
||||
"gpt-realtime/future"
|
||||
);
|
||||
assert_eq!(
|
||||
direct_realtime_model_from_query(Some("INTENT=QUICKSILVER&model=gpt-live")).unwrap(),
|
||||
"gpt-live"
|
||||
);
|
||||
|
||||
for query in [
|
||||
None,
|
||||
Some("model=gpt-live"),
|
||||
Some("intent=other&model=gpt-live"),
|
||||
Some("intent=quicksilver&intent=quicksilver&model=gpt-live"),
|
||||
Some("intent=quicksilver&intent=other&model=gpt-live"),
|
||||
] {
|
||||
assert_eq!(
|
||||
direct_realtime_model_from_query(query),
|
||||
Err(LiveProtocolError::InvalidLiveIntent),
|
||||
"query should not select Codex Live: {query:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
direct_realtime_model_from_query(Some("intent=quicksilver&model=a&MODEL=b")),
|
||||
Err(LiveProtocolError::InvalidModelQuery)
|
||||
);
|
||||
assert_eq!(
|
||||
direct_realtime_model_from_query(Some(
|
||||
"intent=quicksilver&model=gpt-live&token=secret"
|
||||
)),
|
||||
Err(LiveProtocolError::InvalidModelQuery)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_call_create_requires_unique_quicksilver_avas_selectors() {
|
||||
assert_eq!(
|
||||
validate_realtime_call_create_query(Some(
|
||||
"intent=quicksilver&architecture=avas&future_hint=opaque"
|
||||
)),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_realtime_call_create_query(Some("architecture=avas")),
|
||||
Err(LiveProtocolError::InvalidLiveIntent)
|
||||
);
|
||||
for query in [
|
||||
"intent=other&architecture=avas",
|
||||
"intent=quicksilver&intent=quicksilver&architecture=avas",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_realtime_call_create_query(Some(query)),
|
||||
Err(LiveProtocolError::InvalidLiveIntent)
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"intent=quicksilver",
|
||||
"intent=quicksilver&architecture=other",
|
||||
"intent=quicksilver&architecture=avas&ARCHITECTURE=avas",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_realtime_call_create_query(Some(query)),
|
||||
Err(LiveProtocolError::InvalidLiveArchitecture)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
validate_realtime_call_create_query(Some(
|
||||
"intent=quicksilver&architecture=avas&access_token=secret"
|
||||
)),
|
||||
Err(LiveProtocolError::InvalidModelQuery)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_realtime_v2_query_omits_intent_and_rejects_sideband_selectors() {
|
||||
assert_eq!(
|
||||
direct_realtime_v2_model_from_query(Some("model=gpt-realtime%2Ffuture&client=codex"))
|
||||
.unwrap(),
|
||||
"gpt-realtime/future"
|
||||
);
|
||||
for query in [
|
||||
None,
|
||||
Some("intent=quicksilver&model=gpt-live"),
|
||||
Some("intent=other&model=gpt-live"),
|
||||
Some("model=gpt-live&call_id=rtc_1"),
|
||||
Some("model=a&MODEL=b"),
|
||||
Some("model=gpt-live&token=secret"),
|
||||
] {
|
||||
assert!(
|
||||
direct_realtime_v2_model_from_query(query).is_err(),
|
||||
"v2 query should be rejected: {query:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_v2_request_requires_a_codex_originator() {
|
||||
let mut codex = HeaderMap::new();
|
||||
codex.insert("originator", "codex_work_desktop".parse().unwrap());
|
||||
assert!(realtime_v2_request_is_codex(
|
||||
Some("model=gpt-realtime-1.5"),
|
||||
&codex
|
||||
));
|
||||
codex.insert("originator", "codex_cli_rs/0.145.2".parse().unwrap());
|
||||
assert!(realtime_v2_request_is_codex(
|
||||
Some("model=gpt-realtime-1.5"),
|
||||
&codex
|
||||
));
|
||||
for (query, originator) in [
|
||||
("model=gpt-realtime-1.5", "openai-python"),
|
||||
(
|
||||
"intent=quicksilver&model=gpt-realtime-1.5",
|
||||
"codex_work_desktop",
|
||||
),
|
||||
("model=gpt-realtime-1.5&call_id=rtc_1", "codex_work_desktop"),
|
||||
("model=gpt-realtime-1.5", ""),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
if !originator.is_empty() {
|
||||
headers.insert("originator", originator.parse().unwrap());
|
||||
}
|
||||
assert!(!realtime_v2_request_is_codex(Some(query), &headers));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_protocol_starts_with_session_update_not_response_create() {
|
||||
let opaque = r#"{"type":"session.update","session":{"future_capability":{"version":2}},"future_event_field":[1,2,3]}"#;
|
||||
@@ -733,6 +1079,9 @@ mod tests {
|
||||
for location in [
|
||||
"https://api.openai.com/v1/live/rtc_abc-123",
|
||||
"/v1/live/550e8400-e29b-41d4-a716-446655440000",
|
||||
"/v1/realtime/calls/rtc_current",
|
||||
"https://chatgpt.com/backend-api/codex/realtime/calls/rtc_backend",
|
||||
"/v1/realtime/calls/calls/rtc_forwarded",
|
||||
] {
|
||||
assert!(extract_call_id_from_location(location).is_ok());
|
||||
}
|
||||
@@ -740,7 +1089,13 @@ mod tests {
|
||||
extract_call_id_from_location("/v1/live/rtc%2Fescape"),
|
||||
Err(LiveProtocolError::InvalidCallLocation)
|
||||
);
|
||||
for location in ["/v1/live", "/v1/live/not-a-call-id"] {
|
||||
for location in [
|
||||
"/v1/live",
|
||||
"/v1/live/not-a-call-id",
|
||||
"/unrelated/rtc_opaque",
|
||||
"?call_id=rtc_query_only",
|
||||
"/v1/realtime/calls/rtc_valid#fragment",
|
||||
] {
|
||||
assert_eq!(
|
||||
extract_call_id_from_location(location),
|
||||
Err(LiveProtocolError::InvalidCallLocation)
|
||||
@@ -754,6 +1109,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_call_create_and_sideband_route_dialects() {
|
||||
assert_eq!(
|
||||
LiveRouteDialect::from_call_create_path("/v1/live"),
|
||||
Some(LiveRouteDialect::LegacyLive)
|
||||
);
|
||||
assert_eq!(
|
||||
LiveRouteDialect::from_call_create_path("/v1/realtime/calls"),
|
||||
Some(LiveRouteDialect::Realtime)
|
||||
);
|
||||
assert_eq!(
|
||||
LiveRouteDialect::from_call_create_path("/v1/realtime"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
sideband_call_from_request("/v1/live/rtc_legacy", None),
|
||||
Ok((LiveRouteDialect::LegacyLive, "rtc_legacy".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
sideband_call_from_request(
|
||||
"/v1/realtime",
|
||||
Some("intent=quicksilver&call_id=rtc_current")
|
||||
),
|
||||
Ok((LiveRouteDialect::Realtime, "rtc_current".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_sideband_query_rejects_ambiguous_or_sensitive_call_ids() {
|
||||
assert!(realtime_sideband_query_has_call_id(Some("call_id=")));
|
||||
assert!(realtime_sideband_query_has_call_id(Some(
|
||||
"CALL_ID=rtc_one&call_id=rtc_two"
|
||||
)));
|
||||
for query in [
|
||||
"call_id=",
|
||||
"call_id=rtc_one&call_id=rtc_two",
|
||||
"call_id=rtc_valid&token=secret",
|
||||
"model=gpt-realtime",
|
||||
] {
|
||||
assert_eq!(
|
||||
sideband_call_from_request("/v1/realtime", Some(query)),
|
||||
Err(LiveProtocolError::InvalidCallId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_event_discriminator_does_not_project_unknown_fields() {
|
||||
let raw = r#"{"type":"delegation.created","unknown":{"nested":[1,2,3]}}"#;
|
||||
|
||||
@@ -26,18 +26,21 @@ use crate::handlers::proxy::websocket::transport::{
|
||||
upstream_message_to_client, websocket_relay_frame_queue, UpstreamWebSocketErrorCodes,
|
||||
WebSocketRelayPumpControl, WebSocketRelayQueueError, WebSocketWriteError,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use crate::{AppState, GatewayError, LocalExecutionRuntimeMissDiagnostic};
|
||||
|
||||
use super::audit::{
|
||||
LiveAuditTransport, LiveSessionAudit, LiveSessionDisposition, LiveSessionTerminal,
|
||||
};
|
||||
use super::live_usage_accounting_is_safe;
|
||||
use super::planner::{
|
||||
build_live_stream_admission_attempt, direct_live_websocket_url, live_sideband_url,
|
||||
plan_live_candidate, LivePoolLeaseGuard, PlannedLiveCandidate,
|
||||
build_live_stream_admission_attempt, direct_live_websocket_url_for_dialect, live_sideband_url,
|
||||
plan_live_candidate, LiveCandidatePlanningOutcome, LivePoolLeaseGuard, PlannedLiveCandidate,
|
||||
};
|
||||
use super::protocol::{
|
||||
call_id_from_path, direct_model_from_query, event_type, validate_initial_session_update,
|
||||
direct_model_from_query, direct_realtime_model_from_query, direct_realtime_v2_model_from_query,
|
||||
event_type, realtime_sideband_query_has_call_id, realtime_v2_request_is_codex,
|
||||
sideband_call_from_request, validate_initial_session_update, LiveRouteDialect,
|
||||
LEGACY_LIVE_CALL_PATH, REALTIME_SIDEBAND_PATH,
|
||||
};
|
||||
use super::registry::{
|
||||
LiveCallBinding, LiveCallLookup, LiveCallRegistry, LiveCallRegistryError, LiveSidebandLease,
|
||||
@@ -158,7 +161,9 @@ pub(super) struct PreparedLiveSideband {
|
||||
|
||||
pub(super) struct LiveWebSocketPreflightRejection {
|
||||
status: StatusCode,
|
||||
termination: &'static str,
|
||||
message: &'static str,
|
||||
audit_persisted: bool,
|
||||
}
|
||||
|
||||
impl LiveWebSocketPreflightRejection {
|
||||
@@ -169,6 +174,14 @@ impl LiveWebSocketPreflightRejection {
|
||||
pub(super) const fn message(&self) -> &'static str {
|
||||
self.message
|
||||
}
|
||||
|
||||
pub(super) const fn termination(&self) -> &'static str {
|
||||
self.termination
|
||||
}
|
||||
|
||||
pub(super) const fn audit_persisted(&self) -> bool {
|
||||
self.audit_persisted
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_live_websocket(
|
||||
@@ -193,7 +206,7 @@ pub(super) async fn prepare_live_websocket(
|
||||
"Codex Live is unavailable for finite-balance keys until Frameless usage settlement is supported",
|
||||
));
|
||||
}
|
||||
if context.uri.path() == "/v1/live" {
|
||||
if context.uri.path() == LEGACY_LIVE_CALL_PATH {
|
||||
let client_model = match direct_model_from_query(context.uri.query()) {
|
||||
Ok(model) => model,
|
||||
Err(error) => {
|
||||
@@ -206,22 +219,70 @@ pub(super) async fn prepare_live_websocket(
|
||||
));
|
||||
}
|
||||
};
|
||||
return prepare_direct_live_websocket(state, context, client_model.as_str())
|
||||
return prepare_direct_live_websocket(
|
||||
state,
|
||||
context,
|
||||
client_model.as_str(),
|
||||
LiveRouteDialect::LegacyLive,
|
||||
)
|
||||
.await
|
||||
.map(PreparedLiveWebSocket::Direct);
|
||||
}
|
||||
// Codex Realtime V1 uses the OpenAI `/v1/realtime` path with
|
||||
// `intent=quicksilver&model=...`; the current default V2 omits `intent`
|
||||
// and is identified by the first-party Codex originator header. A
|
||||
// call_id on that path is a WebRTC sideband attachment and must continue
|
||||
// through the registry branch below.
|
||||
if context.uri.path() == REALTIME_SIDEBAND_PATH
|
||||
&& !realtime_sideband_query_has_call_id(context.uri.query())
|
||||
{
|
||||
let (client_model, dialect) = match direct_realtime_model_from_query(context.uri.query()) {
|
||||
Ok(model) => (model, LiveRouteDialect::Realtime),
|
||||
Err(_v1_error)
|
||||
if realtime_v2_request_is_codex(context.uri.query(), &context.headers) =>
|
||||
{
|
||||
match direct_realtime_v2_model_from_query(context.uri.query()) {
|
||||
Ok(model) => (model, LiveRouteDialect::RealtimeV2),
|
||||
Err(error) => {
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
"direct",
|
||||
error.status_code(),
|
||||
error.code(),
|
||||
error.client_message(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
// Preserve the V1 error (notably InvalidLiveIntent) for
|
||||
// ordinary Realtime callers and malformed Codex V1 queries.
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
"direct",
|
||||
error.status_code(),
|
||||
error.code(),
|
||||
error.client_message(),
|
||||
));
|
||||
}
|
||||
};
|
||||
return prepare_direct_live_websocket(state, context, client_model.as_str(), dialect)
|
||||
.await
|
||||
.map(PreparedLiveWebSocket::Direct);
|
||||
}
|
||||
let call_id = match call_id_from_path(context.uri.path()) {
|
||||
Ok(call_id) => call_id,
|
||||
Err(error) => {
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
"sideband",
|
||||
error.status_code(),
|
||||
error.code(),
|
||||
error.client_message(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let (dialect, call_id) =
|
||||
match sideband_call_from_request(context.uri.path(), context.uri.query()) {
|
||||
Ok(sideband) => sideband,
|
||||
Err(error) => {
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
"sideband",
|
||||
error.status_code(),
|
||||
error.code(),
|
||||
error.client_message(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(auth) = context.decision.auth_context.as_ref() else {
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
@@ -299,7 +360,7 @@ pub(super) async fn prepare_live_websocket(
|
||||
));
|
||||
}
|
||||
};
|
||||
prepare_sideband_live_websocket(state, context, call_id, binding)
|
||||
prepare_sideband_live_websocket(state, context, call_id, binding, dialect)
|
||||
.await
|
||||
.map(PreparedLiveWebSocket::Sideband)
|
||||
}
|
||||
@@ -308,6 +369,7 @@ async fn prepare_direct_live_websocket(
|
||||
state: &AppState,
|
||||
context: &WebSocketRequestContext,
|
||||
client_model: &str,
|
||||
dialect: LiveRouteDialect,
|
||||
) -> Result<PreparedLiveRelay, LiveWebSocketPreflightRejection> {
|
||||
let started_at = Instant::now();
|
||||
let candidate = match plan_live_candidate(
|
||||
@@ -317,12 +379,20 @@ async fn prepare_direct_live_websocket(
|
||||
&context.headers,
|
||||
&context.remote_addr,
|
||||
client_model,
|
||||
dialect,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(candidate)) => candidate,
|
||||
Ok(None) => {
|
||||
Ok(LiveCandidatePlanningOutcome {
|
||||
candidate: Some(candidate),
|
||||
..
|
||||
}) => candidate,
|
||||
Ok(LiveCandidatePlanningOutcome {
|
||||
candidate: None,
|
||||
runtime_miss,
|
||||
}) => {
|
||||
log_live_candidate_unavailable(context, "direct", client_model, runtime_miss.as_ref());
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
"direct",
|
||||
@@ -353,7 +423,7 @@ async fn prepare_direct_live_websocket(
|
||||
}
|
||||
};
|
||||
let pool_lease = LivePoolLeaseGuard::new(state, &candidate);
|
||||
let upstream_url = match direct_live_websocket_url(&candidate) {
|
||||
let upstream_url = match direct_live_websocket_url_for_dialect(&candidate, dialect) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
pool_lease.release().await;
|
||||
@@ -488,6 +558,7 @@ async fn prepare_sideband_live_websocket(
|
||||
context: &WebSocketRequestContext,
|
||||
call_id: String,
|
||||
binding: LiveCallBinding,
|
||||
dialect: LiveRouteDialect,
|
||||
) -> Result<PreparedLiveSideband, LiveWebSocketPreflightRejection> {
|
||||
let started_at = Instant::now();
|
||||
let Some(auth) = context.decision.auth_context.as_ref() else {
|
||||
@@ -562,6 +633,7 @@ async fn prepare_sideband_live_websocket(
|
||||
&context.headers,
|
||||
&context.remote_addr,
|
||||
binding.client_model(),
|
||||
dialect,
|
||||
Some(binding.pinned_candidate()),
|
||||
),
|
||||
)
|
||||
@@ -577,8 +649,14 @@ async fn prepare_sideband_live_websocket(
|
||||
sideband_loss_message(loss),
|
||||
));
|
||||
}
|
||||
Ok(Ok(Some(candidate))) if binding.matches_candidate(&candidate) => candidate,
|
||||
Ok(Ok(Some(candidate))) => {
|
||||
Ok(Ok(LiveCandidatePlanningOutcome {
|
||||
candidate: Some(candidate),
|
||||
..
|
||||
})) if binding.matches_candidate(&candidate) => candidate,
|
||||
Ok(Ok(LiveCandidatePlanningOutcome {
|
||||
candidate: Some(candidate),
|
||||
..
|
||||
})) => {
|
||||
crate::orchestration::release_pool_key_lease_from_report_context(
|
||||
state,
|
||||
candidate.execution.report_context.as_ref(),
|
||||
@@ -593,7 +671,16 @@ async fn prepare_sideband_live_websocket(
|
||||
"Codex Live call provider binding is no longer valid",
|
||||
));
|
||||
}
|
||||
Ok(Ok(None)) => {
|
||||
Ok(Ok(LiveCandidatePlanningOutcome {
|
||||
candidate: None,
|
||||
runtime_miss,
|
||||
})) => {
|
||||
log_live_candidate_unavailable(
|
||||
context,
|
||||
"sideband",
|
||||
binding.client_model(),
|
||||
runtime_miss.as_ref(),
|
||||
);
|
||||
release_sideband_lease(&mut sideband_lease, context).await;
|
||||
return Err(preflight_rejection(
|
||||
context,
|
||||
@@ -625,7 +712,7 @@ async fn prepare_sideband_live_websocket(
|
||||
}
|
||||
};
|
||||
let pool_lease = LivePoolLeaseGuard::new(state, &candidate);
|
||||
let upstream_url = match live_sideband_url(&candidate, call_id.as_str()) {
|
||||
let upstream_url = match live_sideband_url(&candidate, call_id.as_str(), dialect) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
release_sideband_lease(&mut sideband_lease, context).await;
|
||||
@@ -1058,6 +1145,7 @@ async fn audited_preflight_rejection(
|
||||
started_at: Instant,
|
||||
audit: Option<LiveSessionAudit>,
|
||||
) -> LiveWebSocketPreflightRejection {
|
||||
let audit_persisted = audit.is_some();
|
||||
if let Some(audit) = audit {
|
||||
audit
|
||||
.finish(
|
||||
@@ -1066,7 +1154,9 @@ async fn audited_preflight_rejection(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
preflight_rejection(context, mode, status, termination, message)
|
||||
let mut rejection = preflight_rejection(context, mode, status, termination, message);
|
||||
rejection.audit_persisted = audit_persisted;
|
||||
rejection
|
||||
}
|
||||
|
||||
fn preflight_rejection(
|
||||
@@ -1088,7 +1178,43 @@ fn preflight_rejection(
|
||||
termination,
|
||||
"Codex Live WebSocket preflight rejected the HTTP upgrade"
|
||||
);
|
||||
LiveWebSocketPreflightRejection { status, message }
|
||||
LiveWebSocketPreflightRejection {
|
||||
status,
|
||||
termination,
|
||||
message,
|
||||
audit_persisted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_live_candidate_unavailable(
|
||||
context: &WebSocketRequestContext,
|
||||
mode: &'static str,
|
||||
client_model: &str,
|
||||
runtime_miss: Option<&LocalExecutionRuntimeMissDiagnostic>,
|
||||
) {
|
||||
warn!(
|
||||
target: LIVE_LOG_TARGET,
|
||||
event_name = "codex_live_candidate_unavailable",
|
||||
log_type = "ops",
|
||||
transport = WEBSOCKET_LOG_TRANSPORT,
|
||||
websocket = true,
|
||||
trace_id = %context.trace_id,
|
||||
mode,
|
||||
client_model,
|
||||
runtime_miss_reason = runtime_miss
|
||||
.map(|diagnostic| diagnostic.reason.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
candidate_count = runtime_miss
|
||||
.and_then(|diagnostic| diagnostic.candidate_count)
|
||||
.unwrap_or(0),
|
||||
skipped_candidate_count = runtime_miss
|
||||
.and_then(|diagnostic| diagnostic.skipped_candidate_count)
|
||||
.unwrap_or(0),
|
||||
skip_reasons = runtime_miss
|
||||
.and_then(|diagnostic| diagnostic.skip_reasons_summary())
|
||||
.unwrap_or_default(),
|
||||
"Codex Live request has no eligible provider mapping"
|
||||
);
|
||||
}
|
||||
|
||||
fn gateway_error_kind(error: &GatewayError) -> &'static str {
|
||||
|
||||
@@ -1708,14 +1708,22 @@ async fn receive_realtime_message(socket: &mut wreq::ws::WebSocket) -> WreqWsMes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_creates_bound_codex_live_oauth_calls_with_opaque_session_fields() {
|
||||
fn gateway_creates_bound_codex_live_oauth_calls_with_legacy_responses_mapping() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-live-oauth-frontdoor",
|
||||
run_codex_live_oauth_frontdoor_scenario(),
|
||||
run_codex_live_oauth_frontdoor_scenario(CodexLiveWebRtcTestDialect::LegacyLive),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
#[test]
|
||||
fn gateway_creates_bound_codex_realtime_oauth_calls_with_legacy_responses_mapping() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-realtime-oauth-frontdoor",
|
||||
run_codex_live_oauth_frontdoor_scenario(CodexLiveWebRtcTestDialect::Realtime),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_codex_live_oauth_frontdoor_scenario(dialect: CodexLiveWebRtcTestDialect) {
|
||||
const PROVIDER_ID: &str = "provider-codex-live";
|
||||
const ENDPOINT_ID: &str = "endpoint-provider-codex-live";
|
||||
const UPSTREAM_KEY_ID: &str = "key-provider-codex-live";
|
||||
@@ -1724,6 +1732,15 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
const CALL_ID: &str = "rtc_frontdoor_live";
|
||||
|
||||
let mut row = sample_codex_live_candidate_row(PROVIDER_ID, CLIENT_MODEL, PROVIDER_MODEL);
|
||||
// Existing Codex associations predate the dedicated Live format. The
|
||||
// provider-aware compatibility rule must carry their Responses-scoped
|
||||
// source mapping through the complete scheduler, not only the repository
|
||||
// fast path.
|
||||
if let Some(mappings) = row.model_provider_model_mappings.as_mut() {
|
||||
for mapping in mappings {
|
||||
mapping.api_formats = Some(vec!["openai:responses".to_string()]);
|
||||
}
|
||||
}
|
||||
row.key_allowed_models = Some(vec![PROVIDER_MODEL.to_string()]);
|
||||
let candidate_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
@@ -1755,6 +1772,8 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
vec![endpoint],
|
||||
vec![upstream_key],
|
||||
));
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
|
||||
let captured_plan = Arc::new(Mutex::new(None::<aether_contracts::ExecutionPlan>));
|
||||
let captured_plan_for_runtime = Arc::clone(&captured_plan);
|
||||
@@ -1804,13 +1823,19 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let state = build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_minimal_candidate_selection_and_auth_for_tests(
|
||||
candidate_repository,
|
||||
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_and_usage_for_tests(
|
||||
auth_repository,
|
||||
)
|
||||
.attach_provider_catalog_repository_for_tests(provider_catalog_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
candidate_repository,
|
||||
provider_catalog_repository,
|
||||
request_candidate_repository,
|
||||
Arc::clone(&usage_repository),
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
),
|
||||
)
|
||||
.with_usage_runtime_for_tests(crate::usage::UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
..crate::usage::UsageRuntimeConfig::default()
|
||||
});
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
@@ -1829,7 +1854,10 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
session
|
||||
);
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/live"))
|
||||
.post(format!(
|
||||
"{gateway_url}{}",
|
||||
dialect.call_create_request_target()
|
||||
))
|
||||
.header("authorization", "Bearer sk-codex-live")
|
||||
.header(
|
||||
"content-type",
|
||||
@@ -1842,7 +1870,7 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
.await
|
||||
.expect("Codex Live call creation should complete");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let downstream_location = format!("/v1/live/{CALL_ID}");
|
||||
let downstream_location = dialect.call_location(CALL_ID);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
@@ -1896,7 +1924,7 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
assert_eq!(provider_body["session"]["instructions"], "Keep this opaque");
|
||||
assert_eq!(
|
||||
plan.headers.get("openai-alpha").map(String::as_str),
|
||||
Some("quicksilver=v2")
|
||||
Some(dialect.alpha_header())
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("originator").map(String::as_str),
|
||||
@@ -1918,6 +1946,49 @@ async fn run_codex_live_oauth_frontdoor_scenario() {
|
||||
assert_eq!(plan.headers.get("thread-id"), Some(converged_session));
|
||||
uuid::Uuid::parse_str(converged_session).expect("converged session ID must be a UUID");
|
||||
|
||||
let call_create_usage = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(usage) = usage_repository
|
||||
.find_by_request_id(plan.request_id.as_str())
|
||||
.await
|
||||
.expect("Live call-create usage read should succeed")
|
||||
{
|
||||
break usage;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Live call-create usage should be persisted before timeout");
|
||||
assert_eq!(call_create_usage.status, "completed");
|
||||
assert_eq!(call_create_usage.billing_status, "void");
|
||||
assert_eq!(call_create_usage.status_code, Some(201));
|
||||
assert_eq!(call_create_usage.request_type.as_deref(), Some("live"));
|
||||
assert_eq!(call_create_usage.api_format.as_deref(), Some("codex:live"));
|
||||
assert_eq!(call_create_usage.model, CLIENT_MODEL);
|
||||
assert!(!call_create_usage.is_stream);
|
||||
assert!(!call_create_usage.is_websocket());
|
||||
assert_eq!(call_create_usage.websocket_transport(), None);
|
||||
assert!(!call_create_usage.usage_available());
|
||||
assert!(!call_create_usage.usage_pricing_available());
|
||||
assert_eq!(call_create_usage.total_tokens, 0);
|
||||
assert_eq!(call_create_usage.total_cost_usd, 0.0);
|
||||
assert_eq!(call_create_usage.actual_total_cost_usd, 0.0);
|
||||
assert!(call_create_usage.request_headers.is_none());
|
||||
assert!(call_create_usage.request_body.is_none());
|
||||
assert!(call_create_usage.provider_request_headers.is_none());
|
||||
assert!(call_create_usage.provider_request_body.is_none());
|
||||
let serialized_call_create_usage =
|
||||
serde_json::to_string(&call_create_usage).expect("Live call-create usage should serialize");
|
||||
for sentinel in [
|
||||
offer_sdp,
|
||||
"Keep this opaque",
|
||||
"sk-codex-live",
|
||||
"oauth-upstream-secret",
|
||||
] {
|
||||
assert!(!serialized_call_create_usage.contains(sentinel));
|
||||
}
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
@@ -1927,20 +1998,78 @@ struct ObservedCodexLiveWebSocket {
|
||||
request_target: String,
|
||||
authorization: Option<String>,
|
||||
alpha: Option<String>,
|
||||
originator: Option<String>,
|
||||
session_id: Option<String>,
|
||||
initial_event: serde_json::Value,
|
||||
event_after_turn_done: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CodexLiveDirectTestDialect {
|
||||
LegacyLive,
|
||||
RealtimeV1,
|
||||
RealtimeV2,
|
||||
}
|
||||
|
||||
impl CodexLiveDirectTestDialect {
|
||||
fn public_path(self) -> &'static str {
|
||||
match self {
|
||||
Self::LegacyLive => "/v1/live",
|
||||
Self::RealtimeV1 | Self::RealtimeV2 => "/v1/realtime",
|
||||
}
|
||||
}
|
||||
|
||||
fn query_prefix(self) -> &'static str {
|
||||
match self {
|
||||
Self::RealtimeV1 => "intent=quicksilver&",
|
||||
Self::LegacyLive | Self::RealtimeV2 => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_upstream_target(self, provider_model: &str) -> String {
|
||||
match self {
|
||||
Self::LegacyLive => format!("/v1/live?model={provider_model}"),
|
||||
Self::RealtimeV1 => {
|
||||
format!("/v1/realtime?intent=quicksilver&model={provider_model}")
|
||||
}
|
||||
Self::RealtimeV2 => format!("/v1/realtime?model={provider_model}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_alpha(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::LegacyLive => Some("quicksilver=v2"),
|
||||
Self::RealtimeV1 => Some("quicksilver=v1"),
|
||||
Self::RealtimeV2 => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_relays_codex_live_api_key_websocket_opaquely() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-live-api-key-websocket-frontdoor",
|
||||
run_codex_live_api_key_websocket_frontdoor_scenario(),
|
||||
run_codex_live_api_key_websocket_frontdoor_scenario(CodexLiveDirectTestDialect::LegacyLive),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_codex_live_api_key_websocket_frontdoor_scenario() {
|
||||
#[test]
|
||||
fn gateway_relays_codex_realtime_v2_api_key_websocket_opaquely() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-realtime-v2-api-key-websocket-frontdoor",
|
||||
run_codex_live_api_key_websocket_frontdoor_scenario(CodexLiveDirectTestDialect::RealtimeV2),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_relays_codex_realtime_v1_api_key_websocket_opaquely() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-realtime-v1-api-key-websocket-frontdoor",
|
||||
run_codex_live_api_key_websocket_frontdoor_scenario(CodexLiveDirectTestDialect::RealtimeV1),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_codex_live_api_key_websocket_frontdoor_scenario(dialect: CodexLiveDirectTestDialect) {
|
||||
const PROVIDER_ID: &str = "provider-codex-live-api-key";
|
||||
const ENDPOINT_ID: &str = "endpoint-provider-codex-live-api-key";
|
||||
const UPSTREAM_KEY_ID: &str = "key-provider-codex-live-api-key";
|
||||
@@ -1951,6 +2080,7 @@ async fn run_codex_live_api_key_websocket_frontdoor_scenario() {
|
||||
let upstream_state = Arc::new(Mutex::new(Some(observed_tx)));
|
||||
let upstream = Router::new()
|
||||
.route("/v1/live", get(mock_codex_live_websocket))
|
||||
.route("/v1/realtime", get(mock_codex_live_websocket))
|
||||
.with_state(upstream_state);
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
|
||||
@@ -2014,10 +2144,18 @@ async fn run_codex_live_api_key_websocket_frontdoor_scenario() {
|
||||
http::HeaderName::from_static("openai-alpha"),
|
||||
http::HeaderValue::from_static("client-value-must-be-replaced"),
|
||||
);
|
||||
if matches!(dialect, CodexLiveDirectTestDialect::RealtimeV2) {
|
||||
handshake_headers.insert(
|
||||
http::HeaderName::from_static("originator"),
|
||||
http::HeaderValue::from_static("codex_work_desktop"),
|
||||
);
|
||||
}
|
||||
let invalid_model_response = wreq::Client::new()
|
||||
.websocket(format!(
|
||||
"{}/v1/live?model={CLIENT_MODEL}&model=second-model",
|
||||
gateway_url.replacen("http://", "ws://", 1)
|
||||
"{}{}?{}model={CLIENT_MODEL}&model=second-model",
|
||||
gateway_url.replacen("http://", "ws://", 1),
|
||||
dialect.public_path(),
|
||||
dialect.query_prefix(),
|
||||
))
|
||||
.headers(handshake_headers.clone())
|
||||
.send()
|
||||
@@ -2026,8 +2164,10 @@ async fn run_codex_live_api_key_websocket_frontdoor_scenario() {
|
||||
assert_eq!(invalid_model_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let websocket_url = format!(
|
||||
"{}/v1/live?foo=bar&model={CLIENT_MODEL}&trace=1",
|
||||
gateway_url.replacen("http://", "ws://", 1)
|
||||
"{}{}?{}foo=bar&model={CLIENT_MODEL}&trace=1",
|
||||
gateway_url.replacen("http://", "ws://", 1),
|
||||
dialect.public_path(),
|
||||
dialect.query_prefix(),
|
||||
);
|
||||
let response = wreq::Client::new()
|
||||
.websocket(websocket_url)
|
||||
@@ -2091,13 +2231,17 @@ async fn run_codex_live_api_key_websocket_frontdoor_scenario() {
|
||||
.expect("mock upstream observation channel should remain open");
|
||||
assert_eq!(
|
||||
observed.request_target,
|
||||
format!("/v1/live?model={PROVIDER_MODEL}")
|
||||
dialect.expected_upstream_target(PROVIDER_MODEL)
|
||||
);
|
||||
assert_eq!(
|
||||
observed.authorization.as_deref(),
|
||||
Some("Bearer oauth-upstream-secret")
|
||||
);
|
||||
assert_eq!(observed.alpha.as_deref(), Some("quicksilver=v2"));
|
||||
assert_eq!(observed.alpha.as_deref(), dialect.expected_alpha());
|
||||
assert_eq!(
|
||||
observed.originator.as_deref(),
|
||||
matches!(dialect, CodexLiveDirectTestDialect::RealtimeV2).then_some("codex_work_desktop")
|
||||
);
|
||||
assert_eq!(observed.session_id.as_deref(), Some("stable-live-session"));
|
||||
let mut expected_initial_event = initial_event;
|
||||
expected_initial_event["session"]["model"] = json!(PROVIDER_MODEL);
|
||||
@@ -2119,15 +2263,63 @@ struct ObservedCodexLiveSideband {
|
||||
session_update: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CodexLiveWebRtcTestDialect {
|
||||
LegacyLive,
|
||||
Realtime,
|
||||
}
|
||||
|
||||
impl CodexLiveWebRtcTestDialect {
|
||||
fn call_create_path(self) -> &'static str {
|
||||
match self {
|
||||
Self::LegacyLive => "/v1/live",
|
||||
Self::Realtime => "/v1/realtime/calls",
|
||||
}
|
||||
}
|
||||
|
||||
fn call_location(self, call_id: &str) -> String {
|
||||
format!("{}/{call_id}", self.call_create_path())
|
||||
}
|
||||
|
||||
fn call_create_request_target(self) -> &'static str {
|
||||
match self {
|
||||
Self::LegacyLive => "/v1/live",
|
||||
Self::Realtime => "/v1/realtime/calls?intent=quicksilver&architecture=avas",
|
||||
}
|
||||
}
|
||||
|
||||
fn alpha_header(self) -> &'static str {
|
||||
match self {
|
||||
Self::LegacyLive => "quicksilver=v2",
|
||||
Self::Realtime => "quicksilver=v1",
|
||||
}
|
||||
}
|
||||
|
||||
fn sideband_path(self, call_id: &str) -> String {
|
||||
match self {
|
||||
Self::LegacyLive => format!("/v1/live/{call_id}"),
|
||||
Self::Realtime => format!("/v1/realtime?intent=quicksilver&call_id={call_id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_creates_and_relays_bound_codex_live_api_key_sideband() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-live-api-key-sideband-frontdoor",
|
||||
run_codex_live_api_key_sideband_frontdoor_scenario(),
|
||||
run_codex_live_api_key_sideband_frontdoor_scenario(CodexLiveWebRtcTestDialect::LegacyLive),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
#[test]
|
||||
fn gateway_creates_and_relays_bound_codex_live_api_key_realtime_calls_sideband() {
|
||||
super::run_frontdoor_async_test(
|
||||
"codex-live-api-key-realtime-calls-sideband-frontdoor",
|
||||
run_codex_live_api_key_sideband_frontdoor_scenario(CodexLiveWebRtcTestDialect::Realtime),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_codex_live_api_key_sideband_frontdoor_scenario(dialect: CodexLiveWebRtcTestDialect) {
|
||||
const PROVIDER_ID: &str = "provider-codex-live-sideband";
|
||||
const ENDPOINT_ID: &str = "endpoint-provider-codex-live-sideband";
|
||||
const UPSTREAM_KEY_ID: &str = "key-provider-codex-live-sideband";
|
||||
@@ -2142,12 +2334,13 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
"/v1/live/{call_id}",
|
||||
get(mock_codex_live_sideband_websocket),
|
||||
)
|
||||
.route("/v1/realtime", get(mock_codex_live_sideband_websocket))
|
||||
.with_state(upstream_state);
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
|
||||
let captured_plan = Arc::new(Mutex::new(None::<aether_contracts::ExecutionPlan>));
|
||||
let captured_plan_for_runtime = Arc::clone(&captured_plan);
|
||||
let upstream_location = format!("{upstream_url}/v1/live/{CALL_ID}");
|
||||
let upstream_location = format!("{upstream_url}{}", dialect.call_location(CALL_ID));
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |request: Request| {
|
||||
@@ -2237,9 +2430,11 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
let gateway = build_router_with_state(state);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let sideband_path = dialect.sideband_path(CALL_ID);
|
||||
let sideband_url = format!(
|
||||
"{}/v1/live/{CALL_ID}",
|
||||
gateway_url.replacen("http://", "ws://", 1)
|
||||
"{}{}",
|
||||
gateway_url.replacen("http://", "ws://", 1),
|
||||
sideband_path
|
||||
);
|
||||
let mut sideband_headers = HeaderMap::new();
|
||||
sideband_headers.insert(
|
||||
@@ -2273,7 +2468,10 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
session
|
||||
);
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/v1/live"))
|
||||
.post(format!(
|
||||
"{gateway_url}{}",
|
||||
dialect.call_create_request_target()
|
||||
))
|
||||
.header("authorization", "Bearer sk-codex-live-sideband")
|
||||
.header(
|
||||
"content-type",
|
||||
@@ -2291,7 +2489,7 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
.headers()
|
||||
.get(http::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(format!("/v1/live/{CALL_ID}").as_str())
|
||||
Some(dialect.call_location(CALL_ID).as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
@@ -2315,8 +2513,17 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
.clone()
|
||||
.expect("Live API-key call should reach execution runtime");
|
||||
let plan_url = url::Url::parse(plan.url.as_str()).expect("Live API-key URL should parse");
|
||||
assert_eq!(plan_url.path(), "/v1/live");
|
||||
assert!(plan_url.query().is_none());
|
||||
assert_eq!(plan_url.path(), dialect.call_create_path());
|
||||
match dialect {
|
||||
CodexLiveWebRtcTestDialect::LegacyLive => assert!(plan_url.query().is_none()),
|
||||
CodexLiveWebRtcTestDialect::Realtime => assert_eq!(
|
||||
plan_url.query_pairs().collect::<HashMap<_, _>>(),
|
||||
HashMap::from([
|
||||
("intent".into(), "quicksilver".into()),
|
||||
("architecture".into(), "avas".into()),
|
||||
])
|
||||
),
|
||||
}
|
||||
assert_eq!(plan.method, "POST");
|
||||
assert!(!plan.stream);
|
||||
assert!(plan
|
||||
@@ -2344,7 +2551,7 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("openai-alpha").map(String::as_str),
|
||||
Some("quicksilver=v2")
|
||||
Some(dialect.alpha_header())
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("x-session-id").map(String::as_str),
|
||||
@@ -2380,8 +2587,9 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
);
|
||||
let conflicting_response = wreq::Client::new()
|
||||
.websocket(format!(
|
||||
"{}/v1/live/{CALL_ID}",
|
||||
gateway_url.replacen("http://", "ws://", 1)
|
||||
"{}{}",
|
||||
gateway_url.replacen("http://", "ws://", 1),
|
||||
dialect.sideband_path(CALL_ID)
|
||||
))
|
||||
.headers(sideband_headers)
|
||||
.send()
|
||||
@@ -2414,12 +2622,12 @@ async fn run_codex_live_api_key_sideband_frontdoor_scenario() {
|
||||
.await
|
||||
.expect("mock sideband should observe the opaque command before timeout")
|
||||
.expect("mock sideband observation channel should remain open");
|
||||
assert_eq!(observed.request_target, format!("/v1/live/{CALL_ID}"));
|
||||
assert_eq!(observed.request_target, sideband_path);
|
||||
assert_eq!(
|
||||
observed.authorization.as_deref(),
|
||||
Some("Bearer oauth-upstream-secret")
|
||||
);
|
||||
assert_eq!(observed.alpha.as_deref(), Some("quicksilver=v2"));
|
||||
assert_eq!(observed.alpha.as_deref(), Some(dialect.alpha_header()));
|
||||
assert_eq!(
|
||||
observed.session_id.as_deref(),
|
||||
Some("stable-live-sideband-session")
|
||||
@@ -2501,6 +2709,10 @@ async fn mock_codex_live_websocket(
|
||||
.get("openai-alpha")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let originator = headers
|
||||
.get("originator")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let session_id = headers
|
||||
.get("x-session-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
@@ -2535,6 +2747,7 @@ async fn mock_codex_live_websocket(
|
||||
request_target,
|
||||
authorization,
|
||||
alpha,
|
||||
originator,
|
||||
session_id,
|
||||
initial_event,
|
||||
event_after_turn_done,
|
||||
|
||||
@@ -127,8 +127,9 @@ pub fn resolve_execution_runtime_stream_plan_kind_with_client_surface(
|
||||
|
||||
if route_family == Some("codex")
|
||||
&& route_kind == Some("live")
|
||||
&& ((*method == Method::GET && (path == "/v1/live" || path.starts_with("/v1/live/")))
|
||||
|| (*method == Method::POST && path == "/v1/live"))
|
||||
&& ((*method == Method::GET
|
||||
&& (path == "/v1/live" || path.starts_with("/v1/live/") || path == "/v1/realtime"))
|
||||
|| (*method == Method::POST && matches!(path, "/v1/live" | "/v1/realtime/calls")))
|
||||
{
|
||||
return Some(CODEX_LIVE_STREAM_PLAN_KIND);
|
||||
}
|
||||
@@ -471,14 +472,42 @@ pub fn sanitize_request_path_and_query(path: &str, query: Option<&str>) -> Optio
|
||||
|
||||
let sanitized_path = sanitize_request_path(path)?;
|
||||
let sanitized_query = query
|
||||
.and_then(sanitize_request_query_string)
|
||||
.or_else(|| embedded_query.and_then(sanitize_request_query_string));
|
||||
.and_then(|query| sanitize_request_query_string_for_path(path, query))
|
||||
.or_else(|| {
|
||||
embedded_query.and_then(|query| sanitize_request_query_string_for_path(path, query))
|
||||
});
|
||||
Some(match sanitized_query {
|
||||
Some(query) => format!("{sanitized_path}?{query}"),
|
||||
None => sanitized_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_request_query_string_for_path(path: &str, query: &str) -> Option<String> {
|
||||
if path != "/v1/realtime" {
|
||||
return sanitize_request_query_string(query);
|
||||
}
|
||||
|
||||
let query = query.trim().trim_start_matches('?').trim();
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut serializer = form_urlencoded::Serializer::new(String::new());
|
||||
let mut redacted_call_id = false;
|
||||
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
|
||||
if key.eq_ignore_ascii_case("call_id") {
|
||||
if !value.trim().is_empty() && !redacted_call_id {
|
||||
serializer.append_pair("call_id", "{call_id}");
|
||||
redacted_call_id = true;
|
||||
}
|
||||
} else if request_query_key_is_safe_to_trace(key.as_ref()) {
|
||||
serializer.append_pair(key.as_ref(), value.as_ref());
|
||||
}
|
||||
}
|
||||
let sanitized = serializer.finish();
|
||||
(!sanitized.is_empty()).then_some(sanitized)
|
||||
}
|
||||
|
||||
fn request_query_key_is_safe_to_trace(key: &str) -> bool {
|
||||
matches!(
|
||||
key.to_ascii_lowercase().as_str(),
|
||||
@@ -699,8 +728,10 @@ mod tests {
|
||||
fn resolves_codex_live_call_create_and_websocket_routes() {
|
||||
for (method, path) in [
|
||||
(Method::POST, "/v1/live"),
|
||||
(Method::POST, "/v1/realtime/calls"),
|
||||
(Method::GET, "/v1/live"),
|
||||
(Method::GET, "/v1/live/rtc_opaque"),
|
||||
(Method::GET, "/v1/realtime"),
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_execution_runtime_stream_plan_kind(
|
||||
@@ -973,6 +1004,22 @@ mod tests {
|
||||
.as_deref(),
|
||||
Some("/v1/realtime?model=gpt-realtime-2.1")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_request_path_and_query(
|
||||
"/v1/realtime?intent=quicksilver&call_id=rtc_secret_opaque&model=gpt-realtime-1.5&api_key=secret",
|
||||
None
|
||||
)
|
||||
.as_deref(),
|
||||
Some("/v1/realtime?call_id=%7Bcall_id%7D&model=gpt-realtime-1.5")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_request_path_and_query(
|
||||
"/v1/realtime?c%61ll_id=rtc_secret_encoded&call_id=rtc_duplicate",
|
||||
None
|
||||
)
|
||||
.as_deref(),
|
||||
Some("/v1/realtime?call_id=%7Bcall_id%7D")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -978,7 +978,7 @@ const PROVIDER_MODEL_MAPPING_API_FORMAT_MATCH_SQL: &str = r#"(
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'codex'
|
||||
AND LOWER($4) = 'codex:live'
|
||||
AND LOWER(BTRIM(fmt.value)) = 'openai:responses'
|
||||
AND LOWER(BTRIM(fmt.value)) IN ('openai:responses', '/v1/responses')
|
||||
)
|
||||
)"#;
|
||||
|
||||
@@ -1514,7 +1514,8 @@ mod tests {
|
||||
};
|
||||
use crate::{PostgresPoolConfig, PostgresPoolFactory};
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredPoolKeyCandidateOrder, StoredProviderModelMapping,
|
||||
provider_model_mapping_api_format_covers, StoredPoolKeyCandidateOrder,
|
||||
StoredProviderModelMapping,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1606,10 +1607,24 @@ mod tests {
|
||||
assert!(!sql.contains(PROVIDER_MODEL_MAPPING_API_FORMAT_MATCH_MARKER));
|
||||
assert!(compatibility.contains("LOWER(BTRIM(p.provider_type)) = 'codex'"));
|
||||
assert!(compatibility.contains("LOWER($4) = 'codex:live'"));
|
||||
assert!(compatibility.contains("LOWER(BTRIM(fmt.value)) = 'openai:responses'"));
|
||||
for legacy_responses_alias in ["openai:responses", "/v1/responses"] {
|
||||
assert!(provider_model_mapping_api_format_covers(
|
||||
"codex",
|
||||
legacy_responses_alias,
|
||||
"codex:live"
|
||||
));
|
||||
assert!(compatibility.contains(&format!("'{legacy_responses_alias}'")));
|
||||
}
|
||||
assert!(!LIST_FOR_EXACT_API_FORMAT_SQL.contains(compatibility));
|
||||
assert!(!LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL.contains(compatibility));
|
||||
assert!(!LIST_POOL_KEYS_FOR_GROUP_SQL.contains(compatibility));
|
||||
for permission_sql in [
|
||||
LIST_FOR_EXACT_API_FORMAT_SQL,
|
||||
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
|
||||
LIST_POOL_KEYS_FOR_GROUP_SQL,
|
||||
] {
|
||||
assert!(!permission_sql.contains("'/v1/responses'"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -254,6 +254,12 @@ mod tests {
|
||||
),
|
||||
"/v1/realtime?model=gpt-realtime-2.1"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_access_log_path(
|
||||
"/v1/realtime?intent=quicksilver&call_id=rtc_secret_opaque&model=gpt-realtime-1.5&api_key=secret"
|
||||
),
|
||||
"/v1/realtime?call_id=%7Bcall_id%7D&model=gpt-realtime-1.5"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
|
||||
@@ -214,6 +214,71 @@ mod tests {
|
||||
assert_eq!(candidates[0].selected_provider_model_name, "gpt-5-canary-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_live_enumeration_reuses_responses_mapping_without_widening_provider_scope() {
|
||||
let mut codex = sample_row("codex-live");
|
||||
codex.provider_name = "Codex".to_string();
|
||||
codex.provider_type = "codex".to_string();
|
||||
codex.endpoint_api_format = "codex:live".to_string();
|
||||
codex.endpoint_api_family = Some("codex".to_string());
|
||||
codex.endpoint_kind = Some("live".to_string());
|
||||
codex.key_auth_type = "oauth".to_string();
|
||||
codex.key_api_formats = Some(vec!["codex:live".to_string()]);
|
||||
codex.key_allowed_models = Some(vec!["gpt-future-live".to_string()]);
|
||||
codex.global_model_name = "live-future-alias".to_string();
|
||||
codex.model_provider_model_name = "gpt-future-live".to_string();
|
||||
codex.model_provider_model_mappings = Some(vec![StoredProviderModelMapping {
|
||||
name: "gpt-future-live".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec!["openai:responses".to_string()]),
|
||||
endpoint_ids: Some(vec![codex.endpoint_id.clone()]),
|
||||
operations: None,
|
||||
}]);
|
||||
|
||||
let constraints = SchedulerAuthConstraints {
|
||||
allowed_providers: Some(vec!["codex".to_string()]),
|
||||
allowed_api_formats: Some(vec!["codex:live".to_string()]),
|
||||
allowed_models: Some(vec!["live-future-alias".to_string()]),
|
||||
};
|
||||
let candidates =
|
||||
super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
|
||||
rows: vec![codex.clone()],
|
||||
normalized_api_format: "codex:live",
|
||||
request_operation: None,
|
||||
requested_model_name: "live-future-alias",
|
||||
resolved_global_model_name: "live-future-alias",
|
||||
require_streaming: true,
|
||||
required_capabilities: None,
|
||||
auth_constraints: Some(&constraints),
|
||||
})
|
||||
.expect("Codex Live candidate enumeration should build");
|
||||
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(
|
||||
candidates[0].selected_provider_model_name,
|
||||
"gpt-future-live"
|
||||
);
|
||||
|
||||
for provider_type in ["openai", "custom"] {
|
||||
let mut non_codex = codex.clone();
|
||||
non_codex.provider_type = provider_type.to_string();
|
||||
let candidates = super::enumerate_minimal_candidate_selection(
|
||||
EnumerateMinimalCandidateSelectionInput {
|
||||
rows: vec![non_codex],
|
||||
normalized_api_format: "codex:live",
|
||||
request_operation: None,
|
||||
requested_model_name: "live-future-alias",
|
||||
resolved_global_model_name: "live-future-alias",
|
||||
require_streaming: true,
|
||||
required_capabilities: None,
|
||||
auth_constraints: None,
|
||||
},
|
||||
)
|
||||
.expect("non-Codex Live candidate enumeration should build");
|
||||
assert!(candidates.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumeration_preserves_effective_streaming_capability() {
|
||||
let mut row = sample_row("1");
|
||||
|
||||
@@ -2,7 +2,8 @@ use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||
provider_model_mapping_api_format_covers, StoredMinimalCandidateSelectionRow,
|
||||
StoredProviderModelMapping,
|
||||
};
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use regex::RegexBuilder;
|
||||
@@ -339,9 +340,9 @@ fn mapping_scope_matches(
|
||||
request_operation: Option<&str>,
|
||||
) -> bool {
|
||||
let api_format_matches_scope = mapping.api_formats.as_ref().is_none_or(|api_formats| {
|
||||
api_formats
|
||||
.iter()
|
||||
.any(|value| api_format_scope_covers(value, api_format))
|
||||
api_formats.iter().any(|value| {
|
||||
provider_model_mapping_api_format_covers(&row.provider_type, value, api_format)
|
||||
})
|
||||
});
|
||||
if !api_format_matches_scope {
|
||||
return false;
|
||||
@@ -498,10 +499,6 @@ fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format(left) == normalize_api_format(right)
|
||||
}
|
||||
|
||||
fn api_format_scope_covers(allowed: &str, requested: &str) -> bool {
|
||||
aether_ai_formats::api_format_permission_covers(allowed, requested)
|
||||
}
|
||||
|
||||
fn requested_model_name_candidates(
|
||||
requested_model_name: &str,
|
||||
enable_model_directives: bool,
|
||||
@@ -690,6 +687,56 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_live_reuses_only_codex_responses_model_mappings() {
|
||||
let mut row = sample_row("live-client-alias", "gpt-realtime-future");
|
||||
row.provider_type = "codex".to_string();
|
||||
row.endpoint_id = "endpoint-live".to_string();
|
||||
row.endpoint_api_format = "codex:live".to_string();
|
||||
row.key_auth_type = "oauth".to_string();
|
||||
row.key_api_formats = Some(vec!["codex:live".to_string()]);
|
||||
row.model_provider_model_mappings = Some(vec![StoredProviderModelMapping {
|
||||
name: "gpt-realtime-future".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec!["openai:responses".to_string()]),
|
||||
endpoint_ids: Some(vec!["endpoint-live".to_string()]),
|
||||
operations: None,
|
||||
}]);
|
||||
|
||||
assert!(row_supports_requested_model(
|
||||
&row,
|
||||
"live-client-alias",
|
||||
"codex:live"
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_provider_model_name(&row, "live-client-alias", "codex:live")
|
||||
.map(|resolved| resolved.0),
|
||||
Some("gpt-realtime-future".to_string())
|
||||
);
|
||||
|
||||
let mut wrong_endpoint = row.clone();
|
||||
wrong_endpoint.endpoint_id = "endpoint-other".to_string();
|
||||
assert!(!row_supports_requested_model(
|
||||
&wrong_endpoint,
|
||||
"live-client-alias",
|
||||
"codex:live"
|
||||
));
|
||||
|
||||
for provider_type in ["openai", "custom"] {
|
||||
let mut non_codex = row.clone();
|
||||
non_codex.provider_type = provider_type.to_string();
|
||||
assert!(!row_supports_requested_model(
|
||||
&non_codex,
|
||||
"live-client-alias",
|
||||
"codex:live"
|
||||
));
|
||||
assert!(
|
||||
resolve_provider_model_name(&non_codex, "live-client-alias", "codex:live")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_scoped_mapping_overrides_generic_mapping_for_compaction() {
|
||||
let mut row = sample_row("gpt-5.6-sol", "gpt-5.6-sol");
|
||||
|
||||
+65
-6
@@ -1,13 +1,15 @@
|
||||
# WebSocket transports
|
||||
|
||||
Aether exposes three independent WebSocket surfaces. They share transport
|
||||
Aether exposes several independent WebSocket surfaces. They share transport
|
||||
machinery, but not request schemas or continuation state:
|
||||
|
||||
| Public route | API format | Protocol |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/responses` | `openai:responses` | Responses WebSocket mode; every turn starts with `response.create`. |
|
||||
| `GET /v1/realtime?model=...` | `openai:realtime` | OpenAI Realtime JSON events, including Base64 audio events. |
|
||||
| `GET /v1/live[/{call_id}]` | `codex:live` | Codex Frameless/Live direct and WebRTC-sideband transport. |
|
||||
| `GET /v1/realtime?model=...` with a first-party Codex `originator` | `codex:live` | Current Codex Realtime v2 direct WebSocket transport. |
|
||||
| `POST /v1/realtime/calls`, `GET /v1/realtime?intent=quicksilver&call_id=...` | `codex:live` | Codex Realtime v1 AVAS WebRTC call creation and sideband transport. |
|
||||
| `GET/POST /v1/live[/{call_id}]` | `codex:live` | Legacy Codex Frameless direct and WebRTC compatibility transport. |
|
||||
|
||||
Do not point one surface at an endpoint configured for another. In particular,
|
||||
a Realtime or Live event is not passed through the Responses
|
||||
@@ -62,13 +64,29 @@ Codex clients. It is related to the OpenAI Realtime API, but it is not the
|
||||
Responses WebSocket protocol and never enters Aether's `response.create`
|
||||
state machine:
|
||||
|
||||
- Direct WebSocket: `GET /v1/live?model=<global-model>`. The first client text
|
||||
- Current WebRTC call creation: `POST /v1/realtime/calls?intent=quicksilver&architecture=avas`
|
||||
with bounded `sdp` and `session` multipart parts. Aether normalizes those
|
||||
selectors, applies the global-to-provider mapping, and rewrites the upstream
|
||||
`Location` to `/v1/realtime/calls/<call-id>`.
|
||||
- Current WebRTC sideband: `GET /v1/realtime?intent=quicksilver&call_id=<call-id>`.
|
||||
The unique `intent=quicksilver` selector classifies it as Codex Live;
|
||||
`call_id` alone remains on the separate OpenAI Realtime surface.
|
||||
- Current direct WebSocket (Realtime v2):
|
||||
`GET /v1/realtime?model=<authorized-global-model>`. V2 intentionally omits
|
||||
`intent=quicksilver`; Aether uses the first-party Codex `originator` header
|
||||
to distinguish it from ordinary OpenAI Realtime. The first client event is
|
||||
still the opaque `session.update` frame generated by Codex; Aether forwards
|
||||
it without converting it into a Responses `response.create` event.
|
||||
- Realtime v1 direct WebSocket uses
|
||||
`GET /v1/realtime?intent=quicksilver&model=<authorized-global-model>` and
|
||||
sends `openai-alpha: quicksilver=v1` upstream. V2 does not send that header.
|
||||
- Legacy direct WebSocket: `GET /v1/live?model=<global-model>`. The first client text
|
||||
frame must be `session.update`; later text, binary, ping, pong, and close
|
||||
frames are relayed opaquely.
|
||||
- WebRTC call creation: `POST /v1/live` with bounded `sdp` and `session`
|
||||
- Legacy WebRTC call creation: `POST /v1/live` with bounded `sdp` and `session`
|
||||
multipart parts. Aether applies the existing global-to-provider model
|
||||
mapping and rewrites the upstream `Location` to `/v1/live/<call-id>`.
|
||||
- WebRTC sideband: `GET /v1/live/<call-id>`. Frameless sideband attaches to an
|
||||
- Legacy WebRTC sideband: `GET /v1/live/<call-id>`. Frameless sideband attaches to an
|
||||
already initialized call, so Aether neither waits for nor sends a second
|
||||
`session.update` frame.
|
||||
|
||||
@@ -78,6 +96,45 @@ custom providers can add it in the endpoint editor. The
|
||||
`responses_websocket.enabled` provider option belongs only to
|
||||
`openai:responses` WebSocket mode and is not reused as the Live permission.
|
||||
|
||||
Codex Live also requires an authorized model mapping; adding the endpoint alone
|
||||
is not enough. For WebRTC, Codex sends the selected model in the multipart
|
||||
`session.model`. Aether treats that value as the downstream global model,
|
||||
selects an existing mapping whose provider endpoint is `codex:live`, and
|
||||
rewrites only `session.model` to the mapped upstream model. Configure Codex's
|
||||
realtime model selection to an authorized global alias (the current Codex
|
||||
default may be `gpt-realtime-1.5`, but that value is client-version dependent),
|
||||
or create an authorized mapping for the alias the client already sends. Aether
|
||||
does not invent a Live model name or add a bundled hard-coded model merely
|
||||
because the endpoint is enabled. For Codex providers only, an existing model mapping scoped to
|
||||
`openai:responses` (including the historical `/v1/responses` alias) can be
|
||||
reused for Live. The provider endpoint and key must still explicitly allow
|
||||
`codex:live`; OpenAI and custom providers do not receive this compatibility
|
||||
rule.
|
||||
|
||||
For Codex Desktop/app-server, point both the call-creation and sideband
|
||||
overrides at the same Aether origin when using a custom provider. Explicitly
|
||||
setting both avoids a client-version-dependent fallback to the OpenAI origin:
|
||||
|
||||
```toml
|
||||
model_provider = "aether"
|
||||
experimental_realtime_webrtc_call_base_url = "https://<aether-host>/v1"
|
||||
experimental_realtime_ws_base_url = "https://<aether-host>/v1"
|
||||
experimental_realtime_ws_model = "<authorized-global-live-alias>"
|
||||
|
||||
[model_providers.aether]
|
||||
name = "Aether"
|
||||
base_url = "https://<aether-host>/v1"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
The two experimental overrides are optional when the selected provider's
|
||||
`base_url` already points at Aether, but if either is set they must resolve to
|
||||
the same deployment so the authenticated call binding can be found. A missing
|
||||
call-create request in Aether means the client did not select this provider or
|
||||
failed before gateway routing; a call-create request without the matching
|
||||
sideband usually means the sideband origin or credential differs. These
|
||||
settings may change with newer Codex releases.
|
||||
|
||||
API-key and bearer providers can use direct WebSocket or WebRTC. ChatGPT OAuth
|
||||
uses the official Codex backend for WebRTC call creation and the OpenAI Live
|
||||
origin for its sideband; direct OAuth WebSocket and custom OAuth backend
|
||||
@@ -109,7 +166,9 @@ created call that never attaches a sideband is not held against provider
|
||||
concurrency after call creation.
|
||||
|
||||
For the public GA Realtime API's connection and session concepts, see the
|
||||
[OpenAI Realtime guide](https://developers.openai.com/api/docs/guides/realtime).
|
||||
[OpenAI Realtime guide](https://developers.openai.com/api/docs/guides/realtime),
|
||||
[WebRTC connection guide](https://developers.openai.com/api/docs/guides/realtime-webrtc),
|
||||
and [server-side controls guide](https://developers.openai.com/api/docs/guides/realtime-server-controls).
|
||||
|
||||
OpenAI's current WebSocket service supports named `stream_id` lanes: requests on
|
||||
the same lane are FIFO, while different lanes may run concurrently. Aether's
|
||||
|
||||
Reference in New Issue
Block a user