Skip to content

Commit 24f281d

Browse files
committed
LCORE-4070: raise a model error when OGX reports a streamed request as failed
A prompt too long for the model made /v1/streaming_query stream a complete answer that had nothing to do with the question, where "Prompt is too long" was expected. The conversation then held a retry prompt as the user message. OGX reports a provider failure of a streamed Responses request inside the stream, as a response.failed event. OgxResponsesModel passed it on, pydantic-ai took the empty response for a missing answer and sent a second request with the retry prompt "Validation feedback: Please return text. Fix the errors and try again." Its reply was streamed and stored as the answer. _FilteredResponseStream now raises ModelHTTPError for the event, with status 500 and the error of the event as body. The run ends after one model request and nothing is stored. The existing mapping turns a context-length message into 413; an unrecognised failure stays a 500. This applies where the model request is streamed: /v1/streaming_query, A2A, and /v1/query with a Granite Guardian shield. A failed request is no longer followed by a second one. The non-streamed call is untouched. Two unit tests: request_stream raises for a response.failed event, and an agent run over such a stream makes one model request and maps to 413. Checked on a live service in library mode with gpt-4o-mini on /v1/streaming_query only; A2A and Granite Guardian were not run.
1 parent 5737342 commit 24f281d

2 files changed

Lines changed: 112 additions & 3 deletions

File tree

‎src/pydantic_ai_lightspeed/ogx/_model.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from pydantic_ai import UnexpectedModelBehavior
3232
from pydantic_ai._run_context import RunContext
3333
from pydantic_ai._utils import PeekableAsyncStream, Unset, number_to_datetime
34+
from pydantic_ai.exceptions import ModelHTTPError
3435
from pydantic_ai.messages import ModelMessage, ModelResponse
3536
from pydantic_ai.models import (
3637
ModelRequestParameters,
@@ -150,12 +151,27 @@ def __aiter__(self) -> AsyncIterator[responses.ResponseStreamEvent]:
150151
async def _filtered_iter(
151152
self,
152153
) -> AsyncIterator[responses.ResponseStreamEvent]:
153-
"""Yield events, buffering early argument deltas until their item is announced."""
154+
"""Yield events, buffering early argument deltas until their item is announced.
155+
156+
Raises:
157+
ModelHTTPError: When OGX reports the request as failed.
158+
"""
154159
async for event in self._source:
155160
if self._on_completed is not None and isinstance(
156161
event, responses.ResponseCompletedEvent
157162
):
158163
self._on_completed(event.response)
164+
if isinstance(event, responses.ResponseFailedEvent):
165+
# OGX reports a provider failure of a streamed request in-band
166+
# (HTTP 200); the non-streamed call raises an HTTP error with
167+
# the same message. Raise here too, or pydantic-ai takes the
168+
# empty response for a missing answer and asks the model again.
169+
error = event.response.error
170+
raise ModelHTTPError(
171+
status_code=500,
172+
model_name=event.response.model,
173+
body=error.model_dump() if error is not None else None,
174+
)
159175
if isinstance(event, responses.ResponseOutputItemAddedEvent):
160176
if (
161177
isinstance(event.item, responses.ResponseFunctionToolCall)

‎tests/unit/pydantic_ai_lightspeed/ogx/test_model.py‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Unit tests for pydantic_ai_lightspeed.ogx._model module."""
22

3-
# pylint: disable=protected-access,too-few-public-methods
3+
# pylint: disable=protected-access,too-few-public-methods,too-many-lines
44

55
from collections.abc import AsyncIterator
66
from typing import Any
@@ -11,7 +11,8 @@
1111
OpenAIResponseReasoning,
1212
)
1313
from openai.types import responses
14-
from pydantic_ai import ModelMessage, UnexpectedModelBehavior
14+
from pydantic_ai import Agent, ModelMessage, UnexpectedModelBehavior
15+
from pydantic_ai.exceptions import ModelHTTPError
1516
from pydantic_ai.messages import ModelResponse
1617
from pydantic_ai.models.openai import (
1718
OpenAIResponsesModel,
@@ -21,13 +22,16 @@
2122
from pydantic_ai.settings import ModelSettings
2223
from pytest_mock import MockerFixture
2324

25+
from models.api.responses.error import PromptTooLongResponse
2426
from models.common.responses.responses_api_params import ResponsesApiParams
27+
from pydantic_ai_lightspeed.ogx import OgxProvider
2528
from pydantic_ai_lightspeed.ogx._model import (
2629
_LLS_RESPONSES_EXTRA_FIELDS,
2730
OgxResponsesModel,
2831
_FilteredResponseStream,
2932
_model_settings_from_responses_params,
3033
)
34+
from utils.agents.error_handler import map_agent_inference_error
3135

3236
_REQUIRED_PARAMS = {
3337
"input": "hello",
@@ -37,6 +41,12 @@
3741
"stream": True,
3842
}
3943

44+
# What OpenAI answers a prompt that is too long with; OGX passes the message on.
45+
_CONTEXT_LENGTH_MESSAGE = (
46+
"This model's maximum context length is 128000 tokens. However, your "
47+
"messages resulted in 153021 tokens. Please reduce the length of the messages."
48+
)
49+
4050

4151
def _make_params(**overrides: object) -> ResponsesApiParams:
4252
"""Build a ``ResponsesApiParams`` with required fields plus overrides."""
@@ -550,6 +560,20 @@ def _make_response_created_event() -> responses.ResponseCreatedEvent:
550560
)
551561

552562

563+
def _make_response_failed_event(message: str) -> responses.ResponseFailedEvent:
564+
"""Build the event OGX ends a streamed request with when the provider fails."""
565+
return responses.ResponseFailedEvent(
566+
response=_make_response_created_event().response.model_copy(
567+
update={
568+
"status": "failed",
569+
"error": responses.ResponseError(code="server_error", message=message),
570+
}
571+
),
572+
sequence_number=1,
573+
type="response.failed",
574+
)
575+
576+
553577
class TestRequestStream:
554578
"""Tests for OgxResponsesModel.request_stream."""
555579

@@ -670,6 +694,75 @@ async def test_happy_path_yields_streamed_response(
670694
async with model.request_stream([mocker.Mock()], {}, mocker.Mock()) as streamed:
671695
assert isinstance(streamed, OpenAIResponsesStreamedResponse)
672696

697+
@pytest.mark.asyncio
698+
async def test_failed_response_raises_the_provider_error(
699+
self, model: OgxResponsesModel, mocker: MockerFixture
700+
) -> None:
701+
"""Test that a request OGX reports as failed in the stream is a model error.
702+
703+
OGX answers a streamed request with HTTP 200 and reports a provider
704+
failure as a ``response.failed`` event. Passed on, pydantic-ai takes
705+
the empty response for a missing answer and asks the model again.
706+
"""
707+
events = [
708+
_make_response_created_event(),
709+
_make_response_failed_event(_CONTEXT_LENGTH_MESSAGE),
710+
]
711+
model._responses_create = mocker.AsyncMock(
712+
return_value=_make_mock_response_stream(mocker, events)
713+
)
714+
715+
with pytest.raises(ModelHTTPError, match="maximum context length") as exc_info:
716+
async with model.request_stream(
717+
[mocker.Mock()], {}, mocker.Mock()
718+
) as streamed:
719+
async for _ in streamed:
720+
pass
721+
722+
assert exc_info.value.status_code == 500
723+
724+
725+
class TestAgentRunOverFailedStream:
726+
"""Tests for an agent run whose streamed model request OGX reports as failed."""
727+
728+
@pytest.mark.asyncio
729+
async def test_run_ends_after_one_request_as_prompt_too_long(
730+
self, mocker: MockerFixture
731+
) -> None:
732+
"""Test that the run ends with the provider error, without a retry.
733+
734+
Without the error pydantic-ai sends a second request that carries a
735+
retry prompt, and its reply is streamed as the answer (LCORE-4070).
736+
A context-length failure maps to 413.
737+
"""
738+
model = OgxResponsesModel(
739+
"test-model", provider=OgxProvider(base_url="http://localhost:8321/v1")
740+
)
741+
responses_create = mocker.patch.object(
742+
model,
743+
"_responses_create",
744+
new_callable=mocker.AsyncMock,
745+
side_effect=lambda *_: _make_mock_response_stream(
746+
mocker,
747+
[
748+
_make_response_created_event(),
749+
_make_response_failed_event(_CONTEXT_LENGTH_MESSAGE),
750+
],
751+
),
752+
)
753+
agent = Agent(model, defer_model_check=True)
754+
755+
with pytest.raises(ModelHTTPError) as exc_info:
756+
async with agent.run_stream_events("question") as stream:
757+
async for _ in stream:
758+
pass
759+
760+
assert responses_create.await_count == 1
761+
assert isinstance(
762+
map_agent_inference_error(exc_info.value, "openai/test-model"),
763+
PromptTooLongResponse,
764+
)
765+
673766

674767
class TestLlsResponsesExtraFields:
675768
"""Tests for the _LLS_RESPONSES_EXTRA_FIELDS constant."""

0 commit comments

Comments
 (0)