Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/openant-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"httpx>=0.24.0",
"httpcore>=1.0.0", # 4b: the google adapter catches its bases directly (belt-and-braces under httpx)
"PyYAML>=6.0",
"requests>=2.31.0",
"tree-sitter>=0.21.0",
Expand Down
95 changes: 95 additions & 0 deletions libs/openant-core/tests/test_llm_google_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,98 @@ def test_tool_use_only_candidate_is_valid_not_empty():
prompt_token_count=1, candidates_token_count=1, total_token_count=2))
result = _response_to_unified(resp)
assert result.content and result.stop_reason == "tool_use"


# ---------------------------------------------------------------------------
# The real-transport guard (#576 review follow-up 4b): the suite's factories
# INJECT httpx errors, so nothing detects a future google-genai default-
# transport flip (the anthropic/openai 3.x SDKs already moved to httpx2; the
# genai sync client subclasses httpx.Client TODAY and references httpx2 in
# the same module — the drift direction is live). These tests drive the
# adapter through the SDK's ACTUAL default transport: a REAL genai.Client
# against an unroutable endpoint, no injected client, no factory stubs. A
# transport flip breaks the exception CLASS IDENTITY — the raised error no
# longer matches the adapter's `except httpx.*` clauses — and these tests
# go RED the day the default transport's error classes diverge.
# ---------------------------------------------------------------------------

def _real_transport_adapter():
"""A GoogleAdapter around a REAL SDK client: hermetic (a dummy key, an
unroutable endpoint via the SDK's own HttpOptions, zero network beyond
the refused connection), max_retries=0 so the failure is immediate."""
from utilities.llm.providers.google import GoogleAdapter
return GoogleAdapter(api_key="test-key",
base_url="http://127.0.0.1:9", max_retries=0)


def test_real_transport_connection_refused_maps_typed():
"""The conn-refused class through the SDK's DEFAULT transport (the #576
probe shape, made permanent): a real genai.Client raises whatever its
real transport raises; the adapter must map it to LLMConnectionError.
RED on a transport flip (the error no longer matches the httpx clauses)."""
from utilities.llm import Message, TextBlock
from utilities.llm.adapter import LLMConnectionError
adapter = _real_transport_adapter()
with pytest.raises(LLMConnectionError):
adapter.complete(model="gemini-2.5-pro", system=None,
messages=[Message(role="user",
content=[TextBlock("hi")])],
max_tokens=8)


def test_real_transport_validate_maps_typed():
"""The same guard on the validate() path (its own except ladder)."""
from utilities.llm.adapter import LLMConnectionError
adapter = _real_transport_adapter()
with pytest.raises(LLMConnectionError):
adapter.validate(model="gemini-2.5-pro")


def test_real_transport_half_open_connection_maps_typed():
"""The accept-then-close case (the #576 review's second shape): a
listener that accepts and immediately closes. If the SDK's default
transport raises a class the adapter's catch list MISSES (e.g.
httpx.RemoteProtocolError / httpx.ReadError), the error escapes the
LLMConnectionError mapping — this test goes RED, naming the missing
clause. Today's conn-refused-only list is asserted live, not assumed."""
import socket
import threading
from utilities.llm import Message, TextBlock
from utilities.llm.adapter import LLMError

listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(4)
port = listener.getsockname()[1]

def accept_and_close():
for _ in range(8): # the SDK retries 0; cover races anyway
try:
conn, _ = listener.accept()
conn.close()
except OSError:
return

t = threading.Thread(target=accept_and_close, daemon=True)
t.start()
try:
from utilities.llm.providers.google import GoogleAdapter
adapter = GoogleAdapter(api_key="test-key",
base_url=f"http://127.0.0.1:{port}",
max_retries=0)
try:
adapter.complete(model="gemini-2.5-pro", system=None,
messages=[Message(role="user",
content=[TextBlock("hi")])],
max_tokens=8)
except LLMError:
pass # typed mapping held — the guard's pass condition
# Anything NOT an LLMError escapes untyped: pytest.raises nothing,
# so the raw exception propagates and THIS test fails with the
# un-typed error's own traceback — naming exactly which transport
# class the adapter's catch list is missing.
finally:
listener.close()
t.join(timeout=2)
finally:
listener.close()
22 changes: 20 additions & 2 deletions libs/openant-core/utilities/llm/providers/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
import threading
from typing import Any, Optional

import httpcore
import httpx
from google import genai
from google.genai import errors as genai_errors
Expand Down Expand Up @@ -303,7 +304,23 @@ def complete(
raise LLMResponseError(redact_secrets(str(exc))) from redacted_cause_from(exc)
except genai_errors.APIError as exc:
raise LLMResponseError(redact_secrets(str(exc))) from redacted_cause_from(exc)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout, httpx.TimeoutException) as exc:
# 4b (the real-transport guard's catch): the previous clause named
# six httpx classes individually and MISSED ReadError /
# RemoteProtocolError (a mid-connection reset escaped untyped —
# proven live by the accept-then-close guard test).
# httpx.TransportError is the single base of every transport error
# in the httpx family (connect, read, write, pool, remote-protocol,
# the timeout family) — strictly wider than the old list, never
# narrower. The httpcore bases are BELT-AND-BRACES for a raw
# httpcore exception arriving un-wrapped (httpx maps httpcore into
# its OWN hierarchy — httpx.ReadError is NOT httpcore.ReadError;
# the families chain via `raise ... from`, neither inherits the
# other). httpcore's roots are NetworkError / ProtocolError /
# TimeoutException (there is no httpcore.TransportError); all three
# are caught. A transport-backend flip (the httpx2 direction the
# anthropic/openai SDKs already took) changes which family arrives;
# this clause and the guard tests keep the mapping honest.
except (httpx.TransportError, httpcore.NetworkError, httpcore.ProtocolError, httpcore.TimeoutException) as exc:
raise LLMConnectionError(redact_secrets(str(exc))) from redacted_cause_from(exc)

return _response_to_unified(response)
Expand Down Expand Up @@ -332,7 +349,8 @@ def validate(self, model: str) -> None:
raise LLMResponseError(redact_secrets(str(exc))) from redacted_cause_from(exc)
except genai_errors.APIError as exc:
raise LLMResponseError(redact_secrets(str(exc))) from redacted_cause_from(exc)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.PoolTimeout, httpx.TimeoutException) as exc:
# The same transport clause as complete()'s (see its comment).
except (httpx.TransportError, httpcore.NetworkError, httpcore.ProtocolError, httpcore.TimeoutException) as exc:
raise LLMConnectionError(redact_secrets(str(exc))) from redacted_cause_from(exc)


Expand Down
Loading