refactor(agent/chat_completion_helpers): fold interruptible_api_call into _NonStreamRequest; compact SSE error parsing helpers

This commit is contained in:
Teknium
2026-09-02 18:21:41 -07:00
parent 113f04616b
commit bcf6fee21d
+342 -439
View File
@@ -133,16 +133,16 @@ class ProviderStreamError(Exception):
def _format_message(self) -> str:
error_obj = self.body.get("error", {}) if isinstance(self.body, dict) else {}
code = error_obj.get("code") if isinstance(error_obj, dict) else None
message = error_obj.get("message") if isinstance(error_obj, dict) else None
if not isinstance(error_obj, dict):
error_obj = {}
parts = ["Provider stream returned an error event"]
if self.status_code:
parts.append(f"HTTP {self.status_code}")
if code:
parts.append(str(code))
if error_obj.get("code"):
parts.append(str(error_obj["code"]))
text = " - ".join(parts)
if message:
text += f": {message}"
if error_obj.get("message"):
text += f": {error_obj['message']}"
return text
@@ -164,21 +164,11 @@ def _status_code_from_payload(payload: Any) -> Optional[int]:
if not isinstance(payload, dict):
return None
candidates = [
payload.get("status_code"),
payload.get("status"),
payload.get("http_status"),
]
candidates = [payload.get(k) for k in ("status_code", "status", "http_status")]
error_obj = payload.get("error")
if isinstance(error_obj, dict):
candidates.extend([
error_obj.get("status_code"),
error_obj.get("status"),
error_obj.get("http_status"),
error_obj.get("code"),
])
candidates.extend(error_obj.get(k) for k in ("status_code", "status", "http_status", "code"))
candidates.append(payload.get("code"))
for candidate in candidates:
status_code = _status_code_from_value(candidate)
if status_code is not None:
@@ -202,36 +192,21 @@ def _parse_provider_sse_events(text: str) -> list[dict]:
events: list[dict] = []
current = {"event": None, "data": [], "comments": [], "fields": {}}
def _has_event_data(event: dict) -> bool:
return bool(
event.get("event")
or event.get("data")
or event.get("comments")
or event.get("fields")
)
def _flush_current():
nonlocal current
if _has_event_data(current):
data_text = "\n".join(current["data"])
status_candidates = list(current["comments"])
for key in ("status", "status_code", "http_status"):
if key in current["fields"]:
status_candidates.append(current["fields"][key])
if any(current.values()):
status_candidates = list(current["comments"]) + [
current["fields"][key]
for key in ("status", "status_code", "http_status")
if key in current["fields"]
]
events.append({
"event": current["event"],
"data": data_text,
"data": "\n".join(current["data"]),
"comments": list(current["comments"]),
"fields": dict(current["fields"]),
"status_code": next(
(
status
for status in (
_status_code_from_value(value)
for value in status_candidates
)
if status is not None
),
(s for s in map(_status_code_from_value, status_candidates) if s is not None),
None,
),
})
@@ -350,13 +325,11 @@ def _payload_has_error_shape(payload: Any) -> bool:
return False
if isinstance(payload.get("error"), (dict, str)):
return True
if payload.get("message") and (
return bool(payload.get("message")) and bool(
payload.get("code")
or payload.get("error_code")
or _status_code_from_payload(payload) is not None
):
return True
return False
)
def _provider_stream_text_may_be_sse(text: str) -> bool:
@@ -409,28 +382,9 @@ def _provider_stream_error_from_text(
if not has_error_finish:
return None
for event in _parse_provider_sse_events(text):
event_name = str(event.get("event") or "").strip().lower()
payload = _json_object_from_text(event.get("data") or "") or {}
status_code = event.get("status_code") or _status_code_from_payload(payload)
is_error_event = event_name == "error"
is_http_error = status_code is not None and status_code >= 400
is_error_payload = _payload_has_error_shape(payload)
is_structured_error_event = is_error_event and (
has_error_finish or is_http_error or is_error_payload
)
is_bare_error_finish_payload = (
not is_error_event and has_error_finish and is_error_payload
)
headers = getattr(response, "headers", None) if response is not None else None
if not (
is_http_error
or is_structured_error_event
or is_bare_error_finish_payload
):
continue
headers = getattr(response, "headers", None) if response is not None else None
def _error(payload: dict, status_code: Optional[int]) -> ProviderStreamError:
return ProviderStreamError(
status_code=status_code,
body=_provider_error_body(payload, status_code),
@@ -438,26 +392,23 @@ def _provider_stream_error_from_text(
headers=headers,
)
for event in _parse_provider_sse_events(text):
is_error_event = str(event.get("event") or "").strip().lower() == "error"
payload = _json_object_from_text(event.get("data") or "") or {}
status_code = event.get("status_code") or _status_code_from_payload(payload)
is_http_error = status_code is not None and status_code >= 400
is_error_payload = _payload_has_error_shape(payload)
# has_error_finish is True here, so an error event always qualifies;
# a non-error event needs an error-shaped payload or an HTTP error code.
if is_http_error or is_error_event or is_error_payload:
return _error(payload, status_code)
payload = _json_object_from_text(text)
if payload is not None:
status_code = _status_code_from_payload(payload)
if has_error_finish or (status_code is not None and status_code >= 400):
headers = getattr(response, "headers", None) if response is not None else None
return ProviderStreamError(
status_code=status_code,
body=_provider_error_body(payload, status_code),
raw_text=text,
headers=headers,
)
return _error(payload, _status_code_from_payload(payload))
if has_error_finish and text.strip():
headers = getattr(response, "headers", None) if response is not None else None
return ProviderStreamError(
status_code=None,
body=_provider_error_body({}, None),
raw_text=text,
headers=headers,
)
if text.strip():
return _error({}, None)
return None
@@ -481,37 +432,21 @@ def estimate_request_context_tokens(api_payload: Any) -> int:
def _chars(value: Any) -> int:
if value is None:
return 0
if isinstance(value, str):
return len(value)
return len(str(value))
def _message_chars(messages: Any) -> int:
if not isinstance(messages, list):
return _chars(messages)
return sum(_chars(item) for item in messages)
return len(value if isinstance(value, str) else str(value))
if isinstance(api_payload, list):
return _message_chars(api_payload) // 4
if isinstance(api_payload, dict):
messages = api_payload.get("messages")
if isinstance(messages, list):
total_chars = _message_chars(messages)
if "tools" in api_payload:
total_chars += _chars(api_payload.get("tools"))
return total_chars // 4
if "input" in api_payload:
total_chars = (
_chars(api_payload.get("input"))
+ _chars(api_payload.get("instructions"))
+ _chars(api_payload.get("tools"))
)
return total_chars // 4
return sum(_chars(value) for value in api_payload.values()) // 4
return _chars(api_payload) // 4
return sum(_chars(item) for item in api_payload) // 4
if not isinstance(api_payload, dict):
return _chars(api_payload) // 4
messages = api_payload.get("messages")
if isinstance(messages, list):
total_chars = sum(_chars(item) for item in messages)
if "tools" in api_payload:
total_chars += _chars(api_payload.get("tools"))
return total_chars // 4
if "input" in api_payload:
return sum(_chars(api_payload.get(k)) for k in ("input", "instructions", "tools")) // 4
return sum(_chars(value) for value in api_payload.values()) // 4
def _is_openai_codex_backend(agent) -> bool:
@@ -530,12 +465,9 @@ def openai_codex_stale_timeout_floor(est_tokens: int) -> float:
floor engages above 10k estimated tokens so those gateway-scale payloads
are covered; smaller requests keep the generic default.
"""
if est_tokens > 100_000:
return 1200.0
if est_tokens > 50_000:
return 900.0
if est_tokens > 10_000:
return 600.0
for threshold, floor in ((100_000, 1200.0), (50_000, 900.0), (10_000, 600.0)):
if est_tokens > threshold:
return floor
return 0.0
@@ -559,19 +491,16 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
"""Build the validated provider-routing object shared by request paths."""
preferences: Dict[str, Any] = {}
if agent.providers_allowed:
preferences["only"] = agent.providers_allowed
if agent.providers_ignored:
preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
preferences["order"] = agent.providers_order
provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if provider_sort:
preferences["sort"] = provider_sort
if agent.provider_require_parameters:
preferences["require_parameters"] = True
if agent.provider_data_collection:
preferences["data_collection"] = agent.provider_data_collection
for key, value in (
("only", agent.providers_allowed),
("ignore", agent.providers_ignored),
("order", agent.providers_order),
("sort", _validated_openrouter_provider_sort(agent.provider_sort)),
("require_parameters", True if agent.provider_require_parameters else None),
("data_collection", agent.provider_data_collection),
):
if value:
preferences[key] = value
return preferences
@@ -628,45 +557,32 @@ def _env_float(name: str, default: float) -> float:
def _estimate_chunk_bytes(chunk: Any) -> int:
"""Cheap per-chunk size estimate for the stream diagnostic counters.
The previous implementation used ``len(repr(chunk))`` — a full recursive
repr of a pydantic model on EVERY streaming chunk (5.5-8.8 µs each,
~20-30 ms of pure CPU on a 3,000-chunk response, in the hottest loop in
the agent). The counter only feeds a retry-diagnostic log line, so an
estimate based on the delta payload lengths is plenty (2.1-2.4 µs, ~3x
cheaper, and independent of model/pydantic field count). Chat Completions
chunks are sized from their delta content/reasoning/tool-argument strings
plus a small framing constant; anything shape-unknown (Anthropic events,
stub providers) falls back to a flat constant so `bytes` stays monotonic
and roughly proportional to traffic.
Sized from delta content/reasoning/tool-argument string lengths plus a
framing floor — ~3x cheaper than ``len(repr(chunk))`` on a pydantic model
in the hottest loop in the agent. The counter only feeds a retry-diagnostic
log line, so unknown shapes (Anthropic events, stubs) just keep the floor.
"""
size = 40 # SSE/JSON framing floor per chunk
def _add(obj, *attrs):
nonlocal size
for attr in attrs:
v = getattr(obj, attr, None)
if isinstance(v, str):
size += len(v)
try:
choices = getattr(chunk, "choices", None)
if choices:
delta = getattr(choices[0], "delta", None)
if delta is not None:
for attr in ("content", "reasoning_content", "reasoning"):
v = getattr(delta, attr, None)
if isinstance(v, str):
size += len(v)
tool_calls = getattr(delta, "tool_calls", None)
if tool_calls:
for tc in tool_calls:
fn = getattr(tc, "function", None)
if fn is not None:
args = getattr(fn, "arguments", None)
if isinstance(args, str):
size += len(args)
name = getattr(fn, "name", None)
if isinstance(name, str):
size += len(name)
_add(delta, "content", "reasoning_content", "reasoning")
for tc in getattr(delta, "tool_calls", None) or ():
fn = getattr(tc, "function", None)
if fn is not None:
_add(fn, "arguments", "name")
else:
# Non-chat-completions shapes (Anthropic events etc.): try the
# common text fields, else keep the framing floor.
for attr in ("text", "partial_json"):
v = getattr(getattr(chunk, "delta", None), attr, None)
if isinstance(v, str):
size += len(v)
_add(getattr(chunk, "delta", None), "text", "partial_json")
except Exception:
pass
return size
@@ -698,15 +614,12 @@ def _codex_wait_notice_recovery(
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
# A session wedged against an unresponsive provider hits the stale detector
# on every call and loops forever (observed: 494 consecutive failures over
# 3+ days, each burning the full stale timeout × retries with no response).
# The agent carries ``_consecutive_stale_streams``: incremented on every
# stale kill, reset only when a call actually completes (or when the
# provider is swapped — switch_model / try_activate_fallback /
# restore_primary_runtime — since the streak measured the OLD provider).
# Past the give-up threshold, calls abort immediately with an actionable
# error instead of re-waiting out the stale timeout.
# A session wedged against an unresponsive provider would otherwise hit the
# stale detector on every call forever. ``agent._consecutive_stale_streams``
# is bumped on every stale kill and reset only when a call completes or the
# provider is swapped (switch_model / try_activate_fallback /
# restore_primary_runtime — the streak measured the OLD provider). Past the
# give-up threshold, calls abort immediately with an actionable error.
def _stale_streak(agent) -> int:
try:
@@ -1575,311 +1488,301 @@ def _codex_silent_hang_hint(agent, api_kwargs: dict) -> Optional[str]:
return None
def interruptible_api_call(agent, api_kwargs: dict):
class _NonStreamRequest:
"""One non-streaming request on a worker thread, polled by the caller.
State shared between the worker (``_call``) and the poll loop lives on the
instance; ``_abort_request`` may run from the poll (stranger) thread.
"""
Run the API call in a background thread so the main conversation loop
can detect interrupts without waiting for the full HTTP round-trip.
Each worker thread gets its own OpenAI client instance. Interrupts only
close that worker-local client, so retries and other requests never
inherit a closed transport.
def __init__(self, agent, api_kwargs: dict):
self.agent = agent
self.api_kwargs = api_kwargs
self.result = {"response": None, "error": None}
self.clients = _RequestClientRegistry(agent)
# Request-local cancel flag: agent._interrupt_requested is cleared at turn
# boundaries but this daemon worker can outlive the turn, so the worker
# needs to know THIS request was force-closed and not surface the
# resulting transport error as a network bug (#6600).
self.cancelled = False
# Codex retirement token: the worker checks
# ``agent._active_codex_stream_request_token`` to know it still owns the
# turn; a watchdog kill clears it so a worker still draining SSE raises
# instead of returning partial output as "completed"
# (run_codex_stream._request_is_current). ``codex_retired`` is the
# request-local mirror used to swallow our own force-close error.
self.codex_token = object() if agent.api_mode == "codex_responses" else None
self.codex_retired = False
self.wd = _resolve_nonstream_watchdogs(agent, api_kwargs)
self.call_start = time.time()
self.thread = None
Includes a stale-call detector: if no response arrives within the
configured timeout, the connection is killed and an error raised so
the main retry loop can try again with backoff / credential rotation /
provider fallback.
"""
# Cron and other non-interactive, nested-pool contexts must not spawn the
# interrupt worker — it wedges before the socket opens on the 2nd+ call
# (#62151). Run inline instead. See should_use_direct_api_call.
if should_use_direct_api_call(agent):
return direct_api_call(agent, api_kwargs)
def _install_codex_request_token(self) -> None:
# Already retired before the worker got going — do not re-publish.
if self.codex_token is not None and not self.codex_retired:
self.agent._active_codex_stream_request_token = self.codex_token
result = {"response": None, "error": None}
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
# of the guard in interruptible_streaming_api_call. Quiet-mode /
# subagent / no-stream-consumer sessions take THIS path, and a wedged
# unattended session here has the same infinite stale-retry class.
_check_stale_giveup(agent)
_clients = _RequestClientRegistry(agent)
# Request-local cancel flag: agent._interrupt_requested is cleared at turn
# boundaries but this daemon worker can outlive the turn, so the worker
# needs to know THIS request was force-closed and not surface the
# resulting transport error as a network bug (#6600).
_request_cancelled = {"value": False}
# Codex retirement token: the worker checks
# ``agent._active_codex_stream_request_token`` to know it still owns the
# turn; a watchdog kill clears it so a worker still draining SSE raises
# instead of returning partial output as "completed"
# (run_codex_stream._request_is_current). ``_codex_request_retired`` is
# the request-local mirror used to swallow our own force-close error.
_codex_request_token = object() if agent.api_mode == "codex_responses" else None
_codex_request_retired = {"value": False}
def _install_codex_request_token() -> None:
if _codex_request_token is None:
def _retire_codex_request_token(self) -> None:
if self.codex_token is None:
return
if _codex_request_retired["value"]:
# Already retired before the worker got going — do not re-publish.
return
agent._active_codex_stream_request_token = _codex_request_token
self.codex_retired = True
if getattr(self.agent, "_active_codex_stream_request_token", None) is self.codex_token:
self.agent._active_codex_stream_request_token = None
def _retire_codex_request_token() -> None:
if _codex_request_token is None:
return
_codex_request_retired["value"] = True
if (
getattr(agent, "_active_codex_stream_request_token", None)
is _codex_request_token
):
agent._active_codex_stream_request_token = None
def _make_client(self, reason: str, kind: str = "openai"):
# Per-request clients are registered with the abort machinery so the
# watchdogs force-close the worker's connection, never the shared
# client (#67142).
if kind == "anthropic_messages":
client = self.agent._create_request_anthropic_client(reason=reason)
else:
client = self.agent._create_request_openai_client(
reason=reason, api_kwargs=self.api_kwargs
)
return self.clients.set_client(client, kind=kind)
def _call():
def _call(self):
try:
_install_codex_request_token()
# Per-request clients are registered with the abort machinery so
# the watchdogs force-close the worker's connection, never the
# shared client (#67142).
result["response"] = _dispatch_nonstreaming_api_request(
agent,
api_kwargs,
make_client=lambda reason, kind="openai": _clients.set_client(
agent._create_request_anthropic_client(reason=reason)
if kind == "anthropic_messages"
else agent._create_request_openai_client(
reason=reason, api_kwargs=api_kwargs
),
kind=kind,
),
self._install_codex_request_token()
self.result["response"] = _dispatch_nonstreaming_api_request(
self.agent, self.api_kwargs, make_client=self._make_client
)
except Exception as e:
# Our own force-close caused this error: swallow it, the main
# thread raises InterruptedError (#6600). Retirement logs at info
# (a watchdog discarded output the provider already sent — what an
# operator debugging a truncated reply needs); cancellation at debug.
if _request_cancelled["value"] or _codex_request_retired["value"]:
if _codex_request_retired["value"]:
logger.info(
"Codex worker caught %s after request retirement — "
"discarding the stale partial instead of surfacing it "
"as a completed response. %s",
type(e).__name__,
agent._client_log_context(),
)
else:
logger.debug(
"Non-streaming worker caught %s after request "
"cancellation — exiting without surfacing a network "
"error.",
type(e).__name__,
)
if self.codex_retired:
logger.info(
"Codex worker caught %s after request retirement — "
"discarding the stale partial instead of surfacing it "
"as a completed response. %s",
type(e).__name__,
self.agent._client_log_context(),
)
return
result["error"] = e
if self.cancelled:
logger.debug(
"Non-streaming worker caught %s after request "
"cancellation — exiting without surfacing a network "
"error.",
type(e).__name__,
)
return
self.result["error"] = e
finally:
# Retire first: close_once can raise, and a leaked token would let
# a later worker mistake itself for the owning attempt.
_retire_codex_request_token()
self._retire_codex_request_token()
# Reuse reason only on a clean response; error or cancel-swallow
# really closes so the next attempt builds a fresh pool.
_clients.close_once(
self.clients.close_once(
"request_complete"
if result["response"] is not None
if self.result["response"] is not None
else "request_error_cleanup"
)
wd = _resolve_nonstream_watchdogs(agent, api_kwargs)
_stale_timeout = wd.stale_timeout
_codex_watchdog_enabled = wd.codex
_est_tokens_for_codex_watchdog = wd.est_tokens
_ttfb_enabled, _ttfb_timeout = wd.ttfb_enabled, wd.ttfb_timeout
_codex_idle_enabled, _codex_idle_timeout = wd.idle_enabled, wd.idle_timeout
if _codex_watchdog_enabled:
# Reset before the worker starts so a marker left over from a previous
# call on this agent can't be misread as first-byte for this one.
agent._codex_stream_last_event_ts = None
agent._codex_stream_last_progress_ts = None
_call_start = time.time()
agent._touch_activity("waiting for non-streaming API response")
def _abort_request(reason: str) -> None:
"""Watchdog/interrupt kill: abort the request client and retire the codex
token; the worker sees its own forced close via the cancel flags."""
def _abort_request(self, reason: str) -> None:
"""Watchdog/interrupt kill: abort the request client (kind-aware, #67142)
and retire the codex token; the worker sees its own forced close via
the cancel flags."""
try:
# #67142: routes by client kind — anthropic aborts the request-local
# client's sockets from this poll (stranger) thread instead of
# closing the shared _anthropic_client.
_clients.close_once(reason)
self.clients.close_once(reason)
except Exception:
pass
_retire_codex_request_token()
self._retire_codex_request_token()
def _await_worker_after_kill(timeout_message: str) -> None:
def _await_worker_after_kill(self, timeout_message: str) -> None:
# Wait briefly for the worker to notice the closed connection.
t.join(timeout=2.0)
if result["error"] is None and result["response"] is None:
result["error"] = TimeoutError(timeout_message)
self.thread.join(timeout=2.0)
if self.result["error"] is None and self.result["response"] is None:
self.result["error"] = TimeoutError(timeout_message)
t = threading.Thread(target=_context_thread_target(_call), daemon=True)
t.start()
_poll_count = 0
while t.is_alive():
t.join(timeout=0.3)
_poll_count += 1
def _model(self) -> str:
return self.api_kwargs.get("model", "unknown")
# Every ~30s: gateway inactivity heartbeat + rewrite the status line
# so users see WHAT the wait is (the "infinite thinking" complaint).
if _poll_count % 100 == 0: # 100 × 0.3s = 30s
_elapsed = time.time() - _call_start
try:
_recovery = _codex_wait_notice_recovery(
stale_timeout=_stale_timeout,
ttfb_enabled=_ttfb_enabled,
ttfb_timeout=_ttfb_timeout,
last_event_ts=getattr(
agent, "_codex_stream_last_event_ts", None
),
call_start=_call_start,
idle_enabled=_codex_idle_enabled,
idle_timeout=_codex_idle_timeout,
elapsed=_elapsed,
)
agent._emit_wait_notice(
f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — "
f"{int(_elapsed)}s with no response yet (provider may be slow "
f"or overloaded{_recovery})"
)
except Exception:
logger.debug("wait-notice construction failed", exc_info=True)
def _emit_wait_notice(self, elapsed: float) -> None:
wd = self.wd
try:
recovery = _codex_wait_notice_recovery(
stale_timeout=wd.stale_timeout,
ttfb_enabled=wd.ttfb_enabled,
ttfb_timeout=wd.ttfb_timeout,
last_event_ts=getattr(self.agent, "_codex_stream_last_event_ts", None),
call_start=self.call_start,
idle_enabled=wd.idle_enabled,
idle_timeout=wd.idle_timeout,
elapsed=elapsed,
)
self.agent._emit_wait_notice(
f"⏳ waiting on {self.api_kwargs.get('model', 'the provider')} — "
f"{int(elapsed)}s with no response yet (provider may be slow "
f"or overloaded{recovery})"
)
except Exception:
logger.debug("wait-notice construction failed", exc_info=True)
_elapsed = time.time() - _call_start
def _ttfb_kill(self, elapsed: float) -> None:
"""No Codex event past the first-byte cutoff — kill so the retry loop
reconnects instead of waiting out the stale timeout."""
agent, wd = self.agent, self.wd
silent_hint = _codex_silent_hang_hint(agent, self.api_kwargs)
logger.warning(
"Codex stream produced no bytes within TTFB cutoff "
"(%.0fs > %.0fs, model=%s). Backend accepted the connection "
"but sent no stream events. Killing connection so the retry "
"loop can reconnect.",
elapsed, wd.ttfb_timeout, self._model(),
)
agent._buffer_status(
f"⚠️ No first byte from provider in {int(elapsed)}s "
f"(codex stream, model: {self._model()}). "
f"Reconnecting." + (f" {silent_hint}" if silent_hint else "")
)
self._abort_request("codex_ttfb_kill")
agent._emit_wait_notice(
f"⚠ no response from provider in {int(elapsed)}s — "
f"reconnecting..."
)
agent._touch_activity(
f"codex stream killed after {int(elapsed)}s with no first byte"
)
self._await_worker_after_kill(
f"Codex stream produced no bytes within {int(elapsed)}s "
f"(TTFB threshold: {int(wd.ttfb_timeout)}s)"
+ (f". {silent_hint}" if silent_hint else "")
)
# TTFB detector: no Codex event past the first-byte cutoff — kill so
# the retry loop reconnects instead of waiting out the stale timeout.
if (
_ttfb_enabled
and _elapsed > _ttfb_timeout
and getattr(agent, "_codex_stream_last_event_ts", None) is None
):
_silent_hint = _codex_silent_hang_hint(agent, api_kwargs)
logger.warning(
"Codex stream produced no bytes within TTFB cutoff "
"(%.0fs > %.0fs, model=%s). Backend accepted the connection "
"but sent no stream events. Killing connection so the retry "
"loop can reconnect.",
_elapsed, _ttfb_timeout, api_kwargs.get("model", "unknown"),
)
if _silent_hint:
agent._buffer_status(
f"⚠️ No first byte from provider in {int(_elapsed)}s "
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
f"Reconnecting. {_silent_hint}"
)
else:
agent._buffer_status(
f"⚠️ No first byte from provider in {int(_elapsed)}s "
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
f"Reconnecting."
)
_abort_request("codex_ttfb_kill")
agent._emit_wait_notice(
f"⚠ no response from provider in {int(_elapsed)}s — "
f"reconnecting..."
)
agent._touch_activity(
f"codex stream killed after {int(_elapsed)}s with no first byte"
)
_await_worker_after_kill(
f"Codex stream produced no bytes within {int(_elapsed)}s "
f"(TTFB threshold: {int(_ttfb_timeout)}s)"
+ (f". {_silent_hint}" if _silent_hint else "")
)
break
def _idle_kill(self, event_stale_elapsed: float) -> None:
"""First byte arrived, then SSE events stopped (keepalive/in_progress
frames refresh the timestamp and don't count)."""
agent, wd = self.agent, self.wd
logger.warning(
"Codex stream produced no SSE events for %.0fs after first byte "
"(threshold %.0fs, model=%s, context=~%s tokens). Killing "
"connection so the retry loop can reconnect.",
event_stale_elapsed,
wd.idle_timeout,
self._model(),
f"{wd.est_tokens:,}",
)
agent._buffer_status(
f"⚠️ Codex stream sent no events for {int(event_stale_elapsed)}s "
f"after first byte (model: {self._model()}). "
f"Reconnecting."
)
self._abort_request("codex_stream_idle_kill")
agent._touch_activity(
f"codex stream killed after {int(event_stale_elapsed)}s with no SSE events"
)
self._await_worker_after_kill(
f"Codex stream produced no SSE events for {int(event_stale_elapsed)}s "
f"after first byte (threshold: {int(wd.idle_timeout)}s)"
)
# Stream-idle detector: first byte arrived, then events stopped
# (keepalive/in_progress frames refresh the timestamp and don't count).
_last_codex_event_ts = getattr(agent, "_codex_stream_last_event_ts", None)
if (
_codex_idle_enabled
and _last_codex_event_ts is not None
and (time.time() - _last_codex_event_ts) > _codex_idle_timeout
):
_event_stale_elapsed = time.time() - _last_codex_event_ts
logger.warning(
"Codex stream produced no SSE events for %.0fs after first byte "
"(threshold %.0fs, model=%s, context=~%s tokens). Killing "
"connection so the retry loop can reconnect.",
_event_stale_elapsed,
_codex_idle_timeout,
api_kwargs.get("model", "unknown"),
f"{_est_tokens_for_codex_watchdog:,}",
)
agent._buffer_status(
f"⚠️ Codex stream sent no events for {int(_event_stale_elapsed)}s "
f"after first byte (model: {api_kwargs.get('model', 'unknown')}). "
f"Reconnecting."
)
_abort_request("codex_stream_idle_kill")
agent._touch_activity(
f"codex stream killed after {int(_event_stale_elapsed)}s with no SSE events"
)
_await_worker_after_kill(
f"Codex stream produced no SSE events for {int(_event_stale_elapsed)}s "
f"after first byte (threshold: {int(_codex_idle_timeout)}s)"
)
break
def _stale_kill(self, elapsed: float) -> None:
"""No response within the stale timeout: kill and count toward the
circuit breaker (#58962, see ``_stale_streak``)."""
agent, wd = self.agent, self.wd
silent_hint = _codex_silent_hang_hint(agent, self.api_kwargs)
_report_stale_nonstream_kill(
agent, self.api_kwargs, elapsed, wd.stale_timeout, hint=silent_hint
)
self._abort_request("stale_call_kill")
_bump_stale_streak(agent)
_touch_stale_kill_activity(agent, elapsed)
self._await_worker_after_kill(
f"Non-streaming API call timed out after {int(elapsed)}s "
f"with no response (threshold: {int(wd.stale_timeout)}s)"
+ (f". {silent_hint}" if silent_hint else "")
)
# Stale-call detector: kill the connection if no response
# arrives within the configured timeout.
if _elapsed > _stale_timeout:
_silent_hint = _codex_silent_hang_hint(agent, api_kwargs)
_report_stale_nonstream_kill(
agent, api_kwargs, _elapsed, _stale_timeout, hint=_silent_hint
)
_abort_request("stale_call_kill")
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
_touch_stale_kill_activity(agent, _elapsed)
_await_worker_after_kill(
f"Non-streaming API call timed out after {int(_elapsed)}s "
f"with no response (threshold: {int(_stale_timeout)}s)"
+ (f". {_silent_hint}" if _silent_hint else "")
)
break
def _interrupt(self, elapsed: float) -> None:
agent = self.agent
_record_interrupted_provider_wait(
agent,
elapsed,
response_started=(
self.wd.codex
and getattr(agent, "_codex_stream_last_event_ts", None) is not None
),
)
# Mark cancelled BEFORE force-closing so the worker treats the
# transport error as a cancel (#6600). Never close the shared client:
# releasing a TLS FD mid-SSL-BIO corrupted an unrelated SQLite DB
# (#67142). Then let the worker unwind Relay scopes before raising
# (#81521).
self.cancelled = True
logger.debug(
"Force-closing httpx client due to interrupt (not a network error)."
)
self._abort_request("interrupt_abort")
_join_worker_for_relay_teardown(self.thread, label="Non-streaming")
raise InterruptedError("Agent interrupted during API call")
if agent._interrupt_requested:
_record_interrupted_provider_wait(
agent,
_elapsed,
response_started=(
_codex_watchdog_enabled
and getattr(agent, "_codex_stream_last_event_ts", None) is not None
),
)
# Mark cancelled BEFORE force-closing so the worker treats the
# transport error as a cancel (#6600).
_request_cancelled["value"] = True
logger.debug(
"Force-closing httpx client due to interrupt (not a network error)."
)
# Force-close the worker-local connection (never the shared client:
# releasing a TLS FD mid-SSL-BIO corrupted an unrelated SQLite DB,
# #67142), then let the worker unwind Relay scopes before raising
# (#81521).
_abort_request("interrupt_abort")
_join_worker_for_relay_teardown(t, label="Non-streaming")
raise InterruptedError("Agent interrupted during API call")
if result["error"] is not None:
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
def run(self):
agent, wd = self.agent, self.wd
if wd.codex:
# Reset before the worker starts so a marker left over from a previous
# call on this agent can't be misread as first-byte for this one.
agent._codex_stream_last_event_ts = None
agent._codex_stream_last_progress_ts = None
agent._touch_activity("waiting for non-streaming API response")
self.thread = t = threading.Thread(target=_context_thread_target(self._call), daemon=True)
t.start()
poll_count = 0
while t.is_alive():
t.join(timeout=0.3)
poll_count += 1
# Every ~30s: gateway inactivity heartbeat + rewrite the status line
# so users see WHAT the wait is (the "infinite thinking" complaint).
if poll_count % 100 == 0: # 100 × 0.3s = 30s
self._emit_wait_notice(time.time() - self.call_start)
elapsed = time.time() - self.call_start
last_event_ts = getattr(agent, "_codex_stream_last_event_ts", None)
if wd.ttfb_enabled and elapsed > wd.ttfb_timeout and last_event_ts is None:
self._ttfb_kill(elapsed)
break
if (
wd.idle_enabled
and last_event_ts is not None
and (time.time() - last_event_ts) > wd.idle_timeout
):
self._idle_kill(time.time() - last_event_ts)
break
if elapsed > wd.stale_timeout:
self._stale_kill(elapsed)
break
if agent._interrupt_requested:
self._interrupt(elapsed)
if self.result["error"] is not None:
raise self.result["error"]
# Success — the provider proved responsive: clear the breaker (#58962).
if self.result["response"] is not None:
_reset_stale_streak(agent)
return self.result["response"]
def interruptible_api_call(agent, api_kwargs: dict):
"""Run the API call on a worker thread so the caller can detect interrupts
without waiting for the full HTTP round-trip.
Each worker gets its own per-request client; interrupts close only that
client so retries never inherit a closed transport. A stale-call detector
kills the connection and raises so the main retry loop can apply backoff /
credential rotation / provider fallback.
"""
# Cron and other non-interactive, nested-pool contexts must not spawn the
# interrupt worker — it wedges before the socket opens on the 2nd+ call
# (#62151). Run inline instead. See should_use_direct_api_call.
if should_use_direct_api_call(agent):
return direct_api_call(agent, api_kwargs)
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
# of the guard in interruptible_streaming_api_call. Quiet-mode /
# subagent / no-stream-consumer sessions take THIS path.
_check_stale_giveup(agent)
return _NonStreamRequest(agent, api_kwargs).run()
def _consume_ephemeral_reasoning_off(agent) -> bool: