diff --git a/src/llm_core.py b/src/llm_core.py index e4d2c51..9ee4ca6 100644 --- a/src/llm_core.py +++ b/src/llm_core.py @@ -512,6 +512,12 @@ def _build_anthropic_payload(model, messages, temperature, max_tokens, stream=Fa # Convert multimodal content (image_url → image) for Anthropic content = _convert_openai_content_to_anthropic(m["content"]) chat_messages.append({"role": m["role"], "content": content}) + # Anthropic only accepts temperature in [0.0, 1.0] and 400s on anything above + # 1.0. Clamp here (in the Anthropic builder only) so presets/sliders that use + # the wider OpenAI 0.0-2.0 range — e.g. the shipped "Nietzsche" preset at 1.2 + # — don't hard-break every Claude request. OpenAI's own path is left untouched. + if temperature is not None: + temperature = max(0.0, min(temperature, 1.0)) payload = { "model": model, "messages": chat_messages, diff --git a/tests/test_llm_core_anthropic_temp_clamp.py b/tests/test_llm_core_anthropic_temp_clamp.py new file mode 100644 index 0000000..d2f81ca --- /dev/null +++ b/tests/test_llm_core_anthropic_temp_clamp.py @@ -0,0 +1,40 @@ +"""Regression guard for #1615 — Anthropic temperature must be clamped to [0.0, 1.0]. + +Anthropic's Messages API rejects temperature > 1.0 with HTTP 400. The shipped +"Nietzsche" preset uses temperature 1.2 (static/js/presets.js) and the UI slider +allows up to 2.0 (static/index.html), so _build_anthropic_payload must clamp into +[0.0, 1.0]. The clamp lives only in the Anthropic builder — OpenAI keeps its +wider 0.0-2.0 range. +""" +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") + +from src.llm_core import _build_anthropic_payload + + +def _temp(t): + payload = _build_anthropic_payload( + "claude-x", [{"role": "user", "content": "hi"}], t, 100 + ) + return payload["temperature"] + + +def test_above_range_is_clamped_to_one(): + assert _temp(1.2) == 1.0 # the shipped "Nietzsche" preset — previously 400'd + assert _temp(2.0) == 1.0 # UI slider max + + +def test_in_range_is_unchanged(): + assert _temp(0.0) == 0.0 + assert _temp(0.7) == 0.7 + assert _temp(1.0) == 1.0 + + +def test_below_range_is_clamped_to_zero(): + assert _temp(-0.5) == 0.0 + + +def test_none_is_passed_through_unchanged(): + # Callers may pass None; behavior is unchanged (no clamp, no crash). + assert _temp(None) is None