diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76f2aed..6f23a45f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,27 @@ jobs: # holds the invariants no single package can own. `make test` runs this same bare command. - run: uv run pytest + # The lock pins the lowest anthropic the Claude packages allow, so `test` never sees a newer + # one. anthropic 1.0 removed keyword arguments the handler used to pass (AIC-3735); this runs + # the full suite, whose Claude Messages drift tests read the SDK's own signatures, on the + # newest anthropic claude-messages allows. `uv pip install anthropic` alone ignores that + # package's cap, so the requirement is read from its pyproject.toml, and --upgrade-package + # upgrades anthropic only, not its dependencies. + test-latest-anthropic: + name: Tests (newest anthropic) + needs: setup + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - run: uv lock + - run: uv sync --all-packages + - run: uv pip install --upgrade-package anthropic -r packages/claude-messages/pyproject.toml + - run: uv run --no-sync python -c "import anthropic; print('anthropic', anthropic.__version__)" + - run: uv run --no-sync pytest + # ─── Build ──────────────────────────────────────────────────────────────── build: name: Build all packages diff --git a/packages/claude-messages/pyproject.toml b/packages/claude-messages/pyproject.toml index 8044191c..666031ef 100644 --- a/packages/claude-messages/pyproject.toml +++ b/packages/claude-messages/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server>=0.6,<2", "opentelemetry-api>=1.25", - "anthropic>=0.113", + "anthropic>=0.113,<2", ] description = "Anthropic Claude messages handler for LaunchDarkly AI SDK" readme = "README.md" diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index fd0190d9..4a2a6658 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -78,6 +78,12 @@ } ) +#: Forwarded keys that ``anthropic`` 1.0 removed from ``messages.create``/``.stream`` (a ``TypeError`` +#: if passed as keyword arguments). The API still honours them for models that predate the change, +#: and ``extra_body`` is merged into the request JSON as-is on every ``anthropic`` release, so +#: :func:`_forwarded_parameters` sends them there rather than branching on the installed version. +_MESSAGES_SAMPLING_KEYS = frozenset({"temperature", "top_k", "top_p"}) + #: Forwarded keys whose value must be an object. A config value of any other type is malformed #: and dropped rather than passed to the API. _MESSAGES_MAPPING_KEYS = frozenset( @@ -170,6 +176,12 @@ def _rebuild_typed_objects(params: dict[str, Any]) -> dict[str, Any]: "extra_headers", "extra_query", "extra_body", + # Added after anthropic 0.113: a per-request diagnostics opt-in, a header that picks the + # workspace for a credential, and an inference speed (price/latency) mode. None of them + # shapes what the model generates. + "diagnostics", + "speed", + "workspace_id", } ) @@ -218,9 +230,10 @@ def _forwarded_parameters(config: AiConfigRep) -> dict[str, Any]: moved into ``output_config``, then only :data:`_MESSAGES_FORWARDED_KEYS`, with malformed object values dropped, typed objects rebuilt (see :data:`_TYPED_OBJECT_SHAPES`), and ``output_config`` narrowed to ``effort`` (see :func:`_rebuild_output_config`). ``invoke`` and - ``stream`` both call this, so they forward the same keys. + ``stream`` both call this, so they forward the same keys. The sampling keys + (:data:`_MESSAGES_SAMPLING_KEYS`) come back inside ``extra_body``, not as top-level keys. """ - return _rebuild_output_config( + params = _rebuild_output_config( _rebuild_typed_objects( select_forwarded_parameters( _rename_effort_to_output_config(model_parameters(config)), @@ -229,6 +242,10 @@ def _forwarded_parameters(config: AiConfigRep) -> dict[str, Any]: ) ) ) + sampling = {k: params.pop(k) for k in _MESSAGES_SAMPLING_KEYS & params.keys()} + if sampling: + params["extra_body"] = sampling + return params def _build_tools( diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index 26f8ab9d..ce8f6835 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -12,7 +12,7 @@ from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch -import httpx +import anthropic._base_client as _anthropic_base_client import pytest from tests.forwarding_spec import ( @@ -26,6 +26,10 @@ find_leaks, ) +# anthropic>=1 builds its client on httpx2 and rejects an httpx client; use whichever it imports. +httpx = getattr(_anthropic_base_client, "httpx2", None) or _anthropic_base_client.httpx + + # --------------------------------------------------------------------------- # Fake anthropic response helpers # --------------------------------------------------------------------------- @@ -389,7 +393,7 @@ async def test_snake_case_param_reaches_provider( h = create_claude_messages_handler() await h(config, "q", {}, {}) call_kwargs = mock_anthropic.messages.create.call_args.kwargs - assert call_kwargs["top_p"] == 0.5 + assert call_kwargs["extra_body"]["top_p"] == 0.5 async def test_max_tokens_default_preserved_without_parameters( self, mock_anthropic: MagicMock @@ -469,7 +473,7 @@ async def test_streaming_forwards_snake_case_param( async for _event in gen: pass call_kwargs = mock_anthropic.messages.stream.call_args.kwargs - assert call_kwargs["top_p"] == 0.3 + assert call_kwargs["extra_body"]["top_p"] == 0.3 async def test_top_p_and_max_tokens_reach_the_wire(self) -> None: """Intercepts the real outgoing HTTP request with an httpx MockTransport, rather than @@ -506,13 +510,15 @@ def _handler(request: httpx.Request) -> httpx.Response: **CONFIG, "model": { **CONFIG["model"], - "parameters": {"top_p": 0.4, "max_tokens": 256}, + "parameters": {"temperature": 0.2, "top_p": 0.4, "max_tokens": 256}, }, } with patch("anthropic.AsyncAnthropic", return_value=real_client): h = create_claude_messages_handler() await h(config, "q", {}, {}) + # anthropic>=1 rejects these as keyword arguments; they still reach the wire via extra_body. + assert captured["body"]["temperature"] == 0.2 assert captured["body"]["top_p"] == 0.4 assert captured["body"]["max_tokens"] == 256 @@ -549,8 +555,7 @@ async def test_transport_key_is_never_forwarded( h = create_claude_messages_handler() await h(config, "q", {}, {}) call_kwargs = mock_anthropic.messages.create.call_args.kwargs - assert call_kwargs["top_p"] == 0.5 - assert "extra_body" not in call_kwargs + assert call_kwargs["extra_body"] == {"top_p": 0.5} async def test_stream_key_is_never_forwarded( self, mock_anthropic: MagicMock @@ -2860,7 +2865,7 @@ async def test_invoke(self, mock_anthropic: MagicMock) -> None: call_kwargs = mock_anthropic.messages.create.call_args.kwargs assert "output_format" not in call_kwargs assert call_kwargs["output_config"] == {"effort": "low"} - assert call_kwargs["top_p"] == 0.4 + assert call_kwargs["extra_body"]["top_p"] == 0.4 async def test_stream(self, mock_anthropic: MagicMock) -> None: import launchdarkly_ai_claude_messages.spans as spans_mod @@ -2876,7 +2881,7 @@ async def test_stream(self, mock_anthropic: MagicMock) -> None: call_kwargs = mock_anthropic.messages.stream.call_args.kwargs assert "output_format" not in call_kwargs assert call_kwargs["output_config"] == {"effort": "low"} - assert call_kwargs["top_p"] == 0.4 + assert call_kwargs["extra_body"]["top_p"] == 0.4 class TestOutputConfigKeepsOnlyEffort: @@ -3027,7 +3032,9 @@ def _forwarded(parameters: dict[str, Any]) -> dict[str, Any]: def test_a_missing_or_unknown_type_or_required_sub_key_is_dropped( self, parameters: dict[str, Any] ) -> None: - assert self._forwarded({**parameters, "top_p": 0.4}) == {"top_p": 0.4} + assert self._forwarded({**parameters, "top_p": 0.4}) == { + "extra_body": {"top_p": 0.4} + } @pytest.mark.parametrize( ("key", "value", "expected"), @@ -3079,6 +3086,11 @@ def test_the_shapes_match_the_sdk_types(self) -> None: from launchdarkly_ai_claude_messages.handler import _TYPED_OBJECT_SHAPES + # Thinking variants newer anthropic releases declare that the handler does not forward yet + # (the API takes them; a config that sets one is dropped, not an error). Add one to + # ``_TYPED_OBJECT_SHAPES`` and remove it here to forward it. + not_forwarded_thinking = {"between_tools"} + def shapes(*variants: Any) -> dict[str, tuple[frozenset[str], frozenset[str]]]: # The SDK's TypedDicts are total=False with ``Required[...]`` under postponed # annotations, so ``__required_keys__`` is empty; read ``Required`` from the hints. @@ -3096,8 +3108,11 @@ def shapes(*variants: Any) -> dict[str, tuple[frozenset[str], frozenset[str]]]: ) return out + sdk_thinking = shapes(*typing.get_args(ThinkingConfigParam)) + for kind in not_forwarded_thinking: + sdk_thinking.pop(kind, None) assert _TYPED_OBJECT_SHAPES == { - "thinking": shapes(*typing.get_args(ThinkingConfigParam)), + "thinking": sdk_thinking, "tool_choice": shapes(*typing.get_args(ToolChoiceParam)), "cache_control": shapes(CacheControlEphemeralParam), } @@ -3121,7 +3136,7 @@ def _assert_dropped(self, kwargs: dict[str, Any]) -> None: assert not {"thinking", "cache_control", "output_config", "tool_choice"} & set( kwargs ) - assert kwargs["top_p"] == 0.4 + assert kwargs["extra_body"]["top_p"] == 0.4 async def test_invoke(self, mock_anthropic: MagicMock) -> None: from launchdarkly_ai_claude_messages import create_claude_messages_handler diff --git a/packages/claude-messages/tests/test_parameter_forwarding.py b/packages/claude-messages/tests/test_parameter_forwarding.py index 5c6a33b8..ea41618c 100644 --- a/packages/claude-messages/tests/test_parameter_forwarding.py +++ b/packages/claude-messages/tests/test_parameter_forwarding.py @@ -14,11 +14,14 @@ import inspect from collections.abc import Callable +import pytest from anthropic.resources.messages import AsyncMessages from launchdarkly_ai_claude_messages.handler import ( _MESSAGES_EXCLUDED_KEYS, _MESSAGES_FORWARDED_KEYS, + _MESSAGES_SAMPLING_KEYS, + _forwarded_parameters, ) #: Handler-owned: set by each call site itself, never taken from the config. @@ -37,6 +40,10 @@ def _signature_keys(fn: Callable[..., object]) -> frozenset[str]: return frozenset(keys) +#: Excluded keys that only newer ``anthropic`` releases accept (``pyproject.toml`` allows back to +#: 0.113), so a missing one is not stale. +_EXCLUDED_ADDED_LATER = frozenset({"diagnostics", "speed", "workspace_id"}) + _CREATE_KEYS = _signature_keys(AsyncMessages.create) _STREAM_KEYS = _signature_keys(AsyncMessages.stream) @@ -59,7 +66,9 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: assert not overlap, f"keys classified more than once: {sorted(overlap)}" def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: - stale = (_MESSAGES_FORWARDED_KEYS | _OWNED_KEYS) - _CREATE_KEYS + stale = ( + (_MESSAGES_FORWARDED_KEYS - _MESSAGES_SAMPLING_KEYS) | _OWNED_KEYS + ) - _CREATE_KEYS assert not stale, ( f"{sorted(stale)} forwarded or handler-owned in claude-messages handler.py but " "AsyncMessages.create does not accept them" @@ -77,7 +86,9 @@ def test_every_accepted_key_is_classified_exactly_once(self) -> None: ) def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: - stale = (_MESSAGES_FORWARDED_KEYS | _OWNED_KEYS) - _STREAM_KEYS + stale = ( + (_MESSAGES_FORWARDED_KEYS - _MESSAGES_SAMPLING_KEYS) | _OWNED_KEYS + ) - _STREAM_KEYS assert not stale, ( f"{sorted(stale)} forwarded or handler-owned in claude-messages handler.py but " "AsyncMessages.stream does not accept them" @@ -85,8 +96,34 @@ def test_every_forwarded_or_owned_key_is_a_real_parameter(self) -> None: def test_every_excluded_key_is_a_real_parameter() -> None: - stale = _MESSAGES_EXCLUDED_KEYS - (_CREATE_KEYS | _STREAM_KEYS) + stale = (_MESSAGES_EXCLUDED_KEYS - _EXCLUDED_ADDED_LATER) - ( + _CREATE_KEYS | _STREAM_KEYS + ) assert not stale, ( f"{sorted(stale)} excluded in claude-messages handler.py but neither " "AsyncMessages.create nor .stream accepts them" ) + + +@pytest.mark.parametrize("call", [AsyncMessages.create, AsyncMessages.stream]) +def test_forwarded_parameters_bind_to_the_installed_sdk( + call: Callable[..., object], +) -> None: + """``anthropic`` 1.0 dropped ``temperature``/``top_p``/``top_k`` from both calls, so passing + them as keyword arguments is a ``TypeError`` ("unexpected keyword argument"). Binds what the + handler would pass against the installed SDK's real signature, which fails on any release + that rejects a forwarded key and passes on the ones that take it.""" + params = _forwarded_parameters( + { + "model": { + "parameters": { + "temperature": 0.2, + "top_p": 0.9, + "top_k": 40, + "stop_sequences": ["END"], + } + } + } + ) + assert params["extra_body"] == {"temperature": 0.2, "top_p": 0.9, "top_k": 40} + inspect.signature(call).bind(None, model="m", max_tokens=1, messages=[], **params) diff --git a/uv.lock b/uv.lock index 3c521c23..eeb1d2e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1365,7 +1365,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.113" }, + { name = "anthropic", specifier = ">=0.113,<2" }, { name = "launchdarkly-ai-server", editable = "packages/client" }, { name = "opentelemetry-api", specifier = ">=1.25" }, ]