[integrations][java] Share POJO JSON Schema generation across Ollama, Gemini, Bedrock and watsonx - #1120
Conversation
The bare JacksonModule lists enum constants by their Java names, while a caller's ObjectMapper reads the @JsonProperty value or @jsonvalue method result. A response that satisfied the schema could then fail to deserialize, e.g. "IN_PROGRESS" against an enum Jackson reads as "in-progress". pydantic already lists enum values on the Python side, so the two languages also disagreed. Enable FLATTENED_ENUMS_FROM_JSONPROPERTY and FLATTENED_ENUMS_FROM_JSONVALUE. Only the listed enum values change: property names, property order and the required set stay as they were. The new test reads every listed value back with a plain ObjectMapper for one enum of each style, so dropping either option fails it. Generated-by: Claude Code 2.1.272 (Claude Opus 5)
|
Now that #1097, #1098, and #1117 have landed, I think this may be a good point to introduce the shared Java POJO-to-JSON-Schema component discussed in #1097, rather than landing a Watsonx-only fix here and moving it immediately afterwards. Would you be open to expanding this PR, or replacing it with a follow-up, that migrates Ollama, Gemini, Bedrock, and Watsonx together while keeping their provider-specific schema post-processing and request envelopes local? |
|
Agreed, now is the right time. I'll expand this PR into a shared schema module under Two behavior changes come with it. Ollama gets the same enum fix, and Bedrock lists properties in declaration order like the other three instead of alphabetically. I'll call both out in the description. |
… models The Ollama, Gemini, Bedrock and watsonx connections each configure their own victools schema generator from the same core settings, and the copies have drifted: enum wire values are listed by two of them and not the others, and one keeps victools' alphabetical property order. Add a chat-models common module with PojoJsonSchemaGenerator. It fixes the shared recipe (draft 2020-12, the plain JSON preset, Jackson naming with enum constants listed by their mapped values, declaration order, and every field required except an Optional one) and applies only the extra options a caller passes. The connections move onto it in the following commits. The module is added to the chat-models reactor and listed in dist next to the other integration modules. Generated-by: Claude Code 2.1.272 (Claude Opus 5)
… generator Gemini and watsonx now build their native schema with PojoJsonSchemaGenerator and pass only the settings that are theirs: typed map values for both, and closed objects for Gemini. Gemini's $ref sibling stripping and the watsonx response_format envelope stay in the connections. What either connection sends is unchanged. The schemas derived for a fixture set covering property order, maps, Optional, $defs reuse, recursion, unions, Jackson renames and every enum style are byte-identical before and after. The rationale comments keep only the provider-specific constraints, and the tests that restated the shared contract are removed. Each connection keeps its enum round-trip test, which exercises its own request path. Generated-by: Claude Code 2.1.272 (Claude Opus 5)
Ollama now builds its format schema with PojoJsonSchemaGenerator, adding only typed map values. An enum mapped by @JsonProperty on its constants or by a @jsonvalue method is listed by those wire values, so a response that satisfies the schema reads back into the enum. A plain enum, and one that annotates only some constants, is listed by constant name as before. A numeric @jsonvalue enum is now listed as {"type":"string","enum":[1,2]}, the shape the other connections already emit. For string enums this matches the values pydantic lists on the Python side. Apart from enum values, the derived schema is byte-identical for a fixture set covering property order, maps, Optional, $defs reuse, recursion, unions and Jackson renames. The test restating the shared contract is removed, and an enum round-trip test through the request path is added. Generated-by: Claude Code 2.1.272 (Claude Opus 5)
Bedrock now builds its output schema with PojoJsonSchemaGenerator and adds no option. The derived schema lists properties in the order the class declares them instead of alphabetically, and the required array follows the same order. For a fixture set covering maps, Optional, $defs reuse and naming, recursion, unions, Jackson renames and every enum style, only a class declared out of alphabetical order changes, and only in that order. The rationale comment keeps the Bedrock constraints (the draft it validates against, map values left untyped, recursion rejected). The test restating the shared contract is removed, and a declaration-order test through the request path is added. Generated-by: Claude Code 2.1.272 (Claude Opus 5)
|
Expanded as discussed. The PR now adds the shared generator and moves Ollama, Gemini, Bedrock and watsonx onto it, with the provider-specific options and envelopes left in each connection. The description lists the three behavior changes: enum values for Ollama and watsonx, and declaration order for Bedrock. |
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for taking this on. LGTM.
By the way, does this mean that structured-output support has now been implemented for all model providers?
Linked issue: #280
Purpose of change
A Java output schema is now derived the same way by the Ollama, Gemini, Bedrock and watsonx connections. Two things change for a caller. Ollama and watsonx list an enum mapped by
@JsonPropertyon its constants or by a@JsonValuemethod under those mapped values, so a reply that satisfies the schema reads back into the enum. Bedrock lists schema properties, and therequiredarray, in the order the class declares them instead of alphabetically.The four connections each configured their own victools generator from the same core settings, and the copies had drifted. This PR moves that shared recipe into one component, and each connection keeps only what is specific to its provider.
Runtime flow
A new module,
flink-agents-integrations-chat-models-common, holdsPojoJsonSchemaGenerator.generate(Class<?>, Option...). It always sets draft 2020-12, the plain JSON preset (fields only), the Jackson module with bothFLATTENED_ENUMS_*options, declaration order, and every field required except anOptionalone, then applies the caller's options.generate(type, MAP_VALUES_AS_ADDITIONAL_PROPERTIES)becomes the requestformat.generate(type, MAP_VALUES_AS_ADDITIONAL_PROPERTIES, FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT), then its$refsibling stripping, becomesresponseJsonSchema.generate(type)is serialized intoJsonSchemaDefinition.generate(type, MAP_VALUES_AS_ADDITIONAL_PROPERTIES)goes inside itsresponse_formatenvelope.Key decisions
The API takes victools
Options, not a builder. A configured builder would let a connection replace the sorter or the required check. All four modules already depend on victools.The generator is not in
flink-agents-api. That would put victools on every user's classpath for a concern only these four wire formats have. OpenAI and Anthropic derive schemas inside their SDKs.Bedrock moves to declaration order instead of keeping a sorter setting only it would use. The other three connections and pydantic already emit it.
Behavioral Semantics
Interaction decisions
$refsiblings strippedBehavioral contracts
@JsonPropertyrenames,@JsonIgnoredropped) and require every field except anOptionalone.@JsonPropertyon its constants or by a@JsonValuemethod by those mapped values.propertiesin declaration order, with no property for a getter.$refsibling stripping apply only to the connections the table lists them for.name,strict) and where each connection places the schema are unchanged.Failure behavior
No new failure path: generation errors, capability gates and the prompt fallback behave as before. Two enum shapes still do not round-trip, now alike in all four connections: an enum that maps only some constants with
@JsonPropertyis listed by Java names for all of them, and an enum whose@JsonValuereturns a number is listed as"type": "string"with numeric values, which no reply can satisfy.Tests
PojoJsonSchemaGeneratorTest:namesPropertiesTheWayJacksonReadsThem,requiresEveryFieldExceptOptional,declaresDraft202012listsEnumConstantsByTheirJacksonWireValues, and through each connection's request path: OllamageneratedSchemaFollowsJacksonEnumValues, GeminiderivedSchemaListsEnumsByTheirJacksonWireValues, BedrocktestDerivedSchemaFollowsJacksonEnumValues, watsonxderivedSchemaFollowsJacksonEnumValueskeepsDeclarationOrderWithoutGetters, BedrocktestDerivedSchemaKeepsDeclarationOrder, GemininativeSchemaAppliedForPojoappliesAnOptionOnlyWhenPassedIn, OllamageneratedSchemaGivesMapValuesTheirSchema, GeminiderivedSchemaClosesObjectsand the fourderivedSchema*RefSiblings*tests, BedrocktestDerivedSchemaLeavesMapsBare, watsonxderivedSchemaGivesMapValuesTheirSchemabuildPayloadWritesResponseFormatForPojoSchema,serializedRequestBodyCarriesResponseFormatAtRoot; the existing native-path gate tests in each connectionCoverage by risk. The main risk is a connection silently sending a different schema. Each connection's enum test runs through its own request path and fails if the connection stops using the shared generator, and Bedrock's order test covers the order change. Connection tests that only restated the shared contract are removed.
Not verified. No live call was made against Ollama, Bedrock or watsonx. Nothing establishes whether Bedrock treats property order as significant. Ollama's MLX runner, which uses a different grammar engine than llama.cpp, was not examined.
Implementation invariants and supporting evidence
Optional,$defsreuse and same-named types, recursion, unions, Jackson renames, five enum styles), the schemas derived with this PR's watsonx enum commit applied and after the migration were compared. Gemini and watsonx are byte-identical. Ollama differs only in theenumarrays of the three annotated-enum fixtures. Bedrock differs only in the out-of-alphabetical-order fixture, inpropertiesandrequiredorder.build(), after the module, sorter and required check are registered.jsonschema-generatordeclared.generate(schemaClass)compiles to an emptyOption[], so the class references victools directly.dist/pom.xml, andPojoJsonSchemaGeneratoris present in the flink-2.2 fat and thin jars.API
No user-facing API changes. There is a new published artifact,
flink-agents-integrations-chat-models-common, with one public class. It is bundled in dist and reaches Maven builds transitively through the four connector artifacts. Apart from the Ollama and watsonx enum values and the Bedrock property order described above, nothing changes for an existing caller.Documentation
doc-neededdoc-not-neededdoc-includedThe enum annotation guidance belongs in the structured output docs that will cover every provider.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code 2.1.272 (Claude Opus 5)