diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 5e36b1f37..0a04bee01 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -117,6 +117,63 @@ def _get_anthropic_max_output(model: str) -> int: return best_val +def _resolve_positive_anthropic_max_tokens(value) -> Optional[int]: + """Return ``value`` floored to a positive int, or ``None`` if it is not a + finite positive number. Ported from openclaw/openclaw#66664. + + Anthropic's Messages API rejects ``max_tokens`` values that are 0, + negative, non-integer, or non-finite with HTTP 400. Python's ``or`` + idiom (``max_tokens or fallback``) correctly catches ``0`` but lets + negative ints and fractional floats (``-1``, ``0.5``) through to the + API, producing a user-visible failure instead of a local error. + """ + # Booleans are a subclass of int — exclude explicitly so ``True`` doesn't + # silently become 1 and ``False`` doesn't become 0. + if isinstance(value, bool): + return None + if not isinstance(value, (int, float)): + return None + try: + import math + if not math.isfinite(value): + return None + except Exception: + return None + floored = int(value) # truncates toward zero for floats + return floored if floored > 0 else None + + +def _resolve_anthropic_messages_max_tokens( + requested, + model: str, + context_length: Optional[int] = None, +) -> int: + """Resolve the ``max_tokens`` budget for an Anthropic Messages call. + + Prefers ``requested`` when it is a positive finite number; otherwise + falls back to the model's output ceiling. Raises ``ValueError`` if no + positive budget can be resolved (should not happen with current model + table defaults, but guards against a future regression where + ``_get_anthropic_max_output`` could return ``0``). + + Separately, callers apply a context-window clamp — this resolver does + not, to keep the positive-value contract independent of endpoint + specifics. + + Ported from openclaw/openclaw#66664 (resolveAnthropicMessagesMaxTokens). + """ + resolved = _resolve_positive_anthropic_max_tokens(requested) + if resolved is not None: + return resolved + fallback = _get_anthropic_max_output(model) + if fallback > 0: + return fallback + raise ValueError( + f"Anthropic Messages adapter requires a positive max_tokens value for " + f"model {model!r}; got {requested!r} and no model default resolved." + ) + + def _supports_adaptive_thinking(model: str) -> bool: """Return True for Claude 4.6+ models that support adaptive thinking.""" return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS) @@ -1391,7 +1448,12 @@ def build_anthropic_kwargs( model = normalize_model_name(model, preserve_dots=preserve_dots) # effective_max_tokens = output cap for this call (≠ total context window) - effective_max_tokens = max_tokens or _get_anthropic_max_output(model) + # Use the resolver helper so non-positive values (negative ints, + # fractional floats, NaN, non-numeric) fail locally with a clear error + # rather than 400-ing at the Anthropic API. See openclaw/openclaw#66664. + effective_max_tokens = _resolve_anthropic_messages_max_tokens( + max_tokens, model, context_length=context_length + ) # Clamp output cap to fit inside the total context window. # Only matters for small custom endpoints where context_length < native diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index b947a2df8..dedf3e125 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -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