fix(structured_output): fall back to prompt-based schema when the native path cannot consume it - #41577
Open
Harsh23Kashyap wants to merge 1 commit into
Conversation
…ive path cannot consume it Fixes langgenius#40907. The native JSON-schema path in `invoke_llm_with_structured_output` was taken on `model_schema.support_structure_output` alone. For a provider whose API has no `response_format` concept (concrete instance: `claude-sonnet-4-6` in `langgenius/anthropic` v0.3.26), no `response_format` parameter rule lists `json_schema` as an option, so the native path set `json_schema` in the request body without setting `response_format`. The provider plugin does not read `json_schema`, the schema was silently dropped, and the request returned unconstrained output. The prompt-based fallback was excluded by the `if/else` branch so a model that declared the feature produced worse results than one that did not. The new `can_use_native_schema` predicate requires both `support_structure_output=True` and a `response_format` parameter rule that lists `ResponseFormat.JSON_SCHEMA`. If the native path cannot actually consume the schema, fall through to the prompt-based branch which already handles the case correctly for models that do not declare the feature. An INFO log is emitted on the fallback path so the operator can see which models are silently using prompt-based schema injection. The operator-visible editor indicator is unchanged; aligning it to the runtime predicate is a follow-up so this PR stays scoped to the runtime behavior the reporter identified. Tests: `test_invoke_llm_with_structured_output_native_declared_but_plugin_cannot_use` covers the no-`response_format`-rule case; `test_invoke_llm_with_structured_output_native_declared_but_response_format_rule_omits_json_schema` covers the response_format-exists-but-omits-json_schema case. Both assert the prompt-based fallback ran by checking the request body (`json_schema` is not set) and the prompt_messages (a `SystemPromptMessage` containing the schema payload is injected).
Harsh23Kashyap
requested review from
QuantumGhost and
laipz8200
as code owners
September 1, 2026 07:28
Contributor
Pyrefly Diffbase → PR--- /tmp/pyrefly_base.txt 2026-09-01 07:30:08.570988852 +0000
+++ /tmp/pyrefly_pr.txt 2026-09-01 07:29:59.686927301 +0000
@@ -5720,6 +5720,10 @@
--> tests/unit_tests/core/llm_generator/output_parser/test_structured_output.py:192:16
ERROR `in` is not supported between `str` and `None` [not-iterable]
--> tests/unit_tests/core/llm_generator/output_parser/test_structured_output.py:203:16
+ERROR `not in` is not supported between `Literal['{{schema}}']` and `None` [not-iterable]
+ --> tests/unit_tests/core/llm_generator/output_parser/test_structured_output.py:358:17
+ERROR `in` is not supported between `Literal['"type": "object"']` and `None` [not-iterable]
+ --> tests/unit_tests/core/llm_generator/output_parser/test_structured_output.py:359:17
ERROR Argument `() -> Session` is not assignable to parameter `session_factory` with type `sessionmaker[@_]` in function `sqlalchemy.orm.scoping.scoped_session.__init__` [bad-argument-type]
--> tests/unit_tests/core/llm_generator/test_llm_generator.py:37:31
ERROR Object of class `NoneType` has no attribute `split` [missing-attribute]
|
Contributor
Pyrefly Type Coverage
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #40907 — when a model plugin's YAML declares
structured-outputin itsfeatureslist but the provider plugin does not actually consume the JSON schema, the schema was silently dropped from the request and the response came back unconstrained. The fix detects this case at the native-vs-prompt branch and falls through to the prompt-based schema injection path, which already works correctly for models that do not declare the feature.Root cause
api/core/llm_generator/output_parser/structured_output.py:125-128(pre-fix):The branch decision is taken on
support_structure_outputalone. The native path inside_handle_native_json_schema(lines 222-228) setsjson_schemain the request body and then conditionally setsresponse_formatonly if aresponse_formatparameter rule listsjson_schemaas an option. For a provider whose API has noresponse_formatconcept (concrete instance:claude-sonnet-4-6inlanggenius/anthropicv0.3.26, filed separately aslanggenius/dify-official-plugins#3671), no such rule exists. Nothing is set, the provider plugin does not readjson_schema, and the schema is dropped on the wire. Theelsebranch (prompt-based fallback) had already been excluded by theif/else, so a model that declared the feature produced worse results than one that did not: the latter would still get prompt-based schema guidance.The reporter's second observation — that the editor indicator reads served model metadata while the runtime branches on the plugin's YAML declaration — is out of scope for this PR. Aligning the editor indicator to the runtime predicate is a sensible follow-up that reuses the same predicate.
Changes
api/core/llm_generator/output_parser/structured_output.py— added thecan_use_native_schemapredicate that requires BOTHsupport_structure_output=TrueAND aresponse_formatparameter rule that listsResponseFormat.JSON_SCHEMA. If the native path cannot actually consume the schema, fall through to the prompt-based branch (which already works). An INFO log is emitted on the fallback path so the operator can see which models are silently using prompt-based schema injection. The native and prompt-based branches themselves are unchanged.api/tests/unit_tests/core/llm_generator/output_parser/test_structured_output.py— 2 new tests cover the two regression paths. Both assert the prompt-based fallback ran by checking the request body (json_schemais not set) and theprompt_messages(aSystemPromptMessagecontaining the schema payload is injected).Out of scope (deliberate)
can_use_native_schemapredicate so the UI and runtime agree.claude-sonnet-4-6inlanggenius/anthropicv0.3.26 should be fixed in the plugin repo (the reporter filedlanggenius/dify-official-plugins#3671). Once that ships, the runtime predicate still works correctly.json_schemawithout going throughresponse_format. If a future provider natively consumesjson_schemavia a custom key (notresponse_format), the current predicate would still route them to the prompt-based branch. The runtime behavior would still be correct (the schema would be applied via prompt injection), just less efficient. If such providers appear, the predicate can be extended to allow-list them; not adding speculative hooks for non-existent providers.Testing
uv run python -m pytest tests/unit_tests/core/llm_generator/output_parser/test_structured_output.py— 25/25 pass (was 23/23; 2 new tests added).uv run ruff checkon both files — All checks passed.uv run ruff format --check— clean on both files.uv run pyrefly check core/llm_generator/output_parser/structured_output.py— 0 diagnostics.pyrefly checkon the test file is not gated (file is intests/unit_tests/pyrefly.tomlproject-excludesper "Existing strict-mode debt")./consult-kiro(openai/gpt-5 --variant low) confirmed: "Fix looks correct and minimal; it prevents silent schema drops by gating the native path on real JSON Schema consumability and otherwise cleanly falls back to prompt injection." Suggested the optional INFO log; applied.Risk
Low. The change is to a single branch in a single function. The new predicate is a strict tightening: any model that was previously routed to the native path but where the native path silently dropped the schema will now be routed to the prompt-based path. The prompt-based path already works correctly for models that do not declare the feature, so the user-visible behavior strictly improves (the schema is at least attempted) for any model in the buggy state. Models that were correctly using the native path are unaffected.
Impact
claude-sonnet-4-6inlanggenius/anthropicv0.3.26) now applies the schema via prompt injection. The user enables structured output in the editor; the request now goes out with the schema in the system prompt and aresponse_format: json_objecthint, so the model is at least asked to conform. Once the plugin-side fix lands, the runtime predicate will route these models back to the native path automatically.Follow-up opportunities
_set_response_formatfallback path: if the model has noresponse_formatrule that listsJSONorJSON_OBJECT, the fallback also sets nothing on the wire. Less critical (the prompt-based injection still applies the schema) but worth a comment if the maintainer wants to be consistent.