mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-10-18 23:17:19 +00:00
patch: recover interrupted streams after delivered output (#1986)
Some checks are pending
Some checks are pending
## Why
The hidden-stream retries in #1974 stop being sufficient once the
harness receives output. A later connection failure must preserve that
output and the completed calls protected by #1973 while recovering the
unfinished turn.
## How
- Continue with the same provider and model using released text and
readable reasoning within the existing attempt budget. The harness owns
conversation history.
- Give one public response owner responsibility for projection,
tool-group release, continuation mapping, publication and failure
finalization. Inspect native completion snapshots before mapping so
completed calls survive recovery.
- Permit request corrections only before that physical attempt publishes
output. Retain accepted corrections across later interruptions by
replacing only the private continuation turns in the current request.
Preserve released calls and finish complete calls as a tool handoff
without another generation.
- Reconcile exact replay across text parts and message items while
preserving source identities. Keep streamed text, completion snapshots
and later continuation history consistent, with one public response
identity and increasing sequence numbers.
- Handle nullable reasoning content and distinguish FCC replay records
for readable reasoning from genuine native signatures or encryption.
Refresh readable records at synthetic closure so later harness requests
retain all delivered reasoning.
- Count only real published content as continuation progress. Keep
compatibility padding limited to initial generation, and prevent
synthetic closures from turning an empty or echo-only continuation into
success.
- Bound recovery retention. After retained state is exhausted, preserve
delivered frames and terminate later failures with the existing
`response.failed` envelope, empty output, null usage, and an explanation
that the complete final snapshot is unavailable.
- Rebuild retained failure snapshots for pending as well as active
recovery. A timeout before the first continuation event preserves the
partial answer without requiring successful item closures.
- Preserve provider error categories and execution cleanup, including
when failed snapshots contain native content. Inspect those snapshots
for recovery restrictions without replacing their original error. Apply
annotation restrictions to part snapshots as well as dedicated and item
events.
- Keep delivered interruptions in structured-output requests and public
Responses text with token probabilities on the failure path. Block
reconstruction before tool handoff or replay trimming, while preserving
healthy output and hidden retries.
Accepted tradeoffs: continuation is best effort, and exact overlap
removal can remove intentional repetition. Ambiguous replay can delay
frames within a fixed retention bound. After recovery state is
discarded, a failed response cannot reconstruct the whole answer;
consumers retain prior stream events. The existing matcher's performance
optimization for exceptionally long partial replays remains explicitly
excluded. Chat's pre-existing error adapter can still close a partial
JSON fragment before reporting failure, causing typed SDK parsing to
raise; that projection limitation was verified on the base branch.
Validation: repository suppression, Ruff, Ty and pytest checks passed
locally (7,933 passed, 13 skipped). Official Anthropic and OpenAI SDK
consumers passed 107 cases. Coverage includes text/reasoning/tools,
replay across parts, retry visibility, tool-result follow-up, retention
failures, synthetic closure progress, nullable reasoning, later history
replay, corrections across repeated interruptions, empty continuations,
structured-output eligibility, token metadata, native error categories,
execution failure accounting, timeout before continuation output,
pending handoff failure, cancellation and healthy large responses.
<!-- greptile_comment -->
<!-- greptile_summary -->
<h2><a
href="https://app.greptile.com/api/retrigger?id=74179686"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=2"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=2"
align="right"></picture></a>Confidence Score: 5/5</h2>
<!-- greptile-risk -->
No blocking findings remain. The outstanding overlap-search delay is
non-blocking.
<h2><a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22ali%2Frecover-delivered-streams%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Frecover-delivered-streams%22.%0A%0A%23%23%23%20Issue%201%0Asrc%2Ffree_claude_code%2Fcore%2Fcontinuation_stream.py%3A239-243%0AWhen%20a%20continuation%20begins%20with%20a%20long%20substring%20of%20delivered%20text%20that%20is%20not%20its%20suffix%2C%20this%20loop%20tests%20every%20possible%20overlap%20before%20publishing%20the%20next%20delta.%20A%20524%2C288-character%20prefix%20delayed%20that%20delta%20by%203.1%20seconds%2C%20making%20a%20valid%20stream%20appear%20stalled.%20This%20is%20a%20non-blocking%20responsiveness%20concern%3B%20a%20linear-time%20overlap%20search%20would%20avoid%20the%20delay.%0A%0ANote%3A%20If%20this%20suggestion%20doesn't%20match%20your%20team's%20coding%20style%2C%20reply%20to%20this%20and%20let%20me%20know.%20I'll%20remember%20it%20for%20next%20time!%0A%0A---%0A%0AFor%20each%20issue%20above%2C%20determine%20whether%20it%20is%20valid%20and%20should%20be%20fixed.%20If%20so%2C%20fix%20it%20directly.&repo=alishahryar1%2Ffree-claude-code&pr=1986&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=7"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=7"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=7"
align="right"></picture></a>Findings</h2>
1. <img alt="P2"
src="https://greptile-static-assets.s3.amazonaws.com/badges/p2.svg?v=9"
align="top"> **Overlap search delays streaming** <a
href="https://github.com/Alishahryar1/free-claude-code/pull/1986#discussion_r4175258337">▶</a>
<details open><summary>Summary</summary>
The PR adds recovery for provider streams interrupted after output has
reached the client. The latest change preserves retained public output
when a pending continuation fails before emitting another event. No new
findings were identified.
</details>
<!-- greptile_confidence_score:5 -->
<sub>Reviews (9) · Last reviewed commit: ["fix: retain output when
pending
recovery..."](cf093468ee)</sub>
<!-- /greptile_comment -->
This commit is contained in:
parent
f29cc304bf
commit
c61d7bf44f
39 changed files with 6103 additions and 312 deletions
47
src/free_claude_code/api/classifier_projection.py
Normal file
47
src/free_claude_code/api/classifier_projection.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Project classifier content within one public attempt."""
|
||||
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.anthropic.streaming import format_sse_event
|
||||
|
||||
|
||||
class ClassifierProjection:
|
||||
"""Project one attempt's blocks without interpreting classifier text."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._indices: dict[int, int | None] = {}
|
||||
self._next_index = 0
|
||||
|
||||
def feed(self, frame: str) -> str | None:
|
||||
events = parse_sse_text(frame)
|
||||
if not events:
|
||||
return frame
|
||||
event = events[0]
|
||||
payload = event.data
|
||||
kind = payload.get("type", event.event)
|
||||
if kind not in {
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
}:
|
||||
return frame
|
||||
index = payload.get("index")
|
||||
if not isinstance(index, int):
|
||||
return frame
|
||||
if kind == "content_block_start":
|
||||
block = payload.get("content_block")
|
||||
if isinstance(block, dict) and block.get("type") in {
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
}:
|
||||
self._indices[index] = None
|
||||
else:
|
||||
self._indices[index] = self._next_index
|
||||
self._next_index += 1
|
||||
if index not in self._indices:
|
||||
return frame
|
||||
visible_index = self._indices[index]
|
||||
if kind == "content_block_stop":
|
||||
del self._indices[index]
|
||||
if visible_index is None:
|
||||
return None
|
||||
return format_sse_event(event.event, {**payload, "index": visible_index})
|
||||
|
|
@ -3,76 +3,27 @@
|
|||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from free_claude_code.core.anthropic.stream_contracts import SSEEvent
|
||||
from free_claude_code.core.anthropic.streaming import format_sse_event
|
||||
from free_claude_code.core.anthropic.streaming.decoder import AnthropicSSEDecoder
|
||||
from free_claude_code.core.stream_delivery import current_stream_delivery
|
||||
from free_claude_code.core.trace import close_stream_input
|
||||
|
||||
from ..classifier_projection import ClassifierProjection
|
||||
|
||||
|
||||
async def classifier_response(source: AsyncIterator[str]) -> AsyncIterator[str]:
|
||||
"""Project block lifecycles without interpreting any classifier text."""
|
||||
"""Apply the same projection to private, nonstreaming aggregation."""
|
||||
decoder = AnthropicSSEDecoder()
|
||||
indices: dict[int, int | None] = {}
|
||||
next_index = 0
|
||||
delivery = current_stream_delivery()
|
||||
revision = delivery.attempt_revision if delivery else 0
|
||||
|
||||
def synchronize() -> None:
|
||||
nonlocal decoder, next_index, revision
|
||||
if delivery is None or revision == delivery.attempt_revision:
|
||||
return
|
||||
revision = delivery.attempt_revision
|
||||
decoder = AnthropicSSEDecoder()
|
||||
indices.clear()
|
||||
next_index = 0
|
||||
|
||||
def project(event: SSEEvent) -> str | None:
|
||||
nonlocal next_index
|
||||
payload = event.data
|
||||
kind = payload.get("type", event.event)
|
||||
if kind not in {
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
}:
|
||||
return event.raw + "\n\n"
|
||||
index = payload.get("index")
|
||||
if not isinstance(index, int):
|
||||
return event.raw + "\n\n"
|
||||
if kind == "content_block_start":
|
||||
block = payload.get("content_block")
|
||||
if isinstance(block, dict) and block.get("type") in {
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
}:
|
||||
indices[index] = None
|
||||
else:
|
||||
indices[index] = next_index
|
||||
next_index += 1
|
||||
if index not in indices:
|
||||
return event.raw + "\n\n"
|
||||
visible_index = indices[index]
|
||||
if kind == "content_block_stop":
|
||||
del indices[index]
|
||||
if visible_index is None:
|
||||
return None
|
||||
return format_sse_event(event.event, {**payload, "index": visible_index})
|
||||
|
||||
projection = ClassifierProjection()
|
||||
try:
|
||||
async for chunk in source:
|
||||
synchronize()
|
||||
for event in decoder.feed(chunk):
|
||||
projected = project(event)
|
||||
for frame in decoder.feed_frames(chunk):
|
||||
projected = projection.feed(frame)
|
||||
if projected is not None:
|
||||
yield projected
|
||||
synchronize()
|
||||
for event in decoder.finish():
|
||||
projected = project(event)
|
||||
for frame in decoder.finish_frames():
|
||||
projected = projection.feed(frame)
|
||||
if projected is not None:
|
||||
yield projected
|
||||
finally:
|
||||
synchronize()
|
||||
await close_stream_input(
|
||||
source,
|
||||
owner="classifier_response",
|
||||
|
|
|
|||
|
|
@ -157,14 +157,11 @@ class MessagesHandler:
|
|||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
if routed.reasoning.control is ReasoningControl.PREFER_OFF and isinstance(
|
||||
result, _MessagesStreamResult
|
||||
):
|
||||
result = _MessagesStreamResult(classifier_response(result.body))
|
||||
return await self._to_public_response(
|
||||
result,
|
||||
stream=request_data.stream,
|
||||
request_id=request_id,
|
||||
hide_reasoning=routed.reasoning.control is ReasoningControl.PREFER_OFF,
|
||||
)
|
||||
except ApplicationError:
|
||||
raise
|
||||
|
|
@ -184,6 +181,7 @@ class MessagesHandler:
|
|||
*,
|
||||
stream: bool,
|
||||
request_id: str,
|
||||
hide_reasoning: bool = False,
|
||||
) -> object:
|
||||
if isinstance(result, _MessagesCompleteResult):
|
||||
return result.response
|
||||
|
|
@ -193,7 +191,7 @@ class MessagesHandler:
|
|||
# serving that raw here breaks the client SDK's response parse.
|
||||
try:
|
||||
message, error, _complete = await aggregate_anthropic_sse_to_message(
|
||||
result.body
|
||||
classifier_response(result.body) if hide_reasoning else result.body
|
||||
)
|
||||
except GeneratorExit:
|
||||
raise
|
||||
|
|
@ -244,6 +242,7 @@ class MessagesHandler:
|
|||
exc, request_id=request_id
|
||||
),
|
||||
request_id=request_id,
|
||||
hide_reasoning=hide_reasoning,
|
||||
)
|
||||
|
||||
def _pre_start_error_response(
|
||||
|
|
|
|||
|
|
@ -36,8 +36,7 @@ from free_claude_code.core.request_outcomes import (
|
|||
from free_claude_code.core.stream_delivery import StreamDeliveryState
|
||||
from free_claude_code.core.trace import close_stream_input, trace_event
|
||||
|
||||
from .stream_delivery import DeliveryObservedStream, PublicStreamEnvelope
|
||||
from .tool_call_buffer import ToolCallBufferedStream
|
||||
from .stream_delivery import DeliveryObservedStream, PublicResponseStream
|
||||
|
||||
TERMINAL_EXECUTION_ERROR_HEADERS = {"x-should-retry": "false"}
|
||||
|
||||
|
|
@ -238,9 +237,9 @@ async def _first_chunk_streaming_response(
|
|||
pre_start_error_response: PreStartErrorResponse,
|
||||
terminal_frame: TerminalFrameEmitter | None,
|
||||
terminal_failure_observer: TerminalFailureObserver | None,
|
||||
hide_reasoning: bool = False,
|
||||
) -> Response:
|
||||
state = StreamDeliveryState()
|
||||
envelope = PublicStreamEnvelope(state, wire_api=wire_api)
|
||||
body = DeliveryObservedStream(body, state)
|
||||
try:
|
||||
first_chunk = await anext(body)
|
||||
|
|
@ -262,16 +261,14 @@ async def _first_chunk_streaming_response(
|
|||
return pre_start_error_response(exc)
|
||||
|
||||
return ManagedStreamingResponse(
|
||||
ToolCallBufferedStream(
|
||||
_PrefetchedStream(
|
||||
first_chunk,
|
||||
body,
|
||||
terminal_frame=terminal_frame,
|
||||
terminal_failure_observer=terminal_failure_observer,
|
||||
envelope=envelope,
|
||||
),
|
||||
PublicResponseStream(
|
||||
_PrefetchedStream(first_chunk, body),
|
||||
state,
|
||||
wire_api=wire_api,
|
||||
envelope=envelope,
|
||||
first_chunk=first_chunk,
|
||||
terminal_frame=terminal_frame,
|
||||
terminal_failure_observer=terminal_failure_observer,
|
||||
hide_reasoning=hide_reasoning,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers=dict(headers),
|
||||
|
|
@ -296,92 +293,46 @@ async def _close_pre_start_body(
|
|||
|
||||
|
||||
class _PrefetchedStream(AsyncIterator[str]):
|
||||
"""Replay one prefetched frame while retaining ownership of the tail."""
|
||||
"""Replay the raw prefetched chunk and retain ownership of the tail."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
first_chunk: str,
|
||||
body: AsyncIterator[str],
|
||||
*,
|
||||
terminal_frame: TerminalFrameEmitter | None,
|
||||
terminal_failure_observer: TerminalFailureObserver | None,
|
||||
envelope: PublicStreamEnvelope,
|
||||
) -> None:
|
||||
def __init__(self, first_chunk: str, body: AsyncIterator[str]) -> None:
|
||||
self._first_chunk: str | None = first_chunk
|
||||
self._initial_chunk = first_chunk
|
||||
self._latest_chunk = first_chunk
|
||||
self._body = body
|
||||
self._terminal_frame = terminal_frame
|
||||
self._terminal_failure_observer = terminal_failure_observer
|
||||
self._done = False
|
||||
self._closed = False
|
||||
self._envelope = envelope
|
||||
self._revision = envelope.state.attempt_revision
|
||||
|
||||
def __aiter__(self) -> _PrefetchedStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
if self._closed or self._done:
|
||||
if self._closed:
|
||||
raise StopAsyncIteration
|
||||
if self._first_chunk is not None:
|
||||
first_chunk = self._first_chunk
|
||||
self._first_chunk = None
|
||||
first_chunk, self._first_chunk = self._first_chunk, None
|
||||
return first_chunk
|
||||
try:
|
||||
chunk = await anext(self._body)
|
||||
self._synchronize()
|
||||
self._latest_chunk = chunk
|
||||
return chunk
|
||||
except StopAsyncIteration:
|
||||
self._done = True
|
||||
raise
|
||||
except BaseExceptionGroup as exc:
|
||||
return self._terminal_chunk(find_execution_failure(exc) or exc)
|
||||
except Exception as exc:
|
||||
return self._terminal_chunk(exc)
|
||||
return await anext(self._body)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._done = True
|
||||
close_error = await try_close_async_iterator(self._body)
|
||||
if close_error is not None:
|
||||
raise close_error
|
||||
|
||||
def _terminal_chunk(self, exc: BaseException) -> str:
|
||||
self._synchronize()
|
||||
terminal_frame = self._terminal_frame
|
||||
if terminal_frame is None:
|
||||
raise exc
|
||||
self._done = True
|
||||
if self._terminal_failure_observer is not None:
|
||||
self._terminal_failure_observer(exc)
|
||||
return terminal_frame(
|
||||
self._envelope.start_frame or self._initial_chunk, self._latest_chunk, exc
|
||||
)
|
||||
|
||||
def _synchronize(self) -> None:
|
||||
if self._revision == self._envelope.state.attempt_revision:
|
||||
return
|
||||
self._revision = self._envelope.state.attempt_revision
|
||||
self._envelope.synchronize()
|
||||
self._initial_chunk = self._envelope.start_frame
|
||||
self._latest_chunk = ""
|
||||
|
||||
|
||||
async def anthropic_sse_streaming_response(
|
||||
body: AsyncIterator[str],
|
||||
*,
|
||||
pre_start_error_response: PreStartErrorResponse,
|
||||
request_id: str,
|
||||
hide_reasoning: bool = False,
|
||||
) -> Response:
|
||||
"""Return a streaming response for Anthropic-style SSE streams."""
|
||||
return await _first_chunk_streaming_response(
|
||||
body,
|
||||
wire_api="messages",
|
||||
headers=ANTHROPIC_SSE_RESPONSE_HEADERS,
|
||||
hide_reasoning=hide_reasoning,
|
||||
pre_start_error_response=pre_start_error_response,
|
||||
terminal_frame=_anthropic_terminal_frame,
|
||||
terminal_failure_observer=lambda exc: _trace_anthropic_terminal_failure(
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
"""Observe public delivery and retain one response envelope across hidden retries."""
|
||||
"""Own public projection, tool release, recovery mapping and wire termination."""
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterator
|
||||
from copy import deepcopy
|
||||
from typing import Any, Literal
|
||||
|
||||
import simplejson
|
||||
|
||||
from free_claude_code.core.anthropic.streaming.decoder import AnthropicSSEDecoder
|
||||
from free_claude_code.core.async_iterators import try_close_async_iterator
|
||||
from free_claude_code.core.continuation_stream import ContinuationStream
|
||||
from free_claude_code.core.delivered_response import DeliveredResponse
|
||||
from free_claude_code.core.failures import find_execution_failure
|
||||
from free_claude_code.core.stream_delivery import (
|
||||
StreamDeliveryState,
|
||||
bind_stream_delivery,
|
||||
)
|
||||
|
||||
from .classifier_projection import ClassifierProjection
|
||||
from .stream_frames import frame_data, replace_frame_data
|
||||
from .tool_call_buffer import ToolCallBuffer
|
||||
|
||||
|
||||
class DeliveryObservedStream(AsyncIterator[str]):
|
||||
"""Bind observation for each read/close, including prefetch in another task."""
|
||||
|
|
@ -34,92 +43,233 @@ class DeliveryObservedStream(AsyncIterator[str]):
|
|||
raise error
|
||||
|
||||
|
||||
def frame_data(frame: str) -> dict[str, Any] | None:
|
||||
"""Read only SSE data, retaining decimal precision for envelope edits."""
|
||||
text = "\n".join(
|
||||
line[5:].lstrip(" ")
|
||||
for line in re.split(r"\r\n|\r|\n", frame)
|
||||
if line.startswith("data:")
|
||||
)
|
||||
try:
|
||||
value: object = simplejson.loads(text, use_decimal=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def replace_frame_data(frame: str, payload: Mapping[str, object]) -> str:
|
||||
"""Replace data lines while preserving other SSE fields and line endings."""
|
||||
lines = re.split(r"(\r\n|\r|\n)", frame)
|
||||
replacement = "data: " + simplejson.dumps(
|
||||
payload, use_decimal=True, ensure_ascii=False
|
||||
)
|
||||
parts: list[str] = []
|
||||
replaced = False
|
||||
for index in range(0, len(lines), 2):
|
||||
line = lines[index]
|
||||
ending = lines[index + 1] if index + 1 < len(lines) else ""
|
||||
if line.startswith("data:"):
|
||||
if replaced:
|
||||
continue
|
||||
line = replacement
|
||||
replaced = True
|
||||
parts.extend((line, ending))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class PublicStreamEnvelope:
|
||||
"""Own published metadata without reconstructing content or tool arguments."""
|
||||
class PublicResponseStream(AsyncIterator[str]):
|
||||
"""Publish one response while providers retain their physical request loops."""
|
||||
|
||||
def __init__(
|
||||
self, state: StreamDeliveryState, *, wire_api: Literal["messages", "responses"]
|
||||
self,
|
||||
body: AsyncIterator[str],
|
||||
state: StreamDeliveryState,
|
||||
*,
|
||||
wire_api: Literal["messages", "responses"],
|
||||
first_chunk: str,
|
||||
terminal_frame: Callable[[str, str, BaseException], str] | None,
|
||||
terminal_failure_observer: Callable[[BaseException], None] | None,
|
||||
hide_reasoning: bool = False,
|
||||
) -> None:
|
||||
self._body = body
|
||||
self.state = state
|
||||
self._wire_api = wire_api
|
||||
self._revision = state.attempt_revision
|
||||
self._hide_reasoning = hide_reasoning
|
||||
self._decoder = AnthropicSSEDecoder()
|
||||
self._projection = ClassifierProjection() if hide_reasoning else None
|
||||
self._tools = ToolCallBuffer(wire_api)
|
||||
self._delivered = DeliveredResponse(wire_api)
|
||||
self._continuation = ContinuationStream(self._delivered)
|
||||
state.bind_prefix(self._delivered.snapshot)
|
||||
self._published_starts: set[str] = set()
|
||||
self._seen_starts: set[str] = set()
|
||||
self._replacement = False
|
||||
self._public_id: str | None = None
|
||||
self._created_at: object = None
|
||||
self._sequence: int = -1
|
||||
self._sequence = -1
|
||||
self._offset: int | None = None
|
||||
self._pending_starts: list[str] = []
|
||||
self.start_frame = ""
|
||||
self._initial_chunk = first_chunk
|
||||
self._latest_chunk = ""
|
||||
self._terminal_frame = terminal_frame
|
||||
self._terminal_failure_observer = terminal_failure_observer
|
||||
self._done = False
|
||||
self._closed = False
|
||||
self._events = self._iterate()
|
||||
|
||||
def synchronize(self) -> None:
|
||||
if self._revision == self.state.attempt_revision:
|
||||
def __aiter__(self) -> PublicResponseStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
if self._closed or self._done:
|
||||
raise StopAsyncIteration
|
||||
try:
|
||||
while True:
|
||||
frame, synthetic = await anext(self._events)
|
||||
frame = self._finalize_frame(frame)
|
||||
value = self._publish(frame, synthetic=synthetic)
|
||||
if value is not None:
|
||||
self._latest_chunk = value
|
||||
return value
|
||||
except StopAsyncIteration:
|
||||
self._done = True
|
||||
raise
|
||||
except (Exception, BaseExceptionGroup) as exc:
|
||||
self._done = True
|
||||
self._pending_starts.clear()
|
||||
self._tools = ToolCallBuffer(self._wire_api)
|
||||
failure = find_execution_failure(exc) or exc
|
||||
if self._terminal_frame is None:
|
||||
raise
|
||||
if self._terminal_failure_observer is not None:
|
||||
self._terminal_failure_observer(failure)
|
||||
frame = self._terminal_frame(
|
||||
self.start_frame or self._initial_chunk, self._latest_chunk, failure
|
||||
)
|
||||
frame = self._finalize_frame(frame)
|
||||
return self._publish(frame, synthetic=True) or frame
|
||||
|
||||
def _finalize_frame(self, frame: str) -> str:
|
||||
payload = frame_data(frame)
|
||||
if payload is None:
|
||||
return frame
|
||||
kind = payload.get("type")
|
||||
if kind == "response.failed":
|
||||
response = payload.get("response")
|
||||
if not isinstance(response, dict):
|
||||
return frame
|
||||
# Failure snapshots may be based on the provider's empty start frame.
|
||||
# Use only retained public evidence, independently of a failed mapper.
|
||||
if self._delivered.retention_exhausted:
|
||||
response["output"] = []
|
||||
response["usage"] = None
|
||||
error = response.get("error")
|
||||
error = error if isinstance(error, dict) else {}
|
||||
message = error.get("message") or "Provider response failed."
|
||||
response["error"] = {
|
||||
**error,
|
||||
"message": f"{message}\n\nThe partial answer was already streamed. "
|
||||
"A complete final snapshot is unavailable.",
|
||||
}
|
||||
elif self._continuation.active or self.state.transition is not None:
|
||||
# A timeout can precede the first frame that activates recovery.
|
||||
response["output"] = [
|
||||
deepcopy(item) for _, item in sorted(self._delivered.items.items())
|
||||
]
|
||||
response["usage"] = self._delivered.usage(response["output"])
|
||||
else:
|
||||
return frame
|
||||
elif not self._continuation.active or kind in {"error", "response.error"}:
|
||||
return frame
|
||||
else:
|
||||
payload = self._continuation.finalize(payload)
|
||||
return replace_frame_data(frame, payload)
|
||||
|
||||
async def _iterate(self) -> AsyncGenerator[tuple[str, bool]]:
|
||||
while True:
|
||||
try:
|
||||
chunk = await anext(self._body)
|
||||
except StopAsyncIteration:
|
||||
for frame in self._synchronize():
|
||||
yield frame, True
|
||||
if self._continuation.handoff:
|
||||
for payload in self._delivered.handoff_events():
|
||||
for frame in self._prepare(self._format(payload)):
|
||||
yield frame, True
|
||||
else:
|
||||
for frame in self._decoder.finish_frames():
|
||||
for ready in self._process(frame):
|
||||
yield ready, False
|
||||
return
|
||||
for frame in self._synchronize():
|
||||
yield frame, True
|
||||
for frame in self._decoder.feed_frames(chunk):
|
||||
for ready in self._process(frame):
|
||||
yield ready, False
|
||||
|
||||
def _synchronize(self) -> Iterator[str]:
|
||||
transition = self.state.transition
|
||||
if self._revision == self.state.attempt_revision and transition is None:
|
||||
return
|
||||
self._revision = self.state.attempt_revision
|
||||
self._replacement = bool(self._published_starts)
|
||||
self.state.transition = None
|
||||
self._decoder = AnthropicSSEDecoder()
|
||||
self._tools = ToolCallBuffer(self._wire_api)
|
||||
self._projection = ClassifierProjection() if self._hide_reasoning else None
|
||||
self._seen_starts.clear()
|
||||
self._offset = None
|
||||
self.discard_pending_starts()
|
||||
|
||||
def discard_pending_starts(self) -> None:
|
||||
"""Discard metadata that no client has observed."""
|
||||
self._pending_starts.clear()
|
||||
self._offset = None
|
||||
if transition is not None:
|
||||
self._continuation.begin(handoff=transition == "handoff")
|
||||
elif self._continuation.active:
|
||||
self._continuation.restart_attempt()
|
||||
self._replacement = (
|
||||
bool(self._published_starts) and not self._continuation.active
|
||||
)
|
||||
for event in self._continuation.boundary():
|
||||
yield self._format(event)
|
||||
|
||||
def _release_content(self, frame: str) -> str:
|
||||
self.state.release_content()
|
||||
def _process(self, frame: str) -> Iterator[str]:
|
||||
if self._projection is not None:
|
||||
projected = self._projection.feed(frame)
|
||||
if projected is None:
|
||||
return
|
||||
frame = projected
|
||||
for released in self._tools.feed(frame):
|
||||
yield from self._prepare(released)
|
||||
|
||||
def _prepare(self, frame: str) -> Iterator[str]:
|
||||
if not self._continuation.active:
|
||||
yield frame
|
||||
return
|
||||
payload = frame_data(frame)
|
||||
if payload is None:
|
||||
yield frame
|
||||
return
|
||||
for item in self._continuation.prepare(payload):
|
||||
yield (
|
||||
replace_frame_data(frame, item)
|
||||
if item.get("type") == payload.get("type")
|
||||
else self._format(item)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format(payload: dict[str, Any]) -> str:
|
||||
return f"event: {payload['type']}\ndata: {simplejson.dumps(payload, use_decimal=True)}\n\n"
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = self._done = True
|
||||
await self._events.aclose()
|
||||
self._pending_starts.clear()
|
||||
self._decoder = AnthropicSSEDecoder()
|
||||
self._tools = ToolCallBuffer(self._wire_api)
|
||||
self.state.bind_prefix(None)
|
||||
self._delivered = DeliveredResponse(self._wire_api)
|
||||
self._continuation = ContinuationStream(self._delivered)
|
||||
self.start_frame = self._initial_chunk = self._latest_chunk = ""
|
||||
error = await try_close_async_iterator(self._body)
|
||||
if error is not None:
|
||||
raise error
|
||||
|
||||
def _release_content(self, frame: str, *, synthetic: bool) -> str:
|
||||
self.state.release_content(synthetic=synthetic)
|
||||
for start in self._pending_starts:
|
||||
self._record(start, synthetic=synthetic)
|
||||
self._record(frame, synthetic=synthetic)
|
||||
if not self._pending_starts:
|
||||
return frame
|
||||
self.start_frame = self._pending_starts[0]
|
||||
self._published_starts.add("message_start")
|
||||
prefix = "".join(self._pending_starts)
|
||||
self.discard_pending_starts()
|
||||
self._pending_starts.clear()
|
||||
return prefix + frame
|
||||
|
||||
def publish(self, frame: str) -> str | None:
|
||||
def _record(self, frame: str, *, synthetic: bool) -> None:
|
||||
payload = frame_data(frame)
|
||||
if payload is not None:
|
||||
self._delivered.observe(payload, count_progress=not synthetic)
|
||||
elif any(line.startswith("data:") for line in frame.splitlines()):
|
||||
self._delivered.unsafe_reason = "unknown_event"
|
||||
|
||||
def _publish(self, frame: str, *, synthetic: bool = False) -> str | None:
|
||||
"""Normalize only a replacement envelope and mark actually released frames."""
|
||||
self.synchronize()
|
||||
payload = frame_data(frame)
|
||||
if payload is None:
|
||||
if any(
|
||||
line.strip() and not line.startswith(":")
|
||||
for line in re.split(r"\r\n|\r|\n", frame)
|
||||
):
|
||||
return self._release_content(frame)
|
||||
return self._release_content(frame, synthetic=synthetic)
|
||||
return frame
|
||||
|
||||
raw_kind = payload.get("type")
|
||||
|
|
@ -130,7 +280,7 @@ class PublicStreamEnvelope:
|
|||
self._pending_starts.append(frame)
|
||||
return None
|
||||
if kind == "error":
|
||||
self.discard_pending_starts()
|
||||
self._pending_starts.clear()
|
||||
start = kind in {"message_start", "response.created", "response.in_progress"}
|
||||
if start and metadata:
|
||||
if kind not in self._seen_starts:
|
||||
|
|
@ -181,7 +331,10 @@ class PublicStreamEnvelope:
|
|||
number += self._offset
|
||||
self._sequence = max(self._sequence, number)
|
||||
frame = replace_frame_data(frame, payload) if changed else frame
|
||||
return frame if metadata else self._release_content(frame)
|
||||
if metadata:
|
||||
self._record(frame, synthetic=synthetic)
|
||||
return frame
|
||||
return self._release_content(frame, synthetic=synthetic)
|
||||
|
||||
def _metadata(self, kind: str, payload: dict[str, Any]) -> bool:
|
||||
if kind == "ping":
|
||||
|
|
|
|||
41
src/free_claude_code/api/stream_frames.py
Normal file
41
src/free_claude_code/api/stream_frames.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Read and replace SSE data without losing framing or decimal precision."""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import simplejson
|
||||
|
||||
|
||||
def frame_data(frame: str) -> dict[str, Any] | None:
|
||||
"""Read only SSE data, retaining decimal precision for envelope edits."""
|
||||
text = "\n".join(
|
||||
line[5:].lstrip(" ")
|
||||
for line in re.split(r"\r\n|\r|\n", frame)
|
||||
if line.startswith("data:")
|
||||
)
|
||||
try:
|
||||
value: object = simplejson.loads(text, use_decimal=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def replace_frame_data(frame: str, payload: Mapping[str, object]) -> str:
|
||||
"""Replace data lines while preserving other SSE fields and line endings."""
|
||||
lines = re.split(r"(\r\n|\r|\n)", frame)
|
||||
replacement = "data: " + simplejson.dumps(
|
||||
payload, use_decimal=True, ensure_ascii=False
|
||||
)
|
||||
parts: list[str] = []
|
||||
replaced = False
|
||||
for index in range(0, len(lines), 2):
|
||||
line = lines[index]
|
||||
ending = lines[index + 1] if index + 1 < len(lines) else ""
|
||||
if line.startswith("data:"):
|
||||
if replaced:
|
||||
continue
|
||||
line = replacement
|
||||
replaced = True
|
||||
parts.extend((line, ending))
|
||||
return "".join(parts)
|
||||
|
|
@ -1,15 +1,13 @@
|
|||
"""Withhold incomplete client tool calls at the public SSE delivery boundary."""
|
||||
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from collections.abc import Mapping
|
||||
from typing import Literal, cast
|
||||
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.anthropic.streaming.decoder import AnthropicSSEDecoder
|
||||
from free_claude_code.core.async_iterators import try_close_async_iterator
|
||||
from free_claude_code.core.json_types import JsonValue
|
||||
from free_claude_code.core.openai_responses import is_client_search
|
||||
|
||||
from .stream_delivery import PublicStreamEnvelope, frame_data, replace_frame_data
|
||||
from .stream_frames import frame_data, replace_frame_data
|
||||
|
||||
type CallKey = int | str
|
||||
|
||||
|
|
@ -46,19 +44,11 @@ def _client_call(item: Mapping[str, object]) -> bool:
|
|||
)
|
||||
|
||||
|
||||
class ToolCallBufferedStream(AsyncIterator[str]):
|
||||
"""Keep overlapping calls and intervening frames in their original order."""
|
||||
class ToolCallBuffer:
|
||||
"""Select complete groups in source order, without owning public delivery."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
body: AsyncIterator[str],
|
||||
*,
|
||||
wire_api: Literal["messages", "responses"],
|
||||
envelope: PublicStreamEnvelope | None = None,
|
||||
) -> None:
|
||||
self._body = body
|
||||
def __init__(self, wire_api: Literal["messages", "responses"]) -> None:
|
||||
self._wire_api = wire_api
|
||||
self._decoder = AnthropicSSEDecoder()
|
||||
self._frames: list[str] = []
|
||||
self._calls: set[CallKey] = set()
|
||||
self._pending: set[CallKey] = set()
|
||||
|
|
@ -66,75 +56,8 @@ class ToolCallBufferedStream(AsyncIterator[str]):
|
|||
self._discarded: set[CallKey] = set()
|
||||
self._ids: dict[str, CallKey] = {}
|
||||
self._indices: dict[int, CallKey] = {}
|
||||
self._done = False
|
||||
self._closed = False
|
||||
self._envelope = envelope
|
||||
self._revision = envelope.state.attempt_revision if envelope else 0
|
||||
|
||||
def __aiter__(self) -> ToolCallBufferedStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
while not self._closed and not self._done:
|
||||
try:
|
||||
chunk = await anext(self._body)
|
||||
except StopAsyncIteration:
|
||||
self._synchronize()
|
||||
self._done = True
|
||||
frames = self._decoder.finish_frames()
|
||||
except BaseException:
|
||||
self._synchronize()
|
||||
self._discard_group()
|
||||
if self._envelope is not None:
|
||||
self._envelope.discard_pending_starts()
|
||||
raise
|
||||
else:
|
||||
self._synchronize()
|
||||
frames = self._decoder.feed_frames(chunk)
|
||||
ready = [value for frame in frames for value in self._accept(frame)]
|
||||
if self._envelope is not None:
|
||||
ready = [
|
||||
value
|
||||
for frame in ready
|
||||
if (value := self._envelope.publish(frame)) is not None
|
||||
]
|
||||
if self._done:
|
||||
self._discard_group()
|
||||
if self._envelope is not None:
|
||||
self._envelope.discard_pending_starts()
|
||||
if ready:
|
||||
return "".join(ready)
|
||||
raise StopAsyncIteration
|
||||
|
||||
def _synchronize(self) -> None:
|
||||
envelope = self._envelope
|
||||
if envelope is None or self._revision == envelope.state.attempt_revision:
|
||||
return
|
||||
self._revision = envelope.state.attempt_revision
|
||||
self._discard_group()
|
||||
self._decoder = AnthropicSSEDecoder()
|
||||
self._ids.clear()
|
||||
self._indices.clear()
|
||||
self._delivered.clear()
|
||||
self._discarded.clear()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._discard_group()
|
||||
if self._envelope is not None:
|
||||
self._envelope.discard_pending_starts()
|
||||
self._decoder.finish_frames()
|
||||
self._ids.clear()
|
||||
self._indices.clear()
|
||||
self._delivered.clear()
|
||||
self._discarded.clear()
|
||||
close_error = await try_close_async_iterator(self._body)
|
||||
if close_error is not None:
|
||||
raise close_error
|
||||
|
||||
def _accept(self, frame: str) -> list[str]:
|
||||
def feed(self, frame: str) -> list[str]:
|
||||
events = parse_sse_text(frame)
|
||||
if not events:
|
||||
return self._publish(frame)
|
||||
|
|
|
|||
335
src/free_claude_code/core/continuation_overlap.py
Normal file
335
src/free_claude_code/core/continuation_overlap.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"""Remove one logical replay prefix while preserving source parts and events."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import simplejson
|
||||
|
||||
from .delivered_response import RECOVERY_RETENTION_BYTES
|
||||
from .failures import ExecutionFailure, FailureKind
|
||||
|
||||
type PartKey = tuple[int | str, int]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Part:
|
||||
order: tuple[int, int]
|
||||
text: str = ""
|
||||
seen: int = 0
|
||||
closed: bool = False
|
||||
removed: int = 0
|
||||
fixed: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Text:
|
||||
key: PartKey
|
||||
order: tuple[int, int]
|
||||
value: dict[str, Any]
|
||||
field: str
|
||||
delta: bool = False
|
||||
part: _Part | None = None
|
||||
offset: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Frame:
|
||||
data: dict[str, Any]
|
||||
texts: list[_Text]
|
||||
|
||||
|
||||
class ContinuationOverlap:
|
||||
"""Own unpublished replay evidence, never the public response transcript."""
|
||||
|
||||
def __init__(self, expected: str) -> None:
|
||||
self._expected = expected
|
||||
self._pending = bool(expected)
|
||||
self._parts: dict[PartKey, _Part] = {}
|
||||
self._ids: dict[int, str] = {}
|
||||
self._frames: list[_Frame] = []
|
||||
self._frame_bytes = 0
|
||||
self._frontier: tuple[int, int] | None = None
|
||||
|
||||
def feed(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
data = deepcopy(payload)
|
||||
block = data.get("content_block", {})
|
||||
if (
|
||||
data.get("type") == "content_block_start"
|
||||
and block.get("type") == "text"
|
||||
and block.get("text")
|
||||
):
|
||||
text, block["text"] = block["text"], ""
|
||||
return [
|
||||
*self._feed(data),
|
||||
*self._feed(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": data["index"],
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
}
|
||||
),
|
||||
]
|
||||
return self._feed(data)
|
||||
|
||||
def _feed(self, data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
kind = data.get("type", "")
|
||||
if kind in {"error", "response.error", "response.failed"}:
|
||||
self._frames.clear()
|
||||
self._frame_bytes = 0
|
||||
return [data]
|
||||
if not self._expected:
|
||||
return self._render(_Frame(data, self._texts(data)))
|
||||
texts = self._texts(data)
|
||||
frame = _Frame(data, texts)
|
||||
terminal = kind in {
|
||||
"response.completed",
|
||||
"response.incomplete",
|
||||
"message_stop",
|
||||
} or (
|
||||
kind == "message_delta"
|
||||
and data.get("delta", {}).get("stop_reason") is not None
|
||||
)
|
||||
if not self._pending:
|
||||
if kind in {"response.completed", "response.incomplete"}:
|
||||
self._check_terminal_order(texts)
|
||||
for text in texts:
|
||||
self._settled_text(text)
|
||||
return self._render(frame)
|
||||
|
||||
if kind in {"response.completed", "response.incomplete"}:
|
||||
supplied = {text.key for text in texts}
|
||||
if any(
|
||||
part.text and key not in supplied for key, part in self._parts.items()
|
||||
):
|
||||
self._inconsistent()
|
||||
|
||||
for text in texts:
|
||||
part = self._parts.setdefault(text.key, _Part(text.order))
|
||||
part.order = text.order
|
||||
text.part = part
|
||||
value = text.value.get(text.field, "")
|
||||
text.offset = part.seen if text.delta else 0
|
||||
if text.delta:
|
||||
part.text = (part.text + value)[: len(self._expected) + 1]
|
||||
part.seen += len(value)
|
||||
else:
|
||||
if not value.startswith(part.text):
|
||||
self._inconsistent()
|
||||
part.text = value[: len(self._expected) + 1]
|
||||
part.seen = len(value)
|
||||
if kind.endswith(".done"):
|
||||
part.closed = True
|
||||
self._close_parts(data, terminal=terminal)
|
||||
ordered = sorted(self._parts.items(), key=lambda entry: entry[1].order)
|
||||
candidate = ""
|
||||
candidates: list[tuple[PartKey, _Part]] = []
|
||||
for key, part in ordered:
|
||||
candidates.append((key, part))
|
||||
candidate += part.text[: len(self._expected) + 1 - len(candidate)]
|
||||
if not part.closed or len(candidate) > len(self._expected):
|
||||
break
|
||||
if terminal or candidate not in self._expected:
|
||||
self._resolve(candidate, candidates)
|
||||
frames, self._frames = self._frames, []
|
||||
self._frame_bytes = 0
|
||||
self._check_size()
|
||||
return [
|
||||
event for ready in [*frames, frame] for event in self._render(ready)
|
||||
]
|
||||
|
||||
if self._frames or any(text.value.get(text.field) for text in texts):
|
||||
self._frames.append(frame)
|
||||
self._frame_bytes += self._size(data)
|
||||
self._check_size()
|
||||
return []
|
||||
self._check_size()
|
||||
return self._render(frame)
|
||||
|
||||
def _identity(self, data: dict[str, Any], item: dict[str, Any]) -> int | str:
|
||||
index = data.get("output_index", 0)
|
||||
identity = item.get("id") or data.get("item_id") or self._ids.get(index, index)
|
||||
if self._pending and isinstance(identity, str) and "output_index" in data:
|
||||
self._ids[index] = identity
|
||||
return identity
|
||||
|
||||
def _texts(self, data: dict[str, Any]) -> list[_Text]:
|
||||
kind = data.get("type", "")
|
||||
index = data.get("index", 0)
|
||||
if kind == "content_block_start":
|
||||
block = data.get("content_block", {})
|
||||
return (
|
||||
[_Text((index, 0), (index, 0), block, "text")]
|
||||
if block.get("type") == "text"
|
||||
else []
|
||||
)
|
||||
if kind == "content_block_delta":
|
||||
delta = data.get("delta", {})
|
||||
return (
|
||||
[_Text((index, 0), (index, 0), delta, "text", delta=True)]
|
||||
if delta.get("type") == "text_delta"
|
||||
else []
|
||||
)
|
||||
if kind in {"response.completed", "response.incomplete"}:
|
||||
texts = []
|
||||
for position, item in enumerate(data.get("response", {}).get("output", [])):
|
||||
texts.extend(self._item_texts(item, item.get("id", position), position))
|
||||
return texts
|
||||
item = data.get("item", {})
|
||||
if kind in {"response.output_item.added", "response.output_item.done"}:
|
||||
if item.get("type") != "message":
|
||||
return []
|
||||
identity = self._identity(data, item)
|
||||
return self._item_texts(item, identity, data.get("output_index", 0))
|
||||
if kind not in {
|
||||
"response.content_part.added",
|
||||
"response.content_part.done",
|
||||
"response.output_text.delta",
|
||||
"response.output_text.done",
|
||||
}:
|
||||
return []
|
||||
position = data.get("content_index", 0)
|
||||
key = (self._identity(data, {}), position)
|
||||
order = (data.get("output_index", 0), position)
|
||||
if kind in {"response.output_text.delta", "response.output_text.done"}:
|
||||
delta = kind.endswith(".delta")
|
||||
return [_Text(key, order, data, "delta" if delta else "text", delta=delta)]
|
||||
part = data.get("part", {})
|
||||
return (
|
||||
[_Text(key, order, part, "text")]
|
||||
if part.get("type") == "output_text"
|
||||
else []
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _item_texts(
|
||||
item: dict[str, Any], identity: int | str, index: int
|
||||
) -> list[_Text]:
|
||||
if item.get("type") != "message":
|
||||
return []
|
||||
return [
|
||||
_Text((identity, position), (index, position), part, "text")
|
||||
for position, part in enumerate(item.get("content", []))
|
||||
if part.get("type") == "output_text"
|
||||
]
|
||||
|
||||
def _close_parts(self, data: dict[str, Any], *, terminal: bool) -> None:
|
||||
kind = data.get("type")
|
||||
identity = (
|
||||
self._identity(data, data.get("item", {}))
|
||||
if kind == "response.output_item.done"
|
||||
and data.get("item", {}).get("type") == "message"
|
||||
else data.get("index")
|
||||
if kind == "content_block_stop"
|
||||
else None
|
||||
)
|
||||
for key, part in self._parts.items():
|
||||
if terminal or key[0] == identity:
|
||||
part.closed = True
|
||||
|
||||
def _resolve(self, candidate: str, candidates: list[tuple[PartKey, _Part]]) -> None:
|
||||
overlap = self._overlap_size(candidate)
|
||||
remaining = len(candidate)
|
||||
self._pending = False
|
||||
self._parts = {}
|
||||
for key, part in candidates:
|
||||
if not part.text or not remaining:
|
||||
continue
|
||||
part.text = part.text[:remaining]
|
||||
remaining -= len(part.text)
|
||||
part.removed = min(overlap, len(part.text))
|
||||
overlap -= part.removed
|
||||
part.fixed = bool(remaining)
|
||||
self._parts[key] = part
|
||||
self._frontier = part.order
|
||||
identities = {key[0] for key in self._parts}
|
||||
self._ids = {
|
||||
index: value for index, value in self._ids.items() if value in identities
|
||||
}
|
||||
|
||||
def _overlap_size(self, candidate: str) -> int:
|
||||
for size in range(min(len(self._expected), len(candidate)), 0, -1):
|
||||
if self._expected.endswith(candidate[:size]):
|
||||
return size
|
||||
return 0
|
||||
|
||||
def _settled_text(self, text: _Text) -> None:
|
||||
value = text.value.get(text.field, "")
|
||||
part = self._parts.get(text.key)
|
||||
if part is None:
|
||||
if value and self._frontier is not None and text.order < self._frontier:
|
||||
self._inconsistent()
|
||||
return
|
||||
text.part = part
|
||||
if text.delta:
|
||||
if value and part.fixed:
|
||||
self._inconsistent()
|
||||
text.offset = part.seen
|
||||
part.seen += len(value)
|
||||
else:
|
||||
if not value.startswith(part.text) or (
|
||||
part.fixed and len(value) != part.seen
|
||||
):
|
||||
self._inconsistent()
|
||||
part.seen = max(part.seen, len(value))
|
||||
|
||||
def _check_terminal_order(self, texts: list[_Text]) -> None:
|
||||
keys = list(self._parts)
|
||||
observed = [text.key for text in texts if text.value.get(text.field)]
|
||||
if observed[: len(keys)] != keys:
|
||||
self._inconsistent()
|
||||
# Terminal positions may have shifted when the tool gate removed an item.
|
||||
for text in texts:
|
||||
if text.key not in self._parts:
|
||||
text.order = (
|
||||
(self._frontier[0] + 1, 0) if self._frontier else text.order
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render(frame: _Frame) -> list[dict[str, Any]]:
|
||||
for text in frame.texts:
|
||||
if text.part is not None:
|
||||
removed = (
|
||||
max(0, text.part.removed - text.offset)
|
||||
if text.delta
|
||||
else text.part.removed
|
||||
)
|
||||
text.value[text.field] = text.value.get(text.field, "")[removed:]
|
||||
if (
|
||||
frame.texts
|
||||
and all(text.delta for text in frame.texts)
|
||||
and not any(text.value.get(text.field) for text in frame.texts)
|
||||
):
|
||||
return []
|
||||
return [frame.data]
|
||||
|
||||
@staticmethod
|
||||
def _size(value: object) -> int:
|
||||
return len(
|
||||
simplejson.dumps(value, use_decimal=True, ensure_ascii=False).encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
def _check_size(self) -> None:
|
||||
retained = self._frame_bytes + self._size(self._ids)
|
||||
retained += sum(
|
||||
self._size((key, part.order, part.text, part.seen))
|
||||
for key, part in self._parts.items()
|
||||
)
|
||||
if retained > RECOVERY_RETENTION_BYTES:
|
||||
raise ExecutionFailure(
|
||||
kind=FailureKind.UPSTREAM,
|
||||
status_code=502,
|
||||
message="The continuation replay exceeded the retained-data limit.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inconsistent() -> None:
|
||||
raise ExecutionFailure(
|
||||
kind=FailureKind.UPSTREAM,
|
||||
status_code=502,
|
||||
message="The continuation changed an already observed answer prefix.",
|
||||
retryable=False,
|
||||
)
|
||||
157
src/free_claude_code/core/continuation_stream.py
Normal file
157
src/free_claude_code/core/continuation_stream.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Map continuation events into an existing public response lifecycle."""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
from uuid import uuid4, uuid5
|
||||
|
||||
from .continuation_overlap import ContinuationOverlap
|
||||
from .delivered_response import (
|
||||
DeliveredResponse,
|
||||
client_call,
|
||||
responses_text_constraint,
|
||||
)
|
||||
from .failures import ExecutionFailure, FailureKind
|
||||
|
||||
|
||||
class ContinuationStream:
|
||||
"""Own only recovery-time mappings; ordinary events remain untouched."""
|
||||
|
||||
def __init__(self, delivered: DeliveredResponse) -> None:
|
||||
self.delivered = delivered
|
||||
self.active = False
|
||||
self.handoff = False
|
||||
self._offset = 0
|
||||
self._namespace = uuid4()
|
||||
self._boundary: list[dict[str, Any]] = []
|
||||
self._expected = ""
|
||||
self._overlap = ContinuationOverlap("")
|
||||
self._initial_progress = 0
|
||||
self._prefix_items: list[dict[str, Any]] = []
|
||||
|
||||
@property
|
||||
def made_progress(self) -> bool:
|
||||
return self.delivered.progress > self._initial_progress
|
||||
|
||||
def begin(self, *, handoff: bool) -> None:
|
||||
self.active = True
|
||||
self.handoff = handoff
|
||||
self._offset = max(self.delivered.items, default=-1) + 1
|
||||
self._boundary = self.delivered.closing_events()
|
||||
self._expected = self.delivered.snapshot().text
|
||||
self.restart_attempt()
|
||||
finalized = deepcopy(self.delivered.items)
|
||||
for event in self._boundary:
|
||||
if event["type"] == "response.output_item.done":
|
||||
finalized[event["output_index"]] = deepcopy(event["item"])
|
||||
self._prefix_items = [item for _, item in sorted(finalized.items())]
|
||||
|
||||
def restart_attempt(self) -> None:
|
||||
"""Replace an invisible attempt without losing its finalized prefix."""
|
||||
self._namespace = uuid4()
|
||||
self._overlap = ContinuationOverlap(self._expected)
|
||||
self._initial_progress = self.delivered.progress
|
||||
|
||||
def boundary(self) -> list[dict[str, Any]]:
|
||||
events, self._boundary = self._boundary, []
|
||||
return [self._sequence(event) for event in events]
|
||||
|
||||
def _sequence(self, event: dict[str, Any]) -> dict[str, Any]:
|
||||
if self.delivered.wire_api == "responses":
|
||||
self.delivered.sequence += 1
|
||||
event["sequence_number"] = self.delivered.sequence
|
||||
return event
|
||||
|
||||
def _item_id(self, value: str) -> str:
|
||||
return f"{value.split('_')[0]}_{uuid5(self._namespace, value).hex}"
|
||||
|
||||
def prepare(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not self.active:
|
||||
return [payload]
|
||||
if (
|
||||
self.delivered.wire_api == "responses"
|
||||
and payload.get("type")
|
||||
not in {"response.failed", "response.error", "error"}
|
||||
and responses_text_constraint(payload)
|
||||
):
|
||||
raise ExecutionFailure(
|
||||
kind=FailureKind.UPSTREAM,
|
||||
status_code=502,
|
||||
message="The continuation returned text with a native output contract that cannot be reconstructed.",
|
||||
retryable=False,
|
||||
)
|
||||
if payload.get("type") in {
|
||||
"message_start",
|
||||
"response.created",
|
||||
"response.in_progress",
|
||||
}:
|
||||
return []
|
||||
events = [deepcopy(payload)] if self.handoff else self._overlap.feed(payload)
|
||||
return [self._map(event) for event in events]
|
||||
|
||||
def _map(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.handoff:
|
||||
if isinstance(data.get("index"), int):
|
||||
data["index"] += self._offset
|
||||
if isinstance(data.get("output_index"), int):
|
||||
data["output_index"] += self._offset
|
||||
if isinstance(data.get("item_id"), str):
|
||||
data["item_id"] = self._item_id(data["item_id"])
|
||||
item = data.get("item")
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
||||
item["id"] = self._item_id(item["id"])
|
||||
if "response_id" in data:
|
||||
data["response_id"] = self.delivered.header.get("id", data["response_id"])
|
||||
response = data.get("response")
|
||||
if isinstance(response, dict):
|
||||
response["id"] = self.delivered.header.get("id", response.get("id"))
|
||||
if "created_at" in self.delivered.header:
|
||||
response["created_at"] = self.delivered.header["created_at"]
|
||||
if not self.handoff:
|
||||
for item in response.get("output", []):
|
||||
if isinstance(item.get("id"), str):
|
||||
item["id"] = self._item_id(item["id"])
|
||||
return self._sequence(data)
|
||||
|
||||
def finalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Finish the response only after preceding frames have been published."""
|
||||
if not self.active:
|
||||
return data
|
||||
kind = data.get("type")
|
||||
response = data.get("response")
|
||||
output = response.get("output", []) if isinstance(response, dict) else []
|
||||
terminal = kind in {"response.completed", "response.incomplete", "message_stop"}
|
||||
terminal |= (
|
||||
kind == "message_delta"
|
||||
and data.get("delta", {}).get("stop_reason") is not None
|
||||
)
|
||||
if (
|
||||
terminal
|
||||
and not self.handoff
|
||||
and not (self.made_progress or self._has_output(output))
|
||||
):
|
||||
raise ExecutionFailure(
|
||||
kind=FailureKind.UPSTREAM,
|
||||
status_code=502,
|
||||
message="The provider continuation completed without adding output.",
|
||||
retryable=False,
|
||||
)
|
||||
if isinstance(response, dict):
|
||||
response["output"] = [
|
||||
*deepcopy(self._prefix_items),
|
||||
*([] if self.handoff else output),
|
||||
]
|
||||
response["usage"] = self.delivered.usage(response["output"])
|
||||
elif kind == "message_delta" and "usage" in data:
|
||||
data["usage"] = {"output_tokens": self.delivered.output_tokens()}
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _has_output(items: list[dict[str, Any]]) -> bool:
|
||||
return any(
|
||||
client_call(item)
|
||||
or any(
|
||||
part.get("text") or part.get("refusal")
|
||||
for part in [*(item.get("content") or []), *(item.get("summary") or [])]
|
||||
)
|
||||
for item in items
|
||||
)
|
||||
555
src/free_claude_code/core/delivered_response.py
Normal file
555
src/free_claude_code/core/delivered_response.py
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
"""Bounded, request-local evidence of output released to a stream consumer."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import simplejson
|
||||
|
||||
from .history_replay import (
|
||||
ReplayRecord,
|
||||
encode_replay,
|
||||
readable_reasoning,
|
||||
unencrypted_responses_replay,
|
||||
)
|
||||
from .openai_responses import is_client_search
|
||||
from .token_estimation import estimate_text_tokens
|
||||
|
||||
RECOVERY_RETENTION_BYTES = 1_048_576
|
||||
|
||||
CLIENT_CALL_TYPES = frozenset(
|
||||
{
|
||||
"function_call",
|
||||
"custom_tool_call",
|
||||
"computer_call",
|
||||
"local_shell_call",
|
||||
"apply_patch_call",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def client_call(item: dict[str, Any]) -> bool:
|
||||
kind = item.get("type")
|
||||
if kind == "tool_search_call":
|
||||
return is_client_search(item)
|
||||
return kind in CLIENT_CALL_TYPES or (
|
||||
kind == "shell_call"
|
||||
and isinstance(item.get("environment"), dict)
|
||||
and item["environment"].get("type") == "local"
|
||||
)
|
||||
|
||||
|
||||
def is_structured_text_format(value: object) -> bool:
|
||||
return isinstance(value, dict) and value.get("type") in (
|
||||
"json_schema",
|
||||
"json_object",
|
||||
)
|
||||
|
||||
|
||||
def responses_text_constraint(data: dict[str, Any]) -> str | None:
|
||||
"""Identify public text contracts that cannot survive synthetic completion."""
|
||||
response = data.get("response")
|
||||
response = response if isinstance(response, dict) else {}
|
||||
text = response.get("text")
|
||||
if isinstance(text, dict) and is_structured_text_format(text.get("format")):
|
||||
return "structured_output"
|
||||
part = data.get("part")
|
||||
if data.get("logprobs") or (isinstance(part, dict) and part.get("logprobs")):
|
||||
return "text_logprobs"
|
||||
items = [data.get("item"), *(response.get("output") or [])]
|
||||
if any(
|
||||
part.get("logprobs")
|
||||
for item in items
|
||||
if isinstance(item, dict)
|
||||
for part in item.get("content") or []
|
||||
):
|
||||
return "text_logprobs"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveredPrefix:
|
||||
text: str
|
||||
thinking: str
|
||||
has_calls: bool
|
||||
eligible: bool
|
||||
can_handoff: bool
|
||||
unsafe_reason: str | None
|
||||
|
||||
|
||||
class DeliveredResponse:
|
||||
"""Track this response only; the harness continues to own conversation history."""
|
||||
|
||||
def __init__(self, wire_api: Literal["messages", "responses"]) -> None:
|
||||
self.wire_api = wire_api
|
||||
self.items: dict[int, dict[str, Any]] = {}
|
||||
self.closed: set[int] = set()
|
||||
self._text_closed: set[tuple[int, str, int]] = set()
|
||||
self._part_closed: set[tuple[int, str, int]] = set()
|
||||
self.header: dict[str, Any] = {}
|
||||
self.sequence = -1
|
||||
self.unsafe_reason: str | None = None
|
||||
self._text_constraint: str | None = None
|
||||
self._bytes = 0
|
||||
self._limited = False
|
||||
self._limited_output_tokens = 0
|
||||
self.progress = 0
|
||||
|
||||
def _retain(self, value: object) -> bool:
|
||||
if self._limited:
|
||||
return False
|
||||
self._bytes += len(
|
||||
simplejson.dumps(value, use_decimal=True, ensure_ascii=False).encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
if self._bytes > RECOVERY_RETENTION_BYTES:
|
||||
self._limited_output_tokens = self.output_tokens()
|
||||
self._limited = True
|
||||
self.unsafe_reason = "retention_limit"
|
||||
self.items.clear()
|
||||
self.closed.clear()
|
||||
self._text_closed.clear()
|
||||
self._part_closed.clear()
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def retention_exhausted(self) -> bool:
|
||||
return self._limited
|
||||
|
||||
def observe(self, data: dict[str, Any], *, count_progress: bool = True) -> None:
|
||||
kind = data.get("type", "")
|
||||
if not isinstance(kind, str):
|
||||
self.unsafe_reason = "unknown_event"
|
||||
return
|
||||
sequence = data.get("sequence_number")
|
||||
if isinstance(sequence, int):
|
||||
self.sequence = max(self.sequence, sequence)
|
||||
if kind in {"message_start", "response.created"} and not self.header:
|
||||
self.header = deepcopy(data.get("message", data.get("response", {})))
|
||||
delta = data.get("delta", "")
|
||||
text = (
|
||||
delta
|
||||
if isinstance(delta, str)
|
||||
else "".join(
|
||||
delta.get(field, "") for field in ("text", "thinking", "partial_json")
|
||||
)
|
||||
if isinstance(delta, dict)
|
||||
else ""
|
||||
)
|
||||
block = data.get("content_block", {})
|
||||
if kind == "content_block_start":
|
||||
text += block.get("text", "") + block.get("thinking", "")
|
||||
if (
|
||||
block.get("type") == "redacted_thinking"
|
||||
and (record := unencrypted_responses_replay(block.get("data")))
|
||||
is not None
|
||||
):
|
||||
text += "".join(text for text, _ in readable_reasoning(record.native))
|
||||
if count_progress and (
|
||||
text
|
||||
or self._new_snapshot_text(data)
|
||||
or data.get("part", {}).get("refusal")
|
||||
or (kind == "response.refusal.done" and data.get("refusal"))
|
||||
or (kind == "content_block_start" and block.get("type") == "tool_use")
|
||||
or (
|
||||
kind == "response.output_item.done"
|
||||
and client_call(data.get("item", {}))
|
||||
)
|
||||
):
|
||||
self.progress += 1
|
||||
if not self._limited:
|
||||
if self.wire_api == "messages":
|
||||
self._messages(kind, data)
|
||||
else:
|
||||
self._responses(kind, data)
|
||||
if self._limited:
|
||||
self._limited_output_tokens += estimate_text_tokens(text)
|
||||
|
||||
def _new_snapshot_text(self, data: dict[str, Any]) -> bool:
|
||||
"""Cumulative text is progress only when it adds published characters."""
|
||||
kind = data.get("type")
|
||||
recorded = self.items.get(data.get("output_index"), {})
|
||||
previous = recorded.get("content") or []
|
||||
if kind in {"response.output_item.added", "response.output_item.done"}:
|
||||
item = data.get("item", {})
|
||||
if item.get("type") != "message":
|
||||
return False
|
||||
parts = enumerate(item.get("content") or [])
|
||||
elif kind in {"response.content_part.added", "response.content_part.done"}:
|
||||
parts = [(data.get("content_index", 0), data.get("part", {}))]
|
||||
elif kind == "response.output_text.done":
|
||||
parts = [
|
||||
(
|
||||
data.get("content_index", 0),
|
||||
{"type": "output_text", "text": data.get("text", "")},
|
||||
)
|
||||
]
|
||||
else:
|
||||
return False
|
||||
return any(
|
||||
part.get("type") == "output_text"
|
||||
and len(part.get("text", ""))
|
||||
> len(previous[index].get("text", "") if index < len(previous) else "")
|
||||
for index, part in parts
|
||||
)
|
||||
|
||||
def _messages(self, kind: str, data: dict[str, Any]) -> None:
|
||||
index = data.get("index")
|
||||
if not isinstance(index, int):
|
||||
if kind not in {
|
||||
"message_start",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
"ping",
|
||||
"error",
|
||||
}:
|
||||
self.unsafe_reason = "unknown_event"
|
||||
return
|
||||
if kind == "content_block_start":
|
||||
block = data.get("content_block", {})
|
||||
if not self._retain(block):
|
||||
return
|
||||
self.items[index] = deepcopy(block)
|
||||
if block.get("type") not in {
|
||||
"text",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"tool_use",
|
||||
}:
|
||||
self.unsafe_reason = "native_content"
|
||||
if block.get("signature") or block.get("type") == "redacted_thinking":
|
||||
value = (
|
||||
block.get("data")
|
||||
if block.get("type") == "redacted_thinking"
|
||||
else block.get("signature")
|
||||
)
|
||||
if unencrypted_responses_replay(value) is None:
|
||||
self.unsafe_reason = "opaque_reasoning"
|
||||
elif kind == "content_block_delta":
|
||||
delta = data.get("delta", {})
|
||||
if not self._retain(delta):
|
||||
return
|
||||
block = self.items.get(index)
|
||||
if block is None:
|
||||
self.unsafe_reason = "missing_block"
|
||||
return
|
||||
field = {
|
||||
"text_delta": "text",
|
||||
"thinking_delta": "thinking",
|
||||
"input_json_delta": "partial_json",
|
||||
}.get(delta.get("type"))
|
||||
if field:
|
||||
block[field] = block.get(field, "") + delta.get(field, "")
|
||||
elif delta.get("type") == "signature_delta":
|
||||
block["signature"] = block.get("signature", "") + delta.get(
|
||||
"signature", ""
|
||||
)
|
||||
if unencrypted_responses_replay(block["signature"]) is None:
|
||||
self.unsafe_reason = "opaque_delta"
|
||||
else:
|
||||
self.unsafe_reason = "opaque_delta"
|
||||
elif kind == "content_block_stop":
|
||||
self.closed.add(index)
|
||||
|
||||
def _responses(self, kind: str, data: dict[str, Any]) -> None:
|
||||
self._text_constraint = self._text_constraint or responses_text_constraint(data)
|
||||
index = data.get("output_index")
|
||||
if not isinstance(index, int):
|
||||
if kind not in {
|
||||
"response.created",
|
||||
"response.in_progress",
|
||||
"response.completed",
|
||||
"response.incomplete",
|
||||
"response.failed",
|
||||
"response.error",
|
||||
"error",
|
||||
"ping",
|
||||
}:
|
||||
self.unsafe_reason = "unknown_event"
|
||||
return
|
||||
if kind in {"response.output_item.added", "response.output_item.done"}:
|
||||
item = data.get("item", {})
|
||||
if not self._retain(item):
|
||||
return
|
||||
self.items[index] = deepcopy(item)
|
||||
if item.get("type") not in {"message", "reasoning"} and not client_call(
|
||||
item
|
||||
):
|
||||
self.unsafe_reason = "native_content"
|
||||
if (
|
||||
item.get("encrypted_content")
|
||||
and unencrypted_responses_replay(item["encrypted_content"]) is None
|
||||
):
|
||||
self.unsafe_reason = "opaque_reasoning"
|
||||
if any(part.get("type") == "refusal" for part in item.get("content") or []):
|
||||
self.unsafe_reason = "refusal"
|
||||
if kind.endswith(".done"):
|
||||
self.closed.add(index)
|
||||
elif kind in {
|
||||
"response.content_part.added",
|
||||
"response.reasoning_summary_part.added",
|
||||
"response.content_part.done",
|
||||
"response.reasoning_summary_part.done",
|
||||
}:
|
||||
part = data.get("part", {})
|
||||
if not self._retain(part) or (
|
||||
kind.endswith(".done")
|
||||
and not self._retain(
|
||||
(
|
||||
kind,
|
||||
index,
|
||||
data.get("content_index", data.get("summary_index", 0)),
|
||||
)
|
||||
)
|
||||
):
|
||||
return
|
||||
item = self.items.get(index)
|
||||
if item is None:
|
||||
self.unsafe_reason = "missing_item"
|
||||
return
|
||||
field = "summary" if "reasoning" in kind else "content"
|
||||
position = data.get(
|
||||
"summary_index" if field == "summary" else "content_index", 0
|
||||
)
|
||||
parts = item[field] = item.get(field) or []
|
||||
while len(parts) <= position:
|
||||
parts.append({})
|
||||
parts[position] = deepcopy(part)
|
||||
if kind.endswith(".done"):
|
||||
self._part_closed.add((index, field, position))
|
||||
if part.get("type") == "refusal":
|
||||
self.unsafe_reason = "refusal"
|
||||
elif part.get("type") not in {
|
||||
"output_text",
|
||||
"summary_text",
|
||||
"reasoning_text",
|
||||
}:
|
||||
self.unsafe_reason = "native_content"
|
||||
elif kind in {
|
||||
"response.output_text.delta",
|
||||
"response.reasoning_summary_text.delta",
|
||||
"response.reasoning_text.delta",
|
||||
"response.refusal.delta",
|
||||
"response.output_text.done",
|
||||
"response.reasoning_summary_text.done",
|
||||
"response.reasoning_text.done",
|
||||
"response.refusal.done",
|
||||
}:
|
||||
done = kind.endswith(".done")
|
||||
text_field = "refusal" if "refusal" in kind else "text"
|
||||
delta = data.get(text_field if done else "delta", "")
|
||||
if not self._retain(delta) or (
|
||||
done
|
||||
and not self._retain(
|
||||
(
|
||||
kind,
|
||||
index,
|
||||
data.get("content_index", data.get("summary_index", 0)),
|
||||
)
|
||||
)
|
||||
):
|
||||
return
|
||||
item = self.items.get(index)
|
||||
if item is None:
|
||||
self.unsafe_reason = "missing_item"
|
||||
return
|
||||
field = "summary" if "reasoning_summary" in kind else "content"
|
||||
position = data.get(
|
||||
"summary_index" if field == "summary" else "content_index", 0
|
||||
)
|
||||
parts = item[field] = item.get(field) or []
|
||||
while len(parts) <= position:
|
||||
parts.append(
|
||||
{
|
||||
"type": "summary_text"
|
||||
if field == "summary"
|
||||
else "reasoning_text"
|
||||
if "reasoning" in kind
|
||||
else "refusal"
|
||||
if text_field == "refusal"
|
||||
else "output_text",
|
||||
text_field: "",
|
||||
}
|
||||
)
|
||||
parts[position][text_field] = (
|
||||
delta if done else parts[position].get(text_field, "") + delta
|
||||
)
|
||||
if done:
|
||||
self._text_closed.add((index, field, position))
|
||||
if text_field == "refusal":
|
||||
self.unsafe_reason = "refusal"
|
||||
elif kind.endswith(".delta") and kind not in {
|
||||
"response.function_call_arguments.delta",
|
||||
"response.custom_tool_call_input.delta",
|
||||
}:
|
||||
self.unsafe_reason = "native_delta"
|
||||
elif kind not in {
|
||||
"response.function_call_arguments.delta",
|
||||
"response.function_call_arguments.done",
|
||||
"response.custom_tool_call_input.delta",
|
||||
"response.custom_tool_call_input.done",
|
||||
}:
|
||||
self.unsafe_reason = "unknown_event"
|
||||
|
||||
def snapshot(self) -> DeliveredPrefix:
|
||||
text: list[str] = []
|
||||
thinking: list[str] = []
|
||||
has_calls = False
|
||||
for index, item in sorted(self.items.items()):
|
||||
kind = item.get("type")
|
||||
if self.wire_api == "messages":
|
||||
if kind == "text":
|
||||
text.append(item.get("text", ""))
|
||||
elif kind == "thinking":
|
||||
thinking.append(item.get("thinking", ""))
|
||||
elif kind == "redacted_thinking":
|
||||
if (
|
||||
record := unencrypted_responses_replay(item.get("data"))
|
||||
) is not None:
|
||||
thinking.extend(
|
||||
text for text, _ in readable_reasoning(record.native)
|
||||
)
|
||||
elif kind == "tool_use" and index in self.closed:
|
||||
has_calls = True
|
||||
elif kind == "message":
|
||||
text.extend(part.get("text", "") for part in item.get("content") or [])
|
||||
elif kind == "reasoning":
|
||||
thinking.extend(
|
||||
part.get("text", "")
|
||||
for part in [
|
||||
*(item.get("summary") or []),
|
||||
*(item.get("content") or []),
|
||||
]
|
||||
)
|
||||
elif client_call(item) and index in self.closed:
|
||||
has_calls = True
|
||||
unsafe_reason = self._text_constraint or self.unsafe_reason
|
||||
return DeliveredPrefix(
|
||||
"".join(text),
|
||||
"".join(thinking),
|
||||
has_calls,
|
||||
unsafe_reason is None,
|
||||
self.can_handoff,
|
||||
unsafe_reason,
|
||||
)
|
||||
|
||||
def output_tokens(self, output: list[dict[str, Any]] | None = None) -> int:
|
||||
if output is None and self._limited:
|
||||
return self._limited_output_tokens
|
||||
return estimate_text_tokens(
|
||||
simplejson.dumps(
|
||||
list(self.items.values()) if output is None else output,
|
||||
use_decimal=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def can_handoff(self) -> bool:
|
||||
if self._text_constraint or self.unsafe_reason not in {
|
||||
None,
|
||||
"opaque_reasoning",
|
||||
"opaque_delta",
|
||||
}:
|
||||
return False
|
||||
return all(
|
||||
index in self.closed
|
||||
for index, item in self.items.items()
|
||||
if item.get("type") in {"thinking", "redacted_thinking", "reasoning"}
|
||||
)
|
||||
|
||||
def closing_events(self) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
for index, item in sorted(self.items.items()):
|
||||
if index in self.closed:
|
||||
continue
|
||||
if self.wire_api == "messages":
|
||||
events.append({"type": "content_block_stop", "index": index})
|
||||
else:
|
||||
for field in ("content", "summary"):
|
||||
for position, part in enumerate(item.get(field) or []):
|
||||
key = (index, field, position)
|
||||
if key in self._part_closed:
|
||||
continue
|
||||
summary = field == "summary"
|
||||
identity = {
|
||||
"item_id": item["id"],
|
||||
"output_index": index,
|
||||
"summary_index" if summary else "content_index": position,
|
||||
}
|
||||
if key not in self._text_closed:
|
||||
text_kind = (
|
||||
"response.reasoning_summary_text.done"
|
||||
if summary
|
||||
else "response.reasoning_text.done"
|
||||
if part.get("type") == "reasoning_text"
|
||||
else "response.output_text.done"
|
||||
)
|
||||
events.append(
|
||||
{
|
||||
"type": text_kind,
|
||||
**identity,
|
||||
"text": part.get("text", ""),
|
||||
}
|
||||
)
|
||||
if part.get("type") != "reasoning_text":
|
||||
events.append(
|
||||
{
|
||||
"type": "response.reasoning_summary_part.done"
|
||||
if summary
|
||||
else "response.content_part.done",
|
||||
**identity,
|
||||
"part": deepcopy(part),
|
||||
}
|
||||
)
|
||||
closed_item = {**deepcopy(item), "status": "completed"}
|
||||
if (
|
||||
record := unencrypted_responses_replay(
|
||||
item.get("encrypted_content")
|
||||
)
|
||||
) is not None:
|
||||
native = {**record.native, "status": "completed"}
|
||||
for field in ("content", "summary"):
|
||||
if field in closed_item:
|
||||
native[field] = deepcopy(closed_item[field])
|
||||
closed_item["encrypted_content"] = encode_replay(
|
||||
ReplayRecord(record.origin, native)
|
||||
)
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": index,
|
||||
"item": closed_item,
|
||||
}
|
||||
)
|
||||
return events
|
||||
|
||||
def handoff_events(self) -> list[dict[str, Any]]:
|
||||
"""Signal handoff; the public writer fills output and usage after closure."""
|
||||
if self.wire_api == "messages":
|
||||
return [
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "tool_use", "stop_sequence": None},
|
||||
"usage": {},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
response = {
|
||||
**deepcopy(self.header),
|
||||
"status": "completed",
|
||||
}
|
||||
return [{"type": "response.completed", "response": response}]
|
||||
|
||||
def usage(self, output: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
initial = deepcopy(self.header.get("usage") or {})
|
||||
input_tokens = initial.get("input_tokens", 0)
|
||||
output_tokens = self.output_tokens(output)
|
||||
return {
|
||||
**initial,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": input_tokens + output_tokens,
|
||||
}
|
||||
|
|
@ -314,6 +314,19 @@ def has_readable_replay(value: object) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def unencrypted_responses_replay(value: object) -> ReplayRecord | None:
|
||||
"""Identify local Responses carriers without native encrypted reasoning."""
|
||||
if not isinstance(value, str) or not is_replay(value):
|
||||
return None
|
||||
try:
|
||||
record = decode_replay(value)
|
||||
except HistoryReplayError:
|
||||
return None
|
||||
if record.origin.protocol != "responses" or record.native.get("encrypted_content"):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def _replay_readable(
|
||||
item: Mapping[str, JsonValue], record: ReplayRecord | None
|
||||
) -> list[tuple[str, bool]]:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class ResponsesProviderStream:
|
|||
input_tokens: int,
|
||||
log_raw_events: bool = False,
|
||||
tool_names: OpenAIToolNameCodec | None = None,
|
||||
pad_empty: bool = True,
|
||||
) -> None:
|
||||
self.ledger = AnthropicStreamLedger(
|
||||
message_id,
|
||||
|
|
@ -58,6 +59,7 @@ class ResponsesProviderStream:
|
|||
log_raw_events=log_raw_events,
|
||||
)
|
||||
self.completed = False
|
||||
self._pad_empty = pad_empty
|
||||
self.generated_output = False
|
||||
self._tool_names = tool_names or OpenAIToolNameCodec.from_names(())
|
||||
self._tools: dict[str, _ToolState] = {}
|
||||
|
|
@ -219,7 +221,7 @@ class ResponsesProviderStream:
|
|||
response = data.get("response")
|
||||
response = response if isinstance(response, dict) else {}
|
||||
events = list(self.ledger.close_all_blocks())
|
||||
if not self.ledger.has_content_block():
|
||||
if self._pad_empty and not self.ledger.has_content_block():
|
||||
events.extend(self.ledger.ensure_text_block())
|
||||
events.append(self.ledger.emit_text_delta(" "))
|
||||
events.append(self.ledger.stop_text_block())
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
"""Request-local evidence of public stream delivery, independent of wire format."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Literal
|
||||
|
||||
from .delivered_response import DeliveredPrefix
|
||||
|
||||
|
||||
class StreamDeliveryState:
|
||||
"""Retain public commitment while identifying invisible generation attempts."""
|
||||
"""Exchange attempt transitions and immutable public-delivery evidence."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._content_released = False
|
||||
self._attempt_content_released = False
|
||||
self._attempt_revision = 0
|
||||
self._prefix_reader: Callable[[], DeliveredPrefix] | None = None
|
||||
self.transition: Literal["continue", "handoff"] | None = None
|
||||
|
||||
@property
|
||||
def content_released(self) -> bool:
|
||||
|
|
@ -20,12 +26,28 @@ class StreamDeliveryState:
|
|||
def attempt_revision(self) -> int:
|
||||
return self._attempt_revision
|
||||
|
||||
def release_content(self) -> None:
|
||||
@property
|
||||
def attempt_content_released(self) -> bool:
|
||||
return self._attempt_content_released
|
||||
|
||||
@property
|
||||
def prefix(self) -> DeliveredPrefix | None:
|
||||
return self._prefix_reader() if self._prefix_reader is not None else None
|
||||
|
||||
def bind_prefix(self, reader: Callable[[], DeliveredPrefix] | None) -> None:
|
||||
self._prefix_reader = reader
|
||||
|
||||
def release_content(self, *, synthetic: bool = False) -> None:
|
||||
self._content_released = True
|
||||
if not synthetic:
|
||||
self._attempt_content_released = True
|
||||
|
||||
def begin_attempt(self) -> None:
|
||||
if not self._content_released:
|
||||
self._attempt_revision += 1
|
||||
self._attempt_revision += 1
|
||||
self._attempt_content_released = False
|
||||
|
||||
def begin_continuation(self, *, handoff: bool = False) -> None:
|
||||
self.transition = "handoff" if handoff else "continue"
|
||||
|
||||
|
||||
_delivery: ContextVar[StreamDeliveryState | None] = ContextVar(
|
||||
|
|
|
|||
|
|
@ -143,7 +143,10 @@ class ProviderExecution:
|
|||
) -> ProviderAttempt:
|
||||
"""Open the sole active physical call for this execution."""
|
||||
attempt = await self._controller._open_attempt(self, operation_kind)
|
||||
if operation_kind is ProviderOperationKind.GENERATION and self._delivery:
|
||||
if self._delivery and operation_kind in {
|
||||
ProviderOperationKind.GENERATION,
|
||||
ProviderOperationKind.CONTINUATION,
|
||||
}:
|
||||
self._delivery.begin_attempt()
|
||||
return attempt
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ from free_claude_code.providers.admission import (
|
|||
ProviderAdmissionController,
|
||||
ProviderOperationKind,
|
||||
)
|
||||
from free_claude_code.providers.continuation import (
|
||||
ContinuationRequest,
|
||||
SourceRecoveryState,
|
||||
public_recovery,
|
||||
)
|
||||
from free_claude_code.providers.failure_policy import (
|
||||
RetryableProviderProtocolError,
|
||||
classify_provider_failure,
|
||||
|
|
@ -81,11 +86,14 @@ async def stream_native_messages(
|
|||
)
|
||||
return
|
||||
committed = False
|
||||
continuation_request = ContinuationRequest("messages")
|
||||
operation_kind = ProviderOperationKind.GENERATION
|
||||
while execution.can_attempt:
|
||||
normal_stop_seen = False
|
||||
source = SourceRecoveryState(execution)
|
||||
scope = None
|
||||
try:
|
||||
attempt = await execution.open_attempt(ProviderOperationKind.GENERATION)
|
||||
attempt = await execution.open_attempt(operation_kind)
|
||||
scope = ProviderAttemptScope(
|
||||
attempt, provider_name=provider_name, request_id=request_id
|
||||
)
|
||||
|
|
@ -113,6 +121,11 @@ async def stream_native_messages(
|
|||
async for kind, payload in messages_events(response):
|
||||
normal_stop_seen |= is_messages_stop(kind, payload)
|
||||
check_messages_failure(kind, payload, native=True)
|
||||
source.observe(
|
||||
"messages",
|
||||
payload,
|
||||
continuing=operation_kind is ProviderOperationKind.CONTINUATION,
|
||||
)
|
||||
output = relay.feed(kind, payload)
|
||||
if output is None:
|
||||
continue
|
||||
|
|
@ -155,6 +168,23 @@ async def stream_native_messages(
|
|||
normal_stop_seen=normal_stop_seen,
|
||||
):
|
||||
continue
|
||||
if scope is not None:
|
||||
await scope.aclose(active_error=error)
|
||||
recovered = public_recovery(
|
||||
execution,
|
||||
body=body,
|
||||
request=continuation_request,
|
||||
retryable=is_retryable_stream_error(error),
|
||||
normal_stop_seen=normal_stop_seen,
|
||||
source=source,
|
||||
)
|
||||
if recovered is not None:
|
||||
if recovered.body is None:
|
||||
execution.succeed()
|
||||
return
|
||||
body = recovered.body
|
||||
operation_kind = ProviderOperationKind.CONTINUATION
|
||||
continue
|
||||
if error is not raw_error:
|
||||
raise error from raw_error
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ from free_claude_code.providers.admission import (
|
|||
ProviderExecution,
|
||||
ProviderOperationKind,
|
||||
)
|
||||
from free_claude_code.providers.continuation import (
|
||||
ContinuationRequest,
|
||||
SourceRecoveryState,
|
||||
public_recovery,
|
||||
)
|
||||
from free_claude_code.providers.endpoint import RequestEndpoint
|
||||
from free_claude_code.providers.endpoint_types import EndpointContext
|
||||
from free_claude_code.providers.failure_policy import (
|
||||
|
|
@ -275,13 +280,16 @@ class AnthropicMessagesTransport:
|
|||
execution, endpoint=request_endpoint, stream=recovery
|
||||
)
|
||||
corrections = RequestCorrections("messages", reasoning_correction)
|
||||
continuation_request = ContinuationRequest("messages")
|
||||
operation_kind = ProviderOperationKind.GENERATION
|
||||
while execution.can_attempt:
|
||||
normal_stop_seen = False
|
||||
source = SourceRecoveryState(execution)
|
||||
scope: ProviderAttemptScope | None = None
|
||||
stream_opened = False
|
||||
sent_body = body
|
||||
try:
|
||||
attempt = await execution.open_attempt(ProviderOperationKind.GENERATION)
|
||||
attempt = await execution.open_attempt(operation_kind)
|
||||
scope = ProviderAttemptScope(
|
||||
attempt,
|
||||
provider_name=self._provider_name,
|
||||
|
|
@ -336,6 +344,11 @@ class AnthropicMessagesTransport:
|
|||
async for event_type, payload in messages_events(response):
|
||||
normal_stop_seen |= is_messages_stop(event_type, payload)
|
||||
check_messages_failure(event_type, payload)
|
||||
source.observe(
|
||||
"messages",
|
||||
payload,
|
||||
continuing=operation_kind is ProviderOperationKind.CONTINUATION,
|
||||
)
|
||||
output = presenter.feed(event_type, payload)
|
||||
if event_type != "ping" and not attempt.accepted:
|
||||
await attempt.accept()
|
||||
|
|
@ -373,7 +386,7 @@ class AnthropicMessagesTransport:
|
|||
status,
|
||||
scope.attempt,
|
||||
body,
|
||||
operation_kind=ProviderOperationKind.GENERATION,
|
||||
operation_kind=operation_kind,
|
||||
normal_stop_seen=normal_stop_seen,
|
||||
propose_correction=partial(
|
||||
corrections.next_body,
|
||||
|
|
@ -412,6 +425,24 @@ class AnthropicMessagesTransport:
|
|||
if decision.action is RecoveryFailureAction.EARLY_RETRY:
|
||||
recovery.discard()
|
||||
continue
|
||||
if scope is not None:
|
||||
await scope.aclose(active_error=error)
|
||||
recovered = public_recovery(
|
||||
execution,
|
||||
body=body,
|
||||
request=continuation_request,
|
||||
retryable=decision.retryable,
|
||||
normal_stop_seen=normal_stop_seen,
|
||||
source=source,
|
||||
)
|
||||
if recovered is not None:
|
||||
recovery.discard()
|
||||
if recovered.body is None:
|
||||
return
|
||||
body = recovered.body
|
||||
operation_kind = ProviderOperationKind.CONTINUATION
|
||||
corrections = RequestCorrections("messages", reasoning_correction)
|
||||
continue
|
||||
failure = classify_provider_failure(
|
||||
error,
|
||||
provider_name=self._provider_name,
|
||||
|
|
|
|||
283
src/free_claude_code/providers/continuation.py
Normal file
283
src/free_claude_code/providers/continuation.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""Same-provider continuation using evidence from the public delivery boundary."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from free_claude_code.core.delivered_response import (
|
||||
client_call,
|
||||
is_structured_text_format,
|
||||
)
|
||||
from free_claude_code.core.failures import ExecutionFailure, FailureKind
|
||||
from free_claude_code.core.history_replay import reasoning_context
|
||||
from free_claude_code.core.trace import trace_event
|
||||
|
||||
from .admission import ProviderExecution
|
||||
|
||||
type UpstreamProtocol = Literal["chat", "messages", "responses"]
|
||||
|
||||
|
||||
class SourceRecoveryState:
|
||||
"""Native source evidence cannot be inferred from buffered public events."""
|
||||
|
||||
def __init__(self, execution: ProviderExecution) -> None:
|
||||
self._execution = execution
|
||||
self.unsafe_reason: str | None = None
|
||||
self._pending_reasoning: set[int | str] = set()
|
||||
self._signed: set[int | str] = set()
|
||||
self._handoff_blocked = False
|
||||
|
||||
@property
|
||||
def can_handoff(self) -> bool:
|
||||
return not self._handoff_blocked and not self._pending_reasoning
|
||||
|
||||
def observe(
|
||||
self,
|
||||
protocol: UpstreamProtocol,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
continuing: bool = False,
|
||||
) -> None:
|
||||
kind = str(payload.get("type", ""))
|
||||
usage = payload.get("usage")
|
||||
if protocol == "messages" and kind == "message_start":
|
||||
usage = payload.get("message", {}).get("usage")
|
||||
elif protocol == "responses":
|
||||
usage = payload.get("response", {}).get("usage")
|
||||
if isinstance(usage, dict) and usage:
|
||||
trace_event(
|
||||
stage="provider",
|
||||
event="provider.attempt.usage",
|
||||
source="provider",
|
||||
request_id=self._execution.request_id,
|
||||
execution_id=self._execution.execution_id,
|
||||
attempt=self._execution.attempts_started,
|
||||
usage=usage,
|
||||
)
|
||||
if protocol == "chat":
|
||||
for choice in payload.get("choices", []):
|
||||
delta = choice.get("delta") or {}
|
||||
if delta.get("reasoning_details") or delta.get("audio"):
|
||||
self.unsafe_reason = "native_content"
|
||||
self._handoff_blocked = True
|
||||
elif protocol == "messages":
|
||||
block = payload.get("content_block")
|
||||
index = payload.get("index", -1)
|
||||
if isinstance(block, dict) and block.get("type") in {
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
}:
|
||||
self.unsafe_reason = "signed_thinking"
|
||||
self._pending_reasoning.add(index)
|
||||
if block.get("signature") or block.get("data"):
|
||||
self._signed.add(index)
|
||||
if isinstance(block, dict) and block.get("type") not in {
|
||||
"text",
|
||||
"tool_use",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
}:
|
||||
self.unsafe_reason = "native_content"
|
||||
self._handoff_blocked = True
|
||||
delta = payload.get("delta") or {}
|
||||
if (isinstance(block, dict) and block.get("citations")) or delta.get(
|
||||
"type"
|
||||
) == "citations_delta":
|
||||
self.unsafe_reason = "native_content"
|
||||
self._handoff_blocked = True
|
||||
if delta.get("type") in {"thinking_delta", "signature_delta"}:
|
||||
self.unsafe_reason = "signed_thinking"
|
||||
if delta.get("signature"):
|
||||
self._signed.add(index)
|
||||
if kind == "content_block_stop" and index in self._signed:
|
||||
self._pending_reasoning.discard(index)
|
||||
else:
|
||||
item = payload.get("item")
|
||||
if isinstance(item, dict):
|
||||
self._response_item(item, completed=kind == "response.output_item.done")
|
||||
response = payload.get("response")
|
||||
if isinstance(response, dict):
|
||||
for item in response.get("output", []):
|
||||
if isinstance(item, dict):
|
||||
self._response_item(
|
||||
item, completed=item.get("status") == "completed"
|
||||
)
|
||||
part = payload.get("part")
|
||||
if (
|
||||
kind in {"response.content_part.added", "response.content_part.done"}
|
||||
and isinstance(part, dict)
|
||||
and part.get("annotations")
|
||||
):
|
||||
self.unsafe_reason = "native_content"
|
||||
self._handoff_blocked = True
|
||||
if kind.startswith(
|
||||
(
|
||||
"response.web_search",
|
||||
"response.code_interpreter",
|
||||
"response.mcp_",
|
||||
"response.image_generation",
|
||||
"response.audio",
|
||||
)
|
||||
):
|
||||
self.unsafe_reason = "hosted_or_native_content"
|
||||
self._handoff_blocked = True
|
||||
if kind == "response.output_text.annotation.added":
|
||||
self.unsafe_reason = "native_content"
|
||||
self._handoff_blocked = True
|
||||
if continuing and self.unsafe_reason:
|
||||
raise ExecutionFailure(
|
||||
kind=FailureKind.UPSTREAM,
|
||||
status_code=502,
|
||||
message="The continuation returned native state that cannot be combined with the delivered response.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
def _response_item(self, item: dict[str, Any], *, completed: bool) -> None:
|
||||
if item.get("type") not in {"message", "reasoning"} and not client_call(item):
|
||||
self.unsafe_reason = "hosted_or_native_content"
|
||||
self._handoff_blocked = True
|
||||
if item.get("encrypted_content"):
|
||||
self.unsafe_reason = "opaque_reasoning"
|
||||
if not completed:
|
||||
self._pending_reasoning.add(item.get("id", ""))
|
||||
if completed:
|
||||
self._pending_reasoning.discard(item.get("id", ""))
|
||||
if any(
|
||||
part.get("annotations")
|
||||
or part.get("type")
|
||||
not in {"output_text", "refusal", "reasoning_text", "summary_text"}
|
||||
for part in [*(item.get("content") or []), *(item.get("summary") or [])]
|
||||
):
|
||||
self.unsafe_reason = "native_content"
|
||||
self._handoff_blocked = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicRecovery:
|
||||
body: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ContinuationRequest:
|
||||
"""Replace our private history suffix while retaining accepted corrections."""
|
||||
|
||||
def __init__(self, protocol: UpstreamProtocol) -> None:
|
||||
self.protocol = protocol
|
||||
self._injected_rows = 0
|
||||
|
||||
def build(self, body: dict[str, Any], text: str, thinking: str) -> dict[str, Any]:
|
||||
result = deepcopy(body)
|
||||
responses = self.protocol == "responses"
|
||||
field = "input" if responses else "messages"
|
||||
history = result.get(field, [])
|
||||
if responses and isinstance(history, str):
|
||||
history = [{"role": "user", "content": history}]
|
||||
if self._injected_rows:
|
||||
# Request corrections preserve these trailing plain-text turns.
|
||||
history = history[: -self._injected_rows]
|
||||
instruction = "The previous provider stream was interrupted. Continue the assistant response exactly where it stopped. Do not repeat text already written."
|
||||
if result.get("tools"):
|
||||
instruction += " No tool calls from this interrupted assistant turn were delivered or executed. Any unfinished calls were discarded. Emit the required tool calls using the provided tools."
|
||||
if thinking:
|
||||
instruction = f"{reasoning_context(thinking)}\n\n{instruction}"
|
||||
tail = []
|
||||
if text:
|
||||
tail.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text}]
|
||||
if responses
|
||||
else text,
|
||||
}
|
||||
)
|
||||
tail.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": instruction}]
|
||||
if responses
|
||||
else instruction,
|
||||
}
|
||||
)
|
||||
result[field] = [*history, *tail]
|
||||
self._injected_rows = len(tail)
|
||||
return result
|
||||
|
||||
|
||||
def _structured_output(body: dict[str, Any], protocol: UpstreamProtocol) -> bool:
|
||||
if protocol == "chat":
|
||||
extra = body.get("extra_body") or {}
|
||||
value = extra.get("response_format", body.get("response_format"))
|
||||
else:
|
||||
options = body.get("text" if protocol == "responses" else "output_config") or {}
|
||||
value = options.get("format")
|
||||
return is_structured_text_format(value)
|
||||
|
||||
|
||||
def public_recovery(
|
||||
execution: ProviderExecution,
|
||||
*,
|
||||
body: dict[str, Any],
|
||||
request: ContinuationRequest,
|
||||
retryable: bool,
|
||||
normal_stop_seen: bool,
|
||||
source: SourceRecoveryState,
|
||||
) -> PublicRecovery | None:
|
||||
delivery = execution.delivery
|
||||
if delivery is None or not delivery.content_released:
|
||||
return None
|
||||
if not retryable or normal_stop_seen:
|
||||
return None
|
||||
prefix = delivery.prefix
|
||||
if prefix is None:
|
||||
return None
|
||||
structured = _structured_output(body, request.protocol)
|
||||
handoff = (
|
||||
not structured
|
||||
and prefix.has_calls
|
||||
and source.can_handoff
|
||||
and prefix.can_handoff
|
||||
)
|
||||
if not handoff and (structured or source.unsafe_reason or not prefix.eligible):
|
||||
trace_event(
|
||||
stage="provider",
|
||||
event="provider.recovery.ineligible",
|
||||
source="provider",
|
||||
request_id=execution.request_id,
|
||||
reason="structured_output"
|
||||
if structured
|
||||
else source.unsafe_reason or prefix.unsafe_reason,
|
||||
)
|
||||
return None
|
||||
if handoff:
|
||||
delivery.begin_continuation(handoff=True)
|
||||
trace_event(
|
||||
stage="provider",
|
||||
event="provider.recovery.tool_handoff",
|
||||
source="provider",
|
||||
request_id=execution.request_id,
|
||||
usage_estimated=True,
|
||||
)
|
||||
return PublicRecovery()
|
||||
if not execution.can_attempt:
|
||||
trace_event(
|
||||
stage="provider",
|
||||
event="provider.recovery.exhausted",
|
||||
source="provider",
|
||||
request_id=execution.request_id,
|
||||
attempts_started=execution.attempts_started,
|
||||
max_attempts=execution.max_attempts,
|
||||
)
|
||||
return None
|
||||
if not (prefix.text or prefix.thinking):
|
||||
return None
|
||||
replacement = request.build(body, prefix.text, prefix.thinking)
|
||||
delivery.begin_continuation()
|
||||
trace_event(
|
||||
stage="provider",
|
||||
event="provider.recovery.continuation",
|
||||
source="provider",
|
||||
request_id=execution.request_id,
|
||||
attempts_started=execution.attempts_started,
|
||||
max_attempts=execution.max_attempts,
|
||||
usage_estimated=True,
|
||||
)
|
||||
return PublicRecovery(body=replacement)
|
||||
|
|
@ -60,6 +60,11 @@ from free_claude_code.providers.admission import (
|
|||
ProviderAttempt,
|
||||
ProviderOperationKind,
|
||||
)
|
||||
from free_claude_code.providers.continuation import (
|
||||
ContinuationRequest,
|
||||
SourceRecoveryState,
|
||||
public_recovery,
|
||||
)
|
||||
from free_claude_code.providers.endpoint import RequestEndpoint
|
||||
from free_claude_code.providers.endpoint_types import EndpointContext
|
||||
from free_claude_code.providers.failure_policy import (
|
||||
|
|
@ -393,7 +398,7 @@ class _OpenAIChatStreamAssembler:
|
|||
yield from self._output.flush_reasoning_replay()
|
||||
self._upstream_finished = True
|
||||
|
||||
def prepare_completion(self) -> Iterator[str]:
|
||||
def prepare_completion(self, *, pad_empty: bool) -> Iterator[str]:
|
||||
if not self._upstream_finished or self._completion is not None:
|
||||
raise RuntimeError("stream completion cannot be prepared")
|
||||
|
||||
|
|
@ -407,10 +412,13 @@ class _OpenAIChatStreamAssembler:
|
|||
|
||||
has_emitted_tool = self._output.has_emitted_tool_block()
|
||||
has_content_blocks = self._output.has_content_block()
|
||||
if not has_content_blocks or (
|
||||
not has_emitted_tool
|
||||
and not self._output.accumulated_text.strip()
|
||||
and self._output.accumulated_reasoning.strip()
|
||||
if pad_empty and (
|
||||
not has_content_blocks
|
||||
or (
|
||||
not has_emitted_tool
|
||||
and not self._output.accumulated_text.strip()
|
||||
and self._output.accumulated_reasoning.strip()
|
||||
)
|
||||
):
|
||||
yield from self._output.ensure_text_block()
|
||||
yield self._output.emit_text_delta(" ")
|
||||
|
|
@ -941,6 +949,8 @@ class _OpenAIChatStreamRunner:
|
|||
yield from recovery.push(event)
|
||||
|
||||
body = self._body
|
||||
continuation_request = ContinuationRequest("chat")
|
||||
operation_kind = ProviderOperationKind.GENERATION
|
||||
request_stream_usage(body)
|
||||
output_reasoning = self._reasoning.output_enabled
|
||||
corrections = RequestCorrections("chat", self._reasoning_correction)
|
||||
|
|
@ -960,17 +970,58 @@ class _OpenAIChatStreamRunner:
|
|||
|
||||
while True:
|
||||
assembler = self._new_stream_assembler(output_reasoning=output_reasoning)
|
||||
source = SourceRecoveryState(execution)
|
||||
|
||||
def observe_chunk(
|
||||
chunk: Any,
|
||||
assembler: _OpenAIChatStreamAssembler = assembler,
|
||||
source: SourceRecoveryState = source,
|
||||
operation_kind: ProviderOperationKind = operation_kind,
|
||||
) -> None:
|
||||
assembler.observe_upstream_chunk(chunk)
|
||||
source.observe(
|
||||
"chat",
|
||||
{
|
||||
"usage": {
|
||||
key: value
|
||||
for key in (
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
)
|
||||
if (value := usage_int(getattr(chunk, "usage", None), key))
|
||||
is not None
|
||||
},
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"reasoning_details": getattr(
|
||||
getattr(choice, "delta", None),
|
||||
"reasoning_details",
|
||||
None,
|
||||
),
|
||||
"audio": getattr(
|
||||
getattr(choice, "delta", None), "audio", None
|
||||
),
|
||||
}
|
||||
}
|
||||
for choice in (getattr(chunk, "choices", None) or ())
|
||||
],
|
||||
},
|
||||
continuing=operation_kind is ProviderOperationKind.CONTINUATION,
|
||||
)
|
||||
|
||||
scope: ProviderAttemptScope | None = None
|
||||
try:
|
||||
stream, body, attempt, sent_body = await self._transport._create_stream(
|
||||
body,
|
||||
request_recovery,
|
||||
ProviderOperationKind.GENERATION,
|
||||
operation_kind,
|
||||
corrections=corrections,
|
||||
endpoint=self._endpoint,
|
||||
request_client=self._request_client,
|
||||
extra_headers=self._extra_headers,
|
||||
on_event=assembler.observe_upstream_chunk,
|
||||
on_event=observe_chunk,
|
||||
)
|
||||
scope = ProviderAttemptScope(
|
||||
attempt,
|
||||
|
|
@ -1012,7 +1063,7 @@ class _OpenAIChatStreamRunner:
|
|||
provider_authentication_status(error),
|
||||
scope.attempt,
|
||||
body,
|
||||
operation_kind=ProviderOperationKind.GENERATION,
|
||||
operation_kind=operation_kind,
|
||||
normal_stop_seen=assembler.normal_stop_seen,
|
||||
propose_correction=partial(
|
||||
corrections.next_body,
|
||||
|
|
@ -1027,6 +1078,30 @@ class _OpenAIChatStreamRunner:
|
|||
body = corrected_body
|
||||
recovery.discard()
|
||||
continue
|
||||
if (
|
||||
execution.delivery is not None
|
||||
and execution.delivery.content_released
|
||||
):
|
||||
if scope is not None:
|
||||
await scope.aclose(active_error=error)
|
||||
recovered = public_recovery(
|
||||
execution,
|
||||
body=body,
|
||||
request=continuation_request,
|
||||
retryable=is_retryable_stream_error(error),
|
||||
normal_stop_seen=assembler.normal_stop_seen,
|
||||
source=source,
|
||||
)
|
||||
if recovered is not None:
|
||||
recovery.discard()
|
||||
if recovered.body is None:
|
||||
return
|
||||
body = recovered.body
|
||||
operation_kind = ProviderOperationKind.CONTINUATION
|
||||
corrections = RequestCorrections(
|
||||
"chat", self._reasoning_correction
|
||||
)
|
||||
continue
|
||||
resolution = await self._resolve_attempt_failure(
|
||||
error=error,
|
||||
scope=scope,
|
||||
|
|
@ -1052,7 +1127,9 @@ class _OpenAIChatStreamRunner:
|
|||
if scope is not None:
|
||||
await scope.aclose(active_error=sys.exception())
|
||||
|
||||
for event in assembler.prepare_completion():
|
||||
for event in assembler.prepare_completion(
|
||||
pad_empty=operation_kind is ProviderOperationKind.GENERATION
|
||||
):
|
||||
for out_event in hold_event(event):
|
||||
yield out_event
|
||||
completion = assembler.completion
|
||||
|
|
@ -1164,7 +1241,10 @@ class _OpenAIChatStreamRunner:
|
|||
)
|
||||
return _OpenAIChatFailureResolution(outcome=_OpenAIChatFailureOutcome.RETRY)
|
||||
|
||||
if decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY:
|
||||
if (
|
||||
decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY
|
||||
and execution.delivery is None
|
||||
):
|
||||
if scope is not None:
|
||||
await scope.aclose(active_error=error)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -112,4 +112,4 @@ class ResponsesExecutionOutcome:
|
|||
failure: Exception | None = None
|
||||
|
||||
|
||||
type ResponsesPresenterFactory = Callable[[], ResponsesStreamPresenter]
|
||||
type ResponsesPresenterFactory = Callable[[bool], ResponsesStreamPresenter]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ from free_claude_code.providers.admission import (
|
|||
ProviderExecution,
|
||||
ProviderOperationKind,
|
||||
)
|
||||
from free_claude_code.providers.continuation import (
|
||||
ContinuationRequest,
|
||||
SourceRecoveryState,
|
||||
public_recovery,
|
||||
)
|
||||
from free_claude_code.providers.endpoint import RequestEndpoint
|
||||
from free_claude_code.providers.endpoint_types import EndpointContext
|
||||
from free_claude_code.providers.failure_policy import (
|
||||
|
|
@ -155,13 +160,14 @@ class OpenAIResponsesTransport:
|
|||
extra_headers=dict(extra_headers or {}),
|
||||
request_id=request_id,
|
||||
response_model=response_model,
|
||||
presenter_factory=lambda: MessagesResponsesPresenter(
|
||||
presenter_factory=lambda pad_empty: MessagesResponsesPresenter(
|
||||
ResponsesProviderStream(
|
||||
message_id=message_id,
|
||||
model=response_model,
|
||||
input_tokens=input_tokens,
|
||||
tool_names=tool_names,
|
||||
log_raw_events=self._log_raw_sse_events,
|
||||
pad_empty=pad_empty,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -185,7 +191,7 @@ class OpenAIResponsesTransport:
|
|||
extra_headers=dict(extra_headers or {}),
|
||||
request_id=request_id,
|
||||
response_model=response_model,
|
||||
presenter_factory=lambda: NativeResponsesPresenter(
|
||||
presenter_factory=lambda _pad_empty: NativeResponsesPresenter(
|
||||
public_model=response_model, tool_events=tools.event_adapter()
|
||||
),
|
||||
)
|
||||
|
|
@ -318,6 +324,8 @@ class OpenAIResponsesTransport:
|
|||
execution, endpoint=endpoint, stream=recovery
|
||||
)
|
||||
corrections = RequestCorrections("responses", reasoning_correction)
|
||||
continuation_request = ContinuationRequest("responses")
|
||||
operation_kind = ProviderOperationKind.GENERATION
|
||||
trace_event(
|
||||
stage="provider",
|
||||
event="provider.request.sent",
|
||||
|
|
@ -332,7 +340,10 @@ class OpenAIResponsesTransport:
|
|||
|
||||
while execution.can_attempt:
|
||||
normal_stop_seen = False
|
||||
presenter = presenter_factory()
|
||||
source = SourceRecoveryState(execution)
|
||||
presenter = presenter_factory(
|
||||
operation_kind is ProviderOperationKind.GENERATION
|
||||
)
|
||||
start_events = tuple(presenter.start())
|
||||
presenter_started = False
|
||||
adapt_event = (
|
||||
|
|
@ -357,7 +368,7 @@ class OpenAIResponsesTransport:
|
|||
endpoint=endpoint.snapshot if endpoint is not None else None,
|
||||
)
|
||||
sent_body = prepare_history(body, origin)
|
||||
attempt = await execution.open_attempt(ProviderOperationKind.GENERATION)
|
||||
attempt = await execution.open_attempt(operation_kind)
|
||||
scope = ProviderAttemptScope(
|
||||
attempt,
|
||||
provider_name=self._provider_name,
|
||||
|
|
@ -388,11 +399,23 @@ class OpenAIResponsesTransport:
|
|||
JsonObject,
|
||||
upstream_event.to_dict(mode="json"),
|
||||
)
|
||||
if upstream_event.type in {
|
||||
failed = upstream_event.type in {
|
||||
"response.failed",
|
||||
"error",
|
||||
"response.error",
|
||||
}:
|
||||
}
|
||||
context_exceeded = reports_context_window_incomplete(
|
||||
upstream_event.type, payload
|
||||
)
|
||||
# Failed snapshots constrain recovery without replacing the
|
||||
# provider's own error with an output-eligibility failure.
|
||||
source.observe(
|
||||
"responses",
|
||||
payload,
|
||||
continuing=operation_kind is ProviderOperationKind.CONTINUATION
|
||||
and not (failed or context_exceeded),
|
||||
)
|
||||
if failed:
|
||||
stream_failure = responses_stream_failure_from_event(
|
||||
upstream_event.type,
|
||||
payload,
|
||||
|
|
@ -408,10 +431,7 @@ class OpenAIResponsesTransport:
|
|||
# cannot retain a consistent public identity.
|
||||
stream_failure.payload = None
|
||||
raise stream_failure
|
||||
if reports_context_window_incomplete(
|
||||
upstream_event.type,
|
||||
payload,
|
||||
):
|
||||
if context_exceeded:
|
||||
raise context_window_exceeded_provider_failure()
|
||||
if adapt_event is not None:
|
||||
payload = adapt_event(upstream_event.type, payload)
|
||||
|
|
@ -453,7 +473,7 @@ class OpenAIResponsesTransport:
|
|||
provider_authentication_status(error),
|
||||
scope.attempt,
|
||||
body,
|
||||
operation_kind=ProviderOperationKind.GENERATION,
|
||||
operation_kind=operation_kind,
|
||||
normal_stop_seen=normal_stop_seen,
|
||||
propose_correction=partial(
|
||||
corrections.next_body,
|
||||
|
|
@ -504,6 +524,25 @@ class OpenAIResponsesTransport:
|
|||
)
|
||||
continue
|
||||
|
||||
if scope is not None:
|
||||
await scope.aclose(active_error=error)
|
||||
recovered = public_recovery(
|
||||
execution,
|
||||
body=body,
|
||||
request=continuation_request,
|
||||
retryable=decision.retryable,
|
||||
normal_stop_seen=normal_stop_seen,
|
||||
source=source,
|
||||
)
|
||||
if recovered is not None:
|
||||
recovery.discard()
|
||||
if recovered.body is None:
|
||||
return
|
||||
body = recovered.body
|
||||
operation_kind = ProviderOperationKind.CONTINUATION
|
||||
corrections = RequestCorrections("responses", reasoning_correction)
|
||||
continue
|
||||
|
||||
failure = classify_provider_failure(
|
||||
error,
|
||||
provider_name=self._provider_name,
|
||||
|
|
|
|||
|
|
@ -76,18 +76,23 @@ class RequestRecovery:
|
|||
) -> JsonObject | None:
|
||||
delivery = self.execution.delivery
|
||||
if (
|
||||
operation_kind is ProviderOperationKind.GENERATION
|
||||
and normal_stop_seen
|
||||
normal_stop_seen
|
||||
and delivery is not None
|
||||
and not delivery.content_released
|
||||
and (
|
||||
not delivery.content_released
|
||||
or operation_kind is ProviderOperationKind.CONTINUATION
|
||||
)
|
||||
):
|
||||
return None
|
||||
if await self.retry_authentication(error, auth_status, attempt):
|
||||
return body
|
||||
if operation_kind is ProviderOperationKind.GENERATION and self._committed:
|
||||
if (
|
||||
delivery.attempt_content_released
|
||||
if delivery is not None
|
||||
else operation_kind is ProviderOperationKind.GENERATION and self._committed
|
||||
):
|
||||
return None
|
||||
# A separately buffered continuation/repair body can still be corrected
|
||||
# at creation, while authentication follows the original public stream.
|
||||
# Correct only an attempt whose public content can still be replaced.
|
||||
corrected = propose_correction()
|
||||
if corrected is not None and await self._authorize(error, attempt):
|
||||
return corrected
|
||||
|
|
|
|||
451
tests/api/test_continuation_overlap.py
Normal file
451
tests/api/test_continuation_overlap.py
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
"""Continuation replay is independent of how source text is represented."""
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from itertools import pairwise
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.stream_delivery import current_stream_delivery
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import public_text, text_events
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.api.test_tool_call_buffer import _frames, _response
|
||||
from tests.providers.test_history_transports import _harness
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
def responses_parts(parts, *, separate_items=False, representation="deltas"):
|
||||
"""Describe one answer with consistent source snapshots at every level."""
|
||||
template = text_events("responses", "")
|
||||
items = []
|
||||
events = [template[0]]
|
||||
groups = [[part] for part in parts] if separate_items else [parts]
|
||||
for index, group in enumerate(groups):
|
||||
item = {
|
||||
"id": f"msg_piece_{index}",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{"type": "output_text", "text": text, "annotations": []}
|
||||
for text in group
|
||||
],
|
||||
}
|
||||
items.append(item)
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": index,
|
||||
"item": {
|
||||
**item,
|
||||
"content": item["content"]
|
||||
if representation == "item_added"
|
||||
else [],
|
||||
"status": "in_progress",
|
||||
},
|
||||
}
|
||||
)
|
||||
for position, part in enumerate(item["content"]):
|
||||
identity = {
|
||||
"output_index": index,
|
||||
"item_id": item["id"],
|
||||
"content_index": position,
|
||||
}
|
||||
events.append(
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
**identity,
|
||||
"part": {
|
||||
**part,
|
||||
"text": part["text"]
|
||||
if representation in {"part_added", "item_added"}
|
||||
else part["text"][:1]
|
||||
if representation == "mixed"
|
||||
else "",
|
||||
},
|
||||
}
|
||||
)
|
||||
if representation in {"deltas", "mixed"}:
|
||||
events.extend(
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
**identity,
|
||||
"delta": character,
|
||||
}
|
||||
for character in (
|
||||
part["text"][1:] if representation == "mixed" else part["text"]
|
||||
)
|
||||
)
|
||||
if representation not in {"item_done", "part_done", "terminal"}:
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
**identity,
|
||||
"text": part["text"],
|
||||
}
|
||||
)
|
||||
if representation not in {"item_done", "terminal"}:
|
||||
events.append(
|
||||
{"type": "response.content_part.done", **identity, "part": part}
|
||||
)
|
||||
events.append(
|
||||
{"type": "response.output_item.done", "output_index": index, "item": item}
|
||||
)
|
||||
terminal = deepcopy(template[-1])
|
||||
terminal["response"]["output"] = items
|
||||
events.append(terminal)
|
||||
if representation == "terminal":
|
||||
events = [events[0], terminal]
|
||||
return [
|
||||
{**deepcopy(event), "sequence_number": number}
|
||||
for number, event in enumerate(events)
|
||||
]
|
||||
|
||||
|
||||
def response_text(event):
|
||||
return "".join(
|
||||
part.get("text", "")
|
||||
for item in event.data["response"]["output"]
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
|
||||
|
||||
def assert_completion_text_agrees(events):
|
||||
final = {
|
||||
item["id"]: item
|
||||
for item in events[-1].data["response"]["output"]
|
||||
if item["type"] == "message"
|
||||
}
|
||||
for event in events:
|
||||
data = event.data
|
||||
if event.event == "response.output_text.done":
|
||||
part = final[data["item_id"]]["content"][data["content_index"]]
|
||||
assert data["text"] == part["text"]
|
||||
elif event.event == "response.content_part.done":
|
||||
part = final[data["item_id"]]["content"][data["content_index"]]
|
||||
assert data["part"] == part
|
||||
elif event.event == "response.output_item.done":
|
||||
assert data["item"]["content"] == final[data["item"]["id"]]["content"]
|
||||
numbers = [event.data["sequence_number"] for event in events]
|
||||
assert all(before < after for before, after in pairwise(numbers)), numbers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("separate_items", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"representation",
|
||||
[
|
||||
"terminal",
|
||||
"item_done",
|
||||
"part_done",
|
||||
"text_done",
|
||||
"deltas",
|
||||
"part_added",
|
||||
"item_added",
|
||||
"mixed",
|
||||
],
|
||||
)
|
||||
async def test_split_replay_has_one_answer_across_representations(
|
||||
separate_items, representation
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
return 200, responses_parts(
|
||||
["He", "llo world."],
|
||||
separate_items=separate_items,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == "response.completed"
|
||||
assert response_text(events[-1]) == "Hello world."
|
||||
retained = [
|
||||
part["text"]
|
||||
for item in events[-1].data["response"]["output"][1:]
|
||||
for part in item["content"]
|
||||
]
|
||||
assert retained == ["", "world."]
|
||||
assert_completion_text_agrees(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("representation", ["terminal", "text_done", "mixed"])
|
||||
async def test_split_pure_replay_is_not_public_progress(representation):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
return 200, responses_parts(["He", "llo "], representation=representation)
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == "response.failed"
|
||||
assert response_text(events[-1]) == "Hello "
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("seeded", [False, True])
|
||||
async def test_messages_replay_across_blocks_uses_the_same_rule(wire, seeded):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("messages", "abcabc", complete=False)
|
||||
template = text_events("messages", "")
|
||||
events = [template[0]]
|
||||
for index, text in enumerate(["abc", "abcX"]):
|
||||
part = text_events("messages", text)[1:-2]
|
||||
for event in part:
|
||||
event["index"] = index
|
||||
if seeded:
|
||||
part[0]["content_block"]["text"] = text
|
||||
part.pop(1)
|
||||
events.extend(part)
|
||||
return 200, [*events, *template[-2:]]
|
||||
|
||||
async with _harness("messages", reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send(wire, [{"role": "user", "content": "continue"}]), wire
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == (
|
||||
"message_stop" if wire == "messages" else "response.completed"
|
||||
)
|
||||
assert public_text(events, wire) == "abcabcX"
|
||||
if wire == "responses":
|
||||
assert response_text(events[-1]) == "abcabcX"
|
||||
assert_completion_text_agrees(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overflow_preserves_public_prefix_and_closes_the_source(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.continuation_overlap.RECOVERY_RETENTION_BYTES", 1000
|
||||
)
|
||||
closed = []
|
||||
attempts = []
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
try:
|
||||
state.begin_attempt()
|
||||
attempts.append(1)
|
||||
for frame in _frames(
|
||||
"responses",
|
||||
[
|
||||
(event["type"], event)
|
||||
for event in text_events("responses", "Hello ", complete=False)
|
||||
],
|
||||
):
|
||||
yield frame
|
||||
state.begin_continuation()
|
||||
state.begin_attempt()
|
||||
attempts.append(2)
|
||||
for event in responses_parts(["He"], representation="text_done")[:4]:
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
for _ in range(30):
|
||||
event = {"type": "ping", "padding": "x" * 60}
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
pytest.fail("the owner must stop reading after its overlap limit")
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
raw = await delivered(source(), "responses")
|
||||
events = parse_sse_text(raw)
|
||||
assert attempts == [1, 2]
|
||||
assert closed == [True]
|
||||
assert events[-1].event == "response.failed"
|
||||
assert response_text(events[-1]) == "Hello "
|
||||
assert sum(event.event == "response.failed" for event in events) == 1
|
||||
assert not any(event.event == "response.completed" for event in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlap_release_records_each_frame_only_when_returned():
|
||||
states = []
|
||||
resumed = []
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
states.append(state)
|
||||
for frame in _frames(
|
||||
"responses",
|
||||
[
|
||||
(event["type"], event)
|
||||
for event in text_events("responses", "Hello ", complete=False)
|
||||
],
|
||||
):
|
||||
yield frame
|
||||
state.begin_continuation()
|
||||
for event in responses_parts(["He", "llo world."], representation="text_done"):
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
prefix = state.prefix
|
||||
assert prefix is not None
|
||||
if (
|
||||
event["type"] == "response.output_text.done"
|
||||
and event["content_index"] == 0
|
||||
):
|
||||
assert prefix.text == "Hello "
|
||||
if (
|
||||
event["type"] == "response.output_text.done"
|
||||
and event["content_index"] == 1
|
||||
):
|
||||
resumed.append(prefix.text)
|
||||
|
||||
response = await _response("responses", source())
|
||||
events = []
|
||||
try:
|
||||
async for chunk in response.body_iterator:
|
||||
frames = parse_sse_text(str(chunk))
|
||||
assert len(frames) == 1
|
||||
event = frames[0]
|
||||
events.append(event)
|
||||
if (
|
||||
event.event == "response.output_text.done"
|
||||
and event.data["text"] == "world."
|
||||
):
|
||||
assert states[0].prefix.text == "Hello world."
|
||||
assert resumed == []
|
||||
assert resumed == ["Hello world."]
|
||||
assert_completion_text_agrees(events)
|
||||
finally:
|
||||
await response.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_attempt_replacement_discards_queued_replay():
|
||||
unpublished = []
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
state.begin_attempt()
|
||||
prefix = text_events("responses", "Hello ", complete=False)
|
||||
for frame in _frames("responses", [(event["type"], event) for event in prefix]):
|
||||
yield frame
|
||||
state.begin_continuation()
|
||||
for _ in range(2):
|
||||
state.begin_attempt()
|
||||
event = {
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"item_id": "msg_abandoned",
|
||||
"delta": "He",
|
||||
}
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
prefix = state.prefix
|
||||
assert prefix is not None
|
||||
unpublished.append((state.attempt_content_released, prefix.text))
|
||||
state.begin_attempt()
|
||||
for event in responses_parts(["world."], representation="text_done"):
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
|
||||
raw = await delivered(source(), "responses")
|
||||
assert unpublished == [(False, "Hello "), (False, "Hello ")]
|
||||
events = parse_sse_text(raw)
|
||||
assert events[-1].event == "response.completed"
|
||||
assert response_text(events[-1]) == "Hello world."
|
||||
assert "msg_abandoned" not in raw
|
||||
assert_completion_text_agrees(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_drops_ambiguous_replay_and_closes_the_source():
|
||||
waiting = asyncio.Event()
|
||||
closed = []
|
||||
states = []
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
states.append(state)
|
||||
try:
|
||||
prefix = text_events("responses", "Hello ", complete=False)
|
||||
for frame in _frames(
|
||||
"responses", [(event["type"], event) for event in prefix]
|
||||
):
|
||||
yield frame
|
||||
state.begin_continuation()
|
||||
for event in responses_parts(["He"], representation="text_done")[:4]:
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
waiting.set()
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
response = await _response("responses", source())
|
||||
returned = []
|
||||
|
||||
async def consume():
|
||||
async for chunk in response.body_iterator:
|
||||
returned.extend(parse_sse_text(str(chunk)))
|
||||
|
||||
reader = asyncio.create_task(consume())
|
||||
try:
|
||||
await asyncio.wait_for(waiting.wait(), 1)
|
||||
assert states[0].prefix.text == "Hello "
|
||||
reader.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await reader
|
||||
finally:
|
||||
reader.cancel()
|
||||
await asyncio.gather(reader, return_exceptions=True)
|
||||
await response.aclose()
|
||||
assert closed == [True]
|
||||
assert not any(
|
||||
event.event in {"response.failed", "response.completed"} for event in returned
|
||||
)
|
||||
assert all(
|
||||
event.data["text"] == "Hello "
|
||||
for event in returned
|
||||
if event.event == "response.output_text.done"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("representation", ["text_done", "part_done", "item_done"])
|
||||
async def test_snapshot_text_is_published_once_before_another_interruption(
|
||||
representation,
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 2:
|
||||
return 200, responses_parts(
|
||||
["Hello world."], representation=representation
|
||||
)[:-1]
|
||||
return 200, text_events("responses", " Finished.")
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
assert len(bodies) == 3
|
||||
assert bodies[2]["input"][-2]["content"] == [
|
||||
{"type": "output_text", "text": "Hello world."}
|
||||
]
|
||||
events = parse_sse_text(raw)
|
||||
assert events[-1].event == "response.completed"
|
||||
assert response_text(events[-1]) == "Hello world. Finished."
|
||||
assert_completion_text_agrees(events)
|
||||
834
tests/api/test_delivered_stream_recovery.py
Normal file
834
tests/api/test_delivered_stream_recovery.py
Normal file
|
|
@ -0,0 +1,834 @@
|
|||
"""Recovery extends one public response after content has been delivered."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from itertools import pairwise
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from free_claude_code.config.settings import Settings
|
||||
from free_claude_code.core.anthropic.passthrough import NativeMessagesRequest
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.providers.failure_policy import RetryableProviderProtocolError
|
||||
from free_claude_code.providers.request_recovery import RequestCorrections
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.support import create_test_app, provider_manager_for_app
|
||||
from tests.api.test_hidden_stream_retries import delivered, partial_tool
|
||||
from tests.api.test_tool_call_buffer import _response
|
||||
from tests.api.test_tool_call_buffer_transports import _tools
|
||||
from tests.providers.test_anthropic_messages_transport import Wire, _sse
|
||||
from tests.providers.test_anthropic_provider import native_body, provider
|
||||
from tests.providers.test_history_transports import _events_for, _harness
|
||||
from tests.providers.test_native_tool_arguments import tool_events
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def release_provider_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
def text_events(protocol, text, *, complete=True):
|
||||
if protocol == "chat":
|
||||
template = _events_for(protocol)[0]
|
||||
events = [
|
||||
{
|
||||
**template,
|
||||
"choices": [
|
||||
{"index": 0, "delta": {"content": text}, "finish_reason": None}
|
||||
],
|
||||
}
|
||||
]
|
||||
if complete:
|
||||
events.append(
|
||||
{
|
||||
**template,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
}
|
||||
)
|
||||
return events
|
||||
if protocol == "messages":
|
||||
events = [
|
||||
_events_for(protocol)[0],
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": text},
|
||||
},
|
||||
]
|
||||
if complete:
|
||||
events.extend(
|
||||
[
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": "end_turn"},
|
||||
"usage": {"output_tokens": 2},
|
||||
},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
)
|
||||
return events
|
||||
response = deepcopy(_events_for(protocol)[-1]["response"])
|
||||
item = {
|
||||
"id": "msg_text",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": text, "annotations": []}],
|
||||
}
|
||||
response["output"] = [item]
|
||||
events = [
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {**response, "status": "in_progress", "output": []},
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {**item, "status": "in_progress", "content": []},
|
||||
},
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"output_index": 0,
|
||||
"item_id": "msg_text",
|
||||
"content_index": 0,
|
||||
"part": {"type": "output_text", "text": "", "annotations": []},
|
||||
},
|
||||
{
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"item_id": "msg_text",
|
||||
"content_index": 0,
|
||||
"delta": text,
|
||||
},
|
||||
]
|
||||
if complete:
|
||||
events.extend(
|
||||
[
|
||||
{
|
||||
"type": "response.output_text.done",
|
||||
"output_index": 0,
|
||||
"item_id": "msg_text",
|
||||
"content_index": 0,
|
||||
"text": text,
|
||||
},
|
||||
{
|
||||
"type": "response.content_part.done",
|
||||
"output_index": 0,
|
||||
"item_id": "msg_text",
|
||||
"content_index": 0,
|
||||
"part": item["content"][0],
|
||||
},
|
||||
{"type": "response.output_item.done", "output_index": 0, "item": item},
|
||||
{"type": "response.completed", "response": response},
|
||||
]
|
||||
)
|
||||
return [{**event, "sequence_number": index} for index, event in enumerate(events)]
|
||||
|
||||
|
||||
def public_text(events, wire):
|
||||
if wire == "messages":
|
||||
return "".join(
|
||||
event.data.get("delta", {}).get("text", "")
|
||||
+ event.data.get("content_block", {}).get("text", "")
|
||||
for event in events
|
||||
)
|
||||
return "".join(
|
||||
event.data["delta"]
|
||||
for event in events
|
||||
if event.event == "response.output_text.delta"
|
||||
)
|
||||
|
||||
|
||||
def assert_completed(events, wire):
|
||||
assert events[-1].event == (
|
||||
"message_stop" if wire == "messages" else "response.completed"
|
||||
)
|
||||
assert (
|
||||
sum(
|
||||
event.event
|
||||
== ("message_start" if wire == "messages" else "response.created")
|
||||
for event in events
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert not any(event.event in {"error", "response.failed"} for event in events)
|
||||
if wire == "responses":
|
||||
numbers = [event.data["sequence_number"] for event in events]
|
||||
assert all(right > left for left, right in pairwise(numbers))
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
event.data["response"]["id"]
|
||||
for event in events
|
||||
if "response" in event.data
|
||||
}
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_text_cutoff_continues_one_response_and_preserves_tools(protocol, wire):
|
||||
def reply(bodies):
|
||||
return 200, text_events(
|
||||
protocol,
|
||||
"Hello " if len(bodies) == 1 else "world.",
|
||||
complete=len(bodies) > 1,
|
||||
)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=2) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}], tools=_tools(wire)), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Hello world."
|
||||
assert len(bodies) == 2
|
||||
assert bodies[1]["model"] == bodies[0]["model"]
|
||||
assert bodies[1]["tools"] == bodies[0]["tools"]
|
||||
assert "Hello " in str(bodies[1])
|
||||
if wire == "responses":
|
||||
output = events[-1].data["response"]["output"]
|
||||
assert (
|
||||
"".join(
|
||||
part.get("text", "")
|
||||
for item in output
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
== "Hello world."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_released_tool_finishes_handoff_without_another_request(protocol, wire):
|
||||
def reply(_bodies):
|
||||
events = tool_events(protocol, '{"path":"kept"}')
|
||||
return 200, events[:-2] if protocol == "messages" else events[:-1]
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=1) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "read"}], tools=_tools(wire)), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert len(bodies) == 1
|
||||
assert_completed(events, wire)
|
||||
if wire == "messages":
|
||||
assert events[-2].data["delta"]["stop_reason"] == "tool_use"
|
||||
else:
|
||||
assert events[-1].data["response"]["output"][0]["call_id"] == "call_probe"
|
||||
|
||||
|
||||
def after_text(protocol, tail):
|
||||
events = text_events(protocol, "Before. ")
|
||||
events = events[:-1] if protocol != "messages" else events[:-2]
|
||||
tail = deepcopy(tail if protocol == "chat" else tail[1:])
|
||||
for event in tail:
|
||||
field = "index" if protocol == "messages" else "output_index"
|
||||
if field in event:
|
||||
event[field] += 1
|
||||
combined = events + tail
|
||||
if protocol == "responses":
|
||||
for index, event in enumerate(combined):
|
||||
event["sequence_number"] = index
|
||||
return combined
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_partial_call_after_public_text_is_discarded_before_continuation(
|
||||
protocol, wire
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, after_text(protocol, partial_tool(protocol))
|
||||
assert "abandoned" not in str(bodies[-1])
|
||||
return 200, tool_events(protocol, '{"path":"winning"}')
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=2) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "read"}], tools=_tools(wire)), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert len(bodies) == 2
|
||||
assert "call_abandoned" not in result
|
||||
assert public_text(events, wire) == "Before. "
|
||||
if wire == "messages":
|
||||
arguments = "".join(
|
||||
e.data.get("delta", {}).get("partial_json", "") for e in events
|
||||
)
|
||||
assert arguments == '{"path":"winning"}'
|
||||
else:
|
||||
assert "winning" in result
|
||||
calls = [
|
||||
item
|
||||
for item in events[-1].data["response"]["output"]
|
||||
if item["type"] == "function_call"
|
||||
]
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["arguments"] == '{"path":"winning"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_repeated_interruption_uses_latest_prefix_and_one_budget(protocol, wire):
|
||||
def reply(bodies):
|
||||
index = len(bodies) - 1
|
||||
return 200, text_events(
|
||||
protocol, ["One ", "two ", "three."][index], complete=index == 2
|
||||
)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=3) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "count"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "One two three."
|
||||
assert len(bodies) == 3
|
||||
history = bodies[-1].get("messages", bodies[-1].get("input"))
|
||||
assert len(history) == 3
|
||||
assert "One two " in str(history)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,expected",
|
||||
[
|
||||
("Hello world.", "Hello world."),
|
||||
("Hel", "Hello Hel"),
|
||||
(" world.", "Hello world."),
|
||||
],
|
||||
)
|
||||
async def test_exact_overlap_filter_preserves_nonmatching_text(
|
||||
protocol, wire, candidate, expected
|
||||
):
|
||||
def reply(bodies):
|
||||
return 200, text_events(
|
||||
protocol,
|
||||
"Hello " if len(bodies) == 1 else candidate,
|
||||
complete=len(bodies) > 1,
|
||||
)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=2) as (send, _, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == expected
|
||||
if wire == "responses":
|
||||
output = events[-1].data["response"]["output"]
|
||||
assert (
|
||||
"".join(
|
||||
part.get("text", "")
|
||||
for item in output
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_exact_replay_without_new_output_is_not_success(protocol, wire):
|
||||
async with _harness(
|
||||
protocol,
|
||||
lambda bodies: (200, text_events(protocol, "Same.", complete=len(bodies) > 1)),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert len(bodies) == 2
|
||||
assert public_text(events, wire) == "Same."
|
||||
assert events[-1].event == ("error" if wire == "messages" else "response.failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize(
|
||||
"prefix,candidate,expected",
|
||||
[
|
||||
("The answer is", "answer is 42", "The answer is 42"),
|
||||
("abcabc", "abcabcX", "abcabcX"),
|
||||
("abcabd", "abcX", "abcabdabcX"),
|
||||
("very ", "very good", "very good"),
|
||||
("Hello 世界", "世界!", "Hello 世界!"),
|
||||
],
|
||||
)
|
||||
async def test_partial_overlap_across_single_character_chunks(
|
||||
protocol, wire, prefix, candidate, expected
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events(protocol, prefix, complete=False)
|
||||
events = text_events(protocol, candidate)
|
||||
expanded = []
|
||||
for event in events:
|
||||
if protocol == "chat" and event["choices"][0]["delta"].get("content"):
|
||||
for character in candidate:
|
||||
chunk = deepcopy(event)
|
||||
chunk["choices"][0]["delta"]["content"] = character
|
||||
expanded.append(chunk)
|
||||
elif event.get("type") == "content_block_delta":
|
||||
expanded.extend(
|
||||
{**event, "delta": {"type": "text_delta", "text": character}}
|
||||
for character in candidate
|
||||
)
|
||||
elif event.get("type") == "response.output_text.delta":
|
||||
expanded.extend(
|
||||
{**event, "delta": character} for character in candidate
|
||||
)
|
||||
else:
|
||||
expanded.append(event)
|
||||
return 200, expanded
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=2) as (send, _, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "continue"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == expected
|
||||
if wire == "responses":
|
||||
assert (
|
||||
"".join(
|
||||
part.get("text", "")
|
||||
for item in events[-1].data["response"]["output"]
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_passthrough_continuation_closes_original_connection():
|
||||
bodies = []
|
||||
first = Wire([_sse(*text_events("messages", "Hello ", complete=False))])
|
||||
second = Wire([_sse(*text_events("messages", "world."))])
|
||||
|
||||
def reply(request):
|
||||
bodies.append(json.loads(request.content))
|
||||
if len(bodies) == 2:
|
||||
assert first.closed
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=first if len(bodies) == 1 else second,
|
||||
)
|
||||
|
||||
p = provider(reply)
|
||||
try:
|
||||
result = await delivered(
|
||||
p.stream_native_messages(NativeMessagesRequest(native_body(True))),
|
||||
"messages",
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, "messages")
|
||||
assert public_text(events, "messages") == "Hello world."
|
||||
assert len(bodies) == 2
|
||||
assert first.closed and second.closed
|
||||
finally:
|
||||
await p.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("complete", [False, True])
|
||||
async def test_retention_limit_does_not_truncate_healthy_output(
|
||||
monkeypatch, wire, complete
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 100
|
||||
)
|
||||
async with _harness(
|
||||
"chat", lambda _: (200, text_events("chat", "x" * 1000, complete=complete))
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "write"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert len(bodies) == 1
|
||||
assert public_text(events, wire) == "x" * 1000
|
||||
if complete:
|
||||
assert_completed(events, wire)
|
||||
else:
|
||||
assert events[-1].event == (
|
||||
"error" if wire == "messages" else "response.failed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("continuation", [False, True])
|
||||
async def test_stopped_continuation_cannot_request_another_body_correction(
|
||||
monkeypatch, wire, continuation
|
||||
):
|
||||
corrections = []
|
||||
|
||||
def correction(*args, **kwargs):
|
||||
corrections.append(True)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(RequestCorrections, "next_body", correction)
|
||||
|
||||
def adapter(kind, payload):
|
||||
if kind == "response.completed":
|
||||
raise RetryableProviderProtocolError("malformed terminal snapshot")
|
||||
return payload
|
||||
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events(
|
||||
"responses",
|
||||
"First " if len(bodies) == 1 else "second",
|
||||
complete=not continuation or len(bodies) > 1,
|
||||
),
|
||||
),
|
||||
) as (send, bodies, transport):
|
||||
transport._event_adapter_factory = lambda: adapter
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "write"}]), wire
|
||||
)
|
||||
assert len(bodies) == (2 if continuation else 1)
|
||||
assert corrections == []
|
||||
assert parse_sse_text(result)[-1].event == (
|
||||
"error" if wire == "messages" else "response.failed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_opaque_reasoning_is_not_regenerated_after_delivery(protocol, wire):
|
||||
events = _events_for(protocol)
|
||||
if protocol == "chat":
|
||||
events = events[:1]
|
||||
elif protocol == "messages":
|
||||
events = events[:-2]
|
||||
else:
|
||||
events = events[:-1]
|
||||
async with _harness(protocol, lambda _: (200, events)) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "think"}]), wire
|
||||
)
|
||||
assert len(bodies) == 1
|
||||
assert parse_sse_text(result)[-1].event == (
|
||||
"error" if wire == "messages" else "response.failed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_hidden_hosted_action_after_text_blocks_continuation(wire):
|
||||
events = after_text("responses", partial_tool("responses"))
|
||||
events.append(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"sequence_number": 40,
|
||||
"output_index": 2,
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_held",
|
||||
"status": "in_progress",
|
||||
},
|
||||
}
|
||||
)
|
||||
async with _harness("responses", lambda _: (200, events)) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "search"}], tools=_tools(wire)),
|
||||
wire,
|
||||
)
|
||||
assert len(bodies) == 1
|
||||
assert "ws_held" not in result
|
||||
assert parse_sse_text(result)[-1].event == (
|
||||
"error" if wire == "messages" else "response.failed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_during_continuation_closes_both_attempts():
|
||||
entered = asyncio.Event()
|
||||
calls = []
|
||||
first = Wire([_sse(*text_events("messages", "First ", complete=False))])
|
||||
|
||||
class BlockingWire(httpx.AsyncByteStream):
|
||||
closes = 0
|
||||
|
||||
async def __aiter__(self):
|
||||
yield _sse(*text_events("messages", "second ", complete=False))
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def aclose(self):
|
||||
self.closes += 1
|
||||
|
||||
second = BlockingWire()
|
||||
|
||||
def reply(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=first if len(calls) == 1 else second,
|
||||
)
|
||||
|
||||
p = provider(reply)
|
||||
response = await _response(
|
||||
"messages", p.stream_native_messages(NativeMessagesRequest(native_body(True)))
|
||||
)
|
||||
try:
|
||||
|
||||
async def read():
|
||||
return [chunk async for chunk in response.body_iterator]
|
||||
|
||||
task = asyncio.create_task(read())
|
||||
await asyncio.wait_for(entered.wait(), 2)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await response.aclose()
|
||||
await response.aclose()
|
||||
assert len(calls) == 2
|
||||
assert first.closed and second.closes == 1
|
||||
finally:
|
||||
await p.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_completed_signed_thinking_and_call_can_finish_without_regeneration(wire):
|
||||
events = _events_for("messages")[:-2]
|
||||
calls = tool_events("messages", '{"path":"kept"}')[1:-2]
|
||||
for event in calls:
|
||||
if "index" in event:
|
||||
event["index"] += 1
|
||||
async with _harness(
|
||||
"messages", lambda _: (200, events + calls), max_attempts=1
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "read"}], tools=_tools(wire)), wire
|
||||
)
|
||||
assert len(bodies) == 1
|
||||
assert_completed(parse_sse_text(result), wire)
|
||||
|
||||
|
||||
def test_http_recovery_then_real_harness_tool_result_has_no_private_history():
|
||||
sent = []
|
||||
wires = []
|
||||
|
||||
def reply(request):
|
||||
body = json.loads(request.content)
|
||||
sent.append(body)
|
||||
if len(sent) == 3:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"type": "message",
|
||||
"model": "primary",
|
||||
"content": [{"type": "text", "text": "result received"}],
|
||||
},
|
||||
)
|
||||
events = (
|
||||
after_text("messages", partial_tool("messages"))
|
||||
if len(sent) == 1
|
||||
else tool_events("messages", '{"path":"winning"}')
|
||||
)
|
||||
wire = Wire([_sse(*events)])
|
||||
wires.append(wire)
|
||||
return httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, stream=wire
|
||||
)
|
||||
|
||||
app = create_test_app(
|
||||
Settings(MODEL="anthropic/primary", ENABLE_WEB_SERVER_TOOLS=False),
|
||||
providers={"anthropic": provider(reply)},
|
||||
)
|
||||
body = {
|
||||
**native_body(True),
|
||||
"model": "anthropic/primary",
|
||||
"tools": _tools("messages"),
|
||||
}
|
||||
with TestClient(app) as client:
|
||||
first = client.post("/v1/messages", json=body)
|
||||
assert first.status_code == 200
|
||||
events = parse_sse_text(first.text)
|
||||
assert_completed(events, "messages")
|
||||
calls = [
|
||||
event.data["content_block"]
|
||||
for event in events
|
||||
if event.event == "content_block_start"
|
||||
and event.data["content_block"]["type"] == "tool_use"
|
||||
]
|
||||
assert len(calls) == 1
|
||||
call = {**calls[0], "input": {"path": "winning"}}
|
||||
history = [
|
||||
*body["messages"],
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Before. "}, call],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": call["id"],
|
||||
"content": "actual file contents",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
second = client.post(
|
||||
"/v1/messages", json={**body, "stream": False, "messages": history}
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["content"][0]["text"] == "result received"
|
||||
assert len(sent) == 3
|
||||
assert sent[2]["messages"] == history
|
||||
assert "previous provider stream" not in str(sent[2])
|
||||
assert all(wire.closed for wire in wires)
|
||||
assert provider_manager_for_app(app)._current.active_leases == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_unsigned_thinking_can_continue_into_text(wire):
|
||||
first = text_events("chat", "", complete=False)
|
||||
first[0]["choices"][0]["delta"] = {
|
||||
"reasoning_content": "Look at the request first."
|
||||
}
|
||||
async with _harness(
|
||||
"chat",
|
||||
lambda bodies: (
|
||||
200,
|
||||
first if len(bodies) == 1 else text_events("chat", "Answer."),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "answer"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Answer."
|
||||
assert "Look at the request first." in str(bodies[-1])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("echo", [False, True])
|
||||
async def test_retention_limit_during_continuation_keeps_final_output_consistent(
|
||||
monkeypatch, wire, echo
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 1200
|
||||
)
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events(
|
||||
"responses",
|
||||
"First "
|
||||
if len(bodies) == 1
|
||||
else ("First " if echo else "") + "x" * 2000,
|
||||
complete=len(bodies) > 1,
|
||||
),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "write"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "First " + "x" * 2000
|
||||
assert len(bodies) == 2
|
||||
if wire == "responses":
|
||||
final = events[-1].data["response"]
|
||||
assert (
|
||||
"".join(
|
||||
part.get("text", "")
|
||||
for item in final["output"]
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
== "First " + "x" * 2000
|
||||
)
|
||||
streamed_ids = [
|
||||
event.data["item"]["id"]
|
||||
for event in events
|
||||
if event.event == "response.output_item.done"
|
||||
]
|
||||
assert [item["id"] for item in final["output"]] == streamed_ids
|
||||
assert final["usage"]["output_tokens"] > 0
|
||||
else:
|
||||
assert events[-2].data["usage"]["output_tokens"] >= 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_partial_replay_without_new_output_is_not_success(wire):
|
||||
async with _harness(
|
||||
"messages",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events(
|
||||
"messages",
|
||||
"The answer is" if len(bodies) == 1 else "answer is",
|
||||
complete=len(bodies) > 1,
|
||||
),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, _, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "continue"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert public_text(events, wire) == "The answer is"
|
||||
assert events[-1].event == ("error" if wire == "messages" else "response.failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_seeded_text_start_participates_in_overlap_filter(wire):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("messages", "The answer is", complete=False)
|
||||
events = text_events("messages", " 42")
|
||||
events[1]["content_block"]["text"] = "answer is"
|
||||
return 200, events
|
||||
|
||||
async with _harness("messages", reply, max_attempts=2) as (send, _, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "continue"}]), wire
|
||||
)
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "The answer is 42"
|
||||
|
|
@ -32,7 +32,9 @@ from tests.providers.test_request_recovery_transitions import Wire
|
|||
async def _response(source, wire):
|
||||
options = {"pre_start_error_response": _json_error, "request_id": "stopped"}
|
||||
return await (
|
||||
anthropic_sse_streaming_response(source, **options)
|
||||
anthropic_sse_streaming_response(
|
||||
source, pre_start_error_response=_json_error, request_id="stopped"
|
||||
)
|
||||
if wire == "messages"
|
||||
else openai_responses_sse_streaming_response(source, headers={}, **options)
|
||||
)
|
||||
|
|
|
|||
190
tests/api/test_pending_recovery_failure.py
Normal file
190
tests/api/test_pending_recovery_failure.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""A pending recovery retains public output even before its first new event."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from itertools import pairwise
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from free_claude_code.application.execution import ProviderExecutor
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.stream_delivery import current_stream_delivery
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import public_text, text_events
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.api.test_stream_delivery import drained
|
||||
from tests.api.test_tool_call_buffer import _response
|
||||
from tests.providers.test_history_transports import _harness
|
||||
from tests.providers.test_native_tool_arguments import tool_events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("metadata_first", [False, True])
|
||||
async def test_continuation_timeout_retains_delivered_output(
|
||||
monkeypatch, protocol, wire, metadata_first
|
||||
):
|
||||
module = httpx if protocol == "messages" else httpx2
|
||||
native_transport = module.MockTransport
|
||||
closed = []
|
||||
|
||||
class WaitingStream(httpx.AsyncByteStream, httpx2.AsyncByteStream):
|
||||
async def __aiter__(self):
|
||||
if metadata_first:
|
||||
start = text_events(protocol, "")[0]
|
||||
yield ("data: " + json.dumps(start) + "\n\n").encode()
|
||||
await asyncio.Event().wait()
|
||||
yield b""
|
||||
|
||||
async def aclose(self):
|
||||
closed.append(True)
|
||||
|
||||
def transport(handler):
|
||||
calls = 0
|
||||
|
||||
async def handle(request):
|
||||
nonlocal calls
|
||||
response = handler(request)
|
||||
calls += 1
|
||||
if calls > 1:
|
||||
await response.aclose()
|
||||
return module.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=WaitingStream(),
|
||||
)
|
||||
return response
|
||||
|
||||
return native_transport(handle)
|
||||
|
||||
def executor(*args, **kwargs):
|
||||
kwargs["progress_timeout_seconds"] = 0.2
|
||||
return ProviderExecutor(*args, **kwargs)
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(module, "MockTransport", transport)
|
||||
patch.setattr("tests.api.test_hidden_stream_retries.ProviderExecutor", executor)
|
||||
patch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
async with _harness(
|
||||
protocol,
|
||||
lambda _: (200, text_events(protocol, "Hello ", complete=False)),
|
||||
max_attempts=3,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2 and closed == [True]
|
||||
assert public_text(events, wire) == "Hello "
|
||||
failure_type = "error" if wire == "messages" else "response.failed"
|
||||
assert events[-1].event == failure_type
|
||||
assert sum(event.event == failure_type for event in events) == 1
|
||||
assert not any(
|
||||
event.event in {"message_stop", "response.completed"} for event in events
|
||||
)
|
||||
failure = events[-1].data if wire == "messages" else events[-1].data["response"]
|
||||
assert failure["error"]["type"] == "timeout_error"
|
||||
if wire == "messages":
|
||||
return
|
||||
|
||||
assert failure["id"] == events[0].data["response"]["id"]
|
||||
assert failure["status"] == "failed"
|
||||
assert failure["usage"]["output_tokens"] > 0
|
||||
assert all(
|
||||
left.data["sequence_number"] < right.data["sequence_number"]
|
||||
for left, right in pairwise(events)
|
||||
)
|
||||
assert (
|
||||
"".join(
|
||||
part.get("text", "")
|
||||
for item in failure["output"]
|
||||
for part in item.get("content") or []
|
||||
)
|
||||
== "Hello "
|
||||
)
|
||||
|
||||
http = httpx2.AsyncClient(
|
||||
transport=httpx2.MockTransport(
|
||||
lambda _: httpx2.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=raw.encode()
|
||||
)
|
||||
)
|
||||
)
|
||||
async with (
|
||||
AsyncOpenAI(api_key="fixture", max_retries=0, http_client=http) as client,
|
||||
client.responses.stream(model="fixture", input="greet") as stream,
|
||||
):
|
||||
seen = [event async for event in stream]
|
||||
assert seen[-1].type == "response.failed"
|
||||
output = seen[-1].response.output[0]
|
||||
assert output.type == "message"
|
||||
part = output.content[0]
|
||||
assert part.type == "output_text" and part.text == "Hello "
|
||||
with pytest.raises(RuntimeError, match=r"response\.completed"):
|
||||
await stream.get_final_response()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("handoff", [False, True])
|
||||
async def test_pending_recovery_failure_does_not_require_successful_closures(handoff):
|
||||
prefix = (
|
||||
tool_events("responses", '{"path":"x"}')[:-1]
|
||||
if handoff
|
||||
else text_events("responses", "Hello ", complete=False)
|
||||
)
|
||||
|
||||
async def source():
|
||||
for payload in prefix:
|
||||
yield f"event: {payload['type']}\ndata: {json.dumps(payload)}\n\n"
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
state.begin_continuation(handoff=handoff)
|
||||
raise ValueError("recovery boundary failed")
|
||||
|
||||
events = parse_sse_text(await drained(source(), "responses"))
|
||||
failure = events[-1].data["response"]
|
||||
assert events[-1].event == "response.failed"
|
||||
assert "recovery boundary failed" in failure["error"]["message"]
|
||||
assert len(events) == len(prefix) + 1
|
||||
assert len(failure["output"]) == 1
|
||||
if handoff:
|
||||
assert failure["output"][0] == prefix[-1]["item"]
|
||||
else:
|
||||
assert failure["output"][0]["status"] == "in_progress"
|
||||
assert failure["output"][0]["content"][0]["text"] == "Hello "
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_during_pending_continuation_emits_no_failure():
|
||||
closed = []
|
||||
events = []
|
||||
|
||||
async def source():
|
||||
try:
|
||||
for payload in text_events("responses", "Hello ", complete=False):
|
||||
yield f"event: {payload['type']}\ndata: {json.dumps(payload)}\n\n"
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
state.begin_continuation()
|
||||
raise asyncio.CancelledError
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
response = await _response("responses", source())
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
async for chunk in response.body_iterator:
|
||||
events.extend(parse_sse_text(str(chunk)))
|
||||
finally:
|
||||
await response.aclose()
|
||||
assert public_text(events, "responses") == "Hello "
|
||||
assert not any(event.event == "response.failed" for event in events)
|
||||
assert closed == [True]
|
||||
261
tests/api/test_recovery_attempt_state.py
Normal file
261
tests/api/test_recovery_attempt_state.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""A continued response retains corrected requests and counts actual output."""
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.anthropic.passthrough import NativeMessagesRequest
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import (
|
||||
assert_completed,
|
||||
public_text,
|
||||
text_events,
|
||||
)
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.providers.test_anthropic_messages_transport import Wire, _sse
|
||||
from tests.providers.test_anthropic_provider import native_body, provider
|
||||
from tests.providers.test_history_transports import _events_for, _harness, _saved_reply
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def release_provider_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_empty_continuation_fails_without_compatibility_padding(protocol, wire):
|
||||
def reply(bodies):
|
||||
return 200, text_events(
|
||||
protocol,
|
||||
"Hello" if len(bodies) == 1 else "",
|
||||
complete=len(bodies) > 1,
|
||||
)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=3) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
|
||||
events = parse_sse_text(result)
|
||||
assert len(bodies) == 2
|
||||
assert public_text(events, wire) == "Hello"
|
||||
assert events[-1].event == ("error" if wire == "messages" else "response.failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_empty_initial_generation_preserves_compatibility_padding(protocol, wire):
|
||||
async with _harness(protocol, lambda _: (200, text_events(protocol, ""))) as (
|
||||
send,
|
||||
bodies,
|
||||
_,
|
||||
):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert len(bodies) == 1
|
||||
padded = protocol == "chat" or (protocol == "responses" and wire == "messages")
|
||||
assert public_text(events, wire) == (" " if padded else "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_real_provider_whitespace_is_continuation_output(protocol, wire):
|
||||
async with _harness(
|
||||
protocol,
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events(
|
||||
protocol,
|
||||
"Hello" if len(bodies) == 1 else " ",
|
||||
complete=len(bodies) > 1,
|
||||
),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Hello "
|
||||
assert len(bodies) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_readable_reasoning_continuation_succeeds_without_padding(wire):
|
||||
reasoning = text_events("chat", "")
|
||||
reasoning[0]["choices"][0]["delta"] = {
|
||||
"reasoning_content": "Consider the greeting."
|
||||
}
|
||||
async with _harness(
|
||||
"chat",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("chat", "Hello", complete=False)
|
||||
if len(bodies) == 1
|
||||
else reasoning,
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
result = await delivered(
|
||||
send(wire, [{"role": "user", "content": "greet"}]), wire
|
||||
)
|
||||
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Hello"
|
||||
assert "Consider the greeting." in result
|
||||
assert len(bodies) == 2
|
||||
|
||||
|
||||
def rejected_history_error(protocol, body):
|
||||
if protocol == "responses":
|
||||
index = next(
|
||||
i for i, item in enumerate(body["input"]) if item.get("encrypted_content")
|
||||
)
|
||||
param = f"input[{index}].encrypted_content"
|
||||
else:
|
||||
field = "content" if protocol == "messages" else "reasoning_details"
|
||||
key = "signature" if protocol == "messages" else "data"
|
||||
index, block_index = next(
|
||||
(i, j)
|
||||
for i, message in enumerate(body["messages"])
|
||||
if isinstance(message.get(field), list)
|
||||
for j, block in enumerate(message[field])
|
||||
if block.get(key) == "opaque-original"
|
||||
)
|
||||
param = f"messages[{index}].{field}[{block_index}].{key}"
|
||||
return {
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_signature"
|
||||
if protocol == "messages"
|
||||
else "invalid_encrypted_content",
|
||||
"param": param,
|
||||
"message": "Historical reasoning record expired.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_history_correction_survives_another_interruption(protocol, wire):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, _events_for(protocol)
|
||||
if len(bodies) > 2 and "opaque-original" in json.dumps(bodies[-1]):
|
||||
return 400, rejected_history_error(protocol, bodies[-1])
|
||||
return 200, text_events(
|
||||
protocol,
|
||||
"Hello "
|
||||
if len(bodies) == 2
|
||||
else "world "
|
||||
if len(bodies) == 4
|
||||
else "again.",
|
||||
complete=len(bodies) == 5,
|
||||
)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=4) as (send, bodies, _):
|
||||
saved = await _saved_reply(
|
||||
send(wire, [{"role": "user", "content": "think"}]), wire
|
||||
)
|
||||
history = [*saved, {"role": "user", "content": "greet"}]
|
||||
original = deepcopy(history)
|
||||
result = await delivered(send(wire, history), wire)
|
||||
|
||||
assert len(bodies) == 5
|
||||
assert history == original
|
||||
assert "opaque-original" in json.dumps(bodies[2])
|
||||
assert all("opaque-original" not in json.dumps(body) for body in bodies[3:])
|
||||
history_field = "input" if protocol == "responses" else "messages"
|
||||
assert bodies[4][history_field][:-2] == bodies[3][history_field][:-2]
|
||||
assert "Hello world " in str(bodies[4][history_field][-2])
|
||||
events = parse_sse_text(result)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Hello world again."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removed_history_row_stays_removed_after_another_interruption():
|
||||
history = [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_old",
|
||||
"encrypted_content": "opaque-original",
|
||||
"summary": [],
|
||||
},
|
||||
{"role": "user", "content": "greet"},
|
||||
]
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) > 1 and "opaque-original" in json.dumps(bodies[-1]):
|
||||
return 400, rejected_history_error("responses", bodies[-1])
|
||||
return 200, text_events(
|
||||
"responses",
|
||||
"Hello "
|
||||
if len(bodies) == 1
|
||||
else "world "
|
||||
if len(bodies) == 3
|
||||
else "again.",
|
||||
complete=len(bodies) == 4,
|
||||
)
|
||||
|
||||
async with _harness("responses", reply, max_attempts=4) as (send, bodies, _):
|
||||
result = await delivered(send("responses", history), "responses")
|
||||
|
||||
assert len(bodies) == 4
|
||||
assert len(bodies[2]["input"]) == len(bodies[1]["input"]) - 1
|
||||
assert bodies[3]["input"][:-2] == bodies[2]["input"][:-2]
|
||||
assert_completed(parse_sse_text(result), "responses")
|
||||
assert public_text(parse_sse_text(result), "responses") == "Hello world again."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_messages_repeated_continuation_replaces_only_private_tail():
|
||||
bodies = []
|
||||
wires = [
|
||||
Wire([_sse(*text_events("messages", text, complete=index == 2))])
|
||||
for index, text in enumerate(["Hello ", "world ", "again."])
|
||||
]
|
||||
|
||||
def reply(request):
|
||||
assert all(wire.closed for wire in wires[: len(bodies)])
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
stream=wires[len(bodies) - 1],
|
||||
)
|
||||
|
||||
p = provider(reply, max_attempts=3)
|
||||
initial = native_body(True)
|
||||
try:
|
||||
result = await delivered(
|
||||
p.stream_native_messages(NativeMessagesRequest(initial)), "messages"
|
||||
)
|
||||
assert_completed(parse_sse_text(result), "messages")
|
||||
assert public_text(parse_sse_text(result), "messages") == "Hello world again."
|
||||
assert len(bodies) == 3
|
||||
assert bodies[-1]["messages"][:-2] == initial["messages"]
|
||||
assert bodies[-1]["messages"][-2] == {
|
||||
"role": "assistant",
|
||||
"content": "Hello world ",
|
||||
}
|
||||
assert all(wire.closed for wire in wires)
|
||||
finally:
|
||||
await p.cleanup()
|
||||
291
tests/api/test_recovery_failure_contract.py
Normal file
291
tests/api/test_recovery_failure_contract.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""Recovery exhaustion preserves failure semantics and public stream ownership."""
|
||||
|
||||
from copy import deepcopy
|
||||
from itertools import pairwise
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.api.request_outcomes import _observe_event
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.continuation_stream import ContinuationStream
|
||||
from free_claude_code.core.request_outcomes import (
|
||||
RequestOutcome,
|
||||
current_request_outcome,
|
||||
)
|
||||
from free_claude_code.providers.admission import (
|
||||
ProviderAdmissionController,
|
||||
ProviderExecutionState,
|
||||
)
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import (
|
||||
assert_completed,
|
||||
public_text,
|
||||
text_events,
|
||||
)
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.providers.test_history_transports import _harness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
def assert_unavailable_snapshot(events):
|
||||
assert events[-1].event == "response.failed"
|
||||
assert events[-1].data["type"] == "response.failed"
|
||||
assert sum(event.event == "response.failed" for event in events) == 1
|
||||
assert not any(event.event == "response.completed" for event in events)
|
||||
response = events[-1].data["response"]
|
||||
assert response["status"] == "failed"
|
||||
assert response["output"] == []
|
||||
assert response["usage"] is None
|
||||
assert "snapshot" in response["error"]["message"].lower()
|
||||
assert response["id"] == events[0].data["response"]["id"]
|
||||
numbers = [event.data["sequence_number"] for event in events]
|
||||
assert all(left < right for left, right in pairwise(numbers))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("continuing", [False, True])
|
||||
async def test_retention_failure_does_not_claim_a_partial_final_snapshot(
|
||||
monkeypatch, immediate_events, protocol, wire, continuing
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 1200
|
||||
)
|
||||
|
||||
def reply(bodies):
|
||||
text = "First " if continuing and len(bodies) == 1 else "x" * 2000
|
||||
return 200, text_events(protocol, text, complete=False)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "write"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == (2 if continuing else 1)
|
||||
assert public_text(events, wire) == ("First " if continuing else "") + "x" * 2000
|
||||
if wire == "responses":
|
||||
assert_unavailable_snapshot(events)
|
||||
else:
|
||||
assert events[-1].event == "error"
|
||||
assert sum(event.event == "error" for event in events) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limited", [False, True])
|
||||
@pytest.mark.parametrize("code", [None, "context_length_exceeded"])
|
||||
async def test_failed_frame_keeps_error_category_and_finishes_execution(
|
||||
monkeypatch, immediate_events, limited, code
|
||||
):
|
||||
if limited:
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 1200
|
||||
)
|
||||
executions = []
|
||||
original = ProviderAdmissionController.start_execution
|
||||
|
||||
def capture(self, **kwargs):
|
||||
execution = original(self, **kwargs)
|
||||
executions.append(execution)
|
||||
return execution
|
||||
|
||||
monkeypatch.setattr(ProviderAdmissionController, "start_execution", capture)
|
||||
error = {
|
||||
"type": "rate_limit_error" if code is None else "invalid_request_error",
|
||||
"code": code,
|
||||
"param": "input",
|
||||
"message": "Provider rejected the request.",
|
||||
}
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "First ", complete=False)
|
||||
events = text_events("responses", "x" * 2000)
|
||||
events[-1]["type"] = "response.failed"
|
||||
events[-1]["response"].update(status="failed", error=error)
|
||||
return 200, events
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "write"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert executions[-1].state is ProviderExecutionState.FAILED
|
||||
failure = events[-1].data["response"]
|
||||
assert failure["error"]["type"] == error["type"]
|
||||
assert failure["error"]["code"] == code
|
||||
assert failure["error"]["param"] == "input"
|
||||
assert failure["error"]["message"].startswith(error["message"])
|
||||
outcome = RequestOutcome()
|
||||
token = current_request_outcome.set(outcome)
|
||||
try:
|
||||
_observe_event(events[-1])
|
||||
finally:
|
||||
current_request_outcome.reset(token)
|
||||
assert outcome.failure_reason == (code or error["type"])
|
||||
if limited:
|
||||
assert_unavailable_snapshot(events)
|
||||
else:
|
||||
assert (
|
||||
"".join(
|
||||
part.get("text", "")
|
||||
for item in failure["output"]
|
||||
for part in item.get("content", [])
|
||||
)
|
||||
== "First " + "x" * 2000
|
||||
)
|
||||
assert failure["error"] == error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("candidate", ["", "New text."])
|
||||
async def test_production_cap_during_closures_does_not_count_as_new_output(candidate):
|
||||
# The production holdback releases this prefix by size before the disconnect.
|
||||
prefix = "A" * 400_000
|
||||
|
||||
def reply(bodies):
|
||||
return 200, text_events(
|
||||
"responses",
|
||||
prefix if len(bodies) == 1 else candidate,
|
||||
complete=len(bodies) > 1,
|
||||
)
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "write"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert public_text(events, "responses") == prefix + candidate
|
||||
if candidate:
|
||||
assert_completed(events, "responses")
|
||||
else:
|
||||
assert_unavailable_snapshot(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cap_during_closures_does_not_turn_pure_echo_into_success(
|
||||
monkeypatch, immediate_events
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 1200
|
||||
)
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "A" * 600, complete=len(bodies) > 1),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "write"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert public_text(events, "responses") == "A" * 600
|
||||
assert_unavailable_snapshot(events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cap_failure_formatting_does_not_reenter_a_failed_mapper(
|
||||
monkeypatch, immediate_events
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 1200
|
||||
)
|
||||
original = ContinuationStream.prepare
|
||||
|
||||
def prepare(self, payload):
|
||||
if payload["type"] == "response.output_text.done":
|
||||
raise ValueError("public processing failed")
|
||||
return original(self, payload)
|
||||
|
||||
monkeypatch.setattr(ContinuationStream, "prepare", prepare)
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events(
|
||||
"responses",
|
||||
"First " if len(bodies) == 1 else "x" * 2000,
|
||||
complete=len(bodies) > 1,
|
||||
),
|
||||
),
|
||||
max_attempts=3,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "write"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert public_text(events, "responses") == "First " + "x" * 2000
|
||||
assert_unavailable_snapshot(events)
|
||||
assert "public processing failed" in events[-1].data["response"]["error"]["message"]
|
||||
|
||||
|
||||
ANNOTATION = {
|
||||
"type": "url_citation",
|
||||
"url": "https://example.com/source",
|
||||
"title": "Source",
|
||||
"start_index": 0,
|
||||
"end_index": 5,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("part_event", ["added", "done"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("continuing", [False, True])
|
||||
async def test_annotated_part_blocks_continuation_before_its_item_snapshot(
|
||||
immediate_events, part_event, wire, continuing
|
||||
):
|
||||
annotated_attempt = 2 if continuing else 1
|
||||
|
||||
def reply(bodies):
|
||||
if continuing and len(bodies) == 1:
|
||||
return 200, text_events("responses", "First ", complete=False)
|
||||
if len(bodies) != annotated_attempt:
|
||||
return 200, text_events("responses", "Tail.")
|
||||
events = deepcopy(text_events("responses", "Cited "))[:6]
|
||||
events[2 if part_event == "added" else 5]["part"]["annotations"] = [ANNOTATION]
|
||||
return 200, events
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "cite"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == annotated_attempt
|
||||
assert events[-1].event == ("error" if wire == "messages" else "response.failed")
|
||||
assert "Tail." not in public_text(events, wire)
|
||||
if continuing:
|
||||
assert not any(
|
||||
event.data.get("part", {}).get("annotations") for event in events
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("annotated", [False, True])
|
||||
async def test_healthy_original_response_preserves_annotations(
|
||||
immediate_events, annotated
|
||||
):
|
||||
source = text_events("responses", "Cited ")
|
||||
if annotated:
|
||||
source[2]["part"]["annotations"] = [ANNOTATION]
|
||||
source[5]["part"]["annotations"] = [ANNOTATION]
|
||||
async with _harness("responses", lambda _: (200, source)) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "cite"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 1
|
||||
assert_completed(events, "responses")
|
||||
assert public_text(events, "responses") == "Cited "
|
||||
assert events[-1].data["response"]["output"][0]["content"][0]["annotations"] == (
|
||||
[ANNOTATION] if annotated else []
|
||||
)
|
||||
304
tests/api/test_recovery_lifecycle.py
Normal file
304
tests/api/test_recovery_lifecycle.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Recovery preserves content and closes each published lifecycle stage once."""
|
||||
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.delivered_response import DeliveredResponse
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import (
|
||||
assert_completed,
|
||||
public_text,
|
||||
text_events,
|
||||
)
|
||||
from tests.api.test_hidden_stream_retries import delivered, partial_tool
|
||||
from tests.api.test_tool_call_buffer_transports import _tools
|
||||
from tests.providers.test_history_transports import _harness
|
||||
from tests.providers.test_native_tool_arguments import tool_events
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def release_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_closure_with_default_holdback(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
RecoveryHoldbackBuffer,
|
||||
)
|
||||
original_transport = httpx2.MockTransport
|
||||
|
||||
class PacedStream(httpx2.AsyncByteStream):
|
||||
def __init__(self, raw):
|
||||
self.raw = raw
|
||||
|
||||
async def __aiter__(self):
|
||||
first, _, rest = self.raw.partition(b"\n\n")
|
||||
yield first + b"\n\n"
|
||||
await asyncio.sleep(0.8)
|
||||
yield rest
|
||||
|
||||
def transport(handler):
|
||||
async def handle(request):
|
||||
response = handler(request)
|
||||
raw = await response.aread()
|
||||
await response.aclose()
|
||||
return httpx2.Response(
|
||||
200, headers=response.headers, stream=PacedStream(raw)
|
||||
)
|
||||
|
||||
return original_transport(handle)
|
||||
|
||||
monkeypatch.setattr(httpx2, "MockTransport", transport)
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ")[:6]
|
||||
if len(bodies) == 1
|
||||
else text_events("responses", "world."),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, "responses")
|
||||
assert len(bodies) == 2
|
||||
assert "Hello " in str(bodies[1]["input"])
|
||||
assert public_text(events, "responses") == "Hello world."
|
||||
assert (
|
||||
sum(
|
||||
event.event == "response.content_part.done"
|
||||
and event.data["item_id"] == "msg_text"
|
||||
for event in events
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,expected",
|
||||
[
|
||||
("Hello world.", "Hello world."),
|
||||
("lo world.", "Hello world."),
|
||||
("Hel", "Hello Hel"),
|
||||
],
|
||||
)
|
||||
async def test_reasoning_before_continuation_text_keeps_overlap_filter(
|
||||
wire, candidate, expected
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("chat", "Hello ", complete=False)
|
||||
reasoning = text_events("chat", "", complete=False)[0]
|
||||
reasoning["choices"][0]["delta"] = {
|
||||
"reasoning_content": "Continue the greeting."
|
||||
}
|
||||
return 200, [reasoning, *text_events("chat", candidate)]
|
||||
|
||||
async with _harness("chat", reply, max_attempts=2) as (send, _, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "greet"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == expected
|
||||
assert "Continue the greeting." in raw
|
||||
|
||||
|
||||
def refusal_events(*, mixed=False):
|
||||
events = text_events("responses", "lo world." if mixed else "")
|
||||
part = {"type": "refusal", "refusal": "I cannot continue that request."}
|
||||
position = 1 if mixed else 0
|
||||
prefix = events[:-2] if mixed else events[:2]
|
||||
refusal: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "response.content_part.added",
|
||||
"part": {"type": "refusal", "refusal": ""},
|
||||
},
|
||||
{"type": "response.refusal.delta", "delta": part["refusal"]},
|
||||
{"type": "response.refusal.done", "refusal": part["refusal"]},
|
||||
{"type": "response.content_part.done", "part": part},
|
||||
]
|
||||
for event in refusal:
|
||||
event.update(output_index=0, content_index=position, item_id="msg_text")
|
||||
item = events[-2]["item"]
|
||||
item["content"] = [*item["content"], part] if mixed else [part]
|
||||
item["phase"] = "final_answer"
|
||||
return [
|
||||
{**event, "sequence_number": index}
|
||||
for index, event in enumerate([*prefix, *refusal, *events[-2:]])
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("mixed", [False, True])
|
||||
@pytest.mark.parametrize("limited", [False, True])
|
||||
async def test_continuation_preserves_refusal_and_completed_metadata(
|
||||
monkeypatch, mixed, limited
|
||||
):
|
||||
if limited:
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 600
|
||||
)
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 1
|
||||
else refusal_events(mixed=mixed),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, "responses")
|
||||
assert len(bodies) == 2
|
||||
output = events[-1].data["response"]["output"]
|
||||
assert output == [
|
||||
event.data["item"]
|
||||
for event in events
|
||||
if event.event == "response.output_item.done"
|
||||
]
|
||||
assert output[-1]["phase"] == "final_answer"
|
||||
assert output[-1]["content"][-1] == {
|
||||
"type": "refusal",
|
||||
"refusal": "I cannot continue that request.",
|
||||
}
|
||||
assert public_text(events, "responses") == ("Hello world." if mixed else "Hello ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_published_refusal_interruption_does_not_generate_again():
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 1
|
||||
else refusal_events()[:-3],
|
||||
),
|
||||
max_attempts=3,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == "response.failed"
|
||||
assert (
|
||||
events[-1].data["response"]["output"][-1]["content"][-1]["refusal"]
|
||||
== "I cannot continue that request."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cut", [4, 5, 6, 7])
|
||||
async def test_recovery_emits_only_missing_text_completion_stages(cut):
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ")[:cut]
|
||||
if len(bodies) == 1
|
||||
else text_events("responses", "world."),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, _, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, "responses")
|
||||
counts = Counter(
|
||||
event.event
|
||||
for event in events
|
||||
if event.data.get("item_id", event.data.get("item", {}).get("id")) == "msg_text"
|
||||
)
|
||||
for kind in (
|
||||
"response.output_text.done",
|
||||
"response.content_part.done",
|
||||
"response.output_item.done",
|
||||
):
|
||||
assert counts[kind] == 1
|
||||
assert public_text(events, "responses") == "Hello world."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limited", [False, True])
|
||||
@pytest.mark.parametrize("continued", [False, True])
|
||||
@pytest.mark.parametrize("cut", [4, 5, 6])
|
||||
async def test_handoff_snapshot_matches_published_closures(
|
||||
monkeypatch, limited, continued, cut
|
||||
):
|
||||
if limited:
|
||||
original = DeliveredResponse.closing_events
|
||||
|
||||
def cap_during_boundary(self):
|
||||
events = original(self)
|
||||
if self.snapshot().has_calls:
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES",
|
||||
self._bytes + 1,
|
||||
)
|
||||
return events
|
||||
|
||||
monkeypatch.setattr(DeliveredResponse, "closing_events", cap_during_boundary)
|
||||
|
||||
def reply(bodies):
|
||||
if continued and len(bodies) == 1:
|
||||
return 200, text_events("responses", "Before. ", complete=False)
|
||||
events = deepcopy(tool_events("responses", '{"path":"kept"}')[:-1])
|
||||
for event in text_events("responses", "Next ")[1:cut]:
|
||||
event["output_index"] = 1
|
||||
events.append(event)
|
||||
for event in partial_tool("responses")[1:]:
|
||||
if "output_index" in event:
|
||||
event["output_index"] = 2
|
||||
events.append(event)
|
||||
return 200, [
|
||||
{**event, "sequence_number": index} for index, event in enumerate(events)
|
||||
]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2 if continued else 1) as (
|
||||
send,
|
||||
bodies,
|
||||
_,
|
||||
):
|
||||
raw = await delivered(
|
||||
send(
|
||||
"responses",
|
||||
[{"role": "user", "content": "read"}],
|
||||
tools=_tools("responses"),
|
||||
),
|
||||
"responses",
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, "responses")
|
||||
assert len(bodies) == (2 if continued else 1)
|
||||
assert "call_abandoned" not in raw
|
||||
output = events[-1].data["response"]["output"]
|
||||
assert output == [
|
||||
event.data["item"]
|
||||
for event in events
|
||||
if event.event == "response.output_item.done"
|
||||
]
|
||||
assert all(item["status"] == "completed" for item in output)
|
||||
assert [
|
||||
item["arguments"] for item in output if item["type"] == "function_call"
|
||||
] == ['{"path":"kept"}']
|
||||
364
tests/api/test_recovery_public_owner.py
Normal file
364
tests/api/test_recovery_public_owner.py
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"""Attempt completion and public publication share one response lifecycle."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.continuation_stream import ContinuationStream
|
||||
from free_claude_code.core.stream_delivery import current_stream_delivery
|
||||
from free_claude_code.providers.request_recovery import RequestCorrections
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import public_text, text_events
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.api.test_tool_call_buffer import _call, _frames, _response, _start
|
||||
from tests.api.test_tool_call_buffer_transports import _tools
|
||||
from tests.providers.test_history_transports import _harness
|
||||
from tests.providers.test_native_tool_arguments import tool_events
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("preceding_text", [False, True])
|
||||
@pytest.mark.parametrize("snapshot_only", [False, True])
|
||||
async def test_continuation_preserves_call_completed_by_terminal_snapshot(
|
||||
preceding_text, snapshot_only
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
tool = deepcopy(tool_events("responses", '{"path":"file"}'))
|
||||
if preceding_text:
|
||||
text = text_events("responses", "world.")
|
||||
for event in tool:
|
||||
if "output_index" in event:
|
||||
event["output_index"] = 1
|
||||
tool[-1]["response"]["output"].insert(0, text[-2]["item"])
|
||||
events = [
|
||||
*(text[:-1] if preceding_text else tool[:1]),
|
||||
*(
|
||||
[e for e in tool[1:-1] if e["type"] != "response.output_item.done"]
|
||||
if not snapshot_only
|
||||
else []
|
||||
),
|
||||
tool[-1],
|
||||
]
|
||||
return 200, [{**event, "sequence_number": i} for i, event in enumerate(events)]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send(
|
||||
"responses",
|
||||
[{"role": "user", "content": "greet and read"}],
|
||||
tools=_tools("responses"),
|
||||
),
|
||||
"responses",
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == "response.completed"
|
||||
output = events[-1].data["response"]["output"]
|
||||
calls = [item for item in output if item["type"] == "function_call"]
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["call_id"] == "call_probe"
|
||||
assert calls[0]["arguments"] == '{"path":"file"}'
|
||||
assert calls[0]["status"] == "completed"
|
||||
assert public_text(events, "responses") == "Hello " + (
|
||||
"world." if preceding_text else ""
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("activity", ["metadata", "withheld_tools", "text"])
|
||||
async def test_body_correction_depends_on_current_attempt_publication(activity):
|
||||
visible = activity == "text"
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 2:
|
||||
prefix = text_events("responses", "world ", complete=False)
|
||||
hidden = _frames(
|
||||
"responses", [_call("responses", 0)[0], _call("responses", 1)[0]]
|
||||
)
|
||||
return 200, [
|
||||
*(prefix if visible else prefix[:1]),
|
||||
*(
|
||||
[event.data for frame in hidden for event in parse_sse_text(frame)]
|
||||
if activity == "withheld_tools"
|
||||
else []
|
||||
),
|
||||
{
|
||||
"type": "error",
|
||||
"sequence_number": 4,
|
||||
"code": "invalid_encrypted_content",
|
||||
"param": "input[0].encrypted_content",
|
||||
"message": "The encrypted content could not be verified.",
|
||||
},
|
||||
]
|
||||
return 200, text_events("responses", "world again.")
|
||||
|
||||
history = [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_old",
|
||||
"encrypted_content": "opaque",
|
||||
"summary": [],
|
||||
},
|
||||
{"role": "user", "content": "greet"},
|
||||
]
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(send("responses", history), "responses")
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == (2 if visible else 3)
|
||||
assert events[-1].event == ("response.failed" if visible else "response.completed")
|
||||
assert public_text(events, "responses") == (
|
||||
"Hello world " if visible else "Hello world again."
|
||||
)
|
||||
assert "call_0" not in raw and "call_1" not in raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_released_tool_group_is_published_one_frame_at_a_time():
|
||||
resumed = []
|
||||
states = []
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
states.append(state)
|
||||
yield "".join(_frames("responses", [_start("responses")]))
|
||||
yield "".join(_frames("responses", _call("responses")))
|
||||
resumed.append(state.content_released)
|
||||
|
||||
response = await _response("responses", source())
|
||||
try:
|
||||
iterator = aiter(response.body_iterator)
|
||||
assert parse_sse_text(str(await anext(iterator)))[0].event == "response.created"
|
||||
first = parse_sse_text(str(await anext(iterator)))
|
||||
assert len(first) == 1
|
||||
assert first[0].event == "response.output_item.added"
|
||||
assert not states[0].prefix.has_calls
|
||||
assert resumed == []
|
||||
assert len(parse_sse_text(str(await anext(iterator)))) == 1
|
||||
assert resumed == []
|
||||
await anext(iterator)
|
||||
await anext(iterator)
|
||||
assert resumed == []
|
||||
assert [part async for part in iterator] == []
|
||||
assert resumed == [True]
|
||||
finally:
|
||||
await response.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_transform_failure_preserves_returned_frames_and_emits_error(
|
||||
monkeypatch,
|
||||
):
|
||||
original = ContinuationStream.prepare
|
||||
closed = []
|
||||
|
||||
def prepare(self, payload):
|
||||
if payload["type"] == "response.output_text.done":
|
||||
raise ValueError("public transform failed")
|
||||
return original(self, payload)
|
||||
|
||||
monkeypatch.setattr(ContinuationStream, "prepare", prepare)
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
try:
|
||||
for event in text_events("responses", "Hello ", complete=False):
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
state.begin_continuation()
|
||||
yield "".join(
|
||||
_frames(
|
||||
"responses",
|
||||
[
|
||||
(event["type"], event)
|
||||
for event in text_events("responses", "world.")
|
||||
],
|
||||
)
|
||||
)
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
raw = await delivered(source(), "responses")
|
||||
events = parse_sse_text(raw)
|
||||
assert public_text(events, "responses") == "Hello world."
|
||||
assert events[-1].event == "response.failed"
|
||||
assert sum(e.event == "response.failed" for e in events) == 1
|
||||
assert not any(e.event == "response.completed" for e in events)
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("text", ["Hello world.", "world.", "Hello ", ""])
|
||||
async def test_terminal_only_text_uses_the_same_overlap_and_progress_rule(text):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
events = text_events("responses", text)
|
||||
return 200, [events[0], events[-1]]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == (
|
||||
"response.completed" if "world" in text else "response.failed"
|
||||
)
|
||||
if "world" in text:
|
||||
output = events[-1].data["response"]["output"]
|
||||
assert (
|
||||
"".join(part["text"] for item in output for part in item.get("content", []))
|
||||
== "Hello world."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["reasoning", "web_search_call", "message"])
|
||||
async def test_final_only_native_state_is_checked_before_continuation_publication(kind):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
events = text_events("responses", "")
|
||||
item = {"id": "native_unreplayable", "type": kind, "status": "completed"}
|
||||
if kind == "reasoning":
|
||||
item.update(encrypted_content="opaque", summary=[])
|
||||
elif kind == "message":
|
||||
item.update(role="assistant", content=[{"type": "future_native_part"}])
|
||||
events[-1]["response"]["output"] = [item]
|
||||
return 200, [events[0], events[-1]]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
assert len(bodies) == 2
|
||||
assert parse_sse_text(raw)[-1].event == "response.failed"
|
||||
assert "native_unreplayable" not in raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filtered_snapshot_does_not_change_overlap_target_identity():
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
text = text_events("responses", "Hello world.")
|
||||
for event in text:
|
||||
if "output_index" in event:
|
||||
event["output_index"] = 1
|
||||
call = _call("responses", 0)[0]
|
||||
terminal = text[-1]
|
||||
terminal["response"]["output"].insert(0, call[1]["item"])
|
||||
events = [*text[:-1], {"type": call[0], **call[1]}, terminal]
|
||||
return 200, [{**event, "sequence_number": i} for i, event in enumerate(events)]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2) as (send, _, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "greet"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert "call_0" not in raw
|
||||
assert public_text(events, "responses") == "Hello world."
|
||||
assert [
|
||||
item["content"][0]["text"] for item in events[-1].data["response"]["output"]
|
||||
] == ["Hello ", "world."]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attempt_resets_are_atomic_and_old_closures_do_not_commit_new_attempt():
|
||||
attempts = []
|
||||
|
||||
async def source():
|
||||
state = current_stream_delivery()
|
||||
assert state is not None
|
||||
state.begin_attempt()
|
||||
for event in text_events("responses", "Hello ", complete=False):
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
state.begin_continuation()
|
||||
state.begin_attempt()
|
||||
yield _frames("responses", [_start("responses")])[0]
|
||||
attempts.append(state.attempt_content_released)
|
||||
yield "".join(_frames("responses", _call("responses")[:2]))
|
||||
yield 'event: response.output_item.added\ndata: {"type":'
|
||||
attempts.append(state.attempt_content_released)
|
||||
# Several invisible failures can occur in one read of the provider iterator.
|
||||
state.begin_attempt()
|
||||
state.begin_attempt()
|
||||
for event in text_events("responses", "world."):
|
||||
yield _frames("responses", [(event["type"], event)])[0]
|
||||
attempts.append(state.attempt_content_released)
|
||||
|
||||
raw = await delivered(source(), "responses")
|
||||
events = parse_sse_text(raw)
|
||||
assert attempts == [False, False, True]
|
||||
assert public_text(events, "responses") == "Hello world."
|
||||
assert "call_0" not in raw
|
||||
assert events[-1].event == "response.completed"
|
||||
assert sum(e.event == "response.created" for e in events) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
@pytest.mark.parametrize("visible", [False, True])
|
||||
async def test_shared_correction_guard_uses_public_attempt_effects(
|
||||
monkeypatch, protocol, wire, visible
|
||||
):
|
||||
marker = "injected request correction"
|
||||
proposals = []
|
||||
|
||||
def correction(self, error, body, **kwargs):
|
||||
if marker not in str(error):
|
||||
return None
|
||||
proposals.append(True)
|
||||
return {**deepcopy(body), "temperature": 0}
|
||||
|
||||
monkeypatch.setattr(RequestCorrections, "next_body", correction)
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events(protocol, "Hello ", complete=False)
|
||||
if len(bodies) == 2:
|
||||
events = text_events(protocol, "world " if visible else "", complete=False)
|
||||
if not visible and protocol != "chat":
|
||||
events = events[:1]
|
||||
error = {
|
||||
"type": "invalid_request_error",
|
||||
"message": marker,
|
||||
"code": "invalid_request",
|
||||
}
|
||||
events.append(
|
||||
{
|
||||
"type": "error",
|
||||
**{key: value for key, value in error.items() if key != "type"},
|
||||
}
|
||||
if protocol == "responses"
|
||||
else {"type": "error", "error": error}
|
||||
)
|
||||
return 200, events
|
||||
return 200, text_events(protocol, "world.")
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "greet"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == (2 if visible else 3)
|
||||
assert proposals == ([] if visible else [True])
|
||||
assert public_text(events, wire) == ("Hello world " if visible else "Hello world.")
|
||||
assert events[-1].event == (
|
||||
("error" if wire == "messages" else "response.failed")
|
||||
if visible
|
||||
else ("message_stop" if wire == "messages" else "response.completed")
|
||||
)
|
||||
344
tests/api/test_recovery_reasoning.py
Normal file
344
tests/api/test_recovery_reasoning.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""Recovery observes native reasoning without changing its wire or replay meaning."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.anthropic import aggregate_anthropic_sse_to_message
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.history_replay import decode_replay
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import (
|
||||
assert_completed,
|
||||
public_text,
|
||||
text_events,
|
||||
)
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.providers.test_history_transports import _events_for, _harness
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
def reasoning_events(*, field="summary", seeded="", closed=True):
|
||||
events = deepcopy(_events_for("responses"))
|
||||
part_type = "summary_text" if field == "summary" else "reasoning_text"
|
||||
for event in events:
|
||||
item = event.get("item")
|
||||
items = (
|
||||
[item] if item is not None else event.get("response", {}).get("output", [])
|
||||
)
|
||||
for item in items:
|
||||
item.pop("encrypted_content", None)
|
||||
item["content"] = None
|
||||
item["summary"] = []
|
||||
text = seeded if event["type"].endswith(".added") else seeded + "Find 17."
|
||||
if text:
|
||||
item[field] = [{"type": part_type, "text": text}]
|
||||
if (
|
||||
event["type"] == "response.reasoning_summary_text.delta"
|
||||
and field == "content"
|
||||
):
|
||||
event["type"] = "response.reasoning_text.delta"
|
||||
event["content_index"] = event.pop("summary_index")
|
||||
return events if closed else events[:3]
|
||||
|
||||
|
||||
def followed_by_text(events, text):
|
||||
result = deepcopy(events)
|
||||
tail = text_events("responses", text, complete=False)[1:]
|
||||
for event in tail:
|
||||
if "output_index" in event:
|
||||
event["output_index"] = 1
|
||||
return [
|
||||
{**event, "sequence_number": index}
|
||||
for index, event in enumerate([*result, *tail])
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_nullable_reasoning_content_preserves_healthy_response(wire):
|
||||
events = _events_for("responses")
|
||||
for event in events:
|
||||
if "item" in event:
|
||||
event["item"]["content"] = None
|
||||
for item in event.get("response", {}).get("output", []):
|
||||
item["content"] = None
|
||||
async with _harness("responses", lambda _: (200, events), max_attempts=1) as (
|
||||
send,
|
||||
bodies,
|
||||
_,
|
||||
):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
assert len(bodies) == 1
|
||||
assert_completed(parse_sse_text(raw), wire)
|
||||
if wire == "responses":
|
||||
item = parse_sse_text(raw)[-1].data["response"]["output"][0]
|
||||
assert item["content"] is None
|
||||
assert (
|
||||
decode_replay(item["encrypted_content"]).native
|
||||
== events[-1]["response"]["output"][0]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("field", ["summary", "content"])
|
||||
@pytest.mark.parametrize("closed", [False, True])
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_readable_reasoning_continues_from_released_content(field, closed, wire):
|
||||
first = reasoning_events(field=field, closed=closed)
|
||||
if closed:
|
||||
first = first[:-1]
|
||||
first = followed_by_text(first, "Hello ")
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
first if len(bodies) == 1 else text_events("responses", "world."),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Hello world."
|
||||
assert "Find 17." in str(bodies[1]["input"])
|
||||
assert "Hello " in str(bodies[1]["input"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_readable_reasoning_is_allowed_in_the_continuation(wire):
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 1
|
||||
else reasoning_events(),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "Hello "
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_completed_readable_carrier_without_deltas_counts_as_output(wire):
|
||||
continuation = reasoning_events()
|
||||
continuation.pop(2)
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 1
|
||||
else continuation,
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(parse_sse_text(raw), wire)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_completed_readable_carrier_without_deltas_is_continuation_context(wire):
|
||||
first = reasoning_events()[:-1]
|
||||
first.pop(2)
|
||||
first = followed_by_text(first, "Hello ")
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
first if len(bodies) == 1 else text_events("responses", "world."),
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(parse_sse_text(raw), wire)
|
||||
assert "Find 17." in str(bodies[1]["input"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nullable_reasoning_in_terminal_only_continuation_is_output():
|
||||
continuation = reasoning_events()
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 1
|
||||
else [continuation[0], continuation[-1]],
|
||||
),
|
||||
max_attempts=2,
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "solve"}]), "responses"
|
||||
)
|
||||
assert len(bodies) == 2
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, "responses")
|
||||
assert events[-1].data["response"]["output"][-1]["summary"][0]["text"] == "Find 17."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("field", ["summary", "content"])
|
||||
@pytest.mark.parametrize("seeded", ["", "First, "])
|
||||
async def test_synthetic_reasoning_closure_preserves_followup_history(field, seeded):
|
||||
first = followed_by_text(
|
||||
reasoning_events(field=field, seeded=seeded, closed=False), "Hello "
|
||||
)
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, first
|
||||
return 200, text_events("responses", "world." if len(bodies) == 2 else "Next.")
|
||||
|
||||
async with _harness("responses", reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "solve"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, "responses")
|
||||
output = events[-1].data["response"]["output"]
|
||||
history = [*deepcopy(output), {"role": "user", "content": "Next question."}]
|
||||
await delivered(send("responses", history), "responses")
|
||||
assert len(bodies) == 3
|
||||
native = bodies[2]["input"][0]
|
||||
assert native["type"] == "reasoning"
|
||||
assert native["id"] == first[1]["item"]["id"]
|
||||
assert native["status"] == "completed"
|
||||
assert native[field][0]["text"] == seeded + "Find 17."
|
||||
assert native["extension"] == first[1]["item"]["extension"]
|
||||
assert "previous provider stream" not in str(bodies[2])
|
||||
assert history[:-1] == output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_readable_reasoning_survives_repeated_continuation_and_followup(wire):
|
||||
second = followed_by_text(reasoning_events()[:-1], "middle ")
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "First ", complete=False)
|
||||
if len(bodies) == 2:
|
||||
return 200, second
|
||||
return 200, text_events("responses", "last." if len(bodies) == 3 else "Next.")
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert_completed(events, wire)
|
||||
assert public_text(events, wire) == "First middle last."
|
||||
assert "Find 17." in str(bodies[2]["input"])
|
||||
if wire == "responses":
|
||||
output = events[-1].data["response"]["output"]
|
||||
else:
|
||||
|
||||
async def chunks():
|
||||
yield raw
|
||||
|
||||
message, error, stopped = await aggregate_anthropic_sse_to_message(chunks())
|
||||
assert error is None and stopped
|
||||
output = [{"role": "assistant", "content": message["content"]}]
|
||||
await delivered(
|
||||
send(wire, [*output, {"role": "user", "content": "Next question."}]), wire
|
||||
)
|
||||
assert len(bodies) == 4
|
||||
native = next(
|
||||
item for item in bodies[3]["input"] if item.get("type") == "reasoning"
|
||||
)
|
||||
assert native == second[3]["item"]
|
||||
assert "previous provider stream" not in str(bodies[3])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("native", ["annotations", "encrypted_reasoning"])
|
||||
@pytest.mark.parametrize(
|
||||
"failure", ["failed_context", "incomplete_context", "failed_server"]
|
||||
)
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
async def test_native_failure_snapshot_preserves_error_and_blocks_recovery(
|
||||
native, failure, wire
|
||||
):
|
||||
error = {
|
||||
"code": "server_error"
|
||||
if failure == "failed_server"
|
||||
else "context_length_exceeded",
|
||||
"type": "api_error" if failure == "failed_server" else "invalid_request_error",
|
||||
"param": "input",
|
||||
"message": "Model failed to continue.",
|
||||
}
|
||||
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
events = text_events("responses", "Unpublished failure snapshot")
|
||||
final = deepcopy(events[-1])
|
||||
response = final["response"]
|
||||
if failure == "incomplete_context":
|
||||
final["type"] = "response.incomplete"
|
||||
response.update(
|
||||
status="incomplete",
|
||||
incomplete_details={"reason": "model_context_window_exceeded"},
|
||||
)
|
||||
else:
|
||||
final["type"] = "response.failed"
|
||||
response.update(status="failed", error=error)
|
||||
if native == "annotations":
|
||||
response["output"][0]["content"][0]["annotations"] = [
|
||||
{
|
||||
"type": "url_citation",
|
||||
"url": "https://example.com",
|
||||
"title": "Example",
|
||||
"start_index": 0,
|
||||
"end_index": 5,
|
||||
}
|
||||
]
|
||||
else:
|
||||
response["output"].append(
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_final",
|
||||
"summary": [],
|
||||
"content": None,
|
||||
"encrypted_content": "opaque",
|
||||
"status": "completed",
|
||||
}
|
||||
)
|
||||
return 200, [events[0], final]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "solve"}]), wire)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 2
|
||||
assert events[-1].event == ("error" if wire == "messages" else "response.failed")
|
||||
assert public_text(events, wire) == "Hello "
|
||||
assert "Unpublished failure snapshot" not in raw
|
||||
result = (
|
||||
events[-1].data["error"]
|
||||
if wire == "messages"
|
||||
else events[-1].data["response"]["error"]
|
||||
)
|
||||
assert result["type"] == error["type"]
|
||||
if failure == "failed_server":
|
||||
assert error["message"] in result["message"]
|
||||
if wire == "responses":
|
||||
assert result["code"] == error["code"]
|
||||
if failure != "incomplete_context":
|
||||
assert result == error
|
||||
467
tests/api/test_recovery_text_contract.py
Normal file
467
tests/api/test_recovery_text_contract.py
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"""Recovery must not fabricate structured items or misalign token metadata."""
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
import httpx
|
||||
import httpx2
|
||||
import pytest
|
||||
from openai import AsyncOpenAI
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from free_claude_code.core.anthropic.models import MessagesRequest
|
||||
from free_claude_code.core.anthropic.passthrough import NativeMessagesRequest
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.openai_responses import OpenAIResponsesRequest
|
||||
from free_claude_code.core.reasoning import ReasoningPolicy
|
||||
from free_claude_code.providers.stream_recovery import RecoveryHoldbackBuffer
|
||||
from tests.api.test_delivered_stream_recovery import (
|
||||
assert_completed,
|
||||
public_text,
|
||||
text_events,
|
||||
)
|
||||
from tests.api.test_hidden_stream_retries import delivered
|
||||
from tests.api.test_tool_call_buffer_transports import _tools
|
||||
from tests.providers.test_anthropic_messages_transport import Endpoint, _sse
|
||||
from tests.providers.test_anthropic_provider import native_body, provider
|
||||
from tests.providers.test_history_transports import _events_for, _harness
|
||||
from tests.providers.test_native_tool_arguments import tool_events
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def release_provider_events(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.providers.stream_recovery.RecoveryHoldbackBuffer",
|
||||
lambda: RecoveryHoldbackBuffer(max_bytes=1),
|
||||
)
|
||||
|
||||
|
||||
class Answer(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
value: int
|
||||
|
||||
|
||||
def output_format(kind):
|
||||
if kind != "json_schema":
|
||||
return {"type": kind}
|
||||
return {
|
||||
"type": kind,
|
||||
"name": "Answer",
|
||||
"schema": Answer.model_json_schema(),
|
||||
"strict": True,
|
||||
}
|
||||
|
||||
|
||||
def responses_stream(transport, protocol, **fields):
|
||||
options = {
|
||||
"response_model": "public-alias",
|
||||
"request_id": "text-contract",
|
||||
"reasoning": ReasoningPolicy.provider_default(),
|
||||
}
|
||||
if protocol == "messages":
|
||||
options["endpoint_context"] = Endpoint()
|
||||
else:
|
||||
options["input_tokens"] = 0
|
||||
return transport.stream_responses(
|
||||
OpenAIResponsesRequest(model="requested", input="Return value 1", **fields),
|
||||
**options,
|
||||
)
|
||||
|
||||
|
||||
async def consume_structured(raw, *, failed):
|
||||
http = httpx2.AsyncClient(
|
||||
transport=httpx2.MockTransport(
|
||||
lambda _: httpx2.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=raw.encode()
|
||||
)
|
||||
)
|
||||
)
|
||||
async with (
|
||||
AsyncOpenAI(
|
||||
api_key="fixture",
|
||||
base_url="https://consumer.invalid",
|
||||
max_retries=0,
|
||||
http_client=http,
|
||||
) as client,
|
||||
client.responses.stream(
|
||||
model="fixture", input="Return value 1", text_format=Answer
|
||||
) as stream,
|
||||
):
|
||||
if failed:
|
||||
with pytest.raises(RuntimeError, match=r"response\.completed"):
|
||||
await stream.get_final_response()
|
||||
else:
|
||||
response = await stream.get_final_response()
|
||||
assert response.output_parsed == Answer(value=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"protocol,kind",
|
||||
[
|
||||
("responses", "json_schema"),
|
||||
("responses", "json_object"),
|
||||
("chat", "json_schema"),
|
||||
("chat", "json_object"),
|
||||
("messages", "json_schema"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("stage", ["healthy", "hidden_retry", "delivered_failure"])
|
||||
async def test_structured_request_keeps_healthy_output_and_failure_policy(
|
||||
protocol, kind, stage
|
||||
):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1 and stage == "hidden_retry":
|
||||
return 200, []
|
||||
interrupted = len(bodies) == 1 and stage == "delivered_failure"
|
||||
# No echoed format: the request itself must protect this contract.
|
||||
return 200, text_events(
|
||||
protocol,
|
||||
'{"value":' if interrupted else '{"value":1}',
|
||||
complete=not interrupted,
|
||||
)
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=3) as (_, bodies, transport):
|
||||
raw = await delivered(
|
||||
responses_stream(transport, protocol, text={"format": output_format(kind)}),
|
||||
"responses",
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
failed = stage == "delivered_failure"
|
||||
assert len(bodies) == (2 if stage == "hidden_retry" else 1)
|
||||
assert public_text(events, "responses") == (
|
||||
'{"value":' if failed else '{"value":1}'
|
||||
)
|
||||
if failed:
|
||||
assert events[-1].event == "response.failed"
|
||||
assert not any(event.event == "response.completed" for event in events)
|
||||
if protocol != "chat":
|
||||
assert not any(
|
||||
event.event == "response.output_text.done" for event in events
|
||||
)
|
||||
else:
|
||||
assert_completed(events, "responses")
|
||||
# Chat's existing failure writer closes partial text before reporting failure.
|
||||
# Its typed-JSON parsing limitation predates delivered-output recovery.
|
||||
if not failed or protocol != "chat":
|
||||
await consume_structured(raw, failed=failed)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("protocol", ["chat", "responses", "messages"])
|
||||
async def test_explicit_plain_text_format_can_continue(protocol):
|
||||
async with _harness(
|
||||
protocol,
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events(
|
||||
protocol,
|
||||
"Hello " if len(bodies) == 1 else "world.",
|
||||
complete=len(bodies) > 1,
|
||||
),
|
||||
),
|
||||
) as (_, bodies, transport):
|
||||
raw = await delivered(
|
||||
responses_stream(transport, protocol, text={"format": {"type": "text"}}),
|
||||
"responses",
|
||||
)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(parse_sse_text(raw), "responses")
|
||||
assert public_text(parse_sse_text(raw), "responses") == "Hello world."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["native_messages", "translated_messages", "chat_extra"]
|
||||
)
|
||||
async def test_messages_structured_request_does_not_resume_partial_json(path):
|
||||
bodies = []
|
||||
body = {
|
||||
"model": "requested",
|
||||
"messages": [{"role": "user", "content": "Return value 1"}],
|
||||
"max_tokens": 64,
|
||||
}
|
||||
schema = {"type": "json_schema", "schema": Answer.model_json_schema()}
|
||||
if path == "chat_extra":
|
||||
body["extra_body"] = {
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
key: value
|
||||
for key, value in output_format("json_schema").items()
|
||||
if key != "type"
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
body["output_config"] = {"format": schema}
|
||||
if path == "native_messages":
|
||||
|
||||
def reply(request):
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=_sse(*text_events("messages", '{"value":', complete=False)),
|
||||
)
|
||||
|
||||
p = provider(reply)
|
||||
try:
|
||||
raw = await delivered(
|
||||
p.stream_native_messages(
|
||||
NativeMessagesRequest({**native_body(True), **body})
|
||||
),
|
||||
"messages",
|
||||
)
|
||||
finally:
|
||||
await p.cleanup()
|
||||
else:
|
||||
protocol = "chat" if path == "chat_extra" else "messages"
|
||||
async with _harness(
|
||||
protocol,
|
||||
lambda _: (200, text_events(protocol, '{"value":', complete=False)),
|
||||
max_attempts=2,
|
||||
) as (_, bodies, transport):
|
||||
options = {
|
||||
"response_model": "public-alias",
|
||||
"request_id": "text-contract",
|
||||
"reasoning": ReasoningPolicy.provider_default(),
|
||||
}
|
||||
if protocol == "messages":
|
||||
options["endpoint_context"] = Endpoint()
|
||||
else:
|
||||
options["input_tokens"] = 0
|
||||
raw = await delivered(
|
||||
transport.stream_messages(
|
||||
MessagesRequest.model_validate(body), **options
|
||||
),
|
||||
"messages",
|
||||
)
|
||||
assert len(bodies) == 1
|
||||
events = parse_sse_text(raw)
|
||||
assert public_text(events, "messages") == '{"value":'
|
||||
assert events[-1].event == "error"
|
||||
|
||||
|
||||
def scored_events(text, location, *, value=True):
|
||||
events = [deepcopy(event) for event in text_events("responses", text)]
|
||||
scores = (
|
||||
[
|
||||
{
|
||||
"token": text,
|
||||
"bytes": list(text.encode()),
|
||||
"logprob": -0.5,
|
||||
"top_logprobs": [],
|
||||
}
|
||||
]
|
||||
if value is True
|
||||
else value
|
||||
)
|
||||
targets = {
|
||||
"delta": events[3],
|
||||
"text_done": events[4],
|
||||
"part": events[5]["part"],
|
||||
"item": events[6]["item"]["content"][0],
|
||||
"terminal": events[7]["response"]["output"][0]["content"][0],
|
||||
}
|
||||
for key, target in targets.items():
|
||||
if location == "all" or location == key:
|
||||
target["logprobs"] = deepcopy(scores)
|
||||
return events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"continuing,location",
|
||||
[(False, location) for location in ("delta", "text_done", "part", "item")]
|
||||
+ [
|
||||
(True, location)
|
||||
for location in ("delta", "text_done", "part", "item", "terminal")
|
||||
],
|
||||
)
|
||||
async def test_preserved_logprobs_prevent_text_reconstruction(location, continuing):
|
||||
def reply(bodies):
|
||||
if continuing and len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
events = scored_events("Hello world." if continuing else "Hello ", location)
|
||||
if not continuing:
|
||||
events = events[
|
||||
: {"delta": 4, "text_done": 5, "part": 6, "item": 7}[location]
|
||||
]
|
||||
return 200, events
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "hello"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == (2 if continuing else 1)
|
||||
assert events[-1].event == "response.failed"
|
||||
assert public_text(events, "responses").startswith("Hello ")
|
||||
if not continuing:
|
||||
assert public_text(events, "responses") == "Hello "
|
||||
else:
|
||||
rejected_kind = {
|
||||
"delta": "response.output_text.delta",
|
||||
"text_done": "response.output_text.done",
|
||||
"part": "response.content_part.done",
|
||||
"item": "response.output_item.done",
|
||||
"terminal": "response.completed",
|
||||
}[location]
|
||||
assert not any(
|
||||
event.event == rejected_kind and event.data.get("output_index", 1) == 1
|
||||
for event in events
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("value", [None, []])
|
||||
async def test_empty_logprobs_do_not_block_recovery(value):
|
||||
async with _harness(
|
||||
"responses",
|
||||
lambda bodies: (
|
||||
200,
|
||||
text_events("responses", "Hello ", complete=False)
|
||||
if len(bodies) == 1
|
||||
else scored_events("Hello world.", "all", value=value),
|
||||
),
|
||||
) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "hello"}]), "responses"
|
||||
)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(parse_sse_text(raw), "responses")
|
||||
assert public_text(parse_sse_text(raw), "responses") == "Hello world."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_healthy_logprob_payloads_are_preserved():
|
||||
source = scored_events("Hello world.", "all")
|
||||
async with _harness("responses", lambda _: (200, source)) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "hello"}]), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 1
|
||||
assert_completed(events, "responses")
|
||||
by_kind = {event.event: event.data for event in events}
|
||||
assert by_kind["response.output_text.delta"]["logprobs"] == source[3]["logprobs"]
|
||||
assert events[-1].data["response"]["output"] == source[-1]["response"]["output"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("continuing", [False, True])
|
||||
async def test_echoed_structured_format_prevents_reconstruction(continuing):
|
||||
def reply(bodies):
|
||||
events = text_events(
|
||||
"responses",
|
||||
"Hello " if len(bodies) == 1 else "world.",
|
||||
complete=len(bodies) > 1,
|
||||
)
|
||||
if not continuing or len(bodies) > 1:
|
||||
events[0]["response"]["text"] = {"format": output_format("json_schema")}
|
||||
return 200, events
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "hello"}]), "responses"
|
||||
)
|
||||
assert len(bodies) == (2 if continuing else 1)
|
||||
assert parse_sse_text(raw)[-1].event == "response.failed"
|
||||
assert public_text(parse_sse_text(raw), "responses") == "Hello "
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("constraint", ["structured", "logprobs"])
|
||||
@pytest.mark.parametrize("later_reasoning", [False, True])
|
||||
async def test_complete_calls_do_not_bypass_unsupported_text_guard(
|
||||
constraint, later_reasoning
|
||||
):
|
||||
prefix = text_events("responses", '{"value":', complete=False)
|
||||
if constraint == "logprobs":
|
||||
prefix = scored_events("Hello ", "delta")[:4]
|
||||
if later_reasoning:
|
||||
reasoning = deepcopy(_events_for("responses")[1:-1])
|
||||
for event in reasoning:
|
||||
event["output_index"] = 1
|
||||
prefix += reasoning
|
||||
calls = deepcopy(tool_events("responses", '{"path":"file"}')[1:-1])
|
||||
for event in calls:
|
||||
event["output_index"] += 2 if later_reasoning else 1
|
||||
async with _harness(
|
||||
"responses", lambda _: (200, prefix + calls), max_attempts=1
|
||||
) as (_, bodies, transport):
|
||||
fields = {"tools": _tools("responses")}
|
||||
if constraint == "structured":
|
||||
fields["text"] = {"format": output_format("json_schema")}
|
||||
raw = await delivered(
|
||||
responses_stream(transport, "responses", **fields), "responses"
|
||||
)
|
||||
events = parse_sse_text(raw)
|
||||
assert len(bodies) == 1
|
||||
assert events[-1].event == "response.failed"
|
||||
assert not any(event.event == "response.output_text.done" for event in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"protocol,wire",
|
||||
[("responses", "messages"), ("chat", "messages"), ("chat", "responses")],
|
||||
)
|
||||
async def test_metadata_already_omitted_by_projection_does_not_block_recovery(
|
||||
protocol, wire
|
||||
):
|
||||
def reply(bodies):
|
||||
if protocol == "responses":
|
||||
events = scored_events("Hello " if len(bodies) == 1 else "world.", "all")
|
||||
return 200, events[:4] if len(bodies) == 1 else events
|
||||
events = text_events(
|
||||
"chat", "Hello " if len(bodies) == 1 else "world.", complete=len(bodies) > 1
|
||||
)
|
||||
text = events[0]["choices"][0]["delta"]["content"]
|
||||
events[0]["choices"][0]["logprobs"] = {
|
||||
"content": [
|
||||
{
|
||||
"token": text,
|
||||
"logprob": -0.5,
|
||||
"bytes": list(text.encode()),
|
||||
"top_logprobs": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
return 200, events
|
||||
|
||||
async with _harness(protocol, reply, max_attempts=2) as (send, bodies, _):
|
||||
raw = await delivered(send(wire, [{"role": "user", "content": "hello"}]), wire)
|
||||
assert len(bodies) == 2
|
||||
assert_completed(parse_sse_text(raw), wire)
|
||||
assert public_text(parse_sse_text(raw), wire) == "Hello world."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("constraint", ["structured", "logprobs"])
|
||||
async def test_failed_snapshot_keeps_its_error_category_with_text_metadata(constraint):
|
||||
def reply(bodies):
|
||||
if len(bodies) == 1:
|
||||
return 200, text_events("responses", "Hello ", complete=False)
|
||||
failed = scored_events("unused", "terminal")[-1]
|
||||
if constraint == "structured":
|
||||
failed["response"]["output"] = []
|
||||
failed["response"]["text"] = {"format": output_format("json_schema")}
|
||||
failed["type"] = "response.failed"
|
||||
failed["response"]["status"] = "failed"
|
||||
failed["response"]["error"] = {
|
||||
"code": "context_length_exceeded",
|
||||
"message": "The context window is full.",
|
||||
}
|
||||
return 200, [failed]
|
||||
|
||||
async with _harness("responses", reply, max_attempts=3) as (send, bodies, _):
|
||||
raw = await delivered(
|
||||
send("responses", [{"role": "user", "content": "hello"}]), "responses"
|
||||
)
|
||||
assert len(bodies) == 2
|
||||
events = parse_sse_text(raw)
|
||||
assert events[-1].event == "response.failed"
|
||||
assert events[-1].data["response"]["error"]["code"] == "context_length_exceeded"
|
||||
assert public_text(events, "responses") == "Hello "
|
||||
|
|
@ -8,10 +8,10 @@ import pytest
|
|||
import simplejson
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from free_claude_code.api.handlers.classifier_response import classifier_response
|
||||
from free_claude_code.api.response_streams import anthropic_sse_streaming_response
|
||||
from free_claude_code.core.anthropic.stream_contracts import parse_sse_text
|
||||
from free_claude_code.core.stream_delivery import current_stream_delivery
|
||||
from tests.api.test_response_streams import _serve
|
||||
from tests.api.test_response_streams import _json_error, _serve
|
||||
from tests.api.test_tool_call_buffer import _call, _end, _frames, _response, _start
|
||||
from tests.api.test_web_server_tools import (
|
||||
ScriptedSelectionProvider,
|
||||
|
|
@ -57,7 +57,7 @@ async def test_messages_start_is_published_only_with_committed_activity(ending):
|
|||
response = await _response("messages", source())
|
||||
try:
|
||||
iterator = aiter(response.body_iterator)
|
||||
assert await anext(iterator) == harmless
|
||||
assert str(await anext(iterator)) + str(await anext(iterator)) == harmless
|
||||
remaining = "".join([str(chunk) async for chunk in iterator])
|
||||
if ending in {"activity", "empty", "stop"}:
|
||||
assert remaining.startswith(start)
|
||||
|
|
@ -251,7 +251,16 @@ async def test_classifier_resets_projection_when_hidden_attempt_is_replaced():
|
|||
)
|
||||
)
|
||||
|
||||
output = await drained(classifier_response(source()), "messages")
|
||||
response = await anthropic_sse_streaming_response(
|
||||
source(),
|
||||
pre_start_error_response=_json_error,
|
||||
request_id="classifier-retry",
|
||||
hide_reasoning=True,
|
||||
)
|
||||
assert isinstance(response, StreamingResponse)
|
||||
output = b"".join(
|
||||
message.get("body", b"") for message in await _serve(response)
|
||||
).decode()
|
||||
assert "hidden" not in output
|
||||
assert "call_0" in output and "message_stop" in output
|
||||
assert '"index": 0' in output
|
||||
|
|
|
|||
|
|
@ -129,7 +129,10 @@ async def test_real_transport_cutoff_cannot_complete_partial_tool(protocol, wire
|
|||
)
|
||||
|
||||
async with _harness(
|
||||
protocol, lambda _: (200, events), chat_provider_factory=chat_provider
|
||||
protocol,
|
||||
lambda _: (200, events),
|
||||
chat_provider_factory=chat_provider,
|
||||
max_attempts=1,
|
||||
) as (send, bodies, _):
|
||||
response = await _response(
|
||||
wire, send(wire, [{"role": "user", "content": "read"}], tools=_tools(wire))
|
||||
|
|
|
|||
252
tests/core/test_continuation_overlap_policy.py
Normal file
252
tests/core/test_continuation_overlap_policy.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"""Independent answer partitions exercise the overlap policy and its bounds."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.continuation_overlap import ContinuationOverlap
|
||||
from free_claude_code.core.delivered_response import DeliveredResponse
|
||||
from free_claude_code.core.failures import ExecutionFailure
|
||||
from tests.api.test_continuation_overlap import responses_parts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wire", ["messages", "responses"])
|
||||
def test_empty_delta_stays_invisible_with_no_prior_answer_text(wire):
|
||||
event = (
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": ""},
|
||||
}
|
||||
if wire == "messages"
|
||||
else {
|
||||
"type": "response.output_text.delta",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"item_id": "msg_text",
|
||||
"delta": "",
|
||||
}
|
||||
)
|
||||
assert ContinuationOverlap("").feed(event) == []
|
||||
|
||||
|
||||
def retained_parts(prefix, parts):
|
||||
text = "".join(parts)
|
||||
overlap = max(
|
||||
size
|
||||
for size in range(min(len(prefix), len(text)) + 1)
|
||||
if prefix.endswith(text[:size])
|
||||
)
|
||||
result = []
|
||||
for part in parts:
|
||||
result.append(part[overlap:])
|
||||
overlap = max(0, overlap - len(part))
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prefix,parts",
|
||||
[
|
||||
("Hello ", ["He", "llo world."]),
|
||||
("abcabc", ["", "abc", "", "abcX"]),
|
||||
("abcabd", ["ab", "cX"]),
|
||||
("Hello 世界", ["世", "界!"]),
|
||||
("The answer is", ["answer", " is"]),
|
||||
("Hello ", ["He", "l"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"representation",
|
||||
[
|
||||
"deltas",
|
||||
"text_done",
|
||||
"part_done",
|
||||
"item_done",
|
||||
"terminal",
|
||||
"part_added",
|
||||
"item_added",
|
||||
"mixed",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("separate_items", [False, True])
|
||||
def test_text_representation_preserves_each_source_part(
|
||||
prefix, parts, representation, separate_items
|
||||
):
|
||||
source = responses_parts(
|
||||
parts, representation=representation, separate_items=separate_items
|
||||
)
|
||||
original = deepcopy(source)
|
||||
overlap = ContinuationOverlap(prefix)
|
||||
events = [ready for event in source for ready in overlap.feed(event)]
|
||||
expected = retained_parts(prefix, parts)
|
||||
output = events[-1]["response"]["output"]
|
||||
assert [part["text"] for item in output for part in item["content"]] == expected
|
||||
assert source == original
|
||||
finals = {item["id"]: item for item in output}
|
||||
deltas = {}
|
||||
for event in events:
|
||||
kind = event["type"]
|
||||
if kind == "response.output_text.delta":
|
||||
key = (event["item_id"], event["content_index"])
|
||||
deltas[key] = deltas.get(key, "") + event["delta"]
|
||||
elif kind == "response.output_text.done":
|
||||
assert (
|
||||
event["text"]
|
||||
== finals[event["item_id"]]["content"][event["content_index"]]["text"]
|
||||
)
|
||||
elif kind == "response.content_part.done":
|
||||
assert (
|
||||
event["part"]
|
||||
== finals[event["item_id"]]["content"][event["content_index"]]
|
||||
)
|
||||
elif kind == "response.output_item.done":
|
||||
assert event["item"] == finals[event["item"]["id"]]
|
||||
if representation == "deltas":
|
||||
for item in output:
|
||||
for position, part in enumerate(item["content"]):
|
||||
assert deltas.get((item["id"], position), "") == part["text"]
|
||||
|
||||
|
||||
def test_pending_snapshots_are_idempotent_and_lifecycle_frames_keep_order():
|
||||
source = responses_parts(["He", "llo world."], representation="text_done")
|
||||
source.insert(4, deepcopy(source[3]))
|
||||
source.insert(5, {"type": "ping", "marker": "between repeated snapshots"})
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
released = []
|
||||
for index, event in enumerate(source):
|
||||
ready = overlap.feed(event)
|
||||
if 3 <= index <= 6:
|
||||
assert ready == []
|
||||
released.extend(ready)
|
||||
assert [event["type"] for event in released] == [event["type"] for event in source]
|
||||
assert [
|
||||
part["text"] for part in released[-1]["response"]["output"][0]["content"]
|
||||
] == ["", "world."]
|
||||
|
||||
|
||||
def test_interleaved_parts_wait_for_the_earlier_open_part():
|
||||
source = responses_parts(["He", "llo world."], representation="text_done")
|
||||
earlier_done = source[3:5]
|
||||
later = source[5:8]
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
released = []
|
||||
for event in [*source[:3], *later]:
|
||||
ready = overlap.feed(event)
|
||||
if event in later and event["type"].endswith(".done"):
|
||||
assert ready == []
|
||||
released.extend(ready)
|
||||
for event in [*earlier_done, *source[8:]]:
|
||||
released.extend(overlap.feed(event))
|
||||
assert released[-1]["response"]["output"][0]["content"] == [
|
||||
{"type": "output_text", "text": "", "annotations": []},
|
||||
{"type": "output_text", "text": "world.", "annotations": []},
|
||||
]
|
||||
assert [
|
||||
event["content_index"]
|
||||
for event in released
|
||||
if event["type"] == "response.output_text.done"
|
||||
] == [1, 0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["prefix", "order", "insert"])
|
||||
def test_late_snapshot_cannot_rewrite_a_decided_prefix(change):
|
||||
source = responses_parts(
|
||||
["He", "llo world."], representation="text_done", separate_items=True
|
||||
)
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
for event in source[:-1]:
|
||||
overlap.feed(event)
|
||||
terminal = source[-1]
|
||||
items = terminal["response"]["output"]
|
||||
if change == "prefix":
|
||||
items[0]["content"][0]["text"] = "Ha"
|
||||
elif change == "order":
|
||||
items.reverse()
|
||||
else:
|
||||
extra = deepcopy(items[0])
|
||||
extra["id"] = "msg_late"
|
||||
items.insert(0, extra)
|
||||
with pytest.raises(ExecutionFailure, match="observed answer prefix"):
|
||||
overlap.feed(terminal)
|
||||
|
||||
|
||||
def test_terminal_cannot_omit_pending_source_text():
|
||||
source = responses_parts(["He"], representation="text_done")
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
for event in source[:-1]:
|
||||
overlap.feed(event)
|
||||
source[-1]["response"]["output"] = []
|
||||
with pytest.raises(ExecutionFailure, match="observed answer prefix"):
|
||||
overlap.feed(source[-1])
|
||||
|
||||
|
||||
def test_unresolved_frames_are_bounded_and_discarded_on_failure(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.continuation_overlap.RECOVERY_RETENTION_BYTES", 1000
|
||||
)
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
source = responses_parts(["He"], representation="text_done")
|
||||
for event in source[:4]:
|
||||
overlap.feed(event)
|
||||
with pytest.raises(ExecutionFailure, match="retained-data limit"):
|
||||
for _ in range(30):
|
||||
overlap.feed({"type": "ping", "padding": "x" * 60})
|
||||
failure = {"type": "response.failed", "response": {"output": []}}
|
||||
assert overlap.feed(failure) == [failure]
|
||||
|
||||
|
||||
def test_large_immediately_resolvable_snapshot_does_not_fill_the_pending_buffer(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.continuation_overlap.RECOVERY_RETENTION_BYTES", 300
|
||||
)
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
source = responses_parts(["Hello " + "x" * 10000], representation="terminal")
|
||||
events = [ready for event in source for ready in overlap.feed(event)]
|
||||
assert events[-1]["response"]["output"][0]["content"][0]["text"] == "x" * 10000
|
||||
|
||||
|
||||
def test_resolved_overlap_does_not_retain_unaffected_later_items(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.continuation_overlap.RECOVERY_RETENTION_BYTES", 400
|
||||
)
|
||||
overlap = ContinuationOverlap("Hello ")
|
||||
source = responses_parts(["Hello world."], representation="text_done")
|
||||
for event in source[:-1]:
|
||||
overlap.feed(event)
|
||||
for index in range(1, 1000):
|
||||
event = {
|
||||
"type": "response.output_item.done",
|
||||
"output_index": index,
|
||||
"item": {
|
||||
"id": f"msg_{index}",
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": "More."}],
|
||||
},
|
||||
}
|
||||
assert overlap.feed(event) == [event]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"surface", ["item_added", "part_added", "text_done", "part_done", "item_done"]
|
||||
)
|
||||
def test_snapshot_only_text_counts_as_new_public_progress_once(surface):
|
||||
response = DeliveredResponse("responses")
|
||||
source = responses_parts(["Novel"], representation=surface)
|
||||
kind = {
|
||||
"item_added": "response.output_item.added",
|
||||
"part_added": "response.content_part.added",
|
||||
"text_done": "response.output_text.done",
|
||||
"part_done": "response.content_part.done",
|
||||
"item_done": "response.output_item.done",
|
||||
}[surface]
|
||||
target = next(event for event in source if event["type"] == kind)
|
||||
if surface != "item_added":
|
||||
response.observe(source[1])
|
||||
initial = response.progress
|
||||
response.observe(target)
|
||||
assert response.progress > initial
|
||||
released = response.progress
|
||||
response.observe(target)
|
||||
assert response.progress == released
|
||||
205
tests/core/test_delivered_response_lifecycle.py
Normal file
205
tests/core/test_delivered_response_lifecycle.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Published completion stages determine the remaining recovery boundary."""
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.continuation_stream import ContinuationStream
|
||||
from free_claude_code.core.delivered_response import DeliveredResponse
|
||||
|
||||
|
||||
@pytest.mark.parametrize("summary", [False, True])
|
||||
@pytest.mark.parametrize("closed", ["none", "text", "part", "item"])
|
||||
def test_readable_reasoning_closes_only_missing_stages(summary, closed):
|
||||
response = DeliveredResponse("responses")
|
||||
field = "summary" if summary else "content"
|
||||
part = {"type": "summary_text" if summary else "reasoning_text", "text": "Reason."}
|
||||
item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_first",
|
||||
"status": "in_progress",
|
||||
field: [part],
|
||||
}
|
||||
response.observe(
|
||||
{"type": "response.output_item.added", "output_index": 0, "item": item}
|
||||
)
|
||||
identity = {
|
||||
"output_index": 0,
|
||||
"item_id": "rs_first",
|
||||
"summary_index" if summary else "content_index": 0,
|
||||
}
|
||||
text_kind = (
|
||||
"response.reasoning_summary_text.done"
|
||||
if summary
|
||||
else "response.reasoning_text.done"
|
||||
)
|
||||
part_kind = (
|
||||
"response.reasoning_summary_part.done"
|
||||
if summary
|
||||
else "response.content_part.done"
|
||||
)
|
||||
if closed in {"text", "part", "item"}:
|
||||
response.observe({"type": text_kind, **identity, "text": "Reason."})
|
||||
if closed in {"part", "item"}:
|
||||
response.observe({"type": part_kind, **identity, "part": part})
|
||||
if closed == "item":
|
||||
response.observe(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": {**item, "status": "completed"},
|
||||
}
|
||||
)
|
||||
expected = (
|
||||
([] if closed != "none" else [text_kind])
|
||||
+ ([part_kind] if summary and closed in {"none", "text"} else [])
|
||||
+ ([] if closed == "item" else ["response.output_item.done"])
|
||||
)
|
||||
events = response.closing_events()
|
||||
assert [event["type"] for event in events] == expected
|
||||
assert response.closing_events() == events
|
||||
for event in events:
|
||||
response.observe(event)
|
||||
assert response.closing_events() == []
|
||||
|
||||
|
||||
def test_finished_part_does_not_require_another_text_done():
|
||||
response = DeliveredResponse("responses")
|
||||
part = {"type": "output_text", "text": "Done."}
|
||||
response.observe(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {"type": "message", "id": "msg_first", "content": [part]},
|
||||
}
|
||||
)
|
||||
response.observe(
|
||||
{
|
||||
"type": "response.content_part.done",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"part": part,
|
||||
}
|
||||
)
|
||||
assert [event["type"] for event in response.closing_events()] == [
|
||||
"response.output_item.done"
|
||||
]
|
||||
|
||||
|
||||
def test_empty_blocks_and_usage_updates_do_not_finish_overlap():
|
||||
response = DeliveredResponse("messages")
|
||||
response.observe(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": "Hello "},
|
||||
}
|
||||
)
|
||||
continuation = ContinuationStream(response)
|
||||
continuation.begin(handoff=False)
|
||||
for event in continuation.boundary():
|
||||
response.observe(event)
|
||||
continuation.prepare(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
}
|
||||
)
|
||||
assert (
|
||||
continuation.prepare(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": ""},
|
||||
}
|
||||
)
|
||||
== []
|
||||
)
|
||||
continuation.prepare({"type": "content_block_stop", "index": 0})
|
||||
continuation.prepare(
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": None},
|
||||
"usage": {"output_tokens": 1},
|
||||
}
|
||||
)
|
||||
assert (
|
||||
continuation.prepare(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "text_delta", "text": "l"},
|
||||
}
|
||||
)
|
||||
== []
|
||||
)
|
||||
events = continuation.prepare(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "text_delta", "text": "o world."},
|
||||
}
|
||||
)
|
||||
assert events[0]["delta"]["text"] == "world."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", ["added", "done"])
|
||||
def test_refusal_content_without_deltas_counts_as_progress(seed):
|
||||
response = DeliveredResponse("responses")
|
||||
response.observe(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {"type": "message", "id": "msg_refusal", "content": []},
|
||||
}
|
||||
)
|
||||
initial = response.progress
|
||||
response.observe(
|
||||
{
|
||||
"type": f"response.content_part.{seed}",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"part": {"type": "refusal", "refusal": "Cannot continue."},
|
||||
}
|
||||
)
|
||||
assert response.progress > initial
|
||||
assert not response.snapshot().eligible
|
||||
assert not response.can_handoff
|
||||
|
||||
|
||||
def test_handoff_fallback_preserves_already_closed_item_status(monkeypatch):
|
||||
response = DeliveredResponse("responses")
|
||||
response.observe(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "message",
|
||||
"id": "msg_closed",
|
||||
"status": "incomplete",
|
||||
"content": [],
|
||||
},
|
||||
}
|
||||
)
|
||||
response.observe(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 1,
|
||||
"item": {
|
||||
"type": "message",
|
||||
"id": "msg_open",
|
||||
"status": "in_progress",
|
||||
"content": [{"type": "output_text", "text": "Next."}],
|
||||
},
|
||||
}
|
||||
)
|
||||
continuation = ContinuationStream(response)
|
||||
continuation.begin(handoff=True)
|
||||
monkeypatch.setattr(
|
||||
"free_claude_code.core.delivered_response.RECOVERY_RETENTION_BYTES", 1
|
||||
)
|
||||
for event in continuation.boundary():
|
||||
response.observe(event)
|
||||
result = continuation.finalize(
|
||||
continuation.prepare(response.handoff_events()[0])[0]
|
||||
)["response"]
|
||||
assert [item["status"] for item in result["output"]] == ["incomplete", "completed"]
|
||||
121
tests/core/test_recovery_reasoning_policy.py
Normal file
121
tests/core/test_recovery_reasoning_policy.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Public replay carriers retain the recovery restrictions of their native state."""
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.core.delivered_response import DeliveredResponse
|
||||
from free_claude_code.core.history_replay import (
|
||||
ReplayOrigin,
|
||||
ReplayRecord,
|
||||
encode_replay,
|
||||
)
|
||||
|
||||
|
||||
def carrier(protocol, native):
|
||||
return encode_replay(
|
||||
ReplayRecord(ReplayOrigin("test", protocol, "", "", "model"), native)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ["responses", "signature", "redacted"])
|
||||
@pytest.mark.parametrize(
|
||||
("value", "eligible"),
|
||||
[
|
||||
(carrier("responses", {"type": "reasoning", "summary": []}), True),
|
||||
(
|
||||
carrier(
|
||||
"responses",
|
||||
{"type": "reasoning", "summary": [], "encrypted_content": None},
|
||||
),
|
||||
True,
|
||||
),
|
||||
(
|
||||
carrier(
|
||||
"responses",
|
||||
{"type": "reasoning", "summary": [], "encrypted_content": ""},
|
||||
),
|
||||
True,
|
||||
),
|
||||
(
|
||||
carrier(
|
||||
"responses",
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "Readable."}],
|
||||
"encrypted_content": "cipher",
|
||||
},
|
||||
),
|
||||
False,
|
||||
),
|
||||
(
|
||||
carrier(
|
||||
"messages",
|
||||
{"type": "thinking", "thinking": "Readable.", "signature": "signed"},
|
||||
),
|
||||
False,
|
||||
),
|
||||
(
|
||||
carrier(
|
||||
"chat",
|
||||
{
|
||||
"reasoning_details": [
|
||||
{"type": "reasoning.text", "text": "Readable."}
|
||||
]
|
||||
},
|
||||
),
|
||||
False,
|
||||
),
|
||||
("native-ciphertext", False),
|
||||
("fcc:history:v1:malformed", False),
|
||||
("fcc:history:v99:future", False),
|
||||
],
|
||||
ids=[
|
||||
"absent",
|
||||
"null",
|
||||
"empty",
|
||||
"encrypted",
|
||||
"signed",
|
||||
"structured",
|
||||
"raw",
|
||||
"malformed",
|
||||
"unknown-version",
|
||||
],
|
||||
)
|
||||
def test_public_carriers_follow_native_reasoning_semantics(surface, value, eligible):
|
||||
delivered = DeliveredResponse("responses" if surface == "responses" else "messages")
|
||||
if surface == "responses":
|
||||
delivered.observe(
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "reasoning",
|
||||
"id": "rs_public",
|
||||
"summary": [],
|
||||
"encrypted_content": value,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif surface == "signature":
|
||||
delivered.observe(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "thinking", "thinking": "Readable."},
|
||||
}
|
||||
)
|
||||
delivered.observe(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "signature_delta", "signature": value},
|
||||
}
|
||||
)
|
||||
else:
|
||||
delivered.observe(
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"content_block": {"type": "redacted_thinking", "data": value},
|
||||
}
|
||||
)
|
||||
assert delivered.snapshot().eligible is eligible
|
||||
69
tests/providers/test_continuation_request.py
Normal file
69
tests/providers/test_continuation_request.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Continuation tails are replaceable without rolling back accepted corrections."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from free_claude_code.providers.continuation import ContinuationRequest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
@pytest.mark.parametrize("initial_text", ["", "First "])
|
||||
def test_next_continuation_preserves_corrected_history_and_options(
|
||||
protocol, initial_text
|
||||
):
|
||||
field = "input" if protocol == "responses" else "messages"
|
||||
user_turn = {"role": "user", "content": "Continue"}
|
||||
body = {
|
||||
field: [
|
||||
{"role": "assistant", "content": "An old answer"},
|
||||
user_turn,
|
||||
],
|
||||
"max_tokens": 100,
|
||||
"reasoning": {"effort": "none"},
|
||||
"extra_body": {"retained": True, "rejected": True},
|
||||
"temperature": 0.5,
|
||||
}
|
||||
original = deepcopy(body)
|
||||
request = ContinuationRequest(protocol)
|
||||
first = request.build(body, initial_text, "First thought.")
|
||||
assert body == original
|
||||
corrected = deepcopy(first)
|
||||
# An accepted correction can remove a historical row and change request options.
|
||||
del corrected[field][0]
|
||||
del corrected["reasoning"]
|
||||
del corrected["extra_body"]["rejected"]
|
||||
del corrected["temperature"]
|
||||
corrected["max_tokens"] = 200
|
||||
before = deepcopy(corrected)
|
||||
second = request.build(corrected, "First answer.", "First thought. Second thought.")
|
||||
assert corrected == before
|
||||
assert second[field][:-2] == [user_turn]
|
||||
assert second["max_tokens"] == 200
|
||||
assert second["extra_body"] == {"retained": True}
|
||||
assert "reasoning" not in second and "temperature" not in second
|
||||
assert "First answer." in str(second[field][-2])
|
||||
assert "First thought. Second thought." in str(second[field][-1])
|
||||
assert request.build(second, "First answer. More.", "")[field][:-2] == [user_turn]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol", ["chat", "messages", "responses"])
|
||||
def test_original_turns_matching_private_instruction_are_preserved(protocol):
|
||||
field = "input" if protocol == "responses" else "messages"
|
||||
instruction_turn = ContinuationRequest(protocol).build({field: []}, "", "")[field][
|
||||
-1
|
||||
]
|
||||
body = {field: [instruction_turn, {"role": "user", "content": "Continue"}]}
|
||||
original = deepcopy(body)
|
||||
request = ContinuationRequest(protocol)
|
||||
first = request.build(body, "One", "")
|
||||
second = request.build(first, "One two", "")
|
||||
assert second[field][:-2] == original[field]
|
||||
assert body == original
|
||||
|
||||
|
||||
def test_responses_string_input_is_preserved_across_continuations():
|
||||
request = ContinuationRequest("responses")
|
||||
first = request.build({"input": "Original prompt"}, "", "A thought.")
|
||||
second = request.build(first, "Answer", "A thought.")
|
||||
assert second["input"][:-2] == [{"role": "user", "content": "Original prompt"}]
|
||||
|
|
@ -286,7 +286,9 @@ async def test_hidden_normal_stop_prevents_request_corrections(
|
|||
if public:
|
||||
options = {"pre_start_error_response": _json_error, "request_id": "stopped"}
|
||||
response = await (
|
||||
anthropic_sse_streaming_response(source, **options)
|
||||
anthropic_sse_streaming_response(
|
||||
source, pre_start_error_response=_json_error, request_id="stopped"
|
||||
)
|
||||
if wire == "messages"
|
||||
else openai_responses_sse_streaming_response(
|
||||
source, headers={}, **options
|
||||
|
|
|
|||
|
|
@ -379,7 +379,9 @@ async def test_stopped_first_nim_chunk_returns_public_error_and_releases_admissi
|
|||
source = _public_stream(provider, wire)
|
||||
async with asyncio.timeout(2):
|
||||
response = await (
|
||||
anthropic_sse_streaming_response(source, **options)
|
||||
anthropic_sse_streaming_response(
|
||||
source, pre_start_error_response=_json_error, request_id="raw-stop"
|
||||
)
|
||||
if wire == "messages"
|
||||
else openai_responses_sse_streaming_response(
|
||||
source, headers={}, **options
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue