mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix(gateway): support current Codex Realtime live routes
This commit is contained in:
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 {
|
||||
|
||||
Reference in New Issue
Block a user