mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 03:39:49 +08:00
feat(gateway): add Codex Live and OpenAI Realtime
Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
This commit is contained in:
@@ -41,6 +41,8 @@ pub fn apply_codex_oauth_fingerprint_convergence(
|
||||
provider_request_headers: &mut BTreeMap<String, String>,
|
||||
provider_request_body: &mut Value,
|
||||
) -> bool {
|
||||
let is_responses = aether_ai_formats::is_openai_responses_format(provider_api_format);
|
||||
let is_live = aether_ai_formats::api_format_alias_matches(provider_api_format, "codex:live");
|
||||
if !transport
|
||||
.provider
|
||||
.provider_type
|
||||
@@ -48,11 +50,12 @@ pub fn apply_codex_oauth_fingerprint_convergence(
|
||||
.eq_ignore_ascii_case("codex")
|
||||
|| !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
||||
|| crate::agent_identity::is_codex_agent_identity_transport(transport)
|
||||
|| !aether_ai_formats::is_openai_responses_format(provider_api_format)
|
||||
|| aether_ai_formats::openai_responses_request_operation(
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
) == Some(aether_ai_formats::OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
|| (!is_responses && !is_live)
|
||||
|| is_responses
|
||||
&& aether_ai_formats::openai_responses_request_operation(
|
||||
provider_api_format,
|
||||
provider_request_body,
|
||||
) == Some(aether_ai_formats::OPENAI_RESPONSES_OPERATION_COMPACT)
|
||||
|| !codex_fingerprint_convergence_enabled(
|
||||
transport.provider.provider_type.as_str(),
|
||||
transport.provider.config.as_ref(),
|
||||
@@ -74,7 +77,12 @@ pub fn apply_codex_oauth_fingerprint_convergence(
|
||||
let fingerprint = resolve_converged_fingerprint(account_seed, original_client_session_id);
|
||||
|
||||
apply_converged_headers(provider_request_headers, &fingerprint);
|
||||
apply_converged_client_metadata(provider_request_body, &fingerprint);
|
||||
// Live uses the converged identity on the WebSocket/call-control headers.
|
||||
// Its event/session payload is an independent opaque protocol and must not
|
||||
// receive Responses-only `client_metadata` fields.
|
||||
if is_responses {
|
||||
apply_converged_client_metadata(provider_request_body, &fingerprint);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -477,6 +485,27 @@ mod tests {
|
||||
assert_ne!(first.installation_id, other_account.installation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_convergence_sets_the_websocket_identity_without_mutating_the_payload() {
|
||||
let transport = sample_transport();
|
||||
let original_body = json!({"model": "gpt-live", "future_live_field": true});
|
||||
let mut body = original_body.clone();
|
||||
let mut headers = BTreeMap::new();
|
||||
|
||||
assert!(apply_codex_oauth_fingerprint_convergence(
|
||||
&transport,
|
||||
"codex:live",
|
||||
Some("client-live-session"),
|
||||
&mut headers,
|
||||
&mut body,
|
||||
));
|
||||
|
||||
assert_eq!(body, original_body);
|
||||
assert_eq!(headers.get("x-session-id"), headers.get("thread-id"));
|
||||
assert!(headers.contains_key("x-codex-installation-id"));
|
||||
assert!(headers.contains_key("x-codex-window-id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_or_out_of_scope_requests_are_unchanged() {
|
||||
let mut transport = sample_transport();
|
||||
|
||||
@@ -291,7 +291,7 @@ const CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProvider
|
||||
|
||||
const CODEX_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
|
||||
provider_type: "codex",
|
||||
version: 1,
|
||||
version: 2,
|
||||
base_url: "https://chatgpt.com/backend-api/codex",
|
||||
endpoints: &[
|
||||
FixedProviderEndpointTemplate {
|
||||
@@ -318,6 +318,12 @@ const CODEX_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTempla
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
FixedProviderEndpointTemplate {
|
||||
item_key: "codex:live",
|
||||
api_format: "codex:live",
|
||||
custom_path: None,
|
||||
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
|
||||
},
|
||||
],
|
||||
runtime_policy: CODEX_RUNTIME_POLICY,
|
||||
};
|
||||
@@ -656,7 +662,7 @@ mod tests {
|
||||
fn codex_fixed_provider_template_includes_codex_companion_endpoints() {
|
||||
let template = fixed_provider_template("codex").expect("codex template should exist");
|
||||
assert_eq!(template.base_url, "https://chatgpt.com/backend-api/codex");
|
||||
assert_eq!(template.version, 1);
|
||||
assert_eq!(template.version, 2);
|
||||
assert_eq!(
|
||||
template
|
||||
.endpoints
|
||||
@@ -667,7 +673,8 @@ mod tests {
|
||||
"openai:responses",
|
||||
"openai:responses:compact",
|
||||
"openai:search",
|
||||
"openai:image"
|
||||
"openai:image",
|
||||
"codex:live"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -680,6 +687,12 @@ mod tests {
|
||||
fixed_provider_endpoint_template_by_api_format("codex", "openai:search")
|
||||
.expect("codex search endpoint should exist");
|
||||
assert!(search_template.config_defaults.is_empty());
|
||||
|
||||
let live_template = fixed_provider_endpoint_template_by_api_format("codex", "codex:live")
|
||||
.expect("codex Live endpoint should exist");
|
||||
assert_eq!(live_template.item_key, "codex:live");
|
||||
assert_eq!(live_template.custom_path, None);
|
||||
assert!(live_template.config_defaults.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::OnceLock;
|
||||
use aether_ai_formats::ApiOperation;
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use url::form_urlencoded;
|
||||
use url::{form_urlencoded, Url};
|
||||
|
||||
use crate::antigravity::{
|
||||
build_antigravity_v1internal_url, is_antigravity_provider_transport,
|
||||
@@ -128,7 +128,10 @@ fn build_transport_request_url_inner(
|
||||
.ends_with("/messages/count_tokens");
|
||||
let blocked_keys = if normalized_provider_api_format.starts_with("gemini:")
|
||||
|| normalized_provider_api_format == "claude:messages"
|
||||
{
|
||||
|| matches!(
|
||||
normalized_provider_api_format.as_str(),
|
||||
"openai:realtime" | "codex:live"
|
||||
) {
|
||||
GATEWAY_CREDENTIAL_QUERY_KEYS
|
||||
} else {
|
||||
&[][..]
|
||||
@@ -153,6 +156,11 @@ fn build_transport_request_url_inner(
|
||||
{
|
||||
url = build_claude_messages_url(&url, None);
|
||||
}
|
||||
let url = if normalized_provider_api_format == "openai:realtime" {
|
||||
replace_realtime_model_query(url, params.mapped_model?)?
|
||||
} else {
|
||||
url
|
||||
};
|
||||
return Some(maybe_add_gemini_stream_alt_sse(
|
||||
url,
|
||||
&provider_api_format,
|
||||
@@ -179,6 +187,19 @@ fn build_transport_request_url_inner(
|
||||
&transport.endpoint.base_url,
|
||||
params.request_query,
|
||||
)),
|
||||
"openai:realtime" => build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
"/v1/realtime",
|
||||
params.request_query,
|
||||
GATEWAY_CREDENTIAL_QUERY_KEYS,
|
||||
)
|
||||
.and_then(|url| replace_realtime_model_query(url, params.mapped_model?)),
|
||||
"codex:live" => build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
"/live",
|
||||
params.request_query,
|
||||
GATEWAY_CREDENTIAL_QUERY_KEYS,
|
||||
),
|
||||
"openai:embedding" | "jina:embedding" => {
|
||||
build_provider_embedding_v1_url(&transport.endpoint.base_url, params.request_query)
|
||||
}
|
||||
@@ -228,6 +249,24 @@ fn build_transport_request_url_inner(
|
||||
))
|
||||
}
|
||||
|
||||
fn replace_realtime_model_query(raw_url: String, mapped_model: &str) -> Option<String> {
|
||||
let mut url = Url::parse(raw_url.as_str()).ok()?;
|
||||
let retained = url
|
||||
.query_pairs()
|
||||
.filter(|(name, _)| !name.eq_ignore_ascii_case("model"))
|
||||
.map(|(name, value)| (name.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
url.set_query(None);
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
for (name, value) in retained {
|
||||
query.append_pair(name.as_str(), value.as_str());
|
||||
}
|
||||
query.append_pair("model", mapped_model);
|
||||
}
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
pub fn build_local_openai_chat_upstream_url(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
request_query: Option<&str>,
|
||||
@@ -786,6 +825,118 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_url_uses_mapped_model_and_drops_downstream_credentials() {
|
||||
let transport = sample_transport(
|
||||
"custom",
|
||||
"openai:realtime",
|
||||
"https://api.openai.com/v1",
|
||||
None,
|
||||
);
|
||||
|
||||
let url = build_transport_request_url(
|
||||
&transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "openai:realtime",
|
||||
mapped_model: Some("gpt-realtime-provider"),
|
||||
upstream_is_stream: true,
|
||||
request_query: Some("model=client-alias&trace=1&key=downstream-secret"),
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("realtime URL");
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://api.openai.com/v1/realtime?trace=1&model=gpt-realtime-provider"
|
||||
);
|
||||
assert!(!url.contains("downstream-secret"));
|
||||
assert!(!url.contains("client-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_custom_path_still_rewrites_the_model_query() {
|
||||
let transport = sample_transport(
|
||||
"custom",
|
||||
"openai:realtime",
|
||||
"https://voice.example.test/api",
|
||||
Some("/socket/realtime?deployment=future"),
|
||||
);
|
||||
|
||||
let url = build_transport_request_url(
|
||||
&transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "openai:realtime",
|
||||
mapped_model: Some("provider-model"),
|
||||
upstream_is_stream: true,
|
||||
request_query: Some("model=client-model&tenant=alpha"),
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("custom realtime URL");
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://voice.example.test/api/socket/realtime?deployment=future&tenant=alpha&model=provider-model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_live_url_uses_the_dedicated_endpoint_without_forwarding_credentials() {
|
||||
let transport = sample_transport(
|
||||
"custom",
|
||||
"codex:live",
|
||||
"https://voice.example.test/v1",
|
||||
None,
|
||||
);
|
||||
|
||||
let url = build_transport_request_url(
|
||||
&transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "codex:live",
|
||||
mapped_model: Some("provider-model"),
|
||||
upstream_is_stream: true,
|
||||
request_query: Some("trace=1&key=downstream-secret"),
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("Codex Live URL");
|
||||
|
||||
assert_eq!(url, "https://voice.example.test/v1/live?trace=1");
|
||||
assert!(!url.contains("downstream-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_live_custom_path_remains_an_explicit_protocol_endpoint() {
|
||||
let transport = sample_transport(
|
||||
"custom",
|
||||
"codex:live",
|
||||
"https://voice.example.test/v1",
|
||||
Some("/socket/live?deployment=future"),
|
||||
);
|
||||
|
||||
let url = build_transport_request_url(
|
||||
&transport,
|
||||
TransportRequestUrlParams {
|
||||
provider_api_format: "codex:live",
|
||||
mapped_model: Some("provider-model"),
|
||||
upstream_is_stream: true,
|
||||
request_query: Some("tenant=alpha&key=downstream-secret"),
|
||||
kiro_api_region: None,
|
||||
api_operation: None,
|
||||
},
|
||||
)
|
||||
.expect("custom Codex Live URL");
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://voice.example.test/v1/socket/live?deployment=future&tenant=alpha"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_vertex_hook_before_custom_path_for_custom_aiplatform_transport() {
|
||||
let transport = sample_transport(
|
||||
|
||||
@@ -835,6 +835,8 @@ fn resolve_same_format_standard_direct_auth(
|
||||
) -> Option<(String, String)> {
|
||||
if aether_ai_formats::api_format_alias_matches(provider_api_format, "openai:embedding")
|
||||
|| aether_ai_formats::api_format_alias_matches(provider_api_format, "openai:search")
|
||||
|| aether_ai_formats::api_format_alias_matches(provider_api_format, "openai:realtime")
|
||||
|| aether_ai_formats::api_format_alias_matches(provider_api_format, "codex:live")
|
||||
{
|
||||
resolve_local_openai_bearer_auth(transport)
|
||||
} else {
|
||||
@@ -1431,6 +1433,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_realtime_direct_auth_with_bearer_header() {
|
||||
let mut transport = sample_transport("openai");
|
||||
transport.endpoint.api_format = "openai:realtime".to_string();
|
||||
transport.key.auth_type = "api_key".to_string();
|
||||
let behavior = classify_same_format_provider_request_behavior(
|
||||
&transport,
|
||||
SameFormatProviderRequestBehaviorParams {
|
||||
require_streaming: true,
|
||||
provider_api_format: "openai:realtime",
|
||||
report_kind: "openai_realtime_websocket_success",
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_same_format_provider_direct_auth(
|
||||
&behavior,
|
||||
&transport,
|
||||
SameFormatProviderFamily::Standard,
|
||||
"openai:realtime",
|
||||
),
|
||||
Some(("authorization".to_string(), "Bearer secret".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_codex_live_direct_auth_with_bearer_header() {
|
||||
let mut transport = sample_transport("openai");
|
||||
transport.endpoint.api_format = "codex:live".to_string();
|
||||
transport.key.auth_type = "api_key".to_string();
|
||||
let behavior = classify_same_format_provider_request_behavior(
|
||||
&transport,
|
||||
SameFormatProviderRequestBehaviorParams {
|
||||
require_streaming: true,
|
||||
provider_api_format: "codex:live",
|
||||
report_kind: "codex_live_websocket_success",
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_same_format_provider_direct_auth(
|
||||
&behavior,
|
||||
&transport,
|
||||
SameFormatProviderFamily::Standard,
|
||||
"codex:live",
|
||||
),
|
||||
Some(("authorization".to_string(), "Bearer secret".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_claude_same_format_api_key_on_x_api_key_header() {
|
||||
let mut transport = sample_transport("custom");
|
||||
|
||||
Reference in New Issue
Block a user