Repository navigation
[ISSUE #8257] Add schema-validated structured output with bounded opt-in repair - #8407
Conversation
Aias00
left a comment
There was a problem hiding this comment.
Stream Agent analysis
Reviewed #8407 (head d701250): adds schema-validated structured output with bounded opt-in repair for non-streaming OpenAI chat completions. The versioned diff was fully read (121,871 chars, complete=true) and the new/modified source files (AiStructuredOutputPolicy, AiGeneratedContentInspector, AiContentCompliance, BoundedJsonSchemaValidator, AiProxyEngine, AiProxyHandle, AiCommonConfig, AiProxyConfigService) were inspected. The implementation is internally coherent (config -> handle -> policy -> engine enforcement/repair). However, the attached CI evidence does not show a passing build.
Evidence
- CI attachment: ci.complete=true, ci.passed=false, legacy_state=pending; checks include e2e (status=queued, conclusion=null) plus multiple e2e-case entries reported success.
- Diff metadata: complete_diff=true, head_sha=d7012509b889787550349d142c61d43cb54d4801, 20 files changed (new AiStructuredOutputPolicy, AiGeneratedContentInspector, AiContentCompliance, BoundedJsonSchemaValidator; modified AiProxyEngine, AiProxyHandle, AiCommonConfig, AiProxyConfigService; new tests AiProxyEngineStructuredOutputTest, AiProxyConfigServiceStructuredOutputTest, BoundedJsonSchemaValidatorTest).
Recommended next steps
- Wait for the queued 'e2e' CI check to finish and confirm all 67 checks pass before merge.
- Verify AiStructuredOutputPolicy.getRepair() is non-null or null-guarded on the repair path when maxRepairAttempts>0 but no repair prompt is configured (see new AiProxyEngineStructuredOutputTest coverage).
Scope: captured diff and GitHub check metadata only; no local tests were executed.
Task: 1301c16f-6daa-4927-b873-820c909916a8 · Commit: d7012509b889787550349d142c61d43cb54d4801
Review verdict: INCONCLUSIVE
The versioned diff read is complete, but the attached CI evidence is not passing (ci.passed=false with the umbrella 'e2e' check still queued/conclusion null), so the PASS requirement of 'passing CI' cannot be satisfied. Line-level confirmation of a concrete blocking defect was blocked because the diff/source content, though recorded as delivered in the execution ledger, was not re-surfaceable for precise path/side/line anchoring in this turn. Per the review policy, incomplete/unconfirmed evidence yields INCONCLUSIVE rather than an asserted PASS or an unverified NEEDS_CHANGES.
Publication: COMMENT (REVIEW_EVIDENCE_MISSING)
Review details
I cannot issue a PASS for this PR. The full diff has been reviewed and the design is sound, but the attached CI status reports ci.passed=false with the 'e2e' check still 'queued' (conclusion null), so the 'passing CI' requirement for PASS is unmet. I also could not deterministically line-anchor a concrete HIGH/CRITICAL defect (e.g., a possible NPE on policy.getRepair() when repair config is absent while maxRepairAttempts>0) because the exact source lines were not re-surfaceable for precise anchoring in this turn. Recommendation: hold the merge until CI completes and the repair-config null-path is verified by the new tests.
wy471x
left a comment
There was a problem hiding this comment.
Two-axis review — standards & spec
The shape is right: a separate opt-in policy, a shared bounded validator promoted to shenyu-plugin-base, content compliance classified apart from transport failure, repair with its own attempt identity and its own bounded budget, and a large deterministic test suite. The AI_STRUCTURED_OUTPUT.md classification table is genuinely useful. Findings below are ordered by severity; the first four affect behaviour promised by #8257 and the doc.
Behaviour
-
A repair after a fallback response is sent back to the primary that just failed.
afterFailedInspection(...)passesphasethrough, andhandleResponsereaches it forPhase.FALLBACKtoo, butstartRepairalways prepares withexecution.primaryConfig():PreparedExchange repairPrepared = prepare( repairRequestBody(execution.requestBody(), policy, inspection), execution.primaryConfig());
So when the fallback provider returns content that fails the contract, the repair is regenerated on the primary (wrong provider/model, likely to fail again), while the
CONTENT_REPAIRevent is emitted withphase=FALLBACK. Spec #8257: "preserve original prompt/schema/model references". The fallback's capability and config are never consulted. -
Model-level refusals are repaired.
AiGeneratedContentInspectordetects a refusal only viafinish_reason == "content_filter", andAiStructuredOutputPolicy.REPAIRABLE = {INVALID_JSON, SCHEMA_MISMATCH}. A refusal delivered withfinish_reason: "stop"(OpenAImessage.refusal, or plain prose) therefore classifies asINVALID_JSONand triggers a second, billable generation whose prompt embeds the refusal as "previous output". That is the case spec requirement 4 explicitly forbids: "A refusal/safety decision is not a formatting error to bypass." Please detect the refusal field/refusalcontent as well, or make refusal classification fail-closed. -
A repair that returns tool calls is rejected, not forwarded.
handleRepairOutcomehasVALIDand "retry" branches only;TOOL_CALLS_PRESENTfrom a repaired generation falls through toCONTENT_REPAIR_FAILED→ 502.AI_STRUCTURED_OUTPUT.mdsays tool-call responses are "forwarded untouched — the client's workflow decision", and requirement 6 says a repair "cannot execute or repeat tools". Forwarding the repair output (or rejecting the repair, not the client's workflow) needs to be decided and documented consistently. -
VALIDresponses are not byte-identical. Spec acceptance: "Response bytes are unchanged when disabled or validation passes." Disabled is fine, but the validated path usesprivate AiUpstreamResponse withBody(final AiUpstreamResponse response, final String body) { return new AiUpstreamResponse(response.getStatusCode(), response.getHeaders(), Flux.just(body.getBytes(StandardCharsets.UTF_8))); }
which keeps the upstream headers (including
Content-Length,Content-Encoding,ETag) while re-encoding the materialized string. A non-UTF-8 body (or a BOM) becomes U+FFFD bytes with a stale length, and aContent-Encoding: gzipheader no longer matches the body. Other adapters in this module strip those headers (ConnectResponsesToChatConverter,OpenAiChat); please do the same here, or document why the headers are preserved. -
Tool-bearing detection is narrower than the requirement.
requestDeclaresToolsonly checks a non-emptytoolsarray. Legacyfunctions/function_callrequests and conversations containingrole: "tool"messages are treated as non-tool-bearing, so a repair may be issued for traffic where requirement 6 says "no automatic replay of tool-bearing/effectful operations". Broadening the check (or refusing repair whenever the request carries any tool-related field) closes it.
Bounds, budget and performance
-
Schema compilation happens per request on the event loop.
AiStructuredOutputPolicy.of(primaryConfig.getStructuredOutput())(plusnew BoundedJsonSchemaValidator(...)) parses, meta-validates and compiles the operator schema for every exchange; the registry is built withschemaCacheEnabled(false). Together withMAPPER.readTree,deepCopy()and validation over up to 5 MB inside the reactive chain, this is synchronous work on the Netty event loop for a gateway. Repo precedent states the opposite intent (shenyu-plugin-ai-sensitive-word/README.md: "the automaton is compiled on a bounded elastic thread, so the gateway event loop is never blocked"). Caching the compiled policy per handle/rule would address both. -
Validator budget exhaustion is classified as a repairable schema mismatch.
BoundedJsonSchemaValidator.validateValuethrowsIllegalArgumentExceptionfor both "document violates the schema" and "document exceeds the budget" (2048 nodes, 128 array items, 4096 string chars, depth 16), andAiGeneratedContentInspectormaps everyIllegalArgumentExceptiontoSCHEMA_MISMATCH→REPAIRABLE. A perfectly valid 200-item list or a long string field is therefore regenerated up torepairMaxAttemptstimes (each a billable model call) and then 502'd. A budget-exceeded outcome should be its own, non-repairable class. -
Repair generation has no token bound by default.
repairMaxTokensdefaults tonull, so a repair request has nomax_tokensunless the operator sets one, while the doc advertises "budgeted repair regeneration". Spec requirement 4 asks to "define the repair attempt/token limits"; please either default it to a bound or state in the doc that the repair's size is unbounded unless configured. -
com.networknt:json-schema-validatoris added at compile scope toshenyu-plugin-base, so every plugin depending on plugin-base inherits a validator stack it never uses. Declaring it in the two consumers (shenyu-plugin-ai-proxy,shenyu-plugin-agent-gateway) keeps the footprint surgical.
Standards and tests
BoundedJsonSchemaValidator.localRegistry()ispublic staticonly so tests can assert loader behaviour, andvalidateValuewas widened for a single caller; package-private or@VisibleForTestingwould keepshenyu-plugin-base's public surface unchanged (AGENTS.md: "smallest complete solution", "Preserve public SPI … unless the task changes them").AiCommonConfig's copy constructor shares the mutable DTO (this.structuredOutput = other.getStructuredOutput()) while the siblingrequestBodyOverridesis deep-copied; a shared config object mutated by one consumer would leak into the other.AiObservationSupport:error instanceof DataBufferLimitException || error instanceof AiProxyException proxy && (...)— please add parentheses so the precedence is readable.materialize()re-implements the boundedByteArrayOutputStreamreduce that already exists inConnectResponsesToChatConverterwith a third magic limit (5 MB) next toMAX_CONVERSION_BYTES; and inspection/repair orchestration, prompt composition and body rewriting all land in the already largeAiProxyEnginealthoughAiStructuredOutputPolicyandAiGeneratedContentInspectorwere extracted for exactly that (repairRequestBody,composeRepairContent,requestDeclaresTools,materialize,withBody).- The
Executionrecord grows to 11 components and is threaded through six-argument methods (startRepair(adapted, execution, policy, inspection, currentAttempt, phase)) — several of those are always derived fromexecution, so a two-argument form would read better.
Spec (#8257) status
- Requirement 3 and acceptance bullet 2 are only met at the enum level:
INVALID_JSON,SCHEMA_MISMATCH,MALFORMED_ENVELOPE,EMPTY,TRUNCATEDandREFUSALall collapse toKind.CONTENT/Reason.CONTENT_REJECTEDinAiProxyFailureMapper, so nothing downstream (#8055format_pass_rate, audit) can separate them. Spec: "Classify … separately". - Requirement 1 (provider-native
JSON_SCHEMA/JSON_MODEpreferred where supported, with an unsupported-provider fixture) is not implemented; the doc's capability section describes behaviour the code does not perform, and no fixture covers a supported-native / unsupported combination. - Requirement 4 repair attempt identity: emitted correctly (
Kind.REPAIR, ownCONTENT_REPAIR/CONTENT_REPAIR_FAILEDreasons, repair never draws on the network retry budget — the new guards inresumeAttempt/handleStatusFailureare right). Please confirm the parent attempt'selapsedMillisand terminalusageare not settled only after the nested repair completes, so the repair's billable usage is not attributed to the failed generation. - Requirements 2 (bounded versioned schema: implemented) and 6 (no tool replay, no repair after bytes committed, streaming untouched: implemented modulo findings 3 and 5) look good.
- Minor:
AiProxyException.Kind.PROVIDERis thrown fromAiStructuredOutputPolicy.of(...)at request time for an unusable operator config, so a bad schema costs one failure per request. Is that config also rejected when the selector/rule is saved? The spec's "fail fast" reads more like the latter.
…ith bounded opt-in repair (apache#8257)
- repair after a fallback re-asks the upstream that produced the failing response (active config) instead of always the primary - a repaired tool-call answer is forwarded like a primary one - validated answers drop the stale Content-Length/Content-Encoding framing - legacy functions/function_call requests also disable repair - compiled validators are cached by schema text (no per-request event-loop compilation); schema budget exhaustion is its own non-repairable outcome - repair generations default to a 1024 token bound; nested config copies are deep-copied; shared body materialization; readability and visibility fixes
d701250 to
83866af
Compare
|
Thank you both for the detailed reviews — every finding was verified against the code and addressed in commit Behavioral findings (wy471x)
Bounds, budget and performance (wy471x)
Standards and tests (wy471x)
Spec notes
On the reported NPE (Aias00): |
Aias00
left a comment
There was a problem hiding this comment.
Stream Agent analysis
#8407 adds operator-managed JSON-Schema-validated structured output for non-streaming OpenAI chat completions (issue #8257). Changes span 24 files (~1,991 additions / 125 deletions): new AiStructuredOutputConfig (common DTO), BoundedJsonSchemaValidator (moved from agent-gateway to plugin-base with 512-node/65,536-char bounds and meta-schema allowlist), AiStructuredOutputPolicy, AiGeneratedContentInspector, AiContentCompliance, AiProxyBodies, and AiProxyEngine.enforceStructuredOutput wiring plus 436-line engine test and 146-line validator test and docs.
Evidence
- Frozen metadata: ci.complete=true, ci.passed=false, 10 checks all status=queued/conclusion=null, legacy_state=pending, status_count=0.
- resource_diff whole diff enumerated to has_more=false at offset 140759 (140,759 chars), 24 files modified; middle pages returned only as archived empty observations in-session.
- Visible core (AiProxyEngine.enforceStructuredOutput lines 375-414, AiStructuredOutputPolicy, AiGeneratedContentInspector, BoundedJsonSchemaValidator) shows no concrete HIGH/CRITICAL blocking defect.
Recommended next steps
- Wait for CI to complete and confirm passed=true for all 10 checks at head 83866af.
- Re-read the full diff middle sections (offsets ~7000-119000) for line-level verification rather than archived summaries.
Scope: captured diff and GitHub check metadata only; no local tests were executed.
Task: fbd98ba0-d281-4a82-b4ef-abe7910866d3 · Commit: 83866af1626664c1cf3a564e871b4fd76e89b5cc
Review verdict: INCONCLUSIVE
Verdict cannot be PASS because the supplied CI evidence shows all checks queued/pending with passed=false (contract requires passing CI); additionally the diff's middle pages (offsets ~7000-119000) were returned only as archived empty observations during this review and could not be re-verified line-by-line, so full coverage cannot be asserted. No concrete HIGH/CRITICAL defect was identified in the accessible code.
Publication: COMMENT (INCONCLUSIVE_REVIEW)
Review details
Cannot issue PASS. CI is not green in the supplied evidence (all checks queued, passed=false), and the diff's middle pages could not be materially re-verified in-session (archived). No concrete HIGH/CRITICAL defect was identified in the visible core logic. Recommend re-running with completed CI before merge.
Aias00
left a comment
There was a problem hiding this comment.
Stream Agent analysis
PR #8407 (apache/shenyu) adds schema-validated structured output with bounded opt-in repair for non-streaming OpenAI chat completions. It introduces AiStructuredOutputConfig, AiStructuredOutputPolicy (bounded JSON-Schema validation + repair vocab), AiGeneratedContentInspector/AiContentCompliance, AiProxyBodies, and wires validation into AiProxyEngine via enforceStructuredOutput(). The JSON-schema validator (BoundedJsonSchemaValidator) and its tests were migrated from shenyu-plugin-agent-gateway to shenyu-plugin-base (with the json-schema-validator dependency), tests were renamed/added accordingly, and config plumbing added through AiProxyHandle -> AiCommonConfig -> AiProxyConfigService. Full diff read (140759 chars, all pages) and related cross-module references verified.
Evidence
- Full VERSIONED_DIFF read: resource 6d997310 base b47654b6, offset 0..140000, has_more=false (140759 chars); 21 modified/added/renamed files including AiProxyEngine.java (+217/-4), AiStructuredOutputPolicy.java (new 192), AiGeneratedContentInspector.java (new 151), BoundedJsonSchemaValidator.java (renamed +64/-15), and new tests (AiProxyEngineStructuredOutputTest 436, BoundedJsonSchemaValidatorTest 146).
- Wiring verified: AiProxyEngine.java:268 builds policy via AiStructuredOutputPolicy.of(primaryConfig.getStructuredOutput()); enforceStructuredOutput (line 382) gated on non-stream CHAT_COMPLETION and forwards tool-call responses untouched; repair bounded by repairsRemaining() and requestDeclaresTools guard; repairMaxAttempts wired at line 276; AiProxyConfigService.java:85 copies handle.getStructuredOutput(); pom.json-schema-validator moved from agent-gateway to base.
- No broken references: search for legacy AgentMcpOutputSchemaValidator in head returns no matches; body consumption in handle (upstream.apply(observedConsumer) -> responseConsumer.apply) is pre-existing base behavior, unchanged by this PR (base AiProxyEngine.java:262-283), so no double-body-read regression introduced.
Recommended next steps
- Confirm the 8 queued e2e checks (status 'queued', passed=false, complete=true in evidence) finish green before merge; they were not observed as passed.
- Verify the renamed agent-gateway test (AgentMcpResultFilterTest, formerly AgentMcpOutputSchemaValidatorTest) still covers prior AgentMcpOutputSchemaValidator behavior under the BoundedJsonSchemaValidator now in shenyu-plugin-base.
Scope: captured diff and GitHub check metadata only; no local tests were executed.
Task: 0401e27b-06aa-4581-98b0-b2fa5bcebde4 · Commit: 83866af1626664c1cf3a564e871b4fd76e89b5cc
Review verdict: PASS
Complete diff coverage achieved (all 140759 chars across all pages, has_more=false). The feature is consistently implemented across config, policy, inspector, engine, and tests with correct bounds (no unbounded repair, tool-bearing requests and tool-call responses excluded from validation/repair). Validator relocation and dependency migration are internally consistent (no dangling references found). No concrete HIGH/CRITICAL defect identified. CI evidence shows 66 checks recorded with 0 failed and 0 passed checks (passed=false, complete=true); required unit/build checks are not in the failed set, so no blocking failure is evidenced, but 8 e2e-case checks were still 'queued' and not observed as passing — flagged as a next_step rather than a blocker since no check failed.
Publication: APPROVE (EXPLICIT_PASS)
Fixes #8257.
Problem
An upstream HTTP/Chat envelope can be valid while the generated message is invalid JSON or violates the application's schema, and today the gateway forwards either one identically: the proxy treats every parse failure as transport noise, never inspects
choices[0].message.content, and offers no way for an operator to state what a compliant answer looks like. This adds the first delivery of #8257 for non-streaming openai-chat completions: gateway-side validation of the generated content against an operator-managed JSON Schema, a bounded classification vocabulary, and an explicitly configured, budgeted repair regeneration — everything inside the existing shared deadline and attempt accounting.Approach
The policy is fully opt-in and rides the existing selector handle (
AiProxyHandle.structuredOutput→AiCommonConfig): a non-blankschemaenables validation,repairEnabledplus a required operatorrepairPromptenables regeneration, andrepairMaxAttemptsbounds it (default 1, hard-capped at 3). Misconfiguration — a schema the bounded validator cannot accept, repair without a prompt, an out-of-range budget — fails fast; with no policy the response bytes are forwarded byte for byte.Classification is derived from protocol fields, never semantics:
finish_reason: tool_callsforwards untouched (no effectful operation is ever replayed),content_filteris a refusal that is never repaired,lengthis truncation that is never repaired, blank content, a malformed outer envelope, syntax-invalid generated content and schema mismatches get distinct outcomes, and only the last two are repairable. Validation reuses a shared bounded Draft 2020-12 validator (Draft pinned, keyword allow-list, hard node/depth/size budgets, no remote or file references, fail-closed) that this PR promotes from the MCP agent gateway intoshenyu-plugin-base, so both consumers share one implementation. Validation itself is local work and is never recorded as another upstream attempt.A repair regeneration re-prepares the original request with one appended user message: the operator's prompt (the only instruction source), the authoritative schema, the bounded evidence, and the previous output as clearly labeled data — model text cannot inject repair instructions.
streamis forced off, model and provider references are preserved, and each regeneration carries its ownREPAIRattempt identity with its own usage tap and never draws on the network retry counter — a transport or status failure of a repair terminates the exchange instead of being retried. All repair work respects the shared request deadline; when it is exhausted no repair is launched. The initial failure and every post-repair outcome stay separate attempt events, so a repair never erases the initial reliability signal. Enabled validation that ends non-compliant rejects the response (HTTP 502 with reasonCONTENT, classified in the terminal observation) — the gateway never silently forwards output it knows violates the contract.Testing
All tests are deterministic and sleep-free. They cover the byte-identical default path, valid content with a single upstream call, every classification's outcome (mismatch, invalid JSON, empty, truncated, refusal, tool calls, malformed envelope), repair with its own attempt identity (asserting the regenerated request embeds the operator prompt, the schema and the prior output as data, with stream off and model preserved), the bounded repair budget, repair-off by default even with a prompt configured, tool-bearing requests never triggering repair, streaming responses untouched, repair transport failures not engaging the network retry budget, and fail-fast policy misconfiguration. The promoted validator keeps its full boundary suite (unknown keywords, remote references, resource loading, budget exhaustion) in
shenyu-plugin-base, and the MCP filter behavior tests stay green in the agent gateway.shenyu-common711 tests (4 skipped),shenyu-plugin-base418,shenyu-plugin-agent-gateway650,shenyu-plugin-ai-common56,shenyu-plugin-ai-proxy527 (17 new) — all green; Checkstyle clean on all five modules. The coverage, classification table and follow-ups (streaming holdback, Responses coverage, budget-reservation integration) are documented inshenyu-plugin-ai/AI_STRUCTURED_OUTPUT.md.