fix(sync): backport ws parity and codex-spark support

This commit is contained in:
James Peter
2026-02-13 06:40:34 +10:00
parent 1b084511a4
commit 02ecaffbb4
5 changed files with 152 additions and 15 deletions
+18
View File
@@ -36,6 +36,7 @@ pub const DEFAULT_AGENT_NAMES: &[&str] = &[
// Frontline for moderate/challenging tasks
"code-gpt-5.2",
"code-gpt-5.3-codex",
"code-gpt-5.3-codex-spark",
"claude-opus-4.6",
"gemini-3-pro",
// Straightforward / cost-aware
@@ -132,6 +133,19 @@ const AGENT_MODEL_SPECS: &[AgentModelSpec] = &[
gating_env: None,
is_frontline: true,
},
AgentModelSpec {
slug: "code-gpt-5.3-codex-spark",
family: "code",
cli: "coder",
read_only_args: CODE_GPT5_CODEX_READ_ONLY,
write_args: CODE_GPT5_CODEX_WRITE,
model_args: &["--model", "gpt-5.3-codex-spark"],
description: "Fast codex variant tuned for responsive coding loops and smaller edits.",
enabled_by_default: true,
aliases: &["gpt-5.3-codex-spark", "code-gpt-5.3-spark", "codex-spark"],
gating_env: None,
is_frontline: false,
},
AgentModelSpec {
slug: "code-gpt-5.1-codex-mini",
family: "code",
@@ -458,6 +472,10 @@ mod tests {
agent_model_spec("code-gpt-5.2-codex").expect("slug upgrade alias present");
assert_eq!(codex_slug_upgrade.slug, "code-gpt-5.3-codex");
let spark =
agent_model_spec("gpt-5.3-codex-spark").expect("spark alias for codex present");
assert_eq!(spark.slug, "code-gpt-5.3-codex-spark");
let mini = agent_model_spec("gpt-5.1-codex-mini").expect("mini alias present");
assert_eq!(mini.slug, "code-gpt-5.1-codex-mini");
+1 -1
View File
@@ -2061,7 +2061,7 @@ pub fn create_agent_tool(allowed_models: &[String]) -> OpenAiTool {
},
}),
description: Some(
"Optional array of model names (e.g., ['code-gpt-5.2','claude-sonnet-4.5','code-gpt-5.3-codex','gemini-3-flash'])".to_string(),
"Optional array of model names (e.g., ['code-gpt-5.2','claude-sonnet-4.5','code-gpt-5.3-codex-spark','gemini-3-flash'])".to_string(),
),
},
);
+100 -9
View File
@@ -26,7 +26,7 @@ use serde_json::Value;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_util::io::ReaderStream;
use tokio_stream::wrappers::ReceiverStream;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tracing::debug;
use tracing::trace;
use tracing::warn;
@@ -183,6 +183,13 @@ fn is_quota_exceeded_http_error(status: StatusCode, error: &Error) -> bool {
status.is_client_error() && is_quota_exceeded_error(error)
}
fn is_server_overloaded_error(error: &Error) -> bool {
matches!(
error.code.as_deref(),
Some("server_is_overloaded") | Some("slow_down")
)
}
fn is_reasoning_summary_rejected(error: &Error) -> bool {
let param_matches = matches!(error.param.as_deref(), Some("reasoning.summary"));
let code_matches = matches!(error.code.as_deref(), Some("unsupported_value"));
@@ -831,7 +838,7 @@ impl ModelClient {
)
})?;
let (tx_bytes, rx_bytes) = mpsc::channel::<Result<Bytes>>(1600);
let (tx_bytes, rx_bytes) = mpsc::unbounded_channel::<Result<Bytes>>();
let request_id_for_ws = request_id.clone();
tokio::spawn(async move {
loop {
@@ -843,12 +850,12 @@ impl ModelClient {
if let Some(error) = parse_wrapped_websocket_error_event(&text)
.and_then(map_wrapped_websocket_error_event)
{
let _ = tx_bytes.send(Err(error)).await;
let _ = tx_bytes.send(Err(error));
break;
}
let chunk = format!("data: {text}\n\n");
if tx_bytes.send(Ok(Bytes::from(chunk))).await.is_err() {
if tx_bytes.send(Ok(Bytes::from(chunk))).is_err() {
break;
}
}
@@ -865,8 +872,7 @@ impl ModelClient {
"[ws] unexpected binary websocket event".to_string(),
None,
Some(request_id_for_ws.clone()),
)))
.await;
)));
break;
}
Ok(_) => {}
@@ -876,15 +882,14 @@ impl ModelClient {
format!("[ws] websocket error: {err}"),
None,
Some(request_id_for_ws.clone()),
)))
.await;
)));
break;
}
}
}
});
let stream = ReceiverStream::new(rx_bytes);
let stream = UnboundedReceiverStream::new(rx_bytes);
let debug_logger = Arc::clone(&self.debug_logger);
let request_id_clone = request_id.clone();
let otel_event_manager = self.otel_event_manager.clone();
@@ -1901,6 +1906,10 @@ fn map_wrapped_websocket_error_event(event: WrappedWebsocketErrorEvent) -> Optio
return Some(CodexErr::QuotaExceeded);
}
if is_server_overloaded_error(&error) {
return Some(CodexErr::ServerOverloaded);
}
serde_json::json!({
"error": {
"type": error.r#type,
@@ -2563,6 +2572,8 @@ async fn process_sse<S>(
Ok(error) => {
if is_quota_exceeded_error(&error) {
response_error = Some(CodexErr::QuotaExceeded);
} else if is_server_overloaded_error(&error) {
response_error = Some(CodexErr::ServerOverloaded);
} else {
let retry_after = try_parse_retry_after(&error, Utc::now());
let message = error.message.unwrap_or_default();
@@ -2580,6 +2591,19 @@ async fn process_sse<S>(
}
}
}
"response.incomplete" => {
let reason = event.response.as_ref().and_then(|response| {
response
.get("incomplete_details")
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
});
let reason = reason.unwrap_or("unknown");
let message = format!("Incomplete response returned, reason: {reason}");
let event = CodexErr::Stream(message, None, Some(request_id.clone()));
let _ = tx_event.send(Err(event)).await;
return;
}
// Final response completed – includes array of output items & id
"response.completed" => {
if let Some(resp_val) = event.response {
@@ -3444,4 +3468,71 @@ mod tests {
other => panic!("unexpected quota event: {other:?}"),
}
}
#[tokio::test]
async fn server_overloaded_error_is_typed() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_slow_down","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"slow_down","message":"Server is overloaded. Please retry shortly."},"incomplete_details":null}}"#;
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let provider = ModelProviderInfo {
name: "test".to_string(),
base_url: Some("https://test.com".to_string()),
env_key: Some("TEST_API_KEY".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
requires_openai_auth: false,
openrouter: None,
};
let events = collect_events(&[sse1.as_bytes()], provider).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(CodexErr::ServerOverloaded) => {}
other => panic!("unexpected overloaded event: {other:?}"),
}
}
#[tokio::test]
async fn response_incomplete_surfaces_stream_error_reason() {
let raw_incomplete = r#"{"type":"response.incomplete","sequence_number":4,"response":{"id":"resp_incomplete","object":"response","created_at":1759771626,"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#;
let sse1 = format!("event: response.incomplete\ndata: {raw_incomplete}\n\n");
let provider = ModelProviderInfo {
name: "test".to_string(),
base_url: Some("https://test.com".to_string()),
env_key: Some("TEST_API_KEY".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
requires_openai_auth: false,
openrouter: None,
};
let events = collect_events(&[sse1.as_bytes()], provider).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(CodexErr::Stream(message, None, _)) => {
assert_eq!(
message,
"Incomplete response returned, reason: max_output_tokens"
);
}
other => panic!("unexpected incomplete event: {other:?}"),
}
}
}
+3
View File
@@ -141,6 +141,9 @@ pub enum CodexErr {
#[error("{0}")]
ServerError(String),
#[error("server overloaded")]
ServerOverloaded,
/// Retry limit exceeded.
#[error("{0}")]
RetryLimit(RetryLimitReachedError),
+30 -5
View File
@@ -238,7 +238,8 @@ pub struct ModelInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<i64>,
/// Token threshold for automatic compaction. When omitted, core derives it
/// from `context_window` (90%).
/// from `context_window` (90%). When provided, core clamps it to 90% of the
/// context window when available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_compact_token_limit: Option<i64>,
/// Percentage of the context window considered usable for inputs, after
@@ -256,10 +257,16 @@ pub struct ModelInfo {
impl ModelInfo {
pub fn auto_compact_token_limit(&self) -> Option<i64> {
self.auto_compact_token_limit.or_else(|| {
self.context_window
.map(|context_window| (context_window * 9) / 10)
})
let context_limit = self
.context_window
.map(|context_window| (context_window * 9) / 10);
let config_limit = self.auto_compact_token_limit;
if let Some(context_limit) = context_limit {
return Some(
config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
);
}
config_limit
}
pub fn supports_personality(&self) -> bool {
@@ -521,6 +528,24 @@ mod tests {
}
}
#[test]
fn auto_compact_token_limit_uses_context_default() {
let mut model = test_model(None);
model.context_window = Some(100_000);
model.auto_compact_token_limit = None;
assert_eq!(model.auto_compact_token_limit(), Some(90_000));
}
#[test]
fn auto_compact_token_limit_clamps_to_context_limit() {
let mut model = test_model(None);
model.context_window = Some(200_000);
model.auto_compact_token_limit = Some(250_000);
assert_eq!(model.auto_compact_token_limit(), Some(180_000));
}
#[test]
fn get_model_instructions_uses_template_when_placeholder_present() {
let model = test_model(Some(ModelMessages {