fix(codex): preserve transport overrides across runtimes
This commit is contained in:
parent
b721754ead
commit
81387f9896
@ -395,7 +395,7 @@ Notes:
|
||||
- Transport override precedence for OpenAI Codex:
|
||||
1. `[[model_routes]].transport` (route-specific)
|
||||
2. `provider.transport`
|
||||
3. `ZEROCLAW_CODEX_TRANSPORT` / `ZEROCLAW_PROVIDER_TRANSPORT`
|
||||
3. `PROVIDER_TRANSPORT` / `ZEROCLAW_CODEX_TRANSPORT` / `ZEROCLAW_PROVIDER_TRANSPORT`
|
||||
4. legacy `ZEROCLAW_RESPONSES_WEBSOCKET` (boolean)
|
||||
|
||||
## `[skills]`
|
||||
|
||||
@ -4805,7 +4805,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
let provider_runtime_options = providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: config.api_url.clone(),
|
||||
provider_transport: None,
|
||||
provider_transport: config.effective_provider_transport(),
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
|
||||
@ -311,6 +311,14 @@ pub struct ProviderConfig {
|
||||
pub reasoning_level: Option<String>,
|
||||
/// Optional transport override for providers that support multiple transports.
|
||||
/// Supported values: "auto", "websocket", "sse".
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1) `model_routes[].transport` (route-specific)
|
||||
/// 2) `provider.transport`
|
||||
/// 3) env overrides (`PROVIDER_TRANSPORT`, `ZEROCLAW_PROVIDER_TRANSPORT`, `ZEROCLAW_CODEX_TRANSPORT`)
|
||||
/// 4) runtime default (`auto`, WebSocket-first with SSE fallback for OpenAI Codex)
|
||||
///
|
||||
/// Existing configs that omit `provider.transport` remain valid and fall back to defaults.
|
||||
#[serde(default)]
|
||||
pub transport: Option<String>,
|
||||
}
|
||||
@ -3199,8 +3207,12 @@ pub struct ModelRouteConfig {
|
||||
/// Optional API key override for this route's provider
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Optional provider transport override for this route.
|
||||
/// Optional route-specific transport override for this route.
|
||||
/// Supported values: "auto", "websocket", "sse".
|
||||
///
|
||||
/// When `model_routes[].transport` is unset, the route inherits `provider.transport`.
|
||||
/// If both are unset, runtime defaults are used (`auto` for OpenAI Codex).
|
||||
/// Existing configs without this field remain valid.
|
||||
#[serde(default)]
|
||||
pub transport: Option<String>,
|
||||
}
|
||||
|
||||
@ -362,7 +362,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
&providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: config.api_url.clone(),
|
||||
provider_transport: None,
|
||||
provider_transport: config.effective_provider_transport(),
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
|
||||
@ -1552,19 +1552,8 @@ pub fn create_routed_provider_with_options(
|
||||
}
|
||||
}
|
||||
|
||||
// Build route table
|
||||
let routes: Vec<(String, router::Route)> = model_routes
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.hint.clone(),
|
||||
router::Route {
|
||||
provider_name: r.provider.clone(),
|
||||
model: r.model.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Keep only successfully initialized routed providers and preserve
|
||||
// their provider-id bindings (e.g. "<provider>#<hint>").
|
||||
|
||||
Ok(Box::new(
|
||||
router::RouterProvider::new(providers, routes, default_model.to_string())
|
||||
|
||||
@ -9,6 +9,8 @@ use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::{
|
||||
connect_async,
|
||||
tungstenite::{
|
||||
@ -29,6 +31,9 @@ const CODEX_PROVIDER_TRANSPORT_ENV: &str = "ZEROCLAW_PROVIDER_TRANSPORT";
|
||||
const CODEX_RESPONSES_WEBSOCKET_ENV_LEGACY: &str = "ZEROCLAW_RESPONSES_WEBSOCKET";
|
||||
const DEFAULT_CODEX_INSTRUCTIONS: &str =
|
||||
"You are ZeroClaw, a concise and helpful coding assistant.";
|
||||
const CODEX_WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const CODEX_WS_SEND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CODEX_WS_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CodexTransport {
|
||||
@ -714,16 +719,50 @@ impl OpenAiCodexProvider {
|
||||
"parallel_tool_calls": request.parallel_tool_calls,
|
||||
});
|
||||
|
||||
let (mut ws_stream, _) = connect_async(ws_request).await?;
|
||||
ws_stream
|
||||
.send(WsMessage::Text(serde_json::to_string(&payload)?.into()))
|
||||
.await?;
|
||||
let (mut ws_stream, _) = timeout(CODEX_WS_CONNECT_TIMEOUT, connect_async(ws_request))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"OpenAI Codex websocket connect timed out after {}s",
|
||||
CODEX_WS_CONNECT_TIMEOUT.as_secs()
|
||||
)
|
||||
})??;
|
||||
timeout(
|
||||
CODEX_WS_SEND_TIMEOUT,
|
||||
ws_stream.send(WsMessage::Text(serde_json::to_string(&payload)?.into())),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"OpenAI Codex websocket send timed out after {}s",
|
||||
CODEX_WS_SEND_TIMEOUT.as_secs()
|
||||
)
|
||||
})??;
|
||||
|
||||
let mut saw_delta = false;
|
||||
let mut delta_accumulator = String::new();
|
||||
let mut fallback_text: Option<String> = None;
|
||||
let mut timed_out = false;
|
||||
|
||||
while let Some(frame) = ws_stream.next().await {
|
||||
loop {
|
||||
let frame = match timeout(CODEX_WS_READ_TIMEOUT, ws_stream.next()).await {
|
||||
Ok(frame) => frame,
|
||||
Err(_) => {
|
||||
let _ = ws_stream.close(None).await;
|
||||
if saw_delta || fallback_text.is_some() {
|
||||
timed_out = true;
|
||||
break;
|
||||
}
|
||||
anyhow::bail!(
|
||||
"OpenAI Codex websocket stream timed out after {}s waiting for events",
|
||||
CODEX_WS_READ_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let Some(frame) = frame else {
|
||||
break;
|
||||
};
|
||||
let frame = frame?;
|
||||
let event: Value = match frame {
|
||||
WsMessage::Text(text) => serde_json::from_str(text.as_ref())?,
|
||||
@ -786,6 +825,9 @@ impl OpenAiCodexProvider {
|
||||
if let Some(text) = fallback_text {
|
||||
return Ok(text);
|
||||
}
|
||||
if timed_out {
|
||||
anyhow::bail!("No response from OpenAI Codex websocket stream before timeout");
|
||||
}
|
||||
|
||||
anyhow::bail!("No response from OpenAI Codex websocket stream");
|
||||
}
|
||||
|
||||
@ -428,7 +428,7 @@ pub fn all_tools_with_runtime(
|
||||
let provider_runtime_options = crate::providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: root_config.api_url.clone(),
|
||||
provider_transport: None,
|
||||
provider_transport: root_config.effective_provider_transport(),
|
||||
zeroclaw_dir: root_config
|
||||
.config_path
|
||||
.parent()
|
||||
|
||||
@ -217,6 +217,7 @@ impl ModelRoutingConfigTool {
|
||||
"hint": route.hint,
|
||||
"provider": route.provider,
|
||||
"model": route.model,
|
||||
"transport": route.transport,
|
||||
"api_key_configured": has_provider_credential(&route.provider, route.api_key.as_deref()),
|
||||
"classification": classification,
|
||||
})
|
||||
@ -429,6 +430,7 @@ impl ModelRoutingConfigTool {
|
||||
let provider = Self::parse_non_empty_string(args, "provider")?;
|
||||
let model = Self::parse_non_empty_string(args, "model")?;
|
||||
let api_key_update = Self::parse_optional_string_update(args, "api_key")?;
|
||||
let transport_update = Self::parse_optional_string_update(args, "transport")?;
|
||||
|
||||
let keywords_update = if let Some(raw) = args.get("keywords") {
|
||||
Some(Self::parse_string_list(raw, "keywords")?)
|
||||
@ -479,6 +481,12 @@ impl ModelRoutingConfigTool {
|
||||
MaybeSet::Unset => {}
|
||||
}
|
||||
|
||||
match transport_update {
|
||||
MaybeSet::Set(transport) => next_route.transport = Some(transport),
|
||||
MaybeSet::Null => next_route.transport = None,
|
||||
MaybeSet::Unset => {}
|
||||
}
|
||||
|
||||
cfg.model_routes.retain(|route| route.hint != hint);
|
||||
cfg.model_routes.push(next_route);
|
||||
Self::normalize_and_sort_routes(&mut cfg.model_routes);
|
||||
@ -783,6 +791,10 @@ impl Tool for ModelRoutingConfigTool {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional API key override for scenario route or delegate agent"
|
||||
},
|
||||
"transport": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional route transport override for upsert_scenario (auto, websocket, sse)"
|
||||
},
|
||||
"keywords": {
|
||||
"description": "Classification keywords for upsert_scenario (string or string array)",
|
||||
"oneOf": [
|
||||
@ -1005,6 +1017,7 @@ mod tests {
|
||||
"hint": "coding",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.3-codex",
|
||||
"transport": "websocket",
|
||||
"classification_enabled": true,
|
||||
"keywords": ["code", "bug", "refactor"],
|
||||
"patterns": ["```"],
|
||||
@ -1026,6 +1039,7 @@ mod tests {
|
||||
item["hint"] == json!("coding")
|
||||
&& item["provider"] == json!("openai")
|
||||
&& item["model"] == json!("gpt-5.3-codex")
|
||||
&& item["transport"] == json!("websocket")
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user