Skip to content

Commit 57c7880

Browse files
committed
feat(bedrock): add structured output support (issue #4543) (#4552)
1 parent 5caccf9 commit 57c7880

11 files changed

Lines changed: 118 additions & 41 deletions

File tree

docs/docs/tutorials/structured-outputs.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ ChatModel chatModel = WatsonxChatModel.builder()
120120
.logRequests(true)
121121
.logResponses(true)
122122
.build();
123+
// OR
124+
ChatModel chatModel = BedrockChatModel.builder()
125+
.modelId("us.anthropic.claude-haiku-4-5-20251001-v1:0")
126+
.logRequests(true)
127+
.logResponses(true)
128+
.build();
123129

124130
ChatResponse chatResponse = chatModel.chat(chatRequest);
125131

@@ -449,6 +455,13 @@ ChatModel chatModel = WatsonxChatModel.builder()
449455
.logRequests(true)
450456
.logResponses(true)
451457
.build();
458+
// OR
459+
ChatModel chatModel = BedrockChatModel.builder()
460+
.modelId("us.anthropic.claude-haiku-4-5-20251001-v1:0")
461+
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA) // see [8] below
462+
.logRequests(true)
463+
.logResponses(true)
464+
.build();
452465

453466
PersonExtractor personExtractor = AiServices.create(PersonExtractor.class, chatModel); // see [1] below
454467

@@ -473,6 +486,7 @@ as these beans are created automatically. More info on this:
473486
- [5] - This is required to enable the JSON Schema feature for [Ollama](/integrations/language-models/ollama).
474487
- [6] - This is required to enable the JSON Schema feature for [Mistral](/integrations/language-models/mistral-ai).
475488
- [7] - This is required to enable the JSON Schema feature for [watsonx.ai](/integrations/language-models/watsonx).
489+
- [8] - This is required to enable the JSON Schema feature for [Amazon Bedrock](/integrations/language-models/amazon-bedrock).
476490

477491
When all the following conditions are met:
478492
- AI Service method returns a POJO

langchain4j-anthropic/src/main/java/dev/langchain4j/model/anthropic/internal/mapper/AnthropicMapper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ public static Map<String, Object> toAnthropicSchema(JsonSchemaElement schemaElem
484484

485485
// Run with strict=false to avoid unsupported features, like union types in enum schemas
486486
// https://platform.claude.com/docs/en/build-with-claude/structured-outputs#json-schema-limitations
487-
return toMap(schemaElement);
487+
return toMap(schemaElement, false);
488488
}
489489

490490
private static Map<String, Map<String, Object>> mapDefs(Map<String, JsonSchemaElement> defs) {

langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/AbstractBedrockChatModel.java

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.langchain4j.model.bedrock;
22

3+
import static dev.langchain4j.internal.JsonSchemaElementUtils.toMap;
34
import static dev.langchain4j.internal.Utils.copy;
45
import static dev.langchain4j.internal.Utils.getOrDefault;
56
import static dev.langchain4j.internal.Utils.isNotNullOrEmpty;
@@ -19,6 +20,7 @@
1920
import static java.util.Collections.emptyList;
2021
import static java.util.Objects.nonNull;
2122
import static software.amazon.awssdk.core.SdkBytes.fromByteArray;
23+
import static software.amazon.awssdk.services.bedrockruntime.model.OutputFormatType.JSON_SCHEMA;
2224

2325
import dev.langchain4j.Internal;
2426
import dev.langchain4j.agent.tool.ToolExecutionRequest;
@@ -34,7 +36,7 @@
3436
import dev.langchain4j.data.message.UserMessage;
3537
import dev.langchain4j.exception.UnsupportedFeatureException;
3638
import dev.langchain4j.internal.Json;
37-
import dev.langchain4j.internal.JsonSchemaElementUtils;
39+
import dev.langchain4j.model.chat.Capability;
3840
import dev.langchain4j.model.chat.listener.ChatModelListener;
3941
import dev.langchain4j.model.chat.request.ChatRequest;
4042
import dev.langchain4j.model.chat.request.ChatRequestParameters;
@@ -43,18 +45,21 @@
4345
import dev.langchain4j.model.chat.request.ResponseFormatType;
4446
import dev.langchain4j.model.chat.request.ToolChoice;
4547
import dev.langchain4j.model.chat.request.json.JsonRawSchema;
48+
import dev.langchain4j.model.chat.request.json.JsonSchema;
4649
import dev.langchain4j.model.chat.response.PartialThinking;
4750
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
4851
import dev.langchain4j.model.output.FinishReason;
4952
import java.net.URI;
5053
import java.time.Duration;
5154
import java.util.ArrayList;
55+
import java.util.Arrays;
5256
import java.util.Base64;
5357
import java.util.HashMap;
5458
import java.util.List;
5559
import java.util.Map;
5660
import java.util.Objects;
5761
import java.util.Optional;
62+
import java.util.Set;
5863
import java.util.UUID;
5964
import java.util.stream.Collectors;
6065
import org.slf4j.Logger;
@@ -121,13 +126,15 @@ abstract class AbstractBedrockChatModel {
121126
protected final boolean sendThinking;
122127
protected final BedrockChatRequestParameters defaultRequestParameters;
123128
protected final List<ChatModelListener> listeners;
129+
protected final Set<Capability> supportedCapabilities;
124130

125131
protected AbstractBedrockChatModel(AbstractBuilder<?> builder) {
126132
this.region = getOrDefault(builder.region, Region.US_EAST_1);
127133
this.timeout = getOrDefault(builder.timeout, Duration.ofMinutes(1));
128134
this.returnThinking = getOrDefault(builder.returnThinking, false);
129135
this.sendThinking = getOrDefault(builder.sendThinking, true);
130136
this.listeners = copy(builder.listeners);
137+
this.supportedCapabilities = copy(builder.supportedCapabilities);
131138

132139
ChatRequestParameters commonParameters;
133140
if (builder.defaultRequestParameters != null) {
@@ -920,28 +927,27 @@ protected static OutputConfig outputConfigFrom(ResponseFormat responseFormat) {
920927
return null;
921928
}
922929

923-
if (responseFormat.jsonSchema() == null) {
930+
JsonSchema jsonSchema = responseFormat.jsonSchema();
931+
if (jsonSchema == null) {
924932
throw new UnsupportedFeatureException("JSON response format is not supported without a schema");
925933
}
926934

927-
// JSON with schema - use structured output
928-
String schemaJson;
929-
if (responseFormat.jsonSchema().rootElement() instanceof JsonRawSchema rawSchema) {
930-
// For raw JSON schema, use the raw string directly
931-
schemaJson = rawSchema.schema();
935+
String jsonSchemaString;
936+
if (jsonSchema.rootElement() instanceof JsonRawSchema rawSchema) {
937+
jsonSchemaString = rawSchema.schema();
932938
} else {
933-
// For structured schema, convert to Map then to JSON string
934939
Map<String, Object> jsonSchemaMap =
935-
JsonSchemaElementUtils.toMap(responseFormat.jsonSchema().rootElement());
936-
schemaJson = Json.toJson(jsonSchemaMap);
940+
toMap(jsonSchema.rootElement(), true, true, "string");
941+
jsonSchemaString = Json.toJson(jsonSchemaMap);
937942
}
938943

939944
return OutputConfig.builder()
940945
.textFormat(OutputFormat.builder()
941-
.type(OutputFormatType.JSON_SCHEMA)
946+
.type(JSON_SCHEMA)
942947
.structure(OutputFormatStructure.builder()
943948
.jsonSchema(JsonSchemaDefinition.builder()
944-
.schema(schemaJson)
949+
.schema(jsonSchemaString)
950+
.name(jsonSchema.name())
945951
.build())
946952
.build())
947953
.build())
@@ -975,6 +981,7 @@ public abstract static class AbstractBuilder<T extends AbstractBuilder<T>> {
975981
protected Boolean logResponses;
976982
protected Logger logger;
977983
protected List<ChatModelListener> listeners;
984+
protected Set<Capability> supportedCapabilities;
978985

979986
@SuppressWarnings("unchecked")
980987
public T self() {
@@ -1080,5 +1087,15 @@ public T listeners(List<ChatModelListener> listeners) {
10801087
public T listeners(ChatModelListener... listeners) {
10811088
return listeners(asList(listeners));
10821089
}
1090+
1091+
public T supportedCapabilities(Set<Capability> supportedCapabilities) {
1092+
this.supportedCapabilities = supportedCapabilities;
1093+
return self();
1094+
}
1095+
1096+
public T supportedCapabilities(Capability... supportedCapabilities) {
1097+
this.supportedCapabilities = Arrays.stream(supportedCapabilities).collect(Collectors.toSet());
1098+
return self();
1099+
}
10831100
}
10841101
}

langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/BedrockChatModel.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import static java.util.Objects.isNull;
77

88
import dev.langchain4j.model.ModelProvider;
9+
import dev.langchain4j.model.chat.Capability;
910
import dev.langchain4j.model.chat.ChatModel;
1011
import dev.langchain4j.model.chat.listener.ChatModelListener;
1112
import dev.langchain4j.model.chat.request.ChatRequest;
1213
import dev.langchain4j.model.chat.response.ChatResponse;
1314
import java.util.List;
15+
import java.util.Set;
1416
import org.slf4j.Logger;
1517
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
1618
import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient;
@@ -96,6 +98,11 @@ public List<ChatModelListener> listeners() {
9698
return listeners;
9799
}
98100

101+
@Override
102+
public Set<Capability> supportedCapabilities() {
103+
return supportedCapabilities;
104+
}
105+
99106
@Override
100107
public ModelProvider provider() {
101108
return AMAZON_BEDROCK;

langchain4j-bedrock/src/main/java/dev/langchain4j/model/bedrock/BedrockStreamingChatModel.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212

1313
import dev.langchain4j.internal.ToolCallBuilder;
1414
import dev.langchain4j.model.ModelProvider;
15+
import dev.langchain4j.model.chat.Capability;
1516
import dev.langchain4j.model.chat.StreamingChatModel;
1617
import dev.langchain4j.model.chat.listener.ChatModelListener;
1718
import dev.langchain4j.model.chat.request.ChatRequest;
1819
import dev.langchain4j.model.chat.response.ChatResponse;
1920
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
2021
import dev.langchain4j.model.chat.response.StreamingHandle;
2122
import java.util.List;
23+
import java.util.Set;
2224
import java.util.UUID;
2325
import java.util.concurrent.atomic.AtomicReference;
2426
import org.reactivestreams.Subscriber;
@@ -222,6 +224,11 @@ public List<ChatModelListener> listeners() {
222224
return listeners;
223225
}
224226

227+
@Override
228+
public Set<Capability> supportedCapabilities() {
229+
return supportedCapabilities;
230+
}
231+
225232
@Override
226233
public ModelProvider provider() {
227234
return AMAZON_BEDROCK;

langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/BedrockAiServiceWithJsonSchemaIT.java renamed to langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/common/BedrockAiServiceWithJsonSchemaIT.java

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
1-
package dev.langchain4j.model.bedrock;
1+
package dev.langchain4j.model.bedrock.common;
22

3+
import dev.langchain4j.model.bedrock.BedrockChatModel;
34
import dev.langchain4j.model.chat.ChatModel;
45
import dev.langchain4j.service.common.AbstractAiServiceWithJsonSchemaIT;
5-
import java.util.List;
66
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
77

8+
import java.util.List;
9+
10+
import static dev.langchain4j.model.chat.Capability.RESPONSE_FORMAT_JSON_SCHEMA;
11+
812
@EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".+")
913
class BedrockAiServiceWithJsonSchemaIT extends AbstractAiServiceWithJsonSchemaIT {
1014

15+
static ChatModel model = BedrockChatModel.builder()
16+
.modelId("us.anthropic.claude-haiku-4-5-20251001-v1:0")
17+
.supportedCapabilities(RESPONSE_FORMAT_JSON_SCHEMA)
18+
.logRequests(true)
19+
.logResponses(true)
20+
.build();
21+
1122
@Override
1223
protected List<ChatModel> models() {
13-
return List.of(BedrockChatModel.builder()
14-
.modelId("anthropic.claude-haiku-4-5-20251001-v1:0")
15-
.maxRetries(1)
16-
.logRequests(false)
17-
.logRequests(true)
18-
.build());
24+
return List.of(model);
1925
}
2026

2127
@Override
2228
protected boolean isStrictJsonSchemaEnabled(ChatModel model) {
23-
// Bedrock models support strict JSON schema
2429
return true;
2530
}
2631
}

langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/common/BedrockChatModelNovaWithVisionIT.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,27 @@ protected Class<? extends TokenUsage> tokenUsageType(ChatModel model) {
5959

6060
@Override
6161
protected boolean supportsJsonResponseFormat() {
62-
return false; // output format not supported
62+
return false; // JSON response format without schema is not supported
6363
}
6464

6565
@Override
6666
protected boolean supportsJsonResponseFormatWithSchema() {
67-
return false; // output format not supported
67+
return false; // not supported for models used in this class
6868
}
6969

7070
@Override
7171
protected boolean supportsJsonResponseFormatWithRawSchema() {
72-
return false; // output format not supported
72+
return false; // not supported for models used in this class
7373
}
7474

7575
@Override
76-
protected Class<? extends ChatResponseMetadata> chatResponseMetadataType(final ChatModel model) {
76+
protected boolean assertExceptionType() {
77+
// Bedrock throws InvalidRequestException, while test expects UnsupportedFeatureException
78+
return false;
79+
}
80+
81+
@Override
82+
protected Class<? extends ChatResponseMetadata> chatResponseMetadataType(ChatModel model) {
7783
return BedrockChatResponseMetadata.class;
7884
}
7985

langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/common/BedrockChatModelWithVisionIT.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package dev.langchain4j.model.bedrock.common;
22

3-
import static dev.langchain4j.model.bedrock.TestedModels.CLAUDE_3_HAIKU;
4-
import static dev.langchain4j.model.bedrock.common.BedrockAiServicesIT.sleepIfNeeded;
5-
63
import dev.langchain4j.model.bedrock.BedrockChatModel;
74
import dev.langchain4j.model.bedrock.BedrockChatResponseMetadata;
85
import dev.langchain4j.model.bedrock.BedrockTokenUsage;
@@ -11,16 +8,26 @@
118
import dev.langchain4j.model.chat.request.ChatRequestParameters;
129
import dev.langchain4j.model.chat.response.ChatResponseMetadata;
1310
import dev.langchain4j.model.output.TokenUsage;
14-
import java.util.List;
1511
import org.junit.jupiter.api.AfterEach;
1612
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
1713

14+
import java.util.List;
15+
16+
import static dev.langchain4j.model.bedrock.common.BedrockAiServicesIT.sleepIfNeeded;
17+
import static dev.langchain4j.model.chat.Capability.RESPONSE_FORMAT_JSON_SCHEMA;
18+
1819
@EnabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".+")
1920
class BedrockChatModelWithVisionIT extends AbstractChatModelIT {
2021

22+
static ChatModel model = BedrockChatModel.builder()
23+
.modelId("us.anthropic.claude-haiku-4-5-20251001-v1:0")
24+
.logRequests(false) // images are huge in logs
25+
.logResponses(true)
26+
.build();
27+
2128
@Override
2229
protected List<ChatModel> models() {
23-
return List.of(CLAUDE_3_HAIKU); // , LLAMA_3_2_90B); NOT AVAILABLE FOR ME AT THIS MOMENT
30+
return List.of(model);
2431
}
2532

2633
@Override
@@ -49,7 +56,7 @@ protected Class<? extends TokenUsage> tokenUsageType(ChatModel model) {
4956

5057
@Override
5158
protected boolean supportsJsonResponseFormat() {
52-
return false; // output format not supported
59+
return false; // JSON response format *without schema* is not supported
5360
}
5461

5562
@Override

langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/common/BedrockChatModelWithoutVisionIT.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,17 @@ protected boolean supportsToolChoiceRequired() {
7171

7272
@Override
7373
protected boolean supportsJsonResponseFormat() {
74-
return false; // output format not supported
74+
return false; // JSON response format without schema is not supported
7575
}
7676

7777
@Override
7878
protected boolean supportsJsonResponseFormatWithSchema() {
79-
return false; // output format not supported
79+
return false; // not supported for models used in this class
8080
}
8181

8282
@Override
8383
protected boolean supportsJsonResponseFormatWithRawSchema() {
84-
return false; // output format not supported
84+
return false; // not supported for models used in this class
8585
}
8686

8787
@Override

langchain4j-bedrock/src/test/java/dev/langchain4j/model/bedrock/common/BedrockStreamingChatModelIT.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,21 +80,27 @@ protected String customModelName() {
8080

8181
@Override
8282
protected boolean supportsJsonResponseFormat() {
83-
return false; // output format not supported
83+
return false; // JSON response format without schema is not supported
8484
}
8585

8686
@Override
8787
protected boolean supportsJsonResponseFormatWithSchema() {
88-
return false; // output format not supported
88+
return false; // not supported for models used in this class
8989
}
9090

9191
@Override
9292
protected boolean supportsJsonResponseFormatWithRawSchema() {
93-
return false; // output format not supported
93+
return false; // not supported for models used in this class
9494
}
9595

9696
@Override
97-
protected Class<? extends ChatResponseMetadata> chatResponseMetadataType(final StreamingChatModel model) {
97+
protected boolean assertExceptionType() {
98+
// Bedrock throws InvalidRequestException, while test expects UnsupportedFeatureException
99+
return false;
100+
}
101+
102+
@Override
103+
protected Class<? extends ChatResponseMetadata> chatResponseMetadataType(StreamingChatModel model) {
98104
return BedrockChatResponseMetadata.class;
99105
}
100106

0 commit comments

Comments
 (0)