fix(providers): review round 7 — id-disciplined slots, all-lane finish tolerance, retry backoff

Correctness:
- ToolCallSlotter: a slot whose id is KNOWN never splits on an id-less
  delta — on an id-disciplined server new calls arrive with ids, so an
  id-less fragment (the call's FIRST name announcement included) is
  always a continuation. Round-6 regression: {id} → {name} → {args}
  emission split into an unnamed id-bearing call plus a nameless twin.
  Also: a name arriving for a slot with no name yet never splits
  (args-first emission), and a bare same-name delta after complete
  arguments merges as a redundant footer instead of minting a phantom
  zero-argument call that would re-run a side-effecting tool.
- finish_reason_optional is honored on every drained lane, not just
  Chat Completions: Anthropic shims a missing message_delta
  stop_reason + message_stop pair, Responses a missing terminal event
  (both with collected blocks riding the shimmed finish) — the
  documented capabilities-JSON remediation now works on the
  anthropic-compatible/responses-compat gateways it was written for,
  matching the retired non-streaming paths' tolerance.
- Responses: an in-band error/response.failed frame arriving AFTER the
  terminal event is teardown noise — log and end the stream instead of
  raising away a generation already in hand (the in-band twin of
  drain_stream's post-finish transport-blip tolerance).
- model_turn drain retries pace like the SDK request retry they
  replace: 0.5s base, doubling, ±50% jitter — instant re-issues
  re-hit the still-active rate limit/overload and synchronize into
  fleet-scale retry bursts.
- Responses slot bookkeeping survives lax servers: slots minted by a
  counter (len(dict) collided calls after a duplicate/empty item-id
  overwrite), orphan argument deltas route to the most recently
  announced call instead of hardwired slot 0.

Cleanup:
- on_tool_call_delta now receives the normalized ToolCallDelta plus the
  raw SDK delta — Google's capture accumulates the exact bytes the
  mirror sees (the byte-identical extraction no longer exists twice).
- _ArgsScanner feeds only fully id-less slots (its verdict is never
  consulted for id'd slots — dominant-case hot path).
- Anthropic retryable set hoisted to a class constant (per-access
  frozenset allocation, same pattern already fixed on Responses).
- GoogleProvider class docstring names the hook-based capture instead
  of the deleted _extract_tool_calls override.
This commit is contained in:
Patrick Buckley
2026-07-13 20:47:09 -07:00
parent 16647db1b0
commit 8fa0e7a29e
10 changed files with 476 additions and 101 deletions
+13 -12
View File
@@ -41,18 +41,19 @@ Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
such a server already couldn't serve turnstone's chat loop, but a
judge/utility alias pointed at one worked on 1.7 and needs to move to
a current server. Transient mid-stream deaths (connection drop, proxy
hiccup) are re-issued in place up to twice — the retry the SDK's
request loop used to provide these lanes invisibly. Each lane accepts
its own terminal marker (Anthropic `message_stop`, Responses terminal
events); on the Chat Completions lane a server that never sends
`finish_reason` at all needs `{"finish_reason_optional": true}` in the
model definition's capabilities JSON, which restores 1.7's tolerance
(clean end-of-stream after output = completion) for that model —
without it such streams fail as died-mid-generation, because SSE gives
no way to tell the two apart and the default favors catching
truncation. The unread `supports_streaming` capability flag (and its
admin tile) is gone; the o-series models it described are dropped from
the capability table entirely (see Removed).
hiccup) are re-issued in place up to twice with exponential backoff —
the retry the SDK's request loop used to provide these lanes
invisibly. Each lane accepts its own terminal marker (Anthropic
`message_stop`, Responses terminal events); a lax server/gateway that
never sends any terminal signal needs
`{"finish_reason_optional": true}` in the model definition's
capabilities JSON, which restores 1.7's tolerance (clean end-of-stream
after output = completion) for that model on every lane — without it
such streams fail as died-mid-generation, because SSE gives no way to
tell the two apart and the default favors catching truncation. The
unread `supports_streaming` capability flag (and its admin tile) is
gone; the o-series models it described are dropped from the capability
table entirely (see Removed).
- **One turn interface for every model call: `core/model_turn.py` (#827).**
Judges (intent + output guard), perception, title generation, compaction,
+10 -5
View File
@@ -222,7 +222,7 @@ class FakeAnthropicBlock:
def fake_anthropic_stream(
blocks: list[Any],
*,
stop_reason: str = "end_turn",
stop_reason: str | None = "end_turn",
usage: Any = None,
) -> Any:
"""Fake Anthropic SDK stream context manager for tests that drive the
@@ -240,6 +240,10 @@ def fake_anthropic_stream(
``stop_reason`` (+ optional usage object). Without the stripping, the
provider's raw-block accumulator would double every text/thinking
field (start capture + delta append).
``stop_reason=None`` omits the closing ``message_delta`` entirely —
the terminal-signal-less lax-gateway shape ``finish_reason_optional``
exists for (content arrives, then the stream just ends).
"""
events: list[Any] = []
for idx, block in enumerate(blocks):
@@ -295,11 +299,12 @@ def fake_anthropic_stream(
)
)
events.append(SimpleNamespace(type="content_block_stop", index=idx))
events.append(
SimpleNamespace(
type="message_delta", usage=usage, delta=SimpleNamespace(stop_reason=stop_reason)
if stop_reason is not None or usage is not None:
events.append(
SimpleNamespace(
type="message_delta", usage=usage, delta=SimpleNamespace(stop_reason=stop_reason)
)
)
)
mgr = MagicMock()
mgr.__enter__ = MagicMock(return_value=events)
+29 -2
View File
@@ -103,10 +103,11 @@ class _FlakyProvider:
return _iter()
def test_model_turn_retries_transient_mid_stream_death() -> None:
def test_model_turn_retries_transient_mid_stream_death(monkeypatch: pytest.MonkeyPatch) -> None:
# The retired non-streaming transport read the whole body inside the
# SDK's retried request, so single-shot lanes never saw a mid-body wire
# blip — the drain-scoped loop is that retry's new home.
monkeypatch.setattr("turnstone.core.model_turn._DRAIN_RETRY_BASE_DELAY", 0.0)
provider = _FlakyProvider(
[
IncompleteStreamError("stream died mid-response"),
@@ -121,7 +122,8 @@ def test_model_turn_retries_transient_mid_stream_death() -> None:
assert len(provider.calls) == 2
def test_model_turn_gives_up_after_retry_budget() -> None:
def test_model_turn_gives_up_after_retry_budget(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("turnstone.core.model_turn._DRAIN_RETRY_BASE_DELAY", 0.0)
provider = _FlakyProvider([IncompleteStreamError(f"death {i}") for i in range(5)])
lane = ModelLane(provider=provider, client=object(), model="m")
@@ -132,6 +134,31 @@ def test_model_turn_gives_up_after_retry_budget() -> None:
assert len(provider.calls) == 3
def test_model_turn_retry_backs_off_between_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
# Instant re-issues are guaranteed to re-hit a still-active rate
# limit/overload — the loop paces like the SDK request retry it
# replaces: 0.5s base, doubling, ±50% jitter.
import turnstone.core.model_turn as model_turn_mod
sleeps: list[float] = []
monkeypatch.setattr(model_turn_mod, "time", SimpleNamespace(sleep=sleeps.append))
provider = _FlakyProvider(
[
IncompleteStreamError("death 1"),
IncompleteStreamError("death 2"),
CompletionResult(content="ok"),
]
)
lane = ModelLane(provider=provider, client=object(), model="m")
result = model_turn(lane, [Turn.user("x")])
assert result.content == "ok"
assert len(sleeps) == 2
assert 0.25 <= sleeps[0] <= 0.75 # 0.5 * jitter[0.5, 1.5)
assert 0.5 <= sleeps[1] <= 1.5 # 1.0 * jitter[0.5, 1.5)
def test_model_turn_does_not_retry_unrecognized_errors() -> None:
provider = _FlakyProvider([RuntimeError("schema violation")])
lane = ModelLane(provider=provider, client=object(), model="m")
+202 -2
View File
@@ -967,6 +967,49 @@ class TestOpenAIProvider:
assert [tc["function"]["name"] for tc in result.tool_calls] == ["refresh_state", "read"]
assert result.tool_calls[1]["function"]["arguments"] == '{"p": "x"}'
def test_id_first_fragmented_call_stays_one_call(self) -> None:
# id → name → args across three fragments (the later two id-less):
# a slot with a KNOWN id never splits on id-less continuations —
# the call's first name fragment is not a re-announcement (round-7
# regression: it split into an unnamed id-bearing call plus a
# nameless-id twin).
result = self._drain_chunks(
[
_openai_stream_chunk(tool_calls=[_openai_tool_call_delta(index=0, tc_id="call_1")]),
_openai_stream_chunk(
tool_calls=[_openai_tool_call_delta(index=0, name="get_weather")]
),
_openai_stream_chunk(
tool_calls=[_openai_tool_call_delta(index=0, arguments='{"city": "x"}')]
),
_openai_stream_chunk(finish_reason="tool_calls"),
]
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0]["id"] == "call_1"
assert result.tool_calls[0]["function"]["name"] == "get_weather"
assert result.tool_calls[0]["function"]["arguments"] == '{"city": "x"}'
def test_idless_bare_name_footer_after_complete_args_merges(self) -> None:
# A bare same-name delta after the argument JSON closed is a
# redundant footer, not a second zero-argument call — splitting
# would run the side-effecting tool twice.
result = self._drain_chunks(
[
_openai_stream_chunk(
tool_calls=[
_openai_tool_call_delta(index=0, name="write_file", arguments='{"x": 1}')
]
),
_openai_stream_chunk(
tool_calls=[_openai_tool_call_delta(index=0, name="write_file")]
),
_openai_stream_chunk(finish_reason="tool_calls"),
]
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0]["function"]["arguments"] == '{"x": 1}'
def test_idless_name_first_then_args_with_repeated_name_merges(self) -> None:
# Name announced first (no arguments), then arguments arrive
# carrying the SAME name again: one call whose arguments are
@@ -1329,6 +1372,51 @@ class TestAnthropicProvider:
assert result.tool_calls is None
assert result.finish_reason == "stop"
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_terminal_signal_less_stream_raises_by_default(self, mock_ensure: MagicMock) -> None:
# No message_delta stop_reason and no message_stop: on a
# signal-disciplined server (the real API always sends both) this
# is a generation that died mid-response — the drain refuses to
# bless possibly-truncated content.
from turnstone.core.providers import IncompleteStreamError
client = MagicMock()
client.messages.stream.return_value = fake_anthropic_stream(
[SimpleNamespace(type="text", text="full answer")], stop_reason=None
)
with pytest.raises(IncompleteStreamError):
drain_stream(
self.provider.create_streaming(
client=client,
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hi"}],
)
)
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_terminal_signal_less_stream_completes_with_declared_tolerance(
self, mock_ensure: MagicMock
) -> None:
# ``finish_reason_optional`` (operator-declared: this gateway never
# sends terminal signals) restores the retired non-streaming
# path's absent-stop_reason tolerance — the raw blocks ride the
# shimmed finish chunk.
client = MagicMock()
client.messages.stream.return_value = fake_anthropic_stream(
[SimpleNamespace(type="text", text="full answer")], stop_reason=None
)
result = drain_stream(
self.provider.create_streaming(
client=client,
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hi"}],
capabilities=ModelCapabilities(finish_reason_optional=True),
)
)
assert result.content == "full answer"
assert result.finish_reason == "stop"
assert result.provider_blocks
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
def test_drained_stream_with_tool_use(self, mock_ensure: MagicMock) -> None:
client = MagicMock()
@@ -5472,12 +5560,15 @@ class TestResponsesDrainedStream:
)
return events
def _drain(self, events: list[Any]):
def _drain(self, events: list[Any], capabilities: ModelCapabilities | None = None):
client = MagicMock()
client.responses.create.return_value = events
return drain_stream(
self.provider.create_streaming(
client=client, model="gpt-5.1", messages=[{"role": "user", "content": "hi"}]
client=client,
model="gpt-5.1",
messages=[{"role": "user", "content": "hi"}],
capabilities=capabilities,
)
)
@@ -5512,6 +5603,115 @@ class TestResponsesDrainedStream:
assert result.usage is not None
assert result.provider_blocks
def test_terminal_event_less_stream_raises_by_default(self) -> None:
# No response.completed/incomplete ever arrived: on an
# event-disciplined server this is a generation that died
# mid-response — the drain refuses to bless possibly-truncated
# content.
from turnstone.core.providers import IncompleteStreamError
events = self._make_events(text="Hello")[:-1] # drop the terminal event
with pytest.raises(IncompleteStreamError):
self._drain(events)
def test_terminal_event_less_stream_completes_with_declared_tolerance(self) -> None:
# ``finish_reason_optional`` (operator-declared: this server never
# sends terminal events) completes the clean output-bearing end —
# the .done-collected blocks ride the shimmed finish chunk.
events = self._make_events(text="Hello")[:-1]
result = self._drain(events, capabilities=ModelCapabilities(finish_reason_optional=True))
assert result.content == "Hello"
assert result.finish_reason == "stop"
assert result.provider_blocks
def test_post_terminal_error_event_keeps_completed_result(self) -> None:
# A trailing in-band error frame after response.completed is
# teardown noise — raising would discard a generation already in
# hand (the in-band twin of drain_stream's post-finish
# transport-blip tolerance).
events = self._make_events(text="Hello")
events.append(SimpleNamespace(type="error", code="server_error", message="boom"))
result = self._drain(events)
assert result.content == "Hello"
assert result.finish_reason == "stop"
def test_post_terminal_failed_event_keeps_completed_result(self) -> None:
events = self._make_events(text="Hello")
events.append(
SimpleNamespace(
type="response.failed",
response=SimpleNamespace(
error=SimpleNamespace(code="server_error", message="boom")
),
)
)
result = self._drain(events)
assert result.content == "Hello"
assert result.finish_reason == "stop"
def test_orphan_argument_deltas_route_to_last_announced_call(self) -> None:
# A lax server whose argument deltas reference an item_id that was
# never announced: they belong to the call most recently opened,
# not hardwired slot 0.
item_a = SimpleNamespace(type="function_call", call_id="call_a", id="item_a", name="alpha")
item_a.model_dump = lambda **_kw: { # type: ignore[method-assign]
"type": "function_call",
"call_id": "call_a",
"name": "alpha",
}
item_b = SimpleNamespace(type="function_call", call_id="call_b", id="item_b", name="beta")
item_b.model_dump = lambda **_kw: { # type: ignore[method-assign]
"type": "function_call",
"call_id": "call_b",
"name": "beta",
}
events = [
SimpleNamespace(type="response.output_item.added", item=item_a),
SimpleNamespace(type="response.output_item.added", item=item_b),
SimpleNamespace(
type="response.function_call_arguments.delta",
item_id="bogus",
delta='{"x": 1}',
),
SimpleNamespace(
type="response.completed",
response=SimpleNamespace(status="completed", usage=None),
),
]
result = self._drain(events)
assert result.tool_calls is not None
by_name = {tc["function"]["name"]: tc["function"]["arguments"] for tc in result.tool_calls}
assert by_name["beta"] == '{"x": 1}'
assert by_name["alpha"] == ""
def test_duplicate_item_ids_keep_distinct_slots(self) -> None:
# Slot numbering must survive duplicate/empty item ids: len(dict)
# numbering collided the third call onto the second's slot once an
# overwrite kept the dict size flat.
def _item(call_id: str, item_id: str, name: str) -> SimpleNamespace:
item = SimpleNamespace(type="function_call", call_id=call_id, id=item_id, name=name)
item.model_dump = lambda **_kw: { # type: ignore[method-assign]
"type": "function_call",
"call_id": call_id,
"name": name,
}
return item
events = [
SimpleNamespace(type="response.output_item.added", item=_item("call_a", "", "alpha")),
SimpleNamespace(type="response.output_item.added", item=_item("call_b", "", "beta")),
SimpleNamespace(
type="response.output_item.added", item=_item("call_c", "item_c", "gamma")
),
SimpleNamespace(
type="response.completed",
response=SimpleNamespace(status="completed", usage=None),
),
]
result = self._drain(events)
assert result.tool_calls is not None
assert [tc["function"]["name"] for tc in result.tool_calls] == ["alpha", "beta", "gamma"]
def test_completed_terminal_keeps_done_items_without_rebuild(self) -> None:
# Happy path: every item got its ``output_item.done`` and the
# terminal payload carries the same number of items — the rebuild
+13
View File
@@ -31,6 +31,8 @@ Contract, held deliberately narrow:
from __future__ import annotations
import random
import time
import uuid
from dataclasses import dataclass, fields, replace
from typing import TYPE_CHECKING, Any
@@ -65,6 +67,13 @@ log = get_logger(__name__)
# retry (openai/anthropic default ``max_retries=2``) that covered the
# whole body read on the retired non-streaming transport.
_DRAIN_RETRIES = 2
# Base for the exponential inter-attempt delay (0.5s → 1s, ±50% jitter) —
# the SDK retry's pacing, minus Retry-After (an in-band stream failure
# carries no header to honor). An instant re-issue is guaranteed to
# re-hit a still-active rate limit or overload, and at fleet scale
# synchronized re-issues amplify the very condition being retried
# through. Module-level so tests can zero it.
_DRAIN_RETRY_BASE_DELAY = 0.5
# Block types that carry model reasoning natively. Anthropic emits
# ``thinking``/``redacted_thinking`` blocks, OpenAI Responses emits
@@ -750,12 +759,16 @@ def model_turn(
or type(exc).__name__ not in lane.provider.retryable_error_names
):
raise
delay = _DRAIN_RETRY_BASE_DELAY * (2 ** (attempt - 1)) * (0.5 + random.random())
log.warning(
"model_turn.drain_retry",
error_type=type(exc).__name__,
attempt=attempt,
model=lane.model,
retry_in=round(delay, 2),
)
if delay > 0:
time.sleep(delay)
raw_calls: list[dict[str, Any]] = list(result.tool_calls or [])
# Record blanks BEFORE the uuid back-fill: a back-filled id exists only
+59 -20
View File
@@ -903,20 +903,35 @@ class AnthropicProvider:
raise
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_with_cleanup(stream, manager)
return self._iter_with_cleanup(
stream, manager, finish_reason_optional=caps.finish_reason_optional
)
def _iter_with_cleanup(self, stream: Any, manager: Any) -> Iterator[StreamChunk]:
def _iter_with_cleanup(
self, stream: Any, manager: Any, *, finish_reason_optional: bool = False
) -> Iterator[StreamChunk]:
"""Iterate the Anthropic stream, ensuring the context manager exits."""
try:
yield from self._iter_anthropic_stream(stream)
yield from self._iter_anthropic_stream(
stream, finish_reason_optional=finish_reason_optional
)
except BaseException:
manager.__exit__(*sys.exc_info())
raise
else:
manager.__exit__(None, None, None)
def _iter_anthropic_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert Anthropic streaming events to normalized StreamChunks."""
def _iter_anthropic_stream(
self, stream: Any, *, finish_reason_optional: bool = False
) -> Iterator[StreamChunk]:
"""Convert Anthropic streaming events to normalized StreamChunks.
*finish_reason_optional* is the model capability of the same name:
a lax anthropic-compatible gateway that ends the stream without
EITHER terminal signal (no ``message_delta`` stop_reason, no
``message_stop``) gets the end-of-generator shim below the
retired non-streaming path's absent-stop_reason tolerance.
"""
first = True
# Map content block index → tool call index for our accumulator
tool_block_to_index: dict[int, int] = {}
@@ -927,6 +942,7 @@ class AnthropicProvider:
raw_blocks: dict[int, dict[str, Any]] = {}
saw_text_block = False
emitted_finish = False
delivered_output = False
for event in stream:
sc = StreamChunk()
@@ -1079,6 +1095,7 @@ class AnthropicProvider:
# whose content actually arrived intact.
if not emitted_finish:
sc.finish_reason = "stop"
emitted_finish = True
if raw_blocks:
sc.provider_blocks = [raw_blocks[i] for i in sorted(raw_blocks)]
@@ -1098,29 +1115,51 @@ class AnthropicProvider:
)
has_content = sc.content_delta or sc.reasoning_delta or sc.tool_call_deltas
if has_content and first:
sc.is_first = True
first = False
if has_content:
delivered_output = True
if first:
sc.is_first = True
first = False
if has_content or sc.finish_reason or sc.usage or sc.info_delta:
yield sc
# Terminal-signal-less lax-gateway tolerance, armed ONLY by the
# operator-declared ``finish_reason_optional`` capability: a
# stream that ended cleanly after delivering output but carried
# NEITHER terminal signal (no message_delta stop_reason, no
# message_stop — an anthropic-compatible proxy shape; the real
# API always sends both) is a completed generation. Everywhere
# else the drain's complete-or-error gate raises, because a
# missing message_stop on a signal-disciplined server means the
# generation died mid-response.
if finish_reason_optional and not emitted_finish and delivered_output:
sc = StreamChunk(finish_reason="stop")
if raw_blocks:
sc.provider_blocks = [raw_blocks[i] for i in sorted(raw_blocks)]
yield sc
# -- retryable errors ----------------------------------------------------
# Computed once at class creation — the retry predicate consults this
# per error, and a per-access literal allocates a fresh frozenset each
# time.
_RETRYABLE_ERROR_NAMES: frozenset[str] = frozenset(
{
"RateLimitError",
"APITimeoutError",
"APIConnectionError",
"InternalServerError",
"APIError",
"OverloadedError",
# Transport-level: drained stream ended without a stop reason.
"IncompleteStreamError",
}
)
@property
def retryable_error_names(self) -> frozenset[str]:
return frozenset(
{
"RateLimitError",
"APITimeoutError",
"APIConnectionError",
"InternalServerError",
"APIError",
"OverloadedError",
# Transport-level: drained stream ended without a stop reason.
"IncompleteStreamError",
}
)
return self._RETRYABLE_ERROR_NAMES
# -- reasoning extraction ------------------------------------------------
+14 -17
View File
@@ -68,9 +68,12 @@ _GOOGLE_DEFAULT = ModelCapabilities(
class GoogleProvider(OpenAIChatCompletionsProvider):
"""Provider for Google models using the OpenAI-compatible endpoint.
Overrides message preparation and tool-call extraction to preserve
Gemini-specific fields (``thought_signature``) through the round-trip
via the ``provider_blocks`` / ``_provider_content`` fidelity lane.
Overrides message preparation (``_prepare_messages`` reconstructs the
raw tool calls from ``_provider_content``) and registers an
``on_tool_call_delta`` capture with the base stream iterator to
preserve Gemini-specific fields (``thought_signature``) through the
round-trip via the ``provider_blocks`` / ``_provider_content``
fidelity lane.
"""
@property
@@ -139,7 +142,7 @@ class GoogleProvider(OpenAIChatCompletionsProvider):
stream: Any,
*,
finish_reason_optional: bool = False,
on_tool_call_delta: Callable[[int, Any], None] | None = None,
on_tool_call_delta: Callable[[ToolCallDelta, Any], None] | None = None,
) -> Iterator[StreamChunk]:
"""Wrap the base iterator to capture raw tool-call metadata.
@@ -161,25 +164,19 @@ class GoogleProvider(OpenAIChatCompletionsProvider):
"""
raw_tool_calls: dict[int, dict[str, Any]] = {}
def _capture(slot: int, tc_delta: Any) -> None:
fn = tc_delta.function
raw_tc = accumulate_tool_call_delta(
raw_tool_calls,
ToolCallDelta(
index=slot,
id=tc_delta.id or "",
name=(fn.name if fn else None) or "",
arguments_delta=(fn.arguments if fn else None) or "",
),
)
def _capture(tcd: ToolCallDelta, raw_delta: Any) -> None:
# The normalized delta carries the base's slot AND its
# id/name/arguments extraction verbatim — the raw lane
# accumulates the exact bytes the mirror sees.
raw_tc = accumulate_tool_call_delta(raw_tool_calls, tcd)
# Capture provider-specific extras (e.g. thought_signature)
extras = getattr(tc_delta, "__pydantic_extra__", None)
extras = getattr(raw_delta, "__pydantic_extra__", None)
if extras:
for k, v in extras.items():
if k not in ("index", "id", "type", "function"):
raw_tc.setdefault(k, v)
if on_tool_call_delta is not None:
on_tool_call_delta(slot, tc_delta)
on_tool_call_delta(tcd, raw_delta)
# Delegate all chunk processing to the base class
for sc in super()._iter_stream(
+54 -25
View File
@@ -114,25 +114,35 @@ class ToolCallSlotter:
- A delta whose id CONTRADICTS the slot's id is a new call; a delta
whose id MATCHES is the same call, however many times the server
repeats the name header per fragment. Ids are authoritative both
ways.
- An id-less delta with NO name is always an arguments continuation.
- An id-less delta announcing a DIFFERENT name than the slot's is a
new call (two functions cannot be one call).
- An id-less re-announcement of the SAME name splits only when the
slot's accumulated arguments are syntactically complete JSON (the
previous call is over this starts the next parallel call).
Mid-JSON, the name is a redundant per-fragment header and the
delta merges. With NO arguments accumulated yet: a re-announce
that itself carries arguments merges (name-first emission, the
arguments are starting now); a bare re-announce splits (the
ways and a slot whose id is KNOWN never splits on an id-less
delta at all: on an id-disciplined server new calls arrive with
ids, so an id-less fragment (the call's first name announcement,
an argument fragment, a redundant header) is always a
continuation. All heuristics below apply only to fully id-less
slots.
- A delta with NO name is always an arguments continuation.
- A name arriving for a slot that has NO name yet is the call's
FIRST announcement (e.g. the slot was opened by an args-only
delta), never a new call.
- A delta announcing a DIFFERENT name than the slot's is a new call
(two functions cannot be one call).
- A re-announcement of the SAME name splits only when the slot's
accumulated arguments are syntactically complete JSON AND the
delta itself carries arguments (the next parallel call arriving
with its payload). Mid-JSON, or as a bare trailing delta after
complete arguments, the name is a redundant per-fragment header /
footer and the delta merges. With NO arguments accumulated yet:
a re-announce that carries arguments merges (name-first emission,
the arguments are starting now); a bare re-announce splits (the
id-less whole-delta shape two zero-argument parallel calls).
Residual ambiguity, resolved toward the shapes observed in the wild:
a same-name re-announce on an empty slot that carries arguments is
read as one call (not a zero-arg call followed by an arg-ful twin),
and a trailing bare same-name re-announce after complete arguments
is read as a second zero-arg call (not a redundant footer). Only
ids disambiguate those; servers that omit them choose the bet.
and a bare same-name re-announce after complete arguments is read
as a redundant footer (not a second zero-arg call of the same
function). Only ids disambiguate those; servers that omit them
choose the bet.
"""
def __init__(self) -> None:
@@ -152,7 +162,10 @@ class ToolCallSlotter:
self._slot_ids[slot] = tc_id
if name:
self._slot_names[slot] = name
if args:
# JSON-completeness state is consulted only for fully id-less
# slots (``_is_new_call`` short-circuits on a known id), so id'd
# slots — the dominant case — skip the per-character scan.
if args and slot not in self._slot_ids:
self._slot_args.setdefault(slot, _ArgsScanner()).feed(args)
return slot
@@ -160,14 +173,28 @@ class ToolCallSlotter:
slot_id = self._slot_ids.get(slot, "")
if tc_id:
return bool(slot_id) and tc_id != slot_id
if slot_id:
# Id-disciplined slot: new calls on this server arrive with
# ids (the id-conflict rule above), so an id-less delta —
# the call's first name fragment, an argument fragment, a
# redundant header — is always a continuation.
return False
if not name:
return False
slot_name = self._slot_names.get(slot, "")
if slot_name and name != slot_name:
if not slot_name:
# First name announcement for a slot opened by an args-only
# delta — naming the call, not starting a new one.
return False
if name != slot_name:
return True
scanner = self._slot_args.get(slot)
if scanner is not None:
return scanner.complete
# Complete arguments end the call — but only a re-announce
# that itself CARRIES arguments starts the next one; a bare
# same-name delta after complete arguments is a redundant
# footer.
return scanner.complete and bool(args)
return not args
@@ -315,18 +342,20 @@ class OpenAIChatCompletionsProvider:
stream: Any,
*,
finish_reason_optional: bool = False,
on_tool_call_delta: Callable[[int, Any], None] | None = None,
on_tool_call_delta: Callable[[ToolCallDelta, Any], None] | None = None,
) -> Iterator[StreamChunk]:
"""Convert OpenAI Chat Completions stream chunks to StreamChunks.
*finish_reason_optional* is the model capability of the same name:
it arms the lax-server finish shim at the end of this generator.
*on_tool_call_delta* is called with ``(slot, raw_sdk_delta)`` for
every tool-call delta, AFTER slot assignment the seam a subclass
uses to capture provider extras (``GoogleProvider``'s
``thought_signature``) keyed by the SAME slot the normalized
mirror uses, so the raw and mirror lanes cannot desync.
*on_tool_call_delta* is called with ``(normalized, raw_sdk_delta)``
for every tool-call delta the normalized ``ToolCallDelta``
carries the slot the base's slotter assigned AND the base's
id/name/arguments extraction, so a subclass capturing provider
extras (``GoogleProvider``'s ``thought_signature``) accumulates
the exact bytes the ``tool_calls`` mirror sees and cannot desync
from it on either axis.
"""
first = True
annotations: list[Any] = []
@@ -379,8 +408,6 @@ class OpenAIChatCompletionsProvider:
name = (fn.name if fn else None) or ""
args = (fn.arguments if fn else None) or ""
slot = slotter.slot_for(tc_delta.index, tc_id, name, args)
if on_tool_call_delta is not None:
on_tool_call_delta(slot, tc_delta)
tcd = ToolCallDelta(index=slot)
if tc_id:
tcd.id = tc_id
@@ -388,6 +415,8 @@ class OpenAIChatCompletionsProvider:
tcd.name = name
if args:
tcd.arguments_delta = args
if on_tool_call_delta is not None:
on_tool_call_delta(tcd, tc_delta)
sc.tool_call_deltas.append(tcd)
tool_call_count += 1
+68 -7
View File
@@ -570,17 +570,35 @@ class OpenAIResponsesProvider:
stream = client.responses.create(**kwargs)
if cancel_ref is not None:
cancel_ref.append(stream)
return self._iter_stream(stream)
caps = capabilities or self.get_capabilities(model)
return self._iter_stream(stream, finish_reason_optional=caps.finish_reason_optional)
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Convert Responses API stream events to StreamChunks."""
def _iter_stream(
self, stream: Any, *, finish_reason_optional: bool = False
) -> Iterator[StreamChunk]:
"""Convert Responses API stream events to StreamChunks.
*finish_reason_optional* is the model capability of the same name:
a lax Responses-compatible server that ends the stream without any
terminal event (``response.completed`` / ``response.incomplete``)
gets the end-of-generator shim below the retired non-streaming
path needed no terminal event either.
"""
first = True
content_len = 0
reasoning_len = 0
tool_call_count = 0
last_finish: str | None = None
completion_tokens: int | None = None
# Track tool call indices by call_id for consistent ToolCallDelta.index
# Track tool call indices by item_id for consistent ToolCallDelta.index.
# ``next_tool_idx`` mints slots (NOT len(dict): duplicate/empty item
# ids overwrite their mapping and would collide later slots);
# ``last_tool_idx`` routes argument deltas whose item_id was never
# announced — a lax server's deltas belong to the call most recently
# opened, not hardwired slot 0.
tool_call_indices: dict[str, int] = {}
next_tool_idx = 0
last_tool_idx = 0
# Collect output items for provider_blocks
provider_blocks: list[dict[str, Any]] = []
# Collect annotations across text parts
@@ -609,6 +627,7 @@ class OpenAIResponsesProvider:
delta_text = getattr(event, "delta", "")
if delta_text:
sc = StreamChunk(reasoning_delta=delta_text)
reasoning_len += len(delta_text)
if first:
sc.is_first = True
first = False
@@ -637,7 +656,9 @@ class OpenAIResponsesProvider:
call_id = getattr(item, "call_id", "")
item_id = getattr(item, "id", "")
name = getattr(item, "name", "")
idx = len(tool_call_indices)
idx = next_tool_idx
next_tool_idx += 1
last_tool_idx = idx
# Index by item_id — argument deltas reference this, not call_id
tool_call_indices[item_id] = idx
sc = StreamChunk(
@@ -655,7 +676,7 @@ class OpenAIResponsesProvider:
item_id = getattr(event, "item_id", "")
delta_args = getattr(event, "delta", "")
if delta_args:
idx = tool_call_indices.get(item_id, 0)
idx = tool_call_indices.get(item_id, last_tool_idx)
yield StreamChunk(
tool_call_deltas=[ToolCallDelta(index=idx, arguments_delta=delta_args)]
)
@@ -736,8 +757,19 @@ class OpenAIResponsesProvider:
# The SDK YIELDS `error` SSE events rather than raising, and no
# response.failed necessarily follows — without this branch the
# stream exhausts finish-less and the real API message is lost
# behind a misleading IncompleteStreamError.
# behind a misleading IncompleteStreamError. AFTER a terminal
# event the generation is complete and in hand: a trailing
# error frame is teardown noise, and raising would discard the
# finished result (the in-band twin of the post-finish
# transport-blip tolerance ``drain_stream`` grants).
if event_type == "error":
if last_finish is not None:
log.warning(
"openai.responses.post_terminal_error",
code=getattr(event, "code", "") or "",
message=getattr(event, "message", "") or "",
)
break
_raise_responses_failure(
getattr(event, "code", "") or "",
getattr(event, "message", "Unknown error") or "Unknown error",
@@ -747,16 +779,45 @@ class OpenAIResponsesProvider:
if event_type == "response.failed":
response = getattr(event, "response", None)
error = getattr(response, "error", None) if response else None
if last_finish is not None:
log.warning(
"openai.responses.post_terminal_error",
code=(getattr(error, "code", "") if error else "") or "",
message=(getattr(error, "message", "") if error else "") or "",
)
break
_raise_responses_failure(
(getattr(error, "code", "") if error else "") or "",
(getattr(error, "message", "") if error else "") or "Unknown error",
)
# Terminal-event-less lax-server tolerance, armed ONLY by the
# operator-declared ``finish_reason_optional`` capability: a
# stream that ended cleanly after delivering output but never sent
# ``response.completed``/``response.incomplete`` is a completed
# generation on such a server (the retired non-streaming path
# needed no terminal event). The ``.done``-collected blocks ride
# the shimmed finish chunk, exactly as they would the terminal
# handler's. Everywhere else the drain's complete-or-error gate
# raises — a missing terminal on an event-disciplined server means
# the generation died mid-response.
if (
finish_reason_optional
and last_finish is None
and (content_len or reasoning_len or tool_call_count)
):
last_finish = "stop"
sc = StreamChunk(finish_reason="stop")
if provider_blocks:
sc.provider_blocks = provider_blocks
yield sc
log.debug(
"openai.responses.response",
stream=True,
finish_reason=last_finish,
content_length=content_len,
reasoning_length=reasoning_len,
tool_call_count=tool_call_count,
completion_tokens=completion_tokens,
)
+14 -11
View File
@@ -148,10 +148,10 @@ def drain_stream(chunks: Iterator[StreamChunk]) -> CompletionResult:
- A stream that exhausts with NO finish reason raises
:class:`IncompleteStreamError` (retryable) every adapter emits one
on a healthy stream (a server that genuinely never sends one needs
``finish_reason_optional`` declared in its model capabilities; the
chat iterator then shims ``"stop"`` once output arrived), so its
absence means the generation died mid-response. Partial text must
on a healthy stream (a server that genuinely never sends a terminal
signal needs ``finish_reason_optional`` declared in its model
capabilities; the adapter then shims ``"stop"`` once output
arrived), so its absence means the generation died mid-response. Partial text must
never be handed to a caller that stores it as a complete result (a
compaction summary, a title). A transport blip AFTER the finish
reason keeps the completed result and forfeits only trailing
@@ -358,18 +358,21 @@ class ModelCapabilities:
# "" = omit (standard reasoning). There is no gpt-5.6-pro model.
supports_pro_mode: bool = False
reasoning_mode: str = ""
# Chat-lane lax-server tolerance (operator-declared, model-definition
# capabilities JSON): this server never sends ``finish_reason``, so a
# Lax-server tolerance (operator-declared, model-definition
# capabilities JSON): this server never sends a terminal signal, so a
# stream that ends CLEANLY after delivering output (content, reasoning,
# or tool calls) is a completed generation — the chat iterator shims
# ``finish_reason="stop"`` and :func:`drain_stream`'s complete-or-error
# gate passes. Leave False (the default) for every server that
# reliably sends finish reasons: there, a clean finish-less end IS a
# or tool calls) is a completed generation — the adapter shims a
# ``"stop"`` finish and :func:`drain_stream`'s complete-or-error gate
# passes. Leave False (the default) for every server that reliably
# terminates its streams: there, a clean signal-less end IS a
# died-mid-generation stream (worker crashed behind a clean-closing
# proxy/ASGI layer) and blessing it would store partial text as a
# complete result. SSE has no body framing, so the two cases are one
# wire shape — this flag is the operator asserting which server class
# they run. Honored by the Chat Completions lane only.
# they run. Honored on every drained lane: Chat Completions (no
# ``finish_reason`` ever arrived), Anthropic (no ``message_delta``
# stop_reason AND no ``message_stop``), Responses (no terminal
# ``response.completed``/``response.incomplete`` event).
finish_reason_optional: bool = False