fix(responses): log completed response, not the event, for non-streaming callers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-10 19:13:33 +00:00
parent c40828509b
commit 6bf2156827
4 changed files with 50 additions and 14 deletions

View file

@ -368,18 +368,7 @@ class BaseResponsesAPIStreamingIterator:
if self._persist_completed_response_before_logging:
self._persist_completed_response_to_cache(is_async=is_async)
# Create a copy for logging to avoid modifying the response object that will be returned to the user
# The logging handlers may transform usage from Responses API format (input_tokens/output_tokens)
# to chat completion format (prompt_tokens/completion_tokens) for internal logging
# Use model_dump + model_validate instead of deepcopy to avoid pickle errors with
# Pydantic ValidatorIterator when response contains tool_choice with allowed_tools (fixes #17192)
logging_response = self.completed_response
if self.completed_response is not None and hasattr(self.completed_response, "model_dump"):
try:
logging_response = type(self.completed_response).model_validate(self.completed_response.model_dump())
except Exception:
# Fallback to original if serialization fails
pass
logging_response: Final = self._response_for_success_logging()
end_time: Final = datetime.now()
if is_async:
@ -409,6 +398,32 @@ class BaseResponsesAPIStreamingIterator:
)
self._run_post_success_hooks(end_time=end_time)
def _response_for_success_logging(self) -> ResponsesAPIStreamingResponse | ResponsesAPIResponse | None:
"""A copy of the completed response, so the handlers' usage transforms (Responses API
input_tokens/output_tokens to chat completion prompt_tokens/completion_tokens) don't touch
what the caller gets back. model_dump + model_validate instead of deepcopy avoids pickle
errors with Pydantic ValidatorIterator on tool_choice with allowed_tools (fixes #17192).
The handlers only unwrap the completion event in their assembled-stream branch, which a
non-streaming caller draining this iterator never reaches, so unwrap it here instead."""
completed: Final = self.completed_response
copied: Final = self._model_copy_for_logging(completed)
unwrapped: Final = getattr(copied, "response", None)
if getattr(self.logging_obj, "stream", None) is not True and isinstance(unwrapped, ResponsesAPIResponse):
return unwrapped
return copied
@staticmethod
def _model_copy_for_logging(
completed: ResponsesAPIStreamingResponse | None,
) -> ResponsesAPIStreamingResponse | None:
if completed is None or not hasattr(completed, "model_dump"):
return completed
try:
return type(completed).model_validate(completed.model_dump())
except Exception:
return completed
def _handle_logging_completed_response(self):
"""Base implementation - should be overridden by subclasses"""

View file

@ -192,7 +192,7 @@
"limit": 0
},
"S110": {
"limit": 218
"limit": 217
},
"S112": {
"limit": 22

View file

@ -235,3 +235,24 @@ def test_sync_transport_error_before_completed_event_raises():
with pytest.raises(httpx.ReadError):
for _ in iterator:
pass
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_completed_event_is_unwrapped_only_for_non_streaming_callers(stream: bool):
"""A non-streaming caller can still drain this iterator (chat -> responses bridge
against a provider that always answers with SSE). The success handlers only unwrap
the completion event when logging in stream mode, so a wrapped event there yields no
standard_logging_object and the SpendLogs row is dropped (#36426)."""
logging_obj = _logging_obj_stub()
logging_obj.stream = stream
iterator = _make_iterator(sse_events=_COMPLETE_STREAM_EVENTS, logging_obj=logging_obj)
async for _ in iterator:
pass
logged = logging_obj.dispatch_success_handlers.call_args.args[0]
if stream:
assert logged.type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED
else:
assert isinstance(logged, ResponsesAPIResponse)

View file

@ -27,7 +27,7 @@
"limit": 0
},
"LIT010": {
"limit": 16744
"limit": 16742
},
"LIT011": {
"limit": 5596