This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new f8184d3ec [integrations][anthropic] Stop sending rejected sampling 
parameters (#1037)
f8184d3ec is described below

commit f8184d3ec0be48525e0040d8b5b23b00f046dab3
Author: Weiqing Yang <[email protected]>
AuthorDate: Wed Sep 2 02:34:13 2026 -0700

    [integrations][anthropic] Stop sending rejected sampling parameters (#1037)
    
    Generated-by: Claude Code 2.1.251 (Claude Opus 5)
---
 docs/content/docs/development/chat_models.md       |   7 +-
 integrations/chat-models/anthropic/pom.xml         |   6 +
 .../anthropic/AnthropicChatModelConnection.java    | 155 +++++++++-
 .../anthropic/AnthropicChatModelSetup.java         |   5 +-
 .../AnthropicChatModelConnectionTest.java          | 324 +++++++++++++++++++++
 .../chat_models/anthropic/anthropic_chat_model.py  |  82 ++++++
 .../tests/test_anthropic_response_parsing.py       | 116 ++++++++
 7 files changed, 680 insertions(+), 15 deletions(-)

diff --git a/docs/content/docs/development/chat_models.md 
b/docs/content/docs/development/chat_models.md
index d9a21f96c..588bff641 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -291,9 +291,8 @@ Anthropic provides cloud-based chat models featuring the 
Claude family, known fo
 | `prompt` | Prompt \| str | None | Prompt template or reference to prompt 
resource |
 | `tools` | List[str] | None | List of tool names available to the model |
 | `max_tokens` | int | `1024` | Maximum number of tokens to generate |
-| `temperature` | float | `0.1` | Sampling temperature (0.0 to 1.0) |
+| `temperature` | float | `0.1` | Sampling temperature (0.0 to 1.0) (not sent 
on Claude 4.7 and later, which reject a non-default sampling parameter) |
 | `json_prefill` | bool | `False` | Prefill assistant response with "{" to 
enforce JSON output (applies only on models that accept assistant-message 
prefilling; disabled when tools are used, or when the request carries an 
`output_config`) |
-| `additional_kwargs` | dict | `{}` | Additional Anthropic API parameters |
 
 {{< /tab >}}
 
@@ -306,10 +305,10 @@ Anthropic provides cloud-based chat models featuring the 
Claude family, known fo
 | `prompt` | Prompt \| String | None | Prompt template or reference to prompt 
resource |
 | `tools` | List<String> | None | List of tool names available to the model |
 | `max_tokens` | long | `1024` | Maximum number of tokens to generate |
-| `temperature` | double | `0.1` | Sampling temperature (0.0 to 1.0) |
+| `temperature` | double | `0.1` | Sampling temperature (0.0 to 1.0) (not sent 
on Claude 4.7 and later, which reject a non-default sampling parameter) |
 | `json_prefill` | boolean | `false` | Prefill assistant response with "{" to 
enforce JSON output (applies only on models that accept assistant-message 
prefilling; disabled when tools are used, or when the request carries an 
`output_config`) |
 | `strict_tools` | boolean | `false` | Enable strict mode for tool calling 
schemas |
-| `additional_kwargs` | Map<String, Object> | `{}` | Additional Anthropic API 
parameters (top_k, top_p, stop_sequences); an `output_config` supplied here 
takes precedence over one derived from an output schema |
+| `additional_kwargs` | Map<String, Object> | `{}` | Additional Anthropic API 
parameters (`temperature`, `top_k`, `top_p`, `stop_sequences`); none of the 
three sampling parameters is sent on Claude 4.7 and later. A `temperature` 
supplied here takes precedence over the top-level one, and an `output_config` 
supplied here takes precedence over one derived from an output schema |
 
 {{< /tab >}}
 
diff --git a/integrations/chat-models/anthropic/pom.xml 
b/integrations/chat-models/anthropic/pom.xml
index 2fd9b2871..07d0b592b 100644
--- a/integrations/chat-models/anthropic/pom.xml
+++ b/integrations/chat-models/anthropic/pom.xml
@@ -43,6 +43,12 @@ under the License.
             <artifactId>anthropic-java</artifactId>
             <version>${anthropic.version}</version>
         </dependency>
+
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>${slf4j.version}</version>
+        </dependency>
     </dependencies>
 
 </project>
diff --git 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
index bf8e0afe0..e2dcb9831 100644
--- 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
+++ 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
@@ -42,6 +42,8 @@ import 
org.apache.flink.agents.api.chat.model.BaseChatModelConnection;
 import org.apache.flink.agents.api.resource.ResourceContext;
 import org.apache.flink.agents.api.resource.ResourceDescriptor;
 import org.apache.flink.agents.api.tools.ToolMetadata;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.time.Duration;
 import java.util.ArrayList;
@@ -51,6 +53,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 
 /**
@@ -81,6 +84,8 @@ import java.util.stream.Collectors;
  */
 public class AnthropicChatModelConnection extends BaseChatModelConnection {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(AnthropicChatModelConnection.class);
+
     private static final TypeReference<Map<String, Object>> MAP_TYPE = new 
TypeReference<>() {};
 
     private final ObjectMapper mapper = new ObjectMapper();
@@ -223,6 +228,128 @@ public class AnthropicChatModelConnection extends 
BaseChatModelConnection {
         return !PREFILL_UNSUPPORTED_MODELS.contains(effectiveModel);
     }
 
+    // Models that reject a non-default sampling parameter. Source of truth:
+    // https://platform.claude.com/docs/en/about-claude/models/migration-guide
+    //
+    // The documented rule is that setting temperature, top_p or top_k to any 
non-default value on
+    // Claude Opus 4.7 or later returns a 400, and the Claude Sonnet 5 release 
notes carry the same
+    // rule for the Sonnet line while stating it is new for Sonnet-class 
models. Sending the
+    // provider default, or omitting the parameter, stays acceptable on every 
model, so the way to
+    // honour this is to drop the parameter rather than to substitute a value.
+    //
+    // This is the third boundary encoded in this class and it lines up with 
neither of the others.
+    // Structured output starts at the 4.5 generation and prefill rejection at 
4.6, while sampling
+    // rejection starts at 4.7. Claude 4.6 therefore rejects a prefill while 
still accepting a
+    // temperature, so the prefill list above cannot be reused here even 
though the two overlap.
+    //
+    // Claude Fable 5, Claude Mythos 5 and Claude Mythos Preview are listed 
without a matching
+    // sentence in the migration guide. That guide records each release's 
deltas, and these models
+    // succeed Claude Opus 4.8, which already rejects sampling parameters, so 
there was no delta to
+    // record. They are treated as rejecting because they inherit the 
constraint, not because a
+    // release note restates it.
+    private static final Set<String> SAMPLING_UNSUPPORTED_MODELS =
+            Set.of(
+                    "claude-opus-4-7",
+                    "claude-opus-4-8",
+                    "claude-opus-5",
+                    "claude-sonnet-5",
+                    "claude-fable-5",
+                    "claude-mythos-5",
+                    "claude-mythos-preview");
+
+    // The {model name, parameter name} pairs already reported through the 
warning in
+    // sendSamplingParam, so a rejected sampling parameter is surfaced once 
per model and parameter
+    // instead of on every request. The parameter belongs in the key because a 
model withdraws three
+    // of them at once: keying on the name alone would report whichever was 
dropped first and leave
+    // every later one silent.
+    private static final Set<List<String>> SAMPLING_WARNED_PARAMS = 
ConcurrentHashMap.newKeySet();
+
+    /**
+     * Whether {@code effectiveModel} accepts non-default sampling parameters, 
meaning {@code
+     * temperature}, {@code top_p} and {@code top_k}.
+     *
+     * <p>See the list above for the source of truth and for why it is kept 
apart from the prefill
+     * and structured-output lists. An unrecognized name reports {@code true}, 
matching the
+     * documented rule: sampling parameters are the long-standing behaviour 
and only the listed
+     * names withdraw them. That default carries the same cost as {@link 
#supportsJsonPrefill}: a
+     * rejecting model this list has not caught up with is sent a sampling 
parameter and answered
+     * with a 400.
+     */
+    static boolean supportsSamplingParams(String effectiveModel) {
+        // Load-bearing: the list is an immutable Set, whose contains(null) 
throws rather than
+        // reporting absence.
+        if (effectiveModel == null) {
+            return true;
+        }
+        return !SAMPLING_UNSUPPORTED_MODELS.contains(effectiveModel);
+    }
+
+    /**
+     * Whether the sampling parameter {@code param}, carrying {@code value}, 
may be put on a request
+     * for {@code effectiveModel}, warning the first time it may not.
+     *
+     * <p>A rejected parameter is dropped rather than clamped: the provider 
accepts an omitted
+     * parameter but answers a non-default one with a 400, and substituting 
the provider default
+     * would quietly change sampling behaviour instead of leaving it to the 
provider.
+     */
+    private static boolean sendSamplingParam(String effectiveModel, String 
param, Object value) {
+        if (supportsSamplingParams(effectiveModel)) {
+            return true;
+        }
+        samplingWarning(effectiveModel, param, value).ifPresent(LOG::warn);
+        return false;
+    }
+
+    /**
+     * The warning owed for dropping {@code param}, which carried {@code 
value}, on {@code
+     * effectiveModel}: the message on the first call for that model and 
parameter, and empty on
+     * every call after.
+     *
+     * <p>The message is owed against the pair rather than against the model 
name alone. A model
+     * withdraws three sampling parameters at once, so a debt held against the 
name would be settled
+     * by whichever parameter happened to be dropped first, leaving every 
later one dropped in
+     * silence.
+     *
+     * <p>Building the message here rather than at the call site keeps it on 
the same side of the
+     * once-per-pair decision as the decision itself, so there is no 
arrangement of the caller that
+     * reports a parameter on every request.
+     *
+     * <p>{@code effectiveModel} must not be null, which holds because only a 
name on the list above
+     * ever reaches this method.
+     */
+    static Optional<String> samplingWarning(String effectiveModel, String 
param, Object value) {
+        if (!SAMPLING_WARNED_PARAMS.add(List.of(effectiveModel, param))) {
+            return Optional.empty();
+        }
+        return Optional.of(
+                String.format(
+                        "Model %s rejects non-default sampling parameters, so 
the configured %s %s"
+                                + " was not sent. Steer the model through its 
prompt instead.",
+                        effectiveModel, param, value));
+    }
+
+    /**
+     * The temperature a request would carry, given the top-level {@code 
temperature} and an {@code
+     * additional_kwargs} map that may hold one of its own.
+     *
+     * <p>The map wins when it holds a {@link Number} under that key: an entry 
naming the parameter
+     * outright is the more specific setting, so it takes the top-level 
parameter's place. An absent
+     * key, or one holding anything other than a {@link Number}, leaves {@code 
topLevel} in place,
+     * because such a value reaches the request by neither route.
+     *
+     * <p>Resolving here rather than gating each route separately is what 
keeps the
+     * dropped-parameter warning honest. That warning is owed once per model 
and parameter, so
+     * gating the top-level value first would spend the one report on a value 
the map was about to
+     * override, naming a setting that was never going to be sent.
+     */
+    static Object effectiveTemperature(Object topLevel, Map<String, Object> 
additionalKwargs) {
+        if (additionalKwargs == null) {
+            return topLevel;
+        }
+        Object override = additionalKwargs.get("temperature");
+        return override instanceof Number ? override : topLevel;
+    }
+
     /**
      * Derives the native {@code output_config} for a POJO class through the 
SDK's typed
      * structured-output builder.
@@ -365,16 +492,19 @@ public class AnthropicChatModelConnection extends 
BaseChatModelConnection {
             builder.maxTokens(((Number) maxTokens).longValue());
         }
 
-        Object temperature = modelParams.remove("temperature");
-        if (temperature instanceof Number) {
-            builder.temperature(((Number) temperature).doubleValue());
-        }
-
         @SuppressWarnings("unchecked")
         Map<String, Object> additionalKwargs =
                 (Map<String, Object>) modelParams.remove("additional_kwargs");
+
+        Object temperature =
+                effectiveTemperature(modelParams.remove("temperature"), 
additionalKwargs);
+        if (temperature instanceof Number
+                && sendSamplingParam(modelName, "temperature", temperature)) {
+            builder.temperature(((Number) temperature).doubleValue());
+        }
+
         if (additionalKwargs != null) {
-            applyAdditionalKwargs(builder, additionalKwargs);
+            applyAdditionalKwargs(builder, additionalKwargs, modelName);
         }
 
         // Read here rather than inside the native structured-output branch 
below because it governs
@@ -687,22 +817,29 @@ public class AnthropicChatModelConnection extends 
BaseChatModelConnection {
     }
 
     private void applyAdditionalKwargs(
-            MessageCreateParams.Builder builder, Map<String, Object> kwargs) {
+            MessageCreateParams.Builder builder, Map<String, Object> kwargs, 
String modelName) {
         for (Map.Entry<String, Object> entry : kwargs.entrySet()) {
             String key = entry.getKey();
             Object value = entry.getValue();
 
             switch (key) {
                 case "top_k":
-                    if (value instanceof Number) {
+                    if (value instanceof Number && 
sendSamplingParam(modelName, key, value)) {
                         builder.topK(((Number) value).longValue());
                     }
                     break;
                 case "top_p":
-                    if (value instanceof Number) {
+                    if (value instanceof Number && 
sendSamplingParam(modelName, key, value)) {
                         builder.topP(((Number) value).doubleValue());
                     }
                     break;
+                case "temperature":
+                    // Resolved against the top-level "temperature" by 
effectiveTemperature and
+                    // gated before this map is applied, so there is nothing 
left to do with it
+                    // here. The case still has to exist: without a label of 
its own the key falls
+                    // to the default branch and goes onto the body as a raw 
property, ungated,
+                    // which the models that withdraw sampling parameters 
answer with a 400.
+                    break;
                 case "stop_sequences":
                     if (value instanceof List) {
                         @SuppressWarnings("unchecked")
diff --git 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetup.java
 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetup.java
index d58befb61..629da8822 100644
--- 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetup.java
+++ 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetup.java
@@ -48,8 +48,9 @@ import java.util.Optional;
  *   <li><b>strict_tools</b> (optional): When true, tool calls adhere strictly 
to the JSON schema.
  *       (default: false)
  *   <li><b>tools</b> (optional): List of tool names available for the model 
to use
- *   <li><b>additional_kwargs</b> (optional): Additional parameters (top_k, 
top_p, stop_sequences).
- *       An output_config supplied here takes precedence over one derived from 
an output schema.
+ *   <li><b>additional_kwargs</b> (optional): Additional parameters 
(temperature, top_k, top_p,
+ *       stop_sequences). A temperature supplied here takes precedence over 
the top-level one, and
+ *       an output_config supplied here takes precedence over one derived from 
an output schema.
  * </ul>
  *
  * <p>Example usage:
diff --git 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
index bc63533d0..e1e2057b5 100644
--- 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
+++ 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
@@ -19,6 +19,7 @@
 package org.apache.flink.agents.integrations.chatmodels.anthropic;
 
 import com.anthropic.models.messages.Message;
+import com.anthropic.models.messages.MessageCreateParams;
 import com.anthropic.models.messages.MessageParam;
 import com.anthropic.models.messages.Model;
 import com.anthropic.models.messages.OutputConfig;
@@ -40,6 +41,7 @@ import org.apache.flink.agents.api.tools.ToolType;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.NullSource;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -52,8 +54,10 @@ import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Stream;
 
+import static org.assertj.core.api.Assertions.as;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.InstanceOfAssertFactories.STRING;
 
 /**
  * Unit tests for {@link AnthropicChatModelConnection}'s request construction, 
its native
@@ -628,6 +632,253 @@ class AnthropicChatModelConnectionTest {
         assertPrefillDecisionForModel("claude-opus-4-6", false);
     }
 
+    /**
+     * The models the provider documents as rejecting a non-default sampling 
parameter, in the order
+     * the connection lists them. Mirroring that order keeps the two lists 
comparable side by side,
+     * so a name added to one and not the other stands out.
+     */
+    private static Stream<String> samplingUnsupportedModels() {
+        return Stream.of(
+                "claude-opus-4-7",
+                "claude-opus-4-8",
+                "claude-opus-5",
+                "claude-sonnet-5",
+                "claude-fable-5",
+                "claude-mythos-5",
+                "claude-mythos-preview");
+    }
+
+    /**
+     * Names that accept a temperature. The two 4.6-generation names are the 
load-bearing ones: both
+     * reject a prefill, so deriving the sampling rule from the prefill list 
would strip a
+     * temperature the provider still accepts.
+     */
+    private static Stream<String> samplingSupportedModels() {
+        return Stream.of(
+                "claude-opus-4-6",
+                "claude-sonnet-4-6",
+                "claude-opus-4-5",
+                "claude-sonnet-4-5",
+                "claude-sonnet-4-20250514",
+                "claude-3-5-sonnet-latest",
+                "");
+    }
+
+    /**
+     * Builds a request for {@code model} carrying each supplied sampling 
parameter, so the caller
+     * can read back which of them survived. {@code temperature} is a 
top-level parameter while
+     * {@code top_p} and {@code top_k} travel in {@code additional_kwargs}, 
and a null argument
+     * leaves that parameter out of the request entirely.
+     */
+    private static MessageCreateParams samplingRequest(
+            String model, Double temperature, Double topP, Long topK) {
+        Map<String, Object> params = paramsWithModel(model, null);
+        if (temperature != null) {
+            params.put("temperature", temperature);
+        }
+        Map<String, Object> additionalKwargs = new HashMap<>();
+        if (topP != null) {
+            additionalKwargs.put("top_p", topP);
+        }
+        if (topK != null) {
+            additionalKwargs.put("top_k", topK);
+        }
+        params.put("additional_kwargs", additionalKwargs);
+        return connection().buildRequest(userMessage(), List.of(), params, 
null).params;
+    }
+
+    /**
+     * Builds a request for {@code model} whose temperature arrives through 
{@code
+     * additional_kwargs}, the second route the parameter can reach the 
request body by.
+     */
+    private static MessageCreateParams kwargsTemperatureRequest(String model) {
+        Map<String, Object> params = paramsWithModel(model, null);
+        params.put("additional_kwargs", Map.of("temperature", 0.5d));
+        return connection().buildRequest(userMessage(), List.of(), params, 
null).params;
+    }
+
+    /**
+     * Builds a request for {@code model} carrying a top-level temperature and 
a different one in
+     * {@code additional_kwargs}, the case where the two routes disagree about 
the value.
+     */
+    private static MessageCreateParams competingTemperatureRequest(String 
model) {
+        Map<String, Object> params = paramsWithModel(model, null);
+        params.put("temperature", 0.1d);
+        params.put("additional_kwargs", Map.of("temperature", 0.5d));
+        return connection().buildRequest(userMessage(), List.of(), params, 
null).params;
+    }
+
+    /**
+     * A top-level temperature, an {@code additional_kwargs} map and the value 
the two resolve to.
+     * The map wins only when it holds a number, since only a number reaches 
the request by either
+     * route.
+     */
+    private static Stream<Arguments> temperatureResolutions() {
+        return Stream.of(
+                Arguments.of(0.1d, Map.of("temperature", 0.5d), 0.5d),
+                Arguments.of(0.1d, Map.of("top_p", 0.9d), 0.1d),
+                Arguments.of(0.1d, Map.of("temperature", "0.5"), 0.1d),
+                Arguments.of(null, Map.of("temperature", 0.5d), 0.5d),
+                Arguments.of(0.1d, null, 0.1d));
+    }
+
+    @ParameterizedTest
+    @MethodSource("samplingUnsupportedModels")
+    @DisplayName("every model documented as rejecting sampling parameters 
reports unsupported")
+    void testSamplingUnsupportedModelsReportUnsupported(String model) {
+        
assertThat(AnthropicChatModelConnection.supportsSamplingParams(model)).isFalse();
+    }
+
+    @ParameterizedTest
+    @NullSource
+    @MethodSource("samplingSupportedModels")
+    @DisplayName("a model outside that list reports sampling supported")
+    void testSamplingSupportedModelsReportSupported(String model) {
+        
assertThat(AnthropicChatModelConnection.supportsSamplingParams(model)).isTrue();
+    }
+
+    @Test
+    @DisplayName("temperature is dropped on a model that rejects sampling 
parameters")
+    void testTemperatureDroppedOnUnsupportedModel() {
+        assertThat(samplingRequest("claude-opus-4-7", 0.1d, null, 
null).temperature()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("top_p is dropped on a model that rejects sampling 
parameters")
+    void testTopPDroppedOnUnsupportedModel() {
+        MessageCreateParams params = samplingRequest("claude-opus-4-7", null, 
0.9d, null);
+
+        assertThat(params.topP()).isEmpty();
+        // An additional_kwargs key with no case of its own reaches the body 
as a raw property, so
+        // an empty typed field is not on its own proof the parameter was left 
off the request.
+        
assertThat(params._additionalBodyProperties()).doesNotContainKey("top_p");
+    }
+
+    @Test
+    @DisplayName("top_k is dropped on a model that rejects sampling 
parameters")
+    void testTopKDroppedOnUnsupportedModel() {
+        MessageCreateParams params = samplingRequest("claude-opus-4-7", null, 
null, 5L);
+
+        assertThat(params.topK()).isEmpty();
+        
assertThat(params._additionalBodyProperties()).doesNotContainKey("top_k");
+    }
+
+    @Test
+    @DisplayName("all three sampling parameters are sent on a model that 
accepts them")
+    void testSamplingParamsSentOnSupportedModel() {
+        MessageCreateParams params = 
samplingRequest("claude-sonnet-4-20250514", 0.1d, 0.9d, 5L);
+
+        assertThat(params.temperature()).contains(0.1d);
+        assertThat(params.topP()).contains(0.9d);
+        assertThat(params.topK()).contains(5L);
+    }
+
+    @Test
+    @DisplayName("temperature supplied through additional_kwargs is dropped on 
a rejecting model")
+    void testKwargsTemperatureDroppedOnUnsupportedModel() {
+        MessageCreateParams params = 
kwargsTemperatureRequest("claude-opus-4-7");
+
+        assertThat(params.temperature()).isEmpty();
+        // Without a case of its own the key falls to the default branch and 
reaches the body as a
+        // raw property, which serializes to the same "temperature" field the 
typed setter writes.
+        
assertThat(params._additionalBodyProperties()).doesNotContainKey("temperature");
+    }
+
+    @Test
+    @DisplayName("temperature supplied through additional_kwargs is sent on an 
accepting model")
+    void testKwargsTemperatureSentOnSupportedModel() {
+        
assertThat(kwargsTemperatureRequest("claude-sonnet-4-20250514").temperature())
+                .contains(0.5d);
+    }
+
+    @Test
+    @DisplayName("stop_sequences supplied through additional_kwargs reaches 
the typed field")
+    void testStopSequencesFromAdditionalKwargs() {
+        Map<String, Object> params = 
paramsWithModel("claude-sonnet-4-20250514", null);
+        params.put("additional_kwargs", Map.of("stop_sequences", 
List.of("STOP", "END")));
+
+        MessageCreateParams built =
+                connection().buildRequest(userMessage(), List.of(), params, 
null).params;
+
+        assertThat(built.stopSequences()).contains(List.of("STOP", "END"));
+    }
+
+    @ParameterizedTest
+    @MethodSource("temperatureResolutions")
+    @DisplayName(
+            "additional_kwargs overrides the top-level temperature only when 
it holds a number")
+    void testEffectiveTemperature(Object topLevel, Map<String, Object> kwargs, 
Object expected) {
+        assertThat(AnthropicChatModelConnection.effectiveTemperature(topLevel, 
kwargs))
+                .isEqualTo(expected);
+    }
+
+    @Test
+    @DisplayName("the additional_kwargs temperature is the one an accepting 
model is sent")
+    void testCompetingTemperaturesResolveToKwargsOnSupportedModel() {
+        
assertThat(competingTemperatureRequest("claude-sonnet-4-20250514").temperature())
+                .contains(0.5d);
+    }
+
+    @Test
+    @DisplayName("neither competing temperature reaches a model that rejects 
sampling parameters")
+    void testCompetingTemperaturesDroppedOnUnsupportedModel() {
+        // A rejecting name whose temperature no other test drives through 
buildRequest. The
+        // warning owed for a dropped parameter is tracked per model and 
parameter for the life of
+        // the JVM, so sharing a name would leave this test's meaning 
dependent on run order.
+        MessageCreateParams params = 
competingTemperatureRequest("claude-opus-4-8");
+
+        assertThat(params.temperature()).isEmpty();
+        // An ungated additional_kwargs value reaches the body as a raw 
property, which serializes
+        // to the same "temperature" field the typed setter writes, so the 
typed field being empty
+        // is not on its own proof the parameter was left off the request.
+        
assertThat(params._additionalBodyProperties()).doesNotContainKey("temperature");
+        // One report is owed for the pair and the request spent it, so 
nothing is left to report.
+        // That the request above is what spent it holds only while no other 
test claims this
+        // model name and parameter first; one that did would satisfy this 
assertion for its own
+        // reason and leave nothing here for it to catch.
+        assertThat(
+                        AnthropicChatModelConnection.samplingWarning(
+                                "claude-opus-4-8", "temperature", 0.5d))
+                .isEmpty();
+    }
+
+    @Test
+    @DisplayName(
+            "a dropped sampling parameter owes one warning naming the model, 
parameter and value")
+    void testDroppedSamplingParamOwesOneWarningPerParameter() {
+        // A name no other test asks a warning for, since the bookkeeping 
behind it lives for the
+        // life of the JVM and a pair already reported would answer empty here.
+        String model = "claude-mythos-preview";
+
+        Optional<String> first =
+                AnthropicChatModelConnection.samplingWarning(model, 
"temperature", 0.1d);
+        Optional<String> repeat =
+                AnthropicChatModelConnection.samplingWarning(model, 
"temperature", 0.1d);
+        Optional<String> other = 
AnthropicChatModelConnection.samplingWarning(model, "top_p", 0.9d);
+
+        // The message has to identify which request parameter went missing 
and what it held,
+        // because that is the only trace the dropped value leaves.
+        assertThat(first).get(as(STRING)).contains(model, "temperature", 
"0.1");
+        // The repeat is silent, but a different parameter is a different fact 
about the request
+        // and owes a report of its own.
+        assertThat(repeat).isEmpty();
+        assertThat(other).get(as(STRING)).contains(model, "top_p", "0.9");
+    }
+
+    @Test
+    @DisplayName("a 4.6 model rejects the prefill while keeping its sampling 
parameters")
+    void testSamplingAndPrefillBoundariesDiffer() {
+        // The two rules draw different lines, and this model sits between 
them: the provider
+        // withdraws prefilling from 4.6 on but sampling parameters only from 
4.7 on. Deriving the
+        // sampling rule from the prefill list would drop the temperature 
here, where the provider
+        // still accepts it.
+        
assertThat(AnthropicChatModelConnection.supportsJsonPrefill("claude-sonnet-4-6")).isFalse();
+        
assertThat(AnthropicChatModelConnection.supportsSamplingParams("claude-sonnet-4-6"))
+                .isTrue();
+        assertThat(samplingRequest("claude-sonnet-4-6", 0.1d, null, 
null).temperature())
+                .contains(0.1d);
+    }
+
     @Test
     @DisplayName("json_prefill is applied on a structured-output capable model 
that accepts it")
     void testPrefillAppliedOnStructuredOutputCapableModel() {
@@ -640,6 +891,79 @@ class AnthropicChatModelConnectionTest {
         assertPrefillDecisionForModel("claude-sonnet-4-5", true);
     }
 
+    /**
+     * A temperature value that records whether anything rendered it as text.
+     *
+     * <p>The report owed for a dropped sampling parameter is assembled with a 
{@code %s}
+     * conversion, so the value it names is whichever one had {@code 
toString()} called on it. A
+     * value that was never rendered was never reported. That is the only 
handle on the choice from
+     * outside the class: a model rejecting sampling parameters is sent 
neither candidate, so the
+     * request itself looks the same whichever one the report picked.
+     *
+     * <p>Rendering is the measurement, so observing one taints it. A 
breakpoint that displays this
+     * value, or a debug print of it, calls {@code toString()} and makes 
{@code wasRendered()}
+     * answer true regardless of what the code under test did.
+     */
+    private static final class RecordingTemperature extends Number {
+        private final double value;
+        private boolean rendered;
+
+        RecordingTemperature(double value) {
+            this.value = value;
+        }
+
+        boolean wasRendered() {
+            return rendered;
+        }
+
+        @Override
+        public String toString() {
+            rendered = true;
+            return Double.toString(value);
+        }
+
+        @Override
+        public int intValue() {
+            return (int) value;
+        }
+
+        @Override
+        public long longValue() {
+            return (long) value;
+        }
+
+        @Override
+        public float floatValue() {
+            return (float) value;
+        }
+
+        @Override
+        public double doubleValue() {
+            return value;
+        }
+    }
+
+    @Test
+    @DisplayName("a dropped temperature is reported as the additional_kwargs 
value it resolved to")
+    void testDroppedTemperatureIsReportedAsTheResolvedValue() {
+        // A rejecting name no other test builds a request for or asks a 
temperature report of.
+        // The report is owed once per model and parameter for the life of the 
JVM, so a pair
+        // claimed elsewhere first would leave nothing to render here and the 
first assertion
+        // below would fail: a collision surfaces as a failure, not as a test 
that still passes
+        // while pinning less.
+        RecordingTemperature topLevel = new RecordingTemperature(0.1d);
+        RecordingTemperature override = new RecordingTemperature(0.5d);
+        Map<String, Object> params = paramsWithModel("claude-mythos-5", null);
+        params.put("temperature", topLevel);
+        params.put("additional_kwargs", Map.of("temperature", override));
+
+        connection().buildRequest(userMessage(), List.of(), params, null);
+
+        assertThat(override.wasRendered()).isTrue();
+        // The overridden value would have named a setting the request was 
never going to carry.
+        assertThat(topLevel.wasRendered()).isFalse();
+    }
+
     /** Minimal tool stub; only its presence in the tools list matters. */
     private static class StubTool extends Tool {
         StubTool() {
diff --git 
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
 
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
index dde0a8941..f4406cc12 100644
--- 
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
+++ 
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
@@ -15,6 +15,7 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import logging
 import uuid
 from typing import Any, Dict, List, Sequence
 
@@ -32,6 +33,8 @@ from flink_agents.api.chat_models.chat_model import (
 )
 from flink_agents.api.tools.tool import Tool, ToolMetadata
 
+logger = logging.getLogger(__name__)
+
 
 def to_anthropic_tool(
     *, metadata: ToolMetadata, skip_length_check: bool = False
@@ -197,6 +200,64 @@ def _supports_json_prefill(effective_model: str | None) -> 
bool:
     return effective_model not in _PREFILL_UNSUPPORTED_MODELS
 
 
+# Models that reject a non-default sampling parameter. Source of truth:
+# https://platform.claude.com/docs/en/about-claude/models/migration-guide
+#
+# The documented rule is that setting temperature, top_p or top_k to any 
non-default
+# value on Claude Opus 4.7 or later returns a 400, and the Claude Sonnet 5 
release notes
+# carry the same rule for the Sonnet line while stating it is new for 
Sonnet-class
+# models. Sending the provider default, or omitting the parameter, stays 
acceptable on
+# every model, so the way to honour this is to drop the parameter rather than 
to
+# substitute a value.
+#
+# This is the third boundary encoded in this module and it lines up with 
neither of the
+# others. Structured output starts at the 4.5 generation and prefill rejection 
at 4.6,
+# while sampling rejection starts at 4.7. Claude 4.6 therefore rejects a 
prefill while
+# still accepting a temperature, so the prefill list above cannot be reused 
here even
+# though the two overlap.
+#
+# Claude Fable 5, Claude Mythos 5 and Claude Mythos Preview are listed without 
a
+# matching sentence in the migration guide. That guide records each release's 
deltas,
+# and these models succeed Claude Opus 4.8, which already rejects sampling 
parameters,
+# so there was no delta to record. They are treated as rejecting because they 
inherit
+# the constraint, not because a release note restates it.
+_SAMPLING_UNSUPPORTED_MODELS = frozenset(
+    {
+        "claude-opus-4-7",
+        "claude-opus-4-8",
+        "claude-opus-5",
+        "claude-sonnet-5",
+        "claude-fable-5",
+        "claude-mythos-5",
+        "claude-mythos-preview",
+    }
+)
+
+# The request parameters those models withdraw.
+_SAMPLING_PARAMS = ("temperature", "top_p", "top_k")
+
+# The (model name, parameter name) pairs already reported through the warning 
in
+# ``chat``, so a rejected sampling parameter is surfaced once per model and 
parameter
+# instead of on every request. The parameter belongs in the key because a model
+# withdraws three of them at once: keying on the name alone would report 
whichever was
+# dropped first and leave every later one silent.
+_SAMPLING_WARNED_PARAMS: set[tuple[str, str]] = set()
+
+
+def _supports_sampling_params(effective_model: str | None) -> bool:
+    """Whether ``effective_model`` accepts non-default sampling parameters.
+
+    The parameters are ``temperature``, ``top_p`` and ``top_k``. See the list 
above for
+    the source of truth and for why it is kept apart from the prefill and
+    structured-output lists. An unrecognized name reports ``True``, matching 
the
+    documented rule: sampling parameters are the long-standing behaviour and 
only the
+    listed names withdraw them. That default carries the same cost as
+    ``_supports_json_prefill``: a rejecting model this list has not caught up 
with is
+    sent a sampling parameter and answered with a 400.
+    """
+    return effective_model not in _SAMPLING_UNSUPPORTED_MODELS
+
+
 def _native_output_config(output_schema: Any) -> Dict[str, Any] | None:
     """Build the Anthropic ``output_config`` for a native structured-output 
request.
 
@@ -398,6 +459,27 @@ class 
AnthropicChatModelConnection(BaseChatModelConnection):
                 {"role": MessageRole.ASSISTANT.value, "content": "{"},
             ]
 
+        # Dropped rather than clamped on a model that rejects them: the 
provider
+        # accepts an omitted parameter but answers a non-default one with a 
400,
+        # and substituting the provider default would quietly change sampling
+        # behaviour instead of leaving it to the provider.
+        if not _supports_sampling_params(kwargs.get("model")):
+            model_name = kwargs.get("model")
+            for param in _SAMPLING_PARAMS:
+                if param not in kwargs:
+                    continue
+                dropped = kwargs.pop(param)
+                if (model_name, param) not in _SAMPLING_WARNED_PARAMS:
+                    _SAMPLING_WARNED_PARAMS.add((model_name, param))
+                    logger.warning(
+                        "Model %s rejects non-default sampling parameters, so 
the "
+                        "configured %s %s was not sent. Steer the model 
through its "
+                        "prompt instead.",
+                        model_name,
+                        param,
+                        dropped,
+                    )
+
         message = self.client.messages.create(
             messages=anthropic_messages,
             tools=anthropic_tools or NOT_GIVEN,
diff --git 
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
 
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
index 0577a32fd..3a492ad35 100644
--- 
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
+++ 
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
@@ -15,6 +15,7 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import logging
 from typing import Any, Callable, Dict
 from unittest.mock import MagicMock
 
@@ -28,9 +29,11 @@ from flink_agents.api.agents.types import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.tools.tool import Tool, ToolMetadata, ToolType
 from flink_agents.integrations.chat_models.anthropic.anthropic_chat_model 
import (
+    _SAMPLING_WARNED_PARAMS,
     AnthropicChatModelConnection,
     AnthropicChatModelSetup,
     _supports_json_prefill,
+    _supports_sampling_params,
 )
 
 
@@ -595,3 +598,116 @@ def test_setup_honors_explicit_json_prefill() -> None:
     # unconditionally.
     setup = AnthropicChatModelSetup(connection="conn", json_prefill=True)
     assert setup.model_kwargs["json_prefill"] is True
+
+
+# The models the provider documents as rejecting a non-default sampling 
parameter, in
+# the order the module lists them. Mirroring that order keeps the two 
comparable side by
+# side, so a name added to one and not the other stands out.
+_SAMPLING_UNSUPPORTED = [
+    "claude-opus-4-7",
+    "claude-opus-4-8",
+    "claude-opus-5",
+    "claude-sonnet-5",
+    "claude-fable-5",
+    "claude-mythos-5",
+    "claude-mythos-preview",
+]
+
+# Names that accept sampling parameters. The two 4.6-generation names are the
+# load-bearing ones: both reject a prefill, so deriving the sampling rule from 
the
+# prefill list would strip a temperature the provider still accepts.
+_SAMPLING_SUPPORTED = [
+    "claude-opus-4-6",
+    "claude-sonnet-4-6",
+    "claude-opus-4-5",
+    "claude-sonnet-4-5",
+    "claude-sonnet-4-20250514",
+    "claude-3-5-sonnet-latest",
+    None,
+]
+
+
+_ABSENT = object()
+
+# The three sampling parameters, spelled out rather than read from the module 
under test.
+# Reading the production tuple would make a name dropped from it disappear 
from the comparison
+# below, so the drop would go unnoticed instead of failing an assertion.
+_SAMPLING_PARAM_NAMES = ("temperature", "top_p", "top_k")
+
+
+def _sent_sampling(model: str, **sampling: Any) -> Dict[str, Any]:
+    """Which of the sampling parameters the request reached the client with.
+
+    Each of the three names appears in the result, mapped either to the value 
the client
+    saw or to ``_ABSENT`` when it was dropped on the way.
+    """
+    message = Message(
+        id="m",
+        model="claude",
+        role="assistant",
+        type="message",
+        stop_reason="end_turn",
+        content=[TextBlock(type="text", text=_CONTINUATION)],
+        usage=_usage(),
+    )
+    connection = _connection_returning(message)
+    connection.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model=model,
+        **sampling,
+    )
+    sent = connection.client.messages.create.call_args.kwargs
+    return {param: sent.get(param, _ABSENT) for param in _SAMPLING_PARAM_NAMES}
+
+
[email protected]("model", _SAMPLING_UNSUPPORTED)
+def test_sampling_predicate_rejects_unsupported_models(model) -> None:
+    assert _supports_sampling_params(model) is False
+
+
[email protected]("model", _SAMPLING_SUPPORTED)
+def test_sampling_predicate_accepts_other_models(model) -> None:
+    assert _supports_sampling_params(model) is True
+
+
[email protected](
+    ("param", "value"), [("temperature", 0.1), ("top_p", 0.9), ("top_k", 5)]
+)
+def test_sampling_param_dropped_on_unsupported_model(param, value) -> None:
+    assert _sent_sampling("claude-opus-4-7", **{param: value})[param] is 
_ABSENT
+
+
+def test_sampling_params_sent_on_supported_model() -> None:
+    assert _sent_sampling(
+        "claude-sonnet-4-20250514", temperature=0.1, top_p=0.9, top_k=5
+    ) == {"temperature": 0.1, "top_p": 0.9, "top_k": 5}
+
+
+def test_each_dropped_sampling_param_is_reported_once(caplog) -> None:
+    # The bookkeeping behind the warning lives for the life of the process, so 
a pair
+    # left behind by an earlier test would suppress a warning this test has to 
observe.
+    model = "claude-mythos-preview"
+    for param in _SAMPLING_PARAM_NAMES:
+        _SAMPLING_WARNED_PARAMS.discard((model, param))
+
+    with caplog.at_level(logging.WARNING):
+        _sent_sampling(model, temperature=0.1)
+        _sent_sampling(model, temperature=0.1)
+        _sent_sampling(model, top_p=0.9)
+
+    # The repeat stays silent, but a different parameter is a different fact 
about the
+    # request and has to be reported on its own.
+    warnings = [record.getMessage() for record in caplog.records]
+    assert len(warnings) == 2
+    assert "temperature" in warnings[0]
+    assert "top_p" in warnings[1]
+
+
+def test_sampling_and_prefill_boundaries_differ() -> None:
+    # The two rules draw different lines, and this model sits between them: 
the provider
+    # withdraws prefilling from 4.6 on but sampling parameters only from 4.7 
on.
+    # Deriving the sampling rule from the prefill list would drop the 
temperature here,
+    # where the provider still accepts it.
+    assert _supports_json_prefill("claude-sonnet-4-6") is False
+    assert _supports_sampling_params("claude-sonnet-4-6") is True
+    assert _sent_sampling("claude-sonnet-4-6", temperature=0.1)["temperature"] 
== 0.1

Reply via email to