fix(anthropic): guard max_tokens against non-positive values

Port from openclaw/openclaw#66664. The build_anthropic_kwargs call site
used 'max_tokens or _get_anthropic_max_output(model)', which correctly
falls back when max_tokens is 0 or None (falsy) but lets negative ints
(-1, -500), fractional floats (0.5, 8192.7), NaN, and infinity leak
through to the Anthropic API. Anthropic rejects these with HTTP 400
('max_tokens: must be greater than or equal to 1'), turning a local
config error into a surprise mid-conversation failure.

Add two resolver helpers matching OpenClaw's:
  _resolve_positive_anthropic_max_tokens — returns int(value) only if
    value is a finite positive number; excludes bools, strings, NaN,
    infinity, sub-one positives (floor to 0).
  _resolve_anthropic_messages_max_tokens — prefers a positive requested
    value, else falls back to the model's output ceiling; raises
    ValueError only if no positive budget can be resolved.

The context-window clamp at the call site (max_tokens > context_length)
is preserved unchanged — it handles oversized values; the new resolver
handles non-positive values. These concerns are now cleanly separated.

Tests: 17 new cases covering positive/zero/negative ints, fractional
floats (both >1 and <1), NaN, infinity, booleans, strings, None, and
integration via build_anthropic_kwargs.

Refs: openclaw/openclaw#66664
This commit is contained in:
Teknium
2026-04-19 17:18:23 -07:00
committed by Teknium
parent 8152de2a84
commit c9c6182839
2 changed files with 151 additions and 1 deletions

View File

@@ -1659,3 +1659,91 @@ class TestToolChoice:
tool_choice="search",
)
assert kwargs["tool_choice"] == {"type": "tool", "name": "search"}
# ---------------------------------------------------------------------------
# max_tokens resolver — openclaw/openclaw#66664 port
# ---------------------------------------------------------------------------
from agent.anthropic_adapter import (
_resolve_positive_anthropic_max_tokens,
_resolve_anthropic_messages_max_tokens,
)
class TestResolvePositiveMaxTokens:
"""Unit tests for the positive-int resolver helper."""
def test_positive_int_passes_through(self):
assert _resolve_positive_anthropic_max_tokens(8192) == 8192
def test_zero_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(0) is None
def test_negative_int_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(-1) is None
assert _resolve_positive_anthropic_max_tokens(-500) is None
def test_fractional_float_floored_and_kept_if_positive(self):
# 8192.7 -> 8192, still positive
assert _resolve_positive_anthropic_max_tokens(8192.7) == 8192
def test_small_positive_float_below_one_returns_none(self):
# 0.5 floors to 0, which is not positive
assert _resolve_positive_anthropic_max_tokens(0.5) is None
def test_negative_float_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(-1.5) is None
def test_nan_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(float("nan")) is None
def test_infinity_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(float("inf")) is None
assert _resolve_positive_anthropic_max_tokens(float("-inf")) is None
def test_bool_true_returns_none(self):
# True is an int subclass but semantically never a real max_tokens value
assert _resolve_positive_anthropic_max_tokens(True) is None
assert _resolve_positive_anthropic_max_tokens(False) is None
def test_string_returns_none(self):
assert _resolve_positive_anthropic_max_tokens("8192") is None
def test_none_returns_none(self):
assert _resolve_positive_anthropic_max_tokens(None) is None
class TestResolveMessagesMaxTokens:
"""Integration tests for the full Messages resolver."""
def test_positive_requested_wins(self):
assert _resolve_anthropic_messages_max_tokens(
8192, "claude-opus-4-6"
) == 8192
def test_zero_falls_back_to_model_default(self):
# Should use _get_anthropic_max_output(model), not crash
result = _resolve_anthropic_messages_max_tokens(0, "claude-opus-4-6")
assert result > 0
def test_none_falls_back_to_model_default(self):
result = _resolve_anthropic_messages_max_tokens(None, "claude-opus-4-6")
assert result > 0
def test_negative_falls_back_to_model_default(self):
# Previously leaked -1 to the API; now falls back safely
result = _resolve_anthropic_messages_max_tokens(-1, "claude-opus-4-6")
assert result > 0
def test_fractional_positive_floored(self):
assert _resolve_anthropic_messages_max_tokens(
8192.5, "claude-opus-4-6"
) == 8192
def test_sub_one_float_falls_back(self):
# 0.5 floors to 0 -> not positive -> falls back to model ceiling
result = _resolve_anthropic_messages_max_tokens(0.5, "claude-opus-4-6")
assert result > 0
assert result != 0