mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(sync): backport ws parity and codex-spark support
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ pub enum CodexErr {
|
||||
#[error("{0}")]
|
||||
ServerError(String),
|
||||
|
||||
#[error("server overloaded")]
|
||||
ServerOverloaded,
|
||||
|
||||
/// Retry limit exceeded.
|
||||
#[error("{0}")]
|
||||
RetryLimit(RetryLimitReachedError),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user