Skip to content

[integrations][java] Share POJO JSON Schema generation across Ollama, Gemini, Bedrock and watsonx - #1120

Merged
wenjin272 merged 5 commits into
apache:mainfrom
weiqingy:280-watsonx-enum-values
Sep 15, 2026
Merged

wenjin272 merged 5 commits into
apache:mainfrom
weiqingy:280-watsonx-enum-values

Conversation

@weiqingy

@weiqingy weiqingy commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

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 @JsonProperty on its constants or by a @JsonValue method under those mapped values, so a reply that satisfies the schema reads back into the enum. Bedrock lists schema properties, and the required array, 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, holds PojoJsonSchemaGenerator.generate(Class<?>, Option...). It always sets draft 2020-12, the plain JSON preset (fields only), the Jackson module with both FLATTENED_ENUMS_* options, declaration order, and every field required except an Optional one, then applies the caller's options.

  • Ollama: generate(type, MAP_VALUES_AS_ADDITIONAL_PROPERTIES) becomes the request format.
  • Gemini: generate(type, MAP_VALUES_AS_ADDITIONAL_PROPERTIES, FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT), then its $ref sibling stripping, becomes responseJsonSchema.
  • Bedrock: generate(type) is serialized into JsonSchemaDefinition.
  • watsonx: generate(type, MAP_VALUES_AS_ADDITIONAL_PROPERTIES) goes inside its response_format envelope.

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

Connection Enum wire values Property order Map values Closed objects $ref siblings stripped
Ollama yes (was constant names) declaration typed no no
Gemini yes declaration typed yes yes
Bedrock yes declaration (was alphabetical) bare object no no
watsonx yes (was constant names) declaration typed no no

Behavioral contracts

  1. All four connections name properties the way Jackson reads them (@JsonProperty renames, @JsonIgnore dropped) and require every field except an Optional one.
  2. All four list an enum mapped by @JsonProperty on its constants or by a @JsonValue method by those mapped values.
  3. All four emit properties in declaration order, with no property for a getter.
  4. Typed map values, closed objects and $ref sibling stripping apply only to the connections the table lists them for.
  5. The watsonx envelope (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 @JsonProperty is listed by Java names for all of them, and an enum whose @JsonValue returns a number is listed as "type": "string" with numeric values, which no reply can satisfy.

Tests

Contract Tests
1 PojoJsonSchemaGeneratorTest: namesPropertiesTheWayJacksonReadsThem, requiresEveryFieldExceptOptional, declaresDraft202012
2 listsEnumConstantsByTheirJacksonWireValues, and through each connection's request path: Ollama generatedSchemaFollowsJacksonEnumValues, Gemini derivedSchemaListsEnumsByTheirJacksonWireValues, Bedrock testDerivedSchemaFollowsJacksonEnumValues, watsonx derivedSchemaFollowsJacksonEnumValues
3 keepsDeclarationOrderWithoutGetters, Bedrock testDerivedSchemaKeepsDeclarationOrder, Gemini nativeSchemaAppliedForPojo
4 appliesAnOptionOnlyWhenPassedIn, Ollama generatedSchemaGivesMapValuesTheirSchema, Gemini derivedSchemaClosesObjects and the four derivedSchema*RefSiblings* tests, Bedrock testDerivedSchemaLeavesMapsBare, watsonx derivedSchemaGivesMapValuesTheirSchema
5 watsonx buildPayloadWritesResponseFormatForPojoSchema, serializedRequestBodyCarriesResponseFormatAtRoot; the existing native-path gate tests in each connection

Coverage 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
  • Suite results: common 6, Ollama 15, Gemini 64, Bedrock 84, watsonx 47 with 2 skipped (the credential-gated live tests).
  • Output equivalence. For 13 fixtures per connection (property order, maps, Optional, $defs reuse 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 the enum arrays of the three annotated-enum fixtures. Bedrock differs only in the out-of-alphabetical-order fixture, in properties and required order.
  • The order of the options a caller passes does not matter: the builder resolves them in build(), after the module, sorter and required check are registered.
  • Bedrock keeps jsonschema-generator declared. generate(schemaClass) compiles to an empty Option[], so the class references victools directly.
  • The new module is listed in dist/pom.xml, and PojoJsonSchemaGenerator is present in the flink-2.2 fat and thin jars.
  • For Ollama on llama.cpp, the grammar built from a JSON schema emits required properties in schema order, then the optional ones, which is why declaration order matters there.

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-needed
  • doc-not-needed
  • doc-included

The 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?

  • Yes
  • No

Generated-by: Claude Code 2.1.272 (Claude Opus 5)

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)
@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Sep 15, 2026
@wenjin272

Copy link
Copy Markdown
Contributor

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?

@weiqingy

Copy link
Copy Markdown
Collaborator Author

Agreed, now is the right time. I'll expand this PR into a shared schema module under integrations/chat-models and move Ollama, Gemini, Bedrock and watsonx onto it. Gemini's $ref handling, the watsonx response_format envelope and Bedrock's request shape stay in their own connections, and so do the per-provider options (map value schemas, and closed objects on Gemini).

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)
@weiqingy weiqingy changed the title [integrations][watsonx] List enum constants by their Jackson wire values [integrations][java] Share POJO JSON Schema generation across Ollama, Gemini, Bedrock and watsonx Sep 15, 2026
@weiqingy

Copy link
Copy Markdown
Collaborator Author

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.

@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs and removed doc-not-needed Your PR changes do not impact docs labels Sep 15, 2026

@wenjin272 wenjin272 left a comment

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.

Thanks for taking this on. LGTM.

By the way, does this mean that structured-output support has now been implemented for all model providers?

@wenjin272
wenjin272 merged commit 2050b52 into apache:main Sep 15, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants