fix(models): record model-call failures on the provider's own span - #4143
Conversation
`Span.__exit__` finishes a span without attaching an exception, so a span only carries error data if the provider sets it explicitly. `OpenAIResponsesModel` does that at both of its span sites; the other three providers do not, at eight sites between them: openai_chatcompletions.py generation_span get_response / stream_response litellm_model.py generation_span get_response / stream_response any_llm_model.py response_span _get/_stream_response_via_responses any_llm_model.py generation_span _get/_stream_response_via_chat A model call that fails through Chat Completions, LiteLLM, or AnyLLM therefore exports a span with no error marker, so a failed turn is indistinguishable from a successful one in the traces UI. The sharpest case is `any_llm_model.py`, which opens the same `response_span` as `openai_responses.py` and leaves it blank. `openai_chatcompletions.get_response` has no `try` at all, so even the `ModelBehaviorError` it raises for a choice-less payload escapes unannotated. Add `model_span_errors` to `util/_error_tracing.py` next to the existing `attach_error_to_span` and `get_trace_error`, and apply it at the eight sites. It is a context manager entered in the same `with` as the span, so the span bodies are untouched and the annotation cannot be forgotten for one branch of a provider while being present in another. Redaction goes through the existing `get_trace_error`, so nothing new decides what is safe to record. Deliberately unchanged: `openai_responses.py`, which already annotates both of its spans and is the behavior this matches; the per-site `log_model_action_error` calls, which are a separate concern from span data; and `Span.__exit__`, which would change every span type rather than the model providers.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f40a512bd3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@seratch since you're already holding #4142, one thing worth saying up front so this doesn't create ordering work for you: these two are fully independent. Zero file overlap (#4142 is The one review question I'd flag, since it's the only judgement call in here: I put the error recording in a context manager ( All 9 checks green including both mypy and pyright. The 9 new tests in |
seratch
left a comment
There was a problem hiding this comment.
I've verified this against real provider requests and the OpenAI Platform trace backend. The matrix covered a few provider integrations, the base and PR revisions, non-streaming and streaming calls, successful requests, and identical controlled error responses. All traces were exported successfully and appeared in Platform Logs. Successful calls remained unchanged, while the PR correctly added Error getting response or Error streaming response to failed generation spans. We did not observe trace ingestion failures, schema issues, UI rendering problems, or API key exposure.
One change is still required before merging. model_span_errors eagerly evaluates str(error) even when tracing is disabled or sensitive trace data is excluded. If an exception has a broken or side-effecting __str__, this can replace the original provider exception with a new exception from __str__. Please avoid stringifying the exception unless its text will actually be included in the span, and ensure that tracing never changes the exception propagated to the caller. Please also add regression coverage for disabled and redacted tracing that verifies the original exception object is preserved.
…d terminal stream failures Two fixes to the span-error recording this PR adds. model_span_errors evaluated str(error) before get_trace_error decided whether to keep it, so with tracing disabled or ModelTracing.ENABLED_WITHOUT_DATA a provider exception with a side-effecting __str__ ran anyway, and one with a raising __str__ replaced the provider failure the caller saw. The exception is now only stringified when its text will actually be exported, and recording is best-effort so annotating a span can never change what propagates. The Responses streaming paths learn about response.failed / response.incomplete / error before they raise it. A consumer that stops at that terminal event calls aclose(), which raises GeneratorExit at the yield and skips the raise after the loop; GeneratorExit is a BaseException, so nothing recorded the failure and the span exported as if the call had succeeded. Both the AnyLLM path and OpenAIResponsesModel now record at the point of knowledge. The OpenAIResponsesModel half is a pre-existing gap on main, not one this PR introduced. Tests: 6 new cases in tests/test_provider_span_errors.py, all failing without these changes. They cover redacted and disabled tracing preserving the original exception object, __str__ being called exactly once when data is included, a raising __str__, a raising span backend, and both terminal-event paths.
|
thanks for actually running it against the trace backend, that's more verification than i could do locally and it's useful to know the export side is clean. you're right about the eager stringify, and it's worse than i realised. reproduced with an exception whose so the caller loses the provider failure entirely and gets my tracing code's exception instead. that's a real regression i introduced, not a theoretical one. fixed in two parts. the stringify only happens when the text will actually be exported: def _model_error_text(error: Exception, *, trace_include_sensitive_data: bool) -> str:
if not trace_include_sensitive_data:
return REDACTED_TRACE_ERROR_MESSAGE
try:
return str(error)
except Exception:
logger.warning(...)
return f"Unrenderable {type(error).__name__}"and recording as a whole is best-effort now, so a raising four regression tests, all failing without the change: redacted tracing preserves the original exception object and never calls on the other finding in your review, the terminal-event one, that's also real and it's not limited to the AnyLLM path. a consumer that stops at both Responses streaming paths now record at the point the failure is known rather than at the point it would have been raised. worth flagging clearly: the gate green, both mypy and pyright, 6245 tests. 16 in |
Summary
Span.__exit__finishes a span without attaching an exception (tracing/spans.py), so a span carries error data only if the provider sets it explicitly.OpenAIResponsesModeldoes that at both of its span sites. The other three providers do not, at eight sites between them:models/openai_chatcompletions.pygeneration_spanget_response,stream_responseextensions/models/litellm_model.pygeneration_spanget_response,stream_responseextensions/models/any_llm_model.pyresponse_span_get_response_via_responses,_stream_response_via_responsesextensions/models/any_llm_model.pygeneration_span_get_response_via_chat,_stream_response_via_chatA model call that fails through Chat Completions, LiteLLM, or AnyLLM exports a span with no error marker, so in the traces UI a failed turn looks like a normal one.
Two details make this concrete rather than cosmetic:
any_llm_model.pyopens the sameresponse_spanasopenai_responses.py, so byte-identical span types are error-annotated by one provider and blank by the other.openai_chatcompletions.get_responsehas notryat all, so even theModelBehaviorErrorit raises itself for a choice-less payload ("possible provider error payload") escapes with the span unannotated.Root cause
Error annotation is per-call-site convention rather than a property of opening a model span. Nothing makes it fail loudly when a new provider, or a new branch of an existing one, forgets it.
Approach
Add
model_span_errorstoutil/_error_tracing.py, alongside the existingattach_error_to_spanandget_trace_error, and apply it at the eight sites. It is a context manager entered in the samewithstatement as the span:Two consequences worth calling out. The span bodies are not re-indented, so the diff is the
withheaders plus the helper rather than several hundred lines of moved code. And because the annotation is attached to the span's own scope, it cannot be present in one branch of a provider and missing in another, which is exactly how the current divergence arose. Redaction reusesget_trace_error, so nothing new decides what is safe to record. Messages match the existing convention:"Error getting response"for non-streaming,"Error streaming response"for streaming.Parenthesized multi-context
withis already used inrun_internal/model_retry.py, andrequires-pythonis>=3.10.What this deliberately does not change
openai_responses.py. It already annotates both spans and is the behavior this matches. Rewriting working code onto the new helper would widen the diff without changing behavior.log_model_action_errorcalls. Logging is a separate concern from span data; this PR only fixes what the trace records.Span.__exit__. Attaching errors there would change every span type in the SDK, not the model providers, and fix(tracing): mark the agent span when a non-streaming run fails #4073 is already addressing agent-span error reporting.generation_spaninopenai_responses.py's streaming terminal-event path and other non-provider spans.Test plan
tests/test_provider_span_errors.py, 9 cases covering all eight sites plus redaction. Each patches the provider's fetch to raise, runs the call inside atrace(), and asserts the span carries the expected error.get_response,stream_responseModelTracing.ENABLED_WITHOUT_DATAget_response,stream_responseresponse_span× get/stream,generation_span× get/stream)All 9 fail on
mainwithgeneration span carried no error/response span carried no error, and pass with this change.Verification, in
AGENTS.md's mandated order:Issue number
N/A — searched open and closed issues and PRs for
SpanError,set_error,generation_span,response_span, andattach_error_to_span. The nearest is #4073, which fixes under-reported failures on the agent span (run.py,run_internal/) and touches none of these files. No duplicate found.Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PRDeveloped with Claude Code; reviewed and tested by Pranav before marking ready for review.