fix(providers): support OpenAI v3 HTTPX2 transport

This commit is contained in:
Patrick Buckley
2026-08-11 23:12:02 -07:00
parent d961af5c45
commit 6eae1c3954
13 changed files with 378 additions and 126 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
{ {
"description": "LLM SDKs — always review manually", "description": "LLM SDKs — always review manually",
"groupName": "LLM SDKs", "groupName": "LLM SDKs",
"matchPackageNames": ["openai", "anthropic", "mcp"], "matchPackageNames": ["openai", "httpx2", "anthropic", "mcp"],
"schedule": ["before 9am on Monday"], "schedule": ["before 9am on Monday"],
"automerge": false "automerge": false
}, },
+10
View File
@@ -152,6 +152,16 @@ Earlier stable lines (`stable/1.6`, `stable/1.5`) are frozen.
### Changed ### Changed
- **OpenAI SDK v3 and its HTTPX2 default transport are now supported (#1009).**
Chat Completions and Responses streams normalize native HTTPX2 connection
deaths through the same retry boundary as legacy HTTPX-backed providers,
including failures observed after safe cross-thread client closure during a
model-registry reload. The OpenAI v3 runtime escape hatch for explicitly
injected legacy HTTPX clients remains supported. OpenAI connections now
follow HTTPX2's operating-system trust store by default; deployments that
relied on a modified `certifi` bundle must install that CA in the system
store or set `SSL_CERT_FILE` / `SSL_CERT_DIR`.
- **Log event rename: `drain_stream.post_finish_blip` is now - **Log event rename: `drain_stream.post_finish_blip` is now
`stream.post_finish_blip`; its `usage_captured` field is retained.** The `stream.post_finish_blip`; its `usage_captured` field is retained.** The
single-shot drain normalizes mid-body transport deaths through the same single-shot drain normalizes mid-body transport deaths through the same
+11 -8
View File
@@ -177,7 +177,8 @@ Runs a complete multi-turn conversation:
1. Appends the user message. 1. Appends the user message.
2. Checks `_cancelled` event — stops if set (timeout cleanup). 2. Checks `_cancelled` event — stops if set (timeout cleanup).
3. Calls the model API (non-streaming). 3. Calls the model through the production streaming provider path and drains
the result.
4. If tool calls are returned, executes them (with stdout suppressed) and 4. If tool calls are returned, executes them (with stdout suppressed) and
logs each call to `self.tool_call_log`. logs each call to `self.tool_call_log`.
5. Repeats up to `max_turns` or until the model responds without tool calls. 5. Repeats up to `max_turns` or until the model responds without tool calls.
@@ -190,14 +191,15 @@ Parallel tool calls are capped at 10 per turn to prevent degenerate repetition.
Each test runs in a `ThreadPoolExecutor(max_workers=1)` with a per-test Each test runs in a `ThreadPoolExecutor(max_workers=1)` with a per-test
timeout (`--test-timeout`). Each attempt gets its own `OpenAI` client with timeout (`--test-timeout`). Each attempt gets its own `OpenAI` client with
a matching httpx read timeout. On timeout, three layers of defense prevent a matching per-read HTTP transport timeout. Because a trickling stream can
zombie connections: continually reset that read timeout, three layers bound the harness and stop
follow-on work:
1. **httpx timeout**: Per-request read timeout aborts the HTTP call and 1. **Executor wall clock**: The harness stops waiting after `--test-timeout`.
releases the server slot.
2. **`_cancelled` event**: Prevents the orphan thread from starting new turns. 2. **`_cancelled` event**: Prevents the orphan thread from starting new turns.
3. **`run_client.close()`**: Closes the connection pool to abort any 3. **`run_client.close()`**: Retires the connection pool and prevents reuse.
in-flight request. HTTPX2 does not promise that cross-thread client closure immediately aborts
an active body read; that read unwinds on its next wire event or read timeout.
### Retry Logic ### Retry Logic
@@ -214,7 +216,8 @@ Each test case runs in isolation:
1. A fresh temp directory is created. 1. A fresh temp directory is created.
2. Setup files are written to the temp directory. 2. Setup files are written to the temp directory.
3. The working directory is changed to the temp directory. 3. The working directory is changed to the temp directory.
4. A per-attempt `OpenAI` client is created with httpx timeout matching `--test-timeout`. 4. A per-attempt `OpenAI` client is created with an HTTP transport timeout
matching `--test-timeout`.
5. A new `HeadlessSession` is created with the current developer prompt. 5. A new `HeadlessSession` is created with the current developer prompt.
6. `send_headless()` runs the user prompt through the conversation loop. 6. `send_headless()` runs the user prompt through the conversation loop.
7. The tool log is scored against expected actions. 7. The tool log is scored against expected actions.
+6 -3
View File
@@ -23,11 +23,14 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Artificial Intelligence",
] ]
dependencies = [ dependencies = [
# 2.45 adds the GPT-5.6 typed fields used below. Version 3 replaces the # Version 3 moves the default transport to HTTPX2. Keep major upgrades
# httpx transport family, so migrate the streaming retry boundary first. # deliberate because the stream retry boundary depends on that contract.
"openai>=2.45,<3", "openai>=3,<4",
"anthropic>=0.117", # tracks the release current at claude-opus-5 onboarding; hard runtime floor is still 0.105 (mid-conversation system blocks) — Opus 5 itself needs no new SDK surface (model ids are opaque strings; "refusal" has been in the StopReason literal since ~0.95). Raise this when adopting fast mode / server-side fallbacks / advisor / mid-conversation tool changes, which DO need newer typed params. "anthropic>=0.117", # tracks the release current at claude-opus-5 onboarding; hard runtime floor is still 0.105 (mid-conversation system blocks) — Opus 5 itself needs no new SDK surface (model ids are opaque strings; "refusal" has been in the StopReason literal since ~0.95). Raise this when adopting fast mode / server-side fallbacks / advisor / mid-conversation tool changes, which DO need newer typed params.
"httpx>=0.28", "httpx>=0.28",
# Direct because the provider boundary catches this exception family;
# OpenAI v3's transitive dependency alone is not an import contract.
"httpx2>=2.7,<3",
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately "mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
"starlette>=1.3.1", # CVE-2026-54282 (path->authority host spoof) + CVE-2026-54283 (url-encoded form DoS); supersedes the PYSEC-2026-161 host-header path-injection floor "starlette>=1.3.1", # CVE-2026-54282 (path->authority host spoof) + CVE-2026-54283 (url-encoded form DoS); supersedes the PYSEC-2026-161 host-header path-injection floor
"uvicorn>=0.34", "uvicorn>=0.34",
+39 -23
View File
@@ -9,6 +9,8 @@ provider_blocks, trailing-citation fold).
from __future__ import annotations from __future__ import annotations
import httpx
import httpx2
import pytest import pytest
from turnstone.core.providers import ( from turnstone.core.providers import (
@@ -418,11 +420,18 @@ class TestTransportGuarded:
"""``transport_guarded`` — drain's conversion rule for consumers that """``transport_guarded`` — drain's conversion rule for consumers that
keep streaming semantics (the interactive loop).""" keep streaming semantics (the interactive loop)."""
@pytest.mark.parametrize("exc_name", ["ReadError", "RemoteProtocolError"]) @pytest.mark.parametrize(
def test_pre_finish_transport_error_becomes_retryable_incomplete(self, exc_name): "exc_cls",
import httpx [
httpx.ReadError,
exc_cls = getattr(httpx, exc_name) httpx.RemoteProtocolError,
httpx2.ReadError,
httpx2.RemoteProtocolError,
],
ids=["httpx-read", "httpx-protocol", "httpx2-read", "httpx2-protocol"],
)
def test_pre_finish_transport_error_becomes_retryable_incomplete(self, exc_cls):
exc_name = exc_cls.__name__
def chunks(): def chunks():
yield StreamChunk(content_delta="partial") yield StreamChunk(content_delta="partial")
@@ -434,14 +443,17 @@ class TestTransportGuarded:
next(it) next(it)
assert isinstance(excinfo.value.__cause__, exc_cls) assert isinstance(excinfo.value.__cause__, exc_cls)
def test_message_byte_matches_drains_shape(self): @pytest.mark.parametrize(
"exc_cls",
[httpx.ReadError, httpx2.ReadError],
ids=["httpx", "httpx2"],
)
def test_message_byte_matches_drains_shape(self, exc_cls):
# The wrapper and the drain are the SAME conversion rule; any test # The wrapper and the drain are the SAME conversion rule; any test
# or log filter pinned to drain's message must match the wrapper's. # or log filter pinned to drain's message must match the wrapper's.
import httpx
def chunks(): def chunks():
yield StreamChunk(content_delta="partial") yield StreamChunk(content_delta="partial")
raise httpx.ReadError("[SSL] record layer failure (_ssl.c:2590)") raise exc_cls("[SSL] record layer failure (_ssl.c:2590)")
with pytest.raises(IncompleteStreamError) as guarded: with pytest.raises(IncompleteStreamError) as guarded:
list(transport_guarded(chunks())) list(transport_guarded(chunks()))
@@ -449,17 +461,20 @@ class TestTransportGuarded:
drain_stream(chunks()) drain_stream(chunks())
assert str(guarded.value) == str(drained.value) assert str(guarded.value) == str(drained.value)
def test_post_finish_blip_ends_stream_cleanly(self, caplog): @pytest.mark.parametrize(
"exc_cls",
[httpx.ReadError, httpx2.ReadError],
ids=["httpx", "httpx2"],
)
def test_post_finish_blip_ends_stream_cleanly(self, caplog, exc_cls):
# The generation already completed — the blip only cost trailing # The generation already completed — the blip only cost trailing
# metadata, so the stream ends instead of raising. # metadata, so the stream ends instead of raising.
import logging import logging
import httpx
def chunks(): def chunks():
yield StreamChunk(content_delta="done") yield StreamChunk(content_delta="done")
yield StreamChunk(finish_reason="stop") yield StreamChunk(finish_reason="stop")
raise httpx.ReadError("late blip") raise exc_cls("late blip")
with caplog.at_level(logging.WARNING, logger="turnstone.core.providers._protocol"): with caplog.at_level(logging.WARNING, logger="turnstone.core.providers._protocol"):
out = list(transport_guarded(chunks())) out = list(transport_guarded(chunks()))
@@ -502,32 +517,33 @@ class TestErrorPropagation:
# The generation completed (finish reason in hand) — a trailing # The generation completed (finish reason in hand) — a trailing
# transport blip forfeits only trailing metadata (here: the usage # transport blip forfeits only trailing metadata (here: the usage
# chunk), never the completed result. # chunk), never the completed result.
import httpx
def chunks(): def chunks():
yield StreamChunk(content_delta="whole answer") yield StreamChunk(content_delta="whole answer")
yield StreamChunk(finish_reason="stop") yield StreamChunk(finish_reason="stop")
raise httpx.ReadError("late blip") raise httpx2.ReadError("late blip")
result = drain_stream(chunks()) result = drain_stream(chunks())
assert result.content == "whole answer" assert result.content == "whole answer"
assert result.finish_reason == "stop" assert result.finish_reason == "stop"
assert result.usage is None assert result.usage is None
def test_httpx_transport_error_becomes_retryable_incomplete(self): @pytest.mark.parametrize(
"exc_cls",
[httpx.RemoteProtocolError, httpx2.RemoteProtocolError],
ids=["httpx", "httpx2"],
)
def test_transport_error_becomes_retryable_incomplete(self, exc_cls):
# Streaming moves the body read out of the SDK's wrapped request: # Streaming moves the body read out of the SDK's wrapped request:
# a mid-body wire failure surfaces as a raw httpx.TransportError # a mid-body wire failure surfaces as a raw HTTPX-family TransportError
# no retry predicate recognizes. The drain re-raises it (chained, # no retry predicate recognizes. The drain re-raises it (chained,
# message preserved) as the retryable IncompleteStreamError. # message preserved) as the retryable IncompleteStreamError.
import httpx
def chunks(): def chunks():
yield StreamChunk(content_delta="partial") yield StreamChunk(content_delta="partial")
raise httpx.RemoteProtocolError("peer closed connection") raise exc_cls("peer closed connection")
with pytest.raises(IncompleteStreamError, match="RemoteProtocolError") as excinfo: with pytest.raises(IncompleteStreamError, match="RemoteProtocolError") as excinfo:
drain_stream(chunks()) drain_stream(chunks())
assert isinstance(excinfo.value.__cause__, httpx.RemoteProtocolError) assert isinstance(excinfo.value.__cause__, exc_cls)
def test_mid_stream_exception_propagates_verbatim(self): def test_mid_stream_exception_propagates_verbatim(self):
# Retry/deadline/fallback policy is the caller's — the drain adds # Retry/deadline/fallback policy is the caller's — the drain adds
+1 -1
View File
@@ -2753,7 +2753,7 @@ class TestRunResourceLifecycle:
assert not is_storage_initialized() assert not is_storage_initialized()
def test_a_hung_generation_is_bounded_by_the_wall_clock(self, monkeypatch): def test_a_hung_generation_is_bounded_by_the_wall_clock(self, monkeypatch):
"""The per-request httpx timeout cannot bound a STREAM — a """The per-request HTTP transport timeout cannot bound a STREAM — a
trickling response resets the read timeout indefinitely — so trickling response resets the read timeout indefinitely — so
without the executor wall clock a hung generation occupies a run without the executor wall clock a hung generation occupies a run
slot forever and is scored as a body regression when the sweep slot forever and is scored as a body regression when the sweep
+219 -56
View File
@@ -1,17 +1,19 @@
"""Offline pins of the SDK boundary behaviors the #937 retry design rests on. """Offline pins of the SDK boundary behaviors the #937 retry design rests on.
Three facts, each probed against the REAL SDKs over mock/loopback Four facts, each probed against the REAL SDKs over mock/loopback
transports (no network, no live backend): transports (no network, no live backend):
1. The OpenAI SDK's ``max_retries`` covers request time only — a 1. OpenAI v3's ``max_retries`` covers request time only — a mid-BODY death
mid-BODY death produces no re-request, and the raw ``httpx.ReadError`` produces no re-request, and the raw ``httpx2.ReadError`` escapes both Chat
escapes the chunk iterator unwrapped. Completions and Responses chunk iterators unwrapped.
2. The Anthropic ``messages.stream()`` helper propagates the same shape. 2. OpenAI v3's runtime-only legacy-client path preserves the old ``httpx``
3. Closing an httpx-backed SDK client from another thread while a read is exception family when an application explicitly injects that client.
blocked (the ``ModelRegistry.reload()`` shape) surfaces as an 3. The Anthropic ``messages.stream()`` helper propagates the ``httpx`` shape.
``httpx.TransportError`` on the blocked ``next()`` which is why 4. Closing an OpenAI v3 default client from another thread while a read is
``_stream_response``'s mid-stream re-issue ladder re-resolves the blocked (the ``ModelRegistry.reload()`` shape) completes safely; a later
registry binding before re-creating. wire release surfaces as an ``httpx2.TransportError`` on the blocked
``next()``. The production ``transport_guarded`` seam must normalize it
before the retry gate.
If an SDK/httpx upgrade changes any of these, the ``transport_guarded`` If an SDK/httpx upgrade changes any of these, the ``transport_guarded``
conversion (and the retry gate consuming it) must be re-verified these conversion (and the retry gate consuming it) must be re-verified these
@@ -27,6 +29,7 @@ import time
import anthropic import anthropic
import httpx import httpx
import httpx2
import openai import openai
import pytest import pytest
@@ -39,6 +42,12 @@ CHAT_CHUNK = (
'"finish_reason":null}]}' + LF + LF '"finish_reason":null}]}' + LF + LF
) )
RESPONSES_EVENT = (
'data: {"type":"response.output_text.delta","sequence_number":0,'
'"item_id":"item_1","output_index":0,"content_index":0,'
'"delta":"hello","logprobs":[]}' + LF + LF
)
ANTHROPIC_EVENTS = ( ANTHROPIC_EVENTS = (
"event: message_start" "event: message_start"
+ LF + LF
@@ -71,6 +80,17 @@ class _DyingStream(httpx.SyncByteStream):
raise httpx.ReadError("[SSL] record layer failure (_ssl.c:2590)") raise httpx.ReadError("[SSL] record layer failure (_ssl.c:2590)")
class _Httpx2DyingStream(httpx2.SyncByteStream):
"""HTTPX2 response body: one SSE payload, then a wire death."""
def __init__(self, payload: bytes) -> None:
self._payload = payload
def __iter__(self):
yield self._payload
raise httpx2.ReadError("[SSL] record layer failure (_ssl.c:2590)")
def _dying_transport(payload: str, requests: list) -> httpx.MockTransport: def _dying_transport(payload: str, requests: list) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
requests.append(request) requests.append(request)
@@ -84,12 +104,70 @@ def _dying_transport(payload: str, requests: list) -> httpx.MockTransport:
return httpx.MockTransport(handler) return httpx.MockTransport(handler)
def test_openai_midbody_death_is_unwrapped_readerror_and_no_rerequest(): def _httpx2_dying_transport(payload: str, requests: list) -> httpx2.MockTransport:
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(
200,
headers={"content-type": "text/event-stream"},
stream=_Httpx2DyingStream(payload.encode()),
request=request,
)
return httpx2.MockTransport(handler)
def test_openai_v3_chat_midbody_death_is_unwrapped_httpx2_error_and_no_rerequest():
requests: list = [] requests: list = []
client = openai.OpenAI( client = openai.OpenAI(
api_key="probe", api_key="probe",
base_url="http://probe.invalid/v1", base_url="http://probe.invalid/v1",
http_client=httpx.Client(transport=_dying_transport(CHAT_CHUNK, requests)), http_client=httpx2.Client(transport=_httpx2_dying_transport(CHAT_CHUNK, requests)),
max_retries=2,
)
stream = client.chat.completions.create(
model="m", messages=[{"role": "user", "content": "hi"}], stream=True
)
texts = []
with pytest.raises(httpx2.ReadError) as excinfo:
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
texts.append(chunk.choices[0].delta.content)
# The retry gate matches on the class NAME; pin the exact identity the
# SDK lets escape, and that it is the HTTPX2 transport family.
assert type(excinfo.value).__name__ == "ReadError"
assert isinstance(excinfo.value, httpx2.TransportError)
assert texts == ["hello"] # the request succeeded; the BODY died
assert len(requests) == 1 # max_retries never re-requested mid-body
def test_openai_v3_responses_midbody_death_is_unwrapped_httpx2_error_and_no_rerequest():
requests: list = []
client = openai.OpenAI(
api_key="probe",
base_url="http://probe.invalid/v1",
http_client=httpx2.Client(transport=_httpx2_dying_transport(RESPONSES_EVENT, requests)),
max_retries=2,
)
stream = client.responses.create(model="m", input="hi", stream=True)
texts = []
with pytest.raises(httpx2.ReadError) as excinfo:
for event in stream:
if event.type == "response.output_text.delta":
texts.append(event.delta)
assert type(excinfo.value).__name__ == "ReadError"
assert isinstance(excinfo.value, httpx2.TransportError)
assert texts == ["hello"]
assert len(requests) == 1
def test_openai_v3_legacy_httpx_midbody_death_keeps_legacy_error_family():
requests: list = []
legacy_http_client = httpx.Client(transport=_dying_transport(CHAT_CHUNK, requests))
client = openai.OpenAI(
api_key="probe",
base_url="http://probe.invalid/v1",
http_client=legacy_http_client, # type: ignore[arg-type]
max_retries=2, max_retries=2,
) )
stream = client.chat.completions.create( stream = client.chat.completions.create(
@@ -100,12 +178,10 @@ def test_openai_midbody_death_is_unwrapped_readerror_and_no_rerequest():
for chunk in stream: for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content: if chunk.choices and chunk.choices[0].delta.content:
texts.append(chunk.choices[0].delta.content) texts.append(chunk.choices[0].delta.content)
# The retry gate matches on the class NAME; pin the exact identity the
# SDK lets escape, and that it is the httpx transport family.
assert type(excinfo.value).__name__ == "ReadError" assert type(excinfo.value).__name__ == "ReadError"
assert isinstance(excinfo.value, httpx.TransportError) assert isinstance(excinfo.value, httpx.TransportError)
assert texts == ["hello"] # the request succeeded; the BODY died assert texts == ["hello"]
assert len(requests) == 1 # max_retries never re-requested mid-body assert len(requests) == 1
def test_anthropic_midbody_death_is_unwrapped_readerror_and_no_rerequest(): def test_anthropic_midbody_death_is_unwrapped_readerror_and_no_rerequest():
@@ -136,13 +212,59 @@ def test_anthropic_midbody_death_is_unwrapped_readerror_and_no_rerequest():
assert len(requests) == 1 assert len(requests) == 1
def test_cross_thread_client_close_surfaces_transport_error_to_blocked_read(): @pytest.mark.parametrize("surface", ["chat", "responses"])
"""The ``ModelRegistry.reload()`` shape: an admin-thread ``client.close()`` def test_closed_v3_default_client_creation_stays_sdk_wrapped_and_unarmed(surface: str):
under a worker blocked in ``next()`` must surface as an """A re-create on the client closed by reload is still a creation error.
``httpx.TransportError`` (so ``transport_guarded`` converts it and the
retry gate passes) not as a plain ``RuntimeError`` the gate would OpenAI v3 must wrap the default HTTPX2 client's ``RuntimeError`` as its
treat as fatal.""" retryable ``APIConnectionError`` before either adapter can arm the stream.
body = f"{len(CHAT_CHUNK):x}" + CRLF + CHAT_CHUNK + CRLF This preserves the creation-vs-mid-stream classifier while the original
normalized stream death remains available to the outer re-issue ladder.
"""
from turnstone.core.providers import create_provider
client = openai.OpenAI(
api_key="probe",
base_url="http://probe.invalid/v1",
max_retries=0,
)
client.close()
cancel_ref: list = []
provider = create_provider("openai-compatible", api_surface=surface)
with pytest.raises(openai.APIConnectionError) as excinfo:
provider.create_streaming(
client=client,
model="m",
messages=[{"role": "user", "content": "hi"}],
cancel_ref=cancel_ref,
)
assert cancel_ref == []
assert type(excinfo.value).__name__ in provider.retryable_error_names
assert type(excinfo.value.__cause__) is RuntimeError
@pytest.mark.parametrize(
("surface", "payload"),
[("chat", CHAT_CHUNK), ("responses", RESPONSES_EVENT)],
)
def test_cross_thread_v3_default_client_close_then_wire_release_is_normalized(
surface: str, payload: str
):
"""The ``ModelRegistry.reload()`` shape stays safe at the retry seam.
OpenAI v3's synchronous HTTPX2 client does not promise that cross-thread
``close()`` itself interrupts a blocked body read. Pin the behavior
Turnstone needs instead: closing from the admin thread completes safely
while a worker is in ``next()``, and the subsequent wire release reaches
``transport_guarded`` as a provider-retryable ``IncompleteStreamError``
for both OpenAI streaming adapters.
"""
from turnstone.core.providers import create_provider, transport_guarded
from turnstone.core.providers._protocol import IncompleteStreamError
body = f"{len(payload):x}" + CRLF + payload + CRLF
response_head = ( response_head = (
"HTTP/1.1 200 OK" "HTTP/1.1 200 OK"
+ CRLF + CRLF
@@ -153,59 +275,100 @@ def test_cross_thread_client_close_surfaces_transport_error_to_blocked_read():
+ CRLF + CRLF
) )
listener = socket.create_server(("127.0.0.1", 0)) listener = socket.create_server(("127.0.0.1", 0))
listener.settimeout(10.0)
port = listener.getsockname()[1] port = listener.getsockname()[1]
client_closed = threading.Event() release_peer = threading.Event()
reader_blocked = threading.Event() reader_blocked = threading.Event()
close_done = threading.Event()
first_content: list[str] = []
reader_errors: list[BaseException] = []
closer_errors: list[BaseException] = []
server_errors: list[BaseException] = []
def serve() -> None: def serve() -> None:
conn, _ = listener.accept() try:
conn.recv(65536) conn, _ = listener.accept()
conn.sendall((response_head + body).encode()) with conn:
# Hold the connection (no second chunk) so the reader blocks, and conn.settimeout(10.0)
# release only once the client has been closed under it. conn.recv(65536)
client_closed.wait(timeout=10.0) conn.sendall((response_head + body).encode())
conn.close() # Keep the peer open through close_done: any reader error
# before release_peer is therefore caused by close(), not EOF.
if not release_peer.wait(timeout=15.0):
raise AssertionError("peer release was never signalled")
except BaseException as exc:
server_errors.append(exc)
client = openai.OpenAI(api_key="probe", base_url=f"http://127.0.0.1:{port}/v1", max_retries=0) client = openai.OpenAI(
api_key="probe",
base_url=f"http://127.0.0.1:{port}/v1",
max_retries=0,
timeout=5.0,
)
def read_stream() -> None:
try:
provider = create_provider("openai-compatible", api_surface=surface)
chunks = provider.create_streaming(
client=client,
model="m",
messages=[{"role": "user", "content": "hi"}],
)
it = transport_guarded(chunks)
first_content.append(next(it).content_delta)
reader_blocked.set()
next(it)
except BaseException as exc:
reader_errors.append(exc)
def closer() -> None: def closer() -> None:
reader_blocked.wait(timeout=10.0) try:
time.sleep(0.5) # let the reader enter the blocking socket read if not reader_blocked.wait(timeout=10.0):
client.close() raise AssertionError("reader never reached the blocked body read")
client_closed.set() time.sleep(0.5) # let the reader enter the blocking socket read
client.close()
except BaseException as exc:
closer_errors.append(exc)
finally:
close_done.set()
server_thread = threading.Thread(target=serve) server_thread = threading.Thread(target=serve)
reader_thread = threading.Thread(target=read_stream)
closer_thread = threading.Thread(target=closer) closer_thread = threading.Thread(target=closer)
server_thread.start() server_thread.start()
reader_thread.start()
closer_thread.start() closer_thread.start()
try: try:
stream = client.chat.completions.create( assert reader_blocked.wait(timeout=10.0)
model="m", messages=[{"role": "user", "content": "hi"}], stream=True assert close_done.wait(timeout=10.0)
) assert closer_errors == []
it = iter(stream) # The server has not closed its peer yet, proving close() completed
first = next(it) # the one sent chunk arrives; the wire then idles # safely rather than merely returning after an EOF unblocked it.
assert first.choices[0].delta.content == "hello" assert server_errors == []
reader_blocked.set() release_peer.set()
with pytest.raises(httpx.TransportError) as excinfo: reader_thread.join(timeout=10.0)
next(it) # blocked read, killed by the cross-thread close()
# Platform/timing-dependent: the killed read surfaces as ReadError
# (EBADF from the blocked recv) or, where the reader observes EOF
# first, RemoteProtocolError (chunked body never terminated). Both
# are TransportError members of _BACKEND_STREAM_EXC_NAMES, so
# transport_guarded converts either and the retry gate passes — the
# property this pin exists for.
assert type(excinfo.value).__name__ in {"ReadError", "RemoteProtocolError"}
finally: finally:
# Unblock and join both threads on every exit path so nothing # Unblock and join every thread on every exit path so nothing
# outlives the test (leaked-thread guard). # outlives the test (leaked-thread guard).
reader_blocked.set() reader_blocked.set()
client_closed.set() release_peer.set()
closer_thread.join(timeout=10.0) closer_thread.join(timeout=10.0)
reader_thread.join(timeout=10.0)
server_thread.join(timeout=10.0) server_thread.join(timeout=10.0)
listener.close() listener.close()
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
client.close() client.close()
assert first_content == ["hello"]
assert len(reader_errors) == 1
exc = reader_errors[0]
assert isinstance(exc, IncompleteStreamError)
assert any(name in str(exc) for name in ("ReadError", "RemoteProtocolError"))
cause = exc.__cause__
assert isinstance(cause, httpx2.TransportError)
assert type(cause).__name__ in {"ReadError", "RemoteProtocolError"}
assert server_errors == []
assert not closer_thread.is_alive() assert not closer_thread.is_alive()
assert not reader_thread.is_alive()
assert not server_thread.is_alive() assert not server_thread.is_alive()
@@ -240,7 +403,7 @@ class TestEagerAppendContract:
requests: list = [] requests: list = []
client = openai.OpenAI( client = openai.OpenAI(
api_key="probe", api_key="probe",
http_client=httpx.Client(transport=_dying_transport(CHAT_CHUNK, requests)), http_client=httpx2.Client(transport=_httpx2_dying_transport(CHAT_CHUNK, requests)),
) )
self._armed_at_return(OpenAIChatCompletionsProvider(), client) self._armed_at_return(OpenAIChatCompletionsProvider(), client)
assert len(requests) == 1 # the HTTP call happened inside create assert len(requests) == 1 # the HTTP call happened inside create
@@ -251,7 +414,7 @@ class TestEagerAppendContract:
requests: list = [] requests: list = []
client = openai.OpenAI( client = openai.OpenAI(
api_key="probe", api_key="probe",
http_client=httpx.Client(transport=_dying_transport(CHAT_CHUNK, requests)), http_client=httpx2.Client(transport=_httpx2_dying_transport(RESPONSES_EVENT, requests)),
) )
self._armed_at_return(OpenAIResponsesProvider(), client) self._armed_at_return(OpenAIResponsesProvider(), client)
assert len(requests) == 1 assert len(requests) == 1
+2 -2
View File
@@ -1,6 +1,6 @@
"""Tests for :meth:`ChatSession._format_backend_error`. """Tests for :meth:`ChatSession._format_backend_error`.
The helper turns bare backend-boundary exceptions (httpx ``ReadTimeout``, The helper turns bare backend-boundary exceptions (HTTPX/HTTPX2 ``ReadTimeout``,
OpenAI SDK ``APITimeoutError`` / ``APIConnectionError`` / OpenAI SDK ``APITimeoutError`` / ``APIConnectionError`` /
``NotFoundError`` / ``RateLimitError`` / ``AuthenticationError``) into ``NotFoundError`` / ``RateLimitError`` / ``AuthenticationError``) into
operator-actionable messages that include the provider, base URL, and operator-actionable messages that include the provider, base URL, and
@@ -225,7 +225,7 @@ def test_rate_limit_with_overflow_phrasing_is_not_mislabeled_overflow():
def _stream_death_exemplars() -> list[BaseException]: def _stream_death_exemplars() -> list[BaseException]:
"""One realistic instance per name in ``_BACKEND_STREAM_EXC_NAMES``: """One realistic instance per name in ``_BACKEND_STREAM_EXC_NAMES``:
the normalized shape the guarded iterators raise, plus the raw httpx the normalized shape the guarded iterators raise, plus the raw HTTPX-family
names for any future unguarded path.""" names for any future unguarded path."""
from turnstone.core.providers import IncompleteStreamError from turnstone.core.providers import IncompleteStreamError
+5 -5
View File
@@ -452,11 +452,11 @@ class OpenAIChatCompletionsProvider:
# operator-declared ``finish_reason_optional`` capability: on a # operator-declared ``finish_reason_optional`` capability: on a
# server that never sends finish reasons, a stream that ended # server that never sends finish reasons, a stream that ended
# CLEANLY — the SDK ends iteration on [DONE]; an abrupt connection # CLEANLY — the SDK ends iteration on [DONE]; an abrupt connection
# death raises httpx.TransportError out of this generator — after # death raises the active HTTP client's TransportError out of this
# delivering output is a completed generation. Everywhere else a # generator — after delivering output is a completed generation.
# clean finish-less end is indistinguishable from a generation # Everywhere else a clean finish-less end is indistinguishable
# that died behind a clean-closing proxy/ASGI layer, so the shim # from a generation that died behind a clean-closing proxy/ASGI layer,
# stays DISARMED and the drain's complete-or-error gate raises # so the shim stays DISARMED and the drain's complete-or-error gate raises
# (retryable) instead of blessing possibly-truncated text. # (retryable) instead of blessing possibly-truncated text.
# Reasoning counts as delivered output — a thinking model that # Reasoning counts as delivered output — a thinking model that
# spent its budget before emitting content is still a completed # spent its budget before emitting content is still a completed
+13 -7
View File
@@ -228,10 +228,12 @@ def transport_guarded(chunks: Iterator[StreamChunk]) -> Iterator[StreamChunk]:
Streaming moves the body read out of the SDK's Streaming moves the body read out of the SDK's
``APIConnectionError``-wrapped request into raw iteration, so a ``APIConnectionError``-wrapped request into raw iteration, so a
mid-body wire death (connection drop, TLS record failure, read mid-body wire death (connection drop, TLS record failure, read
timeout) surfaces as a bare ``httpx.TransportError`` no retry timeout) surfaces as a bare transport error no retry predicate
predicate recognizes. This wrapper is that conversion rule made recognizes. OpenAI v3's default client raises ``httpx2`` errors;
reusable for consumers that keep streaming semantics (the Anthropic, Turnstone's own HTTP clients, and the OpenAI v3 legacy-client
interactive loop); :func:`drain_stream` applies the same rule for escape hatch raise ``httpx`` errors. This wrapper is the one conversion
rule for both families, reusable by consumers that keep streaming
semantics (the interactive loop); :func:`drain_stream` applies it for
the single-shot lanes. the single-shot lanes.
- A ``TransportError`` BEFORE any finish reason re-raises (chained) - A ``TransportError`` BEFORE any finish reason re-raises (chained)
@@ -243,7 +245,11 @@ def transport_guarded(chunks: Iterator[StreamChunk]) -> Iterator[StreamChunk]:
- Everything else chunks, exhaustion, non-transport exceptions - Everything else chunks, exhaustion, non-transport exceptions
passes through untouched. passes through untouched.
""" """
import httpx # noqa: PLC0415 — heavyweight; deferred off the type-module import path # Both libraries are heavyweight; keep them off this module's dataclass-
# only import path. ``httpx`` remains Turnstone's application transport,
# while ``httpx2`` is the OpenAI v3 default transport.
import httpx # noqa: PLC0415
import httpx2 # noqa: PLC0415
finish_seen = False finish_seen = False
usage_seen = False usage_seen = False
@@ -253,7 +259,7 @@ def transport_guarded(chunks: Iterator[StreamChunk]) -> Iterator[StreamChunk]:
sc = next(iterator) sc = next(iterator)
except StopIteration: except StopIteration:
return return
except httpx.TransportError as exc: except (httpx.TransportError, httpx2.TransportError) as exc:
if finish_seen: if finish_seen:
# usage_captured distinguishes "completed result kept but # usage_captured distinguishes "completed result kept but
# its spend went missing from usage accounting" (the chat # its spend went missing from usage accounting" (the chat
@@ -348,7 +354,7 @@ def drain_stream(
Raises whatever the underlying stream raises retry/deadline/fallback Raises whatever the underlying stream raises retry/deadline/fallback
policy stays with the caller, exactly as with the old non-streaming policy stays with the caller, exactly as with the old non-streaming
transport EXCEPT httpx transport failures, normalized by transport EXCEPT HTTPX/HTTPX2 transport failures, normalized by
:func:`transport_guarded` (the one conversion rule, shared with the :func:`transport_guarded` (the one conversion rule, shared with the
interactive loop): a mid-body death before the finish reason is interactive loop): a mid-body death before the finish reason is
re-raised (chained) as :class:`IncompleteStreamError`, restoring the re-raised (chained) as :class:`IncompleteStreamError`, restoring the
+6 -6
View File
@@ -1935,11 +1935,11 @@ _BACKEND_AUTH_EXC_NAMES: frozenset[str] = frozenset(
_BACKEND_RATE_LIMIT_EXC_NAMES: frozenset[str] = frozenset({"RateLimitError"}) _BACKEND_RATE_LIMIT_EXC_NAMES: frozenset[str] = frozenset({"RateLimitError"})
# Mid-response stream deaths: the normalized shape every guarded iterator # Mid-response stream deaths: the normalized shape every guarded iterator
# raises (``IncompleteStreamError`` from ``drain_stream`` / # raises (``IncompleteStreamError`` from ``drain_stream`` /
# ``transport_guarded``) plus the raw httpx names for any future unguarded # ``transport_guarded``) plus the raw HTTPX/HTTPX2 names for any future
# path (defense in depth). Unioning them into ``_BACKEND_KNOWN_EXC_NAMES`` # unguarded path (defense in depth). Unioning them into
# is required — ``_format_backend_error`` gates on that set before the # ``_BACKEND_KNOWN_EXC_NAMES`` is required — ``_format_backend_error`` gates
# branch lookups — and makes these three names ineligible for # on that set before the branch lookups — and makes these three names
# ``_is_ctx_overflow``'s text-based overflow detection (its class # ineligible for ``_is_ctx_overflow``'s text-based overflow detection (its class
# self-gate): harmless, since their texts are fixed transport/SSL strings # self-gate): harmless, since their texts are fixed transport/SSL strings
# that never carry overflow phrases. # that never carry overflow phrases.
_BACKEND_STREAM_EXC_NAMES: frozenset[str] = frozenset( _BACKEND_STREAM_EXC_NAMES: frozenset[str] = frozenset(
@@ -7456,7 +7456,7 @@ class ChatSession:
can arrive as several exception classes); everything else is matched by can arrive as several exception classes); everything else is matched by
class name (see the ``_BACKEND_*_EXC_NAMES`` sets above) so the same class name (see the ``_BACKEND_*_EXC_NAMES`` sets above) so the same
helper covers helper covers
httpx ``ReadTimeout`` / ``ConnectError``, OpenAI SDK HTTPX/HTTPX2 ``ReadTimeout`` / ``ConnectError``, OpenAI SDK
``APITimeoutError`` / ``APIConnectionError`` / ``APITimeoutError`` / ``APIConnectionError`` /
``NotFoundError`` / ``RateLimitError`` / ``AuthenticationError``, ``NotFoundError`` / ``RateLimitError`` / ``AuthenticationError``,
and the Anthropic SDK equivalents (which share names). and the Anthropic SDK equivalents (which share names).
+8 -8
View File
@@ -232,7 +232,7 @@ class HeadlessSession(ChatSession):
- auto_approve is always True - auto_approve is always True
- Tool calls are recorded into a structured log - Tool calls are recorded into a structured log
- All stdout output is suppressed - All stdout output is suppressed
- send_headless() uses non-streaming API - send_headless() drains the production streaming provider path
""" """
def __init__( def __init__(
@@ -307,8 +307,8 @@ class HeadlessSession(ChatSession):
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Run a complete conversation turn headlessly. """Run a complete conversation turn headlessly.
Uses non-streaming API calls. Captures all tool calls into Drains production streaming provider calls into single-shot results.
self.tool_call_log. Captures all tool calls into ``self.tool_call_log``.
Returns the tool call log: list of dicts with keys: Returns the tool call log: list of dicts with keys:
tool: str, args: dict, result: str (truncated), ok: bool, tool: str, args: dict, result: str (truncated), ok: bool,
@@ -533,9 +533,9 @@ def run_with_lifecycle(
past the teardown (which closes only the last one built). past the teardown (which closes only the last one built).
* *drive* (returned by ``build_session``) submitted to a * *drive* (returned by ``build_session``) submitted to a
one-worker executor and bounded by ``future.result(test_timeout)``. one-worker executor and bounded by ``future.result(test_timeout)``.
The per-request httpx timeout cannot bound a STREAM: a trickling The per-request HTTP transport timeout cannot bound a STREAM: a
response resets the read timeout indefinitely, so without the trickling response resets the read timeout indefinitely, so without
wall clock a hung generation occupies a run slot forever. On the wall clock a hung generation occupies a run slot forever. On
timeout the session is DROPPED, not closed: the shutdown did not timeout the session is DROPPED, not closed: the shutdown did not
wait, so the worker is still inside the drive, and ``close()``'s wait, so the worker is still inside the drive, and ``close()``'s
bounded shell-join would trade a bounded leak for a blocked bounded shell-join would trade a bounded leak for a blocked
@@ -736,8 +736,8 @@ def _run_single_test(
) )
def _build_client() -> Any: def _build_client() -> Any:
# Per-attempt client with request-level timeout so httpx aborts # Per-attempt client with a per-read timeout. The executor wall clock
# the HTTP request itself — no zombie connections on the server. # above separately bounds a trickling stream that keeps resetting it.
return OpenAI( return OpenAI(
base_url=client.base_url, base_url=client.base_url,
api_key=client.api_key, api_key=client.api_key,
Generated
+57 -6
View File
@@ -9,7 +9,8 @@ resolution-markers = [
"python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'",
"python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.13' and sys_platform == 'win32'",
"python_full_version == '3.13.*' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'",
"python_full_version < '3.13' and sys_platform != 'win32'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'",
"(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32')",
] ]
[[package]] [[package]]
@@ -972,6 +973,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
] ]
[[package]]
name = "httpcore2"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" },
{ name = "truststore", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" },
]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.28.1" version = "0.28.1"
@@ -996,6 +1010,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
] ]
[[package]]
name = "httpx2"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.18"
@@ -1568,21 +1608,21 @@ wheels = [
[[package]] [[package]]
name = "openai" name = "openai"
version = "2.53.0" version = "3.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "distro" }, { name = "distro" },
{ name = "httpx" }, { name = "httpx2" },
{ name = "jiter" }, { name = "jiter" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "sniffio" }, { name = "sniffio" },
{ name = "tqdm" }, { name = "tqdm" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } sdist = { url = "https://files.pythonhosted.org/packages/54/8c/2f500e8be09d1ae98c530467962535198b02cd4550cd418bbbaedc8b2910/openai-3.0.0.tar.gz", hash = "sha256:ffd00ef1678d70957e1f1ed98d5bfcf1d661f41ea4482f22e7d0144a66435a49", size = 1123740, upload-time = "2026-08-12T01:55:50.849Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, { url = "https://files.pythonhosted.org/packages/7b/0d/9850e7eddb5e66da4439ed503e78e09ad1fd0195e6df51e4236c75763581/openai-3.0.0-py3-none-any.whl", hash = "sha256:8d32ac3a6647a66910d6cb8a64f0fa5a6c823604b6e82db83d9d055c6709bd51", size = 1665775, upload-time = "2026-08-12T01:55:48.678Z" },
] ]
[[package]] [[package]]
@@ -2568,6 +2608,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
] ]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]] [[package]]
name = "turnstone" name = "turnstone"
version = "1.8.0a7" version = "1.8.0a7"
@@ -2581,6 +2630,7 @@ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "httpx" }, { name = "httpx" },
{ name = "httpx-sse" }, { name = "httpx-sse" },
{ name = "httpx2" },
{ name = "lacme" }, { name = "lacme" },
{ name = "mcp" }, { name = "mcp" },
{ name = "openai" }, { name = "openai" },
@@ -2636,10 +2686,11 @@ requires-dist = [
{ name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.4" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.4" },
{ name = "httpx", specifier = ">=0.28" }, { name = "httpx", specifier = ">=0.28" },
{ name = "httpx-sse", specifier = ">=0.4" }, { name = "httpx-sse", specifier = ">=0.4" },
{ name = "httpx2", specifier = ">=2.7,<3" },
{ name = "lacme", specifier = ">=1.0.5" }, { name = "lacme", specifier = ">=1.0.5" },
{ name = "mcp", specifier = ">=1.27,<2" }, { name = "mcp", specifier = ">=1.27,<2" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" },
{ name = "openai", specifier = ">=2.45,<3" }, { name = "openai", specifier = ">=3,<4" },
{ name = "pillow", specifier = ">=10" }, { name = "pillow", specifier = ">=10" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.0" },