[integrations][java][python] Apply Azure OpenAI native structured output - #930
Conversation
Translate a BaseModel output schema into the provider's native response_format json-schema parameter instead of rejecting it, so callers with a schema are no longer forced onto the prompt-engineering fallback. Capability resolves against model_of_azure_deployment rather than model, because on Azure the latter is a user-chosen deployment name that carries no information about the backing model. The native path additionally requires an api_version at or above Azure's documented 2024-08-01 floor; below it the schema is never sent, so an older api-version cannot produce an unconstrained response that the caller mistakes for a structured one. A caller-supplied response_format that the native path would overwrite now raises rather than being silently replaced or reaching the SDK as a duplicate keyword argument.
Translate a POJO output schema into openai-java's native responseFormat instead of inheriting the rejecting default, mirroring the Python Azure OpenAI connection: the capable-model allowlist and the api-version floor are identical in both languages. Capability is keyed on model_of_azure_deployment rather than model, because the latter names the Azure deployment and says nothing about the model behind it. The native path also requires an api_version at or above 2024-08-01, so a schema is never sent to a service version that predates structured output. Building the request inline left no seam to observe, so chat() is split into a 4-arg override, a private doChat, and package-private buildRequest and toResponse. The split turns one read of model_of_azure_deployment into two that must agree; toResponse makes that pinnable, and reading the key after the call is equivalent to reading it before because the parameter map is built per call and never retained. A caller-supplied response_format that the native path would overwrite now raises rather than being silently replaced.
Implementation DescriptionAs-built description of the final code on this branch, derived by walking 1. Runtime flowBoth languages follow the same order. The connection decides everything locally; nothing upstream is consulted. Python,
Java,
The api-version value is read from a new 2. Behavioral contractsEach is a checkable claim about the final code. C1. Native structured output is applied only when all four hold: the schema is non-null, the schema translates (a C2. Capability is decided from C3. An absent, empty, or unrecognized C4. A bare C5. An api-version whose leading 10 characters sort below C6. A C7. A null schema leaves behavior identical to before this change. C8. Bound tools do not suppress the native path. A request may carry both tools and C9. When the native path applies and the caller also supplied C10. On every path where the native format is not applied, a caller-supplied C11. The capability predicate reads no instance state. It is answerable on an instance that was never initialized, where field access would raise. C12. Response handling does not consume the caller's C13. Token metrics ( C14. The Java and Python allowlists and floor constant are identical, including ordering of the allowlist entries. C15. A connection that translates schemas natively is exempt from the cross-connection walk that requires every connection to reject a schema it cannot translate. The exemption is derived from whether 3. Key decisionsCapability is keyed on Matching is exact, never by prefix. The sibling OpenAI connection matches one model family by prefix. Azure reports a model name and a model version as separate properties, so a name has no version suffix to discriminate on, and prefix matching would classify unsupported versions as capable. The api-version floor is enforced in the request path, not in the capability predicate. The predicate's argument is a model name and carries no api-version, and the predicate must stay free of instance state (C11). Putting the floor check in the predicate would break that. The floor is enforced conservatively because the failure mode below it is not documented. Azure documents The comparison is a date-prefix comparison, not a validator. It assumes the documented zero-padded
The collision check is scoped inside the branch that actually applied the schema. Hoisting it above the translation result would make it fire on a Java needed a request-building seam before any of this was testable. The previous Java derives the response format through a throwaway typed builder. Python imports a private SDK helper. Java's 4. Failure behaviorMissing deployment name. Null or blank api-version, Java. The constructor raises Reserved typed field inside Caller-supplied Unsupported configuration that is not an error. An unrecognized or absent backing model, an api-version below the floor, and a schema that does not translate all fall back silently to an unconstrained request. Nothing is logged and nothing raises. This is deliberate: the prompt-engineering fallback is expected to govern the response in those cases. It is also the most likely surprise for a user, since a capable deployment with An error returned by the service. In Java, A response that does not satisfy the requested schema. This connection does not validate the response against the schema. With The SDK fails to produce a response format, Java. Schema generation rejects the type. A Import-time failure, Python. If a future openai release moves 5. Compatibility impactNo public type, field, or method signature changes. Java adds an override of a 4-arg No dependency changes. No A caller that passes no output schema sees identical behavior. The request is built by the same statements in the same order, and the response is converted identically. One behavior does change for existing callers. Passing a non-null
The cross-connection walk test changes population, not strictness. It exempts natively-translating connections. On this branch the exemption matches exactly one class and leaves the others held to the rejection contract. Its pre-existing non-empty assertion still runs on the filtered list, so an exemption that grew to cover everything would fail. 6. Tests to contractsPython tests are in
Contracts with no direct test, stated rather than omitted:
|
|
Revision 2. Section 4 previously said a refusal is returned as ordinary message content, which is wrong: a refusal arrives with empty content and is carried separately, and in Python it is dropped. Corrected below, with a new contract in section 2, a compatibility note in section 5, and the missing test coverage recorded in section 6. Revision 1 stays above unchanged. Implementation DescriptionAs-built description of the final code on this branch, derived by walking 1. Runtime flowBoth languages follow the same order. The connection decides everything locally; nothing upstream is consulted. Python,
Java,
The api-version value is read from a new Neither language inspects 2. Behavioral contractsEach is a checkable claim about the final code. C1. Native structured output is applied only when all four hold: the schema is non-null, the schema translates (a C2. Capability is decided from C3. An absent, empty, or unrecognized C4. A bare C5. An api-version whose leading 10 characters sort below C6. A C7. A null schema leaves behavior identical to before this change. C8. Bound tools do not suppress the native path. A request may carry both tools and C9. When the native path applies and the caller also supplied C10. On every path where the native format is not applied, a caller-supplied C11. The capability predicate reads no instance state. It is answerable on an instance that was never initialized, where field access would raise. C12. Response handling does not consume the caller's C13. Token metrics ( C14. The Java and Python allowlists and floor constant are identical, including ordering of the allowlist entries. C15. A connection that translates schemas natively is exempt from the cross-connection walk that requires every connection to reject a schema it cannot translate. The exemption is derived from whether C16 (new in this revision). A model refusal is not represented as message content, and the two languages differ in what survives it. The provider returns an empty 3. Key decisionsCapability is keyed on Matching is exact, never by prefix. The sibling OpenAI connection matches one model family by prefix. Azure reports a model name and a model version as separate properties, so a name has no version suffix to discriminate on, and prefix matching would classify unsupported versions as capable. The api-version floor is enforced in the request path, not in the capability predicate. The predicate's argument is a model name and carries no api-version, and the predicate must stay free of instance state (C11). Putting the floor check in the predicate would break that. The floor is enforced conservatively because the failure mode below it is not documented. Azure documents The comparison is a date-prefix comparison, not a validator. It assumes the documented zero-padded
The collision check is scoped inside the branch that actually applied the schema. Hoisting it above the translation result would make it fire on a Java needed a request-building seam before any of this was testable. The previous Java derives the response format through a throwaway typed builder. Python imports a private SDK helper. Java's Response handling was deliberately left unchanged. Both connections delegate to the shared conversion helper that already existed. That keeps this change scoped to the request side, and it is why the refusal representation described in C16 is inherited rather than addressed here. 4. Failure behaviorMissing deployment name. Null or blank api-version, Java. The constructor raises Reserved typed field inside Caller-supplied Unsupported configuration that is not an error. An unrecognized or absent backing model, an api-version below the floor, and a schema that does not translate all fall back silently to an unconstrained request. Nothing is logged and nothing raises. This is deliberate: the prompt-engineering fallback is expected to govern the response in those cases. It is also the most likely surprise for a user, since a capable deployment with An error returned by the service. In Java, A model refusal. Corrected in this revision. Under A length-truncated completion. A response that otherwise does not satisfy the requested schema. This connection does not validate the response against the schema. With The SDK fails to produce a response format, Java. Schema generation rejects the type. A Import-time failure, Python. If a future openai release moves 5. Compatibility impactNo public type, field, or method signature changes. Java adds an override of a 4-arg No dependency changes. No A caller that passes no output schema sees identical behavior. The request is built by the same statements in the same order, and the response is converted identically. One behavior does change for existing callers. Passing a non-null
The refusal handling in C16 predates this change, but this change makes it easier to reach. The cross-connection walk test changes population, not strictness. It exempts natively-translating connections. On this branch the exemption matches exactly one class and leaves the others held to the rejection contract. Its pre-existing non-empty assertion still runs on the filtered list, so an exemption that grew to cover everything would fail. 6. Tests to contractsPython tests are in
Contracts with no direct test, stated rather than omitted:
|
|
@wenjin272 This is the PR I mentioned on #894 as the first sample for the Implementation Description experiment, so the description is up as a comment rather than in the body. Current version is rev 2. Rev 1 is left above it untouched, since a reviewer approves a specific version and editing in place would change that silently. Rev 2 exists because a check against the code found a mistake in rev 1. It stated that a provider refusal comes back as ordinary message content, and that is wrong: content is empty, Java preserves the reason in Whenever you get to this one, the two-stage flow would be: read the description at the architecture and contract level first, then point a coding agent at the diff to check both that the code matches it and that it does not omit anything. I am curious whether your agent finds omissions that mine did not, since that check is the part carrying the most weight in the model. |
SpecProblemThe Azure OpenAI connections reject a non-null output schema, so every caller with a schema stays on the prompt-engineering fallback even when the deployment's backing model supports native structured output. Azure OpenAI exposes the same Proposed solutionMirror the OpenAI native path in both Azure OpenAI connections: declare a capability predicate, translate a POJO/ Two deviations from the OpenAI connection, both forced by the platform. Capability resolves against A second conjunct gates on the configured API surfaceNo new public type in either language. No config field added, removed, or made required. No signature change to an existing method.
Behavior
Files expected to changeTwo connection sources, two test files, plus the cross-connection walk test if the sibling OpenAI PR had not merged first. Test coverageBoth languages pin the same set, in dedicated unit tests that need no Azure resource. Grouped by what each group proves rather than listed test by test.
Java also pins that token metrics still label the response with the backing model, and that response handling leaves the caller's model params intact. Only Java needed the request/response split, so only Java can regress there. Python also pins that the capability predicate answers on an uninitialized instance. That is what lets the cross-connection walk test call it without credentials. Not tested on purpose: anything needing a live Azure resource, and the behavior of a pre-floor api-version, which the design never sends. Out of scopeAzure AI Inference native output, verified impossible at the pinned SDK and recorded on #280. Ollama, whose capability is server-version-gated rather than model-gated and needs its own design. Threading a schema down from an action or an agent, which the foundation deliberately left out. |
|
@wenjin272 Following up on the ping above. A Spec is now posted as a comment too. The Implementation Description is as-built, written from the finished code. The Spec is what the change was meant to do, written before the code, and is much shorter. Could you please take a look at the PR and let me know which one is better from your review point of view? If you only have time to check one spot, please look at |
wenjin272
left a comment
There was a problem hiding this comment.
I found three issues that appear to conflict with the proposed behavior.
| // applies only for a POJO Class schema — a RowTypeInfo (wrapped in OutputSchema) keeps the | ||
| // prompt-engineering fallback, as do an incapable model and an api-version below the floor. | ||
| String nativeSchemaName = null; | ||
| if (outputSchema instanceof Class |
There was a problem hiding this comment.
On every non-native branch, this omits response_format and still sends the request. The connection neither adds a schema prompt nor signals the caller to fall back, so a non-null outputSchema is silently dropped and direct callers receive an unconstrained response instead of the previous exception. Could we reject the schema when native format cannot be applied, leaving the caller to invoke the prompt path with a null schema?
There was a problem hiding this comment.
You're right, the exception is gone. Azure previously inherited the base 4-arg chat, which throws on a non-null schema, and Python called _reject_unsupported_output_schema. This PR removes both.
This is the same drop as #919 (r3671993321), where you agreed the fix belongs in #912 and asked for a TODO at the capability check. That marker shipped in e7639ed, but this branch predates it. Added in 4d60405 for both languages, including the two Azure-only cases: an api-version below the floor, and model_of_azure_deployment unset.
I'd rather not make Azure reject on its own, though. The OpenAI connection is merged with the opposite behavior and tests that assert it, so throwing only here would leave the two disagreeing. Happy to pull the change forward if you prefer, as long as it lands in both connections together.
| """ | ||
| if not self.api_version: | ||
| return False | ||
| return self.api_version[:10] >= _MIN_STRUCTURED_OUTPUT_API_VERSION |
There was a problem hiding this comment.
v1 passes this gate only because the comparison is lexicographic: "v1" >= "2024-08-01" evaluates to true. However, the current AzureOpenAI client still sends the legacy /openai/deployments/{model}/chat/completions?api-version=v1 request, while Azure v1 uses /openai/v1/chat/completions. Since the mocked test does not inspect the final URL, could we remove v1 from this gate/test until both connections support the unified endpoint?
There was a problem hiding this comment.
Agreed, v1 was passing on string ordering alone.
Fixed in 62c6a1d. The gate now classifies only the dated form Azure documents, so v1 and any other non-dated value report not-capable and keep the prompt fallback. I changed the gate rather than just the test, since otherwise the code would still admit v1. Both suites assert that now, with latest as a second case. The documented examples use 2024-10-21, so nothing regresses.
One asymmetry worth flagging: in openai-java, azure_url_path_mode=UNIFIED or an endpoint ending in /openai/v1 does reach the unified endpoint, so v1 is reachable there in a way it is not from Python's AzureOpenAI. Not-capable is still the right answer, it just costs a prompt fallback rather than being impossible. The Java javadoc says so; the Python docstring does not, since there is no equivalent option.
| # here because Azure model identifiers do not contain spaces. | ||
| _NATIVE_STRUCTURED_OUTPUT_MODELS = frozenset( | ||
| { | ||
| "gpt-5.1-codex", |
There was a problem hiding this comment.
This allowlist includes Responses-only models such as gpt-5.1-codex, gpt-5.1-codex-mini, and gpt-5-codex, but this connection calls chat.completions.create. Could we intersect the Structured Outputs list with the models that Azure supports on the Chat Completions API?
There was a problem hiding this comment.
Good catch, and it was broader than the three you named.
Fixed in 62c6a1d. The set is now the intersection of the structured-outputs list with the models Azure serves on Chat Completions. The "Chat Completions API" row on the reasoning page also marks gpt-5-pro, codex-mini and o3-pro unsupported, so six come out and thirteen remain.
Both rejection tests now pin all six by name, so re-adding one gets caught.
|
@weiqingy Here is my feedback after using #930 as our first experiment with developer-side artifacts for AI-assisted review. For this PR, I found the Spec more useful than the Implementation Description. The change is focused, the production-code diff is relatively small, and its impact is limited to a specific integration rather than a critical runtime path. The Spec gave me enough context to understand the design intent, behavioral boundaries, and key implementation decisions. By comparison, the Implementation Description sometimes went into so much detail that reading the code directly was more efficient. This is partly contextual. I am already familiar with and confident in Weiqing’s code quality, so I did not feel the need to inspect every implementation detail manually. A detailed as-built description may be more valuable for a larger, cross-cutting, unfamiliar, or critical-path change. For a PR of this size and risk profile, however, a concise Spec seems to be the better reviewer artifact. As a second-stage experiment, I also asked Codex to review the implementation against the Spec, generate findings, and post inline comments directly to GitHub. This workflow was practical: the Spec served as the review anchor, while the coding agent handled the detailed inspection of the implementation. This is only one data point, but my current impression is that the appropriate level of description should depend on the PR’s size and risk. For focused changes like this one, a Spec may provide most of the review benefit with less authoring and reading overhead. |
…t capability gate Two conditions decided native structured output too generously. The model allowlist was taken from Azure's structured-outputs page, which lists the models supporting the feature on any API. This connection calls the Chat Completions API, so the set has to be the intersection with the models Azure serves there. Six entries were served only on the Responses API and are removed: gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5-pro, gpt-5-codex, codex-mini and o3-pro. Thirteen entries remain. Both rejection tests now pin all six so re-adding one is caught. The api-version gate compared the leading characters lexicographically against the floor, which admitted the GA "v1" literal as a string-ordering artifact. The gate now classifies only the api-version form Azure documents, a zero-padded YYYY-MM-DD date; any other value reports not-capable and keeps the prompt-engineering fallback. No dated api-version in the pinned SDK is newly rejected, and the documented example value is unaffected. Java and Python keep identical model sets and gate semantics.
…he Azure connection The connection never sees the requested structured-output strategy, so its capability re-check cannot tell an explicit NATIVE request apart from an AUTO that merely resolved to native. Whenever the native branch is skipped the caller gets an unconstrained response rather than an error. On Azure that also happens below the api-version floor and when model_of_azure_deployment is unset, so capability cannot be resolved at all. Records this at both capability re-check sites, matching the marker the OpenAI connection already carries.
|
@wenjin272 Thanks for the review. I have updated the PR to address the three comments, could you take another look? On the experiment feedback, this is useful from a single PR. I agree it was too detailed, though I suspect the cause was my prompt rather than the format itself. I had told the agent to make the description self-contained so a reviewing agent would only need one document. That optimized for machines and made it unreadable for humans: 24k characters for a 1,232-line diff. That rule is gone now. The description is the PR body, and #952 is the corrected sample: 123 lines, around 6k characters. Curious whether it reads better to you. I also agree on scaling detail to size and risk. Maybe a rule along these lines:
Does that split match what you had in mind, or would you draw the line differently? |
|
Thanks @weiqingy, this is broadly aligned with what I had in mind. What reviewers need is a clear explanation of the PR’s design intent and key decisions. Whether that comes from a developer-written Spec or an as-built description derived from the code is less important. What we should standardize is the content that this reviewer-facing description is expected to cover. I agree that failure behavior and test coverage should be included. For large or cross-cutting PRs, the description may also need diagrams or a concise explanation of the runtime flow to help reviewers understand the change without reconstructing everything from the diff. I have not yet formed a complete view of what the required structure should be. At this point, I think the minimum should include:
We can refine this list as we gather more experience from additional PRs. |
|
@wenjin272 Thanks for the review. Agreed, and agreed that what the description covers matters more than where it came from. Your four look right. I would add two more. Both came up on #952, which is only 123 lines, so this is not just a big-PR thing. Compatibility impact. Writing it made me look at a file the diff never touches, and I found that a refusal we now store on a message gets sent back to the provider on the next request. Nothing breaks, and Java does the same, but it is a real behavior change that none of the design docs mentioned. The #930 miss that became #936 was the same kind of thing, in a file outside the diff. Behavioral contracts. Listing what the code promises, instead of listing the tests, is what showed me one promise had no test at all. I added it before opening the PR. A list of tests would have looked fine. So six: design intent, key decisions, contracts, failure behavior, a test for each contract, compatibility. Runtime flow and diagrams only for big PRs, as you say. Starting with your four and adding as we go is fine too. #965 is 919 lines, so it should tell us more about the big end. |
Linked issue: #280
Purpose of change
The Azure OpenAI connections reject a non-null
output_schema, so every caller with a schema stays on the prompt-engineering fallback even when the deployment's backing model supports native structured output. This applies the native path for them, in Java and Python together, on top of the mechanism added in #843.Two things differ from the OpenAI connection, both forced by how Azure works rather than by preference.
Capability is keyed on
model_of_azure_deployment, notmodel. On Azure themodelparameter is the deployment name, an arbitrary string the user chose, so matching an allowlist against it is wrong in both directions: a deployment calledprod-chatbacked by a capable model would report not-capable, and a deployment calledgpt-4obacked by anything would report capable.model_of_azure_deploymentis the only input that names the model actually behind the deployment. When it is unset, capability reports false and the caller stays on the prompt path, which is the safe direction.The native path additionally requires an
api_versionat or above2024-08-01. This is a safety gate rather than an optimization. Azure documents2024-08-01-previewas the first version supporting structured outputs, but I could not determine whether an older version returns an error or silently ignoresresponse_format, and neither SDK does any client-side gating. Never sending below the floor makes both possible behaviors safe: if the service would reject it, the call is never made; if it would silently ignore it, the native path is never taken so the prompt fallback still applies. This matters because the docs currently recommend2024-02-15-previewand2024-02-01, both below the floor.A bare
gpt-4ois deliberately absent from the allowlist. Azure reports a model and its version as separate properties, so a baregpt-4omay be the2024-05-13build, which does not support the feature, and no available input disambiguates it. This matches the same exclusion made for the OpenAI connection.Building the Java request inline left no seam to observe, so
chat()is split into a 4-arg override, a privatedoChat, and package-privatebuildRequestandtoResponse. The split turns a single read ofmodel_of_azure_deploymentinto two reads that must agree;toResponsemakes that testable. Reading the key after the call is equivalent to reading it before because the parameter map is built fresh per call and never retained.One behavior worth calling out: if a caller supplies their own
response_formatand the native path is about to write one, the connection now raises instead of silently replacing it. Before this change that combination was unreachable; it produced a raw SDK error on one channel and dropped the caller's value on the other. Passingresponse_formatwithout a schema is unaffected on every non-native path.Azure AI Inference (the
azureaimodule) is not included. Native structured output cannot work there at the pinnedazure-ai-inference:1.0.0-beta.5:json_schemais rejected below api-version2024-08-01-preview, but raisingModelServiceVersionto that value makes the endpoint return 404, which is why the SDK pins its own default down. I have recorded the details on #280 so nobody repeats the investigation.Tests
Both languages pin the same set of behaviors: native applied for a capable model with a POJO/BaseModel schema and an adequate api-version; not applied for an incapable, unknown, or bare-
gpt-4omodel; not applied for aRowTypeInfoschema or a null schema; not applied below the api-version floor; the capability predicate's true and false cases; the collision raising; and a caller-suppliedresponse_formatpassing through untouched on every non-native path.Python: 54 tests pass in
flink_agents/integrations/chat_models(non-integration), 447 acrossapiandplan. The new tests are in their own non-integration file, since the existing Azure test module is integration-marked and would be skipped in the unit run.Java: 58 tests pass in the
openaimodule,spotless:checkclean, no compiler warnings. The five pre-existing tests inAzureOpenAIChatModelConnectionTestare unmodified and still pass, including the ones guarding the exception behavior around the refactored region.The capable-model list and the api-version floor are identical in both languages.
API
No new public type, no new configuration field, and no signature change to an existing method. Java adds a 4-arg
chatoverride of the overload introduced in #843; Python'soutput_schemaparameter already existed and is now honored rather than rejected. A caller that passes nooutput_schemasees unchanged behavior.No dependency change in either language, and no build file is touched.
Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5)