chore(deps): anthropic 1.5, openai 3.13, and the move to httpx2

Both SDKs' 1.x/3.x majors run on httpx2 (the maintained fork of httpx by its
original author, published by Pydantic at github.com/pydantic/httpx2, version
line 2.x) instead of httpx, which is what makes these two bumps one change.
httpx2 is now a declared dependency because two modules import it directly.
Everything else in the 0.x -> 1.x / 2.x -> 3.x change lists is absent here:
no Text Completions, no `with_raw_response`, no raw `output_format` dicts, no
Bedrock client, and `requires-python` is already 3.12.

Three code changes, all forced by the bump:

The BYOM DNS-pinning client in `docsgpt/security/safe_url.py` is handed to
`OpenAI(http_client=...)`, and the SDK rejects an old-httpx client at
construction — which would have taken the SSRF guard offline. It is built on
httpx2 now, and its `sni_hostname` extension carries a `str` rather than
ascii bytes: httpcore passes the value straight to
`ssl.SSLContext.wrap_socket`, and the truststore backend httpx2 uses for the
default system trust store encodes it instead of accepting bytes. Bytes
therefore failed every real handshake while passing the existing tests, which
stub the transport out; the test now pins the type and says why.

The stream-retry error tuple in `docsgpt/llm/base.py` named `httpx`
exceptions only. The two libraries' exception classes are unrelated types, so
after the bump the retry silently stopped firing for openai and anthropic
while still working for google-genai and elevenlabs. It now covers both
stacks, with a parametrized test over each.

anthropic 1.x dropped temperature/top_p/top_k from `messages.create`'s
signature (passing one raises TypeError) without dropping them from the API,
so the provider forwards them through `extra_body`. The wire request is
unchanged and a model that rejects them 400s exactly as before.
This commit is contained in:
Alex committed 2026-09-12 17:44:18 +01:00
1 parent b3326b3c33
commit f7baa1952f
12 files changed
+237 -73

No files matched your search

+7 -4
View File
@@ -416,12 +416,15 @@ class TestSecurityDispatchSSRFGuard:
def test_dispatch_injects_pinned_http_client_for_user_model(
self, pg_conn
):
"""LLMCreator must build a DNS-rebinding-safe httpx.Client and
"""LLMCreator must build a DNS-rebinding-safe httpx2.Client and
forward it to the OpenAI SDK so the SDK's request-time DNS
lookup cannot escape the create-time SSRF guard. ``validate_
user_base_url`` alone is TOCTOU and does not close the
rebinding window — the pinned client is what does."""
import httpx
rebinding window — the pinned client is what does. The client
must be httpx2's: the OpenAI SDK rejects an old-httpx client
at construction, which would take the guard offline.
"""
import httpx2
from docsgpt.core.model_settings import (
AvailableModel,
@@ -474,7 +477,7 @@ class TestSecurityDispatchSSRFGuard:
client = captured["http_client"]
try:
assert isinstance(client, httpx.Client), (
assert isinstance(client, httpx2.Client), (
"http_client must be set for user-source models so the "
"OpenAI SDK doesn't re-resolve DNS at request time"
)
+8 -2
View File
@@ -585,6 +585,10 @@ class TestRawGen:
assert _sent(llm)["max_tokens"] == 1234
def test_sampling_params_forwarded(self, llm):
"""anthropic 1.x dropped the sampling params from
``messages.create``'s signature (passing one is a TypeError), so they
ride in ``extra_body``, which the SDK merges into the request JSON
as-is. The wire request is unchanged."""
llm._raw_gen(
llm,
model="m",
@@ -592,8 +596,10 @@ class TestRawGen:
temperature=0.3,
top_p=0.9,
)
assert _sent(llm)["temperature"] == 0.3
assert _sent(llm)["top_p"] == 0.9
sent = _sent(llm)
assert sent["extra_body"] == {"temperature": 0.3, "top_p": 0.9}
assert "temperature" not in sent
assert "top_p" not in sent
def test_openai_only_params_not_forwarded(self, llm):
"""OpenAI-shaped request params reach every provider via
+31
View File
@@ -10,6 +10,7 @@ import types
from unittest.mock import MagicMock
import httpx
import httpx2
import pytest
from docsgpt.llm.anthropic import AnthropicLLM
@@ -362,6 +363,36 @@ class TestStreamingFallback:
assert primary.stream_calls == 2
assert not backup.gen_stream_called
@pytest.mark.parametrize(
"error",
[httpx2.RemoteProtocolError, httpx.RemoteProtocolError],
ids=["httpx2", "httpx"],
)
def test_stream_transport_error_retries_on_either_http_stack(
self, patch_model_utils, error
):
"""The providers are split across two HTTP stacks whose exception
classes are unrelated types: openai and anthropic raise from httpx2,
google-genai and elevenlabs still from httpx. Naming one stack makes
the retry silently stop firing for the other half.
"""
backup = FakeLLM(stream_chunks=["fallback"])
patch_model_utils(
get_provider=lambda m, **_kwargs: "openai",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
primary = FakeLLM(
stream_chunks=["ok1", "ok2"],
backup_models=["backup-model"],
)
primary.fail_schedule = [0, None]
primary.error_schedule = [error, RuntimeError]
assert list(primary.gen_stream(**CALL_ARGS)) == ["ok1", "ok2"]
assert primary.stream_calls == 2
assert not backup.gen_stream_called
def test_stream_transport_error_retry_then_fails_uses_fallback(
self, patch_model_utils
):
+15 -7
View File
@@ -537,7 +537,7 @@ def _capture_httpx_handle_request(monkeypatch):
opening a real socket.
"""
import httpx
import httpx2
captured: dict = {}
@@ -545,10 +545,10 @@ def _capture_httpx_handle_request(monkeypatch):
captured["url"] = request.url
captured["sni"] = request.extensions.get("sni_hostname")
captured["host_header"] = request.headers.get("host")
return httpx.Response(200, content=b"ok")
return httpx2.Response(200, content=b"ok")
monkeypatch.setattr(
"httpx.HTTPTransport.handle_request", fake_handle
"httpx2.HTTPTransport.handle_request", fake_handle
)
return captured
@@ -610,7 +610,14 @@ def test_pinned_httpx_transport_rewrites_url_to_validated_ip(monkeypatch):
def test_pinned_httpx_transport_sets_sni_for_original_hostname(monkeypatch):
"""TLS SNI / cert verification must use the original hostname; the
transport sets it via the ``sni_hostname`` extension that
httpcore forwards to ``start_tls``'s ``server_hostname``."""
httpcore forwards to ``start_tls``'s ``server_hostname``.
The value must be a ``str``, not ascii bytes: httpcore passes it
straight to ``ssl.SSLContext.wrap_socket``, and the ``truststore``
backend httpx2 uses for the default system trust store encodes it
rather than accepting bytes — so bytes fail every real handshake
while passing any test that stubs the transport out.
"""
captured = _capture_httpx_handle_request(monkeypatch)
@@ -623,7 +630,8 @@ def test_pinned_httpx_transport_sets_sni_for_original_hostname(monkeypatch):
finally:
client.close()
assert captured["sni"] == b"api.example.com"
assert captured["sni"] == "api.example.com"
assert isinstance(captured["sni"], str)
@pytest.mark.unit
@@ -712,7 +720,7 @@ def test_pinned_httpx_transport_refuses_unexpected_host(monkeypatch):
transport refuses rather than silently dialing the validated IP
with a different host's credentials."""
import httpx
import httpx2
with mock.patch(
"socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
@@ -721,7 +729,7 @@ def test_pinned_httpx_transport_refuses_unexpected_host(monkeypatch):
try:
with pytest.raises(UnsafeUserUrlError, match="refused request"):
client.get("https://other.example.com/v1/test")
except httpx.RequestError:
except httpx2.RequestError:
# Some httpx versions may wrap the transport error; accept
# either path so long as the request didn't succeed.
pass