Files
dev/code-rs/core/src/client.rs
T

2547 lines
98 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::BTreeMap;
use std::io::BufRead;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::AuthManager;
use crate::RefreshTokenError;
use bytes::Bytes;
use code_app_server_protocol::AuthMode;
use code_protocol::models::ResponseItem;
use eventsource_stream::Eventsource;
use futures::prelude::*;
use httpdate::parse_http_date;
use regex_lite::Regex;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_util::io::ReaderStream;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use chrono::{DateTime, Utc};
const AUTH_REQUIRED_MESSAGE: &str = "Authentication required. Run `code login` to continue.";
use crate::agent_defaults::{default_agent_configs, enabled_agent_model_specs};
use crate::chat_completions::AggregateStreamExt;
use crate::chat_completions::stream_chat_completions;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::client_common::ResponsesApiRequest;
use crate::client_common::create_reasoning_param_for_request;
use crate::config::Config;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::config_types::TextVerbosity as TextVerbosityConfig;
use crate::debug_logger::DebugLogger;
use crate::default_client::create_client;
use crate::error::{CodexErr, RetryAfter};
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::error::UsageLimitReachedError;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_family::{find_family_for_model, ModelFamily};
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::openai_model_info::get_model_info;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::openai_tools::ConfigShellToolType;
use crate::openai_tools::ToolsConfig;
use crate::protocol::RateLimitSnapshotEvent;
use crate::protocol::SandboxPolicy;
use crate::protocol::TokenUsage;
use crate::reasoning::clamp_reasoning_effort_for_model;
use crate::slash_commands::get_enabled_agents;
use crate::util::backoff;
use code_otel::otel_event_manager::{OtelEventManager, TurnLatencyPayload};
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
const RESPONSES_BETA_HEADER_V1: &str = "responses=v1";
const RESPONSES_BETA_HEADER_EXPERIMENTAL: &str = "responses=experimental";
#[derive(Default, Debug)]
struct StreamCheckpoint {
/// Highest sequence_number observed across attempts. Used to drop replayed deltas.
last_sequence: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: Error,
}
#[derive(Debug, Deserialize)]
struct Error {
r#type: Option<String>,
#[allow(dead_code)]
code: Option<String>,
/// Optional parameter that triggered the error (e.g. "reasoning.summary").
#[allow(dead_code)]
param: Option<String>,
message: Option<String>,
// Optional fields available on "usage_limit_reached" and "usage_not_included" errors
plan_type: Option<String>,
resets_in_seconds: Option<u64>,
}
#[derive(Serialize)]
struct CompactHistoryRequest<'a> {
model: &'a str,
#[serde(borrow)]
input: &'a [ResponseItem],
instructions: String,
}
#[derive(Debug, Deserialize)]
struct CompactHistoryResponse {
output: Vec<ResponseItem>,
}
fn rate_limit_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?i)(?:please\s+try\s+again|try\s+again|please\s+retry|retry|try)\s+(?:in|after)\s*(\d+(?:\.\d+)?)\s*(ms|milliseconds?|s|sec|secs|seconds?)"
)
.expect("valid rate limit regex")
})
}
fn try_parse_retry_after(err: &Error, now: DateTime<Utc>) -> Option<RetryAfter> {
if let Some(seconds) = err.resets_in_seconds {
return Some(RetryAfter::from_duration(Duration::from_secs(seconds), now));
}
let message = err.message.as_deref()?;
let re = rate_limit_regex();
let captures = re.captures(message)?;
let value = captures.get(1)?.as_str().trim().parse::<f64>().ok()?;
if value.is_sign_negative() {
return None;
}
let unit = captures.get(2)?.as_str().trim().to_ascii_lowercase();
if unit.starts_with("ms") {
Some(RetryAfter::from_duration(Duration::from_millis(value.round() as u64), now))
} else if unit.starts_with("sec") || unit == "s" || unit.starts_with("second") {
Some(RetryAfter::from_duration(Duration::from_secs_f64(value), now))
} else {
None
}
}
fn is_quota_exceeded_error(error: &Error) -> bool {
matches!(
error.code.as_deref().or_else(|| error.r#type.as_deref()),
Some("insufficient_quota")
)
}
fn is_quota_exceeded_http_error(status: StatusCode, error: &Error) -> bool {
status.is_client_error() && is_quota_exceeded_error(error)
}
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"));
let message_matches = error
.message
.as_deref()
.map(|msg| {
let msg = msg.to_ascii_lowercase();
msg.contains("organization must be verified") && msg.contains("reasoning summar")
})
.unwrap_or(false);
// Only treat as rejection if it's specifically an "unsupported_value" error
// for the reasoning.summary parameter, or if the message explicitly says
// the organization must be verified for reasoning summaries.
(param_matches && code_matches) || (code_matches && message_matches)
}
fn map_unauthorized_outcome(
had_auth: bool,
refresh_error: Option<&RefreshTokenError>,
) -> Option<CodexErr> {
if let Some(err) = refresh_error {
if err.is_permanent() {
return Some(CodexErr::AuthRefreshPermanent(err.message.clone()));
}
return None;
}
if !had_auth {
return Some(CodexErr::AuthRefreshPermanent(
AUTH_REQUIRED_MESSAGE.to_string(),
));
}
None
}
#[derive(Debug)]
pub struct ModelClient {
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
otel_event_manager: Option<OtelEventManager>,
client: reqwest::Client,
provider: ModelProviderInfo,
session_id: Uuid,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
reasoning_summary_disabled: AtomicBool,
verbosity: TextVerbosityConfig,
debug_logger: Arc<Mutex<DebugLogger>>,
}
impl Clone for ModelClient {
fn clone(&self) -> Self {
Self {
config: Arc::clone(&self.config),
auth_manager: self.auth_manager.clone(),
otel_event_manager: self.otel_event_manager.clone(),
client: self.client.clone(),
provider: self.provider.clone(),
session_id: self.session_id,
effort: self.effort,
summary: self.summary,
reasoning_summary_disabled: AtomicBool::new(
self.reasoning_summary_disabled.load(Ordering::Relaxed),
),
verbosity: self.verbosity,
debug_logger: Arc::clone(&self.debug_logger),
}
}
}
impl ModelClient {
pub fn new(
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
otel_event_manager: Option<OtelEventManager>,
provider: ModelProviderInfo,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
verbosity: TextVerbosityConfig,
session_id: Uuid,
debug_logger: Arc<Mutex<DebugLogger>>,
) -> Self {
let effective_verbosity = clamp_text_verbosity_for_model(config.model.as_str(), verbosity);
let clamped_effort = clamp_reasoning_effort_for_model(config.model.as_str(), effort);
let client = create_client(&config.responses_originator_header);
Self {
config,
auth_manager,
otel_event_manager,
client,
provider,
session_id,
effort: clamped_effort,
summary,
reasoning_summary_disabled: AtomicBool::new(false),
verbosity: effective_verbosity,
debug_logger,
}
}
/// Get the reasoning effort configuration
pub fn get_reasoning_effort(&self) -> ReasoningEffortConfig {
self.effort
}
/// Get the reasoning summary configuration
pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig {
if self.reasoning_summary_disabled.load(Ordering::Relaxed) {
ReasoningSummaryConfig::None
} else {
self.summary
}
}
fn current_reasoning_param(
&self,
family: &ModelFamily,
effort: ReasoningEffortConfig,
) -> Option<crate::client_common::Reasoning> {
if self.reasoning_summary_disabled.load(Ordering::Relaxed) {
return None;
}
create_reasoning_param_for_request(
family,
Some(effort),
self.summary,
)
}
fn disable_reasoning_summary(&self) {
if !self.reasoning_summary_disabled.swap(true, Ordering::Relaxed) {
tracing::warn!("disabling reasoning summaries after API rejection");
}
}
/// Get the text verbosity configuration
#[allow(dead_code)]
pub fn get_text_verbosity(&self) -> TextVerbosityConfig {
self.verbosity
}
pub fn get_otel_event_manager(&self) -> Option<OtelEventManager> {
self.otel_event_manager.clone()
}
pub fn log_turn_latency_debug(&self, payload: &TurnLatencyPayload) {
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.log_turn_latency(payload);
}
}
pub fn code_home(&self) -> &Path {
&self.config.code_home
}
pub fn build_tools_config_with_sandbox(
&self,
sandbox_policy: SandboxPolicy,
) -> ToolsConfig {
let mut tools_config = ToolsConfig::new(
&self.config.model_family,
self.config.approval_policy,
sandbox_policy.clone(),
self.config.include_plan_tool,
self.config.include_apply_patch_tool,
self.config.tools_web_search_request,
self.config.use_experimental_streamable_shell_tool,
self.config.include_view_image_tool,
);
tools_config.web_search_allowed_domains = self.config.tools_web_search_allowed_domains.clone();
let mut agent_models: Vec<String> = if self.config.agents.is_empty() {
default_agent_configs()
.into_iter()
.filter(|cfg| cfg.enabled)
.map(|cfg| cfg.name)
.collect()
} else {
get_enabled_agents(&self.config.agents)
};
if agent_models.is_empty() {
agent_models = enabled_agent_model_specs()
.into_iter()
.map(|spec| spec.slug.to_string())
.collect();
}
agent_models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
agent_models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
tools_config.set_agent_models(agent_models);
let base_shell_type = tools_config.shell_type.clone();
let base_uses_native_shell = matches!(
&base_shell_type,
ConfigShellToolType::LocalShell | ConfigShellToolType::StreamableShell
);
tools_config.shell_type = match sandbox_policy.clone() {
SandboxPolicy::ReadOnly => {
if base_uses_native_shell {
base_shell_type.clone()
} else {
ConfigShellToolType::ShellWithRequest {
sandbox_policy: SandboxPolicy::ReadOnly,
}
}
}
sp @ SandboxPolicy::WorkspaceWrite { .. } => {
if base_uses_native_shell {
base_shell_type.clone()
} else {
ConfigShellToolType::ShellWithRequest { sandbox_policy: sp }
}
}
SandboxPolicy::DangerFullAccess => base_shell_type,
};
tools_config
}
pub fn build_tools_config(&self) -> ToolsConfig {
self.build_tools_config_with_sandbox(self.config.sandbox_policy.clone())
}
pub fn get_auto_compact_token_limit(&self) -> Option<i64> {
self.config.model_auto_compact_token_limit.or_else(|| {
get_model_info(&self.config.model_family).and_then(|info| info.auto_compact_token_limit)
})
}
pub fn default_model_slug(&self) -> &str {
self.config.model.as_str()
}
pub fn default_model_family(&self) -> &ModelFamily {
&self.config.model_family
}
/// Dispatches to either the Responses or Chat implementation depending on
/// the provider config. Public callers always invoke `stream()` – the
/// specialised helpers are private to avoid accidental misuse.
pub async fn stream(&self, prompt: &Prompt) -> Result<ResponseStream> {
let env_log_tag = std::env::var("CODE_DEBUG_LOG_TAG").ok();
let log_tag = env_log_tag
.as_deref()
.or(prompt.log_tag.as_deref());
match self.provider.wire_api {
WireApi::Responses => self.stream_responses(prompt, log_tag).await,
WireApi::Chat => {
let effective_family = prompt
.model_family_override
.as_ref()
.unwrap_or(&self.config.model_family);
let model_slug = prompt
.model_override
.as_deref()
.unwrap_or(self.config.model.as_str());
// Create the raw streaming connection first.
let response_stream = stream_chat_completions(
prompt,
effective_family,
model_slug,
&self.client,
&self.provider,
&self.debug_logger,
self.auth_manager.clone(),
self.otel_event_manager.clone(),
log_tag,
)
.await?;
// Wrap it with the aggregation adapter so callers see *only*
// the final assistant message per turn (matching the
// behaviour of the Responses API).
let mut aggregated = if self.config.show_raw_agent_reasoning {
crate::chat_completions::AggregatedChatStream::streaming_mode(response_stream)
} else {
response_stream.aggregate()
};
// Bridge the aggregated stream back into a standard
// `ResponseStream` by forwarding events through a channel.
let (tx, rx) = mpsc::channel::<Result<ResponseEvent>>(16);
tokio::spawn(async move {
use futures::StreamExt;
while let Some(ev) = aggregated.next().await {
// Exit early if receiver hung up.
if tx.send(ev).await.is_err() {
break;
}
}
});
Ok(ResponseStream { rx_event: rx })
}
}
}
/// Implementation for the OpenAI *Responses* experimental API.
async fn stream_responses(&self, prompt: &Prompt, log_tag: Option<&str>) -> Result<ResponseStream> {
if let Some(path) = &*CODEX_RS_SSE_FIXTURE {
// short circuit for tests
warn!(path, "Streaming from fixture");
return stream_from_fixture(path, self.provider.clone(), self.otel_event_manager.clone())
.await;
}
let auth_manager = self.auth_manager.clone();
let auth_mode = auth_manager
.as_ref()
.and_then(|m| m.auth())
.as_ref()
.map(|a| a.mode);
// Use non-stored turns on all paths for stability.
let store = false;
let request_model = prompt
.model_override
.as_deref()
.unwrap_or(self.config.model.as_str());
let effective_effort = clamp_reasoning_effort_for_model(request_model, self.effort);
let request_family = find_family_for_model(request_model)
.unwrap_or_else(|| self.config.model_family.clone());
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
let mut tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
if matches!(effective_effort, ReasoningEffortConfig::Minimal) {
tools_json.retain(|tool| {
tool.get("type")
.and_then(|value| value.as_str())
.map(|tool_type| tool_type != "web_search")
.unwrap_or(true)
});
}
let input_with_instructions = prompt.get_formatted_input();
// Build `text` parameter with conditional verbosity and optional format.
// - Omit entirely for ChatGPT auth unless a `text.format` or output schema is present.
// - Only include `text.verbosity` for GPT-5 family models; warn and ignore otherwise.
// - When a structured `format` is present, still include `verbosity` so GPT-5 can honor it.
let want_format = prompt.text_format.clone().or_else(|| {
prompt.output_schema.as_ref().map(|schema| crate::client_common::TextFormat {
r#type: "json_schema".to_string(),
name: Some("code_output_schema".to_string()),
strict: Some(true),
schema: Some(schema.clone()),
})
});
let effective_verbosity = clamp_text_verbosity_for_model(request_model, self.verbosity);
let verbosity = match &request_family.family {
family if family == "gpt-5" || family == "gpt-5.1" => Some(effective_verbosity),
_ => None,
};
let text_template = match (auth_mode, want_format, verbosity) {
(Some(AuthMode::ChatGPT), None, _) => None,
(_, Some(fmt), _) => Some(crate::client_common::Text {
verbosity: effective_verbosity.into(),
format: Some(fmt),
}),
(_, None, Some(_)) => Some(crate::client_common::Text {
verbosity: effective_verbosity.into(),
format: None,
}),
(_, None, None) => None,
};
// In general, we want to explicitly send `store: false` when using the Responses API,
// but in practice, the Azure Responses API rejects `store: false`:
//
// - If store = false and id is sent an error is thrown that ID is not found
// - If store = false and id is not sent an error is thrown that ID is required
//
// For Azure, we send `store: true` and preserve reasoning item IDs.
let azure_workaround = self.provider.is_azure_responses_endpoint();
let model_slug = request_model;
let session_id = prompt
.session_id_override
.unwrap_or(self.session_id);
let session_id_str = session_id.to_string();
let mut attempt = 0;
let max_retries = self.provider.request_max_retries();
let mut request_id = String::new();
// Compute endpoint with the latest available auth (may be None at this point).
let endpoint = self
.provider
.get_full_url(&auth_manager.as_ref().and_then(|m| m.auth()));
loop {
attempt += 1;
let reasoning = self.current_reasoning_param(&request_family, effective_effort);
// Request encrypted COT if we are not storing responses,
// otherwise reasoning items will be referenced by ID
let include: Vec<String> = if !store && reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
Vec::new()
};
let text = text_template.clone();
let payload = ResponsesApiRequest {
model: model_slug,
instructions: &full_instructions,
input: &input_with_instructions,
tools: &tools_json,
tool_choice: "auto",
parallel_tool_calls: true,
reasoning,
text,
store: azure_workaround,
stream: true,
include,
// Use a stable per-process cache key (session id). With store=false this is inert.
prompt_cache_key: Some(session_id_str.clone()),
};
let mut payload_json = serde_json::to_value(&payload)?;
if let Some(model_value) = payload_json.get_mut("model") {
*model_value = serde_json::Value::String(model_slug.to_string());
}
if azure_workaround {
attach_item_ids(&mut payload_json, &input_with_instructions);
}
if let Some(openrouter_cfg) = self.provider.openrouter_config() {
if let Some(obj) = payload_json.as_object_mut() {
if let Some(provider) = &openrouter_cfg.provider {
obj.insert(
"provider".to_string(),
serde_json::to_value(provider)?
);
}
if let Some(route) = &openrouter_cfg.route {
obj.insert("route".to_string(), route.clone());
}
for (key, value) in &openrouter_cfg.extra {
obj.entry(key.clone()).or_insert(value.clone());
}
}
}
let payload_body = serde_json::to_string(&payload_json)?;
let mut auth_refresh_error: Option<RefreshTokenError> = None;
// Always fetch the latest auth in case a prior attempt refreshed the token.
let auth = auth_manager.as_ref().and_then(|m| m.auth());
trace!(
"POST to {}: {}",
self.provider.get_full_url(&auth),
payload_body.as_str()
);
let mut req_builder = self
.provider
.create_request_builder(&self.client, &auth)
.await?;
let has_beta_header = req_builder
.try_clone()
.and_then(|builder| builder.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta_header {
let beta_value = if self.provider.is_public_openai_responses_endpoint() {
RESPONSES_BETA_HEADER_V1
} else {
RESPONSES_BETA_HEADER_EXPERIMENTAL
};
req_builder = req_builder.header("OpenAI-Beta", beta_value);
}
req_builder = req_builder
// Send `conversation_id`/`session_id` so the server can hit the prompt-cache.
.header("conversation_id", session_id_str.clone())
.header("session_id", session_id_str.clone())
.header(reqwest::header::ACCEPT, "text/event-stream")
.json(&payload_json);
if let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
&& let Some(account_id) = auth.get_account_id()
{
req_builder = req_builder.header("chatgpt-account-id", account_id);
}
if request_id.is_empty() {
let endpoint_for_log = self.provider.get_full_url(&auth);
let header_snapshot = req_builder
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
if let Ok(logger) = self.debug_logger.lock() {
request_id = logger
.start_request_log(
&endpoint_for_log,
&payload_json,
header_snapshot.as_ref(),
log_tag,
)
.unwrap_or_default();
}
}
let res = if let Some(otel) = self.otel_event_manager.as_ref() {
otel.log_request(attempt, || req_builder.send()).await
} else {
req_builder.send().await
};
if let Ok(resp) = &res {
trace!(
"Response status: {}, request-id: {}",
resp.status(),
resp.headers()
.get("x-request-id")
.map(|v| v.to_str().unwrap_or_default())
.unwrap_or_default()
);
}
match res {
Ok(resp) if resp.status().is_success() => {
// Log successful response initiation
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"stream_initiated",
&serde_json::json!({
"status": "success",
"status_code": resp.status().as_u16(),
"x_request_id": resp.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
}),
);
}
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
if let Some(snapshot) = parse_rate_limit_snapshot(resp.headers()) {
debug!(
"rate limit headers:\n{}",
format_rate_limit_headers(resp.headers())
);
if tx_event
.send(Ok(ResponseEvent::RateLimits(snapshot)))
.await
.is_err()
{
debug!("receiver dropped rate limit snapshot event");
}
}
// spawn task to process SSE
let stream = resp.bytes_stream().map_err(CodexErr::Reqwest);
let debug_logger = Arc::clone(&self.debug_logger);
let request_id_clone = request_id.clone();
let otel_event_manager = self.otel_event_manager.clone();
tokio::spawn(process_sse(
stream,
tx_event,
self.provider.stream_idle_timeout(),
debug_logger,
request_id_clone,
otel_event_manager,
Arc::new(RwLock::new(StreamCheckpoint::default())),
));
return Ok(ResponseStream { rx_event });
}
Ok(res) => {
let status = res.status();
// Capture x-request-id up-front in case we consume the response body later.
let x_request_id = res
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let now = Utc::now();
// Pull out Retry‑After header if present.
let retry_after_hint = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|raw| parse_retry_after_header(raw, now));
if status == StatusCode::UNAUTHORIZED {
if let Some(manager) = auth_manager.as_ref() {
match manager.refresh_token_classified().await {
Ok(Some(_)) => {}
Ok(None) => {
auth_refresh_error = Some(RefreshTokenError::permanent(
AUTH_REQUIRED_MESSAGE,
));
}
Err(err) => {
auth_refresh_error = Some(err);
}
}
} else {
auth_refresh_error = Some(RefreshTokenError::permanent(
"Authentication manager unavailable; please log in again.",
));
}
}
// Read the response body once for diagnostics across error branches.
let body_text = res.text().await.unwrap_or_default();
let body = serde_json::from_str::<ErrorResponse>(&body_text).ok();
if status == StatusCode::BAD_REQUEST {
if let Some(ErrorResponse { ref error }) = body {
if !self.reasoning_summary_disabled.load(Ordering::Relaxed)
&& is_reasoning_summary_rejected(error)
{
self.disable_reasoning_summary();
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"reasoning_summary_disabled",
&serde_json::json!({
"status": status.as_u16(),
"message": error.message.clone(),
"code": error.code.clone(),
"param": error.param.clone(),
}),
);
}
// Retry immediately with reasoning summaries removed.
attempt = 0;
continue;
}
}
}
// The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx
// errors. When we bubble early with only the HTTP status the caller sees an opaque
// "unexpected status 400 Bad Request" which makes debugging nearly impossible.
// Instead, read (and include) the response text so higher layers and users see the
// exact error message (e.g. "Unknown parameter: 'input[0].metadata'"). The body is
// small and this branch only runs on error paths so the extra allocation is
// negligible.
if !(status == StatusCode::TOO_MANY_REQUESTS
|| status == StatusCode::UNAUTHORIZED
|| status.is_server_error())
{
// Log error response
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"error",
&serde_json::json!({
"status": status.as_u16(),
"body": body_text
}),
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body: body_text,
request_id: None,
}));
}
if let Some(ErrorResponse { ref error }) = body {
if is_quota_exceeded_http_error(status, error) {
return Err(CodexErr::QuotaExceeded);
}
}
if status == StatusCode::UNAUTHORIZED {
if let Some(error) =
map_unauthorized_outcome(auth.is_some(), auth_refresh_error.as_ref())
{
return Err(error);
}
}
if status == StatusCode::TOO_MANY_REQUESTS {
if let Some(ErrorResponse { ref error }) = body {
if error.r#type.as_deref() == Some("usage_limit_reached") {
// Prefer the plan_type provided in the error message if present
// because it's more up to date than the one encoded in the auth
// token.
let plan_type = error
.plan_type
.clone()
.or_else(|| auth.and_then(|a| a.get_plan_type()));
let resets_in_seconds = error.resets_in_seconds;
return Err(CodexErr::UsageLimitReached(UsageLimitReachedError {
plan_type,
resets_in_seconds,
}));
} else if error.r#type.as_deref() == Some("usage_not_included") {
return Err(CodexErr::UsageNotIncluded);
}
}
}
if attempt > max_retries {
// On final attempt, surface rich diagnostics for server errors.
// On final attempt, surface rich diagnostics for server errors.
if status.is_server_error() {
let (message, body_excerpt) =
match serde_json::from_str::<ErrorResponse>(&body_text) {
Ok(ErrorResponse { error }) => {
let msg = error
.message
.unwrap_or_else(|| "server error".to_string());
(msg, None)
}
Err(_) => {
let mut excerpt = body_text;
const MAX: usize = 600;
if excerpt.len() > MAX {
excerpt.truncate(MAX);
}
(
"server error".to_string(),
if excerpt.is_empty() {
None
} else {
Some(excerpt)
},
)
}
};
// Build a single-line, actionable message for the UI and logs.
let mut msg = format!("server error {status}: {message}");
if let Some(id) = &x_request_id {
msg.push_str(&format!(" (request-id: {id})"));
}
if let Some(excerpt) = &body_excerpt {
msg.push_str(&format!(" | body: {excerpt}"));
}
// Log detailed context to the debug logger and close the request log.
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"server_error_on_retry_limit",
&serde_json::json!({
"status": status.as_u16(),
"x_request_id": x_request_id,
"message": message,
"body_excerpt": body_excerpt,
}),
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::ServerError(msg));
}
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
retryable: status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS,
}));
}
let mut retry_after_delay = retry_after_hint;
if retry_after_delay.is_none() {
if let Some(ErrorResponse { ref error }) = body {
retry_after_delay = try_parse_retry_after(error, now);
}
}
let delay = retry_after_delay
.as_ref()
.map(|info| info.delay)
.unwrap_or_else(|| backoff(attempt));
tokio::time::sleep(delay).await;
}
Err(e) => {
let is_connectivity = e.is_connect() || e.is_timeout() || e.is_request();
if attempt > max_retries {
// Log network error before surfacing.
if let Ok(logger) = self.debug_logger.lock() {
let _ = logger.log_error(&endpoint, &format!("Network error: {}", e), log_tag);
}
if is_connectivity {
let req_id = (!request_id.is_empty()).then(|| request_id.clone());
return Err(CodexErr::Stream(
format!("[transport] network unavailable: {e}"),
None,
req_id,
));
}
return Err(e.into());
}
let delay = backoff(attempt);
tokio::time::sleep(delay).await;
}
}
}
}
pub fn get_provider(&self) -> ModelProviderInfo {
self.provider.clone()
}
/// Returns the currently configured model slug.
#[allow(dead_code)]
pub fn get_model(&self) -> String {
self.config.model.clone()
}
/// Returns the currently configured model family.
#[allow(dead_code)]
pub fn get_model_family(&self) -> ModelFamily {
self.config.model_family.clone()
}
#[allow(dead_code)]
pub fn get_model_context_window(&self) -> Option<u64> {
self.config.model_context_window
}
#[allow(dead_code)]
pub fn get_auth_manager(&self) -> Option<Arc<AuthManager>> {
self.auth_manager.clone()
}
pub async fn compact_conversation_history(&self, prompt: &Prompt) -> Result<Vec<ResponseItem>> {
if prompt.input.is_empty() {
return Ok(Vec::new());
}
let auth_manager = self.auth_manager.clone();
let auth = auth_manager.as_ref().and_then(|m| m.auth());
let mut request = self
.provider
.create_compact_request_builder(&self.client, &auth)
.await?;
// Ensure Responses API beta header is present for compact calls. Mirror the
// streaming path: use the public "responses=v1" header for the public OpenAI
// endpoint and fall back to "responses=experimental" for other providers.
let has_beta_header = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta_header {
let beta_value = if self.provider.is_public_openai_responses_endpoint() {
RESPONSES_BETA_HEADER_V1
} else {
RESPONSES_BETA_HEADER_EXPERIMENTAL
};
request = request.header("OpenAI-Beta", beta_value);
}
if let Some(auth) = auth.as_ref()
&& auth.mode == AuthMode::ChatGPT
&& let Some(account_id) = auth.get_account_id()
{
request = request.header("chatgpt-account-id", account_id);
}
let instructions = prompt
.get_full_instructions(&self.config.model_family)
.into_owned();
let payload = CompactHistoryRequest {
model: &self.config.model,
input: &prompt.input,
instructions: instructions.clone(),
};
let payload_json = serde_json::json!({
"model": payload.model,
"input": payload.input,
"instructions": instructions,
});
request = request.json(&payload);
let header_snapshot = request
.try_clone()
.and_then(|builder| builder.build().ok())
.map(|req| header_map_to_json(req.headers()));
let mut request_id = String::new();
if let Ok(logger) = self.debug_logger.lock() {
let endpoint = self
.provider
.get_compact_url(&auth)
.unwrap_or_else(|| self.provider.get_full_url(&auth));
request_id = logger
.start_request_log(&endpoint, &payload_json, header_snapshot.as_ref(), Some("compact_remote"))
.unwrap_or_default();
}
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if let Ok(logger) = self.debug_logger.lock() {
let response_body: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| serde_json::json!({ "raw": body }));
let _ = logger.append_response_event(
&request_id,
"compact_response",
&serde_json::json!({
"status_code": status.as_u16(),
"body": response_body,
}),
);
let _ = logger.end_request_log(&request_id);
}
if !status.is_success() {
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
let CompactHistoryResponse { output } = serde_json::from_str(&body)?;
Ok(output)
}
}
fn clamp_text_verbosity_for_model(
model: &str,
requested: TextVerbosityConfig,
) -> TextVerbosityConfig {
let allowed = supported_text_verbosity_for_model(model);
if allowed.iter().any(|v| v == &requested) {
return requested;
}
if let Some(medium) = allowed.iter().find(|v| matches!(v, TextVerbosityConfig::Medium)) {
tracing::debug!(
model,
requested = ?requested,
fallback = ?medium,
"text verbosity clamped to supported value for model",
);
return *medium;
}
let fallback = *allowed.first().unwrap_or(&TextVerbosityConfig::Medium);
tracing::debug!(
model,
requested = ?requested,
fallback = ?fallback,
"text verbosity clamped to first supported value for model",
);
fallback
}
fn supported_text_verbosity_for_model(model: &str) -> &'static [TextVerbosityConfig] {
if model.eq_ignore_ascii_case("gpt-5.1-codex-max") {
return &[TextVerbosityConfig::Medium];
}
const ALL: &[TextVerbosityConfig] = &[TextVerbosityConfig::Low, TextVerbosityConfig::Medium, TextVerbosityConfig::High];
ALL
}
#[derive(Debug, Deserialize, Serialize)]
struct SseEvent {
#[serde(rename = "type")]
kind: String,
response: Option<Value>,
item: Option<Value>,
delta: Option<String>,
// Present on delta events from the Responses API; used to correlate
// streaming chunks with the final OutputItemDone.
item_id: Option<String>,
// Optional ordering metadata from the Responses API; used to filter
// duplicates and out‑of‑order reasoning deltas.
sequence_number: Option<u64>,
output_index: Option<u32>,
content_index: Option<u32>,
summary_index: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ResponseCompleted {
id: String,
usage: Option<ResponseCompletedUsage>,
}
#[derive(Debug, Deserialize)]
struct ResponseCompletedUsage {
input_tokens: u64,
input_tokens_details: Option<ResponseCompletedInputTokensDetails>,
output_tokens: u64,
output_tokens_details: Option<ResponseCompletedOutputTokensDetails>,
total_tokens: u64,
}
impl From<ResponseCompletedUsage> for TokenUsage {
fn from(val: ResponseCompletedUsage) -> Self {
TokenUsage {
input_tokens: val.input_tokens,
cached_input_tokens: val
.input_tokens_details
.map(|d| d.cached_tokens)
.unwrap_or(0),
output_tokens: val.output_tokens,
reasoning_output_tokens: val
.output_tokens_details
.map(|d| d.reasoning_tokens)
.unwrap_or(0),
total_tokens: val.total_tokens,
}
}
}
#[derive(Debug, Deserialize)]
struct ResponseCompletedInputTokensDetails {
cached_tokens: u64,
}
#[derive(Debug, Deserialize)]
struct ResponseCompletedOutputTokensDetails {
reasoning_tokens: u64,
}
fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) {
let Some(input_value) = payload_json.get_mut("input") else {
return;
};
let serde_json::Value::Array(items) = input_value else {
return;
};
for (value, item) in items.iter_mut().zip(original_items.iter()) {
if let ResponseItem::Reasoning { id, .. }
| ResponseItem::Message { id: Some(id), .. }
| ResponseItem::WebSearchCall { id: Some(id), .. }
| ResponseItem::FunctionCall { id: Some(id), .. }
| ResponseItem::LocalShellCall { id: Some(id), .. }
| ResponseItem::CustomToolCall { id: Some(id), .. } = item
{
if id.is_empty() {
continue;
}
if let Some(obj) = value.as_object_mut() {
obj.insert("id".to_string(), Value::String(id.clone()));
}
}
}
}
fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEvent> {
let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?;
let secondary_used_percent = parse_header_f64(headers, "x-codex-secondary-used-percent")?;
let primary_to_secondary_ratio_percent =
parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?;
let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?;
let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?;
let primary_reset_after_seconds =
parse_header_u64(headers, "x-codex-primary-reset-after-seconds");
let secondary_reset_after_seconds =
parse_header_u64(headers, "x-codex-secondary-reset-after-seconds");
Some(RateLimitSnapshotEvent {
primary_used_percent,
secondary_used_percent,
primary_to_secondary_ratio_percent,
primary_window_minutes,
secondary_window_minutes,
primary_reset_after_seconds,
secondary_reset_after_seconds,
})
}
fn format_rate_limit_headers(headers: &HeaderMap) -> String {
let mut pairs: Vec<String> = headers
.iter()
.map(|(name, value)| {
let value_str = value.to_str().unwrap_or("<invalid>");
format!("{}: {}", name, value_str)
})
.collect();
pairs.sort();
pairs.join("\n")
}
fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option<f64> {
parse_header_str(headers, name)?
.parse::<f64>()
.ok()
.filter(|v| v.is_finite())
}
fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option<u64> {
parse_header_str(headers, name)?.parse::<u64>().ok()
}
fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name)?.to_str().ok()
}
fn parse_retry_after_header(value: &str, now: DateTime<Utc>) -> Option<RetryAfter> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed
.trim_matches(|c: char| matches!(c, '"' | '\'' | '<' | '>'))
.trim();
if normalized.is_empty() {
return None;
}
if let Ok(secs) = normalized.parse::<u64>() {
return Some(RetryAfter::from_duration(Duration::from_secs(secs), now));
}
if let Ok(float_secs) = normalized.parse::<f64>() {
if !float_secs.is_sign_negative() {
return Some(RetryAfter::from_duration(Duration::from_secs_f64(float_secs), now));
}
}
if let Ok(system_time) = parse_http_date(normalized) {
let resume_at: DateTime<Utc> = system_time.into();
return Some(RetryAfter::from_resume_at(resume_at, now));
}
if let Ok(dt) = DateTime::parse_from_rfc3339(normalized) {
return Some(RetryAfter::from_resume_at(dt.with_timezone(&Utc), now));
}
if let Ok(dt) = DateTime::parse_from_rfc2822(normalized) {
return Some(RetryAfter::from_resume_at(dt.with_timezone(&Utc), now));
}
if let Ok(dt) = DateTime::parse_from_str(normalized, "%a, %d %b %Y %H:%M:%S %z") {
return Some(RetryAfter::from_resume_at(dt.with_timezone(&Utc), now));
}
None
}
fn header_map_to_json(headers: &HeaderMap) -> Value {
let mut ordered: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (name, value) in headers.iter() {
let entry = ordered.entry(name.as_str().to_string()).or_default();
entry.push(value.to_str().unwrap_or_default().to_string());
}
serde_json::to_value(ordered).unwrap_or(Value::Null)
}
async fn process_sse<S>(
stream: S,
tx_event: mpsc::Sender<Result<ResponseEvent>>,
idle_timeout: Duration,
debug_logger: Arc<Mutex<DebugLogger>>,
request_id: String,
otel_event_manager: Option<OtelEventManager>,
checkpoint: Arc<RwLock<StreamCheckpoint>>,
) where
S: Stream<Item = Result<Bytes>> + Unpin,
{
let mut stream = stream.eventsource();
// If the stream stays completely silent for an extended period treat it as disconnected.
// The response id returned from the "complete" message.
let mut response_completed: Option<ResponseCompleted> = None;
let mut response_error: Option<CodexErr> = None;
// Track the current item_id to include with delta events
let mut current_item_id: Option<String> = None;
// Monotonic sequence guards to drop duplicate/out‑of‑order deltas.
// Keys are item_id strings.
use std::collections::HashMap;
// Track last sequence_number per (item_id, output_index[, content_index])
// Default indices to 0 when absent for robustness across providers.
let mut last_seq_reasoning_summary: HashMap<(String, u32, u32), u64> = HashMap::new();
let mut last_seq_reasoning_content: HashMap<(String, u32, u32), u64> = HashMap::new();
// Best-effort duplicate text guard when sequence_number is unavailable.
let mut last_text_reasoning_summary: HashMap<(String, u32, u32), String> = HashMap::new();
let mut last_text_reasoning_content: HashMap<(String, u32, u32), String> = HashMap::new();
let mut global_last_seq: Option<u64> = checkpoint.read().ok().and_then(|c| c.last_sequence);
loop {
let next_event = if let Some(manager) = otel_event_manager.as_ref() {
manager
.log_sse_event(|| timeout(idle_timeout, stream.next()))
.await
} else {
timeout(idle_timeout, stream.next()).await
};
let sse = match next_event {
Ok(Some(Ok(sse))) => sse,
Ok(Some(Err(e))) => {
debug!("SSE Error: {e:#}");
let event = CodexErr::Stream(
format!("[transport] {e}"),
None,
Some(request_id.clone()),
);
let _ = tx_event.send(Err(event)).await;
return;
}
Ok(None) => {
match response_completed {
Some(ResponseCompleted {
id: response_id,
usage,
}) => {
if let (Some(usage), Some(manager)) = (&usage, otel_event_manager.as_ref()) {
manager.sse_event_completed(
usage.input_tokens,
usage.output_tokens,
usage
.input_tokens_details
.as_ref()
.map(|d| d.cached_tokens),
usage
.output_tokens_details
.as_ref()
.map(|d| d.reasoning_tokens),
usage.total_tokens,
);
}
let event = ResponseEvent::Completed {
response_id,
token_usage: usage.map(Into::into),
};
let _ = tx_event.send(Ok(event)).await;
}
None => {
let error = response_error.unwrap_or(CodexErr::Stream(
"stream closed before response.completed".into(),
None,
Some(request_id.clone()),
));
if let Some(manager) = otel_event_manager.as_ref() {
manager.see_event_completed_failed(&error);
}
let _ = tx_event.send(Err(error)).await;
}
}
// Mark the request log as complete
if let Ok(logger) = debug_logger.lock() {
let _ = logger.end_request_log(&request_id);
}
return;
}
Err(_) => {
let _ = tx_event
.send(Err(CodexErr::Stream(
"[idle] timeout waiting for SSE".into(),
None,
Some(request_id.clone()),
)))
.await;
return;
}
};
let raw = sse.data.clone();
trace!("SSE event: {}", raw);
// Log the raw SSE event data
if let Ok(logger) = debug_logger.lock() {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&sse.data) {
let _ = logger.append_response_event(&request_id, "sse_event", &json_value);
}
}
let event: SseEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
Err(e) => {
// Log parse error with data excerpt, and record it in the debug logger as well.
let mut excerpt = sse.data.clone();
const MAX: usize = 600;
if excerpt.len() > MAX {
excerpt.truncate(MAX);
}
debug!("Failed to parse SSE event: {e}, data: {excerpt}");
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"sse_parse_error",
&serde_json::json!({
"error": e.to_string(),
"data_excerpt": excerpt,
}),
);
}
continue;
}
};
if let Some(seq) = event.sequence_number {
if let Some(last) = global_last_seq {
if seq <= last {
continue;
}
}
global_last_seq = Some(seq);
if let Ok(mut guard) = checkpoint.write() {
guard.last_sequence = Some(seq);
}
}
match event.kind.as_str() {
// Individual output item finalised. Forward immediately so the
// rest of the agent can stream assistant text/functions *live*
// instead of waiting for the final `response.completed` envelope.
//
// IMPORTANT: We used to ignore these events and forward the
// duplicated `output` array embedded in the `response.completed`
// payload. That produced two concrete issues:
// 1. No real‑time streaming – the user only saw output after the
// entire turn had finished, which broke the "typing" UX and
// made long‑running turns look stalled.
// 2. Duplicate `function_call_output` items – both the
// individual *and* the completed array were forwarded, which
// confused the backend and triggered 400
// "previous_response_not_found" errors because the duplicated
// IDs did not match the incremental turn chain.
//
// The fix is to forward the incremental events *as they come* and
// drop the duplicated list inside `response.completed`.
"response.output_item.done" => {
let Some(item_val) = event.item else { continue };
// Special-case: web_search_call completion -> synthesize a completion event
if item_val
.get("type")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "web_search_call")
{
let call_id = item_val
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let query = item_val
.get("action")
.and_then(|a| a.get("query"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let ev = ResponseEvent::WebSearchCallCompleted { call_id, query };
if tx_event.send(Ok(ev)).await.is_err() {
return;
}
}
let Ok(item) = serde_json::from_value::<ResponseItem>(item_val.clone()) else {
debug!("failed to parse ResponseItem from output_item.done");
continue;
};
// Extract item_id if present
if let Some(id) = item_val.get("id").and_then(|v| v.as_str()) {
current_item_id = Some(id.to_string());
} else {
// Check within the parsed item structure
match &item {
ResponseItem::Message { id, .. }
| ResponseItem::FunctionCall { id, .. }
| ResponseItem::LocalShellCall { id, .. } => {
if let Some(item_id) = id {
current_item_id = Some(item_id.clone());
}
}
ResponseItem::Reasoning { id, .. } => {
current_item_id = Some(id.clone());
}
_ => {}
}
}
let event = ResponseEvent::OutputItemDone { item, sequence_number: event.sequence_number, output_index: event.output_index };
if tx_event.send(Ok(event)).await.is_err() {
return;
}
}
"response.output_text.delta" => {
if let Some(delta) = event.delta {
// Prefer the explicit item_id from the SSE event; fall back to last seen.
if let Some(ref id) = event.item_id {
current_item_id = Some(id.clone());
}
tracing::debug!("sse.delta output_text id={:?} len={}", current_item_id, delta.len());
let ev = ResponseEvent::OutputTextDelta {
delta,
item_id: event.item_id.or_else(|| current_item_id.clone()),
sequence_number: event.sequence_number,
output_index: event.output_index,
};
if tx_event.send(Ok(ev)).await.is_err() {
return;
}
}
}
"response.reasoning_summary_text.delta" => {
if let Some(delta) = event.delta {
if let Some(ref id) = event.item_id {
current_item_id = Some(id.clone());
}
// Compose key using item_id + output_index
let out_idx: u32 = event.output_index.unwrap_or(0);
let sum_idx: u32 = event.summary_index.unwrap_or(0);
if let Some(ref id) = current_item_id {
// Drop duplicates/out‑of‑order by sequence_number when available
if let Some(sn) = event.sequence_number {
let last = last_seq_reasoning_summary.entry((id.clone(), out_idx, sum_idx)).or_insert(0);
if *last >= sn { continue; }
*last = sn;
} else {
// Best-effort: drop exact duplicate text for same key when seq is missing
let key = (id.clone(), out_idx, sum_idx);
if last_text_reasoning_summary.get(&key).map_or(false, |prev| prev == &delta) {
continue;
}
last_text_reasoning_summary.insert(key, delta.clone());
}
}
tracing::debug!(
"sse.delta reasoning_summary id={:?} out_idx={} sum_idx={} len={} seq={:?}",
current_item_id, out_idx, sum_idx,
delta.len(),
event.sequence_number
);
let ev = ResponseEvent::ReasoningSummaryDelta {
delta,
item_id: event.item_id.or_else(|| current_item_id.clone()),
sequence_number: event.sequence_number,
output_index: event.output_index,
summary_index: event.summary_index,
};
if tx_event.send(Ok(ev)).await.is_err() {
return;
}
}
}
"response.reasoning_text.delta" => {
if let Some(delta) = event.delta {
if let Some(ref id) = event.item_id {
current_item_id = Some(id.clone());
}
// Compose key using item_id + output_index + content_index
let out_idx: u32 = event.output_index.unwrap_or(0);
let content_idx: u32 = event.content_index.unwrap_or(0);
if let Some(ref id) = current_item_id {
// Drop duplicates/out‑of‑order by sequence_number when available
if let Some(sn) = event.sequence_number {
let last = last_seq_reasoning_content.entry((id.clone(), out_idx, content_idx)).or_insert(0);
if *last >= sn { continue; }
*last = sn;
} else {
// Best-effort: drop exact duplicate text for same key when seq is missing
let key = (id.clone(), out_idx, content_idx);
if last_text_reasoning_content.get(&key).map_or(false, |prev| prev == &delta) {
continue;
}
last_text_reasoning_content.insert(key, delta.clone());
}
}
tracing::debug!(
"sse.delta reasoning_content id={:?} out_idx={} content_idx={} len={} seq={:?}",
current_item_id, out_idx, content_idx,
delta.len(),
event.sequence_number
);
let ev = ResponseEvent::ReasoningContentDelta {
delta,
item_id: event.item_id.or_else(|| current_item_id.clone()),
sequence_number: event.sequence_number,
output_index: event.output_index,
content_index: event.content_index,
};
if tx_event.send(Ok(ev)).await.is_err() {
return;
}
}
}
"response.created" => {
if event.response.is_some() {
let _ = tx_event.send(Ok(ResponseEvent::Created {})).await;
}
}
"response.failed" => {
if let Some(resp_val) = event.response {
response_error = Some(CodexErr::Stream(
"response.failed event received".to_string(),
None,
Some(request_id.clone()),
));
let error = resp_val.get("error");
if let Some(error) = error {
match serde_json::from_value::<Error>(error.clone()) {
Ok(error) => {
if is_quota_exceeded_error(&error) {
response_error = Some(CodexErr::QuotaExceeded);
} else {
let retry_after = try_parse_retry_after(&error, Utc::now());
let message = error.message.unwrap_or_default();
response_error = Some(CodexErr::Stream(
message,
retry_after,
Some(request_id.clone()),
));
}
}
Err(e) => {
debug!("failed to parse ErrorResponse: {e}");
}
}
}
}
}
// Final response completed – includes array of output items & id
"response.completed" => {
if let Some(resp_val) = event.response {
match serde_json::from_value::<ResponseCompleted>(resp_val) {
Ok(r) => {
response_completed = Some(r);
}
Err(e) => {
debug!("failed to parse ResponseCompleted: {e}");
continue;
}
};
};
}
"response.content_part.done"
| "response.function_call_arguments.delta"
| "response.custom_tool_call_input.delta"
| "response.custom_tool_call_input.done" // also emitted as response.output_item.done
| "response.in_progress"
| "response.output_item.added"
| "response.output_text.done" => {
if event.kind == "response.output_item.added" {
if let Some(item) = event.item.as_ref() {
// Detect web_search_call begin and forward a synthetic event upstream.
if let Some(ty) = item.get("type").and_then(|v| v.as_str()) {
if ty == "web_search_call" {
let call_id = item
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ev = ResponseEvent::WebSearchCallBegin { call_id };
if tx_event.send(Ok(ev)).await.is_err() {
return;
}
}
}
}
}
}
"response.reasoning_summary_part.added" => {
// Boundary between reasoning summary sections (e.g., titles).
let event = ResponseEvent::ReasoningSummaryPartAdded;
if tx_event.send(Ok(event)).await.is_err() {
return;
}
}
"response.reasoning_summary_text.done" => {}
_ => {}
}
}
}
/// used in tests to stream from a text SSE file
async fn stream_from_fixture(
path: impl AsRef<Path>,
provider: ModelProviderInfo,
otel_event_manager: Option<OtelEventManager>,
) -> Result<ResponseStream> {
let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent>>(1600);
let f = std::fs::File::open(path.as_ref())?;
let lines = std::io::BufReader::new(f).lines();
// insert \n\n after each line for proper SSE parsing
let mut content = String::new();
for line in lines {
content.push_str(&line?);
content.push_str("\n\n");
}
let rdr = std::io::Cursor::new(content);
let stream = ReaderStream::new(rdr).map_err(CodexErr::Io);
// Create a dummy debug logger for testing
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
tokio::spawn(process_sse(
stream,
tx_event,
provider.stream_idle_timeout(),
debug_logger,
String::new(), // Empty request_id for test fixture
otel_event_manager,
Arc::new(RwLock::new(StreamCheckpoint::default())),
));
Ok(ResponseStream { rx_event })
}
// Note: legacy helpers for parsing Retry-After headers and rate-limit messages
// were removed during merge cleanup. If needed in the future, pick them from
// upstream and integrate with our error handling path.
#[cfg(test)]
mod tests {
use super::*;
use crate::model_provider_info::{ModelProviderInfo, WireApi};
use std::collections::HashMap;
use serde_json::json;
use tokio::sync::mpsc;
use tokio_test::io::Builder as IoBuilder;
use tokio_util::io::ReaderStream;
use chrono::{Duration as ChronoDuration, TimeZone, Utc};
// ────────────────────────────
// Helpers
// ────────────────────────────
#[test]
fn unauthorized_outcome_returns_permanent_error_for_permanent_refresh_failure() {
let err = RefreshTokenError::permanent("token revoked");
let outcome = map_unauthorized_outcome(true, Some(&err))
.expect("should produce CodexErr");
match outcome {
CodexErr::AuthRefreshPermanent(msg) => {
assert!(
msg.contains("token revoked"),
"unexpected message: {}",
msg
);
}
other => panic!("unexpected outcome: {:?}", other),
}
}
#[test]
fn unauthorized_outcome_requires_login_without_auth() {
let outcome = map_unauthorized_outcome(false, None)
.expect("should require login");
match outcome {
CodexErr::AuthRefreshPermanent(msg) => {
assert_eq!(msg, AUTH_REQUIRED_MESSAGE);
}
other => panic!("unexpected outcome: {:?}", other),
}
}
#[test]
fn unauthorized_outcome_allows_retry_for_transient_refresh_error() {
let err = RefreshTokenError::transient("server busy");
assert!(map_unauthorized_outcome(true, Some(&err)).is_none());
}
#[tokio::test]
async fn responses_request_uses_beta_header_for_public_openai() {
let provider = ModelProviderInfo {
name: "openai".to_string(),
base_url: Some("https://api.openai.com/v1".to_string()),
env_key: None,
env_key_instructions: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
openrouter: None,
};
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("client");
let mut builder = provider
.create_request_builder(&client, &None)
.await
.expect("builder");
let has_beta = builder
.try_clone()
.and_then(|b| b.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta {
builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_V1);
}
let request = builder
.try_clone()
.expect("clone request builder")
.build()
.expect("build request");
let header_value = request
.headers()
.get("OpenAI-Beta")
.expect("OpenAI-Beta header present");
assert_eq!(header_value, RESPONSES_BETA_HEADER_V1);
}
#[tokio::test]
async fn responses_request_uses_experimental_for_backend() {
let provider = ModelProviderInfo {
name: "backend".to_string(),
base_url: Some("https://chatgpt.com/backend-api/codex".to_string()),
env_key: None,
env_key_instructions: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
openrouter: None,
};
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("client");
let mut builder = provider
.create_request_builder(&client, &None)
.await
.expect("builder");
let has_beta = builder
.try_clone()
.and_then(|b| b.build().ok())
.map_or(false, |req| req.headers().contains_key("OpenAI-Beta"));
if !has_beta {
builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_EXPERIMENTAL);
}
let request = builder
.try_clone()
.expect("clone request builder")
.build()
.expect("build request");
let header_value = request
.headers()
.get("OpenAI-Beta")
.expect("OpenAI-Beta header present");
assert_eq!(header_value, RESPONSES_BETA_HEADER_EXPERIMENTAL);
}
#[tokio::test]
async fn responses_request_respects_preexisting_beta_header() {
let mut headers = HashMap::new();
headers.insert("OpenAI-Beta".to_string(), "custom".to_string());
let provider = ModelProviderInfo {
name: "custom".to_string(),
base_url: Some("https://api.openai.com/v1".to_string()),
env_key: None,
env_key_instructions: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: Some(headers),
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: None,
stream_idle_timeout_ms: None,
requires_openai_auth: false,
openrouter: None,
};
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("client");
let request = provider
.create_request_builder(&client, &None)
.await
.expect("builder")
.try_clone()
.expect("clone request builder")
.build()
.expect("build request");
let header_value = request
.headers()
.get("OpenAI-Beta")
.expect("OpenAI-Beta header present");
assert_eq!(header_value, "custom");
}
/// Runs the SSE parser on pre-chunked byte slices and returns every event
/// (including any final `Err` from a stream-closure check).
async fn collect_events(
chunks: &[&[u8]],
provider: ModelProviderInfo,
) -> Vec<Result<ResponseEvent>> {
let mut builder = IoBuilder::new();
for chunk in chunks {
builder.read(chunk);
}
let reader = builder.build();
let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let checkpoint = Arc::new(RwLock::new(StreamCheckpoint::default()));
tokio::spawn(process_sse(
stream,
tx,
provider.stream_idle_timeout(),
debug_logger,
String::new(),
None,
checkpoint,
));
let mut events = Vec::new();
while let Some(ev) = rx.recv().await {
events.push(ev);
}
events
}
/// Builds an in-memory SSE stream from JSON fixtures and returns only the
/// successfully parsed events (panics on internal channel errors).
async fn run_sse(
events: Vec<serde_json::Value>,
provider: ModelProviderInfo,
) -> Vec<ResponseEvent> {
let mut body = String::new();
for e in events {
let kind = e
.get("type")
.and_then(|v| v.as_str())
.expect("fixture event missing type");
if e.as_object().map(|o| o.len() == 1).unwrap_or(false) {
body.push_str(&format!("event: {kind}\n\n"));
} else {
body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
}
}
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let checkpoint = Arc::new(RwLock::new(StreamCheckpoint::default()));
tokio::spawn(process_sse(
stream,
tx,
provider.stream_idle_timeout(),
debug_logger,
String::new(),
None,
checkpoint,
));
let mut out = Vec::new();
while let Some(ev) = rx.recv().await {
out.push(ev.expect("channel closed"));
}
out
}
// ────────────────────────────
// Tests from `implement-test-for-responses-api-sse-parser`
// ────────────────────────────
#[tokio::test]
async fn parses_items_and_completed() {
let item1 = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello"}]
}
})
.to_string();
let item2 = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "World"}]
}
})
.to_string();
let completed = json!({
"type": "response.completed",
"response": { "id": "resp1" }
})
.to_string();
let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
let sse3 = format!("event: response.completed\ndata: {completed}\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,
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(), sse2.as_bytes(), sse3.as_bytes()],
provider,
)
.await;
assert_eq!(events.len(), 3);
matches!(
&events[0],
Ok(ResponseEvent::OutputItemDone {
item: ResponseItem::Message { role, .. },
..
}) if role == "assistant"
);
matches!(
&events[1],
Ok(ResponseEvent::OutputItemDone {
item: ResponseItem::Message { role, .. },
..
}) if role == "assistant"
);
match &events[2] {
Ok(ResponseEvent::Completed {
response_id,
token_usage,
}) => {
assert_eq!(response_id, "resp1");
assert!(token_usage.is_none());
}
other => panic!("unexpected third event: {other:?}"),
}
}
#[tokio::test]
async fn error_when_missing_completed() {
let item1 = json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello"}]
}
})
.to_string();
let sse1 = format!("event: response.output_item.done\ndata: {item1}\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,
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(), 2);
matches!(
events[0],
Ok(ResponseEvent::OutputItemDone { .. })
);
match &events[1] {
Err(CodexErr::Stream(msg, _, _)) => {
assert_eq!(msg, "stream closed before response.completed")
}
other => panic!("unexpected second event: {other:?}"),
}
}
#[tokio::test]
async fn error_when_error_event() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#;
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,
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(msg, Some(retry), _)) => {
assert_eq!(
msg,
"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."
);
assert_eq!(retry.delay, Duration::from_secs_f64(11.054));
}
other => panic!("unexpected second event: {other:?}"),
}
}
// ────────────────────────────
// Table-driven test from `main`
// ────────────────────────────
/// Verifies that the adapter produces the right `ResponseEvent` for a
/// variety of incoming `type` values.
#[tokio::test]
async fn table_driven_event_kinds() {
struct TestCase {
name: &'static str,
event: serde_json::Value,
expect_first: fn(&ResponseEvent) -> bool,
expected_len: usize,
}
fn is_created(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::Created)
}
fn is_output(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::OutputItemDone { .. })
}
fn is_completed(ev: &ResponseEvent) -> bool {
matches!(ev, ResponseEvent::Completed { .. })
}
let completed = json!({
"type": "response.completed",
"response": {
"id": "c",
"usage": {
"input_tokens": 0,
"input_tokens_details": null,
"output_tokens": 0,
"output_tokens_details": null,
"total_tokens": 0
},
"output": []
}
});
let cases = vec![
TestCase {
name: "created",
event: json!({"type": "response.created", "response": {}}),
expect_first: is_created,
expected_len: 2,
},
TestCase {
name: "output_item.done",
event: json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"content": [
{"type": "output_text", "text": "hi"}
]
}
}),
expect_first: is_output,
expected_len: 2,
},
TestCase {
name: "unknown",
event: json!({"type": "response.new_tool_event"}),
expect_first: is_completed,
expected_len: 1,
},
];
for case in cases {
let mut evs = vec![case.event];
evs.push(completed.clone());
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,
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 out = run_sse(evs, provider).await;
assert_eq!(out.len(), case.expected_len, "case {}", case.name);
assert!(
(case.expect_first)(&out[0]),
"first event mismatch in case {}",
case.name
);
}
}
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2025, 11, 7, 12, 0, 0).unwrap()
}
#[test]
fn test_try_parse_retry_after_ms() {
let now = fixed_now();
let err = Error {
r#type: None,
message: Some("Rate limit reached for gpt-5.1 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
code: Some("rate_limit_exceeded".to_string()),
param: None,
plan_type: None,
resets_in_seconds: None,
};
let retry_after = try_parse_retry_after(&err, now).expect("retry");
assert_eq!(retry_after.delay, Duration::from_millis(28));
assert!(retry_after.resume_at >= now);
}
#[test]
fn test_try_parse_retry_after_seconds() {
let now = fixed_now();
let err = Error {
r#type: None,
message: Some("Rate limit reached for gpt-5.1 in organization <ORG> on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
code: Some("rate_limit_exceeded".to_string()),
param: None,
plan_type: None,
resets_in_seconds: None,
};
let retry_after = try_parse_retry_after(&err, now).expect("retry");
assert_eq!(retry_after.delay, Duration::from_secs_f64(1.898));
}
#[test]
fn test_try_parse_retry_after_azure() {
let now = fixed_now();
let err = Error {
r#type: None,
message: Some("Rate limit exceeded. Retry after 35 seconds.".to_string()),
code: Some("rate_limit_exceeded".to_string()),
param: None,
plan_type: None,
resets_in_seconds: None,
};
let retry_after = try_parse_retry_after(&err, now).expect("retry");
assert_eq!(retry_after.delay, Duration::from_secs(35));
}
#[test]
fn test_try_parse_retry_after_none_when_missing() {
let now = fixed_now();
let err = Error {
r#type: None,
message: Some("Some other error".to_string()),
code: None,
param: None,
plan_type: None,
resets_in_seconds: None,
};
assert!(try_parse_retry_after(&err, now).is_none());
}
#[test]
fn parse_retry_after_header_parses_seconds() {
let now = fixed_now();
let retry = parse_retry_after_header("42", now).expect("header");
assert_eq!(retry.delay, Duration::from_secs(42));
assert_eq!(retry.resume_at, now + ChronoDuration::seconds(42));
}
#[test]
fn parse_retry_after_header_parses_rfc7231_date() {
let now = Utc.with_ymd_and_hms(1994, 11, 15, 8, 0, 0).unwrap();
let retry = parse_retry_after_header("Tue, 15 Nov 1994 08:12:31 GMT", now).expect("header");
assert_eq!(
retry.resume_at,
Utc.with_ymd_and_hms(1994, 11, 15, 8, 12, 31).unwrap()
);
}
#[test]
fn parse_retry_after_header_clamps_past_date() {
let now = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
let retry = parse_retry_after_header("Tue, 15 Nov 1994 08:12:31 GMT", now).expect("header");
assert_eq!(retry.delay, Duration::ZERO);
assert_eq!(retry.resume_at, now);
}
#[test]
fn parse_retry_after_header_strips_wrappers() {
let now = fixed_now();
let retry = parse_retry_after_header(" \"17\" ", now).expect("header");
assert_eq!(retry.delay, Duration::from_secs(17));
}
#[test]
fn retry_after_prefers_header_over_body_hint() {
let now = fixed_now();
let header_retry = parse_retry_after_header("5", now);
let mut chosen = header_retry.clone();
if chosen.is_none() {
let err = Error {
r#type: None,
message: Some(
"Rate limit reached for gpt-5.1. Please try again in 30 seconds.".to_string(),
),
code: Some("rate_limit_exceeded".to_string()),
param: None,
plan_type: None,
resets_in_seconds: None,
};
chosen = try_parse_retry_after(&err, now);
}
let retry = chosen.expect("retry");
assert_eq!(retry.delay, Duration::from_secs(5));
}
#[test]
fn parse_retry_after_header_handles_timezones() {
let now = Utc.with_ymd_and_hms(2025, 3, 9, 5, 0, 0).unwrap();
let retry = parse_retry_after_header("Sun, 09 Mar 2025 01:30:00 -0500", now).expect("header");
assert_eq!(
retry.resume_at,
Utc.with_ymd_and_hms(2025, 3, 9, 6, 30, 0).unwrap()
);
}
#[test]
fn quota_error_detected_for_common_statuses() {
let error = Error {
r#type: Some("invalid_request_error".to_string()),
message: Some("You exceeded your current quota".to_string()),
code: Some("insufficient_quota".to_string()),
param: None,
plan_type: None,
resets_in_seconds: None,
};
for status in [
StatusCode::BAD_REQUEST,
StatusCode::FORBIDDEN,
StatusCode::TOO_MANY_REQUESTS,
] {
assert!(is_quota_exceeded_http_error(status, &error), "status {status} should be fatal");
}
assert!(
!is_quota_exceeded_http_error(StatusCode::INTERNAL_SERVER_ERROR, &error),
"server errors should not map to quota handling"
);
}
#[test]
fn malformed_quota_body_is_ignored() {
let error = Error {
r#type: Some("invalid_request_error".to_string()),
message: Some("missing code".to_string()),
code: None,
param: None,
plan_type: None,
resets_in_seconds: None,
};
assert!(!is_quota_exceeded_http_error(StatusCode::BAD_REQUEST, &error));
}
#[test]
fn reasoning_summary_rejection_is_detected() {
let error_with_param = Error {
r#type: Some("invalid_request_error".to_string()),
message: Some("Your organization must be verified to generate reasoning summaries.".to_string()),
code: Some("unsupported_value".to_string()),
param: Some("reasoning.summary".to_string()),
plan_type: None,
resets_in_seconds: None,
};
assert!(is_reasoning_summary_rejected(&error_with_param));
let error_by_message = Error {
r#type: Some("invalid_request_error".to_string()),
message: Some("Your organization must be verified to generate reasoning summaries. If you just verified, it can take up to 15 minutes for access to propagate.".to_string()),
code: Some("unsupported_value".to_string()),
param: None,
plan_type: None,
resets_in_seconds: None,
};
assert!(is_reasoning_summary_rejected(&error_by_message));
// An error with param="reasoning.summary" but a different error code
// (e.g., rate_limit_exceeded) should NOT be treated as a rejection.
let rate_limit_error = Error {
r#type: Some("rate_limit_error".to_string()),
message: Some("Rate limit reached for reasoning.summary requests.".to_string()),
code: Some("rate_limit_exceeded".to_string()),
param: Some("reasoning.summary".to_string()),
plan_type: None,
resets_in_seconds: None,
};
assert!(!is_reasoning_summary_rejected(&rate_limit_error));
}
#[tokio::test]
async fn quota_exceeded_error_is_fatal() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details."},"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,
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::QuotaExceeded) => {}
other => panic!("unexpected quota event: {other:?}"),
}
}
}