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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/claude-messages/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
}
)

Expand Down Expand Up @@ -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)),
Expand All @@ -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(
Expand Down
37 changes: 26 additions & 11 deletions packages/claude-messages/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand All @@ -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),
}
Expand All @@ -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
Expand Down
43 changes: 40 additions & 3 deletions packages/claude-messages/tests/test_parameter_forwarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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"
Expand All @@ -77,16 +86,44 @@ 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"
)


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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading