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
26 changes: 19 additions & 7 deletions src/llm_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1237,15 +1237,27 @@ def _anthropic_rejects_temperature(model: str) -> bool:
return False
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
# minor match, kept) instead of reading the date `20250514` as a giant minor
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
# 20260201`) keep their explicit minor and are still matched.
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
# temperature). Both version components are capped at 1-2 digits and forbid a
# trailing digit, so an 8-digit date can never be read as a version number:
# `claude-opus-4-20250514` (Opus 4.0) parses as major-only rather than reading
# `20250514` as a giant minor, and `claude-3-opus-20240229` (legacy Claude 3
# Opus, date directly after "opus-") fails to match at all rather than reading
# the date as a giant major. Dated 4.7+ snapshots (`claude-opus-4-7-20260201`)
# keep their explicit minor and are still matched.
#
# The minor is optional and a missing minor reads as `.0`, so major-only ids
# like `claude-opus-5` are correctly treated as >= 4.7 (issue #5753). Without
# this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
# only on paths that pass a temperature, e.g. scheduled tasks inheriting
# `stream_agent_loop`'s 0.3 default, which returned empty responses.
match = re.search(
r"(?<![a-z])opus[-_]?(\d{1,2})(?!\d)(?:[-_.](\d{1,2})(?!\d))?", model.lower()
)
if not match:
return False
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
return (major, minor) >= (4, 7)

# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
# API accepts "high", "medium", "low", "none" — see
Expand Down
26 changes: 25 additions & 1 deletion tests/test_llm_core_anthropic_temp_omit.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
"anthropic/claude-opus-4-7", # tolerate a provider-prefixed id
"claude-opus-4-10", # future minor still >= 4.7
"claude-opus-5-0", # future major
# Major-only ids: a missing minor reads as `.0`, so these are >= 4.7 too
# (issue #5753). Before the fix the version pattern required a minor, so
# these fell through to "accepts temperature" and every call 400'd.
"claude-opus-5",
"claude-opus-5-20260101", # major-only + dated snapshot
"anthropic/claude-opus-5", # major-only behind a provider prefix
"claude-opus-6", # future major-only
],
)
def test_opus_47_plus_rejects_temperature(model):
Expand All @@ -48,7 +55,10 @@ def test_opus_47_plus_rejects_temperature(model):
"claude-opus-4-6-20251201", # dated 4.6 snapshot — older, still keeps temperature
"claude-sonnet-4-6",
"claude-3-5-sonnet",
"claude-3-opus-20240229", # legacy Claude 3 Opus — no opus-N-M pattern, kept
"claude-3-opus-20240229", # legacy Claude 3 Opus — date directly after
# "opus-", so the major must not swallow it as version 20240229 (that is
# what makes capping the major at 1-2 digits necessary once the minor
# became optional in #5753).
"claude-haiku-4-5",
"claude-x",
"octopus-4-8", # "opus" only as a substring of another word — must not match
Expand Down Expand Up @@ -87,6 +97,20 @@ def test_payload_keeps_temperature_for_older_models():
assert _payload("claude-3-5-sonnet", 1.2)["temperature"] == 1.0


def test_payload_omits_temperature_for_major_only_opus_5():
# Issue #5753: the scheduled-task path calls stream_agent_loop() without a
# temperature and inherits its 0.3 default, so `claude-opus-5` 400'd on every
# run and surfaced as "the model returned an empty response". Interactive chat
# leaves temperature None and never hit it.
assert "temperature" not in _payload("claude-opus-5", 0.3)


def test_payload_keeps_temperature_for_legacy_claude_3_opus():
# Guards the major-digit cap: `opus-20240229` must not parse as version
# 20240229, or Claude 3 Opus would silently lose the caller's temperature.
assert _payload("claude-3-opus-20240229", 0.5)["temperature"] == 0.5


def test_payload_keeps_temperature_for_dated_opus_4_0():
# Anthropic's dated id for Opus 4.0 (claude-opus-4-20250514) is in this repo's
# ANTHROPIC_MODELS list. The date must not be misread as a >= 4.7 minor, or the
Expand Down