Skip to content

Commit c6f6cd6

Browse files
Nimrod007claude
andauthored
[issue-7526][BE] fix: support Anthropic adaptive-thinking models as online-rule judges (#7531)
* [issue-7526] [BE] fix: support Anthropic adaptive-thinking models as online-rule judges Online LLM-as-judge rules failed with Anthropic adaptive-thinking models (claude-sonnet-5, claude-opus-4-7/4-8): the judge-path builder forwarded temperature unconditionally (400 "temperature is deprecated for this model") and never set max_tokens or read the rule's custom_parameters, so thinking consumed the whole budget and the judge returned an empty response (finishReason=LENGTH). In AnthropicClientGenerator.newChatLanguageModel (the judge path): - Gate temperature server-side via AnthropicModelName.supportsSamplingParams, and also skip it whenever thinking is enabled per-rule via custom_parameters (Anthropic rejects sampling params while thinking is on) — covers API-created rules that bypass the FE sanitizer. - Forward custom_parameters (thinking type/budget, max_tokens) onto the native langchain4j builder. - Always send max_tokens, defaulting to the Playground default (4096) and stacking it above any thinking budget so max_tokens > budget_tokens holds. - Ignore non-positive max_tokens/budget_tokens from the bypass path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(online-rules): address Anthropic judge review feedback Address baz-reviewer comments on the Anthropic adaptive-thinking judge fix: - Fail closed for unknown model names in AnthropicModelName.supportsSamplingParams (default false) so a new adaptive-thinking model can't reintroduce the temperature 400 before it is added to the enum; back the lookup with a cached static map instead of streaming values() on every call. - Treat thinking as enabled unless the type is explicitly "disabled" (covers "adaptive" and any future type), and decode the thinking block once via parseThinking(), reused by both the temperature gate and the builder wiring. - Clamp max_tokens above the thinking budget even when an explicit max_tokens is set, so max_tokens <= budget_tokens can no longer reach Anthropic and 400. - Rename the headroom test to reflect what it asserts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(online-rules): harden Anthropic judge thinking/max_tokens parsing Second round of baz-reviewer feedback on the adaptive-thinking judge fix: - Default unknown model names back to true in supportsSamplingParams so registry-only Anthropic models keep temperature support; adaptive models remain explicitly opted out. - Treat thinking as enabled only for an explicit non-blank type other than "disabled", so an empty/blank thinking block no longer suppresses temperature or shapes max_tokens. - Forward thinking.budget_tokens only when thinking is enabled, so a budget-only (no type) block can't reach Anthropic as a partial config and 400. - Widen to long before adding headroom in resolveMaxTokens so an extreme budget can't overflow max_tokens to a negative int. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(online-rules): assert default max_tokens in Anthropic disabled-thinking case Harden dropsBudgetWhenThinkingDisabled so a regression that changes max_tokens (not just the thinking budget) is caught. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 79aaa97 commit c6f6cd6

3 files changed

Lines changed: 377 additions & 4 deletions

File tree

apps/opik-backend/src/main/java/com/comet/opik/infrastructure/llm/antropic/AnthropicClientGenerator.java

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.comet.opik.infrastructure.LlmProviderClientConfig;
55
import com.comet.opik.infrastructure.llm.LlmProviderClientApiConfig;
66
import com.comet.opik.infrastructure.llm.LlmProviderClientGenerator;
7+
import com.fasterxml.jackson.databind.JsonNode;
78
import dev.langchain4j.model.anthropic.AnthropicChatModel;
89
import dev.langchain4j.model.anthropic.internal.client.AnthropicClient;
910
import dev.langchain4j.model.chat.ChatModel;
@@ -65,11 +66,99 @@ private ChatModel newChatLanguageModel(LlmProviderClientApiConfig config,
6566
builder.baseUrl(config.baseUrl());
6667
}
6768

68-
Optional.ofNullable(modelParameters.temperature()).ifPresent(builder::temperature);
69+
var customParameters = modelParameters.customParameters();
70+
var thinking = parseThinking(customParameters);
71+
72+
// Anthropic rejects temperature with a 400 in two cases: (1) adaptive-thinking models
73+
// (claude-sonnet-5, claude-opus-4-7/4-8) that report no sampling-param support, and (2) any model
74+
// once extended thinking is enabled per-rule via custom_parameters. Gate on both server-side so
75+
// API-created rules (which bypass the FE sanitizer) don't fail.
76+
if (AnthropicModelName.supportsSamplingParams(modelParameters.name()) && !thinking.enabled()) {
77+
Optional.ofNullable(modelParameters.temperature()).ifPresent(builder::temperature);
78+
}
79+
80+
applyCustomParameters(builder, customParameters, thinking);
6981

7082
return builder.build();
7183
}
7284

85+
/**
86+
* Single decode of the {@code thinking} block from a rule's {@code custom_parameters}. Thinking counts as
87+
* enabled only when {@code type} is an explicit, non-blank value other than {@code "disabled"} — so
88+
* {@code "enabled"}, {@code "adaptive"}, and any future type gate temperature off, while a missing/blank
89+
* {@code type} (or absent block) is not enabled and must not gate temperature or shape max_tokens.
90+
*/
91+
private ThinkingParams parseThinking(JsonNode customParameters) {
92+
if (customParameters == null || customParameters.isNull()) {
93+
return ThinkingParams.ABSENT;
94+
}
95+
var thinkingNode = customParameters.get("thinking");
96+
if (thinkingNode == null || !thinkingNode.isObject()) {
97+
return ThinkingParams.ABSENT;
98+
}
99+
100+
String type = null;
101+
var typeNode = thinkingNode.get("type");
102+
if (typeNode != null && typeNode.isTextual() && StringUtils.isNotBlank(typeNode.asText())) {
103+
type = typeNode.asText();
104+
}
105+
106+
Integer budgetTokens = null;
107+
var budgetNode = thinkingNode.get("budget_tokens");
108+
if (budgetNode != null && budgetNode.canConvertToInt() && budgetNode.asInt() > 0) {
109+
budgetTokens = budgetNode.asInt();
110+
}
111+
112+
return new ThinkingParams(type != null && !"disabled".equals(type), type, budgetTokens);
113+
}
114+
115+
/**
116+
* Forwards the rule's {@code custom_parameters} (thinking, max_tokens) onto the judge-path builder and
117+
* guarantees a {@code max_tokens} is always sent. Anthropic requires max_tokens, and without an explicit
118+
* cap adaptive thinking can consume the whole budget, yielding an empty response (finishReason=LENGTH).
119+
*/
120+
private void applyCustomParameters(AnthropicChatModel.AnthropicChatModelBuilder builder,
121+
JsonNode customParameters, ThinkingParams thinking) {
122+
Optional.ofNullable(thinking.type()).ifPresent(builder::thinkingType);
123+
124+
// budget_tokens is only valid alongside enabled thinking; forwarding it with an absent or "disabled"
125+
// type produces a partial config that Anthropic rejects with a 400.
126+
Integer thinkingBudgetTokens = thinking.enabled() ? thinking.budgetTokens() : null;
127+
Optional.ofNullable(thinkingBudgetTokens).ifPresent(builder::thinkingBudgetTokens);
128+
129+
builder.maxTokens(resolveMaxTokens(parseMaxTokens(customParameters), thinkingBudgetTokens));
130+
}
131+
132+
private Integer parseMaxTokens(JsonNode customParameters) {
133+
if (customParameters == null || customParameters.isNull()) {
134+
return null;
135+
}
136+
var maxTokensNode = customParameters.get("max_tokens");
137+
if (maxTokensNode != null && maxTokensNode.canConvertToInt() && maxTokensNode.asInt() > 0) {
138+
return maxTokensNode.asInt();
139+
}
140+
return null;
141+
}
142+
143+
/**
144+
* Resolves the {@code max_tokens} sent to Anthropic, guaranteeing {@code max_tokens > thinking.budget_tokens}
145+
* (Anthropic rejects otherwise, since max_tokens covers thinking + output). An explicit rule value is honored
146+
* when it already clears the budget; otherwise it is raised to leave output headroom above the budget.
147+
*/
148+
private int resolveMaxTokens(Integer maxTokens, Integer thinkingBudgetTokens) {
149+
int resolved = maxTokens != null ? maxTokens : LlmProviderAnthropicMapper.DEFAULT_MAX_COMPLETION_TOKENS;
150+
if (thinkingBudgetTokens != null && resolved <= thinkingBudgetTokens) {
151+
// Widen to long before adding headroom so an extreme budget can't overflow to a negative int.
152+
return (int) Math.min(Integer.MAX_VALUE,
153+
(long) thinkingBudgetTokens + LlmProviderAnthropicMapper.DEFAULT_MAX_COMPLETION_TOKENS);
154+
}
155+
return resolved;
156+
}
157+
158+
private record ThinkingParams(boolean enabled, String type, Integer budgetTokens) {
159+
private static final ThinkingParams ABSENT = new ThinkingParams(false, null, null);
160+
}
161+
73162
@Override
74163
public AnthropicClient generate(@NonNull LlmProviderClientApiConfig config, Object... params) {
75164
return newAnthropicClient(config);

apps/opik-backend/src/main/java/com/comet/opik/infrastructure/llm/antropic/AnthropicModelName.java

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import com.comet.opik.infrastructure.llm.StructuredOutputSupported;
44
import lombok.RequiredArgsConstructor;
55

6+
import java.util.Arrays;
7+
import java.util.Map;
8+
import java.util.stream.Collectors;
9+
610
/**
711
* This information is taken from <a href="https://docs.anthropic.com/en/docs/about-claude/models">Anthropic docs</a>
812
*/
@@ -15,15 +19,33 @@ public enum AnthropicModelName implements StructuredOutputSupported {
1519
CLAUDE_OPUS_4("claude-opus-4-20250514"),
1620
CLAUDE_OPUS_4_5("claude-opus-4-5-20251101"),
1721
CLAUDE_OPUS_4_6("claude-opus-4-6"),
18-
CLAUDE_OPUS_4_7("claude-opus-4-7"),
19-
CLAUDE_OPUS_4_8("claude-opus-4-8"),
22+
// Adaptive-thinking models reject sampling params (temperature/top_p/top_k) with a 400.
23+
CLAUDE_OPUS_4_7("claude-opus-4-7", false),
24+
CLAUDE_OPUS_4_8("claude-opus-4-8", false),
2025
CLAUDE_SONNET_4("claude-sonnet-4-20250514"),
2126
CLAUDE_SONNET_4_5("claude-sonnet-4-5"),
2227
CLAUDE_SONNET_4_5_20250929("claude-sonnet-4-5-20250929"),
2328
CLAUDE_SONNET_4_6("claude-sonnet-4-6"),
24-
CLAUDE_SONNET_5("claude-sonnet-5");
29+
CLAUDE_SONNET_5("claude-sonnet-5", false);
2530

2631
private final String value;
32+
private final boolean supportsSamplingParams;
33+
34+
private static final Map<String, Boolean> SAMPLING_PARAMS_SUPPORT = Arrays.stream(values())
35+
.collect(Collectors.toUnmodifiableMap(model -> model.value, model -> model.supportsSamplingParams));
36+
37+
AnthropicModelName(String value) {
38+
this(value, true);
39+
}
40+
41+
/**
42+
* Whether the model accepts sampling params (temperature/top_p/top_k). Adaptive-thinking models
43+
* reject them with a 400 and are opted out explicitly. Unknown model names default to {@code true}
44+
* so registry-only Anthropic models (not enumerated here) keep temperature support.
45+
*/
46+
public static boolean supportsSamplingParams(String modelName) {
47+
return SAMPLING_PARAMS_SUPPORT.getOrDefault(modelName, true);
48+
}
2749

2850
@Override
2951
public String toString() {

0 commit comments

Comments
 (0)