mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 22:50:19 +08:00
feat(gateway): Responses WebSocket 连通性探针
新增 aether-codex-ws-probe 与 aether-openai-responses-ws-probe 两个 二进制,用于在不暴露凭据的前提下验证上游 WebSocket 端点可用性:凭据 只从环境变量读取,不写入日志。公共流程放在 bin/support/responses_ws_probe.rs,各 profile 只负责自己的鉴权与 请求头要求。
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
//! Credential-safe compatibility probe for the Codex Responses WebSocket path.
|
||||
//!
|
||||
//! This binary preserves the established Codex CLI and environment contract.
|
||||
//! The common Responses WebSocket flow lives in `support/responses_ws_probe`;
|
||||
//! this profile owns only Codex authentication and header requirements.
|
||||
|
||||
#[path = "support/responses_ws_probe.rs"]
|
||||
mod responses_ws_probe;
|
||||
|
||||
use aether_gateway::{CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT};
|
||||
use clap::Parser;
|
||||
use http::header::{AUTHORIZATION, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use responses_ws_probe::{
|
||||
bearer_authorization_value, required_env, resolve_probe_url, run_profile_probe, turn_timeout,
|
||||
ProbeArgs, ProbeConfig, ProbeFailure, ResponsesWebSocketProbeProfile,
|
||||
};
|
||||
|
||||
const ACCESS_TOKEN_ENV: &str = "AETHER_CODEX_WS_PROBE_ACCESS_TOKEN";
|
||||
const ACCOUNT_ID_ENV: &str = "AETHER_CODEX_WS_PROBE_ACCOUNT_ID";
|
||||
const MODEL_ENV: &str = "AETHER_CODEX_WS_PROBE_MODEL";
|
||||
const URL_ENV: &str = "AETHER_CODEX_WS_PROBE_URL";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "aether-codex-ws-probe",
|
||||
about = "Verify a Codex Responses WebSocket endpoint without exposing credentials"
|
||||
)]
|
||||
struct Args {
|
||||
/// WebSocket endpoint. If omitted, AETHER_CODEX_WS_PROBE_URL is used.
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
|
||||
/// Per-turn receive timeout in seconds.
|
||||
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..=120))]
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl From<Args> for ProbeArgs {
|
||||
fn from(args: Args) -> Self {
|
||||
Self {
|
||||
url: args.url,
|
||||
timeout_secs: args.timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexResponsesProbeProfile;
|
||||
|
||||
impl ResponsesWebSocketProbeProfile for CodexResponsesProbeProfile {
|
||||
fn build_config(args: &ProbeArgs) -> Result<ProbeConfig, ProbeFailure> {
|
||||
let url = resolve_probe_url(args, URL_ENV, None)?;
|
||||
let access_token = required_env(ACCESS_TOKEN_ENV)?;
|
||||
let account_id = required_env(ACCOUNT_ID_ENV)?;
|
||||
let model = required_env(MODEL_ENV)?;
|
||||
Ok(ProbeConfig::new(
|
||||
url,
|
||||
model,
|
||||
turn_timeout(args),
|
||||
handshake_headers(&access_token, &account_id)?,
|
||||
Self::sent_header_names(),
|
||||
))
|
||||
}
|
||||
|
||||
fn sent_header_names() -> Vec<&'static str> {
|
||||
vec![
|
||||
"authorization",
|
||||
"chatgpt-account-id",
|
||||
"user-agent",
|
||||
"originator",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn handshake_headers(access_token: &str, account_id: &str) -> Result<HeaderMap, ProbeFailure> {
|
||||
let account_id =
|
||||
HeaderValue::from_str(account_id).map_err(|_| ProbeFailure::MissingConfiguration)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, bearer_authorization_value(access_token)?);
|
||||
headers.insert(HeaderName::from_static("chatgpt-account-id"), account_id);
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(CODEX_CLIENT_USER_AGENT),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static(CODEX_CLIENT_ORIGINATOR),
|
||||
);
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let exit_code = run_profile_probe::<CodexResponsesProbeProfile>(Args::parse().into()).await;
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::header::{AUTHORIZATION, USER_AGENT};
|
||||
|
||||
use super::{handshake_headers, CodexResponsesProbeProfile, ResponsesWebSocketProbeProfile};
|
||||
|
||||
#[test]
|
||||
fn codex_profile_keeps_its_required_handshake_headers() {
|
||||
let headers =
|
||||
handshake_headers("test-token", "test-account").expect("headers should build");
|
||||
assert!(headers.contains_key(AUTHORIZATION));
|
||||
assert!(headers.contains_key("chatgpt-account-id"));
|
||||
assert!(headers.contains_key(USER_AGENT));
|
||||
assert!(headers.contains_key("originator"));
|
||||
assert_eq!(
|
||||
CodexResponsesProbeProfile::sent_header_names(),
|
||||
vec![
|
||||
"authorization",
|
||||
"chatgpt-account-id",
|
||||
"user-agent",
|
||||
"originator",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Credential-safe compatibility probe for the official OpenAI Responses
|
||||
//! WebSocket endpoint.
|
||||
//!
|
||||
//! This profile uses standard API-key Bearer authentication and shares the
|
||||
//! protocol flow with the Codex probe without inheriting Codex-specific
|
||||
//! account headers or quota assumptions.
|
||||
|
||||
#[path = "support/responses_ws_probe.rs"]
|
||||
mod responses_ws_probe;
|
||||
|
||||
use clap::Parser;
|
||||
use http::header::AUTHORIZATION;
|
||||
use http::HeaderMap;
|
||||
use responses_ws_probe::{
|
||||
bearer_authorization_value, required_env, resolve_probe_url, run_profile_probe, turn_timeout,
|
||||
ProbeArgs, ProbeConfig, ProbeFailure, ResponsesWebSocketProbeProfile,
|
||||
};
|
||||
|
||||
const API_KEY_ENV: &str = "AETHER_OPENAI_WS_PROBE_API_KEY";
|
||||
const MODEL_ENV: &str = "AETHER_OPENAI_WS_PROBE_MODEL";
|
||||
const URL_ENV: &str = "AETHER_OPENAI_WS_PROBE_URL";
|
||||
const DEFAULT_URL: &str = "wss://api.openai.com/v1/responses";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "aether-openai-responses-ws-probe",
|
||||
about = "Verify an OpenAI Responses WebSocket endpoint without exposing credentials"
|
||||
)]
|
||||
struct Args {
|
||||
/// WebSocket endpoint. If omitted, AETHER_OPENAI_WS_PROBE_URL or the
|
||||
/// official OpenAI endpoint is used.
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
|
||||
/// Per-turn receive timeout in seconds.
|
||||
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u64).range(1..=120))]
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl From<Args> for ProbeArgs {
|
||||
fn from(args: Args) -> Self {
|
||||
Self {
|
||||
url: args.url,
|
||||
timeout_secs: args.timeout_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAiResponsesProbeProfile;
|
||||
|
||||
impl ResponsesWebSocketProbeProfile for OpenAiResponsesProbeProfile {
|
||||
fn build_config(args: &ProbeArgs) -> Result<ProbeConfig, ProbeFailure> {
|
||||
let url = resolve_probe_url(args, URL_ENV, Some(DEFAULT_URL))?;
|
||||
let api_key = required_env(API_KEY_ENV)?;
|
||||
let model = required_env(MODEL_ENV)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, bearer_authorization_value(&api_key)?);
|
||||
Ok(ProbeConfig::new(
|
||||
url,
|
||||
model,
|
||||
turn_timeout(args),
|
||||
headers,
|
||||
Self::sent_header_names(),
|
||||
))
|
||||
}
|
||||
|
||||
fn sent_header_names() -> Vec<&'static str> {
|
||||
vec!["authorization"]
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let exit_code = run_profile_probe::<OpenAiResponsesProbeProfile>(Args::parse().into()).await;
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use http::header::AUTHORIZATION;
|
||||
|
||||
use super::{
|
||||
bearer_authorization_value, responses_ws_probe::parse_probe_url,
|
||||
OpenAiResponsesProbeProfile, ResponsesWebSocketProbeProfile, DEFAULT_URL,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn openai_profile_exposes_only_standard_bearer_authentication() {
|
||||
let authorization = bearer_authorization_value("test-key").expect("header should build");
|
||||
assert_eq!(authorization.to_str().ok(), Some("Bearer test-key"));
|
||||
assert_eq!(
|
||||
OpenAiResponsesProbeProfile::sent_header_names(),
|
||||
vec![AUTHORIZATION.as_str()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_profile_uses_the_official_responses_websocket_endpoint_by_default() {
|
||||
let url = parse_probe_url(DEFAULT_URL).expect("default OpenAI endpoint should be valid");
|
||||
assert_eq!(url.as_str(), DEFAULT_URL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Shared, credential-safe engine for Responses WebSocket compatibility probes.
|
||||
//!
|
||||
//! Provider profiles own their environment variables and handshake headers.
|
||||
//! This module owns the common Responses WebSocket contract: two sequential
|
||||
//! `response.create` warmups, continuation with `previous_response_id`, safe
|
||||
//! event observation, and a redacted JSON report.
|
||||
|
||||
use std::env;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use url::Url;
|
||||
use wreq::ws::message::Message as WreqWsMessage;
|
||||
|
||||
const MAX_FRAME_SIZE: usize = 1 << 20;
|
||||
const MAX_EVENTS_PER_TURN: usize = 16;
|
||||
|
||||
pub(crate) struct ProbeArgs {
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) timeout_secs: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct ProbeConfig {
|
||||
url: Url,
|
||||
model: String,
|
||||
turn_timeout: Duration,
|
||||
handshake_headers: HeaderMap,
|
||||
sent_header_names: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl ProbeConfig {
|
||||
pub(crate) fn new(
|
||||
url: Url,
|
||||
model: String,
|
||||
turn_timeout: Duration,
|
||||
handshake_headers: HeaderMap,
|
||||
sent_header_names: Vec<&'static str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
url,
|
||||
model,
|
||||
turn_timeout,
|
||||
handshake_headers,
|
||||
sent_header_names,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile retains provider-specific authentication and configuration while
|
||||
/// reusing one Responses protocol probe engine.
|
||||
pub(crate) trait ResponsesWebSocketProbeProfile {
|
||||
fn build_config(args: &ProbeArgs) -> Result<ProbeConfig, ProbeFailure>;
|
||||
fn sent_header_names() -> Vec<&'static str>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum ProbeFailure {
|
||||
MissingConfiguration,
|
||||
InvalidEndpoint,
|
||||
ClientBuild,
|
||||
Handshake,
|
||||
Upgrade,
|
||||
Send,
|
||||
ReceiveTimeout,
|
||||
Receive,
|
||||
RemoteError,
|
||||
MissingResponseId,
|
||||
UnexpectedFrame,
|
||||
}
|
||||
|
||||
impl ProbeFailure {
|
||||
const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingConfiguration => "missing_configuration",
|
||||
Self::InvalidEndpoint => "invalid_endpoint",
|
||||
Self::ClientBuild => "client_build_failed",
|
||||
Self::Handshake => "handshake_failed",
|
||||
Self::Upgrade => "upgrade_failed",
|
||||
Self::Send => "send_failed",
|
||||
Self::ReceiveTimeout => "receive_timeout",
|
||||
Self::Receive => "receive_failed",
|
||||
Self::RemoteError => "upstream_error_event",
|
||||
Self::MissingResponseId => "response_id_not_observed",
|
||||
Self::UnexpectedFrame => "unexpected_frame",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProbeReport {
|
||||
status: &'static str,
|
||||
target_host: Option<String>,
|
||||
handshake_status: Option<u16>,
|
||||
sent_header_names: Vec<&'static str>,
|
||||
received_header_names: Vec<String>,
|
||||
observed_event_types: Vec<String>,
|
||||
continuation_confirmed: bool,
|
||||
elapsed_ms: u64,
|
||||
error: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ProbeReport {
|
||||
fn failed(
|
||||
config: Option<&ProbeConfig>,
|
||||
sent_header_names: Vec<&'static str>,
|
||||
started_at: Instant,
|
||||
error: ProbeFailure,
|
||||
) -> Self {
|
||||
Self {
|
||||
status: "failed",
|
||||
target_host: config.and_then(target_host),
|
||||
handshake_status: None,
|
||||
sent_header_names,
|
||||
received_header_names: Vec::new(),
|
||||
observed_event_types: Vec::new(),
|
||||
continuation_confirmed: false,
|
||||
elapsed_ms: started_at.elapsed().as_millis() as u64,
|
||||
error: Some(error.code()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a profile and returns the process exit code after emitting exactly one
|
||||
/// credential-safe JSON report.
|
||||
pub(crate) async fn run_profile_probe<P: ResponsesWebSocketProbeProfile>(args: ProbeArgs) -> i32 {
|
||||
let started_at = Instant::now();
|
||||
let config = match P::build_config(&args) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
print_report(&ProbeReport::failed(
|
||||
None,
|
||||
P::sent_header_names(),
|
||||
started_at,
|
||||
error,
|
||||
));
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
match run_probe(&config, started_at).await {
|
||||
Ok(report) => {
|
||||
print_report(&report);
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
print_report(&ProbeReport::failed(
|
||||
Some(&config),
|
||||
config.sent_header_names.clone(),
|
||||
started_at,
|
||||
error,
|
||||
));
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn required_env(name: &str) -> Result<String, ProbeFailure> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(ProbeFailure::MissingConfiguration)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_probe_url(
|
||||
args: &ProbeArgs,
|
||||
url_env: &str,
|
||||
default_url: Option<&str>,
|
||||
) -> Result<Url, ProbeFailure> {
|
||||
let raw_url = args
|
||||
.url
|
||||
.as_deref()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| env::var(url_env).ok())
|
||||
.or_else(|| default_url.map(str::to_owned));
|
||||
let Some(raw_url) = raw_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Err(ProbeFailure::MissingConfiguration);
|
||||
};
|
||||
parse_probe_url(raw_url)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_probe_url(raw: &str) -> Result<Url, ProbeFailure> {
|
||||
let url = Url::parse(raw).map_err(|_| ProbeFailure::InvalidEndpoint)?;
|
||||
if !matches!(url.scheme(), "ws" | "wss")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(ProbeFailure::InvalidEndpoint);
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub(crate) fn bearer_authorization_value(token: &str) -> Result<HeaderValue, ProbeFailure> {
|
||||
HeaderValue::from_str(format!("Bearer {token}").as_str())
|
||||
.map_err(|_| ProbeFailure::MissingConfiguration)
|
||||
}
|
||||
|
||||
pub(crate) const fn turn_timeout(args: &ProbeArgs) -> Duration {
|
||||
Duration::from_secs(args.timeout_secs)
|
||||
}
|
||||
|
||||
async fn run_probe(config: &ProbeConfig, started_at: Instant) -> Result<ProbeReport, ProbeFailure> {
|
||||
let client = wreq::Client::builder()
|
||||
.connect_timeout(config.turn_timeout)
|
||||
.timeout(config.turn_timeout)
|
||||
.build()
|
||||
.map_err(|_| ProbeFailure::ClientBuild)?;
|
||||
let response = client
|
||||
.websocket(config.url.as_str())
|
||||
.headers(config.handshake_headers.clone())
|
||||
.max_frame_size(MAX_FRAME_SIZE)
|
||||
.max_message_size(MAX_FRAME_SIZE)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ProbeFailure::Handshake)?;
|
||||
let handshake_status = response.status().as_u16();
|
||||
let received_header_names = response
|
||||
.headers()
|
||||
.keys()
|
||||
.map(|name| name.as_str().to_string())
|
||||
.collect();
|
||||
let mut socket = response
|
||||
.into_websocket()
|
||||
.await
|
||||
.map_err(|_| ProbeFailure::Upgrade)?;
|
||||
let mut observed_event_types = Vec::new();
|
||||
|
||||
send_warmup(&mut socket, &config.model, None).await?;
|
||||
let first_response_id =
|
||||
receive_completed_response_id(&mut socket, config.turn_timeout, &mut observed_event_types)
|
||||
.await?;
|
||||
|
||||
send_warmup(&mut socket, &config.model, Some(&first_response_id)).await?;
|
||||
let _second_response_id =
|
||||
receive_completed_response_id(&mut socket, config.turn_timeout, &mut observed_event_types)
|
||||
.await?;
|
||||
|
||||
Ok(ProbeReport {
|
||||
status: "passed",
|
||||
target_host: target_host(config),
|
||||
handshake_status: Some(handshake_status),
|
||||
sent_header_names: config.sent_header_names.clone(),
|
||||
received_header_names,
|
||||
observed_event_types,
|
||||
continuation_confirmed: true,
|
||||
elapsed_ms: started_at.elapsed().as_millis() as u64,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn target_host(config: &ProbeConfig) -> Option<String> {
|
||||
config.url.host_str().map(|host| match config.url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_warmup(
|
||||
socket: &mut wreq::ws::WebSocket,
|
||||
model: &str,
|
||||
previous_response_id: Option<&str>,
|
||||
) -> Result<(), ProbeFailure> {
|
||||
let mut event = json!({
|
||||
"type": "response.create",
|
||||
"model": model,
|
||||
"store": false,
|
||||
"generate": false,
|
||||
"input": [],
|
||||
"tools": [],
|
||||
});
|
||||
if let Some(previous_response_id) = previous_response_id {
|
||||
event["previous_response_id"] = Value::String(previous_response_id.to_string());
|
||||
}
|
||||
socket
|
||||
.send(WreqWsMessage::text(event.to_string()))
|
||||
.await
|
||||
.map_err(|_| ProbeFailure::Send)
|
||||
}
|
||||
|
||||
async fn receive_completed_response_id(
|
||||
socket: &mut wreq::ws::WebSocket,
|
||||
timeout: Duration,
|
||||
observed_event_types: &mut Vec<String>,
|
||||
) -> Result<String, ProbeFailure> {
|
||||
let mut response_id = None;
|
||||
for _ in 0..MAX_EVENTS_PER_TURN {
|
||||
let message = tokio::time::timeout(timeout, socket.recv())
|
||||
.await
|
||||
.map_err(|_| ProbeFailure::ReceiveTimeout)?
|
||||
.ok_or(ProbeFailure::MissingResponseId)?
|
||||
.map_err(|_| ProbeFailure::Receive)?;
|
||||
match message {
|
||||
WreqWsMessage::Text(text) => {
|
||||
let event: Value = serde_json::from_str(text.as_str())
|
||||
.map_err(|_| ProbeFailure::UnexpectedFrame)?;
|
||||
let event_type = event
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(safe_event_label)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let is_remote_error = event_type == "error";
|
||||
let is_completed = event_type == "response.completed";
|
||||
observed_event_types.push(event_type);
|
||||
if is_remote_error {
|
||||
return Err(ProbeFailure::RemoteError);
|
||||
}
|
||||
if let Some(observed_response_id) = event
|
||||
.pointer("/response/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
response_id = Some(observed_response_id.to_string());
|
||||
}
|
||||
if is_completed {
|
||||
return response_id.ok_or(ProbeFailure::MissingResponseId);
|
||||
}
|
||||
}
|
||||
WreqWsMessage::Ping(_) | WreqWsMessage::Pong(_) => continue,
|
||||
WreqWsMessage::Close(_) => return Err(ProbeFailure::MissingResponseId),
|
||||
_ => return Err(ProbeFailure::UnexpectedFrame),
|
||||
}
|
||||
}
|
||||
Err(ProbeFailure::MissingResponseId)
|
||||
}
|
||||
|
||||
fn safe_event_label(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.len() > 80
|
||||
|| !trimmed
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return "unknown".to_string();
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn print_report(report: &ProbeReport) {
|
||||
match serde_json::to_string(report) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(_) => println!("{{\"status\":\"failed\",\"error\":\"report_serialization_failed\"}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use super::{parse_probe_url, run_probe, ProbeConfig};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockState {
|
||||
observed: Mutex<Option<oneshot::Sender<ObservedClientMessages>>>,
|
||||
}
|
||||
|
||||
struct ObservedClientMessages {
|
||||
authorization_present: bool,
|
||||
profile_header_present: bool,
|
||||
second_before_first_completion: bool,
|
||||
first: Value,
|
||||
second: Value,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_confirms_sequential_response_continuation_without_exposing_values() {
|
||||
let (url, observed, server) = spawn_mock_server().await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer test-token-that-must-not-be-reported"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-aether-probe-profile",
|
||||
HeaderValue::from_static("test-profile-id"),
|
||||
);
|
||||
let config = ProbeConfig::new(
|
||||
parse_probe_url(url.as_str()).expect("mock URL should be valid"),
|
||||
"gpt-test".to_string(),
|
||||
Duration::from_secs(2),
|
||||
headers,
|
||||
vec!["authorization", "x-aether-probe-profile"],
|
||||
);
|
||||
|
||||
let report = run_probe(&config, Instant::now())
|
||||
.await
|
||||
.expect("probe should complete against mock server");
|
||||
let client_messages = observed.await.expect("mock should observe client messages");
|
||||
server.abort();
|
||||
|
||||
assert_eq!(report.status, "passed");
|
||||
assert!(report.continuation_confirmed);
|
||||
assert!(report
|
||||
.observed_event_types
|
||||
.contains(&"response.created".to_string()));
|
||||
assert!(report
|
||||
.observed_event_types
|
||||
.contains(&"response.completed".to_string()));
|
||||
assert!(client_messages.authorization_present);
|
||||
assert!(client_messages.profile_header_present);
|
||||
assert!(!client_messages.second_before_first_completion);
|
||||
assert_eq!(client_messages.first["type"], "response.create");
|
||||
assert_eq!(client_messages.first["generate"], false);
|
||||
assert_eq!(client_messages.first["store"], false);
|
||||
assert_eq!(client_messages.second["previous_response_id"], "resp-first");
|
||||
let report_json = serde_json::to_string(&report).expect("report should serialize");
|
||||
assert!(!report_json.contains("test-token-that-must-not-be-reported"));
|
||||
assert!(!report_json.contains("test-profile-id"));
|
||||
assert!(!report_json.contains("resp-first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_rejects_credentials_and_query_strings() {
|
||||
assert!(parse_probe_url("wss://example.test/v1/responses").is_ok());
|
||||
assert!(parse_probe_url("https://example.test/v1/responses").is_err());
|
||||
assert!(parse_probe_url("wss://token@example.test/v1/responses").is_err());
|
||||
assert!(parse_probe_url("wss://example.test/v1/responses?token=secret").is_err());
|
||||
}
|
||||
|
||||
async fn spawn_mock_server() -> (
|
||||
String,
|
||||
oneshot::Receiver<ObservedClientMessages>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let (observed_tx, observed_rx) = oneshot::channel();
|
||||
let state = Arc::new(MockState {
|
||||
observed: Mutex::new(Some(observed_tx)),
|
||||
});
|
||||
let app = Router::new()
|
||||
.route("/v1/responses", get(mock_websocket))
|
||||
.with_state(state);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("mock listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("mock listener should expose address");
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("mock server should run");
|
||||
});
|
||||
(format!("ws://{address}/v1/responses"), observed_rx, server)
|
||||
}
|
||||
|
||||
async fn mock_websocket(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<MockState>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let authorization_present = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.starts_with("Bearer "));
|
||||
let profile_header_present = headers.contains_key("x-aether-probe-profile");
|
||||
ws.on_upgrade(move |socket| async move {
|
||||
serve_mock_socket(socket, state, authorization_present, profile_header_present).await;
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve_mock_socket(
|
||||
socket: WebSocket,
|
||||
state: Arc<MockState>,
|
||||
authorization_present: bool,
|
||||
profile_header_present: bool,
|
||||
) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let first = receive_json(&mut receiver).await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp-first"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
let early_second = tokio::select! {
|
||||
message = receiver.next() => Some(message),
|
||||
_ = tokio::time::sleep(Duration::from_millis(50)) => None,
|
||||
};
|
||||
let second_before_first_completion = early_second.is_some();
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "response.completed",
|
||||
"response": {"id": "resp-first", "status": "completed"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
let second = match early_second {
|
||||
Some(Some(Ok(Message::Text(text)))) => {
|
||||
serde_json::from_str(text.as_str()).expect("early client message should be JSON")
|
||||
}
|
||||
Some(Some(Ok(_))) => panic!("expected text continuation message"),
|
||||
Some(Some(Err(error))) => panic!("client message should be valid: {error}"),
|
||||
Some(None) => panic!("client closed before continuation"),
|
||||
None => receive_json(&mut receiver).await,
|
||||
};
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp-second"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
serde_json::json!({
|
||||
"type": "response.completed",
|
||||
"response": {"id": "resp-second", "status": "completed"}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await;
|
||||
if let Some(observed) = state.observed.lock().await.take() {
|
||||
let _ = observed.send(ObservedClientMessages {
|
||||
authorization_present,
|
||||
profile_header_present,
|
||||
second_before_first_completion,
|
||||
first,
|
||||
second,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_json(receiver: &mut futures_util::stream::SplitStream<WebSocket>) -> Value {
|
||||
let message = receiver
|
||||
.next()
|
||||
.await
|
||||
.expect("client should send a message")
|
||||
.expect("client message should be valid");
|
||||
let Message::Text(text) = message else {
|
||||
panic!("expected text message");
|
||||
};
|
||||
serde_json::from_str(text.as_str()).expect("client message should be JSON")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# Codex Responses WebSocket probe
|
||||
|
||||
`aether-codex-ws-probe` is a P0 compatibility probe for a Codex-compatible
|
||||
Responses WebSocket upstream. It verifies two sequential `response.create`
|
||||
warmups on one socket, with the second request continuing from the first
|
||||
response ID.
|
||||
|
||||
The command, environment variables, JSON report shape, and Codex-specific
|
||||
handshake headers remain stable. It now shares only the protocol-driving core
|
||||
with the separate [OpenAI Responses WebSocket probe](openai-responses-websocket-probe.md);
|
||||
the two probes intentionally retain independent authentication profiles and
|
||||
provider-specific assertions.
|
||||
|
||||
The probe is intentionally not a production proxy. It does not persist,
|
||||
refresh, log, or print credentials, account IDs, response IDs, request bodies,
|
||||
or response bodies.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Use a dedicated, non-production Codex test account. Rotate any credential that
|
||||
has been pasted into a chat, terminal history, issue, or source file before
|
||||
using this probe.
|
||||
|
||||
Set these values only in the process environment or your secret manager:
|
||||
|
||||
```bash
|
||||
export AETHER_CODEX_WS_PROBE_URL='wss://your-codex-upstream.example/backend-api/codex/responses'
|
||||
export AETHER_CODEX_WS_PROBE_ACCESS_TOKEN='your-short-lived-access-token'
|
||||
export AETHER_CODEX_WS_PROBE_ACCOUNT_ID='your-account-id'
|
||||
export AETHER_CODEX_WS_PROBE_MODEL='your-codex-model'
|
||||
```
|
||||
|
||||
The endpoint must use `ws://` or `wss://`, with no credentials, query string,
|
||||
or fragment. The access token is accepted only through
|
||||
`AETHER_CODEX_WS_PROBE_ACCESS_TOKEN`; there is deliberately no command-line
|
||||
flag for it.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run -p aether-gateway --bin aether-codex-ws-probe
|
||||
```
|
||||
|
||||
Use `--url` to override only the endpoint and `--timeout-secs` to set a
|
||||
per-turn receive timeout (1–120 seconds):
|
||||
|
||||
```bash
|
||||
cargo run -p aether-gateway --bin aether-codex-ws-probe -- \
|
||||
--url 'wss://your-codex-upstream.example/backend-api/codex/responses' \
|
||||
--timeout-secs 30
|
||||
```
|
||||
|
||||
The probe emits one JSON line. A successful run has
|
||||
`"continuation_confirmed":true`; its event and header fields contain names
|
||||
only, never values. A failure emits a stable error code such as
|
||||
`"handshake_failed"`, `"upstream_error_event"`, or
|
||||
`"response_id_not_observed"`.
|
||||
|
||||
## Interpretation
|
||||
|
||||
A successful probe establishes that the selected upstream accepts the
|
||||
Responses WebSocket handshake and retains continuation state on one socket.
|
||||
It does not establish that all Codex models, account plans, or tunnel egress
|
||||
paths are supported. In particular, the current `aether-tunnel` HTTP relay
|
||||
does not forward WebSocket upgrades, so a successful direct probe is a
|
||||
prerequisite rather than tunnel support.
|
||||
|
||||
## Gateway bridge
|
||||
|
||||
The gateway exposes WebSocket mode at the same public Responses path:
|
||||
|
||||
```text
|
||||
wss://<aether-gateway>/v1/responses
|
||||
```
|
||||
|
||||
It is disabled by default per provider. In **添加提供商** or **编辑提供商**,
|
||||
enable **Responses WebSocket 模式** under **功能开关** only after the selected
|
||||
upstream has passed a compatible WebSocket probe. The setting takes effect for
|
||||
new WebSocket connections without a gateway restart. It is available to every
|
||||
provider type; candidate planning still requires a selected
|
||||
`openai:responses` endpoint.
|
||||
|
||||
Authenticate the upgrade request with the normal Aether API key. The first
|
||||
client frame must be a text JSON `response.create` containing a non-empty
|
||||
`model`. Aether then applies its regular Responses candidate selection, but
|
||||
accepts only an eligible, WebSocket-enabled endpoint using `openai:responses`.
|
||||
It opens an upstream WebSocket using the selected provider key.
|
||||
|
||||
The selected provider's model mapping and request headers are applied to every
|
||||
turn, along with the rest of that candidate's provider-body normalization:
|
||||
model-directive patches, endpoint body rules, and the Codex body contract
|
||||
(unsupported-field stripping, `store: false`, `tool_choice` defaulting). A
|
||||
continuation turn is normalized against the binding it is pinned to rather than
|
||||
being re-planned, so it can never move to another provider key.
|
||||
`previous_response_id` and `generate` are re-applied after normalization
|
||||
because they are WebSocket protocol state that the provider body contract
|
||||
otherwise strips. `stream` and `background` are removed because they are HTTP
|
||||
transport fields, not WebSocket-mode fields. If a later `response.create` changes the
|
||||
public model, Aether runs access checks and candidate planning again. It keeps
|
||||
the existing upstream when the same target remains eligible, or transparently
|
||||
replaces the upstream between responses when the selected target changes.
|
||||
Overlapping responses on one client socket remain rejected.
|
||||
|
||||
Each `response.create` is tracked as an independent Aether logical request:
|
||||
it receives its own request/candidate identity, usage lifecycle, and terminal
|
||||
audit record. `response.completed`, `response.failed`,
|
||||
`response.incomplete`, `response.cancelled`, client disconnects, and upstream
|
||||
transport failures all settle that turn through the existing stream reporting
|
||||
path.
|
||||
|
||||
Example client setup:
|
||||
|
||||
```python
|
||||
from websocket import create_connection
|
||||
import json
|
||||
import os
|
||||
|
||||
ws = create_connection(
|
||||
"wss://gateway.example/v1/responses",
|
||||
header=[f"Authorization: Bearer {os.environ['AETHER_API_KEY']}"],
|
||||
)
|
||||
ws.send(json.dumps({
|
||||
"type": "response.create",
|
||||
"model": "your-public-model",
|
||||
"store": False,
|
||||
"input": "Explain this repository.",
|
||||
}))
|
||||
```
|
||||
|
||||
### Operating limits
|
||||
|
||||
- Maximum frame and message size: 16 MiB.
|
||||
- An idle connection must send its first `response.create` within 60 seconds.
|
||||
- A connection is closed after 60 minutes; reconnect before then for long runs.
|
||||
- Each `response.create` must receive its first upstream event within the
|
||||
selected provider's `stream_first_byte_timeout` (30 seconds by default),
|
||||
and finish within its `request_timeout` (20 minutes by default). Aether
|
||||
sends `responses_websocket_first_event_timeout` or
|
||||
`responses_websocket_turn_timeout` and closes the bound socket when either
|
||||
deadline expires.
|
||||
- Responses are sequential; no multiplexing is supported on one socket.
|
||||
- Each `response.create` consumes the normal Aether user/API-key RPM budget.
|
||||
- Same-model turns stay on the bound provider key. A model change is planned
|
||||
again and can rebind the upstream between completed turns when necessary.
|
||||
- Direct provider proxy settings are honored through the selected transport
|
||||
profile. Tunnel-mode proxy nodes are not supported for this bridge yet.
|
||||
|
||||
Usage and audit finalization now runs for every accepted `response.create`.
|
||||
Existing usage body-capture and header-redaction policies apply to the resulting
|
||||
records. Newly created WebSocket usage records expose `is_websocket=true`, and
|
||||
the usage-record type column renders them as `WS`. For diagnosis, enable debug
|
||||
logging for `aether_gateway::handlers::proxy::responses_ws`; event logs contain
|
||||
only the event type and frame size, never request or response contents. Codex
|
||||
quota-extension logs remain under `aether_gateway::handlers::proxy::codex_ws`.
|
||||
Every WebSocket-specific log carries `transport="websocket"` and
|
||||
`websocket=true`; keep `log_type` for its existing access/event/ops
|
||||
classification, and render the transport flag as a `WS` label in a log viewer
|
||||
if desired.
|
||||
@@ -0,0 +1,61 @@
|
||||
# OpenAI Responses WebSocket probe
|
||||
|
||||
`aether-openai-responses-ws-probe` verifies the official OpenAI Responses
|
||||
WebSocket protocol using standard API-key Bearer authentication. It sends two
|
||||
sequential `response.create` warmups on one socket, chaining the second from
|
||||
the first response ID with `previous_response_id`.
|
||||
|
||||
It shares its protocol-driving core with the Codex probe, but it does **not**
|
||||
send Codex account headers or require Codex quota events. This makes it the
|
||||
compatibility gate for Aether's standard Responses WebSocket adapter, rather
|
||||
than a replacement for the Codex probe.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Use a dedicated API project and a model that your key can access. Keep values
|
||||
only in your process environment or secret manager:
|
||||
|
||||
```bash
|
||||
export AETHER_OPENAI_WS_PROBE_API_KEY='your-api-key'
|
||||
export AETHER_OPENAI_WS_PROBE_MODEL='your-openai-model'
|
||||
```
|
||||
|
||||
The default endpoint is the official Responses WebSocket endpoint:
|
||||
|
||||
```text
|
||||
wss://api.openai.com/v1/responses
|
||||
```
|
||||
|
||||
To test a compatible endpoint explicitly, set
|
||||
`AETHER_OPENAI_WS_PROBE_URL` or pass `--url`. The endpoint must use `ws://` or
|
||||
`wss://` and may not contain credentials, a query string, or a fragment. The
|
||||
API key has no command-line flag and is never printed.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run -p aether-gateway --bin aether-openai-responses-ws-probe
|
||||
```
|
||||
|
||||
For an explicit endpoint and timeout:
|
||||
|
||||
```bash
|
||||
cargo run -p aether-gateway --bin aether-openai-responses-ws-probe -- \
|
||||
--url 'wss://api.openai.com/v1/responses' \
|
||||
--timeout-secs 30
|
||||
```
|
||||
|
||||
The probe uses `generate:false`, so the warmups prepare continuation state but
|
||||
do not request model output. A successful JSON report contains
|
||||
`"continuation_confirmed":true`; header and event arrays contain names only,
|
||||
never credentials, response IDs, request bodies, or response bodies.
|
||||
|
||||
## Interpretation
|
||||
|
||||
Success establishes that this key, model, and endpoint support the Responses
|
||||
WebSocket handshake plus an in-socket continuation. It does not establish
|
||||
support for every model, tool, service tier, proxy path, or Aether provider
|
||||
configuration. Treat a successful direct probe as a prerequisite before
|
||||
enabling **Responses WebSocket mode** for the matching Aether provider.
|
||||
|
||||
For protocol details, see the official [WebSocket Mode guide](https://developers.openai.com/api/docs/guides/websocket-mode).
|
||||
Reference in New Issue
Block a user