Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 38 additions & 37 deletions dist/src/main/resources/META-INF/NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,16 @@ This project bundles the following dependencies under the Apache Software Licens
- org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10
- org.jetbrains.kotlin:kotlin-reflect:1.8.10
- org.yaml:snakeyaml:2.3
- io.netty:netty-handler:4.1.118.Final
- io.netty:netty-resolver:4.1.118.Final
- io.netty:netty-transport:4.1.118.Final
- io.netty:netty-buffer:4.1.118.Final
- io.netty:netty-codec:4.1.118.Final
- io.netty:netty-codec-http:4.1.118.Final
- io.netty:netty-codec-http2:4.1.118.Final
- io.netty:netty-transport-native-unix-common:4.1.118.Final
- io.netty:netty-transport-classes-epoll:4.1.118.Final
- io.netty:netty-common:4.1.118.Final
- io.netty:netty-handler:4.1.130.Final
- io.netty:netty-resolver:4.1.130.Final
- io.netty:netty-transport:4.1.130.Final
- io.netty:netty-buffer:4.1.130.Final
- io.netty:netty-codec:4.1.130.Final
- io.netty:netty-codec-http:4.1.130.Final
- io.netty:netty-codec-http2:4.1.130.Final
- io.netty:netty-transport-native-unix-common:4.1.130.Final
- io.netty:netty-transport-classes-epoll:4.1.130.Final
- io.netty:netty-common:4.1.130.Final
- io.projectreactor:reactor-core:3.7.0
- org.elasticsearch.client:elasticsearch-rest-client:8.19.0
- org.apache.httpcomponents:httpclient:4.5.13
Expand Down Expand Up @@ -88,33 +88,34 @@ This project bundles the following dependencies under the Apache Software Licens
- io.prometheus:simpleclient_tracer_common:0.16.0
- io.prometheus:simpleclient_tracer_otel:0.16.0
- io.prometheus:simpleclient_tracer_otel_agent:0.16.0
- software.amazon.awssdk:bedrockruntime:2.32.16
- software.amazon.awssdk:s3vectors:2.32.16
- software.amazon.awssdk:sdk-core:2.32.16
- software.amazon.awssdk:aws-core:2.32.16
- software.amazon.awssdk:auth:2.32.16
- software.amazon.awssdk:regions:2.32.16
- software.amazon.awssdk:profiles:2.32.16
- software.amazon.awssdk:utils:2.32.16
- software.amazon.awssdk:annotations:2.32.16
- software.amazon.awssdk:apache-client:2.32.16
- software.amazon.awssdk:netty-nio-client:2.32.16
- software.amazon.awssdk:http-client-spi:2.32.16
- software.amazon.awssdk:http-auth:2.32.16
- software.amazon.awssdk:http-auth-aws:2.32.16
- software.amazon.awssdk:http-auth-aws-eventstream:2.32.16
- software.amazon.awssdk:http-auth-spi:2.32.16
- software.amazon.awssdk:identity-spi:2.32.16
- software.amazon.awssdk:metrics-spi:2.32.16
- software.amazon.awssdk:endpoints-spi:2.32.16
- software.amazon.awssdk:checksums:2.32.16
- software.amazon.awssdk:checksums-spi:2.32.16
- software.amazon.awssdk:retries:2.32.16
- software.amazon.awssdk:retries-spi:2.32.16
- software.amazon.awssdk:aws-json-protocol:2.32.16
- software.amazon.awssdk:protocol-core:2.32.16
- software.amazon.awssdk:json-utils:2.32.16
- software.amazon.awssdk:third-party-jackson-core:2.32.16
- software.amazon.awssdk:bedrockruntime:2.41.22
- software.amazon.awssdk:s3vectors:2.41.22
- software.amazon.awssdk:sdk-core:2.41.22
- software.amazon.awssdk:aws-core:2.41.22
- software.amazon.awssdk:auth:2.41.22
- software.amazon.awssdk:regions:2.41.22
- software.amazon.awssdk:profiles:2.41.22
- software.amazon.awssdk:utils:2.41.22
- software.amazon.awssdk:utils-lite:2.41.22
- software.amazon.awssdk:annotations:2.41.22
- software.amazon.awssdk:apache-client:2.41.22
- software.amazon.awssdk:netty-nio-client:2.41.22
- software.amazon.awssdk:http-client-spi:2.41.22
- software.amazon.awssdk:http-auth:2.41.22
- software.amazon.awssdk:http-auth-aws:2.41.22
- software.amazon.awssdk:http-auth-aws-eventstream:2.41.22
- software.amazon.awssdk:http-auth-spi:2.41.22
- software.amazon.awssdk:identity-spi:2.41.22
- software.amazon.awssdk:metrics-spi:2.41.22
- software.amazon.awssdk:endpoints-spi:2.41.22
- software.amazon.awssdk:checksums:2.41.22
- software.amazon.awssdk:checksums-spi:2.41.22
- software.amazon.awssdk:retries:2.41.22
- software.amazon.awssdk:retries-spi:2.41.22
- software.amazon.awssdk:aws-json-protocol:2.41.22
- software.amazon.awssdk:protocol-core:2.41.22
- software.amazon.awssdk:json-utils:2.41.22
- software.amazon.awssdk:third-party-jackson-core:2.41.22
- software.amazon.eventstream:eventstream:1.0.1
- com.google.genai:google-genai:1.56.0
- com.google.auto.value:auto-value-annotations:1.11.0
Expand Down
11 changes: 11 additions & 0 deletions integrations/chat-models/bedrock/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ under the License.
<artifactId>bedrockruntime</artifactId>
<version>${aws.sdk.version}</version>
</dependency>

<!-- Versions managed by the victools BOM imported in the root pom. -->
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
</dependency>

<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import org.apache.flink.agents.api.RetryExecutor;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
import org.apache.flink.agents.api.chat.messages.MessageRole;
Expand All @@ -39,7 +45,12 @@
import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest;
import software.amazon.awssdk.services.bedrockruntime.model.ConverseResponse;
import software.amazon.awssdk.services.bedrockruntime.model.InferenceConfiguration;
import software.amazon.awssdk.services.bedrockruntime.model.JsonSchemaDefinition;
import software.amazon.awssdk.services.bedrockruntime.model.Message;
import software.amazon.awssdk.services.bedrockruntime.model.OutputConfig;
import software.amazon.awssdk.services.bedrockruntime.model.OutputFormat;
import software.amazon.awssdk.services.bedrockruntime.model.OutputFormatStructure;
import software.amazon.awssdk.services.bedrockruntime.model.OutputFormatType;
import software.amazon.awssdk.services.bedrockruntime.model.SystemContentBlock;
import software.amazon.awssdk.services.bedrockruntime.model.ToolConfiguration;
import software.amazon.awssdk.services.bedrockruntime.model.ToolInputSchema;
Expand All @@ -54,6 +65,10 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
Expand Down Expand Up @@ -88,6 +103,51 @@
public class BedrockChatModelConnection extends BaseChatModelConnection {

private static final ObjectMapper MAPPER = new ObjectMapper();

// Models AWS documents structured-output support for on the bedrock-runtime endpoint. There is
// no single list page: the feature page delegates the per-model answer to the individual model
// cards, where each card carries it as a "Structured outputs" bullet in the Supported or Not
// Supported column of its "Features supported using bedrock-runtime endpoint" table.
//
// The ids are the Model ID column of each card's Programmatic Access table, read from the
// bedrock-runtime row. A card commonly prints a different id for bedrock-mantle and can carry
// opposite verdicts for the two, so the endpoint an id was read from is part of what makes the
// entry correct. This connection calls Converse on bedrock-runtime.
//
// Matching is exact, never by prefix. A Bedrock id already pins the vendor, the snapshot date
// and the version in one string, so there is no alias for a prefix to cover, and a prefix would
// over-capture: "qwen.qwen3" admits qwen.qwen3-vl-235b-a22b, which AWS documents as not
// supported, and "anthropic.claude-sonnet-4" admits anthropic.claude-sonnet-4-20250514-v1:0,
// whose card carries no answer at all. Exact matching also keeps irregular id shapes correct
// with no normalisation rule: mistral.mistral-large-3-675b-instruct carries no version suffix,
// openai.gpt-oss-120b-1:0 carries "-1:0" rather than "-v1:0".
//
// A card whose capability table carries the bullet in neither column is undocumented rather
// than negative, and is absent from this set for that reason.
private static final Set<String> NATIVE_STRUCTURED_OUTPUT_MODELS =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AWS now documents Structured outputs support for Claude Opus 4.6 and lists anthropic.claude-opus-4-6-v1 as its Bedrock model ID, but it is absent from this allowlist. Consequently, both the base ID and its us., eu., au., and global. inference-profile forms are classified as incapable and fall back to prompting. Could we add the base ID and cover the direct and prefixed forms in the capability tests? Reference: AWS Claude Opus 4.6 model card.

Set.of(
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"mistral.mistral-large-3-675b-instruct",
"openai.gpt-oss-120b-1:0",
"openai.gpt-oss-20b-1:0",
"qwen.qwen3-235b-a22b-2507-v1:0",
"qwen.qwen3-32b-v1:0",
"qwen.qwen3-coder-30b-a3b-v1:0",
"qwen.qwen3-coder-480b-a35b-v1:0",
"qwen.qwen3-coder-next",
"qwen.qwen3-next-80b-a3b");

// A cross-Region inference profile id is a model id behind a geographic or global prefix, and
// AWS documents structured output as working through cross-Region inference. The prefix set is
// open-ended — the documentation names members by example and states that new profiles may
// be created — so a leading segment is matched by shape rather than against a fixed list,
// which would already have missed the documented us-gov. profiles. The charset excludes ":"
// and "/", so no ARN can be shortened this way, and the strip is attempted only after the id
// itself fails to match, so a bare model id is never shortened.
private static final Pattern INFERENCE_PROFILE_PREFIX = Pattern.compile("^[a-z0-9-]+\\.(.+)$");

private final BedrockRuntimeClient client;
private final String defaultModel;
private final RetryExecutor retryExecutor;
Expand Down Expand Up @@ -117,9 +177,102 @@ public BedrockChatModelConnection(
.build();
}

/**
* Whether AWS documents structured-output support for {@code effectiveModel}.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <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 || 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<ChatMessage> messages, List<Tool> tools, Map<String, Object> modelParams) {
return chat(messages, tools, modelParams, null);
}

/**
* Translates {@code outputSchema} into Converse's native {@code outputConfig} when it is a POJO
* {@link Class} and the effective model is one AWS documents as supporting it. Any other schema
* form — notably a {@code RowTypeInfo} wrapped in {@code OutputSchema} — and any other model
* leave the request unconstrained, so that the caller keeps the prompt-engineering fallback.
*/
@Override
public ChatMessage chat(
List<ChatMessage> messages,
List<Tool> tools,
Map<String, Object> modelParams,
Object outputSchema) {
ConverseRequest request = buildRequest(messages, tools, modelParams, outputSchema);
String modelId = request.modelId();

ConverseResponse response =
retryExecutor.execute(() -> client.converse(request), "BedrockConverse");

ChatMessage result = convertResponse(response);
if (response.usage() != null) {
result.getExtraArgs().put("model_name", modelId);
result.getExtraArgs().put("promptTokens", response.usage().inputTokens().longValue());
result.getExtraArgs()
.put("completionTokens", response.usage().outputTokens().longValue());
}
return result;
}

/**
* Translate the flink-agents call arguments into a Converse request: the effective model id,
* the SYSTEM/conversation message split, the tool configuration, the inference configuration,
* and the native output configuration when the schema and the model both admit one.
*
* <p>Package-private so a test can assert the request body without issuing a live call through
* the Bedrock runtime client.
*
* <p>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<ChatMessage> messages,
List<Tool> tools,
Map<String, Object> modelParams,
Object outputSchema) {
String modelId = resolveModel(modelParams);

List<ChatMessage> systemMsgs =
Expand Down Expand Up @@ -173,19 +326,73 @@ public ChatMessage chat(
}
}

ConverseRequest request = requestBuilder.build();
if (outputSchema instanceof Class && supportsNativeStructuredOutput(modelId)) {
requestBuilder.outputConfig(nativeOutputConfig((Class<?>) outputSchema));
}

ConverseResponse response =
retryExecutor.execute(() -> client.converse(request), "BedrockConverse");
return requestBuilder.build();
}

ChatMessage result = convertResponse(response);
if (response.usage() != null) {
result.getExtraArgs().put("model_name", modelId);
result.getExtraArgs().put("promptTokens", response.usage().inputTokens().longValue());
result.getExtraArgs()
.put("completionTokens", response.usage().outputTokens().longValue());
}
return result;
/**
* Wraps the schema derived from {@code schemaClass} in the request element Converse reads it
* from.
*
* <p>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. The
// response is read back into the same class with an ObjectMapper, so a property that
// @JsonProperty renames or @JsonIgnore drops has to be stated in the schema under the name
// the mapper reads, or a response that satisfies the schema still fails to deserialize.
// It is applied with no JacksonOption, so it contributes property naming and visibility
// only: 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we align the generated schema with Jackson's enum wire values? The bare JacksonModule emits Java enum names instead of their @JsonProperty or @JsonValue values—for example, IN_PROGRESS instead of in-progress—so a schema-valid response can fail during subsequent ObjectMapper deserialization. Ollama and #1098 use similar schema-generation logic, so this may also be a good opportunity to extract the shared configuration into a chat-models/common component. Would you prefer to introduce that abstraction here or handle the cross-provider migration separately?

configBuilder
.forFields()
.withRequiredCheck(field -> !Optional.class.equals(field.getRawMember().getType()));
return new SchemaGenerator(configBuilder.build()).generateSchema(schemaClass);
}

private static boolean isRetryable(Exception e) {
Expand Down
Loading
Loading