Fix pre-start stream failures returning HTTP 200 (#1026)
Browse files## Problem
Provider streams could commit HTTP 200 before an upstream-backed first
SSE frame was available. When setup or retry failed before usable stream
output, Claude and Codex saw a successful but broken stream instead of a
retryable non-200 error.
## Changes
| Before | After |
| --- | --- |
| API egress returned `StreamingResponse` before probing the provider
iterator. | API egress waits for the first chunk before committing
success headers. |
| Pre-start provider failures became synthetic SSE success streams. |
Pre-start provider failures raise typed errors and return Anthropic or
OpenAI JSON with non-200 status. |
| Post-start unexpected stream failures could truncate protocol output.
| Post-start failures emit terminal Anthropic error or Responses
`response.failed` frames where possible. |
| Provider tests expected pre-start final failures as SSE tails. |
Provider tests assert typed pre-start errors and preserve midstream and
tool-salvage behavior. |
<!-- greptile_comment -->
<details open><summary><h3>Greptile Summary</h3></summary>
This PR changes streaming responses so HTTP success is not committed
before the first protocol chunk. The main changes are:
- First-chunk gated streaming helpers for Anthropic and OpenAI Responses
egress.
- Non-200 JSON error responses for provider failures before stream
output starts.
- Terminal Anthropic `error` and Responses `response.failed` frames for
post-start interruptions.
- Provider transport updates that raise typed pre-start errors while
preserving retry, recovery, and tool salvage paths.
- Targeted API and provider tests plus a patch version and lockfile
update.
</details>
<h3>Confidence Score: 5/5</h3>
Safe to merge with low risk.
The changed paths keep provider retry and recovery ownership in
transports, gate HTTP success before the first chunk, and preserve
cancellation behavior. Tests cover the main Anthropic and OpenAI
Responses pre-start and post-start failure paths.
No files require special attention.
<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>
**What T-Rex did**
- Ran the pre-change stream-gating API tests against the previous commit
98515be55a98ca2bd0be78434c4897c33e3296d8, and they passed 35 tests in
5.08s (EXIT\_CODE: 0).
- Ran the post-change stream-gating API tests against the updated HEAD
d65f4ca107a024790df03cb4d30d6346027597b8, and they passed 35 tests in
2.27s (EXIT\_CODE: 0).
- Ran the focused API test suite against the current code and it passed
35 tests in 3.85s (EXIT\_CODE: 0).
<a
href="https://app.greptile.com/trex/runs/13779430/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>
<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>
<details open><summary><h3>Important Files Changed</h3></summary>
| Filename | Overview |
|----------|----------|
| api/response_streams.py | Introduces protocol-agnostic first-chunk
gating and Anthropic post-start terminal error fallback. |
| api/handlers/messages.py | Awaits first-chunk gated Anthropic stream
responses and maps pre-start provider or unexpected failures to JSON
errors. |
| api/handlers/responses.py | Adds first-chunk gated Responses streaming
and OpenAI-shaped pre-start error serialization. |
| providers/error_mapping.py | Adds shared mapping from final pre-start
stream exceptions to HTTP-serializable provider errors. |
| providers/transports/openai_chat/stream.py | Raises mapped provider
errors for uncommitted OpenAI-chat failures while preserving recovery
and complete tool salvage paths. |
| providers/transports/anthropic_messages/stream.py | Raises mapped
provider errors for uncommitted native stream failures while preserving
committed and salvageable tails. |
| core/openai_responses/stream.py | Preserves pre-start exceptions while
converting post-start Anthropic stream failures into same-assembler
`response.failed` events. |
| tests/api/test_response_streams.py | Adds unit coverage for
first-chunk gating, pre-start error JSON, and post-start terminal
Anthropic frames. |
| tests/api/test_openai_responses.py | Covers Responses pre-start
provider errors and same-id post-start failure frames. |
| tests/providers/test_openai_compat_5xx_retry.py | Covers exhausted
OpenAI-compatible 5xx and connection retries raising provider errors
before stream commit. |
| pyproject.toml | Bumps the package patch version for production
behavior changes. |
| uv.lock | Refreshes the lockfile package version to match the patch
bump. |
</details>
<details open><summary><h3>Sequence Diagram</h3></summary>
<a href="#gh-light-mode-only">
```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant Handler as API Handler
participant Egress as api/response_streams.py
participant Provider as Provider Stream
participant Assembler as Responses Assembler
Client->>Handler: POST /v1/messages or /v1/responses
Handler->>Provider: create async SSE iterator
Handler->>Egress: await first-chunk gated response
Egress->>Provider: anext(body)
alt Provider fails before first chunk
Provider-->>Egress: ProviderError / exception
Egress-->>Handler: protocol JSON error response
Handler-->>Client: non-200 JSON
else First protocol chunk is available
Provider-->>Egress: first SSE chunk
Egress-->>Client: HTTP 200 StreamingResponse
Egress-->>Client: replay first chunk and tail
alt Anthropic post-start failure
Provider-->>Egress: exception
Egress-->>Client: terminal event: error
else Responses post-start failure
Provider-->>Assembler: exception after response.created
Assembler-->>Client: response.failed with same response.id
end
end
```
</a>
<a href="#gh-dark-mode-only">
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant Handler as API Handler
participant Egress as api/response_streams.py
participant Provider as Provider Stream
participant Assembler as Responses Assembler
Client->>Handler: POST /v1/messages or /v1/responses
Handler->>Provider: create async SSE iterator
Handler->>Egress: await first-chunk gated response
Egress->>Provider: anext(body)
alt Provider fails before first chunk
Provider-->>Egress: ProviderError / exception
Egress-->>Handler: protocol JSON error response
Handler-->>Client: non-200 JSON
else First protocol chunk is available
Provider-->>Egress: first SSE chunk
Egress-->>Client: HTTP 200 StreamingResponse
Egress-->>Client: replay first chunk and tail
alt Anthropic post-start failure
Provider-->>Egress: exception
Egress-->>Client: terminal event: error
else Responses post-start failure
Provider-->>Assembler: exception after response.created
Assembler-->>Client: response.failed with same response.id
end
end
```
</a>
</details>
<sub>Reviews (1): Last reviewed commit: ["Fix pre-start stream failure
status"](https://github.com/alishahryar1/free-claude-code/commit/d65f4ca107a024790df03cb4d30d6346027597b8)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=42891757)</sub>
<!-- /greptile_comment -->
- ARCHITECTURE.md +15 -0
- api/handlers/messages.py +51 -4
- api/handlers/responses.py +35 -2
- api/response_streams.py +105 -11
- core/anthropic/streaming/__init__.py +2 -0
- core/anthropic/streaming/emitter.py +8 -0
- core/openai_responses/adapter.py +6 -1
- core/openai_responses/stream.py +52 -6
- plan.md +324 -0
- providers/error_mapping.py +35 -0
- providers/transports/anthropic_messages/stream.py +16 -17
- providers/transports/openai_chat/stream.py +13 -3
- pyproject.toml +1 -1
- tests/api/test_api.py +31 -0
- tests/api/test_openai_responses.py +67 -0
- tests/api/test_response_streams.py +111 -0
- tests/providers/test_anthropic_messages.py +13 -23
- tests/providers/test_anthropic_messages_429_retry.py +10 -15
- tests/providers/test_llamacpp.py +38 -38
- tests/providers/test_mistral.py +7 -4
- tests/providers/test_nvidia_nim.py +13 -13
- tests/providers/test_ollama.py +6 -7
- tests/providers/test_openai_compat_5xx_retry.py +8 -10
- tests/providers/test_provider_transport_logging.py +17 -8
- tests/providers/test_streaming_errors.py +50 -88
- uv.lock +1 -1
|
@@ -247,6 +247,12 @@ owns Anthropic token counting. Shared provider execution lives in
|
|
| 247 |
[api/provider_execution.py](api/provider_execution.py), which resolves a
|
| 248 |
provider, preflights the upstream request, emits trace events, counts input
|
| 249 |
tokens, and returns an Anthropic SSE iterator.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
```mermaid
|
| 252 |
sequenceDiagram
|
|
@@ -438,6 +444,11 @@ classification also lives in this shared layer so stream recovery, provider
|
|
| 438 |
backoff, and provider error mapping agree on retryable overload/rate-limit
|
| 439 |
signals.
|
| 440 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
[core/openai_responses/](core/openai_responses/) owns OpenAI Responses support:
|
| 442 |
|
| 443 |
- the `OpenAIResponsesAdapter` facade used by the API layer;
|
|
@@ -460,6 +471,10 @@ builders, and error mapping. API code should depend on the adapter, not on
|
|
| 460 |
those internal module owners directly. Responses output payloads stay
|
| 461 |
OpenAI-shaped; Anthropic terminal metadata is used internally only when it
|
| 462 |
affects streamed behavior.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
| 464 |
Responses custom tools are also boundary-owned. The adapter accepts native
|
| 465 |
Responses `custom` tool declarations, represents them internally as Anthropic
|
|
|
|
| 247 |
[api/provider_execution.py](api/provider_execution.py), which resolves a
|
| 248 |
provider, preflights the upstream request, emits trace events, counts input
|
| 249 |
tokens, and returns an Anthropic SSE iterator.
|
| 250 |
+
[api/response_streams.py](api/response_streams.py) owns public streaming egress
|
| 251 |
+
commit timing. It waits for the first protocol chunk before returning a
|
| 252 |
+
successful `StreamingResponse`, so provider setup failures can still become real
|
| 253 |
+
non-200 JSON errors that Claude Code and Codex can retry. After the first chunk
|
| 254 |
+
has escaped, HTTP status is committed; any unexpected failure must be represented
|
| 255 |
+
as a protocol terminal frame where feasible.
|
| 256 |
|
| 257 |
```mermaid
|
| 258 |
sequenceDiagram
|
|
|
|
| 444 |
backoff, and provider error mapping agree on retryable overload/rate-limit
|
| 445 |
signals.
|
| 446 |
|
| 447 |
+
Provider transports raise typed provider errors for final stream failures before
|
| 448 |
+
any downstream-visible SSE chunk has escaped the recovery holdback. Once output
|
| 449 |
+
has committed, transports keep ownership of midstream recovery, continuation,
|
| 450 |
+
tool salvage, and protocol-specific success/error tails.
|
| 451 |
+
|
| 452 |
[core/openai_responses/](core/openai_responses/) owns OpenAI Responses support:
|
| 453 |
|
| 454 |
- the `OpenAIResponsesAdapter` facade used by the API layer;
|
|
|
|
| 471 |
those internal module owners directly. Responses output payloads stay
|
| 472 |
OpenAI-shaped; Anthropic terminal metadata is used internally only when it
|
| 473 |
affects streamed behavior.
|
| 474 |
+
Post-start Responses failures are assembler-owned: the active
|
| 475 |
+
`ResponsesStreamAssembler` emits `response.failed` so the terminal event keeps
|
| 476 |
+
the same `response.id`, output ledger, and usage state as the earlier
|
| 477 |
+
`response.created`.
|
| 478 |
|
| 479 |
Responses custom tools are also boundary-owned. The adapter accepts native
|
| 480 |
Responses `custom` tool declarations, represents them internally as Anthropic
|
|
@@ -11,8 +11,16 @@ from api.model_router import ModelRouter, RoutedMessagesRequest
|
|
| 11 |
from api.models.anthropic import MessagesRequest
|
| 12 |
from api.optimization_handlers import try_optimizations
|
| 13 |
from api.provider_execution import ProviderExecutionService, TokenCounter
|
| 14 |
-
from api.request_errors import
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from api.web_tools.egress import WebFetchEgressPolicy, web_fetch_allowed_scheme_set
|
| 17 |
from api.web_tools.request import (
|
| 18 |
is_web_server_tool_request,
|
|
@@ -21,7 +29,11 @@ from api.web_tools.request import (
|
|
| 21 |
from api.web_tools.streaming import stream_web_server_tool_response
|
| 22 |
from config.provider_catalog import PROVIDER_CATALOG
|
| 23 |
from config.settings import Settings
|
| 24 |
-
from core.anthropic import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
from core.trace import trace_event
|
| 26 |
from providers.base import BaseProvider
|
| 27 |
from providers.exceptions import InvalidRequestError, ProviderError
|
|
@@ -48,6 +60,12 @@ _MessagesResult = _MessagesStreamResult | _MessagesCompleteResult
|
|
| 48 |
MessageIntercept = Callable[[RoutedMessagesRequest], _MessagesResult | None]
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
class MessagesHandler:
|
| 52 |
"""Handle Anthropic-compatible Messages requests."""
|
| 53 |
|
|
@@ -116,7 +134,36 @@ class MessagesHandler:
|
|
| 116 |
content={"type": "error", "error": error},
|
| 117 |
)
|
| 118 |
return JSONResponse(content=message)
|
| 119 |
-
return anthropic_sse_streaming_response(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
def _reject_unsupported_server_tools(self, routed: RoutedMessagesRequest) -> None:
|
| 122 |
if routed.resolved.provider_id not in _OPENAI_CHAT_UPSTREAM_IDS:
|
|
|
|
| 11 |
from api.models.anthropic import MessagesRequest
|
| 12 |
from api.optimization_handlers import try_optimizations
|
| 13 |
from api.provider_execution import ProviderExecutionService, TokenCounter
|
| 14 |
+
from api.request_errors import (
|
| 15 |
+
http_status_for_unexpected_api_exception,
|
| 16 |
+
log_unexpected_api_exception,
|
| 17 |
+
require_non_empty_messages,
|
| 18 |
+
unexpected_http_exception,
|
| 19 |
+
)
|
| 20 |
+
from api.response_streams import (
|
| 21 |
+
EmptyStreamError,
|
| 22 |
+
anthropic_sse_streaming_response,
|
| 23 |
+
)
|
| 24 |
from api.web_tools.egress import WebFetchEgressPolicy, web_fetch_allowed_scheme_set
|
| 25 |
from api.web_tools.request import (
|
| 26 |
is_web_server_tool_request,
|
|
|
|
| 29 |
from api.web_tools.streaming import stream_web_server_tool_response
|
| 30 |
from config.provider_catalog import PROVIDER_CATALOG
|
| 31 |
from config.settings import Settings
|
| 32 |
+
from core.anthropic import (
|
| 33 |
+
aggregate_anthropic_sse_to_message,
|
| 34 |
+
get_token_count,
|
| 35 |
+
get_user_facing_error_message,
|
| 36 |
+
)
|
| 37 |
from core.trace import trace_event
|
| 38 |
from providers.base import BaseProvider
|
| 39 |
from providers.exceptions import InvalidRequestError, ProviderError
|
|
|
|
| 60 |
MessageIntercept = Callable[[RoutedMessagesRequest], _MessagesResult | None]
|
| 61 |
|
| 62 |
|
| 63 |
+
def _unexpected_stream_error_message(exc: BaseException) -> str:
|
| 64 |
+
if isinstance(exc, Exception):
|
| 65 |
+
return get_user_facing_error_message(exc)
|
| 66 |
+
return str(exc).strip() or f"{type(exc).__name__} occurred."
|
| 67 |
+
|
| 68 |
+
|
| 69 |
class MessagesHandler:
|
| 70 |
"""Handle Anthropic-compatible Messages requests."""
|
| 71 |
|
|
|
|
| 134 |
content={"type": "error", "error": error},
|
| 135 |
)
|
| 136 |
return JSONResponse(content=message)
|
| 137 |
+
return await anthropic_sse_streaming_response(
|
| 138 |
+
result.body,
|
| 139 |
+
pre_start_error_response=self._pre_start_error_response,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def _pre_start_error_response(self, exc: BaseException) -> JSONResponse:
|
| 143 |
+
if isinstance(exc, ProviderError):
|
| 144 |
+
return JSONResponse(
|
| 145 |
+
status_code=exc.status_code,
|
| 146 |
+
content=exc.to_anthropic_format(),
|
| 147 |
+
)
|
| 148 |
+
log_unexpected_api_exception(
|
| 149 |
+
self._settings,
|
| 150 |
+
exc,
|
| 151 |
+
context=(
|
| 152 |
+
"CREATE_MESSAGE_EMPTY_STREAM"
|
| 153 |
+
if isinstance(exc, EmptyStreamError)
|
| 154 |
+
else "CREATE_MESSAGE_STREAM_START_ERROR"
|
| 155 |
+
),
|
| 156 |
+
)
|
| 157 |
+
return JSONResponse(
|
| 158 |
+
status_code=http_status_for_unexpected_api_exception(exc),
|
| 159 |
+
content={
|
| 160 |
+
"type": "error",
|
| 161 |
+
"error": {
|
| 162 |
+
"type": "api_error",
|
| 163 |
+
"message": _unexpected_stream_error_message(exc),
|
| 164 |
+
},
|
| 165 |
+
},
|
| 166 |
+
)
|
| 167 |
|
| 168 |
def _reject_unsupported_server_tools(self, routed: RoutedMessagesRequest) -> None:
|
| 169 |
if routed.resolved.provider_id not in _OPENAI_CHAT_UPSTREAM_IDS:
|
|
@@ -13,7 +13,10 @@ from api.request_errors import (
|
|
| 13 |
log_unexpected_api_exception,
|
| 14 |
require_non_empty_messages,
|
| 15 |
)
|
| 16 |
-
from api.response_streams import
|
|
|
|
|
|
|
|
|
|
| 17 |
from config.settings import Settings
|
| 18 |
from core.anthropic import get_user_facing_error_message
|
| 19 |
from core.openai_responses import OpenAIResponsesAdapter
|
|
@@ -23,6 +26,12 @@ from providers.exceptions import InvalidRequestError, ProviderError
|
|
| 23 |
ProviderGetter = Callable[[str], BaseProvider]
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
class ResponsesHandler:
|
| 27 |
"""Handle streaming OpenAI Responses-compatible requests."""
|
| 28 |
|
|
@@ -72,12 +81,14 @@ class ResponsesHandler:
|
|
| 72 |
raw_log_label="FULL_RESPONSES_PAYLOAD",
|
| 73 |
raw_log_payload=request_payload,
|
| 74 |
)
|
| 75 |
-
return openai_responses_sse_streaming_response(
|
| 76 |
self._responses_adapter.iter_sse_from_anthropic(
|
| 77 |
streamed,
|
| 78 |
request_payload,
|
|
|
|
| 79 |
),
|
| 80 |
headers=self._responses_adapter.sse_headers,
|
|
|
|
| 81 |
)
|
| 82 |
except OpenAIResponsesAdapter.ConversionError as exc:
|
| 83 |
invalid_request = InvalidRequestError(str(exc))
|
|
@@ -109,3 +120,25 @@ class ResponsesHandler:
|
|
| 109 |
error_type="api_error",
|
| 110 |
),
|
| 111 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
log_unexpected_api_exception,
|
| 14 |
require_non_empty_messages,
|
| 15 |
)
|
| 16 |
+
from api.response_streams import (
|
| 17 |
+
EGRESS_STREAM_INTERRUPTED_MESSAGE,
|
| 18 |
+
openai_responses_sse_streaming_response,
|
| 19 |
+
)
|
| 20 |
from config.settings import Settings
|
| 21 |
from core.anthropic import get_user_facing_error_message
|
| 22 |
from core.openai_responses import OpenAIResponsesAdapter
|
|
|
|
| 26 |
ProviderGetter = Callable[[str], BaseProvider]
|
| 27 |
|
| 28 |
|
| 29 |
+
def _unexpected_stream_error_message(exc: BaseException) -> str:
|
| 30 |
+
if isinstance(exc, Exception):
|
| 31 |
+
return get_user_facing_error_message(exc)
|
| 32 |
+
return str(exc).strip() or f"{type(exc).__name__} occurred."
|
| 33 |
+
|
| 34 |
+
|
| 35 |
class ResponsesHandler:
|
| 36 |
"""Handle streaming OpenAI Responses-compatible requests."""
|
| 37 |
|
|
|
|
| 81 |
raw_log_label="FULL_RESPONSES_PAYLOAD",
|
| 82 |
raw_log_payload=request_payload,
|
| 83 |
)
|
| 84 |
+
return await openai_responses_sse_streaming_response(
|
| 85 |
self._responses_adapter.iter_sse_from_anthropic(
|
| 86 |
streamed,
|
| 87 |
request_payload,
|
| 88 |
+
stream_error_message=EGRESS_STREAM_INTERRUPTED_MESSAGE,
|
| 89 |
),
|
| 90 |
headers=self._responses_adapter.sse_headers,
|
| 91 |
+
pre_start_error_response=self._pre_start_error_response,
|
| 92 |
)
|
| 93 |
except OpenAIResponsesAdapter.ConversionError as exc:
|
| 94 |
invalid_request = InvalidRequestError(str(exc))
|
|
|
|
| 120 |
error_type="api_error",
|
| 121 |
),
|
| 122 |
)
|
| 123 |
+
|
| 124 |
+
def _pre_start_error_response(self, exc: BaseException) -> JSONResponse:
|
| 125 |
+
if isinstance(exc, ProviderError):
|
| 126 |
+
return JSONResponse(
|
| 127 |
+
status_code=exc.status_code,
|
| 128 |
+
content=self._responses_adapter.error_payload(
|
| 129 |
+
message=exc.message,
|
| 130 |
+
error_type=exc.error_type,
|
| 131 |
+
),
|
| 132 |
+
)
|
| 133 |
+
log_unexpected_api_exception(
|
| 134 |
+
self._settings,
|
| 135 |
+
exc,
|
| 136 |
+
context="CREATE_RESPONSE_STREAM_START_ERROR",
|
| 137 |
+
)
|
| 138 |
+
return JSONResponse(
|
| 139 |
+
status_code=http_status_for_unexpected_api_exception(exc),
|
| 140 |
+
content=self._responses_adapter.error_payload(
|
| 141 |
+
message=_unexpected_stream_error_message(exc),
|
| 142 |
+
error_type="api_error",
|
| 143 |
+
),
|
| 144 |
+
)
|
|
@@ -1,29 +1,123 @@
|
|
| 1 |
"""FastAPI streaming response wrappers for public API wire formats."""
|
| 2 |
|
| 3 |
-
|
|
|
|
| 4 |
|
| 5 |
-
from fastapi.responses import StreamingResponse
|
| 6 |
|
| 7 |
-
from core.anthropic.streaming import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
def anthropic_sse_streaming_response(body: AsyncIterator[str]) -> StreamingResponse:
|
| 11 |
-
"""Return a streaming response for Anthropic-style SSE streams."""
|
| 12 |
return StreamingResponse(
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
media_type="text/event-stream",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
headers=ANTHROPIC_SSE_RESPONSE_HEADERS,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
| 19 |
-
def openai_responses_sse_streaming_response(
|
| 20 |
body: AsyncIterator[str],
|
| 21 |
*,
|
| 22 |
headers: Mapping[str, str],
|
| 23 |
-
|
|
|
|
| 24 |
"""Return a streaming response for OpenAI Responses-style SSE."""
|
| 25 |
-
return
|
| 26 |
body,
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
)
|
|
|
|
| 1 |
"""FastAPI streaming response wrappers for public API wire formats."""
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
|
| 5 |
|
| 6 |
+
from fastapi.responses import Response, StreamingResponse
|
| 7 |
|
| 8 |
+
from core.anthropic.streaming import (
|
| 9 |
+
ANTHROPIC_SSE_RESPONSE_HEADERS,
|
| 10 |
+
anthropic_terminal_error_frame,
|
| 11 |
+
)
|
| 12 |
+
from core.trace import trace_event
|
| 13 |
|
| 14 |
+
EGRESS_STREAM_INTERRUPTED_MESSAGE = (
|
| 15 |
+
"The upstream response stream ended unexpectedly; the request could not be "
|
| 16 |
+
"completed."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
PreStartErrorResponse = Callable[[BaseException], Response]
|
| 20 |
+
TerminalFrameEmitter = Callable[[BaseException], str]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class EmptyStreamError(RuntimeError):
|
| 24 |
+
"""Raised when a public stream ends before emitting any protocol chunk."""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _trace_egress_failure(exc: BaseException) -> None:
|
| 28 |
+
trace_event(
|
| 29 |
+
stage="egress",
|
| 30 |
+
event="api.response.egress_error_frame_emitted",
|
| 31 |
+
source="api",
|
| 32 |
+
exc_type=type(exc).__name__,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def _first_chunk_streaming_response(
|
| 37 |
+
body: AsyncIterator[str],
|
| 38 |
+
*,
|
| 39 |
+
headers: Mapping[str, str],
|
| 40 |
+
pre_start_error_response: PreStartErrorResponse,
|
| 41 |
+
terminal_frame: TerminalFrameEmitter | None,
|
| 42 |
+
) -> Response:
|
| 43 |
+
try:
|
| 44 |
+
first_chunk = await anext(body)
|
| 45 |
+
except StopAsyncIteration:
|
| 46 |
+
return pre_start_error_response(
|
| 47 |
+
EmptyStreamError("Stream ended before emitting a response.")
|
| 48 |
+
)
|
| 49 |
+
except GeneratorExit:
|
| 50 |
+
raise
|
| 51 |
+
except asyncio.CancelledError:
|
| 52 |
+
raise
|
| 53 |
+
except BaseExceptionGroup as exc:
|
| 54 |
+
return pre_start_error_response(exc)
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
return pre_start_error_response(exc)
|
| 57 |
|
|
|
|
|
|
|
| 58 |
return StreamingResponse(
|
| 59 |
+
_replay_first_chunk_then_stream(
|
| 60 |
+
first_chunk,
|
| 61 |
+
body,
|
| 62 |
+
terminal_frame=terminal_frame,
|
| 63 |
+
),
|
| 64 |
media_type="text/event-stream",
|
| 65 |
+
headers=dict(headers),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def _replay_first_chunk_then_stream(
|
| 70 |
+
first_chunk: str,
|
| 71 |
+
body: AsyncIterator[str],
|
| 72 |
+
*,
|
| 73 |
+
terminal_frame: TerminalFrameEmitter | None,
|
| 74 |
+
) -> AsyncGenerator[str]:
|
| 75 |
+
yield first_chunk
|
| 76 |
+
try:
|
| 77 |
+
async for chunk in body:
|
| 78 |
+
yield chunk
|
| 79 |
+
except GeneratorExit:
|
| 80 |
+
raise
|
| 81 |
+
except asyncio.CancelledError:
|
| 82 |
+
raise
|
| 83 |
+
except BaseExceptionGroup as exc:
|
| 84 |
+
if terminal_frame is None:
|
| 85 |
+
raise
|
| 86 |
+
_trace_egress_failure(exc)
|
| 87 |
+
yield terminal_frame(exc)
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
if terminal_frame is None:
|
| 90 |
+
raise
|
| 91 |
+
_trace_egress_failure(exc)
|
| 92 |
+
yield terminal_frame(exc)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def anthropic_sse_streaming_response(
|
| 96 |
+
body: AsyncIterator[str],
|
| 97 |
+
*,
|
| 98 |
+
pre_start_error_response: PreStartErrorResponse,
|
| 99 |
+
) -> Response:
|
| 100 |
+
"""Return a streaming response for Anthropic-style SSE streams."""
|
| 101 |
+
return await _first_chunk_streaming_response(
|
| 102 |
+
body,
|
| 103 |
headers=ANTHROPIC_SSE_RESPONSE_HEADERS,
|
| 104 |
+
pre_start_error_response=pre_start_error_response,
|
| 105 |
+
terminal_frame=lambda _exc: anthropic_terminal_error_frame(
|
| 106 |
+
EGRESS_STREAM_INTERRUPTED_MESSAGE
|
| 107 |
+
),
|
| 108 |
)
|
| 109 |
|
| 110 |
|
| 111 |
+
async def openai_responses_sse_streaming_response(
|
| 112 |
body: AsyncIterator[str],
|
| 113 |
*,
|
| 114 |
headers: Mapping[str, str],
|
| 115 |
+
pre_start_error_response: PreStartErrorResponse,
|
| 116 |
+
) -> Response:
|
| 117 |
"""Return a streaming response for OpenAI Responses-style SSE."""
|
| 118 |
+
return await _first_chunk_streaming_response(
|
| 119 |
body,
|
| 120 |
+
headers=headers,
|
| 121 |
+
pre_start_error_response=pre_start_error_response,
|
| 122 |
+
terminal_frame=None,
|
| 123 |
)
|
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from .emitter import (
|
| 4 |
ANTHROPIC_SSE_RESPONSE_HEADERS,
|
| 5 |
AnthropicSseEmitter,
|
|
|
|
| 6 |
format_sse_event,
|
| 7 |
map_stop_reason,
|
| 8 |
)
|
|
@@ -43,6 +44,7 @@ __all__ = [
|
|
| 43 |
"ToolSchema",
|
| 44 |
"TruncatedProviderStreamError",
|
| 45 |
"accept_tool_json_repair",
|
|
|
|
| 46 |
"continuation_suffix",
|
| 47 |
"format_sse_event",
|
| 48 |
"is_retryable_stream_error",
|
|
|
|
| 3 |
from .emitter import (
|
| 4 |
ANTHROPIC_SSE_RESPONSE_HEADERS,
|
| 5 |
AnthropicSseEmitter,
|
| 6 |
+
anthropic_terminal_error_frame,
|
| 7 |
format_sse_event,
|
| 8 |
map_stop_reason,
|
| 9 |
)
|
|
|
|
| 44 |
"ToolSchema",
|
| 45 |
"TruncatedProviderStreamError",
|
| 46 |
"accept_tool_json_repair",
|
| 47 |
+
"anthropic_terminal_error_frame",
|
| 48 |
"continuation_suffix",
|
| 49 |
"format_sse_event",
|
| 50 |
"is_retryable_stream_error",
|
|
@@ -31,6 +31,14 @@ def format_sse_event(event_type: str, data: dict[str, Any]) -> str:
|
|
| 31 |
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
class AnthropicSseEmitter:
|
| 35 |
"""Serialize Anthropic SSE events and optionally log raw event bodies."""
|
| 36 |
|
|
|
|
| 31 |
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
| 32 |
|
| 33 |
|
| 34 |
+
def anthropic_terminal_error_frame(message: str) -> str:
|
| 35 |
+
"""Serialize a terminal Anthropic SSE error event for egress failures."""
|
| 36 |
+
return format_sse_event(
|
| 37 |
+
"error",
|
| 38 |
+
{"type": "error", "error": {"type": "api_error", "message": message}},
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
class AnthropicSseEmitter:
|
| 43 |
"""Serialize Anthropic SSE events and optionally log raw event bodies."""
|
| 44 |
|
|
@@ -22,8 +22,13 @@ class OpenAIResponsesAdapter:
|
|
| 22 |
self,
|
| 23 |
chunks: AsyncIterable[Any],
|
| 24 |
request: Mapping[str, Any],
|
|
|
|
|
|
|
| 25 |
) -> AsyncIterator[str]:
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
def error_payload(self, *, message: str, error_type: str) -> dict[str, Any]:
|
| 29 |
return openai_error_payload(message=message, error_type=error_type)
|
|
|
|
| 22 |
self,
|
| 23 |
chunks: AsyncIterable[Any],
|
| 24 |
request: Mapping[str, Any],
|
| 25 |
+
*,
|
| 26 |
+
stream_error_message: str | None = None,
|
| 27 |
) -> AsyncIterator[str]:
|
| 28 |
+
kwargs: dict[str, str] = {}
|
| 29 |
+
if stream_error_message is not None:
|
| 30 |
+
kwargs["stream_error_message"] = stream_error_message
|
| 31 |
+
return iter_responses_sse_from_anthropic(chunks, request, **kwargs)
|
| 32 |
|
| 33 |
def error_payload(self, *, message: str, error_type: str) -> dict[str, Any]:
|
| 34 |
return openai_error_payload(message=message, error_type=error_type)
|
|
@@ -1,23 +1,69 @@
|
|
| 1 |
"""Translate Anthropic SSE streams into OpenAI Responses SSE streams."""
|
| 2 |
|
|
|
|
| 3 |
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
| 4 |
from typing import Any
|
| 5 |
|
|
|
|
|
|
|
| 6 |
from .anthropic_sse import iter_sse_events
|
| 7 |
from .streaming import ResponsesStreamAssembler
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
async def iter_responses_sse_from_anthropic(
|
| 11 |
chunks: AsyncIterable[Any],
|
| 12 |
request: Mapping[str, Any],
|
|
|
|
|
|
|
| 13 |
) -> AsyncIterator[str]:
|
| 14 |
"""Yield Responses SSE events translated from an Anthropic SSE stream."""
|
| 15 |
|
| 16 |
assembler = ResponsesStreamAssembler(request)
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
yield chunk
|
| 20 |
-
|
| 21 |
-
return
|
| 22 |
-
for chunk in assembler.finish_if_needed():
|
| 23 |
-
yield chunk
|
|
|
|
| 1 |
"""Translate Anthropic SSE streams into OpenAI Responses SSE streams."""
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
from collections.abc import AsyncIterable, AsyncIterator, Mapping
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
+
from core.trace import trace_event
|
| 8 |
+
|
| 9 |
from .anthropic_sse import iter_sse_events
|
| 10 |
from .streaming import ResponsesStreamAssembler
|
| 11 |
|
| 12 |
+
DEFAULT_STREAM_INTERRUPTED_MESSAGE = (
|
| 13 |
+
"The upstream response stream ended unexpectedly; the request could not be "
|
| 14 |
+
"completed."
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
|
| 18 |
async def iter_responses_sse_from_anthropic(
|
| 19 |
chunks: AsyncIterable[Any],
|
| 20 |
request: Mapping[str, Any],
|
| 21 |
+
*,
|
| 22 |
+
stream_error_message: str = DEFAULT_STREAM_INTERRUPTED_MESSAGE,
|
| 23 |
) -> AsyncIterator[str]:
|
| 24 |
"""Yield Responses SSE events translated from an Anthropic SSE stream."""
|
| 25 |
|
| 26 |
assembler = ResponsesStreamAssembler(request)
|
| 27 |
+
emitted_any_chunk = False
|
| 28 |
+
try:
|
| 29 |
+
async for event in iter_sse_events(chunks):
|
| 30 |
+
for chunk in assembler.process_anthropic_event(event):
|
| 31 |
+
yield chunk
|
| 32 |
+
emitted_any_chunk = True
|
| 33 |
+
if assembler.terminal:
|
| 34 |
+
return
|
| 35 |
+
for chunk in assembler.finish_if_needed():
|
| 36 |
+
yield chunk
|
| 37 |
+
emitted_any_chunk = True
|
| 38 |
+
except GeneratorExit:
|
| 39 |
+
raise
|
| 40 |
+
except asyncio.CancelledError:
|
| 41 |
+
raise
|
| 42 |
+
except BaseExceptionGroup as exc:
|
| 43 |
+
if not emitted_any_chunk:
|
| 44 |
+
raise
|
| 45 |
+
trace_event(
|
| 46 |
+
stage="responses",
|
| 47 |
+
event="responses.stream.terminal_failure_frame",
|
| 48 |
+
source="openai_responses",
|
| 49 |
+
exc_type=type(exc).__name__,
|
| 50 |
+
)
|
| 51 |
+
for chunk in assembler.fail_response(
|
| 52 |
+
{"error": {"type": "api_error", "message": stream_error_message}}
|
| 53 |
+
):
|
| 54 |
+
yield chunk
|
| 55 |
+
return
|
| 56 |
+
except Exception as exc:
|
| 57 |
+
if not emitted_any_chunk:
|
| 58 |
+
raise
|
| 59 |
+
trace_event(
|
| 60 |
+
stage="responses",
|
| 61 |
+
event="responses.stream.terminal_failure_frame",
|
| 62 |
+
source="openai_responses",
|
| 63 |
+
exc_type=type(exc).__name__,
|
| 64 |
+
)
|
| 65 |
+
for chunk in assembler.fail_response(
|
| 66 |
+
{"error": {"type": "api_error", "message": stream_error_message}}
|
| 67 |
+
):
|
| 68 |
yield chunk
|
| 69 |
+
return
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# First-Frame Gated Streaming PR Plan
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
|
| 5 |
+
Fix issue #1020 at the real boundary: FCC currently commits downstream
|
| 6 |
+
`HTTP 200` as soon as FastAPI receives a `StreamingResponse`, before the
|
| 7 |
+
provider-backed stream has proven it can emit a valid first SSE chunk. If the
|
| 8 |
+
provider fails before that first chunk, Claude Code sees `200` with an empty or
|
| 9 |
+
malformed stream instead of a non-200 response it can retry.
|
| 10 |
+
|
| 11 |
+
The PR should introduce an API egress first-frame gate and align provider
|
| 12 |
+
transports so pre-start failures raise typed HTTP-mappable errors instead of
|
| 13 |
+
being converted into synthetic successful SSE streams. Once the first chunk has
|
| 14 |
+
escaped, HTTP status can no longer change, so post-start failures still need a
|
| 15 |
+
protocol-correct terminal stream frame.
|
| 16 |
+
|
| 17 |
+
Because this changes production API/provider behavior on `main`, bump the
|
| 18 |
+
current patch version from `3.4.12` to `3.4.13` unless `main` advances first,
|
| 19 |
+
then refresh `uv.lock`.
|
| 20 |
+
|
| 21 |
+
## Customer-Facing Contract
|
| 22 |
+
|
| 23 |
+
- `fcc-server` should not return `HTTP 200` for `/v1/messages` or
|
| 24 |
+
`/v1/responses` until the stream can produce its first protocol chunk.
|
| 25 |
+
- Claude Code should receive a non-200 Anthropic-shaped error when upstream
|
| 26 |
+
provider setup/retry fails before any stream output is viable.
|
| 27 |
+
- Codex should receive a non-200 OpenAI-shaped error when the Responses stream
|
| 28 |
+
fails before `response.created`.
|
| 29 |
+
- After streaming has started, clients should receive a parseable terminal
|
| 30 |
+
protocol frame instead of a truncated connection where feasible.
|
| 31 |
+
- Provider retries, midstream recovery, tool salvage, thinking/reasoning, tool
|
| 32 |
+
calls, local web tools, non-streaming `/v1/messages`, and messaging behavior
|
| 33 |
+
should remain unchanged except for the pre-start HTTP status fix.
|
| 34 |
+
|
| 35 |
+
## Grill-Me Decisions
|
| 36 |
+
|
| 37 |
+
### Is this caused by NIM or FCC?
|
| 38 |
+
|
| 39 |
+
Recommended answer: FCC owns the bug. NIM or any upstream can trigger the
|
| 40 |
+
failure by returning 429/5xx/504 or closing early, but FCC currently commits
|
| 41 |
+
downstream `HTTP 200` before upstream viability is known. Reproducing current
|
| 42 |
+
`api.response_streams.anthropic_sse_streaming_response()` shows
|
| 43 |
+
`http.response.start 200` is sent immediately, before the first delayed body
|
| 44 |
+
chunk and even when the body raises before its first chunk.
|
| 45 |
+
|
| 46 |
+
### Is a terminal SSE error frame enough?
|
| 47 |
+
|
| 48 |
+
Recommended answer: no. A terminal frame fixes only post-start truncation. It
|
| 49 |
+
does not restore Claude Code's HTTP retry behavior because the response is
|
| 50 |
+
still `HTTP 200`. The PR needs first-frame gating for pre-start failures plus
|
| 51 |
+
terminal framing for post-start failures.
|
| 52 |
+
|
| 53 |
+
### Should API egress own provider retry?
|
| 54 |
+
|
| 55 |
+
Recommended answer: no. Provider transports keep upstream retries, recovery,
|
| 56 |
+
tool salvage, and provider-specific fallbacks. API egress owns only the HTTP
|
| 57 |
+
commit boundary: do not commit success until there is a first chunk; after
|
| 58 |
+
success is committed, close the protocol cleanly if possible.
|
| 59 |
+
|
| 60 |
+
### Should providers keep emitting synthetic pre-start SSE errors?
|
| 61 |
+
|
| 62 |
+
Recommended answer: no. A provider-side final error before downstream-visible
|
| 63 |
+
output should raise a mapped `ProviderError`. Synthetic Anthropic SSE error
|
| 64 |
+
tails are only appropriate when the stream has already started or provider
|
| 65 |
+
state has produced output that must be closed in protocol shape.
|
| 66 |
+
|
| 67 |
+
### Should `/v1/responses` use a fresh assembler for egress failures?
|
| 68 |
+
|
| 69 |
+
Recommended answer: no. Responses streams are stateful. A post-start
|
| 70 |
+
`response.failed` must be produced by the same `ResponsesStreamAssembler` that
|
| 71 |
+
emitted `response.created`, preserving `response.id`, active output flushes,
|
| 72 |
+
usage, and response metadata.
|
| 73 |
+
|
| 74 |
+
## Architecture Target
|
| 75 |
+
|
| 76 |
+
### API Egress
|
| 77 |
+
|
| 78 |
+
`api/response_streams.py` should own public HTTP streaming commit timing.
|
| 79 |
+
|
| 80 |
+
Add an async first-frame helper that:
|
| 81 |
+
|
| 82 |
+
1. pulls the first chunk from an `AsyncIterator[str]` before constructing the
|
| 83 |
+
public `StreamingResponse`;
|
| 84 |
+
2. returns a protocol JSON error response if the iterator raises before the
|
| 85 |
+
first chunk;
|
| 86 |
+
3. returns a `StreamingResponse` that replays the first chunk and streams the
|
| 87 |
+
rest when the first chunk exists;
|
| 88 |
+
4. wraps the post-first-chunk tail with a terminal-frame guard for unexpected
|
| 89 |
+
non-cancellation failures.
|
| 90 |
+
|
| 91 |
+
The helper should be protocol-agnostic. Protocol-specific call sites provide:
|
| 92 |
+
|
| 93 |
+
- streaming headers;
|
| 94 |
+
- pre-start JSON error envelope builder;
|
| 95 |
+
- post-start terminal frame behavior.
|
| 96 |
+
|
| 97 |
+
Do not import `core/openai_responses` internals directly into API egress. API
|
| 98 |
+
handlers may use their adapter/facade objects.
|
| 99 |
+
|
| 100 |
+
### Provider Execution
|
| 101 |
+
|
| 102 |
+
`api/provider_execution.py` should keep resolving providers, preflight,
|
| 103 |
+
request tracing, raw-payload logging, token counting, and `traced_async_stream`.
|
| 104 |
+
It should not construct `StreamingResponse` and should not own protocol error
|
| 105 |
+
serialization.
|
| 106 |
+
|
| 107 |
+
### Provider Error Mapping
|
| 108 |
+
|
| 109 |
+
Provider transports need a single helper for pre-start final failures, for
|
| 110 |
+
example under `providers/error_mapping.py`, that converts any final stream
|
| 111 |
+
exception into a `ProviderError`:
|
| 112 |
+
|
| 113 |
+
- preserve existing mapped provider statuses for authentication, bad request,
|
| 114 |
+
rate limit, overload, and upstream 5xx cases;
|
| 115 |
+
- preserve existing user-facing error-message sanitization and request-id
|
| 116 |
+
appending;
|
| 117 |
+
- wrap internal stream exceptions such as `TruncatedProviderStreamError` in an
|
| 118 |
+
upstream-style `APIError` rather than letting raw runtime exceptions escape;
|
| 119 |
+
- keep verbose raw exception detail behind existing diagnostic flags.
|
| 120 |
+
|
| 121 |
+
Do not make API egress inspect OpenAI/httpx exception classes directly. API
|
| 122 |
+
egress should receive either a first chunk or a typed exception it can serialize
|
| 123 |
+
for the product protocol.
|
| 124 |
+
|
| 125 |
+
### OpenAI-Chat Transport
|
| 126 |
+
|
| 127 |
+
`providers/transports/openai_chat/stream.py` should distinguish:
|
| 128 |
+
|
| 129 |
+
- **uncommitted pre-start final error**: raise mapped `ProviderError` so the API
|
| 130 |
+
first-frame gate returns non-200;
|
| 131 |
+
- **committed/buffered stream failure**: preserve existing recovery/error-tail
|
| 132 |
+
behavior;
|
| 133 |
+
- **early retry/recovery success**: unchanged;
|
| 134 |
+
- **complete tool salvage**: unchanged.
|
| 135 |
+
|
| 136 |
+
The important classification is not merely whether `message_start` was created
|
| 137 |
+
internally. It is whether anything has escaped the recovery holdback to the API
|
| 138 |
+
iterator. If the holdback has not committed and no buffered events are being
|
| 139 |
+
flushed as client-visible output, pre-start final errors should raise.
|
| 140 |
+
|
| 141 |
+
### Native Anthropic Transport
|
| 142 |
+
|
| 143 |
+
`providers/transports/anthropic_messages/stream.py` should apply the same
|
| 144 |
+
boundary:
|
| 145 |
+
|
| 146 |
+
- no committed/buffered downstream-visible event: raise mapped `ProviderError`;
|
| 147 |
+
- committed or buffered native stream: use native ledger error-tail behavior.
|
| 148 |
+
|
| 149 |
+
This keeps local native providers and future native providers consistent with
|
| 150 |
+
OpenAI-chat behavior.
|
| 151 |
+
|
| 152 |
+
### OpenAI Responses
|
| 153 |
+
|
| 154 |
+
`core/openai_responses/stream.py` and
|
| 155 |
+
`core/openai_responses/streaming/assembler.py` should own post-start Responses
|
| 156 |
+
terminal failures.
|
| 157 |
+
|
| 158 |
+
Do not add a stateless `OpenAIResponsesAdapter.egress_error_frame()` that mints
|
| 159 |
+
a fresh response id. Instead, the iterator returned by
|
| 160 |
+
`OpenAIResponsesAdapter.iter_sse_from_anthropic()` should:
|
| 161 |
+
|
| 162 |
+
- let pre-`response.created` failures propagate to API egress;
|
| 163 |
+
- after `response.created`, catch unexpected non-cancellation failures, call
|
| 164 |
+
`ResponsesStreamAssembler.fail_response(...)` on the active assembler, yield
|
| 165 |
+
the resulting `response.failed`, then re-raise or trace according to the
|
| 166 |
+
existing egress tracing policy.
|
| 167 |
+
|
| 168 |
+
This preserves `response.failed.response.id == response.created.response.id`.
|
| 169 |
+
|
| 170 |
+
### Anthropic Messages
|
| 171 |
+
|
| 172 |
+
For `/v1/messages`, post-start unexpected failures may use a stateless terminal
|
| 173 |
+
Anthropic `event: error` as the final API egress fallback. Provider-owned error
|
| 174 |
+
tails remain preferred when provider code can close content blocks and emit
|
| 175 |
+
`message_delta`/`message_stop`.
|
| 176 |
+
|
| 177 |
+
## Implementation Plan
|
| 178 |
+
|
| 179 |
+
1. Add first-frame response helpers in `api/response_streams.py`.
|
| 180 |
+
- Add a small private result type for either first chunk or pre-start
|
| 181 |
+
exception.
|
| 182 |
+
- Add `async def anthropic_sse_streaming_response(...)` or a new clearly
|
| 183 |
+
named async builder, because first-frame probing must await the iterator.
|
| 184 |
+
- Add the equivalent Responses builder with injected OpenAI error-envelope
|
| 185 |
+
handling.
|
| 186 |
+
- Keep wrappers protocol-agnostic and make call sites explicit.
|
| 187 |
+
|
| 188 |
+
2. Update `MessagesHandler._to_public_response()`.
|
| 189 |
+
- Await the new Anthropic streaming response builder for `stream != false`.
|
| 190 |
+
- Convert pre-start `ProviderError` to Anthropic JSON with the provider
|
| 191 |
+
status code.
|
| 192 |
+
- Convert unexpected pre-start exceptions to a safe 500 Anthropic JSON error
|
| 193 |
+
using existing safe logging rules.
|
| 194 |
+
- Keep `stream: false` aggregation unchanged.
|
| 195 |
+
|
| 196 |
+
3. Update `ResponsesHandler.create()`.
|
| 197 |
+
- Await the new Responses streaming response builder.
|
| 198 |
+
- Convert pre-start `ProviderError` to OpenAI-shaped JSON using
|
| 199 |
+
`OpenAIResponsesAdapter.error_payload()`.
|
| 200 |
+
- Convert unexpected pre-start exceptions to safe 500 OpenAI-shaped JSON.
|
| 201 |
+
- Keep request conversion errors and `stream: false` rejection unchanged.
|
| 202 |
+
|
| 203 |
+
4. Add a provider-side pre-start failure exception path.
|
| 204 |
+
- Introduce a small neutral helper in provider/shared error mapping that
|
| 205 |
+
always returns a `ProviderError` for a final pre-start stream exception.
|
| 206 |
+
- In OpenAI-chat final-error handling, if the recovery holdback is not
|
| 207 |
+
committed and no event should be exposed, raise `map_error(...)` instead
|
| 208 |
+
of yielding `emit_error_tail(...)`.
|
| 209 |
+
- In native Anthropic final-error handling, raise mapped provider errors when
|
| 210 |
+
no native event has been committed/buffered to the API.
|
| 211 |
+
- Preserve existing synthetic SSE tails once events have escaped or must be
|
| 212 |
+
closed.
|
| 213 |
+
|
| 214 |
+
5. Harden post-start terminal fallback.
|
| 215 |
+
- Add Anthropic terminal-frame serialization under
|
| 216 |
+
`core/anthropic/streaming/` only as a last-resort egress fallback.
|
| 217 |
+
- In Responses stream conversion, add same-assembler failure handling for
|
| 218 |
+
post-`response.created` exceptions.
|
| 219 |
+
- Do not mint fresh Responses IDs for a terminal failure.
|
| 220 |
+
|
| 221 |
+
6. Update architecture docs.
|
| 222 |
+
- Document that API egress owns first-frame HTTP commit gating.
|
| 223 |
+
- Document that providers raise pre-start final failures but own retries and
|
| 224 |
+
midstream recovery.
|
| 225 |
+
- Document that Responses terminal failures are assembler-owned because
|
| 226 |
+
Responses streams are stateful.
|
| 227 |
+
|
| 228 |
+
7. Bump version and lockfile.
|
| 229 |
+
- Update `[project].version` from `3.4.12` to `3.4.13` unless `main`
|
| 230 |
+
advances.
|
| 231 |
+
- Run `uv lock`.
|
| 232 |
+
|
| 233 |
+
## Test Plan
|
| 234 |
+
|
| 235 |
+
### API Egress Tests
|
| 236 |
+
|
| 237 |
+
- ASGI-level test proving `http.response.start 200` is not sent until the first
|
| 238 |
+
chunk is available.
|
| 239 |
+
- ASGI-level test proving a pre-first-chunk `ProviderError` returns non-200 JSON
|
| 240 |
+
and sends no SSE body.
|
| 241 |
+
- ASGI-level test proving a pre-first-chunk unexpected exception returns safe
|
| 242 |
+
500 JSON and does not leak raw exception text by default.
|
| 243 |
+
- ASGI-level test proving post-first-chunk exceptions yield a terminal frame
|
| 244 |
+
before the exception closes the ASGI body.
|
| 245 |
+
- Cancellation and `GeneratorExit` tests proving no terminal frame is emitted
|
| 246 |
+
into a dead socket.
|
| 247 |
+
|
| 248 |
+
### Messages API Tests
|
| 249 |
+
|
| 250 |
+
- `/v1/messages` provider pre-start `RateLimitError` returns HTTP 429
|
| 251 |
+
Anthropic-shaped JSON.
|
| 252 |
+
- `/v1/messages` provider pre-start `APIError(status_code=504)` returns HTTP
|
| 253 |
+
non-200 using the existing provider mapping, not HTTP 200.
|
| 254 |
+
- `/v1/messages` delayed valid provider stream still returns `text/event-stream`
|
| 255 |
+
and valid Anthropic SSE.
|
| 256 |
+
- `/v1/messages stream:false` aggregation remains unchanged.
|
| 257 |
+
|
| 258 |
+
### Responses API Tests
|
| 259 |
+
|
| 260 |
+
- `/v1/responses` provider pre-start `RateLimitError` returns HTTP 429
|
| 261 |
+
OpenAI-shaped JSON.
|
| 262 |
+
- `/v1/responses` delayed valid provider stream still returns
|
| 263 |
+
`text/event-stream`.
|
| 264 |
+
- Responses post-start exception emits `response.failed` with the same
|
| 265 |
+
`response.id` as `response.created`.
|
| 266 |
+
- Existing provider-emitted Anthropic `event:error` still converts to
|
| 267 |
+
`response.failed` with the same active response id.
|
| 268 |
+
|
| 269 |
+
### Provider Transport Tests
|
| 270 |
+
|
| 271 |
+
- OpenAI-chat exhausted pre-stream 429/5xx/transport failure raises mapped
|
| 272 |
+
`ProviderError` before any downstream-visible event.
|
| 273 |
+
- OpenAI-chat retry success path remains unchanged.
|
| 274 |
+
- OpenAI-chat midstream text failure still uses recovery/terminal tail behavior.
|
| 275 |
+
- OpenAI-chat complete tool salvage remains unchanged.
|
| 276 |
+
- Native Anthropic pre-send/pre-event failure raises mapped `ProviderError`.
|
| 277 |
+
- Native Anthropic midstream native event failure still closes through native
|
| 278 |
+
recovery/error-tail behavior.
|
| 279 |
+
|
| 280 |
+
### Contract Tests
|
| 281 |
+
|
| 282 |
+
- API response stream helper is the only owner of first-frame HTTP commit
|
| 283 |
+
gating.
|
| 284 |
+
- Responses egress terminal failure is assembler-owned, not generated by a
|
| 285 |
+
stateless adapter helper.
|
| 286 |
+
- `api/provider_execution.py` remains free of `StreamingResponse` ownership.
|
| 287 |
+
- Architecture relative links still resolve.
|
| 288 |
+
|
| 289 |
+
## Verification Commands
|
| 290 |
+
|
| 291 |
+
Run targeted tests first:
|
| 292 |
+
|
| 293 |
+
```powershell
|
| 294 |
+
uv run pytest tests/api/test_response_streams.py tests/api/test_api_handlers.py tests/api/test_openai_responses.py
|
| 295 |
+
uv run pytest tests/providers/test_streaming_errors.py tests/providers/test_anthropic_messages.py tests/providers/test_openai_compat_5xx_retry.py tests/providers/test_anthropic_messages_429_retry.py
|
| 296 |
+
uv run pytest tests/core/openai_responses/test_sse.py tests/contracts/test_import_boundaries.py tests/contracts/test_architecture_contracts.py
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
Then run the final local gate:
|
| 300 |
+
|
| 301 |
+
```powershell
|
| 302 |
+
.\scripts\ci.ps1
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
## Risks And Guardrails
|
| 306 |
+
|
| 307 |
+
- Pulling the first chunk delays HTTP headers until upstream viability is known.
|
| 308 |
+
This is intentional for Claude/Codex retry correctness, but tests should prove
|
| 309 |
+
normal streams still begin promptly after the first provider chunk.
|
| 310 |
+
- A provider can emit a synthetic SSE error as its first chunk. The PR should
|
| 311 |
+
avoid relying only on API first-chunk gating; providers must raise pre-start
|
| 312 |
+
final failures instead of creating synthetic success streams.
|
| 313 |
+
- Responses post-start fallback must preserve assembler identity. Any test that
|
| 314 |
+
shape-asserts only `response.failed` without comparing IDs is insufficient.
|
| 315 |
+
- Do not broaden API egress into retry/recovery ownership. That would erode the
|
| 316 |
+
provider transport boundaries established in `ARCHITECTURE.md`.
|
| 317 |
+
|
| 318 |
+
## Out Of Scope
|
| 319 |
+
|
| 320 |
+
- Changing retry counts, backoff, or adding `Retry-After` support.
|
| 321 |
+
- Redesigning OpenAI-chat tool-call buffering.
|
| 322 |
+
- Changing model routing, thinking/reasoning policy, or provider request bodies.
|
| 323 |
+
- Changing messaging queue/cancellation behavior.
|
| 324 |
+
- Restoring non-streaming `/v1/responses`.
|
|
@@ -19,6 +19,7 @@ from providers.exceptions import (
|
|
| 19 |
AuthenticationError,
|
| 20 |
InvalidRequestError,
|
| 21 |
OverloadedError,
|
|
|
|
| 22 |
RateLimitError,
|
| 23 |
)
|
| 24 |
from providers.rate_limit import GlobalRateLimiter
|
|
@@ -313,6 +314,40 @@ def user_visible_message_for_mapped_provider_error(
|
|
| 313 |
return get_user_facing_error_message(mapped, read_timeout_s=read_timeout_s)
|
| 314 |
|
| 315 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
def map_error(
|
| 317 |
e: Exception, *, rate_limiter: GlobalRateLimiter | None = None
|
| 318 |
) -> Exception:
|
|
|
|
| 19 |
AuthenticationError,
|
| 20 |
InvalidRequestError,
|
| 21 |
OverloadedError,
|
| 22 |
+
ProviderError,
|
| 23 |
RateLimitError,
|
| 24 |
)
|
| 25 |
from providers.rate_limit import GlobalRateLimiter
|
|
|
|
| 314 |
return get_user_facing_error_message(mapped, read_timeout_s=read_timeout_s)
|
| 315 |
|
| 316 |
|
| 317 |
+
def map_stream_start_error(
|
| 318 |
+
error: Exception,
|
| 319 |
+
*,
|
| 320 |
+
provider_name: str,
|
| 321 |
+
read_timeout_s: float | None,
|
| 322 |
+
request_id: str | None,
|
| 323 |
+
rate_limiter: GlobalRateLimiter | None = None,
|
| 324 |
+
) -> ProviderError:
|
| 325 |
+
"""Map a final pre-start stream failure into an HTTP-serializable provider error.
|
| 326 |
+
|
| 327 |
+
Providers call this only when no downstream-visible SSE chunk has escaped.
|
| 328 |
+
At that boundary the API can still return a real non-200 response, which is
|
| 329 |
+
preferable to synthesizing a successful SSE stream that starts with an error
|
| 330 |
+
message.
|
| 331 |
+
"""
|
| 332 |
+
mapped = map_error(error, rate_limiter=rate_limiter)
|
| 333 |
+
detail = extract_provider_error_detail(error)
|
| 334 |
+
message = user_visible_message_for_mapped_provider_error(
|
| 335 |
+
mapped,
|
| 336 |
+
provider_name=provider_name,
|
| 337 |
+
read_timeout_s=read_timeout_s,
|
| 338 |
+
detail=detail,
|
| 339 |
+
request_id=request_id,
|
| 340 |
+
)
|
| 341 |
+
if isinstance(mapped, ProviderError):
|
| 342 |
+
return ProviderError(
|
| 343 |
+
message,
|
| 344 |
+
status_code=mapped.status_code,
|
| 345 |
+
error_type=mapped.error_type,
|
| 346 |
+
raw_error=mapped.raw_error,
|
| 347 |
+
)
|
| 348 |
+
return APIError(message, status_code=502, raw_error=str(error))
|
| 349 |
+
|
| 350 |
+
|
| 351 |
def map_error(
|
| 352 |
e: Exception, *, rate_limiter: GlobalRateLimiter | None = None
|
| 353 |
) -> Exception:
|
|
@@ -14,6 +14,7 @@ from core.anthropic.streaming import (
|
|
| 14 |
tool_schemas_by_name,
|
| 15 |
)
|
| 16 |
from core.trace import provider_native_messages_body_snapshot, trace_event
|
|
|
|
| 17 |
from providers.transports.http import maybe_await_aclose
|
| 18 |
|
| 19 |
from .recovery import AnthropicMessagesRecovery
|
|
@@ -205,28 +206,26 @@ class AnthropicMessagesStreamAdapter:
|
|
| 205 |
request_id=self._request_id,
|
| 206 |
error_message=error_message,
|
| 207 |
exc_type=type(error).__name__,
|
| 208 |
-
mid_stream=(
|
| 209 |
-
sent_any_event
|
| 210 |
-
or decision.committed
|
| 211 |
-
or decision.has_buffered
|
| 212 |
-
),
|
| 213 |
)
|
| 214 |
-
if decision.committed
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
| 219 |
for event in ledger.midstream_error_tail(error_message):
|
| 220 |
yield event
|
| 221 |
else:
|
| 222 |
recovery.discard()
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
return
|
| 231 |
finally:
|
| 232 |
if response is not None and not response.is_closed:
|
|
|
|
| 14 |
tool_schemas_by_name,
|
| 15 |
)
|
| 16 |
from core.trace import provider_native_messages_body_snapshot, trace_event
|
| 17 |
+
from providers.error_mapping import map_stream_start_error
|
| 18 |
from providers.transports.http import maybe_await_aclose
|
| 19 |
|
| 20 |
from .recovery import AnthropicMessagesRecovery
|
|
|
|
| 206 |
request_id=self._request_id,
|
| 207 |
error_message=error_message,
|
| 208 |
exc_type=type(error).__name__,
|
| 209 |
+
mid_stream=(sent_any_event or decision.committed),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
)
|
| 211 |
+
if decision.committed:
|
| 212 |
+
for event in ledger.midstream_error_tail(error_message):
|
| 213 |
+
yield event
|
| 214 |
+
elif decision.has_buffered and complete_tool_salvageable:
|
| 215 |
+
for event in recovery.flush():
|
| 216 |
+
sent_any_event = True
|
| 217 |
+
yield event
|
| 218 |
for event in ledger.midstream_error_tail(error_message):
|
| 219 |
yield event
|
| 220 |
else:
|
| 221 |
recovery.discard()
|
| 222 |
+
raise map_stream_start_error(
|
| 223 |
+
error,
|
| 224 |
+
provider_name=tag,
|
| 225 |
+
read_timeout_s=self._transport._config.http_read_timeout,
|
| 226 |
+
request_id=self._request_id,
|
| 227 |
+
rate_limiter=self._transport._global_rate_limiter,
|
| 228 |
+
) from error
|
| 229 |
return
|
| 230 |
finally:
|
| 231 |
if response is not None and not response.is_closed:
|
|
@@ -20,7 +20,7 @@ from core.anthropic.streaming import (
|
|
| 20 |
map_stop_reason,
|
| 21 |
)
|
| 22 |
from core.trace import provider_chat_body_snapshot, trace_event
|
| 23 |
-
from providers.error_mapping import map_error
|
| 24 |
from providers.transports.http import maybe_await_aclose
|
| 25 |
|
| 26 |
from .recovery import OpenAIChatRecovery
|
|
@@ -288,12 +288,22 @@ class OpenAIChatStreamAdapter:
|
|
| 288 |
)
|
| 289 |
).__name__,
|
| 290 |
)
|
| 291 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
for event in recovery.flush():
|
| 293 |
yield event
|
| 294 |
elif not decision.committed:
|
| 295 |
recovery.discard()
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
for event in self._recovery.emit_error_tail(ledger, error_message):
|
| 298 |
yield event
|
| 299 |
return
|
|
|
|
| 20 |
map_stop_reason,
|
| 21 |
)
|
| 22 |
from core.trace import provider_chat_body_snapshot, trace_event
|
| 23 |
+
from providers.error_mapping import map_error, map_stream_start_error
|
| 24 |
from providers.transports.http import maybe_await_aclose
|
| 25 |
|
| 26 |
from .recovery import OpenAIChatRecovery
|
|
|
|
| 288 |
)
|
| 289 |
).__name__,
|
| 290 |
)
|
| 291 |
+
if (
|
| 292 |
+
not decision.committed
|
| 293 |
+
and decision.has_buffered
|
| 294 |
+
and complete_tool_salvageable
|
| 295 |
+
):
|
| 296 |
for event in recovery.flush():
|
| 297 |
yield event
|
| 298 |
elif not decision.committed:
|
| 299 |
recovery.discard()
|
| 300 |
+
raise map_stream_start_error(
|
| 301 |
+
error,
|
| 302 |
+
provider_name=tag,
|
| 303 |
+
read_timeout_s=self._transport._config.http_read_timeout,
|
| 304 |
+
request_id=self._request_id,
|
| 305 |
+
rate_limiter=self._transport._global_rate_limiter,
|
| 306 |
+
) from error
|
| 307 |
for event in self._recovery.emit_error_tail(ledger, error_message):
|
| 308 |
yield event
|
| 309 |
return
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "3.4.
|
| 8 |
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
+
version = "3.4.13"
|
| 8 |
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
@@ -4,6 +4,7 @@ import pytest
|
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
|
| 6 |
from api.app import create_app
|
|
|
|
| 7 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 8 |
|
| 9 |
app = create_app()
|
|
@@ -22,6 +23,13 @@ async def _mock_stream_response(*args, **kwargs):
|
|
| 22 |
yield "[DONE]\n\n"
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
mock_provider.stream_response = _mock_stream_response
|
| 26 |
|
| 27 |
|
|
@@ -95,6 +103,29 @@ def test_create_message_stream(client: TestClient):
|
|
| 95 |
assert b"message_start" in content or b"event:" in content
|
| 96 |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def test_create_message_accepts_system_role_messages(client: TestClient):
|
| 99 |
"""Create message accepts latest-client system messages."""
|
| 100 |
mock_provider.stream_response = _mock_stream_response
|
|
|
|
| 4 |
from fastapi.testclient import TestClient
|
| 5 |
|
| 6 |
from api.app import create_app
|
| 7 |
+
from providers.exceptions import RateLimitError
|
| 8 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 9 |
|
| 10 |
app = create_app()
|
|
|
|
| 23 |
yield "[DONE]\n\n"
|
| 24 |
|
| 25 |
|
| 26 |
+
async def _mock_pre_start_rate_limit(*args, **kwargs):
|
| 27 |
+
"""Provider stream that fails before any downstream-visible SSE chunk."""
|
| 28 |
+
_stream_response_calls.append((args, kwargs))
|
| 29 |
+
raise RateLimitError("upstream is busy")
|
| 30 |
+
yield "unreachable"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
mock_provider.stream_response = _mock_stream_response
|
| 34 |
|
| 35 |
|
|
|
|
| 103 |
assert b"message_start" in content or b"event:" in content
|
| 104 |
|
| 105 |
|
| 106 |
+
def test_create_message_pre_start_provider_error_returns_non_200_json(
|
| 107 |
+
client: TestClient,
|
| 108 |
+
):
|
| 109 |
+
"""Pre-first-chunk provider errors should not commit HTTP 200."""
|
| 110 |
+
mock_provider.stream_response = _mock_pre_start_rate_limit
|
| 111 |
+
payload = {
|
| 112 |
+
"model": "claude-3-sonnet",
|
| 113 |
+
"messages": [{"role": "user", "content": "Hi"}],
|
| 114 |
+
"max_tokens": 100,
|
| 115 |
+
"stream": True,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
response = client.post("/v1/messages", json=payload)
|
| 119 |
+
|
| 120 |
+
assert response.status_code == 429
|
| 121 |
+
assert response.headers["content-type"].startswith("application/json")
|
| 122 |
+
assert response.json() == {
|
| 123 |
+
"type": "error",
|
| 124 |
+
"error": {"type": "rate_limit_error", "message": "upstream is busy"},
|
| 125 |
+
}
|
| 126 |
+
mock_provider.stream_response = _mock_stream_response
|
| 127 |
+
|
| 128 |
+
|
| 129 |
def test_create_message_accepts_system_role_messages(client: TestClient):
|
| 130 |
"""Create message accepts latest-client system messages."""
|
| 131 |
mock_provider.stream_response = _mock_stream_response
|
|
@@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
|
|
| 7 |
from api.app import create_app
|
| 8 |
from core.anthropic.stream_contracts import parse_sse_text
|
| 9 |
from core.anthropic.streaming import format_sse_event
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class FakeProvider:
|
|
@@ -23,6 +24,29 @@ class FakeProvider:
|
|
| 23 |
yield chunk
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
@pytest.fixture
|
| 27 |
def responses_client():
|
| 28 |
provider = FakeProvider(_anthropic_text_stream("Hello from provider"))
|
|
@@ -73,6 +97,49 @@ def test_create_response_stream_routes_through_provider(
|
|
| 73 |
assert routed.max_tokens == 32
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
def test_create_response_stream_bypasses_local_message_optimizations() -> None:
|
| 77 |
provider = FakeProvider(_anthropic_text_stream("Provider response"))
|
| 78 |
app = create_app(lifespan_enabled=False)
|
|
|
|
| 7 |
from api.app import create_app
|
| 8 |
from core.anthropic.stream_contracts import parse_sse_text
|
| 9 |
from core.anthropic.streaming import format_sse_event
|
| 10 |
+
from providers.exceptions import RateLimitError
|
| 11 |
|
| 12 |
|
| 13 |
class FakeProvider:
|
|
|
|
| 24 |
yield chunk
|
| 25 |
|
| 26 |
|
| 27 |
+
class PreStartFailingProvider(FakeProvider):
|
| 28 |
+
def __init__(self) -> None:
|
| 29 |
+
super().__init__([])
|
| 30 |
+
|
| 31 |
+
async def stream_response(self, request_data, **_kwargs):
|
| 32 |
+
self.requests.append(request_data)
|
| 33 |
+
self.stream_kwargs.append(_kwargs)
|
| 34 |
+
raise RateLimitError("upstream is busy")
|
| 35 |
+
yield "unreachable"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class PostStartFailingProvider(FakeProvider):
|
| 39 |
+
def __init__(self) -> None:
|
| 40 |
+
super().__init__([format_sse_event("message_start", {"type": "message_start"})])
|
| 41 |
+
|
| 42 |
+
async def stream_response(self, request_data, **_kwargs):
|
| 43 |
+
self.requests.append(request_data)
|
| 44 |
+
self.stream_kwargs.append(_kwargs)
|
| 45 |
+
for chunk in self.chunks:
|
| 46 |
+
yield chunk
|
| 47 |
+
raise RuntimeError("socket closed")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
@pytest.fixture
|
| 51 |
def responses_client():
|
| 52 |
provider = FakeProvider(_anthropic_text_stream("Hello from provider"))
|
|
|
|
| 97 |
assert routed.max_tokens == 32
|
| 98 |
|
| 99 |
|
| 100 |
+
def test_create_response_pre_start_provider_error_returns_openai_error() -> None:
|
| 101 |
+
provider = PreStartFailingProvider()
|
| 102 |
+
app = create_app(lifespan_enabled=False)
|
| 103 |
+
with (
|
| 104 |
+
patch("api.dependencies.resolve_provider", return_value=provider),
|
| 105 |
+
TestClient(app) as client,
|
| 106 |
+
):
|
| 107 |
+
response = client.post(
|
| 108 |
+
"/v1/responses",
|
| 109 |
+
json={
|
| 110 |
+
"model": "nvidia_nim/test-model",
|
| 111 |
+
"input": "Hello",
|
| 112 |
+
},
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
assert response.status_code == 429
|
| 116 |
+
payload = response.json()
|
| 117 |
+
assert payload["error"]["type"] == "rate_limit_error"
|
| 118 |
+
assert payload["error"]["message"] == "upstream is busy"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_create_response_post_start_failure_preserves_response_id() -> None:
|
| 122 |
+
provider = PostStartFailingProvider()
|
| 123 |
+
app = create_app(lifespan_enabled=False)
|
| 124 |
+
with (
|
| 125 |
+
patch("api.dependencies.resolve_provider", return_value=provider),
|
| 126 |
+
TestClient(app) as client,
|
| 127 |
+
):
|
| 128 |
+
response = client.post(
|
| 129 |
+
"/v1/responses",
|
| 130 |
+
json={
|
| 131 |
+
"model": "nvidia_nim/test-model",
|
| 132 |
+
"input": "Hello",
|
| 133 |
+
},
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
assert response.status_code == 200
|
| 137 |
+
events = parse_sse_text(response.text)
|
| 138 |
+
assert [event.event for event in events] == ["response.created", "response.failed"]
|
| 139 |
+
assert events[-1].data["response"]["id"] == events[0].data["response"]["id"]
|
| 140 |
+
assert events[-1].data["response"]["status"] == "failed"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
def test_create_response_stream_bypasses_local_message_optimizations() -> None:
|
| 144 |
provider = FakeProvider(_anthropic_text_stream("Provider response"))
|
| 145 |
app = create_app(lifespan_enabled=False)
|
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for public SSE response start gating."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
from collections.abc import AsyncGenerator
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 9 |
+
|
| 10 |
+
from api.response_streams import (
|
| 11 |
+
EGRESS_STREAM_INTERRUPTED_MESSAGE,
|
| 12 |
+
anthropic_sse_streaming_response,
|
| 13 |
+
)
|
| 14 |
+
from core.anthropic.stream_contracts import parse_sse_text
|
| 15 |
+
from providers.exceptions import RateLimitError
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def _body_chunks(chunks: list[str]) -> AsyncGenerator[str]:
|
| 19 |
+
for chunk in chunks:
|
| 20 |
+
yield chunk
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def _body_raises(exc: BaseException) -> AsyncGenerator[str]:
|
| 24 |
+
raise exc
|
| 25 |
+
yield "unreachable"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def _body_then_raises(
|
| 29 |
+
chunks: list[str], exc: BaseException
|
| 30 |
+
) -> AsyncGenerator[str]:
|
| 31 |
+
for chunk in chunks:
|
| 32 |
+
yield chunk
|
| 33 |
+
raise exc
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _json_error(exc: BaseException) -> JSONResponse:
|
| 37 |
+
if isinstance(exc, RateLimitError):
|
| 38 |
+
return JSONResponse(
|
| 39 |
+
status_code=exc.status_code,
|
| 40 |
+
content=exc.to_anthropic_format(),
|
| 41 |
+
)
|
| 42 |
+
return JSONResponse(
|
| 43 |
+
status_code=500,
|
| 44 |
+
content={
|
| 45 |
+
"type": "error",
|
| 46 |
+
"error": {"type": "api_error", "message": "failed"},
|
| 47 |
+
},
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def _drain(response: StreamingResponse) -> str:
|
| 52 |
+
parts = [
|
| 53 |
+
chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk)
|
| 54 |
+
async for chunk in response.body_iterator
|
| 55 |
+
]
|
| 56 |
+
return "".join(parts)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.mark.asyncio
|
| 60 |
+
async def test_anthropic_response_waits_for_first_chunk_before_returning() -> None:
|
| 61 |
+
ready = asyncio.Event()
|
| 62 |
+
|
| 63 |
+
async def body() -> AsyncGenerator[str]:
|
| 64 |
+
await ready.wait()
|
| 65 |
+
yield 'event: message_start\ndata: {"type":"message_start"}\n\n'
|
| 66 |
+
|
| 67 |
+
task = asyncio.create_task(
|
| 68 |
+
anthropic_sse_streaming_response(
|
| 69 |
+
body(),
|
| 70 |
+
pre_start_error_response=_json_error,
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
await asyncio.sleep(0)
|
| 75 |
+
assert not task.done()
|
| 76 |
+
|
| 77 |
+
ready.set()
|
| 78 |
+
response = await asyncio.wait_for(task, timeout=1)
|
| 79 |
+
assert isinstance(response, StreamingResponse)
|
| 80 |
+
assert "message_start" in await _drain(response)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.mark.asyncio
|
| 84 |
+
async def test_anthropic_pre_start_provider_error_returns_non_200_json() -> None:
|
| 85 |
+
response = await anthropic_sse_streaming_response(
|
| 86 |
+
_body_raises(RateLimitError("provider says slow down")),
|
| 87 |
+
pre_start_error_response=_json_error,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
assert isinstance(response, JSONResponse)
|
| 91 |
+
assert response.status_code == 429
|
| 92 |
+
body = json.loads(bytes(response.body))
|
| 93 |
+
assert body["error"]["type"] == "rate_limit_error"
|
| 94 |
+
assert body["error"]["message"] == "provider says slow down"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@pytest.mark.asyncio
|
| 98 |
+
async def test_anthropic_post_start_exception_emits_terminal_error_frame() -> None:
|
| 99 |
+
response = await anthropic_sse_streaming_response(
|
| 100 |
+
_body_then_raises(
|
| 101 |
+
['event: message_start\ndata: {"type":"message_start"}\n\n'],
|
| 102 |
+
RuntimeError("socket cut"),
|
| 103 |
+
),
|
| 104 |
+
pre_start_error_response=_json_error,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
assert isinstance(response, StreamingResponse)
|
| 108 |
+
text = await _drain(response)
|
| 109 |
+
events = parse_sse_text(text)
|
| 110 |
+
assert [event.event for event in events] == ["message_start", "error"]
|
| 111 |
+
assert events[-1].data["error"]["message"] == EGRESS_STREAM_INTERRUPTED_MESSAGE
|
|
@@ -7,7 +7,7 @@ import httpx
|
|
| 7 |
import pytest
|
| 8 |
|
| 9 |
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
|
| 10 |
-
from core.anthropic.stream_contracts import
|
| 11 |
from core.anthropic.streaming import (
|
| 12 |
MIDSTREAM_RECOVERY_ATTEMPTS,
|
| 13 |
AnthropicStreamLedger,
|
|
@@ -15,9 +15,9 @@ from core.anthropic.streaming import (
|
|
| 15 |
format_sse_event,
|
| 16 |
)
|
| 17 |
from providers.base import ProviderConfig
|
|
|
|
| 18 |
from providers.transports.anthropic_messages import AnthropicMessagesTransport
|
| 19 |
from providers.transports.anthropic_messages.recovery import AnthropicMessagesRecovery
|
| 20 |
-
from tests.stream_contract import assert_canonical_stream_error_envelope
|
| 21 |
|
| 22 |
|
| 23 |
class NativeProvider(AnthropicMessagesTransport):
|
|
@@ -251,7 +251,7 @@ async def test_stream_uses_retry_builds_request_and_closes_response(
|
|
| 251 |
|
| 252 |
|
| 253 |
@pytest.mark.asyncio
|
| 254 |
-
async def
|
| 255 |
provider_config,
|
| 256 |
):
|
| 257 |
provider = NativeProvider(provider_config)
|
|
@@ -266,25 +266,21 @@ async def test_stream_maps_non_200_to_error_event_and_closes_response(
|
|
| 266 |
new_callable=AsyncMock,
|
| 267 |
return_value=response,
|
| 268 |
),
|
|
|
|
| 269 |
):
|
| 270 |
-
|
| 271 |
-
event async for event in provider.stream_response(req, request_id="REQ_123")
|
| 272 |
-
]
|
| 273 |
|
| 274 |
assert response.is_closed
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
blob = "".join(events)
|
| 279 |
-
assert "Internal Server Error" in blob
|
| 280 |
-
assert "REQ_123" in blob
|
| 281 |
|
| 282 |
|
| 283 |
@pytest.mark.asyncio
|
| 284 |
-
async def
|
| 285 |
provider_config,
|
| 286 |
):
|
| 287 |
-
"""
|
| 288 |
provider = NativeProvider(provider_config)
|
| 289 |
req = MockRequest()
|
| 290 |
mid = "msg_midstream_err"
|
|
@@ -325,17 +321,11 @@ async def test_midstream_error_closes_open_block_and_uses_fresh_content_index(
|
|
| 325 |
new_callable=AsyncMock,
|
| 326 |
return_value=response,
|
| 327 |
),
|
|
|
|
| 328 |
):
|
| 329 |
-
|
| 330 |
|
| 331 |
-
|
| 332 |
-
events, user_message_substr="mid-stream failure"
|
| 333 |
-
)
|
| 334 |
-
parsed = parse_sse_text("".join(events))
|
| 335 |
-
starts = [e for e in parsed if e.event == "content_block_start"]
|
| 336 |
-
assert event_index(starts[0]) == 0
|
| 337 |
-
assert event_index(starts[-1]) == 1
|
| 338 |
-
assert {event_index(e) for e in parsed if e.event == "content_block_stop"} == {0, 1}
|
| 339 |
|
| 340 |
|
| 341 |
@pytest.mark.asyncio
|
|
|
|
| 7 |
import pytest
|
| 8 |
|
| 9 |
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
|
| 10 |
+
from core.anthropic.stream_contracts import parse_sse_text
|
| 11 |
from core.anthropic.streaming import (
|
| 12 |
MIDSTREAM_RECOVERY_ATTEMPTS,
|
| 13 |
AnthropicStreamLedger,
|
|
|
|
| 15 |
format_sse_event,
|
| 16 |
)
|
| 17 |
from providers.base import ProviderConfig
|
| 18 |
+
from providers.exceptions import ProviderError
|
| 19 |
from providers.transports.anthropic_messages import AnthropicMessagesTransport
|
| 20 |
from providers.transports.anthropic_messages.recovery import AnthropicMessagesRecovery
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class NativeProvider(AnthropicMessagesTransport):
|
|
|
|
| 251 |
|
| 252 |
|
| 253 |
@pytest.mark.asyncio
|
| 254 |
+
async def test_stream_maps_pre_start_non_200_to_provider_error_and_closes_response(
|
| 255 |
provider_config,
|
| 256 |
):
|
| 257 |
provider = NativeProvider(provider_config)
|
|
|
|
| 266 |
new_callable=AsyncMock,
|
| 267 |
return_value=response,
|
| 268 |
),
|
| 269 |
+
pytest.raises(ProviderError) as exc_info,
|
| 270 |
):
|
| 271 |
+
[event async for event in provider.stream_response(req, request_id="REQ_123")]
|
|
|
|
|
|
|
| 272 |
|
| 273 |
assert response.is_closed
|
| 274 |
+
assert "Upstream provider TEST_NATIVE returned HTTP 500." in exc_info.value.message
|
| 275 |
+
assert "Internal Server Error" in exc_info.value.message
|
| 276 |
+
assert "REQ_123" in exc_info.value.message
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
|
| 279 |
@pytest.mark.asyncio
|
| 280 |
+
async def test_precommit_native_error_raises_without_leaking_open_block(
|
| 281 |
provider_config,
|
| 282 |
):
|
| 283 |
+
"""A native error before holdback commit raises instead of sending HTTP 200 SSE."""
|
| 284 |
provider = NativeProvider(provider_config)
|
| 285 |
req = MockRequest()
|
| 286 |
mid = "msg_midstream_err"
|
|
|
|
| 321 |
new_callable=AsyncMock,
|
| 322 |
return_value=response,
|
| 323 |
),
|
| 324 |
+
pytest.raises(ProviderError) as exc_info,
|
| 325 |
):
|
| 326 |
+
[e async for e in provider.stream_response(req)]
|
| 327 |
|
| 328 |
+
assert "mid-stream failure" in exc_info.value.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
|
| 331 |
@pytest.mark.asyncio
|
|
@@ -8,13 +8,13 @@ import pytest
|
|
| 8 |
|
| 9 |
from core.anthropic.stream_contracts import event_names, parse_sse_text
|
| 10 |
from providers.base import ProviderConfig
|
|
|
|
| 11 |
from providers.rate_limit import GlobalRateLimiter
|
| 12 |
from tests.providers.test_anthropic_messages import (
|
| 13 |
FakeResponse,
|
| 14 |
MockRequest,
|
| 15 |
NativeProvider,
|
| 16 |
)
|
| 17 |
-
from tests.stream_contract import assert_canonical_stream_error_envelope
|
| 18 |
|
| 19 |
|
| 20 |
def _assert_minimal_success_stream(events: list[str]) -> None:
|
|
@@ -236,15 +236,13 @@ async def test_native_stream_5xx_retry_exhausted(provider_config, status_code, s
|
|
| 236 |
return_value=bad,
|
| 237 |
) as mock_send,
|
| 238 |
patch("asyncio.sleep", new_callable=AsyncMock),
|
|
|
|
| 239 |
):
|
| 240 |
-
|
| 241 |
|
| 242 |
assert mock_send.await_count == 5
|
| 243 |
assert bad.is_closed
|
| 244 |
-
|
| 245 |
-
events,
|
| 246 |
-
user_message_substr=substr,
|
| 247 |
-
)
|
| 248 |
finally:
|
| 249 |
GlobalRateLimiter.reset_instance()
|
| 250 |
|
|
@@ -293,8 +291,9 @@ async def test_native_stream_connection_error_retry_exhausted(provider_config):
|
|
| 293 |
patch(
|
| 294 |
"providers.transports.anthropic_messages.stream.trace_event"
|
| 295 |
) as trace,
|
|
|
|
| 296 |
):
|
| 297 |
-
|
| 298 |
e
|
| 299 |
async for e in provider.stream_response(
|
| 300 |
req, request_id="req_native_conn"
|
|
@@ -309,10 +308,7 @@ async def test_native_stream_connection_error_retry_exhausted(provider_config):
|
|
| 309 |
]
|
| 310 |
assert error_traces[-1]["request_id"] == "req_native_conn"
|
| 311 |
assert error_traces[-1]["exc_type"] == "ConnectError"
|
| 312 |
-
|
| 313 |
-
events,
|
| 314 |
-
user_message_substr="Provider exception:\nconnect failed",
|
| 315 |
-
)
|
| 316 |
finally:
|
| 317 |
GlobalRateLimiter.reset_instance()
|
| 318 |
|
|
@@ -352,13 +348,12 @@ async def test_non_retryable_4xx_http_error_not_retried(provider_config):
|
|
| 352 |
new_callable=AsyncMock,
|
| 353 |
return_value=err,
|
| 354 |
) as mock_send,
|
|
|
|
| 355 |
):
|
| 356 |
-
|
| 357 |
|
| 358 |
mock_send.assert_awaited_once()
|
| 359 |
assert err.is_closed
|
| 360 |
-
|
| 361 |
-
events, user_message_substr="Invalid request sent to provider"
|
| 362 |
-
)
|
| 363 |
finally:
|
| 364 |
GlobalRateLimiter.reset_instance()
|
|
|
|
| 8 |
|
| 9 |
from core.anthropic.stream_contracts import event_names, parse_sse_text
|
| 10 |
from providers.base import ProviderConfig
|
| 11 |
+
from providers.exceptions import ProviderError
|
| 12 |
from providers.rate_limit import GlobalRateLimiter
|
| 13 |
from tests.providers.test_anthropic_messages import (
|
| 14 |
FakeResponse,
|
| 15 |
MockRequest,
|
| 16 |
NativeProvider,
|
| 17 |
)
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def _assert_minimal_success_stream(events: list[str]) -> None:
|
|
|
|
| 236 |
return_value=bad,
|
| 237 |
) as mock_send,
|
| 238 |
patch("asyncio.sleep", new_callable=AsyncMock),
|
| 239 |
+
pytest.raises(ProviderError) as exc_info,
|
| 240 |
):
|
| 241 |
+
[e async for e in provider.stream_response(req)]
|
| 242 |
|
| 243 |
assert mock_send.await_count == 5
|
| 244 |
assert bad.is_closed
|
| 245 |
+
assert substr in exc_info.value.message
|
|
|
|
|
|
|
|
|
|
| 246 |
finally:
|
| 247 |
GlobalRateLimiter.reset_instance()
|
| 248 |
|
|
|
|
| 291 |
patch(
|
| 292 |
"providers.transports.anthropic_messages.stream.trace_event"
|
| 293 |
) as trace,
|
| 294 |
+
pytest.raises(ProviderError) as exc_info,
|
| 295 |
):
|
| 296 |
+
[
|
| 297 |
e
|
| 298 |
async for e in provider.stream_response(
|
| 299 |
req, request_id="req_native_conn"
|
|
|
|
| 308 |
]
|
| 309 |
assert error_traces[-1]["request_id"] == "req_native_conn"
|
| 310 |
assert error_traces[-1]["exc_type"] == "ConnectError"
|
| 311 |
+
assert "Provider exception:\nconnect failed" in exc_info.value.message
|
|
|
|
|
|
|
|
|
|
| 312 |
finally:
|
| 313 |
GlobalRateLimiter.reset_instance()
|
| 314 |
|
|
|
|
| 348 |
new_callable=AsyncMock,
|
| 349 |
return_value=err,
|
| 350 |
) as mock_send,
|
| 351 |
+
pytest.raises(ProviderError) as exc_info,
|
| 352 |
):
|
| 353 |
+
[e async for e in provider.stream_response(req)]
|
| 354 |
|
| 355 |
mock_send.assert_awaited_once()
|
| 356 |
assert err.is_closed
|
| 357 |
+
assert "Invalid request sent to provider" in exc_info.value.message
|
|
|
|
|
|
|
| 358 |
finally:
|
| 359 |
GlobalRateLimiter.reset_instance()
|
|
@@ -8,8 +8,8 @@ import pytest
|
|
| 8 |
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
|
| 9 |
from core.anthropic.stream_contracts import parse_sse_text
|
| 10 |
from providers.base import ProviderConfig
|
|
|
|
| 11 |
from providers.llamacpp import LlamaCppProvider
|
| 12 |
-
from tests.stream_contract import assert_canonical_stream_error_envelope
|
| 13 |
|
| 14 |
|
| 15 |
class MockMessage:
|
|
@@ -45,6 +45,15 @@ class MockRequest:
|
|
| 45 |
}
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
@pytest.fixture
|
| 49 |
def llamacpp_config():
|
| 50 |
return ProviderConfig(
|
|
@@ -125,11 +134,7 @@ async def test_stream_response_omits_thinking_when_globally_disabled(llamacpp_co
|
|
| 125 |
mock_response = MagicMock()
|
| 126 |
mock_response.status_code = 200
|
| 127 |
|
| 128 |
-
|
| 129 |
-
if False:
|
| 130 |
-
yield ""
|
| 131 |
-
|
| 132 |
-
mock_response.aiter_lines = empty_aiter
|
| 133 |
|
| 134 |
with (
|
| 135 |
patch.object(provider._client, "build_request") as mock_build,
|
|
@@ -208,11 +213,7 @@ async def test_stream_response_adds_max_tokens_if_missing(llamacpp_provider):
|
|
| 208 |
mock_response = MagicMock()
|
| 209 |
mock_response.status_code = 200
|
| 210 |
|
| 211 |
-
|
| 212 |
-
if False:
|
| 213 |
-
yield ""
|
| 214 |
-
|
| 215 |
-
mock_response.aiter_lines = empty_aiter
|
| 216 |
|
| 217 |
with (
|
| 218 |
patch.object(req, "model_dump", return_value={"model": "test"}),
|
|
@@ -233,7 +234,7 @@ async def test_stream_response_adds_max_tokens_if_missing(llamacpp_provider):
|
|
| 233 |
|
| 234 |
@pytest.mark.asyncio
|
| 235 |
async def test_stream_error_status_code(llamacpp_provider):
|
| 236 |
-
"""
|
| 237 |
req = MockRequest()
|
| 238 |
|
| 239 |
mock_response = MagicMock()
|
|
@@ -256,20 +257,21 @@ async def test_stream_error_status_code(llamacpp_provider):
|
|
| 256 |
return_value=mock_response,
|
| 257 |
),
|
| 258 |
):
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
)
|
| 267 |
-
assert "TEST_ID" in "".join(events)
|
| 268 |
|
| 269 |
|
| 270 |
@pytest.mark.asyncio
|
| 271 |
async def test_stream_network_error(llamacpp_provider):
|
| 272 |
-
"""
|
| 273 |
req = MockRequest()
|
| 274 |
|
| 275 |
with (
|
|
@@ -283,18 +285,18 @@ async def test_stream_network_error(llamacpp_provider):
|
|
| 283 |
side_effect=httpx.ConnectError("Connection refused"),
|
| 284 |
),
|
| 285 |
):
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
assert "Provider exception" in blob
|
| 296 |
-
assert "Connection refused" in blob
|
| 297 |
-
assert "TEST_ID2" in blob
|
| 298 |
|
| 299 |
|
| 300 |
@pytest.mark.asyncio
|
|
@@ -320,17 +322,15 @@ async def test_stream_error_405_mentions_upstream_provider(llamacpp_provider):
|
|
| 320 |
new_callable=AsyncMock,
|
| 321 |
return_value=mock_response,
|
| 322 |
),
|
|
|
|
| 323 |
):
|
| 324 |
-
|
| 325 |
-
e async for e in llamacpp_provider.stream_response(req, request_id="REQ405")
|
| 326 |
-
]
|
| 327 |
|
| 328 |
-
blob = "".join(events)
|
| 329 |
assert (
|
| 330 |
"Upstream provider LLAMACPP rejected the request method or endpoint (HTTP 405)."
|
| 331 |
-
in
|
| 332 |
)
|
| 333 |
-
assert "REQ405" in
|
| 334 |
|
| 335 |
|
| 336 |
def test_build_request_body_disabled_thinking_strips_native_thinking_history(
|
|
|
|
| 8 |
from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
|
| 9 |
from core.anthropic.stream_contracts import parse_sse_text
|
| 10 |
from providers.base import ProviderConfig
|
| 11 |
+
from providers.exceptions import ProviderError
|
| 12 |
from providers.llamacpp import LlamaCppProvider
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class MockMessage:
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
|
| 48 |
+
async def _minimal_native_lines():
|
| 49 |
+
yield "event: message_start"
|
| 50 |
+
yield 'data: {"type":"message_start"}'
|
| 51 |
+
yield ""
|
| 52 |
+
yield "event: message_stop"
|
| 53 |
+
yield 'data: {"type":"message_stop"}'
|
| 54 |
+
yield ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
@pytest.fixture
|
| 58 |
def llamacpp_config():
|
| 59 |
return ProviderConfig(
|
|
|
|
| 134 |
mock_response = MagicMock()
|
| 135 |
mock_response.status_code = 200
|
| 136 |
|
| 137 |
+
mock_response.aiter_lines = _minimal_native_lines
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
with (
|
| 140 |
patch.object(provider._client, "build_request") as mock_build,
|
|
|
|
| 213 |
mock_response = MagicMock()
|
| 214 |
mock_response.status_code = 200
|
| 215 |
|
| 216 |
+
mock_response.aiter_lines = _minimal_native_lines
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
with (
|
| 219 |
patch.object(req, "model_dump", return_value={"model": "test"}),
|
|
|
|
| 234 |
|
| 235 |
@pytest.mark.asyncio
|
| 236 |
async def test_stream_error_status_code(llamacpp_provider):
|
| 237 |
+
"""Pre-start non-200 status code raises for API-level non-200 handling."""
|
| 238 |
req = MockRequest()
|
| 239 |
|
| 240 |
mock_response = MagicMock()
|
|
|
|
| 257 |
return_value=mock_response,
|
| 258 |
),
|
| 259 |
):
|
| 260 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 261 |
+
[
|
| 262 |
+
e
|
| 263 |
+
async for e in llamacpp_provider.stream_response(
|
| 264 |
+
req, request_id="TEST_ID"
|
| 265 |
+
)
|
| 266 |
+
]
|
| 267 |
|
| 268 |
+
assert "Provider API request failed" in exc_info.value.message
|
| 269 |
+
assert "TEST_ID" in exc_info.value.message
|
|
|
|
|
|
|
| 270 |
|
| 271 |
|
| 272 |
@pytest.mark.asyncio
|
| 273 |
async def test_stream_network_error(llamacpp_provider):
|
| 274 |
+
"""Pre-start network errors raise for API-level non-200 handling."""
|
| 275 |
req = MockRequest()
|
| 276 |
|
| 277 |
with (
|
|
|
|
| 285 |
side_effect=httpx.ConnectError("Connection refused"),
|
| 286 |
),
|
| 287 |
):
|
| 288 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 289 |
+
[
|
| 290 |
+
e
|
| 291 |
+
async for e in llamacpp_provider.stream_response(
|
| 292 |
+
req, request_id="TEST_ID2"
|
| 293 |
+
)
|
| 294 |
+
]
|
| 295 |
|
| 296 |
+
assert "Could not connect to provider." in exc_info.value.message
|
| 297 |
+
assert "Provider exception" in exc_info.value.message
|
| 298 |
+
assert "Connection refused" in exc_info.value.message
|
| 299 |
+
assert "TEST_ID2" in exc_info.value.message
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
|
| 302 |
@pytest.mark.asyncio
|
|
|
|
| 322 |
new_callable=AsyncMock,
|
| 323 |
return_value=mock_response,
|
| 324 |
),
|
| 325 |
+
pytest.raises(ProviderError) as exc_info,
|
| 326 |
):
|
| 327 |
+
[e async for e in llamacpp_provider.stream_response(req, request_id="REQ405")]
|
|
|
|
|
|
|
| 328 |
|
|
|
|
| 329 |
assert (
|
| 330 |
"Upstream provider LLAMACPP rejected the request method or endpoint (HTTP 405)."
|
| 331 |
+
in exc_info.value.message
|
| 332 |
)
|
| 333 |
+
assert "REQ405" in exc_info.value.message
|
| 334 |
|
| 335 |
|
| 336 |
def test_build_request_body_disabled_thinking_strips_native_thinking_history(
|
|
@@ -9,6 +9,7 @@ import pytest
|
|
| 9 |
from httpx import Request, Response
|
| 10 |
|
| 11 |
from providers.base import ProviderConfig
|
|
|
|
| 12 |
from providers.mistral import MISTRAL_DEFAULT_BASE, MistralProvider
|
| 13 |
|
| 14 |
|
|
@@ -740,10 +741,11 @@ async def test_stream_response_unrelated_bad_request_does_not_retry(mistral_prov
|
|
| 740 |
) as mock_create:
|
| 741 |
mock_create.side_effect = error
|
| 742 |
|
| 743 |
-
|
|
|
|
| 744 |
|
| 745 |
assert mock_create.await_count == 1
|
| 746 |
-
assert
|
| 747 |
|
| 748 |
|
| 749 |
@pytest.mark.asyncio
|
|
@@ -758,10 +760,11 @@ async def test_stream_response_generic_thinking_error_does_not_retry(
|
|
| 758 |
) as mock_create:
|
| 759 |
mock_create.side_effect = error
|
| 760 |
|
| 761 |
-
|
|
|
|
| 762 |
|
| 763 |
assert mock_create.await_count == 1
|
| 764 |
-
assert
|
| 765 |
|
| 766 |
|
| 767 |
def test_retry_body_without_reasoning_returns_none(mistral_provider):
|
|
|
|
| 9 |
from httpx import Request, Response
|
| 10 |
|
| 11 |
from providers.base import ProviderConfig
|
| 12 |
+
from providers.exceptions import ProviderError
|
| 13 |
from providers.mistral import MISTRAL_DEFAULT_BASE, MistralProvider
|
| 14 |
|
| 15 |
|
|
|
|
| 741 |
) as mock_create:
|
| 742 |
mock_create.side_effect = error
|
| 743 |
|
| 744 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 745 |
+
[e async for e in mistral_provider.stream_response(req)]
|
| 746 |
|
| 747 |
assert mock_create.await_count == 1
|
| 748 |
+
assert "Invalid request sent to provider" in exc_info.value.message
|
| 749 |
|
| 750 |
|
| 751 |
@pytest.mark.asyncio
|
|
|
|
| 760 |
) as mock_create:
|
| 761 |
mock_create.side_effect = error
|
| 762 |
|
| 763 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 764 |
+
[e async for e in mistral_provider.stream_response(req)]
|
| 765 |
|
| 766 |
assert mock_create.await_count == 1
|
| 767 |
+
assert "Invalid request sent to provider" in exc_info.value.message
|
| 768 |
|
| 769 |
|
| 770 |
def test_retry_body_without_reasoning_returns_none(mistral_provider):
|
|
@@ -7,6 +7,7 @@ from httpx import Request, Response
|
|
| 7 |
|
| 8 |
from config.nim import NimSettings
|
| 9 |
from providers.defaults import NVIDIA_NIM_DEFAULT_BASE
|
|
|
|
| 10 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 11 |
from providers.nvidia_nim.tool_schema import NIM_TOOL_ARGUMENT_ALIASES_KEY
|
| 12 |
|
|
@@ -499,12 +500,11 @@ async def test_stream_response_does_not_retry_unrelated_bad_request(provider_con
|
|
| 499 |
) as mock_create:
|
| 500 |
mock_create.side_effect = _make_bad_request_error("unrelated bad request")
|
| 501 |
|
| 502 |
-
|
|
|
|
| 503 |
|
| 504 |
assert mock_create.await_count == 1
|
| 505 |
-
|
| 506 |
-
assert "Invalid request sent to provider" in event_text
|
| 507 |
-
assert "event: message_stop" in event_text
|
| 508 |
|
| 509 |
|
| 510 |
@pytest.mark.asyncio
|
|
@@ -891,11 +891,11 @@ async def test_stream_response_bad_request_without_reasoning_budget_does_not_ret
|
|
| 891 |
) as mock_create:
|
| 892 |
mock_create.side_effect = error
|
| 893 |
|
| 894 |
-
|
|
|
|
| 895 |
|
| 896 |
assert mock_create.await_count == 1
|
| 897 |
-
assert
|
| 898 |
-
assert any("message_stop" in event for event in events)
|
| 899 |
|
| 900 |
|
| 901 |
@pytest.mark.asyncio
|
|
@@ -910,11 +910,11 @@ async def test_stream_response_unrelated_internal_error_does_not_downgrade(
|
|
| 910 |
) as mock_create:
|
| 911 |
mock_create.side_effect = error
|
| 912 |
|
| 913 |
-
|
|
|
|
| 914 |
|
| 915 |
assert mock_create.await_count == 1
|
| 916 |
-
assert
|
| 917 |
-
assert any("message_stop" in event for event in events)
|
| 918 |
|
| 919 |
|
| 920 |
@pytest.mark.asyncio
|
|
@@ -931,8 +931,8 @@ async def test_stream_response_internal_reasoning_content_error_does_not_downgra
|
|
| 931 |
) as mock_create:
|
| 932 |
mock_create.side_effect = error
|
| 933 |
|
| 934 |
-
|
|
|
|
| 935 |
|
| 936 |
assert mock_create.await_count == 1
|
| 937 |
-
assert
|
| 938 |
-
assert any("message_stop" in event for event in events)
|
|
|
|
| 7 |
|
| 8 |
from config.nim import NimSettings
|
| 9 |
from providers.defaults import NVIDIA_NIM_DEFAULT_BASE
|
| 10 |
+
from providers.exceptions import ProviderError
|
| 11 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 12 |
from providers.nvidia_nim.tool_schema import NIM_TOOL_ARGUMENT_ALIASES_KEY
|
| 13 |
|
|
|
|
| 500 |
) as mock_create:
|
| 501 |
mock_create.side_effect = _make_bad_request_error("unrelated bad request")
|
| 502 |
|
| 503 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 504 |
+
[e async for e in provider.stream_response(req)]
|
| 505 |
|
| 506 |
assert mock_create.await_count == 1
|
| 507 |
+
assert "Invalid request sent to provider" in exc_info.value.message
|
|
|
|
|
|
|
| 508 |
|
| 509 |
|
| 510 |
@pytest.mark.asyncio
|
|
|
|
| 891 |
) as mock_create:
|
| 892 |
mock_create.side_effect = error
|
| 893 |
|
| 894 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 895 |
+
[e async for e in nim_provider.stream_response(req)]
|
| 896 |
|
| 897 |
assert mock_create.await_count == 1
|
| 898 |
+
assert "Invalid request sent to provider" in exc_info.value.message
|
|
|
|
| 899 |
|
| 900 |
|
| 901 |
@pytest.mark.asyncio
|
|
|
|
| 910 |
) as mock_create:
|
| 911 |
mock_create.side_effect = error
|
| 912 |
|
| 913 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 914 |
+
[e async for e in nim_provider.stream_response(req)]
|
| 915 |
|
| 916 |
assert mock_create.await_count == 1
|
| 917 |
+
assert "Provider API request failed" in exc_info.value.message
|
|
|
|
| 918 |
|
| 919 |
|
| 920 |
@pytest.mark.asyncio
|
|
|
|
| 931 |
) as mock_create:
|
| 932 |
mock_create.side_effect = error
|
| 933 |
|
| 934 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 935 |
+
[e async for e in nim_provider.stream_response(req)]
|
| 936 |
|
| 937 |
assert mock_create.await_count == 1
|
| 938 |
+
assert "Provider API request failed" in exc_info.value.message
|
|
|
|
@@ -7,8 +7,8 @@ import pytest
|
|
| 7 |
|
| 8 |
from core.anthropic.stream_contracts import parse_sse_text
|
| 9 |
from providers.base import ProviderConfig
|
|
|
|
| 10 |
from providers.ollama import OLLAMA_DEFAULT_BASE, OllamaProvider
|
| 11 |
-
from tests.stream_contract import assert_canonical_stream_error_envelope
|
| 12 |
|
| 13 |
|
| 14 |
class MockMessage:
|
|
@@ -233,7 +233,7 @@ def test_build_request_body_disabled_thinking_strips_assistant_thinking_blocks(
|
|
| 233 |
|
| 234 |
@pytest.mark.asyncio
|
| 235 |
async def test_stream_error_status_code(ollama_provider):
|
| 236 |
-
"""
|
| 237 |
req = MockRequest()
|
| 238 |
mock_response = MagicMock()
|
| 239 |
mock_response.status_code = 500
|
|
@@ -254,16 +254,15 @@ async def test_stream_error_status_code(ollama_provider):
|
|
| 254 |
new_callable=AsyncMock,
|
| 255 |
return_value=mock_response,
|
| 256 |
),
|
|
|
|
| 257 |
):
|
| 258 |
-
|
| 259 |
event
|
| 260 |
async for event in ollama_provider.stream_response(req, request_id="REQ")
|
| 261 |
]
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
)
|
| 266 |
-
assert "REQ" in "".join(events)
|
| 267 |
|
| 268 |
|
| 269 |
@pytest.mark.asyncio
|
|
|
|
| 7 |
|
| 8 |
from core.anthropic.stream_contracts import parse_sse_text
|
| 9 |
from providers.base import ProviderConfig
|
| 10 |
+
from providers.exceptions import ProviderError
|
| 11 |
from providers.ollama import OLLAMA_DEFAULT_BASE, OllamaProvider
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class MockMessage:
|
|
|
|
| 233 |
|
| 234 |
@pytest.mark.asyncio
|
| 235 |
async def test_stream_error_status_code(ollama_provider):
|
| 236 |
+
"""Pre-start non-200 status code raises for API-level non-200 handling."""
|
| 237 |
req = MockRequest()
|
| 238 |
mock_response = MagicMock()
|
| 239 |
mock_response.status_code = 500
|
|
|
|
| 254 |
new_callable=AsyncMock,
|
| 255 |
return_value=mock_response,
|
| 256 |
),
|
| 257 |
+
pytest.raises(ProviderError) as exc_info,
|
| 258 |
):
|
| 259 |
+
[
|
| 260 |
event
|
| 261 |
async for event in ollama_provider.stream_response(req, request_id="REQ")
|
| 262 |
]
|
| 263 |
|
| 264 |
+
assert "Provider API request failed" in exc_info.value.message
|
| 265 |
+
assert "REQ" in exc_info.value.message
|
|
|
|
|
|
|
| 266 |
|
| 267 |
|
| 268 |
@pytest.mark.asyncio
|
|
@@ -9,10 +9,10 @@ from httpx import Request, Response
|
|
| 9 |
|
| 10 |
from config.nim import NimSettings
|
| 11 |
from providers.base import ProviderConfig
|
|
|
|
| 12 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 13 |
from providers.rate_limit import GlobalRateLimiter
|
| 14 |
from tests.providers.test_nvidia_nim import MockRequest
|
| 15 |
-
from tests.stream_contract import assert_canonical_stream_error_envelope
|
| 16 |
|
| 17 |
|
| 18 |
def _internal_5xx(code: int) -> openai.InternalServerError:
|
|
@@ -148,10 +148,9 @@ async def test_nim_stream_connection_error_exhausted_emits_cause_chain():
|
|
| 148 |
) as mock_create,
|
| 149 |
patch("asyncio.sleep", new_callable=AsyncMock),
|
| 150 |
patch("providers.transports.openai_chat.stream.trace_event") as trace,
|
|
|
|
| 151 |
):
|
| 152 |
-
|
| 153 |
-
e async for e in provider.stream_response(req, request_id="req_conn")
|
| 154 |
-
]
|
| 155 |
|
| 156 |
assert mock_create.await_count == 5
|
| 157 |
error_traces = [
|
|
@@ -161,9 +160,8 @@ async def test_nim_stream_connection_error_exhausted_emits_cause_chain():
|
|
| 161 |
]
|
| 162 |
assert error_traces[-1]["request_id"] == "req_conn"
|
| 163 |
assert error_traces[-1]["exc_type"] == "APIConnectionError"
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
user_message_substr="Caused by:\nConnectError: upstream disconnected",
|
| 167 |
)
|
| 168 |
finally:
|
| 169 |
GlobalRateLimiter.reset_instance()
|
|
@@ -206,10 +204,10 @@ async def test_nim_stream_openai_5xx_exhausted_emits_user_message(
|
|
| 206 |
patch("asyncio.sleep", new_callable=AsyncMock),
|
| 207 |
):
|
| 208 |
mock_create.side_effect = _internal_5xx(status_code)
|
| 209 |
-
|
|
|
|
| 210 |
|
| 211 |
assert mock_create.await_count == 5
|
| 212 |
-
|
| 213 |
-
assert expect_substr in blob.lower()
|
| 214 |
finally:
|
| 215 |
GlobalRateLimiter.reset_instance()
|
|
|
|
| 9 |
|
| 10 |
from config.nim import NimSettings
|
| 11 |
from providers.base import ProviderConfig
|
| 12 |
+
from providers.exceptions import ProviderError
|
| 13 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 14 |
from providers.rate_limit import GlobalRateLimiter
|
| 15 |
from tests.providers.test_nvidia_nim import MockRequest
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def _internal_5xx(code: int) -> openai.InternalServerError:
|
|
|
|
| 148 |
) as mock_create,
|
| 149 |
patch("asyncio.sleep", new_callable=AsyncMock),
|
| 150 |
patch("providers.transports.openai_chat.stream.trace_event") as trace,
|
| 151 |
+
pytest.raises(ProviderError) as exc_info,
|
| 152 |
):
|
| 153 |
+
[e async for e in provider.stream_response(req, request_id="req_conn")]
|
|
|
|
|
|
|
| 154 |
|
| 155 |
assert mock_create.await_count == 5
|
| 156 |
error_traces = [
|
|
|
|
| 160 |
]
|
| 161 |
assert error_traces[-1]["request_id"] == "req_conn"
|
| 162 |
assert error_traces[-1]["exc_type"] == "APIConnectionError"
|
| 163 |
+
assert (
|
| 164 |
+
"Caused by:\nConnectError: upstream disconnected" in exc_info.value.message
|
|
|
|
| 165 |
)
|
| 166 |
finally:
|
| 167 |
GlobalRateLimiter.reset_instance()
|
|
|
|
| 204 |
patch("asyncio.sleep", new_callable=AsyncMock),
|
| 205 |
):
|
| 206 |
mock_create.side_effect = _internal_5xx(status_code)
|
| 207 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 208 |
+
[e async for e in provider.stream_response(req)]
|
| 209 |
|
| 210 |
assert mock_create.await_count == 5
|
| 211 |
+
assert expect_substr in exc_info.value.message.lower()
|
|
|
|
| 212 |
finally:
|
| 213 |
GlobalRateLimiter.reset_instance()
|
|
@@ -11,6 +11,7 @@ import pytest
|
|
| 11 |
from config.constants import NATIVE_MESSAGES_ERROR_BODY_LOG_CAP_BYTES
|
| 12 |
from config.nim import NimSettings
|
| 13 |
from providers.base import ProviderConfig
|
|
|
|
| 14 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 15 |
from providers.transports.anthropic_messages import stream as native_stream
|
| 16 |
from tests.provider_request_mocks import make_openai_compat_stream_request
|
|
@@ -71,8 +72,9 @@ async def test_native_non_200_logs_exclude_body_text_by_default(
|
|
| 71 |
return_value=response,
|
| 72 |
),
|
| 73 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 74 |
):
|
| 75 |
-
|
| 76 |
|
| 77 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 78 |
assert "SECRET_UPSTREAM_BODY" not in messages
|
|
@@ -96,8 +98,9 @@ async def test_native_non_200_logs_body_when_verbose(caplog, provider_config):
|
|
| 96 |
return_value=response,
|
| 97 |
),
|
| 98 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 99 |
):
|
| 100 |
-
|
| 101 |
|
| 102 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 103 |
assert "SECRET_UPSTREAM_BODY" in messages
|
|
@@ -124,8 +127,9 @@ async def test_native_non_200_verbose_logs_only_capped_error_body(
|
|
| 124 |
return_value=response,
|
| 125 |
),
|
| 126 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 127 |
):
|
| 128 |
-
|
| 129 |
|
| 130 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 131 |
assert "SECRET_TAIL_NOT_LOGGED" not in messages
|
|
@@ -151,8 +155,9 @@ async def test_native_non_200_default_does_not_read_oversized_body(
|
|
| 151 |
return_value=response,
|
| 152 |
),
|
| 153 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 154 |
):
|
| 155 |
-
|
| 156 |
|
| 157 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 158 |
assert "LEAK_MARKER" not in messages
|
|
@@ -189,8 +194,9 @@ async def test_native_stream_failure_logs_exclude_exception_str_by_default(
|
|
| 189 |
),
|
| 190 |
patch.object(native_stream, "iter_sse_events", boom),
|
| 191 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 192 |
):
|
| 193 |
-
|
| 194 |
|
| 195 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 196 |
assert "SECRET_DETAIL" not in messages
|
|
@@ -225,8 +231,9 @@ async def test_openai_compat_stream_failure_default_logs_exclude_exception_str(c
|
|
| 225 |
_noop_slot,
|
| 226 |
),
|
| 227 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 228 |
):
|
| 229 |
-
|
| 230 |
|
| 231 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 232 |
assert "SECRET_OPENAI_COMPAT" not in messages
|
|
@@ -264,8 +271,9 @@ async def test_openai_compat_stream_failure_default_logs_cause_types_only(caplog
|
|
| 264 |
_noop_slot,
|
| 265 |
),
|
| 266 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 267 |
):
|
| 268 |
-
|
| 269 |
|
| 270 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 271 |
assert "SECRET_CAUSE_DETAIL" not in messages
|
|
@@ -300,8 +308,9 @@ async def test_openai_compat_stream_failure_respects_verbose_flag(caplog):
|
|
| 300 |
_noop_slot,
|
| 301 |
),
|
| 302 |
caplog.at_level(logging.ERROR),
|
|
|
|
| 303 |
):
|
| 304 |
-
|
| 305 |
|
| 306 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 307 |
assert "SECRET_OPENAI_COMPAT" in messages
|
|
|
|
| 11 |
from config.constants import NATIVE_MESSAGES_ERROR_BODY_LOG_CAP_BYTES
|
| 12 |
from config.nim import NimSettings
|
| 13 |
from providers.base import ProviderConfig
|
| 14 |
+
from providers.exceptions import ProviderError
|
| 15 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 16 |
from providers.transports.anthropic_messages import stream as native_stream
|
| 17 |
from tests.provider_request_mocks import make_openai_compat_stream_request
|
|
|
|
| 72 |
return_value=response,
|
| 73 |
),
|
| 74 |
caplog.at_level(logging.ERROR),
|
| 75 |
+
pytest.raises(ProviderError),
|
| 76 |
):
|
| 77 |
+
[e async for e in provider.stream_response(req)]
|
| 78 |
|
| 79 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 80 |
assert "SECRET_UPSTREAM_BODY" not in messages
|
|
|
|
| 98 |
return_value=response,
|
| 99 |
),
|
| 100 |
caplog.at_level(logging.ERROR),
|
| 101 |
+
pytest.raises(ProviderError),
|
| 102 |
):
|
| 103 |
+
[e async for e in provider.stream_response(req)]
|
| 104 |
|
| 105 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 106 |
assert "SECRET_UPSTREAM_BODY" in messages
|
|
|
|
| 127 |
return_value=response,
|
| 128 |
),
|
| 129 |
caplog.at_level(logging.ERROR),
|
| 130 |
+
pytest.raises(ProviderError),
|
| 131 |
):
|
| 132 |
+
[e async for e in provider.stream_response(req)]
|
| 133 |
|
| 134 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 135 |
assert "SECRET_TAIL_NOT_LOGGED" not in messages
|
|
|
|
| 155 |
return_value=response,
|
| 156 |
),
|
| 157 |
caplog.at_level(logging.ERROR),
|
| 158 |
+
pytest.raises(ProviderError),
|
| 159 |
):
|
| 160 |
+
[e async for e in provider.stream_response(req)]
|
| 161 |
|
| 162 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 163 |
assert "LEAK_MARKER" not in messages
|
|
|
|
| 194 |
),
|
| 195 |
patch.object(native_stream, "iter_sse_events", boom),
|
| 196 |
caplog.at_level(logging.ERROR),
|
| 197 |
+
pytest.raises(ProviderError),
|
| 198 |
):
|
| 199 |
+
[e async for e in provider.stream_response(req)]
|
| 200 |
|
| 201 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 202 |
assert "SECRET_DETAIL" not in messages
|
|
|
|
| 231 |
_noop_slot,
|
| 232 |
),
|
| 233 |
caplog.at_level(logging.ERROR),
|
| 234 |
+
pytest.raises(ProviderError),
|
| 235 |
):
|
| 236 |
+
[e async for e in provider.stream_response(req)]
|
| 237 |
|
| 238 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 239 |
assert "SECRET_OPENAI_COMPAT" not in messages
|
|
|
|
| 271 |
_noop_slot,
|
| 272 |
),
|
| 273 |
caplog.at_level(logging.ERROR),
|
| 274 |
+
pytest.raises(ProviderError),
|
| 275 |
):
|
| 276 |
+
[e async for e in provider.stream_response(req)]
|
| 277 |
|
| 278 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 279 |
assert "SECRET_CAUSE_DETAIL" not in messages
|
|
|
|
| 308 |
_noop_slot,
|
| 309 |
),
|
| 310 |
caplog.at_level(logging.ERROR),
|
| 311 |
+
pytest.raises(ProviderError),
|
| 312 |
):
|
| 313 |
+
[e async for e in provider.stream_response(req)]
|
| 314 |
|
| 315 |
messages = " | ".join(r.getMessage() for r in caplog.records)
|
| 316 |
assert "SECRET_OPENAI_COMPAT" in messages
|
|
@@ -10,7 +10,6 @@ import pytest
|
|
| 10 |
|
| 11 |
from config.nim import NimSettings
|
| 12 |
from core.anthropic.stream_contracts import (
|
| 13 |
-
assert_anthropic_stream_contract,
|
| 14 |
parse_sse_text,
|
| 15 |
)
|
| 16 |
from core.anthropic.streaming import (
|
|
@@ -20,6 +19,7 @@ from core.anthropic.streaming import (
|
|
| 20 |
make_text_recovery_body,
|
| 21 |
)
|
| 22 |
from providers.base import ProviderConfig
|
|
|
|
| 23 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 24 |
from providers.transports.openai_chat.recovery import OpenAIChatRecovery
|
| 25 |
from providers.transports.openai_chat.tool_calls import (
|
|
@@ -128,6 +128,12 @@ async def _collect_stream(provider, request):
|
|
| 128 |
return [e async for e in provider.stream_response(request)]
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def _assert_no_content_deltas_after_error_text(
|
| 132 |
events: list[str], error_substr: str
|
| 133 |
) -> None:
|
|
@@ -172,14 +178,11 @@ class TestStreamingExceptionHandling:
|
|
| 172 |
"""Tests for error paths during stream_response."""
|
| 173 |
|
| 174 |
@pytest.mark.asyncio
|
| 175 |
-
async def
|
| 176 |
-
"""
|
| 177 |
provider = _make_provider()
|
| 178 |
request = _make_request()
|
| 179 |
|
| 180 |
-
mock_stream = AsyncMock()
|
| 181 |
-
mock_stream.__aiter__ = MagicMock(side_effect=RuntimeError("API failed"))
|
| 182 |
-
|
| 183 |
with (
|
| 184 |
patch.object(
|
| 185 |
provider._client.chat.completions,
|
|
@@ -194,21 +197,13 @@ class TestStreamingExceptionHandling:
|
|
| 194 |
return_value=False,
|
| 195 |
),
|
| 196 |
):
|
| 197 |
-
|
| 198 |
|
| 199 |
-
|
| 200 |
-
event_text = "".join(events)
|
| 201 |
-
assert "message_start" in event_text
|
| 202 |
-
assert "API failed" in event_text
|
| 203 |
-
assert "message_stop" in event_text
|
| 204 |
-
parsed = parse_sse_text(event_text)
|
| 205 |
-
assert parsed[0].event == "message_start"
|
| 206 |
-
assert sum(event.event == "message_start" for event in parsed) == 1
|
| 207 |
-
_assert_no_content_deltas_after_error_text(events, "API failed")
|
| 208 |
|
| 209 |
@pytest.mark.asyncio
|
| 210 |
-
async def
|
| 211 |
-
"""ReadTimeout(TimeoutError()) should
|
| 212 |
provider = _make_provider()
|
| 213 |
request = _make_request()
|
| 214 |
|
|
@@ -225,24 +220,20 @@ class TestStreamingExceptionHandling:
|
|
| 225 |
new_callable=AsyncMock,
|
| 226 |
return_value=False,
|
| 227 |
),
|
|
|
|
| 228 |
):
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
)
|
| 235 |
-
]
|
| 236 |
|
| 237 |
-
|
| 238 |
-
assert "
|
| 239 |
-
assert "Request ID: req_timeout123" in event_text
|
| 240 |
-
assert "message_stop" in event_text
|
| 241 |
-
_assert_no_content_deltas_after_error_text(events, "timed out after")
|
| 242 |
|
| 243 |
@pytest.mark.asyncio
|
| 244 |
-
async def
|
| 245 |
-
"""
|
| 246 |
provider = _make_provider()
|
| 247 |
request = _make_request()
|
| 248 |
|
|
@@ -263,13 +254,9 @@ class TestStreamingExceptionHandling:
|
|
| 263 |
return_value=False,
|
| 264 |
),
|
| 265 |
):
|
| 266 |
-
|
| 267 |
|
| 268 |
-
|
| 269 |
-
assert "Hello" in event_text
|
| 270 |
-
assert "Connection lost" in event_text
|
| 271 |
-
assert "message_stop" in event_text
|
| 272 |
-
_assert_no_content_deltas_after_error_text(events, "Connection lost")
|
| 273 |
|
| 274 |
@pytest.mark.asyncio
|
| 275 |
async def test_error_after_native_tool_call_uses_top_level_error_event(self):
|
|
@@ -568,28 +555,21 @@ class TestStreamingExceptionHandling:
|
|
| 568 |
new_callable=AsyncMock,
|
| 569 |
side_effect=error,
|
| 570 |
):
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
)
|
| 577 |
-
]
|
| 578 |
|
| 579 |
-
event_text = "".join(events)
|
| 580 |
assert (
|
| 581 |
"Upstream provider NIM rejected the request method or endpoint (HTTP 405)."
|
| 582 |
-
in
|
| 583 |
-
)
|
| 584 |
-
assert "Request ID: REQ405" in event_text
|
| 585 |
-
_assert_no_content_deltas_after_error_text(
|
| 586 |
-
events,
|
| 587 |
-
"Upstream provider NIM rejected the request method or endpoint (HTTP 405).",
|
| 588 |
)
|
|
|
|
| 589 |
|
| 590 |
@pytest.mark.asyncio
|
| 591 |
async def test_stream_with_openai_bad_request_surfaces_upstream_body(self):
|
| 592 |
-
"""OpenAI SDK bodies should be
|
| 593 |
provider = _make_provider()
|
| 594 |
request = _make_request()
|
| 595 |
response = httpx.Response(
|
|
@@ -610,33 +590,20 @@ class TestStreamingExceptionHandling:
|
|
| 610 |
new_callable=AsyncMock,
|
| 611 |
side_effect=error,
|
| 612 |
):
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
)
|
| 619 |
-
]
|
| 620 |
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
for ev in parse_sse_text(event_text)
|
| 625 |
-
if ev.event == "content_block_delta"
|
| 626 |
-
and ev.data.get("delta", {}).get("type") == "text_delta"
|
| 627 |
-
)
|
| 628 |
-
assert "Upstream provider NIM returned HTTP 400." in event_text
|
| 629 |
-
assert "Category: BadRequest" in event_text
|
| 630 |
-
assert "Thinking mode does not support this tool_choice" in event_text
|
| 631 |
assert (
|
| 632 |
'{"error":{"type":"BadRequest","message":"Thinking mode does not support this tool_choice"}}'
|
| 633 |
-
in
|
| 634 |
-
)
|
| 635 |
-
assert "Request ID: REQ_BODY" in event_text
|
| 636 |
-
_assert_no_content_deltas_after_error_text(
|
| 637 |
-
events,
|
| 638 |
-
"Upstream provider NIM returned HTTP 400.",
|
| 639 |
)
|
|
|
|
| 640 |
|
| 641 |
@pytest.mark.asyncio
|
| 642 |
async def test_error_after_native_tool_call_top_level_error_includes_body(self):
|
|
@@ -956,10 +923,10 @@ class TestStreamingExceptionHandling:
|
|
| 956 |
new_callable=AsyncMock,
|
| 957 |
return_value=stream,
|
| 958 |
):
|
| 959 |
-
|
| 960 |
|
| 961 |
assert stream.closed is True
|
| 962 |
-
assert "provider stream failed" in
|
| 963 |
|
| 964 |
@pytest.mark.asyncio
|
| 965 |
async def test_truncated_recovery_stream_falls_back_to_error_tail(self):
|
|
@@ -1395,13 +1362,9 @@ class TestStreamChunkEdgeCases:
|
|
| 1395 |
return_value=False,
|
| 1396 |
),
|
| 1397 |
):
|
| 1398 |
-
|
| 1399 |
|
| 1400 |
-
|
| 1401 |
-
assert "Partial" in event_text
|
| 1402 |
-
assert "Connection reset" in event_text
|
| 1403 |
-
assert "message_stop" in event_text
|
| 1404 |
-
_assert_no_content_deltas_after_error_text(events, "Connection reset")
|
| 1405 |
|
| 1406 |
def test_stream_malformed_tool_args_chunked(self):
|
| 1407 |
"""Chunked tool args that never form valid JSON are flushed with {}."""
|
|
@@ -1456,7 +1419,6 @@ async def test_openai_compat_stream_ends_with_contract_when_tool_name_never_arri
|
|
| 1456 |
return_value=False,
|
| 1457 |
),
|
| 1458 |
):
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
assert "text_delta" in text
|
|
|
|
| 10 |
|
| 11 |
from config.nim import NimSettings
|
| 12 |
from core.anthropic.stream_contracts import (
|
|
|
|
| 13 |
parse_sse_text,
|
| 14 |
)
|
| 15 |
from core.anthropic.streaming import (
|
|
|
|
| 19 |
make_text_recovery_body,
|
| 20 |
)
|
| 21 |
from providers.base import ProviderConfig
|
| 22 |
+
from providers.exceptions import ProviderError
|
| 23 |
from providers.nvidia_nim import NvidiaNimProvider
|
| 24 |
from providers.transports.openai_chat.recovery import OpenAIChatRecovery
|
| 25 |
from providers.transports.openai_chat.tool_calls import (
|
|
|
|
| 128 |
return [e async for e in provider.stream_response(request)]
|
| 129 |
|
| 130 |
|
| 131 |
+
async def _collect_stream_error(provider, request, **kwargs) -> ProviderError:
|
| 132 |
+
with pytest.raises(ProviderError) as exc_info:
|
| 133 |
+
[e async for e in provider.stream_response(request, **kwargs)]
|
| 134 |
+
return exc_info.value
|
| 135 |
+
|
| 136 |
+
|
| 137 |
def _assert_no_content_deltas_after_error_text(
|
| 138 |
events: list[str], error_substr: str
|
| 139 |
) -> None:
|
|
|
|
| 178 |
"""Tests for error paths during stream_response."""
|
| 179 |
|
| 180 |
@pytest.mark.asyncio
|
| 181 |
+
async def test_pre_start_api_error_raises_provider_error(self):
|
| 182 |
+
"""Before holdback commit, provider failures raise for API-level non-200."""
|
| 183 |
provider = _make_provider()
|
| 184 |
request = _make_request()
|
| 185 |
|
|
|
|
|
|
|
|
|
|
| 186 |
with (
|
| 187 |
patch.object(
|
| 188 |
provider._client.chat.completions,
|
|
|
|
| 197 |
return_value=False,
|
| 198 |
),
|
| 199 |
):
|
| 200 |
+
error = await _collect_stream_error(provider, request)
|
| 201 |
|
| 202 |
+
assert "API failed" in error.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
@pytest.mark.asyncio
|
| 205 |
+
async def test_read_timeout_with_empty_message_raises_fallback(self):
|
| 206 |
+
"""ReadTimeout(TimeoutError()) should raise a non-empty timeout message."""
|
| 207 |
provider = _make_provider()
|
| 208 |
request = _make_request()
|
| 209 |
|
|
|
|
| 220 |
new_callable=AsyncMock,
|
| 221 |
return_value=False,
|
| 222 |
),
|
| 223 |
+
patch("asyncio.sleep", new_callable=AsyncMock),
|
| 224 |
):
|
| 225 |
+
error = await _collect_stream_error(
|
| 226 |
+
provider,
|
| 227 |
+
request,
|
| 228 |
+
request_id="req_timeout123",
|
| 229 |
+
)
|
|
|
|
|
|
|
| 230 |
|
| 231 |
+
assert "timed out after" in error.message
|
| 232 |
+
assert "Request ID: req_timeout123" in error.message
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
@pytest.mark.asyncio
|
| 235 |
+
async def test_error_after_precommit_partial_content_raises(self):
|
| 236 |
+
"""Precommit partial text is discarded so the API can return non-200."""
|
| 237 |
provider = _make_provider()
|
| 238 |
request = _make_request()
|
| 239 |
|
|
|
|
| 254 |
return_value=False,
|
| 255 |
),
|
| 256 |
):
|
| 257 |
+
error = await _collect_stream_error(provider, request)
|
| 258 |
|
| 259 |
+
assert "Connection lost" in error.message
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
@pytest.mark.asyncio
|
| 262 |
async def test_error_after_native_tool_call_uses_top_level_error_event(self):
|
|
|
|
| 555 |
new_callable=AsyncMock,
|
| 556 |
side_effect=error,
|
| 557 |
):
|
| 558 |
+
stream_error = await _collect_stream_error(
|
| 559 |
+
provider,
|
| 560 |
+
request,
|
| 561 |
+
request_id="REQ405",
|
| 562 |
+
)
|
|
|
|
|
|
|
| 563 |
|
|
|
|
| 564 |
assert (
|
| 565 |
"Upstream provider NIM rejected the request method or endpoint (HTTP 405)."
|
| 566 |
+
in stream_error.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
)
|
| 568 |
+
assert "Request ID: REQ405" in stream_error.message
|
| 569 |
|
| 570 |
@pytest.mark.asyncio
|
| 571 |
async def test_stream_with_openai_bad_request_surfaces_upstream_body(self):
|
| 572 |
+
"""OpenAI SDK bodies should be raised so users can copy exact provider errors."""
|
| 573 |
provider = _make_provider()
|
| 574 |
request = _make_request()
|
| 575 |
response = httpx.Response(
|
|
|
|
| 590 |
new_callable=AsyncMock,
|
| 591 |
side_effect=error,
|
| 592 |
):
|
| 593 |
+
stream_error = await _collect_stream_error(
|
| 594 |
+
provider,
|
| 595 |
+
request,
|
| 596 |
+
request_id="REQ_BODY",
|
| 597 |
+
)
|
|
|
|
|
|
|
| 598 |
|
| 599 |
+
assert "Upstream provider NIM returned HTTP 400." in stream_error.message
|
| 600 |
+
assert "Category: BadRequest" in stream_error.message
|
| 601 |
+
assert "Thinking mode does not support this tool_choice" in stream_error.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
assert (
|
| 603 |
'{"error":{"type":"BadRequest","message":"Thinking mode does not support this tool_choice"}}'
|
| 604 |
+
in stream_error.message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
)
|
| 606 |
+
assert "Request ID: REQ_BODY" in stream_error.message
|
| 607 |
|
| 608 |
@pytest.mark.asyncio
|
| 609 |
async def test_error_after_native_tool_call_top_level_error_includes_body(self):
|
|
|
|
| 923 |
new_callable=AsyncMock,
|
| 924 |
return_value=stream,
|
| 925 |
):
|
| 926 |
+
error = await _collect_stream_error(provider, request)
|
| 927 |
|
| 928 |
assert stream.closed is True
|
| 929 |
+
assert "provider stream failed" in error.message.lower()
|
| 930 |
|
| 931 |
@pytest.mark.asyncio
|
| 932 |
async def test_truncated_recovery_stream_falls_back_to_error_tail(self):
|
|
|
|
| 1362 |
return_value=False,
|
| 1363 |
),
|
| 1364 |
):
|
| 1365 |
+
error = await _collect_stream_error(provider, request)
|
| 1366 |
|
| 1367 |
+
assert "Connection reset" in error.message
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1368 |
|
| 1369 |
def test_stream_malformed_tool_args_chunked(self):
|
| 1370 |
"""Chunked tool args that never form valid JSON are flushed with {}."""
|
|
|
|
| 1419 |
return_value=False,
|
| 1420 |
),
|
| 1421 |
):
|
| 1422 |
+
error = await _collect_stream_error(provider, request)
|
| 1423 |
+
|
| 1424 |
+
assert "Provider stream ended without finish_reason." in error.message
|
|
|
|
@@ -561,7 +561,7 @@ wheels = [
|
|
| 561 |
|
| 562 |
[[package]]
|
| 563 |
name = "free-claude-code"
|
| 564 |
-
version = "3.4.
|
| 565 |
source = { editable = "." }
|
| 566 |
dependencies = [
|
| 567 |
{ name = "aiohttp" },
|
|
|
|
| 561 |
|
| 562 |
[[package]]
|
| 563 |
name = "free-claude-code"
|
| 564 |
+
version = "3.4.13"
|
| 565 |
source = { editable = "." }
|
| 566 |
dependencies = [
|
| 567 |
{ name = "aiohttp" },
|