[integrations][java][python] Apply Anthropic native structured output - #965
[integrations][java][python] Apply Anthropic native structured output#965weiqingy wants to merge 9 commits into
Conversation
|
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. |
…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.
Generated-by: Claude Code 2.1.226
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. Generated-by: Claude Code 2.1.226
… 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. Generated-by: Claude Code 2.1.226
…NOTICE The SDK floor moved to 2.12.0 in the preceding commit, but the bundled NOTICE still declared 2.11.1 for all three artifacts, so the distribution would have shipped a version that does not match what it bundles. Generated-by: Claude Code 2.1.226
518747f to
d7ea74d
Compare
|
I think Currently Java defaults it to Could we align both implementations as follows?
The model check is important: Anthropic documents that Claude 4.6 and later models, as well as Mythos Preview, reject assistant-message prefill with a 400 because the request must end with a user message: This means the Java implementation also needs an additional model-capability guard. For example, a schema-free request to Claude 4.6 currently retains the default prefill and can be rejected even though no native schema is involved. The caller-supplied Suggested parity tests:
|
|
Thanks @weiqingy for putting this together and for trying the Implementation Description experiment across a larger, two-language change. Overall, the description was useful: it let me establish the runtime flow and intended contracts without first reconstructing them from the diff. I think it could be made more review-oriented in four ways:
My takeaway is that the main description should prioritize the information needed for human review: the purpose, interaction decisions, behavioral contracts, risks, and coverage. Detailed implementation invariants and test mappings can remain available through a |
…carries an output_config Anthropic documents message prefilling as incompatible with structured outputs: the structured outputs guide lists it under the features JSON outputs cannot be combined with. The prefill suppression only covered the output_config this connection derives from an output schema, so a request could still go out carrying both. Two paths reached that state. A caller supplying output_config through additional_kwargs alongside an output schema kept the prefill, because the derived schema stepped aside for the caller's value and the suppression keyed on whether the derived one was applied. A caller supplying output_config with no output schema at all also kept it, because the check for a caller-supplied value lived inside the branch that only runs when a schema is present. The caller-supplied check moves out of that branch, and the prefill now keys on whether the final request carries an output_config from either source. Tool use remains an independent suppressor on the same expression. Nothing else about the native path changes: which requests get a derived output_config is unaffected. The setup Javadoc and the parameter table documented the prefill as disabled only when tools are passed, which understated the rule even before this change, and neither recorded that an output_config supplied through additional_kwargs takes precedence over a derived one. Both now carry the full contract. Generated-by: Claude Code 2.1.229 (Claude Opus 5)
Thanks for the feedback. I have added them to my notes for the experiment. The description on this PR was auto-generated by the agent from an earlier version of the template. Once the template improves with the feedback, hope the descriptions will come out more readable for a human reviewer. |
…opt-in in Java
The json_prefill parameter defaulted to on, which sent a prefilled assistant
"{" message on every request that carried no tools and no output_config. That
default now costs more than it buys: Anthropic withdrew assistant-message
prefilling from the Claude 4.6 generation onward and from Claude Mythos Preview,
Claude Fable 5 and Claude Mythos 5, and a request that prefills one of those
models is answered with a 400 rather than a completion. Prefilling is a
prompt-steering technique with a visible effect on the response, so it becomes
something a setup asks for rather than something it has to know to turn off.
Two changes carry that. The default flips to false, so a setup that says nothing
about prefilling sends no prefill. And the decision now also consults the
effective model, so a setup that does ask for the prefill still gets the
documented behaviour rather than a provider error when it runs against a model
that rejects it.
The model rule lives in its own list rather than being derived from the
structured-output allowlists, whose contents it happens to coincide with today.
The two encode different documented boundaries: structured output starts at the
4.5 generation while prefill rejection starts at 4.6, so the three
4.5-generation names are structured-output capable and still accept a prefill.
A test pins one of those names as prefill-accepting, so folding the two lists
together fails rather than passing on the coincidence.
Generated-by: Claude Code 2.1.229 (Claude Opus 5)
The Anthropic setup accepted json_prefill in Java only, so an agent that wanted
the prefilled assistant "{" to steer a model toward JSON had no way to ask for
it from Python. The parameter now exists on both sides with the same contract:
off unless asked for, and applied only when the request carries no tools, no
output_config from either source, and an effective model the provider documents
as accepting assistant-message prefilling.
The connection consumes the parameter rather than forwarding it, since it
selects a message rather than naming a request field the provider accepts. The
decision is evaluated after the native structured-output block so it can see a
framework-derived output_config as well as a caller-supplied one, and the
response reconstruction is keyed on that same decision: the model continues the
prefilled "{" rather than repeating it, so the document is only complete once
it is put back, and reconstructing on any other signal yields malformed JSON
that the response gives no sign of.
The model list is written out with the same names in the same order as the Java
one, so a name edited on one side and not the other reads as an asymmetric diff
rather than a silent divergence.
Generated-by: Claude Code 2.1.229 (Claude Opus 5)
@wenjin272 Agreed on all four, and thanks for catching this. The prefill check only looked at whether we had added an There was one more case than you named. That check sat inside the All four are in the PR now. The default is One call I would like your view on. The prefill gate has its own model list instead of reusing the structured-output one, because the two do not line up. 4.5 supports structured output but still accepts prefill, and only 4.6 onward rejects it, so sharing a list would quietly switch prefill off for all of 4.5. There is also nothing to query for it, the Models API has |
Zhuoxi2000
left a comment
There was a problem hiding this comment.
Also reviewed this with the previous response-parsing issues (#914, #946) in mind. Token usage is still preserved across the new 4-arg flow, and the first-content-block handling is unchanged. Moving the prefill decision into BuiltRequest also looks cleaner and avoids the old flag/request drift. Left two inline comments: one on the alias boundary and one on the default change.
| "claude-mythos-5", | ||
| "claude-mythos-preview"); | ||
|
|
||
| private static final Set<String> NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES = |
There was a problem hiding this comment.
The alias-prefix logic has the same edge case one level down: startsWith("claude-sonnet-4-5") would also match something like claude-sonnet-4-50. Since the intent is to match either the alias itself or a dated snapshot, name.equals(prefix) || name.startsWith(prefix + "-") seems safer. The same applies to Python’s _NATIVE_STRUCTURED_OUTPUT_ALIAS_PREFIXES.
There was a problem hiding this comment.
Thanks for the review. Improved this in commit a72fbbf4.
| private static final double DEFAULT_TEMPERATURE = 0.1d; | ||
| private static final long DEFAULT_MAX_TOKENS = 1024L; | ||
| private static final boolean DEFAULT_JSON_PREFILL = true; | ||
| private static final boolean DEFAULT_JSON_PREFILL = false; |
There was a problem hiding this comment.
The Java default changing from true to false is also a behavior change for existing setups that relied on implicit prefilling. The new default makes sense given 4.6+ models reject prefilling, but should this get a release-note / BREAKING entry for 0.4 rather than only the docs update?
There was a problem hiding this comment.
You're right, this breaks existing setups. json_prefill defaulted to true in 0.2.0 through 0.3.1, so callers without tools lose the prefilled { on 0.4.
Currently I don't think it needs a separate BREAKING entry. code_review.md says breaking changes are acceptable in beta when they prevent an old bug path staying available, which is this one: the old gate never checked the model, so a 4.6+ model returned a 400 on every tool-free request. It also asks that such a change be intentional, documented and tested, and this one is, in the chat_models.md table and the setup tests in both languages. I have also fixed the PR description, which still claimed nothing changed.
Once we reach 1.0 we should guarantee backward compatibility. Having somewhere to record breaks like this before then would be worth doing, and this is a good example of why.
…ndary The three 4.5-generation names are aliases that front a dated snapshot, so both forms have to report capable and they were matched by prefix. A bare prefix also admits a name that merely extends the alias, so a later minor version such as claude-sonnet-4-50 would inherit the capability of claude-sonnet-4-5 without Anthropic having documented it. The alias now matches itself, or a name that continues it with a "-" separator, which is the shape every dated snapshot takes. A name that extends the alias any other way falls through to the exact set, which is where a future minor version belongs once it is documented. Generated-by: Claude Code 2.1.229 (Claude Opus 5)
…ve page The URL cited for the prefill-rejection rule redirects to the prompt engineering overview, which says nothing about prefilling, so a reader checking the model list against the provider lands on a page that cannot confirm or refute it. The rule now cites the messages guide section that carries it. Generated-by: Claude Code 2.1.229 (Claude Opus 5)
|
Updated the PR body to make it easier to read, applying the points from the review feedback. |
Linked issue: #280
Purpose of change
A caller can now pass an output schema to the Anthropic connection and get back a response the provider itself enforces.
Where the schema cannot be sent natively, the request goes out unconstrained and the existing prompt-engineering fallback takes over. Before this change both languages rejected the schema outright, so the capability was out of reach even on models that support it.
The same code path also decides
json_prefill. That parameter is now an explicit opt-in with one contract in both languages: off by default, and applied only when nothing on the request conflicts with it.Runtime flow
chathands the schema tobuildRequest, which resolves the effective model and then makes two decisions in order. First, whether the schema can be sent natively, in which case it is translated into anoutput_config. Second, whether to prefill, judged on what the request ended up carrying rather than on what was supplied. The table below gives the conditions on both.buildRequestreturns that prefill decision along with the request, so the response conversion reconstructs the leading{from the decision the request was actually built with. Python makes both decisions inside the single call that builds the request and converts the response.Key decisions
Which models are capable is a generational rule, not a list of snapshots. Anthropic documents structured output as generally available from Claude 4.5 onward, plus Mythos Preview. That splits the allowlist in two.
Names from the 4.6 generation on, such as
claude-opus-4-6, carry no date, so the name is itself the snapshot and is matched exactly. The three 4.5-generation names,claude-opus-4-5,claude-sonnet-4-5andclaude-haiku-4-5, are aliases that front a dated snapshot, so each one matches either the alias itself or a name that continues it after a-. That admitsclaude-sonnet-4-5-20250929. It excludes a name that extends the alias without the separator, which is a different minor version.The prefill guard keeps its own list of models, rather than deriving from the structured-output allowlist whose names it currently duplicates. The two encode different boundaries: structured output starts at 4.5, prefill rejection at 4.6. A shared list would withdraw the prefill from the whole 4.5 generation the first time either boundary moves.
A caller's own
output_configwins. When a caller supplies one, no derived config is written, and the prefill is suppressed as well. Overwriting the exact parameter a caller set would throw away a deliberate choice, without an error and without any other trace.Implementation Description
Interaction decisions
A schema means a POJO
Classin Java, or aBaseModelwrapped inOutputSchemain Python.json_prefillis off by default, so every row below assumes the caller turned it on.output_configBaseModeloutput_configBaseModelBaseModelRowTypeInfoor another shapeThe models that reject prefilling are the 4.6 generation and later, Mythos Preview, Fable 5 and Mythos 5. The
RowTypeInforow is where the two lists visibly differ: a 4.5-generation model such asclaude-sonnet-4-5is capable of structured output and still accepts a prefill, so a schema that cannot be sent natively there keeps the prefill its fallback depends on.Behavioral contracts
BaseModelschema, on a capable model, with no calleroutput_config, produces a request carrying the derived config.output_configis preserved unchanged, and no derived config is written alongside it.json_prefillis off unless the caller turns it on. An opted-in prefill is applied only when the request has no tools, has nooutput_configfrom either source, and targets a model that accepts prefilling.{exactly when the request carried the prefill.Failure behavior
No configuration on this path raises. An incapable model, an untranslatable schema, or a caller-supplied
output_configeach only change what the request carries, so the risks here are silent ones.An unrecognized model name is treated differently by the two guards, and that is deliberate. For structured output it counts as not capable, so the request falls back to prompt engineering. For prefill it counts as supported, so an opted-in request is sent with the prefill, and a model that does reject prefilling answers with a 400. The two documented rules have opposite shapes, which is why the two defaults point in opposite directions.
A name that arrives with a provider prefix or a version suffix, such as a Bedrock
anthropic.claude-opus-5, is compared against bare names. It falls to both of those defaults.A caller who forces the
NATIVEstrategy on a rejected model gets the prompt fallback rather than an error. The requested strategy is not visible at this layer, so the code cannot tell an explicitNATIVEfrom one that merely resolved that way. MarkedTODO(#912)in both languages, matching the OpenAI and Azure connections.Provider errors are unchanged. Java wraps them, Python lets the SDK exception through. That difference predates this change.
Tests
The suites run offline: 60 Java cases and 66 Python. The Java side uses no mocking framework, and builds its response objects from the SDK's own public builders.
Every contract above is pinned in both languages:
output_configis preserved{reconstructed exactly when prefilledEvery row of the decision table has a case in both suites, built through the real request builder and asserted on what the request ends up carrying. Three things that table does not show:
claude-sonnet-4-5andclaude-opus-4-6, one name from each matching rule, so narrowing the check to either branch fails a test.Not covered: no test makes a live request, so these pin what the connection sends and never that the provider accepts it. The Python dependency floor is unenforced, because the client is mocked. Provider-prefixed model names have no test either.
Mutation notes and implementation invariants
Mutation notes. Each of the following was applied to a private copy and run, in both languages: deriving the prefill guard from the structured-output allowlist, dropping the
output_configconjunct from the prefill decision, readingjson_prefillby key presence rather than by value, narrowing the capability call to the alias prefixes only, and forwardingjson_prefillto the provider instead of consuming it. Every one fails a test rather than passing quietly.Implementation invariants, not caller-observable:
testCapabilityReadsNoInstanceState,test_capability_reads_no_instance_state.chatforwards all four arguments to the 4-arg form with a null schema.testThreeArgChatForwardsNoSchema.NullPointerExceptionthatSet.contains(null)raises on an immutable allowlist.buildRequestalongside the request it was made for, so the response conversion reads that decision instead of recomputing a flag that can drift from the request it describes.outputFormatFromClasswould produce it directly, but it is compiledACC_SYNTHETICand cannot be named from Java, and the typedoutputConfig(Class)overload retypes both the request and the response while the deserialized POJO is discarded anyway. If the SDK returns no config for a schema it accepted, Java raisesIllegalStateException, which the public API cannot reach because the same call sets the config two lines earlier.testNativePathSendsNoBetaHeaderpins that the native path adds noanthropic-betaheader.API
No new public API in Java. Both connections override methods the foundation already defines, and the 3-arg
chatnow delegates to the 4-arg one. Python gains a single setup parameter,json_prefill, to match the Java one.Two things change for a caller who does nothing differently. The Java
json_prefilldefault moves fromtruetofalse, so a response no longer begins with a forced{; setting the parameter totrueasks for it back, though it stays withheld on a model that rejects prefilling and on any request carrying anoutput_config. And a request carrying anoutput_configsupplied throughadditional_kwargsno longer carries the prefill as well, which Anthropic documents as incompatible. The markdown extraction that already handles fenced JSON is untouched.Two dependency floors rise, each to the first release that exposes the parameter on the non-beta client:
com.anthropic:anthropic-javafrom 2.11.1 to 2.12.0, andanthropicfrom 0.64.0 to 0.77.0. The Java bump forces no other source change and leaves transitive dependencies unchanged.NOTICEis updated to match.Documentation
doc-neededdoc-not-neededdoc-includedThe Anthropic parameter tables gain a
json_prefillrow on the Python tab. The Java row's default changes tofalse, and both name the conditions that withhold the prefill. The Javaadditional_kwargsrow records that anoutput_configsupplied there takes precedence over one derived from a schema.Which providers fulfil
output_schemanatively rather than by prompt engineering stays undocumented, for every provider. It belongs in the integration support matrix as one change, rather than a third of it landing here.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code 2.1.226