LastNoob commited on
Commit
8ace4ff
·
unverified ·
1 Parent(s): 9b129c9

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 CHANGED
@@ -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
api/handlers/messages.py CHANGED
@@ -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 require_non_empty_messages, unexpected_http_exception
15
- from api.response_streams import anthropic_sse_streaming_response
 
 
 
 
 
 
 
 
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 aggregate_anthropic_sse_to_message, get_token_count
 
 
 
 
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(result.body)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
api/handlers/responses.py CHANGED
@@ -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 openai_responses_sse_streaming_response
 
 
 
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
+ )
api/response_streams.py CHANGED
@@ -1,29 +1,123 @@
1
  """FastAPI streaming response wrappers for public API wire formats."""
2
 
3
- from collections.abc import AsyncIterator, Mapping
 
4
 
5
- from fastapi.responses import StreamingResponse
6
 
7
- from core.anthropic.streaming import ANTHROPIC_SSE_RESPONSE_HEADERS
 
 
 
 
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
- body,
 
 
 
 
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
- ) -> StreamingResponse:
 
24
  """Return a streaming response for OpenAI Responses-style SSE."""
25
- return StreamingResponse(
26
  body,
27
- media_type="text/event-stream",
28
- headers=dict(headers),
 
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
  )
core/anthropic/streaming/__init__.py CHANGED
@@ -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",
core/anthropic/streaming/emitter.py CHANGED
@@ -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
 
core/openai_responses/adapter.py CHANGED
@@ -22,8 +22,13 @@ class OpenAIResponsesAdapter:
22
  self,
23
  chunks: AsyncIterable[Any],
24
  request: Mapping[str, Any],
 
 
25
  ) -> AsyncIterator[str]:
26
- return iter_responses_sse_from_anthropic(chunks, request)
 
 
 
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)
core/openai_responses/stream.py CHANGED
@@ -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
- async for event in iter_sse_events(chunks):
18
- for chunk in assembler.process_anthropic_event(event):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  yield chunk
20
- if assembler.terminal:
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
 
 
 
plan.md ADDED
@@ -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`.
providers/error_mapping.py CHANGED
@@ -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:
providers/transports/anthropic_messages/stream.py CHANGED
@@ -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 or decision.has_buffered:
215
- if not decision.committed:
216
- for event in recovery.flush():
217
- sent_any_event = True
218
- yield event
 
 
219
  for event in ledger.midstream_error_tail(error_message):
220
  yield event
221
  else:
222
  recovery.discard()
223
- for event in self._transport._emit_error_events(
224
- request=self._request,
225
- input_tokens=self._input_tokens,
226
- error_message=error_message,
227
- sent_any_event=False,
228
- ):
229
- yield event
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:
providers/transports/openai_chat/stream.py CHANGED
@@ -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 not decision.committed and decision.has_buffered:
 
 
 
 
292
  for event in recovery.flush():
293
  yield event
294
  elif not decision.committed:
295
  recovery.discard()
296
- ledger = self._new_ledger()
 
 
 
 
 
 
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
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "free-claude-code"
7
- version = "3.4.12"
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"
tests/api/test_api.py CHANGED
@@ -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
tests/api/test_openai_responses.py CHANGED
@@ -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)
tests/api/test_response_streams.py ADDED
@@ -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
tests/providers/test_anthropic_messages.py CHANGED
@@ -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 event_index, parse_sse_text
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 test_stream_maps_non_200_to_error_event_and_closes_response(
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
- events = [
271
- event async for event in provider.stream_response(req, request_id="REQ_123")
272
- ]
273
 
274
  assert response.is_closed
275
- assert_canonical_stream_error_envelope(
276
- events, user_message_substr="Upstream provider TEST_NATIVE returned HTTP 500."
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 test_midstream_error_closes_open_block_and_uses_fresh_content_index(
285
  provider_config,
286
  ):
287
- """After upstream message_start + content_block_start, synthetic errors must not reuse index 0."""
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
- events = [e async for e in provider.stream_response(req)]
330
 
331
- assert_canonical_stream_error_envelope(
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
tests/providers/test_anthropic_messages_429_retry.py CHANGED
@@ -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
- events = [e async for e in provider.stream_response(req)]
241
 
242
  assert mock_send.await_count == 5
243
  assert bad.is_closed
244
- assert_canonical_stream_error_envelope(
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
- events = [
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
- assert_canonical_stream_error_envelope(
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
- events = [e async for e in provider.stream_response(req)]
357
 
358
  mock_send.assert_awaited_once()
359
  assert err.is_closed
360
- assert_canonical_stream_error_envelope(
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()
tests/providers/test_llamacpp.py CHANGED
@@ -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
- async def empty_aiter():
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
- async def empty_aiter():
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
- """Non-200 status code raises an error that gets caught and yielded as an SSE API error."""
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
- events = [
260
- e
261
- async for e in llamacpp_provider.stream_response(req, request_id="TEST_ID")
262
- ]
 
 
 
263
 
264
- assert_canonical_stream_error_envelope(
265
- events, user_message_substr="Provider API request failed"
266
- )
267
- assert "TEST_ID" in "".join(events)
268
 
269
 
270
  @pytest.mark.asyncio
271
  async def test_stream_network_error(llamacpp_provider):
272
- """Network errors are caught and yielded as SSE API error events."""
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
- events = [
287
- e
288
- async for e in llamacpp_provider.stream_response(req, request_id="TEST_ID2")
289
- ]
 
 
 
290
 
291
- blob = "".join(events)
292
- assert_canonical_stream_error_envelope(
293
- events, user_message_substr="Could not connect to provider."
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
- events = [
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 blob
332
  )
333
- assert "REQ405" in blob
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(
tests/providers/test_mistral.py CHANGED
@@ -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
- events = [e async for e in mistral_provider.stream_response(req)]
 
744
 
745
  assert mock_create.await_count == 1
746
- assert any("message_stop" in event for event in events)
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
- events = [e async for e in mistral_provider.stream_response(req)]
 
762
 
763
  assert mock_create.await_count == 1
764
- assert any("message_stop" in event for event in events)
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):
tests/providers/test_nvidia_nim.py CHANGED
@@ -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
- events = [e async for e in provider.stream_response(req)]
 
503
 
504
  assert mock_create.await_count == 1
505
- event_text = "".join(events)
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
- events = [e async for e in nim_provider.stream_response(req)]
 
895
 
896
  assert mock_create.await_count == 1
897
- assert any("Invalid request sent to provider" in event for event in events)
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
- events = [e async for e in nim_provider.stream_response(req)]
 
914
 
915
  assert mock_create.await_count == 1
916
- assert any("Provider API request failed" in event for event in events)
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
- events = [e async for e in nim_provider.stream_response(req)]
 
935
 
936
  assert mock_create.await_count == 1
937
- assert any("Provider API request failed" in event for event in events)
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
 
tests/providers/test_ollama.py CHANGED
@@ -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
- """Non-200 status code is yielded as an SSE API error."""
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
- events = [
259
  event
260
  async for event in ollama_provider.stream_response(req, request_id="REQ")
261
  ]
262
 
263
- assert_canonical_stream_error_envelope(
264
- events, user_message_substr="Provider API request failed"
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
tests/providers/test_openai_compat_5xx_retry.py CHANGED
@@ -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
- events = [
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
- assert_canonical_stream_error_envelope(
165
- events,
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
- events = [e async for e in provider.stream_response(req)]
 
210
 
211
  assert mock_create.await_count == 5
212
- blob = "".join(events)
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()
tests/providers/test_provider_transport_logging.py CHANGED
@@ -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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
- _ = [e async for e in provider.stream_response(req)]
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
tests/providers/test_streaming_errors.py CHANGED
@@ -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 test_api_error_emits_sse_error_event(self):
176
- """When API raises during streaming, SSE error event is emitted."""
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
- events = await _collect_stream(provider, request)
198
 
199
- # Should have message_start, error text block, close blocks, message_delta, message_stop
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 test_read_timeout_with_empty_message_emits_fallback(self):
211
- """ReadTimeout(TimeoutError()) should emit a visible, non-empty timeout message."""
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
- events = [
230
- e
231
- async for e in provider.stream_response(
232
- request,
233
- request_id="req_timeout123",
234
- )
235
- ]
236
 
237
- event_text = "".join(events)
238
- assert "timed out after" in event_text
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 test_error_after_partial_content(self):
245
- """Error after partial content: blocks closed, error emitted."""
246
  provider = _make_provider()
247
  request = _make_request()
248
 
@@ -263,13 +254,9 @@ class TestStreamingExceptionHandling:
263
  return_value=False,
264
  ),
265
  ):
266
- events = await _collect_stream(provider, request)
267
 
268
- event_text = "".join(events)
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
- events = [
572
- e
573
- async for e in provider.stream_response(
574
- request,
575
- request_id="REQ405",
576
- )
577
- ]
578
 
579
- event_text = "".join(events)
580
  assert (
581
  "Upstream provider NIM rejected the request method or endpoint (HTTP 405)."
582
- in event_text
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 emitted so users can copy exact provider errors."""
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
- events = [
614
- e
615
- async for e in provider.stream_response(
616
- request,
617
- request_id="REQ_BODY",
618
- )
619
- ]
620
 
621
- event_text = "".join(events)
622
- message_text = "".join(
623
- str(ev.data.get("delta", {}).get("text", ""))
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 message_text
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
- events = await _collect_stream(provider, request)
960
 
961
  assert stream.closed is True
962
- assert "provider stream failed" in "".join(events).lower()
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
- events = await _collect_stream(provider, request)
1399
 
1400
- event_text = "".join(events)
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
- events = await _collect_stream(provider, request)
1460
- text = "".join(events)
1461
- assert_anthropic_stream_contract(parse_sse_text(text))
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
 
uv.lock CHANGED
@@ -561,7 +561,7 @@ wheels = [
561
 
562
  [[package]]
563
  name = "free-claude-code"
564
- version = "3.4.12"
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" },