From 44aa2a351047cacedf9f170df254117365fad3d5 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Fri, 30 Oct 2026 12:36:38 -0700 Subject: [PATCH 1/2] fix(AIC-3735): send Claude Messages sampling parameters through extra_body anthropic 1.0.0 removed temperature, top_p and top_k from messages.create and messages.stream, so every claude-messages call whose AI Config set one raised "AsyncMessages.create() got an unexpected keyword argument". The API still accepts them, and extra_body is merged into the request JSON on every anthropic release, so the handler now sends them there. The wire body is unchanged on 0.113 and now works on 1.x and 2.x. The drift tests now cover the full allowed range (>=0.113): the sampling keys are no longer required in the SDK signatures, and diagnostics, speed and workspace_id (added after 0.113) are excluded. The thinking between_tools variant is listed as not forwarded yet. A new CI job runs the claude-messages suite against the newest anthropic, since the lock pins 0.113.0. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 ++++++++ .../handler.py | 21 ++++++++- .../claude-messages/tests/test_handler.py | 37 +++++++++++----- .../tests/test_parameter_forwarding.py | 43 +++++++++++++++++-- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76f2aed..547c3fe6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,24 @@ 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 Claude Messages suite, whose drift tests read the SDK's own signatures, on the newest. + 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 anthropic + - run: uv run --no-sync python -c "import anthropic; print('anthropic', anthropic.__version__)" + - run: uv run --no-sync pytest packages/claude-messages + # ─── Build ──────────────────────────────────────────────────────────────── build: name: Build all packages 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) From 7a54bf0214ac75a8ebb3cc856cbbca70819b0629 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Fri, 30 Oct 2026 13:04:43 -0700 Subject: [PATCH 2/2] fix(AIC-3735): cap claude-messages below anthropic 2 and test the newest 1.x anthropic 2.x sends API version 2026-10-21, where the API returns 400 for temperature, top_p and top_k, so the extra_body route only holds for the anthropic 0.x and 1.x releases that send 2023-06-01. Cap claude-messages at anthropic<2 until a follow-up decides the 2026-10-21 behaviour. The newest-anthropic CI job now reads the requirement from claude-messages' pyproject.toml, because `uv pip install anthropic` ignores that cap, and uses --upgrade-package so only anthropic moves off the lock (plain --upgrade also upgraded opentelemetry-api and pydantic, breaking the OTel imports). It runs the full suite instead of claude-messages only. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 ++++++--- packages/claude-messages/pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 547c3fe6..6f23a45f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,10 @@ jobs: # 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 Claude Messages suite, whose drift tests read the SDK's own signatures, on the newest. + # 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 @@ -82,9 +85,9 @@ jobs: python-version: "3.12" - run: uv lock - run: uv sync --all-packages - - run: uv pip install --upgrade anthropic + - 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 packages/claude-messages + - run: uv run --no-sync pytest # ─── Build ──────────────────────────────────────────────────────────────── build: 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/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" }, ]