Skip to content

[integrations][java][python] Apply Anthropic native structured output - #965

Open
weiqingy wants to merge 3 commits into
apache:mainfrom
weiqingy:280-pr4-anthropic-native
Open

[integrations][java][python] Apply Anthropic native structured output#965
weiqingy wants to merge 3 commits into
apache:mainfrom
weiqingy:280-pr4-anthropic-native

Conversation

@weiqingy

@weiqingy weiqingy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Linked issue: #280

Purpose of change

Neither side could fulfil a caller-supplied output schema. Java had no 4-arg chat override, so the call reached the base default, which rejects a non-null schema with UnsupportedOperationException so an unconstrained response is never mistaken for a schema-conforming one. Python rejected it explicitly. The capability was refused rather than silently degraded, and unavailable even on models that support provider-enforced structure.

Runtime flow

chat(messages, tools, modelParams, outputSchema) calls buildRequest, which resolves the effective model, then decides in order:

Whether native structured output applies. The schema must be a POJO Class (Java) or a BaseModel wrapped in OutputSchema (Python), supportsNativeStructuredOutput(effectiveModel) must return true, and the caller must not have supplied its own output_config. If all hold, the schema is translated and attached, and the decision is recorded.

Whether JSON prefill applies (Java only). On by default, off when tools are present, off again when the schema was applied natively.

buildRequest returns the request together with that prefill decision. chat passes the pair to convertResponse, which reconstructs the leading { only when the request actually carried it.

Key decisions

Capability is a generational rule, not a per-snapshot list: structured outputs are generally available for Claude 4.5 and later models, plus Mythos Preview. Names from 4.6 onward are dateless and pinned, so they match exactly. The three 4.5-generation names are aliases fronting a dated snapshot, so both forms must match and those match by prefix. A prefix must retain the minor version, since claude-opus-4 would also capture claude-opus-4-1-20250805, which predates the cutoff.

The Java translator extracts the config from a throwaway request. The Kotlin facade StructuredOutputsKt.outputFormatFromClass would produce it directly but is compiled ACC_SYNTHETIC and cannot be named from Java. The typed outputConfig(Class) overload retypes the request and response as StructuredMessageCreateParams and StructuredMessage, while the deserialized POJO is discarded anyway.

A caller-supplied output_config wins rather than being overwritten. Unlike parameters this connection always writes from typed setup fields, it is written only when a schema arrives on a channel the caller does not control, so overwriting would discard a deliberate choice.

The prefill decision was previously computed twice, with the response path prepending { from one of them. It is computed once and carried, so a desynchronized flag is not expressible.

Implementation Description

Behavioral contracts

  1. A POJO or BaseModel schema, a capable model, and no caller output_config produce a request carrying the derived output_config.
  2. A schema of any other shape produces no derived config.
  3. A model name in neither allowlist, including null, reports not-capable and produces no derived config.
  4. A caller-supplied output_config is preserved unchanged, and no derived config is written alongside it.
  5. The capability predicate reads no instance state.
  6. A natively applied schema suppresses json_prefill (Java).
  7. When native is not applied, for any reason, the json_prefill decision is unchanged (Java).
  8. The response conversion uses the prefill decision the request was built with (Java).
  9. The 3-arg chat forwards all four arguments to the 4-arg form with a null schema (Java).
  10. Both languages carry the same twelve identifiers in the same order.

Failure behavior

An unsupported configuration never raises. An incapable model, an untranslatable schema, or a caller-supplied output_config each leave the request without a derived schema, and the prompt-engineering fallback governs.

A null effective model returns false rather than propagating the NullPointerException that Set.contains(null) raises on an immutable allowlist. If the SDK returns no output_config for a schema it accepted, Java raises IllegalStateException, which is not reachable through the public API since the same call sets the config two lines earlier. Provider errors are unchanged: Java wraps them in RuntimeException, Python propagates the SDK exception, a divergence predating this change.

A caller forcing the NATIVE strategy on a model the predicate rejects degrades silently to the prompt fallback rather than raising, because the requested strategy is not visible at this layer. Marked TODO(#912) in both languages, matching the OpenAI and Azure connections.

Tests

32 test methods, 64 cases. No network, and no mocking framework on the Java side: response objects build offline from public SDK builders.

Contract Tests
1 testNativeSchemaAppliedOnCapableModel, test_native_output_config_applied_on_capable_model
2 testNonClassSchemaKeepsFallback, test_native_output_config_not_applied_for_row_type_info
3 testNativeSchemaNotAppliedOnIncapableModel, testIncapableModelsReportNotCapable, test_capability_predicate_rejects_incapable_models
4 testCallerOutputConfigWinsOverSchema, test_caller_output_config_wins_over_schema
5 testCapabilityReadsNoInstanceState, test_capability_reads_no_instance_state
6 testJsonPrefillSuppressedWhenNativeApplies
7 testJsonPrefillAppliedWhenSchemaFallsBack, plus five existing prefill cases
8 assertPrefillDecision drives the real conversion on every row it asserts
9 testThreeArgChatForwardsNoSchema
10 testCapableModelsReportCapable, testAliasPrefixMatchesDatedSnapshot, and their Python twins. Both suites hard-code the list as independent literals, so a typo in one production entry fails

Contracts 1, 3, 4, 6, 7 and 9 were each checked by mutation: inverting the condition, truncating a prefix, dropping the guard, or altering a forwarded argument fails the named test rather than passing quietly. testNativePathSendsNoBetaHeader pins that the native path adds no anthropic-beta header.

Not covered: no live request was made, so these pin what the connection sends, never that the provider accepts it. The Python floor is unenforced by any test, since the client is mocked, so floor correctness rests on introspecting both versions.

API

No new public API. Both connections override methods the foundation already defines, and the Java 3-arg chat now delegates to the 4-arg form.

A caller doing nothing differently sees no change: schema-free requests build exactly as before, json_prefill behaves as before, the response conversion is unchanged. A caller passing a schema previously got an exception on both sides, and now gets a provider-enforced response on a capable model, or the prompt-engineering fallback otherwise.

Two dependency floors rise, each to the first release exposing the parameter on the non-beta client: com.anthropic:anthropic-java 2.11.1 to 2.12.0, and anthropic 0.64.0 to 0.77.0. The Java bump forces no other source change and leaves transitive dependencies unchanged.

Documentation

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

No user-facing documentation changes. Which providers fulfil output_schema natively rather than by prompt engineering is undocumented for every provider, and belongs in the integration support matrix as one change rather than a third of it landing here.

…ilt request

The connection decided whether to apply JSON prefill in two independent
places: chat() read json_prefill from the parameter map and computed a
flag, while buildRequest() separately removed the same key and re-decided
whether to append the prefilled assistant message. convertResponse() then
prepended "{" based on the first computation. The two agreed only by
coincidence, so any change to one path could leave a stray "{" prepended
to a response that never carried a prefill.

buildRequest() now returns a holder carrying the request together with the
decision it actually made, and convertResponse() takes that holder rather
than a loose boolean, so a desynchronized flag is not expressible.

Equivalence rests on the parameter map being copied before any removal, so
moving the read inside buildRequest() cannot change what is read. The one
exception is a map whose lookup semantics differ from a HashMap copy's,
where the previous code was already inconsistent between its two reads.

Adds the module's first test tree.
The connection could not fulfil a caller-supplied output schema. With no
4-arg chat override the call reached the base default, which rejects a
non-null schema with UnsupportedOperationException so that an unconstrained
response can never be mistaken for a schema-conforming one. The capability
was therefore refused rather than silently degraded, and was unavailable
even on models that support provider-enforced structure.

Adds the native path, following the OpenAI and Azure connections: a
capability predicate over the effective model, a schema translator for POJO
classes, a gated branch that attaches output_config, and a guard that keeps
a caller-supplied output_config rather than overwriting it.

Capability follows the documented rule that structured outputs are
generally available for Claude 4.5 and later models. Models from the 4.6
generation onward carry dateless pinned identifiers and match exactly; the
three 4.5-generation aliases match by prefix. A prefix must retain the
minor version, since claude-opus-4 would otherwise capture the incapable
claude-opus-4-1-20250805.

JSON prefill is suppressed when the native path applies, since the two
mechanisms both exist to force JSON and several capable models reject a
prefilled assistant turn outright. A schema on a model that cannot do
native structured output keeps its prefill, because the prompt fallback
still needs it.

Requires anthropic-java 2.12.0, the first release exposing output_config on
the non-beta client. The bump forces no other source change.
… Python

The Python connection rejected a caller-supplied output schema outright, so
the capability was unavailable even on models that support
provider-enforced structure. The Java side gained the native path in the
preceding commit; this brings the two to parity.

Adds the capability predicate, the output_config payload, and a guard that
keeps a caller-supplied output_config rather than overwriting it, 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.

The allowlist carries the same twelve model identifiers as the Java side in
the same order, so a one-sided edit to either language shows up as an
asymmetric diff.

Anthropic's format object is flat, with no json_schema nesting and no name
or strict fields, so the schema translation used by the OpenAI and Azure
connections does not apply here and stays local to this connection.

Requires anthropic 0.77.0, the first release exposing output_config on the
non-beta client. Earlier releases carry it only on the beta client, which
this connection does not use.
@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 Aug 4, 2026
@weiqingy

weiqingy commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @wenjin272 , could you take a look when you get a chance? Third sample for the Implementation Description experiment on #894.

This one speaks to the size question you raised: 919 lines across two languages, where #952 was 123. Same format, nothing changed in how it is written.

It came out at 7.5k characters rather than the 6k I aimed for. I compressed twice and stopped, since what was left was the contracts and the failure paths. So either 6k is too tight for a two-language change, or the tests table should move to a comment sooner.

On whether writing it was worth it: this time it surfaced nothing new about the code, unlike #930 where it turned up the refusal gap that became #936. Worth recording, since a format that only pays off sometimes is a different proposition.

Still not touching the PR template until you have reviewed one.

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.

1 participant