[integrations][java][python] Apply Anthropic native structured output - #965
[integrations][java][python] Apply Anthropic native structured output#965weiqingy wants to merge 3 commits into
Conversation
…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.
|
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. |
Linked issue: #280
Purpose of change
Neither side could fulfil a caller-supplied output schema. Java had no 4-arg
chatoverride, so the call reached the base default, which rejects a non-null schema withUnsupportedOperationExceptionso 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)callsbuildRequest, which resolves the effective model, then decides in order:Whether native structured output applies. The schema must be a POJO
Class(Java) or aBaseModelwrapped inOutputSchema(Python),supportsNativeStructuredOutput(effectiveModel)must return true, and the caller must not have supplied its ownoutput_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.
buildRequestreturns the request together with that prefill decision.chatpasses the pair toconvertResponse, 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-4would also captureclaude-opus-4-1-20250805, which predates the cutoff.The Java translator extracts the config from a throwaway request. The Kotlin facade
StructuredOutputsKt.outputFormatFromClasswould produce it directly but is compiledACC_SYNTHETICand cannot be named from Java. The typedoutputConfig(Class)overload retypes the request and response asStructuredMessageCreateParamsandStructuredMessage, while the deserialized POJO is discarded anyway.A caller-supplied
output_configwins 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
BaseModelschema, a capable model, and no calleroutput_configproduce a request carrying the derivedoutput_config.output_configis preserved unchanged, and no derived config is written alongside it.json_prefill(Java).json_prefilldecision is unchanged (Java).chatforwards all four arguments to the 4-arg form with a null schema (Java).Failure behavior
An unsupported configuration never raises. An incapable model, an untranslatable schema, or a caller-supplied
output_configeach leave the request without a derived schema, and the prompt-engineering fallback governs.A null effective model returns false rather than propagating the
NullPointerExceptionthatSet.contains(null)raises on an immutable allowlist. If the SDK returns nooutput_configfor a schema it accepted, Java raisesIllegalStateException, 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 inRuntimeException, Python propagates the SDK exception, a divergence predating this change.A caller forcing the
NATIVEstrategy 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. MarkedTODO(#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.
testNativeSchemaAppliedOnCapableModel,test_native_output_config_applied_on_capable_modeltestNonClassSchemaKeepsFallback,test_native_output_config_not_applied_for_row_type_infotestNativeSchemaNotAppliedOnIncapableModel,testIncapableModelsReportNotCapable,test_capability_predicate_rejects_incapable_modelstestCallerOutputConfigWinsOverSchema,test_caller_output_config_wins_over_schematestCapabilityReadsNoInstanceState,test_capability_reads_no_instance_statetestJsonPrefillSuppressedWhenNativeAppliestestJsonPrefillAppliedWhenSchemaFallsBack, plus five existing prefill casesassertPrefillDecisiondrives the real conversion on every row it assertstestThreeArgChatForwardsNoSchematestCapableModelsReportCapable,testAliasPrefixMatchesDatedSnapshot, and their Python twins. Both suites hard-code the list as independent literals, so a typo in one production entry failsContracts 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.
testNativePathSendsNoBetaHeaderpins that the native path adds noanthropic-betaheader.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
chatnow delegates to the 4-arg form.A caller doing nothing differently sees no change: schema-free requests build exactly as before,
json_prefillbehaves 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-java2.11.1 to 2.12.0, andanthropic0.64.0 to 0.77.0. The Java bump forces no other source change and leaves transitive dependencies unchanged.Documentation
doc-neededdoc-not-neededdoc-includedNo user-facing documentation changes. Which providers fulfil
output_schemanatively 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.