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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion crates/agentkit-provider-openai/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
version = "0.10.11"
version = "0.10.12"

[dependencies]
tokio-tungstenite = { version = "=0.29.0", default-features = false, features = ["handshake"] }
Expand Down
6 changes: 5 additions & 1 deletion crates/agentkit-provider-openai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ never replayed. Recovery uses the existing retry observations and deadline budge
WebSocket upgrades use a dedicated reqwest HTTP/1 client with redirects and
implicit HTTP retries disabled, using the existing reqwest TLS stack. A custom
`Http` passed to `with_client` applies only to HTTP/SSE, **not** to WebSocket
upgrades; applications requiring custom transport middleware should keep `Http`.
upgrades. Proxy discovery from the environment/system remains enabled by default.
Set `OpenAIResponsesConfig::with_websocket_no_proxy(true)` to disable it for
`WebSocket` and `Auto` upgrades without relaxing the client's hardening or timeouts.
This does not change HTTP/SSE (including Auto fallback): configure its client
separately. Applications requiring custom transport middleware should keep `Http`.

Wire contract reference: OpenAI Codex commit
[`6824dabe0393337a38cb257d5fe75ae5ca168470`](https://github.com/openai/codex/tree/6824dabe0393337a38cb257d5fe75ae5ca168470),
Expand Down
16 changes: 16 additions & 0 deletions crates/agentkit-provider-openai/src/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ pub struct OpenAIResponsesConfig {
user_agent: Option<String>,
originator: Option<String>,
transport: OpenAIResponsesTransport,
websocket_no_proxy: bool,
}

impl fmt::Debug for OpenAIResponsesConfig {
Expand All @@ -155,6 +156,7 @@ impl fmt::Debug for OpenAIResponsesConfig {
.field("endpoint", &self.endpoint)
.field("profile", &self.profile)
.field("transport", &self.transport)
.field("websocket_no_proxy", &self.websocket_no_proxy)
.field("header_names", &self.headers.keys().collect::<Vec<_>>())
.field("request_policy", &self.request_policy)
.field("reasoning_effort", &self.reasoning_effort)
Expand Down Expand Up @@ -197,6 +199,7 @@ impl OpenAIResponsesConfig {
user_agent: None,
originator: None,
transport: OpenAIResponsesTransport::Http,
websocket_no_proxy: false,
}
}

Expand All @@ -223,6 +226,7 @@ impl OpenAIResponsesConfig {
user_agent: None,
originator: None,
transport: OpenAIResponsesTransport::Http,
websocket_no_proxy: false,
}
}

Expand All @@ -232,6 +236,18 @@ impl OpenAIResponsesConfig {
self
}

/// Disables proxy discovery for WebSocket upgrades when `true`.
///
/// Defaults to `false`, preserving reqwest's environment/system proxy discovery.
/// Applies to both `WebSocket` and `Auto`, but not HTTP/SSE (including Auto fallback).
/// A custom HTTP client's proxy policy is not inherited by the dedicated WebSocket
/// client. Set this explicitly to preserve a no-proxy policy across transports.
/// HTTP/1, redirect/retry restrictions, and handshake timeouts remain enforced.
pub fn with_websocket_no_proxy(mut self, no_proxy: bool) -> Self {
self.websocket_no_proxy = no_proxy;
self
}

pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
Expand Down
8 changes: 7 additions & 1 deletion crates/agentkit-provider-openai/src/responses/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,13 @@ pub(super) async fn send(
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(HANDSHAKE_TIMEOUT)
.timeout(HANDSHAKE_TIMEOUT)
.timeout(HANDSHAKE_TIMEOUT);
let client = if context.config.websocket_no_proxy {
client.no_proxy()
} else {
client
};
let client = client
.build()
.map_err(|_| protocol_failure("could not build WebSocket upgrade client"))?;
context.tracker.accounting.attempts = context.tracker.accounting.attempts.saturating_add(1);
Expand Down
86 changes: 86 additions & 0 deletions crates/agentkit-provider-openai/src/responses/websocket/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1697,3 +1697,89 @@ async fn interrupted_missing_previous_backoff_releases_session_without_replay()
peer.join().unwrap();
}
}

#[test]
fn websocket_proxy_policy_child_process() {
const ENDPOINT: &str = "AGENTKIT_TEST_WEBSOCKET_PROXY_ENDPOINT";
if let Ok(endpoint) = std::env::var(ENDPOINT) {
// Only this child inherits the proxy environment; never mutate process globals.
tokio::runtime::Runtime::new().unwrap().block_on(async {
for transport in [
OpenAIResponsesTransport::WebSocket,
OpenAIResponsesTransport::Auto,
] {
for no_proxy in [None, Some(false), Some(true)] {
let config = config(&endpoint, transport);
let config = match no_proxy {
Some(value) => config.with_websocket_no_proxy(value),
None => config,
};
// An explicitly direct HTTP client must not silently change the
// independent WebSocket client's default proxy policy.
let http = Http::new(reqwest::Client::builder().no_proxy().build().unwrap());
let adapter = OpenAIResponsesAdapter::with_client(config, http);
let mut session = adapter
.start_session(SessionConfig::new("proxy-policy"))
.await
.unwrap();
let result = session.begin_turn(request(), None).await;
if no_proxy == Some(true) {
let mut turn = result.expect("direct WebSocket upgrade must succeed");
finished(&drain(&mut turn).await.unwrap());
} else {
assert!(result.is_err(), "proxy must block the upgrade");
}
}
}
});
return;
}

let (endpoint, direct) = server(|listener| {
for _ in 0..2 {
let mut ws = socket(&listener);
receive(&mut ws);
success(&mut ws);
}
});
let expected_endpoint = endpoint.clone();
let (proxy, blocked) = server(move |listener| {
for _ in 0..4 {
let (headers, _) = http(&listener, "403 Forbidden", "proxy blocked upgrade");
assert!(headers.starts_with(&format!("GET {expected_endpoint} HTTP/1.1\r\n")));
assert!(
headers
.to_ascii_lowercase()
.contains("upgrade: websocket\r\n")
);
}
});
let mut child = std::process::Command::new(std::env::current_exe().unwrap());
child.args([
"--exact",
"responses::websocket::tests::websocket_proxy_policy_child_process",
"--nocapture",
]);
// Clear inherited bypasses and conflicting proxy variables in the child only.
for key in [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
] {
child.env(key, &proxy);
}
child.env("NO_PROXY", "").env("no_proxy", "");
child.env_remove("REQUEST_METHOD");
let output = child.env(ENDPOINT, endpoint).output().unwrap();
assert!(
output.status.success(),
"proxy policy child failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
blocked.join().unwrap();
direct.join().unwrap();
}
Loading