See the allowlist above for the source of truth, for why the match is exact, and for why a + * geographic or global inference-profile prefix is stripped before it. + * + *
Every ARN reports {@code false}. A provisioned-throughput, imported-model, + * custom-model-deployment, application-inference-profile or marketplace-endpoint ARN identifies + * a resource without naming the model behind it, and a prompt-router ARN names a set whose + * member is chosen per request, so for none of them is an answer derivable from the identifier + * the request carries. An unrecognized identifier reports {@code false} so that it degrades to + * the prompt-engineering fallback rather than failing at the provider. + * + *
A null or blank model reports {@code false} rather than throwing: {@code resolveModel} + * rejects one before a request is built, but this method is part of the connection contract and + * answers for whatever it is given. Only the null case needs a guard of its own, because the + * allowlist is an immutable Set whose {@code contains(null)} throws; a blank model is merely + * absent from it. + * + *
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 || effectiveModel.isBlank()) {
+ return false;
+ }
+ if (NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel)) {
+ return true;
+ }
+ Matcher profile = INFERENCE_PROFILE_PREFIX.matcher(effectiveModel);
+ return profile.matches() && NATIVE_STRUCTURED_OUTPUT_MODELS.contains(profile.group(1));
+ }
+
@Override
public ChatMessage chat(
List Package-private so a test can assert the request body without issuing a live call through
+ * the Bedrock runtime client.
+ *
+ * Resolving the model is the first step, so an absent model id is rejected before any
+ * request state is built.
+ *
+ * @param messages the conversation, SYSTEM messages included; must not be null
+ * @param tools the tools to advertise, or {@code null} / empty for none
+ * @param modelParams per-call parameters; {@code model}, {@code temperature} and {@code
+ * max_tokens} are read, and {@code null} is accepted
+ * @param outputSchema the schema the response should conform to, or {@code null} for an
+ * unconstrained response; applied natively only for a POJO {@link Class} on a model that
+ * supports it, and otherwise left to the caller's prompt-engineering fallback
+ * @return the request to send to Converse
+ * @throws IllegalArgumentException if neither the call nor the connection supplies a model id
+ */
+ ConverseRequest buildRequest(
+ List Converse takes the schema as serialized text rather than as a document, unlike the tool
+ * input schema on the same request, so the derived schema is written out here.
+ */
+ private static OutputConfig nativeOutputConfig(Class> schemaClass) {
+ return OutputConfig.builder()
+ .textFormat(
+ OutputFormat.builder()
+ .type(OutputFormatType.JSON_SCHEMA)
+ .structure(
+ OutputFormatStructure.builder()
+ .jsonSchema(
+ JsonSchemaDefinition.builder()
+ .schema(
+ toNativeSchema(schemaClass)
+ .toString())
+ .build())
+ .build())
+ .build())
+ .build();
+ }
+
+ // Derives the JSON schema from a POJO class. Every setting below addresses a concrete way the
+ // generated schema otherwise fails to constrain generation:
+ //
+ // - DRAFT_2020_12 is the dialect Bedrock validates a schema against, so the schema
+ // declares it rather than the generator's older default.
+ // - The PLAIN_JSON preset keeps generation to fields. Without a preset, getters surface as
+ // properties of their own, named after the accessor call, e.g. "getSummary()".
+ // - The required check marks every field required except an Optional one. The default marks
+ // nothing required, which lets a model omit fields at will, while marking everything
+ // required would force the fields a caller declared omissible.
+ // - The Jackson module makes the schema name properties the way Jackson names them, and
+ // list enum constants mapped by @JsonProperty or by a @JsonValue method under the values
+ // Jackson reads. The response is read back into the same class with an ObjectMapper, so a
+ // property that @JsonProperty renames or @JsonIgnore drops, and a mapped enum constant,
+ // have to appear in the schema as the mapper reads them, or a response that satisfies the
+ // schema still fails to deserialize. An enum annotating only some constants falls back to
+ // Java names for all of them, so its annotated constants do not read back. The two enum
+ // options change only the listed values: the required set stays the one configured above.
+ //
+ // A Map's value schema is deliberately left underived. Bedrock accepts additionalProperties
+ // only as false, and rejects a schema that carries it as a subschema, so typing map values
+ // would trade an unconstrained map for a rejected request. A Map field reaches the model as a
+ // bare object.
+ //
+ // A self-referencing class derives its own field as a reference back to the schema root,
+ // whatever the required check says. Bedrock does not accept a recursive schema and rejects the
+ // request before the model runs, so declaring the field Optional does not rescue it; only
+ // flattening the recursion does.
+ private static JsonNode toNativeSchema(Class> schemaClass) {
+ SchemaGeneratorConfigBuilder configBuilder =
+ new SchemaGeneratorConfigBuilder(
+ SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
+ .with(
+ new JacksonModule(
+ JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY,
+ JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE));
+ configBuilder
+ .forFields()
+ .withRequiredCheck(field -> !Optional.class.equals(field.getRawMember().getType()));
+ return new SchemaGenerator(configBuilder.build()).generateSchema(schemaClass);
}
private static boolean isRetryable(Exception e) {
diff --git a/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java b/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
index 5e570f59d..592141743 100644
--- a/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
+++ b/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
@@ -18,15 +18,36 @@
package org.apache.flink.agents.integrations.chatmodels.bedrock;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.fasterxml.jackson.databind.JsonNode;
+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.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.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.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import software.amazon.awssdk.core.document.Document;
+import software.amazon.awssdk.services.bedrockruntime.model.ConversationRole;
+import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest;
+import software.amazon.awssdk.services.bedrockruntime.model.Message;
+import software.amazon.awssdk.services.bedrockruntime.model.OutputFormatType;
import java.util.*;
+import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -45,6 +66,34 @@ private static ResourceDescriptor descriptor(String region, String model) {
return b.build();
}
+ private static BedrockChatModelConnection connection() {
+ return new BedrockChatModelConnection(
+ descriptor("us-east-1", "us.anthropic.claude-sonnet-4-20250514-v1:0"), NOOP);
+ }
+
+ /** Minimal tool carrying only metadata; never invoked in these tests. */
+ private static final class SchemaOnlyTool extends Tool {
+ SchemaOnlyTool(String inputSchema) {
+ super(new ToolMetadata("add", "Add two numbers.", inputSchema));
+ }
+
+ @Override
+ public ToolType getToolType() {
+ return ToolType.FUNCTION;
+ }
+
+ @Override
+ public ToolResponse call(ToolParameters parameters) {
+ throw new UnsupportedOperationException("not invoked in this test");
+ }
+ }
+
+ private static ChatMessage toolMessage(String externalId, String content) {
+ Map {@code name} is deserialized from {@code full_name} rather than from the Java field name,
+ * and {@code secret} is not deserialized at all.
+ */
+ public static class Profile {
+ @JsonProperty("full_name")
+ public String name;
+
+ @JsonIgnore public String secret;
+
+ public int age;
+ }
+
+ /**
+ * Output schema fixture shaped to expose what the derived schema constrains: which fields a
+ * response must carry, and how a map field is rendered.
+ */
+ public static class Reading {
+ public int score;
+
+ public Optional The list is the whole allowlist, so an entry dropped or mistyped fails here rather than
+ * narrowing capability silently. The differing suffix shapes are part of what is under test:
+ * {@code mistral.mistral-large-3-675b-instruct} carries no version suffix, {@code
+ * openai.gpt-oss-120b-1:0} carries {@code -1:0}, {@code
+ * anthropic.claude-sonnet-4-5-20250929-v1:0} carries {@code -v1:0}, and {@code
+ * anthropic.claude-opus-4-6-v1} carries {@code -v1} with no {@code :0}, so a rule that assumes
+ * one shape fails on the others.
+ */
+ private static Stream Walks the typed accessors because {@code ConverseRequest.toString()} prints the structure
+ * as redacted sensitive data.
+ */
+ private static JsonNode nativeSchema(ConverseRequest request) throws Exception {
+ return SCHEMA_MAPPER.readTree(
+ request.outputConfig().textFormat().structure().jsonSchema().schema());
+ }
+
+ @ParameterizedTest
+ @MethodSource("capableModels")
+ @DisplayName("every documented model reports capable")
+ void testCapableModelsReportCapable(String model) {
+ // connection() is configured with a model that is not on the list, so a predicate reading
+ // the configured model rather than its argument disagrees with itself here.
+ assertThat(connection().supportsNativeStructuredOutput(model)).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"us.", "eu.", "apac.", "au.", "jp.", "global."})
+ @DisplayName("a geographic inference-profile prefix resolves to the model it fronts")
+ void testGeoPrefixResolvesToTheModelItFronts(String prefix) {
+ // A cross-Region inference profile id is a model id behind a leading segment, and the model
+ // behind it is the one whose capability the request gets.
+ String profile = prefix + "anthropic.claude-opus-4-6-v1";
+ assertThat(connection().supportsNativeStructuredOutput(profile)).isTrue();
+ }
+
+ @Test
+ @DisplayName("the us-gov inference-profile prefix resolves to the model it fronts")
+ void testHyphenatedPrefixResolvesToTheModelItFronts() {
+ // us-gov. is a documented prefix that no other documented prefix resembles, so a rule
+ // written as a fixed set of prefixes tends to omit it while a leading-segment strip covers
+ // it without being told.
+ assertThat(connection().supportsNativeStructuredOutput("us-gov.openai.gpt-oss-120b-1:0"))
+ .isTrue();
+ }
+
+ @Test
+ @DisplayName("a model documented as unsupported reports not capable")
+ void testDocumentedUnsupportedModelReportsNotCapable() {
+ // AWS documents this model as not supporting structured output, and it extends the
+ // qwen.qwen3- prefix that several capable entries share. Any prefix match claims a
+ // capability the provider denies.
+ assertThat(connection().supportsNativeStructuredOutput("qwen.qwen3-vl-235b-a22b"))
+ .isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ INCAPABLE_MODEL,
+ "anthropic.claude-sonnet-4-20250514-v1:0",
+ "mistral.mistral-large-2402-v1:0",
+ "us.anthropic.claude-sonnet-4-20250514-v1:0"
+ })
+ @DisplayName("a model with no documented answer reports not capable")
+ void testUndocumentedModelsReportNotCapable(String model) {
+ // An absent answer is not a positive one. The middle two each extend a capable entry
+ // truncated at a version boundary; the last is the id this module's own example uses, so
+ // its behavior is pinned here rather than discovered at the provider.
+ assertThat(connection().supportsNativeStructuredOutput(model)).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-20250929-v1:0",
+ "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/anthropic.claude-sonnet-4-5-20250929-v1:0"
+ })
+ @DisplayName("an ARN reports not capable even when it spells out a capable model")
+ void testArnFormsReportNotCapable(String model) {
+ // An ARN names a resource rather than a model, and an application inference profile's
+ // trailing segment can spell an id it does not front. A substring match reports both of
+ // these capable.
+ assertThat(connection().supportsNativeStructuredOutput(model)).isFalse();
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"", " "})
+ @DisplayName("a null or blank model reports not capable")
+ void testNullOrBlankModelReportsNotCapable(String model) {
+ // The guard is load-bearing rather than defensive: the allowlist is an immutable Set, whose
+ // contains(null) throws instead of reporting absence.
+ assertThat(connection().supportsNativeStructuredOutput(model)).isFalse();
+ }
+
+ @Test
+ @DisplayName("the derived schema names properties the way Jackson deserializes them")
+ void testDerivedSchemaFollowsJacksonPropertyNames() throws Exception {
+ ConverseRequest request =
+ connection()
+ .buildRequest(
+ List.of(ChatMessage.user("hello")),
+ null,
+ params(CAPABLE_MODEL),
+ Profile.class);
+
+ // The response is read back into the same class, so a property that @JsonProperty renames
+ // or @JsonIgnore drops has to be stated under the name the mapper reads. Derived without
+ // Jackson's property model the schema instead names "name" and demands the ignored
+ // "secret", constraining the model to a document the mapper then refuses.
+ assertThat(nativeSchema(request).path("properties").fieldNames())
+ .toIterable()
+ .containsExactlyInAnyOrder("full_name", "age");
+ }
+
+ @Test
+ @DisplayName("the derived schema lists enum constants the way Jackson deserializes them")
+ void testDerivedSchemaFollowsJacksonEnumValues() throws Exception {
+ ConverseRequest request =
+ connection()
+ .buildRequest(
+ List.of(ChatMessage.user("hello")),
+ null,
+ params(CAPABLE_MODEL),
+ Ticket.class);
+ JsonNode properties = nativeSchema(request).path("properties");
+
+ // Every listed value is one the model may emit, so each has to deserialize into the enum.
+ // Listed by constant name instead, the mapper refuses every value the schema allows.
+ List