Skip to content

Allow custom StructuredOutputConverter(s) to participate in Native Structured Output#5659

Open
filiphr wants to merge 1 commit intospring-projects:mainfrom
filiphr:support-structured-output-with-custom-converter
Open

Allow custom StructuredOutputConverter(s) to participate in Native Structured Output#5659
filiphr wants to merge 1 commit intospring-projects:mainfrom
filiphr:support-structured-output-with-custom-converter

Conversation

@filiphr
Copy link
Contributor

@filiphr filiphr commented Mar 22, 2026

This builds a bit on top of #5412.

The purpose of this is the fact that we extensively use the structured output with our own StructuredOutputConverter(s), which do not extend from BeanOutputConverter. Unfortunately, we now need to do something like

if (StringUtils.isNotEmpty(outputSchema)) {
    clientRequestBuilder.context(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), outputSchema);
    clientRequestBuilder.context(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey(), true);
}

instead of just passing our output converter to the ChatClient. I came up with the workaround because I read the code and how everything works under the hood in Spring AI.

From what I could see the BeanOutputConverter is not really needed for this to work, since any StructuredOutputConverter should be able to provide a JSON Schema if they want to.

Would appreciated your feedback on this.

…ructured Output

Signed-off-by: Filip Hrisafov <filip.hrisafov@gmail.com>
Copy link
Contributor Author

@filiphr filiphr left a comment

Choose a reason for hiding this comment

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

Adding some explanation for some changes that are not entirely related to the initial request. In theory this could lead to some problems outside of the proposed changes here since the contract between the context and the underlying implementations differs.

If you prefer I can split this PR into 2 to handle the contract difference.

return ChatClientResponse.builder()
.chatResponse(chatResponse)
.context(Map.copyOf(formattedChatClientRequest.context()))
.context(new HashMap<>(formattedChatClientRequest.context()))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is needed due to the fact that Map.copyOf does not allow null values, whereas the contract of the context is Map<String, @Nullable Object>.

.prompt(augmentedPrompt)
.context(Map.copyOf(chatClientRequest.context()))
.build();
return chatClientRequest.mutate().prompt(augmentedPrompt).build();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same reason as the change of Map.copyOf

Comment on lines -333 to +524
private Map<String, Object> context = new ConcurrentHashMap<>();
private Map<String, Object> context = new HashMap<>();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same reason as the Map.copyOf. ConcurrentHashMap does not allow null values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant