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 51e9c60c [integrations][java][python] Apply Anthropic native
structured output (#965)
51e9c60c is described below
commit 51e9c60cd244e4860ecf5a59e4ca2a2917fd7470
Author: Weiqing Yang <[email protected]>
AuthorDate: Thu Aug 20 21:00:26 2026 -0700
[integrations][java][python] Apply Anthropic native structured output (#965)
Generated-by: Claude Code 2.1.229 (Claude Opus 5)
---
dist/src/main/resources/META-INF/NOTICE | 6 +-
docs/content/docs/development/chat_models.md | 5 +-
.../anthropic/AnthropicChatModelConnection.java | 275 +++++++++-
.../anthropic/AnthropicChatModelSetup.java | 10 +-
.../AnthropicChatModelConnectionTest.java | 609 +++++++++++++++++++++
.../anthropic/AnthropicChatModelSetupTest.java | 66 +++
integrations/pom.xml | 2 +-
.../chat_models/anthropic/anthropic_chat_model.py | 231 +++++++-
.../tests/test_anthropic_response_parsing.py | 381 ++++++++++++-
python/pyproject.toml | 2 +-
10 files changed, 1544 insertions(+), 43 deletions(-)
diff --git a/dist/src/main/resources/META-INF/NOTICE
b/dist/src/main/resources/META-INF/NOTICE
index a1cf484c..aa53540a 100644
--- a/dist/src/main/resources/META-INF/NOTICE
+++ b/dist/src/main/resources/META-INF/NOTICE
@@ -130,9 +130,9 @@ See bundled license files for details.
- io.github.ollama4j:ollama4j:1.1.5
- org.jsoup:jsoup:1.21.2
-- com.anthropic:anthropic-java:2.11.1
-- com.anthropic:anthropic-java-client-okhttp:2.11.1
-- com.anthropic:anthropic-java-core:2.11.1
+- com.anthropic:anthropic-java:2.12.0
+- com.anthropic:anthropic-java-client-okhttp:2.12.0
+- com.anthropic:anthropic-java-core:2.12.0
- org.reactivestreams:reactive-streams:1.0.4
- io.modelcontextprotocol.sdk:mcp:0.16.0
- io.modelcontextprotocol.sdk:mcp-json-jackson2:0.16.0
diff --git a/docs/content/docs/development/chat_models.md
b/docs/content/docs/development/chat_models.md
index aad8c12a..8f9962aa 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -292,6 +292,7 @@ Anthropic provides cloud-based chat models featuring the
Claude family, known fo
| `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) |
+| `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,9 +307,9 @@ Anthropic provides cloud-based chat models featuring the
Claude family, known fo
| `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) |
-| `json_prefill` | boolean | `true` | Prefill assistant response with "{" to
enforce JSON output (disabled when tools are used) |
+| `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) |
+| `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 |
{{< /tab >}}
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 79917140..bf8e0afe 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
@@ -19,6 +19,7 @@ package
org.apache.flink.agents.integrations.chatmodels.anthropic;
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
+import com.anthropic.core.JsonSchemaLocalValidation;
import com.anthropic.core.JsonValue;
import com.anthropic.models.messages.ContentBlock;
import com.anthropic.models.messages.ContentBlockParam;
@@ -26,6 +27,7 @@ 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;
import com.anthropic.models.messages.TextBlockParam;
import com.anthropic.models.messages.Tool;
import com.anthropic.models.messages.ToolResultBlockParam;
@@ -48,6 +50,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -114,24 +117,173 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
this.client.close();
}
+ // Models Anthropic documents native structured-output support for. Source
of truth:
+ // https://platform.claude.com/docs/en/build-with-claude/structured-outputs
+ //
+ // The documented rule is generational rather than a per-snapshot list:
structured outputs are
+ // generally available for Claude 4.5 and later models, and for Claude
Mythos Preview. Names
+ // from the 4.6 generation onward carry no date and are pinned, so the
name is itself the
+ // snapshot and is matched exactly.
+ //
+ // The three 4.5-generation names are aliases that front a dated snapshot,
so a request may
+ // carry either the alias or the snapshot behind it and both have to
match. Those match the
+ // alias itself or a name continuing with a "-" separator, which covers
+ // claude-sonnet-4-5-20250929. A name that extends the alias without that
separator is a
+ // different minor version and is capable only if the exact set names it.
The alias also has
+ // to retain the minor version: "claude-opus-4" would capture
claude-opus-4-1-20250805, which
+ // predates the cutoff and is not capable.
+ //
+ // A name outside both sets reports not-capable and degrades to the
prompt-engineering
+ // fallback rather than failing at the provider.
+ private static final Set<String> NATIVE_STRUCTURED_OUTPUT_MODELS =
+ Set.of(
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview");
+
+ private static final Set<String> NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES =
+ Set.of("claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5");
+
+ /**
+ * Whether Anthropic documents native structured-output support for {@code
effectiveModel}.
+ *
+ * <p>See the allowlists above for the source of truth and for why a
4.5-generation alias also
+ * matches the dated snapshot behind it while every other name is matched
exactly. An
+ * unrecognized name reports {@code false} so that it degrades to the
prompt-engineering
+ * fallback rather than failing at the provider.
+ *
+ * <p>Reads no instance state, so capability stays answerable
independently of how the
+ * connection was configured.
+ */
+ @Override
+ protected boolean supportsNativeStructuredOutput(String effectiveModel) {
+ // Load-bearing: the allowlist is an immutable Set, whose
contains(null) throws rather than
+ // reporting absence.
+ if (effectiveModel == null) {
+ return false;
+ }
+ return NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel)
+ || NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES.stream()
+ .anyMatch(
+ prefix ->
+ effectiveModel.equals(prefix)
+ ||
effectiveModel.startsWith(prefix + "-"));
+ }
+
+ // Models Anthropic documents as rejecting assistant-message prefilling.
Source of truth:
+ //
https://platform.claude.com/docs/en/build-with-claude/working-with-messages#putting-words-in-claudes-mouth
+ //
+ // Prefilling is not supported from the Claude 4.6 generation onward, nor
on Claude Mythos
+ // Preview, Claude Fable 5 or Claude Mythos 5; a request that prefills one
of them is answered
+ // with a 400 rather than a completion. Anthropic publishes no
programmatic signal for prefill
+ // support the way it does for structured outputs, so the rule has to be a
maintained list of
+ // names. Those names carry no date and are pinned, so the name is itself
the snapshot and is
+ // matched exactly, and a name outside the list is treated as accepting
the prefill.
+ //
+ // Kept in its own storage rather than derived from the structured-output
allowlists above,
+ // whose contents it currently coincides with. The two encode different
documented boundaries:
+ // structured output starts at the 4.5 generation while prefill rejection
starts at 4.6, so the
+ // three 4.5-generation names are structured-output capable and still
accept a prefill. Sharing
+ // one list would hold only until a model moves one boundary without
moving the other.
+ private static final Set<String> PREFILL_UNSUPPORTED_MODELS =
+ Set.of(
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview");
+
+ /**
+ * Whether {@code effectiveModel} accepts the prefilled assistant {@code
"{"} message.
+ *
+ * <p>See the list above for the source of truth and for why it is matched
exactly and kept
+ * apart from the structured-output allowlists. An unrecognized name
reports {@code true}, which
+ * matches the documented rule: prefilling is the long-standing behaviour
and only the listed
+ * names withdraw it. The cost of that default runs the opposite way to
{@link
+ * #supportsNativeStructuredOutput}: a rejecting model this list has not
caught up with is
+ * prefilled and answered with a 400, where an unrecognized name on the
structured-output path
+ * degrades silently to the prompt-engineering fallback instead.
+ */
+ static boolean supportsJsonPrefill(String effectiveModel) {
+ // Load-bearing: the list is an immutable Set, whose contains(null)
throws rather than
+ // reporting absence.
+ if (effectiveModel == null) {
+ return true;
+ }
+ return !PREFILL_UNSUPPORTED_MODELS.contains(effectiveModel);
+ }
+
+ /**
+ * Derives the native {@code output_config} for a POJO class through the
SDK's typed
+ * structured-output builder.
+ *
+ * <p>The Kotlin facade {@code StructuredOutputsKt.outputFormatFromClass}
would produce this
+ * directly, but it is compiled {@code ACC_SYNTHETIC} and so cannot be
named from Java. The
+ * typed builder generates the same schema; the config is extracted from
the throwaway request
+ * it produces and reattached to the real one, which also avoids that
overload's side effect of
+ * retyping the request and the response as {@code
StructuredMessageCreateParams} and {@code
+ * StructuredMessage}. The throwaway request is never sent, so its
placeholder model, message
+ * and token limit only have to satisfy the builder's required-field check.
+ *
+ * <p>Local schema validation is off so that the provider, not the client,
is the authority on
+ * which schemas it accepts.
+ */
+ private static <T> OutputConfig toNativeOutputConfig(Class<T> schemaClass)
{
+ return MessageCreateParams.builder()
+ .model(Model.of(""))
+ .addUserMessage("")
+ .maxTokens(1)
+ .outputConfig(schemaClass, JsonSchemaLocalValidation.NO)
+ .build()
+ .rawParams()
+ .outputConfig()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "Anthropic SDK did not produce an
output_config for schema "
+ + schemaClass.getName()));
+ }
+
@Override
public ChatMessage chat(
List<ChatMessage> messages,
List<org.apache.flink.agents.api.tools.Tool> tools,
Map<String, Object> modelParams) {
+ return chat(messages, tools, modelParams, null);
+ }
+
+ /**
+ * Translates {@code outputSchema} into Anthropic's native {@code
output_config.format} when it
+ * is a POJO {@link Class}, the effective model is one Anthropic documents
structured-output
+ * support for, and the caller has not already supplied its own {@code
output_config}. Any other
+ * combination sends no derived schema, so the request carries only the
output configuration the
+ * caller supplied, if any, and a schema that cannot be sent natively
degrades to the
+ * prompt-engineering fallback rather than failing at the provider.
+ *
+ * <p>A request that ends up carrying an {@code output_config} — whether
derived here or
+ * supplied by the caller — also suppresses the {@code json_prefill}
parameter, since Anthropic
+ * documents message prefilling as incompatible with structured outputs.
+ */
+ @Override
+ public ChatMessage chat(
+ List<ChatMessage> messages,
+ List<org.apache.flink.agents.api.tools.Tool> tools,
+ Map<String, Object> modelParams,
+ Object outputSchema) {
try {
- // Check if JSON prefill is requested before building request
(modelParams may be
- // modified).
- boolean jsonPrefillRequested =
- modelParams != null &&
Boolean.TRUE.equals(modelParams.get("json_prefill"));
- // JSON prefill is automatically disabled when tools are passed in
the request,
- // because it interferes with native tool calling.
- boolean hasToolsInRequest = tools != null && !tools.isEmpty();
- boolean jsonPrefillApplied = jsonPrefillRequested &&
!hasToolsInRequest;
-
- MessageCreateParams params = buildRequest(messages, tools,
modelParams);
- Message response = client.messages().create(params);
- ChatMessage result = convertResponse(response, jsonPrefillApplied);
+ BuiltRequest built = buildRequest(messages, tools, modelParams,
outputSchema);
+ Message response = client.messages().create(built.params);
+ ChatMessage result = convertResponse(built, response);
// Stash token usage
String modelName = null;
@@ -153,10 +305,19 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
}
}
- private MessageCreateParams buildRequest(
+ /**
+ * Builds the request and reports the JSON prefill decision it made.
+ *
+ * <p>Whether the prefilled assistant {@code "{"} message was appended
cannot be recomputed from
+ * the request alone, and {@link #convertResponse} must know it to
reconstruct the full JSON
+ * document. Deciding once here and carrying the answer out keeps the
request and the response
+ * conversion from disagreeing.
+ */
+ BuiltRequest buildRequest(
List<ChatMessage> messages,
List<org.apache.flink.agents.api.tools.Tool> tools,
- Map<String, Object> rawModelParams) {
+ Map<String, Object> rawModelParams,
+ Object outputSchema) {
Map<String, Object> modelParams =
rawModelParams != null ? new HashMap<>(rawModelParams) : new
HashMap<>();
@@ -216,19 +377,72 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
applyAdditionalKwargs(builder, additionalKwargs);
}
- // Handle JSON prefill - append a prefilled assistant message with "{"
to enforce JSON
- // output. Note: JSON prefill is incompatible with tool use as it
forces the model to output
- // JSON text instead of using native tool_use content blocks.
Automatically disable
- // json_prefill when tools are actually passed in the request.
+ // Read here rather than inside the native structured-output branch
below because it governs
+ // the JSON prefill too, and a caller can supply an output_config
without supplying any
+ // output schema for that branch to look at.
+ boolean callerSuppliedOutputConfig =
+ additionalKwargs != null &&
additionalKwargs.containsKey("output_config");
+
+ // Native structured output applies only for a POJO Class schema on a
model Anthropic
+ // documents as capable; a RowTypeInfo (wrapped in OutputSchema) or an
incapable model keeps
+ // the prompt-engineering fallback. A caller-supplied output_config is
the caller being
+ // explicit about the exact parameter this branch writes, so it wins
and the schema falls
+ // back to prompt engineering rather than the two competing on the
same request.
+ //
+ // TODO(#912): the requested strategy is not visible here, so this
re-check cannot tell an
+ // explicit NATIVE request apart from one that merely resolved to
native. A caller asking
+ // for NATIVE on a model this predicate rejects therefore degrades
silently to the
+ // prompt-engineering fallback instead of getting an error. Once
strategy resolution is
+ // wired up, NATIVE must either bypass this capability re-check or
fail explicitly.
+ boolean nativeSchemaApplied = false;
+ if (outputSchema instanceof Class
+ && supportsNativeStructuredOutput(modelName)
+ && !callerSuppliedOutputConfig) {
+ builder.outputConfig(toNativeOutputConfig((Class<?>)
outputSchema));
+ nativeSchemaApplied = true;
+ }
+
+ // JSON prefill appends a prefilled assistant "{" message to steer the
model into emitting a
+ // JSON document. It applies only when the request carries none of
three features:
+ // - tool use, because the prefill forces JSON text instead of
native tool_use blocks;
+ // - structured outputs, which Anthropic documents as incompatible
with message prefilling
+ // — output_config already has the provider enforcing the very
document the prefill
+ // exists to coax out of the model;
+ // - a model that rejects prefilling outright, which answers with a
400 rather than a
+ // completion.
+ // The output_config test covers both ways one can reach the request:
derived from
+ // outputSchema above, or supplied by the caller through
additional_kwargs. It keys on what
+ // the request ends up carrying rather than on what was supplied, so a
schema that could not
+ // be sent natively keeps the prefill its prompt-engineering fallback
depends on — unless
+ // the caller supplied an output_config of its own.
Object jsonPrefill = modelParams.remove("json_prefill");
boolean hasToolsInRequest = tools != null && !tools.isEmpty();
- if (Boolean.TRUE.equals(jsonPrefill) && !hasToolsInRequest) {
+ boolean requestCarriesOutputConfig = nativeSchemaApplied ||
callerSuppliedOutputConfig;
+ boolean jsonPrefillApplied =
+ Boolean.TRUE.equals(jsonPrefill)
+ && !hasToolsInRequest
+ && !requestCarriesOutputConfig
+ && supportsJsonPrefill(modelName);
+ if (jsonPrefillApplied) {
anthropicMessages.add(
MessageParam.builder().role(MessageParam.Role.ASSISTANT).content("{").build());
+ // The builder copies the list it is given, so appending to the
local list after the
+ // earlier messages(...) call is not enough - the list has to be
handed over again.
builder.messages(anthropicMessages);
}
- return builder.build();
+ return new BuiltRequest(builder.build(), jsonPrefillApplied);
+ }
+
+ /** A built request together with the JSON prefill decision applied while
building it. */
+ static final class BuiltRequest {
+ final MessageCreateParams params;
+ final boolean jsonPrefillApplied;
+
+ BuiltRequest(MessageCreateParams params, boolean jsonPrefillApplied) {
+ this.params = params;
+ this.jsonPrefillApplied = jsonPrefillApplied;
+ }
}
private List<TextBlockParam> extractSystemMessages(List<ChatMessage>
messages) {
@@ -362,7 +576,17 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
}
}
- private ChatMessage convertResponse(Message response, boolean
jsonPrefillApplied) {
+ /**
+ * Converts a response into a {@link ChatMessage}, reconstructing the
leading {@code "{"} when
+ * the request carried the JSON prefill.
+ *
+ * <p>Takes the whole {@link BuiltRequest} rather than the prefill flag on
its own so the flag
+ * travels with the request it was derived from, instead of being computed
separately at the call
+ * site where the two can drift apart. A flag that disagrees with the
request either prepends a
+ * stray {@code "{"} or drops a required one, and the resulting JSON is
malformed in a way the
+ * response itself gives no sign of.
+ */
+ ChatMessage convertResponse(BuiltRequest built, Message response) {
List<ContentBlock> contentBlocks = response.content();
if (contentBlocks.isEmpty()) {
throw new IllegalStateException("Anthropic response did not
contain any content.");
@@ -370,7 +594,7 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
StringBuilder textContent = new StringBuilder();
// If JSON prefill was used, prepend "{" since the response only
contains the continuation
- if (jsonPrefillApplied) {
+ if (built.jsonPrefillApplied) {
textContent.append("{");
}
List<Map<String, Object>> toolCalls = new ArrayList<>();
@@ -423,9 +647,10 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
* Extracts JSON content from a string that may contain markdown code
blocks.
*
* <p>Claude often wraps JSON responses in markdown code blocks like
{@code ```json ... ```},
- * especially when tools are configured (since json_prefill is disabled).
This method extracts
- * the JSON content from such responses. If no code block is found, the
original content is
- * returned unchanged.
+ * especially on a response no JSON prefill was applied to, since an
assistant turn already
+ * opened with {@code "{"} cannot be continued into a fence. This method
extracts the JSON
+ * content from such responses. If no code block is found, the original
content is returned
+ * unchanged.
*
* @param content The response content that may contain markdown-wrapped
JSON
* @return The extracted JSON string, or the original content if no code
block is found
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 cf2a7935..b7636f8f 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
@@ -41,11 +41,15 @@ import java.util.Optional;
* <li><b>temperature</b> (optional): Sampling temperature 0.0-1.0 (default:
0.1)
* <li><b>max_tokens</b> (optional): Maximum tokens in response (default:
1024)
* <li><b>json_prefill</b> (optional): When true, prefills assistant
response with "{" to enforce
- * JSON output. Automatically disabled when tools are passed. (default:
true)
+ * JSON output. Applies only on models Anthropic documents as accepting
assistant-message
+ * prefilling, and is automatically disabled when tools are passed, or
when the request
+ * carries an output_config, whether that was derived from an output
schema or supplied
+ * through additional_kwargs. (default: false)
* <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)
+ * <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.
* </ul>
*
* <p>Example usage:
@@ -74,7 +78,7 @@ public class AnthropicChatModelSetup extends
BaseChatModelSetup {
private static final String DEFAULT_MODEL = "claude-sonnet-4-20250514";
private static final double DEFAULT_TEMPERATURE = 0.1d;
private static final long DEFAULT_MAX_TOKENS = 1024L;
- private static final boolean DEFAULT_JSON_PREFILL = true;
+ private static final boolean DEFAULT_JSON_PREFILL = false;
private static final boolean DEFAULT_STRICT_TOOLS = false;
private final Double temperature;
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
new file mode 100644
index 00000000..091b900b
--- /dev/null
+++
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
@@ -0,0 +1,609 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.anthropic;
+
+import com.anthropic.models.messages.Message;
+import com.anthropic.models.messages.MessageParam;
+import com.anthropic.models.messages.Model;
+import com.anthropic.models.messages.OutputConfig;
+import com.anthropic.models.messages.TextBlock;
+import com.anthropic.models.messages.Usage;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.agents.api.tools.ToolMetadata;
+import org.apache.flink.agents.api.tools.ToolParameters;
+import org.apache.flink.agents.api.tools.ToolResponse;
+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.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link AnthropicChatModelConnection}'s request construction,
its native
+ * structured-output capability check, and the response conversion that
consumes the request. No
+ * test issues a request: they inspect what {@code buildRequest} produced,
call the capability
+ * predicate directly, and feed {@code convertResponse} a hand-built response,
so they need no
+ * credentials, no network, and no mocking framework.
+ */
+class AnthropicChatModelConnectionTest {
+
+ private static final ResourceContext NOOP =
ResourceContext.fromGetResource((a, b) -> null);
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final TypeReference<Map<String, Object>> MAP_TYPE = new
TypeReference<>() {};
+
+ /** The continuation an assistant returns after a "{" prefill, and the
document it completes. */
+ private static final String CONTINUATION = "\"ok\": true}";
+
+ private static final String COMPLETED = "{" + CONTINUATION;
+
+ private static ResourceDescriptor descriptor(String model) {
+ return
ResourceDescriptor.Builder.newBuilder(AnthropicChatModelConnection.class.getName())
+ .addInitialArgument("api_key", "test-key")
+ .addInitialArgument("model", model)
+ .build();
+ }
+
+ private static AnthropicChatModelConnection connection() {
+ return new
AnthropicChatModelConnection(descriptor("claude-sonnet-4-20250514"), NOOP);
+ }
+
+ private static Map<String, Object> params(Object jsonPrefill) {
+ Map<String, Object> params = new HashMap<>();
+ params.put("max_tokens", 256);
+ if (jsonPrefill != null) {
+ params.put("json_prefill", jsonPrefill);
+ }
+ return params;
+ }
+
+ private static List<ChatMessage> userMessage() {
+ return List.of(new ChatMessage(MessageRole.USER, "hi"));
+ }
+
+ /** An assistant response carrying a single text block. */
+ private static Message textResponse(String text) {
+ Usage usage =
+ Usage.builder()
+ .inputTokens(1)
+ .outputTokens(1)
+ .cacheCreation(Optional.empty())
+ .cacheCreationInputTokens(Optional.empty())
+ .cacheReadInputTokens(Optional.empty())
+ .serverToolUse(Optional.empty())
+ .serviceTier(Optional.empty())
+ .build();
+ return Message.builder()
+ .id("msg_test")
+ .model(Model.of("claude-sonnet-4-20250514"))
+
.addContent(TextBlock.builder().text(text).citations(Optional.empty()).build())
+ .stopReason(Optional.empty())
+ .stopSequence(Optional.empty())
+ .usage(usage)
+ .build();
+ }
+
+ /** True when the built request ends with the prefilled assistant "{"
message. */
+ private static boolean
requestCarriesPrefill(AnthropicChatModelConnection.BuiltRequest built) {
+ List<MessageParam> messages = built.params.messages();
+ MessageParam last = messages.get(messages.size() - 1);
+ return last.role().equals(MessageParam.Role.ASSISTANT)
+ && last.content().string().isPresent()
+ && "{".equals(last.content().string().get());
+ }
+
+ /**
+ * Asserts that the recorded decision, the request content, and the
converted response all agree
+ * with each other and with {@code expectedApplied}.
+ *
+ * <p>The agreement is the invariant that matters: a decision that does
not match what the
+ * request actually carries makes the conversion either prepend a stray
{@code "{"} or drop a
+ * required one, yielding malformed JSON the response gives no sign of.
+ */
+ private static void assertPrefillDecision(
+ Object jsonPrefill, List<Tool> tools, boolean expectedApplied) {
+ AnthropicChatModelConnection connection = connection();
+ AnthropicChatModelConnection.BuiltRequest built =
+ connection.buildRequest(userMessage(), tools,
params(jsonPrefill), null);
+
+ assertThat(built.jsonPrefillApplied).isEqualTo(expectedApplied);
+ assertThat(requestCarriesPrefill(built)).isEqualTo(expectedApplied);
+ assertThat(connection.convertResponse(built,
textResponse(CONTINUATION)).getContent())
+ .isEqualTo(expectedApplied ? COMPLETED : CONTINUATION);
+ }
+
+ @Test
+ @DisplayName("json_prefill applied when requested with no tools")
+ void testPrefillAppliedWithoutTools() {
+ assertPrefillDecision(true, List.of(), true);
+ }
+
+ @Test
+ @DisplayName("json_prefill not applied when tools are present")
+ void testPrefillNotAppliedWithTools() {
+ assertPrefillDecision(true, List.of(new StubTool()), false);
+ }
+
+ @Test
+ @DisplayName("json_prefill not applied when the parameter is absent")
+ void testPrefillNotAppliedWhenAbsent() {
+ assertPrefillDecision(null, List.of(), false);
+ }
+
+ @Test
+ @DisplayName("json_prefill not applied when the parameter is false")
+ void testPrefillNotAppliedWhenFalse() {
+ assertPrefillDecision(false, List.of(), false);
+ }
+
+ @Test
+ @DisplayName("json_prefill applied when the tools list is null")
+ void testPrefillAppliedWithNullTools() {
+ assertPrefillDecision(true, null, true);
+ }
+
+ @Test
+ @DisplayName("null model params are copied rather than dereferenced")
+ void testNullModelParamsAreCopied() {
+ // With no params there is no max_tokens either, so the SDK's own
required-field check is
+ // the first thing that can fail. Reaching it at all is the assertion:
a regression in the
+ // null handling would surface earlier, as a NullPointerException.
+ assertThatThrownBy(() -> connection().buildRequest(userMessage(),
List.of(), null, null))
+ .isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ @DisplayName("request build failures surface as a wrapped
RuntimeException")
+ void testBuildFailureIsWrapped() {
+ List<ChatMessage> messages = List.of(new ChatMessage(MessageRole.TOOL,
"result"));
+
+ assertThatThrownBy(() -> connection().chat(messages, List.of(),
params(null)))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Failed to call Anthropic messages API.")
+ .hasRootCauseInstanceOf(IllegalArgumentException.class);
+ }
+
+ //
---------------------------------------------------------------------------------------
+ // Native structured output
+ //
---------------------------------------------------------------------------------------
+
+ /**
+ * A model the provider documents native structured-output support for.
+ *
+ * <p>Deliberately a 4.5-generation name, which is the only generation
that is both
+ * structured-output capable and still accepts a JSON prefill. The
output_config tests below
+ * assert that a prefill is suppressed; on a 4.6-or-later name the prefill
capability guard
+ * would suppress it as well, so those assertions would hold even with the
output_config
+ * suppression removed.
+ */
+ private static final String CAPABLE_MODEL = "claude-sonnet-4-5";
+
+ /** The connection's own default model, which predates the
structured-output cutoff. */
+ private static final String INCAPABLE_MODEL = "claude-sonnet-4-20250514";
+
+ /**
+ * The models the provider documents native structured-output support for,
in the order the
+ * connection lists them: the exact-matched names first, then the
prefix-matched aliases.
+ * 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> capableModels() {
+ return Stream.of(
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview",
+ "claude-opus-4-5",
+ "claude-sonnet-4-5",
+ "claude-haiku-4-5");
+ }
+
+ /**
+ * Names that must not be treated as capable. {@code
claude-opus-4-1-20250805} and {@code
+ * claude-opus-4} are the reason the alias prefixes retain their minor
version: truncating
+ * {@code claude-opus-4-5} to {@code claude-opus-4} would admit both.
+ *
+ * <p>The empty name is reachable — a blank configured model survives the
blank-check in the
+ * request builder and arrives here unchanged — and it is the shortest
name the predicate has to
+ * answer for, so a rewrite that indexes into the name rather than
matching it whole breaks on
+ * it.
+ */
+ private static Stream<String> incapableModels() {
+ return Stream.of(
+ "claude-opus-4-1-20250805",
+ "claude-opus-4",
+ "claude-sonnet-4-20250514",
+ "claude-3-5-sonnet-latest",
+ "");
+ }
+
+ /** A POJO the SDK can derive a JSON schema from. */
+ public static class Answer {
+ public String verdict;
+ }
+
+ private static Map<String, Object> paramsWithModel(String model, Object
jsonPrefill) {
+ Map<String, Object> params = params(jsonPrefill);
+ params.put("model", model);
+ return params;
+ }
+
+ private static AnthropicChatModelConnection.BuiltRequest build(
+ String model, Object outputSchema, Object jsonPrefill) {
+ return connection()
+ .buildRequest(
+ userMessage(),
+ List.of(),
+ paramsWithModel(model, jsonPrefill),
+ outputSchema);
+ }
+
+ /** The property names of the JSON schema the request carries, or empty
when it carries none. */
+ private static Set<String> nativeSchemaProperties(
+ AnthropicChatModelConnection.BuiltRequest built) {
+ return built.params
+ .outputConfig()
+ .flatMap(OutputConfig::format)
+ .map(format -> format.schema()._additionalProperties())
+ .map(schema -> schema.get("properties"))
+ .map(properties -> MAPPER.convertValue(properties,
MAP_TYPE).keySet())
+ .orElse(Set.of());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"claude-sonnet-4-5", "claude-opus-4-6"})
+ @DisplayName("a POJO schema on a capable model is sent as output_config")
+ void testNativeSchemaAppliedOnCapableModel(String model) {
+ // One name from each way the capability check can match: a
4.5-generation alias reached by
+ // prefix, and a 4.6 name reached by exact match. The request-build
site consults the check
+ // as a whole, so covering only one branch would let it be narrowed to
that branch while
+ // silently dropping native structured output for every model on the
other.
+ AnthropicChatModelConnection.BuiltRequest built = build(model,
Answer.class, null);
+
+ // Asserting the property name rather than mere presence: an
output_config built from the
+ // wrong class, or from an empty placeholder, would still be present.
+ assertThat(nativeSchemaProperties(built)).containsExactly("verdict");
+ }
+
+ @Test
+ @DisplayName("a POJO schema on an incapable model keeps the prompt
fallback")
+ void testNativeSchemaNotAppliedOnIncapableModel() {
+ assertThat(build(INCAPABLE_MODEL, Answer.class,
null).params.outputConfig()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("no output_config is sent when no schema is supplied")
+ void testNativeSchemaNotAppliedWithoutSchema() {
+ assertThat(build(CAPABLE_MODEL, null,
null).params.outputConfig()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("a schema that is not a Class keeps the prompt fallback")
+ void testNonClassSchemaKeepsFallback() {
+ // The RowTypeInfo case arrives wrapped rather than as a Class;
anything but a Class has no
+ // native translation and must degrade rather than fail.
+ assertThat(build(CAPABLE_MODEL, "not-a-class",
null).params.outputConfig()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("the native path adds no anthropic-beta header")
+ void testNativePathSendsNoBetaHeader() {
+ // Structured outputs are generally available; the beta header the
neighbouring strict_tools
+ // path sends must not leak onto this one.
+ assertThat(build(CAPABLE_MODEL, Answer.class,
null).params._additionalHeaders().names())
+ .doesNotContain("anthropic-beta");
+ }
+
+ @ParameterizedTest
+ @MethodSource("capableModels")
+ @DisplayName("every documented model reports capable")
+ void testCapableModelsReportCapable(String model) {
+
assertThat(connection().supportsNativeStructuredOutput(model)).isTrue();
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @MethodSource("incapableModels")
+ @DisplayName("an undocumented model reports not capable")
+ void testIncapableModelsReportNotCapable(String model) {
+
assertThat(connection().supportsNativeStructuredOutput(model)).isFalse();
+ }
+
+ @Test
+ @DisplayName("an alias prefix also matches the dated snapshot behind it")
+ void testAliasPrefixMatchesDatedSnapshot() {
+ // The three 4.5-generation names are aliases, so a request may carry
the snapshot instead.
+ // Converting the prefixes to exact matches would still satisfy the
capable-models test.
+
assertThat(connection().supportsNativeStructuredOutput("claude-sonnet-4-5-20250929"))
+ .isTrue();
+ }
+
+ @Test
+ @DisplayName("an alias prefix does not match a longer minor version")
+ void testAliasPrefixDoesNotMatchLongerMinorVersion() {
+ // A dated snapshot continues the alias with a "-" separator. A name
that extends the
+ // alias without one is a different minor version, whose capability is
not the alias's to
+ // answer for.
+
assertThat(connection().supportsNativeStructuredOutput("claude-sonnet-4-50")).isFalse();
+ }
+
+ @Test
+ @DisplayName("capability does not depend on the connection's configured
model")
+ void testCapabilityReadsNoInstanceState() {
+ AnthropicChatModelConnection configuredCapable =
+ new AnthropicChatModelConnection(descriptor(CAPABLE_MODEL),
NOOP);
+
+ // connection() is configured with an incapable default. Both must
answer for the argument
+ // alone, so a predicate that consulted the configured model would
disagree with itself.
+
assertThat(configuredCapable.supportsNativeStructuredOutput(INCAPABLE_MODEL))
+
.isEqualTo(connection().supportsNativeStructuredOutput(INCAPABLE_MODEL));
+
assertThat(configuredCapable.supportsNativeStructuredOutput(CAPABLE_MODEL))
+
.isEqualTo(connection().supportsNativeStructuredOutput(CAPABLE_MODEL));
+ }
+
+ @Test
+ @DisplayName("a caller-supplied output_config wins and the schema falls
back")
+ void testCallerOutputConfigWinsOverSchema() {
+ Map<String, Object> params = paramsWithModel(CAPABLE_MODEL, true);
+ params.put("additional_kwargs", Map.of("output_config",
Map.of("format", Map.of())));
+
+ AnthropicChatModelConnection.BuiltRequest built =
+ connection().buildRequest(userMessage(), List.of(), params,
Answer.class);
+
+ assertThat(built.params.outputConfig()).isEmpty();
+
assertThat(built.params._additionalBodyProperties()).containsKey("output_config");
+ // The request still carries an output_config, just the caller's, and
a prefill alongside
+ // one is a combination the provider documents as unsupported.
+ assertThat(built.jsonPrefillApplied).isFalse();
+ assertThat(requestCarriesPrefill(built)).isFalse();
+ }
+
+ @Test
+ @DisplayName("a caller-supplied output_config suppresses json_prefill with
no schema supplied")
+ void testCallerOutputConfigSuppressesPrefillWithoutSchema() {
+ // No output schema, so nothing derives an output_config and the
caller's is the only one on
+ // the request. Asserted on both a capable and an incapable model
because suppression has to
+ // follow the config the request carries rather than the model's
structured-output
+ // capability, which a check folded in beside the capability test
would get wrong.
+ for (String model : List.of(CAPABLE_MODEL, INCAPABLE_MODEL)) {
+ Map<String, Object> params = paramsWithModel(model, true);
+ params.put("additional_kwargs", Map.of("output_config",
Map.of("format", Map.of())));
+
+ AnthropicChatModelConnection.BuiltRequest built =
+ connection().buildRequest(userMessage(), List.of(),
params, null);
+
+ assertThat(built.jsonPrefillApplied).as(model).isFalse();
+ assertThat(requestCarriesPrefill(built)).as(model).isFalse();
+ }
+ }
+
+ @Test
+ @DisplayName("the three-argument chat forwards its arguments and no output
schema")
+ void testThreeArgChatForwardsNoSchema() {
+ // Existing framework callers reach the connection through the
three-argument overload. It
+ // has to keep forwarding no schema: a non-null argument here would
switch every capable
+ // model to native structured output without any caller asking for it.
The other three
+ // arguments have to arrive unchanged, since dropping any of them is
silent - the request
+ // still builds, just without the caller's tools or model parameters.
Overriding the
+ // four-argument overload is what makes the forwarded values
observable without a network
+ // call, since the real one issues the request.
+ Object notCalled = new Object();
+ AtomicReference<Object> forwardedSchema = new
AtomicReference<>(notCalled);
+ AtomicReference<List<ChatMessage>> forwardedMessages = new
AtomicReference<>();
+ AtomicReference<List<Tool>> forwardedTools = new AtomicReference<>();
+ AtomicReference<Map<String, Object>> forwardedParams = new
AtomicReference<>();
+ AnthropicChatModelConnection connection =
+ new AnthropicChatModelConnection(descriptor(CAPABLE_MODEL),
NOOP) {
+ @Override
+ public ChatMessage chat(
+ List<ChatMessage> messages,
+ List<Tool> tools,
+ Map<String, Object> modelParams,
+ Object outputSchema) {
+ forwardedSchema.set(outputSchema);
+ forwardedMessages.set(messages);
+ forwardedTools.set(tools);
+ forwardedParams.set(modelParams);
+ return ChatMessage.assistant("");
+ }
+ };
+
+ List<ChatMessage> messages = userMessage();
+ List<Tool> tools = List.of(new StubTool());
+ Map<String, Object> modelParams = params(null);
+ connection.chat(messages, tools, modelParams);
+
+ assertThat(forwardedSchema.get()).isNotSameAs(notCalled);
+ assertThat(forwardedSchema.get()).isNull();
+ assertThat(forwardedMessages.get()).isSameAs(messages);
+ assertThat(forwardedTools.get()).isSameAs(tools);
+ assertThat(forwardedParams.get()).isSameAs(modelParams);
+ }
+
+ @Test
+ @DisplayName("json_prefill is suppressed when the schema is applied
natively")
+ void testJsonPrefillSuppressedWhenNativeApplies() {
+ AnthropicChatModelConnection connection = connection();
+ AnthropicChatModelConnection.BuiltRequest built =
+ connection.buildRequest(
+ userMessage(),
+ List.of(),
+ paramsWithModel(CAPABLE_MODEL, true),
+ Answer.class);
+
+ assertThat(built.jsonPrefillApplied).isFalse();
+ assertThat(requestCarriesPrefill(built)).isFalse();
+ // The provider returns a complete document, so nothing may be
prepended to it.
+ assertThat(connection.convertResponse(built,
textResponse(COMPLETED)).getContent())
+ .isEqualTo(COMPLETED);
+ }
+
+ @Test
+ @DisplayName("json_prefill survives when a schema falls back to prompt
engineering")
+ void testJsonPrefillAppliedWhenSchemaFallsBack() {
+ // Suppression keys on whether the schema was applied, not on whether
one was supplied.
+ // Keying it on the schema instead would strip the prefill the
fallback still depends on.
+ AnthropicChatModelConnection connection = connection();
+ AnthropicChatModelConnection.BuiltRequest built =
+ connection.buildRequest(
+ userMessage(),
+ List.of(),
+ paramsWithModel(INCAPABLE_MODEL, true),
+ Answer.class);
+
+ assertThat(built.jsonPrefillApplied).isTrue();
+ assertThat(requestCarriesPrefill(built)).isTrue();
+ assertThat(connection.convertResponse(built,
textResponse(CONTINUATION)).getContent())
+ .isEqualTo(COMPLETED);
+ }
+
+ //
---------------------------------------------------------------------------------------
+ // JSON prefill model capability
+ //
---------------------------------------------------------------------------------------
+
+ /**
+ * The models the provider documents as rejecting assistant-message
prefilling, 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> prefillUnsupportedModels() {
+ return Stream.of(
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview");
+ }
+
+ /**
+ * Names that accept a prefill. The three 4.5-generation names are the
load-bearing ones: they
+ * are documented as structured-output capable, so folding the two rules
onto one list would
+ * silently withdraw the prefill from exactly these models.
+ *
+ * <p>{@code claude-sonnet-4-5-20250929} is the dated snapshot behind one
of those aliases, and
+ * {@code claude-3-5-sonnet-latest} stands for every name the list does
not mention, which keeps
+ * the prefill because only the listed names withdraw it.
+ */
+ private static Stream<String> prefillSupportedModels() {
+ return Stream.of(
+ "claude-opus-4-5",
+ "claude-sonnet-4-5",
+ "claude-haiku-4-5",
+ "claude-sonnet-4-5-20250929",
+ "claude-sonnet-4-20250514",
+ "claude-3-5-sonnet-latest",
+ "");
+ }
+
+ /**
+ * Asserts the prefill decision, the request content and the converted
response for a request
+ * that asks for the prefill on {@code model} and gives the decision no
other reason to go
+ * either way: no tools and no output configuration.
+ */
+ private static void assertPrefillDecisionForModel(String model, boolean
expectedApplied) {
+ AnthropicChatModelConnection connection = connection();
+ AnthropicChatModelConnection.BuiltRequest built =
+ connection.buildRequest(
+ userMessage(), List.of(), paramsWithModel(model,
true), null);
+
+ assertThat(built.jsonPrefillApplied).isEqualTo(expectedApplied);
+ assertThat(requestCarriesPrefill(built)).isEqualTo(expectedApplied);
+ assertThat(connection.convertResponse(built,
textResponse(CONTINUATION)).getContent())
+ .isEqualTo(expectedApplied ? COMPLETED : CONTINUATION);
+ }
+
+ @ParameterizedTest
+ @MethodSource("prefillUnsupportedModels")
+ @DisplayName("every model documented as rejecting prefilling reports
unsupported")
+ void testPrefillUnsupportedModelsReportUnsupported(String model) {
+
assertThat(AnthropicChatModelConnection.supportsJsonPrefill(model)).isFalse();
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @MethodSource("prefillSupportedModels")
+ @DisplayName("a model outside that list reports prefill supported")
+ void testPrefillSupportedModelsReportSupported(String model) {
+
assertThat(AnthropicChatModelConnection.supportsJsonPrefill(model)).isTrue();
+ }
+
+ @Test
+ @DisplayName("json_prefill is suppressed on a model that rejects
prefilling")
+ void testPrefillSuppressedOnUnsupportedModel() {
+ assertPrefillDecisionForModel("claude-opus-4-6", false);
+ }
+
+ @Test
+ @DisplayName("json_prefill is applied on a structured-output capable model
that accepts it")
+ void testPrefillAppliedOnStructuredOutputCapableModel() {
+ // The two capability rules draw different lines, and this model sits
between them: the
+ // provider documents structured-output support from the 4.5
generation on but withdraws
+ // prefilling only from 4.6 on. Deriving the prefill rule from the
structured-output
+ // allowlists would strip the prefill here, where the provider still
accepts it.
+
assertThat(connection().supportsNativeStructuredOutput("claude-sonnet-4-5")).isTrue();
+
+ assertPrefillDecisionForModel("claude-sonnet-4-5", true);
+ }
+
+ /** Minimal tool stub; only its presence in the tools list matters. */
+ private static class StubTool extends Tool {
+ StubTool() {
+ super(new ToolMetadata("add", "adds", "{\"type\":\"object\"}"));
+ }
+
+ @Override
+ public ToolType getToolType() {
+ return ToolType.FUNCTION;
+ }
+
+ @Override
+ public ToolResponse call(ToolParameters parameters) {
+ return ToolResponse.success(null);
+ }
+ }
+}
diff --git
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetupTest.java
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetupTest.java
new file mode 100644
index 00000000..8cd126ab
--- /dev/null
+++
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelSetupTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.anthropic;
+
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link AnthropicChatModelSetup}. */
+class AnthropicChatModelSetupTest {
+
+ private static final ResourceContext NOOP =
ResourceContext.fromGetResource((a, b) -> null);
+
+ private static ResourceDescriptor.Builder base() {
+ return
ResourceDescriptor.Builder.newBuilder(AnthropicChatModelSetup.class.getName())
+ .addInitialArgument("connection", "conn");
+ }
+
+ @Test
+ @DisplayName("getParameters applies the documented defaults")
+ void testGetParametersDefaults() {
+ Map<String, Object> params =
+ new AnthropicChatModelSetup(base().build(),
NOOP).getParameters();
+
+ assertThat(params).containsEntry("model", "claude-sonnet-4-20250514");
+ assertThat(params).containsEntry("temperature", 0.1d);
+ assertThat(params).containsEntry("max_tokens", 1024L);
+ assertThat(params).containsEntry("strict_tools", false);
+ // json_prefill is opt-in: it steers the model with a technique
several models reject
+ // outright, so a setup that does not ask for it must not send it.
+ assertThat(params).containsEntry("json_prefill", false);
+ }
+
+ @Test
+ @DisplayName("getParameters honors an explicit json_prefill")
+ void testGetParametersHonorsExplicitJsonPrefill() {
+ // Pins that the argument is read rather than the default being
emitted unconditionally.
+ Map<String, Object> params =
+ new AnthropicChatModelSetup(
+ base().addInitialArgument("json_prefill",
true).build(), NOOP)
+ .getParameters();
+
+ assertThat(params).containsEntry("json_prefill", true);
+ }
+}
diff --git a/integrations/pom.xml b/integrations/pom.xml
index bbd51c55..8c7b4cba 100644
--- a/integrations/pom.xml
+++ b/integrations/pom.xml
@@ -35,7 +35,7 @@ under the License.
<elasticsearch.version>8.19.0</elasticsearch.version>
<milvus.version>2.6.18</milvus.version>
<openai.version>4.8.0</openai.version>
- <anthropic.version>2.11.1</anthropic.version>
+ <anthropic.version>2.12.0</anthropic.version>
<aws.sdk.version>2.32.16</aws.sdk.version>
<google.genai.version>1.56.0</google.genai.version>
</properties>
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 f816a289..2cf1d8da 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
@@ -18,10 +18,10 @@
import uuid
from typing import Any, Dict, List, Sequence
-from anthropic import Anthropic
+from anthropic import Anthropic, transform_schema
from anthropic._types import NOT_GIVEN
from anthropic.types import MessageParam, TextBlockParam, ToolParam
-from pydantic import Field, PrivateAttr
+from pydantic import BaseModel, Field, PrivateAttr
from typing_extensions import override
from flink_agents.api.agents.types import OutputSchema
@@ -111,6 +111,113 @@ def convert_to_anthropic_system_prompts(
]
+# Models Anthropic documents native structured-output support for. Source of
truth:
+# https://platform.claude.com/docs/en/build-with-claude/structured-outputs
+#
+# The documented rule is generational rather than a per-snapshot list:
structured
+# outputs are generally available for Claude 4.5 and later models, and for
Claude Mythos
+# Preview. Names from the 4.6 generation onward carry no date and are pinned,
so the
+# name is itself the snapshot and is matched exactly.
+#
+# The three 4.5-generation names are aliases that front a dated snapshot, so a
request
+# may carry either the alias or the snapshot behind it and both have to match.
Those
+# match the alias itself or a name continuing with a "-" separator, which
covers
+# claude-sonnet-4-5-20250929. A name that extends the alias without that
separator is
+# a different minor version and is capable only if the exact set names it. The
alias
+# also has to retain the minor version: "claude-opus-4" would capture
+# claude-opus-4-1-20250805, which predates the cutoff and is not capable.
+#
+# A name outside both sets reports not-capable and degrades to the
prompt-engineering
+# fallback rather than failing at the provider.
+_NATIVE_STRUCTURED_OUTPUT_MODELS = frozenset(
+ {
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview",
+ }
+)
+
+_NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES = (
+ "claude-opus-4-5",
+ "claude-sonnet-4-5",
+ "claude-haiku-4-5",
+)
+
+
+# Models Anthropic documents as rejecting assistant-message prefilling. Source
of truth:
+#
https://platform.claude.com/docs/en/build-with-claude/working-with-messages#putting-words-in-claudes-mouth
+#
+# Prefilling is not supported from the Claude 4.6 generation onward, nor on
Claude
+# Mythos Preview, Claude Fable 5 or Claude Mythos 5; a request that prefills
one of
+# them is answered with a 400 rather than a completion. Anthropic publishes no
+# programmatic signal for prefill support the way it does for structured
outputs, so
+# the rule has to be a maintained list of names. Those names carry no date and
are
+# pinned, so the name is itself the snapshot and is matched exactly, and a
name outside
+# the list is treated as accepting the prefill.
+#
+# Kept in its own storage rather than derived from the structured-output
allowlists
+# above, whose contents it currently coincides with. The two encode different
+# documented boundaries: structured output starts at the 4.5 generation while
prefill
+# rejection starts at 4.6, so the three 4.5-generation names are
structured-output
+# capable and still accept a prefill. Sharing one list would hold only until a
model
+# moves one boundary without moving the other.
+_PREFILL_UNSUPPORTED_MODELS = frozenset(
+ {
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview",
+ }
+)
+
+
+def _supports_json_prefill(effective_model: str | None) -> bool:
+ """Whether ``effective_model`` accepts the prefilled assistant ``"{"``
message.
+
+ See the list above for the source of truth and for why it is matched
exactly and
+ kept apart from the structured-output allowlists. An unrecognized name
reports
+ ``True``, which matches the documented rule: prefilling is the
long-standing
+ behaviour and only the listed names withdraw it. The cost of that default
runs the
+ opposite way to ``supports_native_structured_output``: a rejecting model
this list
+ has not caught up with is prefilled and answered with a 400, where an
unrecognized
+ name on the structured-output path degrades silently to the
prompt-engineering
+ fallback instead.
+ """
+ return effective_model not in _PREFILL_UNSUPPORTED_MODELS
+
+
+def _native_output_config(output_schema: Any) -> Dict[str, Any] | None:
+ """Build the Anthropic ``output_config`` for a native structured-output
request.
+
+ Returns ``None`` (leaving the request unchanged) unless the schema is a
+ ``BaseModel`` subclass. A ``RowTypeInfo`` schema is skipped so it keeps the
+ prompt-engineering fallback.
+
+ Anthropic's format object carries only the schema and its type, so it
shares no
+ shape with the providers that nest the schema under a named, strict
+ ``json_schema`` object and is built here rather than in a shared helper.
+ """
+ if output_schema is None:
+ return None
+ model = (
+ output_schema.output_schema if isinstance(output_schema, OutputSchema)
else None
+ )
+ if not (isinstance(model, type) and issubclass(model, BaseModel)):
+ return None
+ return {"format": {"type": "json_schema", "schema":
transform_schema(model)}}
+
+
class AnthropicChatModelConnection(BaseChatModelConnection):
"""Manages the connection to the Anthropic AI models for chat interactions.
@@ -165,6 +272,25 @@ class
AnthropicChatModelConnection(BaseChatModelConnection):
)
return self._client
+ @override
+ def supports_native_structured_output(self, effective_model: str | None)
-> bool:
+ """Whether Anthropic documents structured output for
``effective_model``.
+
+ See the module-level allowlists for the source of truth and for why a
+ 4.5-generation alias also matches the dated snapshot behind it while
every other
+ name is matched exactly. A name outside both reports ``False`` so it
degrades to
+ the prompt-engineering fallback rather than failing at the provider.
+
+ Reads no instance state, so capability stays answerable independently
of how
+ the connection was configured.
+ """
+ if not effective_model:
+ return False
+ return effective_model in _NATIVE_STRUCTURED_OUTPUT_MODELS or any(
+ effective_model == prefix or effective_model.startswith(prefix +
"-")
+ for prefix in _NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES
+ )
+
def chat(
self,
messages: Sequence[ChatMessage],
@@ -174,12 +300,29 @@ class
AnthropicChatModelConnection(BaseChatModelConnection):
) -> ChatMessage:
"""Direct communication with Anthropic model service for chat
conversation.
- A non-``None`` ``output_schema`` is rejected: this connection has no
native
- structured-output translation, so callers stay on the
prompt-engineering
- fallback. Declaring the parameter keeps a caller-supplied schema out of
- ``**kwargs``, which is forwarded to the provider SDK.
+ Parameters
+ ----------
+ messages : Sequence[ChatMessage]
+ Input message sequence
+ tools : Optional[List]
+ List of tools that can be called by the model
+ output_schema : OutputSchema | None
+ The schema the response should conform to, or ``None`` for an
unconstrained
+ response. Native structured output is applied only for a
``BaseModel``
+ schema on a model the provider documents as capable, and only when
the
+ caller has not already supplied ``output_config``. Any other
combination
+ sends no derived schema and keeps the prompt-engineering fallback.
+ **kwargs : Any
+ Additional parameters passed to the model service (e.g.,
temperature,
+ max_tokens, etc.). ``json_prefill`` is consumed here rather than
+ forwarded: it selects the prefilled assistant ``"{"`` message
described
+ below and is not a request field the provider accepts.
+
+ Returns:
+ -------
+ ChatMessage
+ Model response message
"""
- self._reject_unsupported_output_schema(output_schema)
anthropic_tools = None
if tools is not None:
anthropic_tools = [
@@ -189,6 +332,55 @@ class
AnthropicChatModelConnection(BaseChatModelConnection):
anthropic_system = convert_to_anthropic_system_prompts(messages)
anthropic_messages = convert_to_anthropic_messages(messages)
+ # Removed from kwargs unconditionally: it is a framework parameter,
and leaving
+ # it in place would reach messages.create as an unknown request field.
+ json_prefill = kwargs.pop("json_prefill", False)
+
+ # TODO(#912): the requested strategy is not visible here, so this check
+ # cannot tell an explicit NATIVE request apart from one that merely
+ # resolved to native. A caller asking for NATIVE on a model this
+ # predicate rejects therefore degrades silently to the
prompt-engineering
+ # fallback instead of getting an error. Once strategy resolution is
wired
+ # up, NATIVE must either bypass this capability check or fail
explicitly.
+ if output_schema is not None and
self.supports_native_structured_output(
+ kwargs.get("model")
+ ):
+ output_config = _native_output_config(output_schema)
+ # An output_config already in kwargs is the caller being explicit
about the
+ # exact parameter this branch writes, so it is left alone and the
schema
+ # keeps the prompt-engineering fallback. Writing over it would
drop the
+ # caller's value with no error and no other trace.
+ if output_config is not None and "output_config" not in kwargs:
+ kwargs["output_config"] = output_config
+
+ # JSON prefill appends a prefilled assistant "{" message to steer the
model
+ # into emitting a JSON document. It applies only when the request
carries none
+ # of three features:
+ # - tool use, because the prefill forces JSON text instead of native
tool_use
+ # blocks;
+ # - structured outputs, which Anthropic documents as incompatible
with message
+ # prefilling - output_config already has the provider enforcing
the very
+ # document the prefill exists to coax out of the model;
+ # - a model that rejects prefilling outright, which answers with a
400 rather
+ # than a completion.
+ # Evaluated after the block above so the output_config test covers
both ways one
+ # can reach the request: derived from output_schema there, or supplied
by the
+ # caller. It keys on what the request ends up carrying rather than on
what was
+ # supplied, so a schema that could not be sent natively keeps the
prefill its
+ # prompt-engineering fallback depends on - unless the caller supplied
an
+ # output_config of its own.
+ prefill_applied = (
+ json_prefill is True
+ and not anthropic_tools
+ and "output_config" not in kwargs
+ and _supports_json_prefill(kwargs.get("model"))
+ )
+ if prefill_applied:
+ anthropic_messages = [
+ *anthropic_messages,
+ {"role": MessageRole.ASSISTANT.value, "content": "{"},
+ ]
+
message = self.client.messages.create(
messages=anthropic_messages,
tools=anthropic_tools or NOT_GIVEN,
@@ -211,6 +403,13 @@ class
AnthropicChatModelConnection(BaseChatModelConnection):
(block.text for block in message.content if block.type == "text"),
""
)
+ # The response continues the prefilled "{" rather than repeating it,
so the
+ # document is only complete once it is put back. Keyed on the decision
actually
+ # applied above: reconstructing on any other signal either prepends a
stray "{"
+ # or drops a required one, and the response itself gives no sign of
either.
+ if prefill_applied:
+ text = "{" + text
+
if message.stop_reason == "tool_use":
tool_calls = [
{
@@ -254,6 +453,7 @@ class AnthropicChatModelConnection(BaseChatModelConnection):
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
DEFAULT_MAX_TOKENS = 1024
DEFAULT_TEMPERATURE = 0.1
+DEFAULT_JSON_PREFILL = False
class AnthropicChatModelSetup(BaseChatModelSetup):
@@ -274,6 +474,12 @@ class AnthropicChatModelSetup(BaseChatModelSetup):
The maximum number of tokens to generate before stopping. Defaults to
1024.
temperature : float
Amount of randomness injected into the response.
+ json_prefill : bool
+ When True, prefills the assistant response with "{" to enforce JSON
output.
+ Applies only on models Anthropic documents as accepting
assistant-message
+ prefilling, and is automatically disabled when tools are passed, or
when the
+ request carries an output_config, whether that was derived from an
output
+ schema or supplied by the caller. Defaults to False.
"""
max_tokens: int = Field(
@@ -287,6 +493,13 @@ class AnthropicChatModelSetup(BaseChatModelSetup):
ge=0.0,
le=1.0,
)
+ json_prefill: bool = Field(
+ default=DEFAULT_JSON_PREFILL,
+ description=(
+ 'When True, prefills the assistant response with "{" to enforce
JSON '
+ "output. Defaults to False."
+ ),
+ )
def __init__(
self,
@@ -294,6 +507,8 @@ class AnthropicChatModelSetup(BaseChatModelSetup):
model: str = DEFAULT_ANTHROPIC_MODEL,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
+ *,
+ json_prefill: bool = DEFAULT_JSON_PREFILL,
**kwargs: Any,
) -> None:
"""Init method."""
@@ -302,6 +517,7 @@ class AnthropicChatModelSetup(BaseChatModelSetup):
model=model,
max_tokens=max_tokens,
temperature=temperature,
+ json_prefill=json_prefill,
**kwargs,
)
@@ -312,4 +528,5 @@ class AnthropicChatModelSetup(BaseChatModelSetup):
"model": self.model,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
+ "json_prefill": self.json_prefill,
}
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 f11889e4..90d3be81 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,18 +15,30 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#################################################################################
+from typing import Any, Dict
from unittest.mock import MagicMock
+import pytest
from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
+from pydantic import BaseModel
+from pyflink.common.typeinfo import Types
+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 (
AnthropicChatModelConnection,
+ AnthropicChatModelSetup,
+ _supports_json_prefill,
)
+def _connection() -> AnthropicChatModelConnection:
+ return AnthropicChatModelConnection(name="test", api_key="dummy")
+
+
def _connection_returning(message: Message) -> AnthropicChatModelConnection:
- connection = AnthropicChatModelConnection(name="test", api_key="dummy")
+ connection = _connection()
client = MagicMock()
client.messages.create.return_value = message
connection._client = client
@@ -137,3 +149,370 @@ def test_tool_use_response_keeps_token_usage() -> None:
)
assert response.extra_args["promptTokens"] == 7
assert response.extra_args["completionTokens"] == 3
+
+
+#
---------------------------------------------------------------------------------
+# Native structured output
+#
---------------------------------------------------------------------------------
+
+
+class _Answer(BaseModel):
+ """A representative BaseModel output schema."""
+
+ verdict: str
+
+
+# A model the provider documents native structured-output support for.
+#
+# Deliberately a 4.5-generation name, which is the only generation that is both
+# structured-output capable and still accepts a JSON prefill. The prefill
tests below
+# assert that an output_config suppresses the prefill; on a 4.6-or-later name
the
+# prefill capability guard would suppress it as well, so those assertions
would hold
+# even with the output_config suppression removed.
+_CAPABLE_MODEL = "claude-sonnet-4-5"
+
+# The default model this integration ships with, which predates the cutoff.
+_INCAPABLE_MODEL = "claude-sonnet-4-20250514"
+
+# The models the provider documents native structured-output support for, in
the order
+# the connection lists them: the exact-matched names first, then the
prefix-matched
+# aliases. The names are written out here rather than read from the
connection, so that
+# a name mistyped there is a disagreement between two lists rather than a
value both
+# sides share.
+_CAPABLE_MODELS = [
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview",
+ "claude-opus-4-5",
+ "claude-sonnet-4-5",
+ "claude-haiku-4-5",
+]
+
+# Names that must not be treated as capable. claude-opus-4-1-20250805 and
claude-opus-4
+# are the reason the alias prefixes retain their minor version: truncating
+# claude-opus-4-5 to claude-opus-4 would admit both.
+_INCAPABLE_MODELS = [
+ "claude-opus-4-1-20250805",
+ "claude-opus-4",
+ "claude-sonnet-4-20250514",
+ "claude-3-5-sonnet-latest",
+ "",
+ None,
+]
+
+
+def _request_kwargs(**chat_kwargs: Any) -> Dict[str, Any]:
+ """The keyword arguments the connection passed to ``messages.create``."""
+ message = Message(
+ id="m",
+ model="claude",
+ role="assistant",
+ type="message",
+ stop_reason="end_turn",
+ content=[TextBlock(type="text", text='{"verdict": "ok"}')],
+ usage=_usage(),
+ )
+ connection = _connection_returning(message)
+ connection.chat([ChatMessage(role=MessageRole.USER, content="hi")],
**chat_kwargs)
+ return connection.client.messages.create.call_args.kwargs
+
+
[email protected]("model", ["claude-sonnet-4-5", "claude-opus-4-6"])
+def test_native_output_config_applied_on_capable_model(model) -> None:
+ # One name from each way the capability check can match: a 4.5-generation
alias
+ # reached by prefix, and a 4.6 name reached by exact match. The chat path
consults
+ # the check as a whole, so covering only one branch would let it be
narrowed to
+ # that branch while silently dropping native structured output for every
model on
+ # the other.
+ output_config = _request_kwargs(
+ model=model, output_schema=OutputSchema(output_schema=_Answer)
+ )["output_config"]
+
+ # Asserting the property name rather than mere presence: a config derived
from the
+ # wrong schema, or from an empty placeholder, would also be present.
+ assert output_config["format"]["type"] == "json_schema"
+ assert set(output_config["format"]["schema"]["properties"]) == {"verdict"}
+
+
+def test_native_output_config_not_applied_on_incapable_model() -> None:
+ assert "output_config" not in _request_kwargs(
+ model=_INCAPABLE_MODEL,
output_schema=OutputSchema(output_schema=_Answer)
+ )
+
+
+def test_native_output_config_not_applied_without_schema() -> None:
+ assert "output_config" not in _request_kwargs(
+ model=_CAPABLE_MODEL, output_schema=None
+ )
+
+
+def test_native_output_config_not_applied_for_row_type_info() -> None:
+ # A RowTypeInfo schema has no native translation and must keep the
+ # prompt-engineering fallback rather than failing.
+ row_type = Types.ROW_NAMED(["verdict"], [Types.STRING()])
+ assert "output_config" not in _request_kwargs(
+ model=_CAPABLE_MODEL,
output_schema=OutputSchema(output_schema=row_type)
+ )
+
+
+def test_caller_output_config_wins_over_schema() -> None:
+ # Only one channel carries output_config into the request, so a derived
config
+ # would replace the caller's outright and report nothing. The caller's
value is
+ # kept and the schema stays on the prompt-engineering fallback.
+ caller_config = {"format": {"type": "json_schema", "schema": {"type":
"object"}}}
+
+ sent = _request_kwargs(
+ model=_CAPABLE_MODEL,
+ output_schema=OutputSchema(output_schema=_Answer),
+ output_config=caller_config,
+ )["output_config"]
+
+ assert sent == caller_config
+
+
[email protected]("model", _CAPABLE_MODELS)
+def test_capability_predicate_accepts_capable_models(model) -> None:
+ assert _connection().supports_native_structured_output(model) is True
+
+
[email protected]("model", _INCAPABLE_MODELS)
+def test_capability_predicate_rejects_incapable_models(model) -> None:
+ assert _connection().supports_native_structured_output(model) is False
+
+
+def test_alias_prefix_matches_dated_snapshot() -> None:
+ # The three 4.5-generation names are aliases, so a request may carry the
dated
+ # snapshot instead. Turning the prefixes into exact matches would still
satisfy
+ # the capable-models test above.
+ predicate = _connection().supports_native_structured_output
+ assert predicate("claude-sonnet-4-5-20250929") is True
+
+
+def test_alias_prefix_does_not_match_longer_minor_version() -> None:
+ # A dated snapshot continues the alias with a "-" separator. A name that
extends
+ # the alias without one is a different minor version, whose capability is
not the
+ # alias's to answer for.
+ predicate = _connection().supports_native_structured_output
+ assert predicate("claude-sonnet-4-50") is False
+
+
+def test_capability_reads_no_instance_state() -> None:
+ # __new__ skips __init__, so no field is set and no client exists. A
predicate
+ # reading instance state would raise here instead of answering for its
argument.
+ bare = AnthropicChatModelConnection.__new__(AnthropicChatModelConnection)
+
+ assert bare.supports_native_structured_output(_CAPABLE_MODEL) is True
+ assert bare.supports_native_structured_output(_INCAPABLE_MODEL) is False
+
+
+#
---------------------------------------------------------------------------------
+# JSON prefill
+#
---------------------------------------------------------------------------------
+
+# The continuation an assistant returns after a "{" prefill, and the document
it
+# completes.
+_CONTINUATION = '"verdict": "ok"}'
+_COMPLETED = "{" + _CONTINUATION
+
+# The models the provider documents as rejecting assistant-message prefilling,
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.
+_PREFILL_UNSUPPORTED = [
+ "claude-opus-4-6",
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-opus-5",
+ "claude-sonnet-4-6",
+ "claude-sonnet-5",
+ "claude-fable-5",
+ "claude-mythos-5",
+ "claude-mythos-preview",
+]
+
+# Names that accept a prefill. The three 4.5-generation names are the
load-bearing
+# ones: they are documented as structured-output capable, so folding the two
rules onto
+# one list would silently withdraw the prefill from exactly these models.
+# claude-sonnet-4-5-20250929 is the dated snapshot behind one of those
aliases, and
+# claude-3-5-sonnet-latest stands for every name the list does not mention,
which keeps
+# the prefill because only the listed names withdraw it.
+_PREFILL_SUPPORTED = [
+ "claude-opus-4-5",
+ "claude-sonnet-4-5",
+ "claude-haiku-4-5",
+ "claude-sonnet-4-5-20250929",
+ "claude-sonnet-4-20250514",
+ "claude-3-5-sonnet-latest",
+ "",
+ None,
+]
+
+
+class _AddArgs(BaseModel):
+ a: int
+
+
+class _StubTool(Tool):
+ """Minimal tool stub; only its presence in the tools list matters."""
+
+ @classmethod
+ def tool_type(cls) -> ToolType:
+ return ToolType.FUNCTION
+
+ def call(self, *args: Any, **kwargs: Any) -> None:
+ return None
+
+
+def _prefill_outcome(**chat_kwargs: Any) -> tuple:
+ """Whether the request carried the prefill, and the content the response
yielded.
+
+ The two have to agree: a response reconstructed on any other signal than
the
+ decision the request was built with either prepends a stray "{" or drops a
+ required one, and the response itself gives no sign of either.
+ """
+ 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)
+ response = connection.chat(
+ [ChatMessage(role=MessageRole.USER, content="hi")], **chat_kwargs
+ )
+ sent = connection.client.messages.create.call_args.kwargs["messages"]
+ return sent[-1] == {"role": "assistant", "content": "{"}, response.content
+
+
+def test_json_prefill_not_applied_by_default() -> None:
+ # The parameter is opt-in: it steers the model with a technique several
models
+ # reject outright, so a caller that does not ask for it must not get it.
+ assert _prefill_outcome(model=_INCAPABLE_MODEL) == (False, _CONTINUATION)
+
+
+def test_json_prefill_not_applied_when_explicitly_false() -> None:
+ # The setup emits json_prefill on every call, so the key is always present
and only
+ # its value separates opt-in from opt-out. Detecting the parameter by
presence
+ # rather than by value would re-enable the prefill for every request.
+ assert _prefill_outcome(model=_INCAPABLE_MODEL, json_prefill=False) == (
+ False,
+ _CONTINUATION,
+ )
+
+
+def test_json_prefill_applied_when_requested() -> None:
+ # An empty tools list, rather than no tools argument: a request configured
with no
+ # tools still reaches the decision carrying a list, and only an emptiness
test
+ # rather than a None test lets the prefill through there.
+ assert _prefill_outcome(model=_INCAPABLE_MODEL, json_prefill=True,
tools=[]) == (
+ True,
+ _COMPLETED,
+ )
+
+
+def test_json_prefill_suppressed_by_tools() -> None:
+ # The prefill forces JSON text where the model would otherwise emit
tool_use blocks.
+ tool = _StubTool(
+ name="add",
+ metadata=ToolMetadata(name="add", description="adds",
args_schema=_AddArgs),
+ )
+
+ assert _prefill_outcome(
+ model=_INCAPABLE_MODEL, json_prefill=True, tools=[tool]
+ ) == (False, _CONTINUATION)
+
+
+def test_json_prefill_suppressed_by_caller_output_config() -> None:
+ assert _prefill_outcome(
+ model=_INCAPABLE_MODEL,
+ json_prefill=True,
+ output_config={"format": {"type": "json_schema", "schema": {"type":
"object"}}},
+ ) == (False, _CONTINUATION)
+
+
+def test_json_prefill_suppressed_by_derived_output_config() -> None:
+ # The schema reaches the request as an output_config of the framework's
own making,
+ # which the provider documents as incompatible with prefilling just the
same. The
+ # model accepts prefilling, so the output_config is the only thing
suppressing it.
+ assert _prefill_outcome(
+ model=_CAPABLE_MODEL,
+ json_prefill=True,
+ output_schema=OutputSchema(output_schema=_Answer),
+ ) == (False, _CONTINUATION)
+
+
+def test_json_prefill_applied_when_schema_falls_back() -> None:
+ # Suppression keys on whether the schema reached the request, not on
whether one was
+ # supplied. Keying it on the schema would strip the prefill the
prompt-engineering
+ # fallback depends on, which is the case the prefill mainly exists for.
+ assert _prefill_outcome(
+ model=_INCAPABLE_MODEL,
+ json_prefill=True,
+ output_schema=OutputSchema(output_schema=_Answer),
+ ) == (True, _COMPLETED)
+
+
+def test_json_prefill_suppressed_on_prefill_unsupported_model() -> None:
+ assert _prefill_outcome(model="claude-opus-4-6", json_prefill=True) == (
+ False,
+ _CONTINUATION,
+ )
+
+
+def test_json_prefill_applied_on_structured_output_capable_model() -> None:
+ # The two capability rules draw different lines, and this model sits
between them:
+ # the provider documents structured-output support from the 4.5 generation
on but
+ # withdraws prefilling only from 4.6 on. Deriving the prefill rule from the
+ # structured-output allowlists would strip the prefill here, where the
provider
+ # still accepts it.
+ assert
_connection().supports_native_structured_output("claude-sonnet-4-5") is True
+
+ assert _prefill_outcome(model="claude-sonnet-4-5", json_prefill=True) == (
+ True,
+ _COMPLETED,
+ )
+
+
[email protected]("json_prefill", [True, False])
+def test_json_prefill_is_not_forwarded_to_the_provider(json_prefill) -> None:
+ # It is a framework parameter, so the SDK would reject it as an unknown
request
+ # field whichever way the decision went.
+ assert "json_prefill" not in _request_kwargs(
+ model=_INCAPABLE_MODEL, json_prefill=json_prefill
+ )
+
+
[email protected]("model", _PREFILL_UNSUPPORTED)
+def test_prefill_predicate_rejects_unsupported_models(model) -> None:
+ assert _supports_json_prefill(model) is False
+
+
[email protected]("model", _PREFILL_SUPPORTED)
+def test_prefill_predicate_accepts_every_other_model(model) -> None:
+ assert _supports_json_prefill(model) is True
+
+
+#
---------------------------------------------------------------------------------
+# Setup parameters
+#
---------------------------------------------------------------------------------
+
+
+def test_setup_defaults_json_prefill_to_false() -> None:
+ assert (
+
AnthropicChatModelSetup(connection="conn").model_kwargs["json_prefill"] is False
+ )
+
+
+def test_setup_honors_explicit_json_prefill() -> None:
+ # Pins that the argument is read rather than the default being emitted
+ # unconditionally.
+ setup = AnthropicChatModelSetup(connection="conn", json_prefill=True)
+ assert setup.model_kwargs["json_prefill"] is True
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 244ea346..a9660d43 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -55,7 +55,7 @@ dependencies = [
"ollama==0.6.1",
"dashscope~=1.24.2",
"openai>=1.66.3",
- "anthropic>=0.64.0",
+ "anthropic>=0.77.0",
"chromadb==1.0.21",
"mem0ai>=0.1.43,<2.0.0",
"onnxruntime<1.24.1;python_version<'3.11'",