Skip to content

[integrations][watsonx] Support watsonx.ai native structured output - #1117

Merged
wenjin272 merged 2 commits into
apache:mainfrom
weiqingy:280-pr8-watsonx-native
Sep 15, 2026
Merged

wenjin272 merged 2 commits into
apache:mainfrom
weiqingy:280-pr8-watsonx-native

Conversation

@weiqingy

@weiqingy weiqingy commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Linked issue: #280

Purpose of change

Passing an output_schema to the watsonx.ai connection now constrains the reply through the provider instead of raising. Both languages rejected a schema outright before, so every caller fell back to prompt engineering, even though the chat endpoint accepts a response_format carrying a JSON Schema and enforces it during decoding.

Runtime flow

The connection derives a JSON Schema from the caller's type, wraps it as {"type": "json_schema", "json_schema": {"name", "schema", "strict": true}}, and sets it at the request root. The branch runs only for a Java POJO class or a pydantic model, and only when the capability predicate answers true. It sits before both parameter-merge steps, so a caller's own value is read before anything is written. Java generates the schema directly, having no vendor SDK here; Python renders it from pydantic through render_output_schema.

Key decisions

No model allowlist. OpenAI and Azure gate this on a list of model names; this connection reports it for every model. IBM publishes no per-model structured-output table and no capability flag on the model specification, and no client I could find gates by model. IBM does state Chat API support requires the vLLM runtime, so reaching this endpoint implies the runtime that applies the constraint. An allowlist would encode a gate nobody documents and answer "unsupported" for models that work. Ollama reports unconditionally for a comparable reason. This diverges from the base contract, which asks an unrecognized model to report false; the override records that.

Plain schema rendering. OpenAI and Azure rewrite the required list to name every property, which OpenAI's strict mode demands and watsonx does not; reusing that renderer would report every optional field as mandatory. Relatedly, additionalProperties is not forbidden, since emitting it on one side only would make the two languages send different documents for the same type.

Behavioral Semantics

Interaction decisions

Output schema kind against a caller-supplied response_format:

Schema No caller value Caller value Caller value of null
None nothing added forwarded unchanged Java drops it; Python forwards it
POJO / pydantic model derived envelope sent raises derived envelope sent
RowTypeInfo nothing added, prompt fallback forwarded unchanged forwarded unchanged

The middle-right cell is the only new refusal. A null is excluded because the derived envelope overwrites it before the request is built. The bottom row is deliberate: the conflict fires only where a schema was applied.

Behavioral contracts

  1. The predicate answers true for every model, including an unknown or absent one.
  2. A POJO or pydantic schema produces a root response_format, strict true, named for the type.
  3. No schema produces no response_format key.
  4. An untranslatable schema produces none either, and the call proceeds.
  5. A schema plus a caller response_format carrying a value raises.
  6. A caller response_format alone reaches the service unchanged.
  7. The derived schema names properties as the deserializer does: renames honored, ignored fields excluded.
  8. Both languages state the same contract for a given type: same property names, types, required set.

Failure behavior

An untranslatable schema degrades to the prompt fallback silently, matching existing behavior. A schema plus a caller response_format value raises before the request is built. A pydantic model with no JSON Schema raises TypeError naming the model rather than a translation failure. Provider and transport errors, including retries, are unchanged.

Two cases do not fail loudly. A self-referential type generates a self-referencing document and is sent as-is; no generator setting here prevents it. An empty completion is not distinguished from a normal one, which is existing behavior for every reply.

Tests

Contract Java Python
1 supportsNativeStructuredOutputIsUnconditional test_supports_native_structured_output_is_unconditional
2 buildPayloadWritesResponseFormatForPojoSchema, serializedRequestBodyCarriesResponseFormatAtRoot test_params_carry_response_format_for_basemodel_schema
3 buildPayloadOmitsResponseFormatWithoutSchema test_params_omit_response_format_without_schema
4 buildPayloadOmitsResponseFormatForRowTypeInfo test_row_type_info_schema_returns_and_sends_no_response_format
5 chatWithSchemaAndCallerResponseFormatRaises test_schema_and_caller_response_format_raises
6 callerResponseFormatSurvivesWhenNoSchemaIsSent test_caller_response_format_survives_when_no_schema_is_sent
7 derivedSchemaHonorsJacksonAnnotations pydantic's own rendering
8 n/a test_derived_schema_matches_java_semantically

Coverage by risk. The highest risk is sending a document the provider rejects or ignores, so the Java suite asserts the full serialized request body through a local stub server rather than the builder's output. Next is a schema constraining the model to a document the deserializer then refuses, covered by contract 7. Conflict handling is covered on both parameter channels in both languages.

Not verified. No live call was made against watsonx, because I hold no credentials. An environment-gated live test ships for each language and neither has run, here or in CI. Nothing here establishes that IBM accepts the document, that an incapable model is rejected rather than ignoring the schema, or how a self-referential schema behaves at the provider. Python's stubbing sits above the SDK's payload assembly, so it pins the parameters handed to the SDK, not the bytes sent.

Implementation invariants and supporting evidence

Not user-observable, so recorded here rather than as contracts.

The Java payload seam widened from a static three-argument method to a four-argument instance method, because the capability predicate it now consults is an instance method.

The generated Java schema omits additionalProperties and keeps the document root inline rather than behind a $ref. Neither is required by the endpoint; both keep the two languages emitting the same shape. The generator marks every field required except an Optional one, which matches pydantic treating a field without a default as required.

The two languages emit different schema metadata: the Java document carries $schema, the pydantic document carries title. Contract 8 is therefore asserted semantically rather than as document equality.

A caller response_format valued null with no schema at all reaches the Python request body, where the Java write loops drop it. Those lines are untouched by this change and the divergence predates it.

Suite results: Java 44 run with 2 skipped, both skipped being the live tests. Python 27 passed with 2 deselected. The cross-connection guard asserting that a connection which cannot translate a schema rejects one still passes; contract 4's test is what keeps that covered once the predicate is overridden.

API

No new public classes, and nothing changes for an existing caller. No production code path passes an output schema in either language today, so this adds a capability rather than altering behavior, and a caller supplying response_format directly is unaffected.

What becomes possible is passing an output_schema without it raising. The only new refusal is supplying both an output schema and a response_format value in one call.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

The capability is not reachable by a user until the policy layer is wired up, so the user-facing documentation belongs with that change.

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

Generated-by: Claude Code 2.1.260 (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. and removed doc-not-needed Your PR changes do not impact docs labels Sep 14, 2026
@weiqingy

Copy link
Copy Markdown
Collaborator Author

The one failing check is unrelated to this PR.

  • cross-language [ubuntu-latest] [python-3.12] [java-21]: pull access denied for minio/minio. The job dies in the Start Milvus step, before any test runs, and the Maven build in that same job reports BUILD SUCCESS immediately before it. Evidence: failing job.

This is a repo-wide break rather than something in this branch. minio/minio is no longer pullable from Docker Hub, so any run started after that change fails the same way, and #1098 is failing identically. PRs whose runs predate it are still green, which is why the break looks selective.

Re-running does not clear it. I re-triggered the job once and it failed with the same error, so this needs the image reference fixed rather than another attempt.

#1116 changes that image reference to quay.io/minio/minio.

The watsonx.ai connection rejected a caller-supplied output schema, so a
caller passing one fell back to prompt engineering even though the chat
endpoint accepts a response_format carrying a JSON Schema and enforces it
during decoding.

The connection now reports native structured-output capability and
translates a POJO schema into the endpoint's json_schema envelope. Unlike
the OpenAI and Azure connections, the capability predicate is unconditional
rather than gated on a model allowlist. IBM publishes no per-model
structured-output table and exposes no capability flag on the model
specification, and no client gates by model. IBM does state that Chat API
support requires the vLLM runtime, so reaching this endpoint implies the
runtime that applies the constraint. An allowlist here would encode a gate
nobody documents and would report false for models that do work.

That diverges from the base contract, which documents capability as
model-dependent and asks an unrecognized model to report false. The Ollama
connection already diverges the same way, and the override records why.

A caller-supplied response_format has always reached the service through
either the model parameters or the additional kwargs. Now that a schema can
also produce one, a request carrying both raises rather than silently
choosing between them: reporting capability while not applying the caller's
schema would make the capability contract untrue. A caller-supplied value
with no schema is forwarded unchanged.

There is no vendor SDK in this module, so the schema is generated directly,
using the generator configuration the Ollama connection established. The
required set marks every field except an Optional one, and
additionalProperties is deliberately not forbidden, because pydantic's plain
schema omits it and emitting it on one side alone would make the two
languages disagree.

Nothing changes at runtime yet. The four-argument chat has no production
caller in either language.

Coverage asserts the serialized request body against a local stub server,
which establishes the wire shape but not that the service accepts the
document. The live test that would establish acceptance is gated on
credentials and has not been run.

Generated-by: Claude Code 2.1.260 (Claude Opus 5)
…Python

The Python connection rejected a caller-supplied output schema outright, so
the capability was unavailable even though the chat endpoint accepts a
response_format carrying a JSON Schema. The Java side gained the native path
in the preceding commit; this brings the two to parity.

Adds the capability predicate, the json_schema envelope, and a guard that
raises when a caller supplies a response_format alongside a schema, then
removes the rejection call. The removal and the predicate override are one
change: overriding the predicate is how a connection reports native support,
and that is what exempts it from the cross-connection test asserting a
connection which cannot translate a schema rejects one. A test restores that
coverage by passing a schema form this connection does not translate.

The schema is rendered with pydantic's plain output rather than the strict
renderer the OpenAI and Azure connections use. That renderer rewrites the
required list to name every property, satisfying a rule OpenAI imposes and
watsonx does not, and applying it here would report every optional field as
mandatory.

A response_format valued None is not treated as a conflict. The derived
envelope overwrites it before the request is built, so it can never reach the
service, and raising on it would reject a request that has no conflict in it.
The Java side draws the line in the same place.

The two languages emit different schema metadata, so the parity test asserts
property names, types and the required set rather than document equality.

Nothing changes at runtime yet. No production caller passes an output schema
in either language.

Coverage stubs the vendor SDK, so it pins the parameters handed to it rather
than the bytes the service receives. The live test that would establish
acceptance is gated on credentials and has not been run.

Generated-by: Claude Code 2.1.260 (Claude Opus 5)
@weiqingy
weiqingy force-pushed the 280-pr8-watsonx-native branch from cfea383 to 53873cd Compare September 14, 2026 16:33
@wenjin272
wenjin272 merged commit 3b8f6ac into apache:main Sep 15, 2026
28 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