Skip to content

Commit a01f01f

Browse files
Add 0082 structured-output failure event surface (#229)
PR A of proposal 0082 (the event + error surface; the OTel/Langfuse rendering and the token metric follow in later PRs). A structured_output_invalid failure is a completion whose final validation gate failed, so the wire response is intact. LlmFailedEvent now carries the response-side surface for that one category (null for every other): output_content (payload-gated), finish_reason (the truncation-vs-malformed triage signal), usage, response_id, and response_model. StructuredOutputInvalid additionally exposes finish_reason and usage so a caller's exception handler can make a truncation-aware retry decision. The response-side data lives in the parsed response, not in the helper that raises the error, so the OpenAI provider attaches it to the error at the parse/validate call site and the failed-event builder reads it off the enriched error. error_message carries the failing-field locator (the failure description), not just the terse category summary. Un-defers observability fixtures 120/121/122 (driven via a new calls_llm.response_schema harness lever) and llm-provider fixtures 022/023 (whose carries block now asserts finish_reason + usage; the carries reader gained subset matching for a mapping-valued field like usage). conformance.toml marks 0082 partial.
1 parent 91a8c2f commit a01f01f

13 files changed

Lines changed: 279 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1010

1111
- **PromptManager service-wide default cache TTL** (proposal 0086, prompt-management §6, spec v0.79.0). `PromptManager` construction accepts an optional `default_cache_ttl_seconds`, applied to any `fetch` or `get` that omits a per-call `cache_ttl_seconds`. Resolution follows a precedence chain: an explicit per-call value (including `0` force-fresh) wins; otherwise the manager default applies; otherwise nothing is forwarded and the backend's own caching governs. An omitted or explicit-`None` per-call value both select the default, so resolution does not depend on argument presence. A negative default is rejected at construction, and the per-call negative rejection is unchanged. `get` delegates to `fetch` and inherits the chain, and the bundled caching backends need no change (they already honor a resolved `cache_ttl_seconds`). Conformance fixture 036 is un-deferred.
1212
- **PromptGroup arity enforcement** (proposal 0080, prompt-management §10 / §11, spec v0.75.0). Constructing a `PromptGroup` with fewer than two members (an empty or single-member group) now raises a categorized `PromptGroupInvalid` at construction, before any render or LLM call. This replaces the previous bare `ValueError` (which pydantic folded into a `ValidationError` carrying no error category) and adds a `prompt_group_invalid` category to the prompt-management error set. `PromptGroupInvalid` is non-transient and is exported from `openarmature.prompts`. Conformance fixture 035 is un-deferred.
13+
- **Structured-output failure diagnostics on the LLM failure event** (proposal 0082, graph-engine §6 + llm-provider §7, spec v0.77.0). A `structured_output_invalid` failure is a completion whose final validation gate failed, so the wire response is intact. `LlmFailedEvent` now carries the response-side surface for that one category (`None` for every other): `output_content` (the content that failed validation, payload-gated), `finish_reason` (the truncation-vs-malformed triage signal, `"length"` vs `"stop"`), `usage`, `response_id`, and `response_model`. The `StructuredOutputInvalid` error additionally exposes `finish_reason` and `usage`, so a caller's exception handler can make a truncation-aware retry decision. Observer rendering of the surface (OTel error span, Langfuse failed Generation) and the token-usage-on-failure metric land in follow-on PRs of the same proposal.
1314

1415
## [0.16.0] — 2026-07-18
1516

conformance.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -816,10 +816,13 @@ since = "0.16.0"
816816
# Spec v0.77.0 (proposal 0082). Structured-output failure diagnostics
817817
# (graph-engine §6 -- LlmFailedEvent response-side surface for
818818
# structured_output_invalid + llm-provider §7 finish_reason / usage).
819-
# Not-yet; llm-provider fixtures 022/023 and observability fixtures
820-
# 120-125 defer with it.
819+
# Partial since 0.17.0: the event + error surface is implemented (PR A);
820+
# the OTel / Langfuse / metrics RENDERING (observability fixtures 123-125)
821+
# lands in later PRs of the split.
821822
[proposals."0082"]
822-
status = "not-yet"
823+
status = "partial"
824+
since = "0.17.0"
825+
note = "The graph-engine §6 event surface + llm-provider §7 error surface (PR A). LlmFailedEvent gains five response-side fields (output_content / finish_reason / usage / response_id / response_model), populated ONLY for structured_output_invalid (None for every other category); output_content is payload-bearing, the other four are not gated. StructuredOutputInvalid gains finish_reason / usage (the §7-mandated pair) plus response_id / response_model (internal, for the event builder), attached at the OpenAI provider's parse/validate call site where the intact wire response is in hand; the failed-event builder reads them off the enriched error. Un-defers observability fixtures 120 (truncation, finish_reason=length) / 121 (schema-mismatch, finish_reason=stop) / 122 (null-on-non-body companion), driven via the typed-event collector with a new calls_llm.response_schema harness lever; and llm-provider fixtures 022 / 023, whose carries block now asserts finish_reason + usage (the carries reader gained subset matching for a mapping-valued field like usage). PARTIAL: the OTel error-span rendering (124), Langfuse failed-Generation rendering (123), and the §11.2 token-usage-on-failure metric (125) are deferred to the OTel+metrics and Langfuse PRs -- they render the surface the event now carries."
823826

824827
# Spec v0.78.0 (proposal 0083). Per-prompt token-budget observability
825828
# (prompt-management §3 / graph-engine §6 / observability §5.5.15).

docs/concepts/observability.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,10 +1017,14 @@ async def my_llm_observer(event):
10171017
The two variants are **mutually exclusive on a single `complete()`
10181018
call** — implementations MUST NOT emit both for the same call.
10191019
Conformance fixture 072 locks this down. The failure variant carries
1020-
the same identity + request-side fields as the completion variant,
1021-
minus the response-side fields (`response_id`, `response_model`,
1022-
`usage`, `output_content`, `finish_reason`) — there was no response
1023-
to record.
1020+
the same identity + request-side fields as the completion variant. The
1021+
response-side fields (`output_content`, `finish_reason`, `usage`,
1022+
`response_id`, `response_model`) are populated only for a
1023+
`structured_output_invalid` failure, where the model returned a
1024+
response whose content failed downstream parse or validation, so
1025+
`finish_reason` (`"length"` signals a truncation) and the token usage
1026+
genuinely exist; they are `None` for every other category, where there
1027+
was no response to record.
10241028

10251029
A custom `Provider` that wants observers to see the same events
10261030
dispatches `LlmCompletionEvent(...)` on success and

src/openarmature/graph/events.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,19 @@ class LlmFailedEvent:
629629
630630
Identity / scoping / request-side field set mirrors
631631
``LlmCompletionEvent`` 1:1 — same field semantics, same nullability
632-
rules. Response-side fields (``response_id``, ``response_model``,
633-
``usage``, ``output_content``, ``finish_reason``) are ABSENT from
634-
this variant — no response was received.
632+
rules. The response-side fields (``output_content``,
633+
``finish_reason``, ``usage``, ``response_id``, ``response_model``)
634+
are populated ONLY for a ``structured_output_invalid`` failure — the
635+
one category where the provider returned a response (content that
636+
failed downstream parse or validation), so this event is in effect a
637+
completion whose final validation gate failed. They are ``None`` for
638+
every other category (no response was received). ``output_content``
639+
is payload-bearing (gated observer-side by ``disable_provider_payload``
640+
like the success variant); the other four are not gated.
641+
``error_message`` carries the failure summary plus the failing-locator
642+
description for this category, so observers read the diagnostic there
643+
without a dedicated field. The validated value is not carried, as on
644+
the completion event.
635645
636646
Failure-specific fields:
637647
@@ -673,6 +683,17 @@ class LlmFailedEvent:
673683
error_message: str
674684
error_type: str | None = None
675685
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
686+
# Response-side surface, populated only for structured_output_invalid
687+
# (the failure is a downstream parse/validation step on an intact wire
688+
# response). None for every other category. output_content is
689+
# payload-bearing; the other four are not gated. Mirrors the same fields
690+
# on LlmCompletionEvent, less output_tool_calls (a structured-output
691+
# failure never carries tool calls).
692+
output_content: str | None = None
693+
finish_reason: str | None = None
694+
usage: "Usage | None" = None
695+
response_id: str | None = None
696+
response_model: str | None = None
676697

677698

678699
# Python-internal per-attempt LLM event. NOT a spec-normative event type

src/openarmature/llm/errors.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
from __future__ import annotations
1818

19-
from typing import Any
19+
from typing import TYPE_CHECKING, Any
20+
21+
if TYPE_CHECKING:
22+
from openarmature.llm.response import Usage
2023

2124
# ---------------------------------------------------------------------------
2225
# Canonical category strings (llm-provider spec §7)
@@ -199,24 +202,49 @@ class StructuredOutputInvalid(LlmProviderError):
199202
raw_content: The raw response content the model produced.
200203
failure_description: A description of the parse or validation
201204
failure.
205+
finish_reason: The normalized finish reason of the response that
206+
failed validation (``"length"`` signals a truncation, the key
207+
retry signal; ``"stop"`` a clean-finish schema/parse failure).
208+
usage: Token usage of the response that failed validation, for
209+
cost attribution and truncation corroboration.
210+
response_id: The provider's response identifier, when present.
211+
response_model: The model identifier the provider reported, when
212+
present.
213+
214+
The failure is a downstream parse/validation step on an intact wire
215+
response, so the response-side context genuinely exists. It is
216+
attached at the parse/validate call site (which holds the parsed
217+
Response); the four fields default to ``None`` until enriched there.
202218
"""
203219

204220
category = STRUCTURED_OUTPUT_INVALID
205221
response_schema: dict[str, Any]
206222
raw_content: str
207223
failure_description: str
224+
finish_reason: str | None
225+
usage: Usage | None
226+
response_id: str | None
227+
response_model: str | None
208228

209229
def __init__(
210230
self,
211231
*args: Any,
212232
response_schema: dict[str, Any],
213233
raw_content: str,
214234
failure_description: str,
235+
finish_reason: str | None = None,
236+
usage: Usage | None = None,
237+
response_id: str | None = None,
238+
response_model: str | None = None,
215239
) -> None:
216240
super().__init__(*args)
217241
self.response_schema = response_schema
218242
self.raw_content = raw_content
219243
self.failure_description = failure_description
244+
self.finish_reason = finish_reason
245+
self.usage = usage
246+
self.response_id = response_id
247+
self.response_model = response_model
220248

221249

222250
__all__ = [

src/openarmature/llm/providers/openai.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,27 @@ def _build_llm_failed_event(
752752
caller_metadata: Mapping[str, AttributeValue] | None = None
753753
if self._populate_caller_metadata:
754754
caller_metadata = dict(current_invocation_metadata())
755+
# Response-side surface, populated only for structured_output_invalid
756+
# (proposal 0082): the failure is a downstream validation step on an
757+
# intact wire response, so the model's output genuinely exists and was
758+
# attached to the error at the parse/validate call site. None for every
759+
# other category (no response received).
760+
output_content: str | None = None
761+
finish_reason: str | None = None
762+
usage: Usage | None = None
763+
response_id: str | None = None
764+
response_model: str | None = None
765+
error_message = str(exc)
766+
if isinstance(exc, StructuredOutputInvalid):
767+
output_content = exc.raw_content
768+
finish_reason = exc.finish_reason
769+
usage = exc.usage
770+
response_id = exc.response_id
771+
response_model = exc.response_model
772+
# error_message carries the failing locator (proposal 0082): the
773+
# terse category summary alone drops the which-field detail, so
774+
# combine it with the failure_description observers triage on.
775+
error_message = f"{exc}: {exc.failure_description}"
755776
return LlmFailedEvent(
756777
invocation_id=invocation_id,
757778
correlation_id=current_correlation_id(),
@@ -771,8 +792,13 @@ def _build_llm_failed_event(
771792
call_id=call_id,
772793
error_category=exc.category,
773794
error_type=type(exc).__name__,
774-
error_message=str(exc),
795+
error_message=error_message,
775796
caller_invocation_metadata=caller_metadata,
797+
output_content=output_content,
798+
finish_reason=finish_reason,
799+
usage=usage,
800+
response_id=response_id,
801+
response_model=response_model,
776802
)
777803

778804
def _build_llm_retry_attempt_event(
@@ -1065,23 +1091,36 @@ def _parse_response(
10651091
except ValidationError as exc:
10661092
raise ProviderInvalidResponse(f"invalid usage record: {exc}") from exc
10671093

1068-
# Structured-output parsing. parsed is absent when no schema
1069-
# was requested AND when the response is a tool-call response
1070-
# — the tool-call path and structured-content path are
1071-
# mutually exclusive at the response level.
1072-
parsed: ParsedValue = None
1073-
if schema_dict is not None and finish_reason_typed != "tool_calls":
1074-
parsed = _parse_and_validate(assistant_msg.content, schema_dict, schema_class)
1075-
10761094
# gen_ai.response.id / gen_ai.response.model semconv (spec
10771095
# §5.5.3) read these off the Response. The wire fields are
10781096
# optional — providers MAY omit either or both. ``None`` when
1079-
# absent or not a string.
1097+
# absent or not a string. Parsed before structured-output
1098+
# validation so a structured_output_invalid failure can carry the
1099+
# response identity (proposal 0082).
10801100
response_id_raw = payload.get("id")
10811101
response_id: str | None = response_id_raw if isinstance(response_id_raw, str) else None
10821102
response_model_raw = payload.get("model")
10831103
response_model: str | None = response_model_raw if isinstance(response_model_raw, str) else None
10841104

1105+
# Structured-output parsing. parsed is absent when no schema
1106+
# was requested AND when the response is a tool-call response
1107+
# — the tool-call path and structured-content path are
1108+
# mutually exclusive at the response level.
1109+
parsed: ParsedValue = None
1110+
if schema_dict is not None and finish_reason_typed != "tool_calls":
1111+
try:
1112+
parsed = _parse_and_validate(assistant_msg.content, schema_dict, schema_class)
1113+
except StructuredOutputInvalid as exc:
1114+
# The wire response is intact; attach its response-side
1115+
# context so the failed event / §7 error carries finish_reason
1116+
# (truncation triage), usage, and the response identity
1117+
# (proposal 0082). raw_content is already set by the helper.
1118+
exc.finish_reason = finish_reason_typed
1119+
exc.usage = usage
1120+
exc.response_id = response_id
1121+
exc.response_model = response_model
1122+
raise
1123+
10851124
return Response(
10861125
message=assistant_msg,
10871126
finish_reason=finish_reason_typed,

src/openarmature/observability/langfuse/observer.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2171,8 +2171,11 @@ def _typed_event_metadata(
21712171
typed LLM event. Shared between the success path
21722172
(LlmCompletionEvent) and the failure path (LlmFailedEvent);
21732173
response-side metadata (finish_reason / response_model /
2174-
response_id) lands only on the success variant since those
2175-
fields don't exist on LlmFailedEvent."""
2174+
response_id) lands only on the success variant for now. The
2175+
failure variant carries those fields too (populated only for a
2176+
structured_output_invalid failure, proposal 0082), but rendering
2177+
them on a failed Generation is deferred to the failed-generation
2178+
rendering PR via the isinstance guard below."""
21762179
metadata: dict[str, Any] = {}
21772180
if correlation_id is not None:
21782181
metadata["correlation_id"] = correlation_id
@@ -2198,11 +2201,13 @@ def _typed_event_metadata(
21982201
metadata["prompt_group_name"] = active_group.group_name
21992202
if event.caller_invocation_metadata is not None:
22002203
_apply_caller_metadata(metadata, event.caller_invocation_metadata)
2201-
# Response-side fields are LlmCompletionEvent-only — absent
2202-
# from LlmFailedEvent. The type guard keeps the success-path
2203-
# metadata complete while the failure-path metadata stays
2204-
# focused on the request-side + the failure-specific fields
2205-
# the caller adds separately.
2204+
# Response-side rendering is success-path-only for now. The failure
2205+
# variant carries these fields too (populated only for a
2206+
# structured_output_invalid failure, proposal 0082), but this
2207+
# deliberate isinstance guard defers rendering them on a failed
2208+
# Generation to the failed-generation rendering PR; the failure-path
2209+
# metadata stays focused on the request-side + the failure-specific
2210+
# fields the caller adds separately.
22062211
if isinstance(event, LlmCompletionEvent):
22072212
if event.finish_reason is not None:
22082213
metadata["finish_reason"] = event.finish_reason

tests/conformance/harness/wire.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,41 @@ def assert_error_carries(exc: BaseException, carries: Mapping[str, Any]) -> None
181181
)
182182
else:
183183
actual = _get_carries_attr(exc, key)
184-
if actual != expected:
184+
if isinstance(expected, Mapping):
185+
# A mapping-valued field (e.g. usage) is a subset match: each
186+
# named key must equal the actual's value, reading a pydantic
187+
# record's fields (Usage) or a plain mapping. Keys the fixture
188+
# omits are ignored (the record MAY carry optional extras).
189+
actual_map = _as_carries_mapping(actual)
190+
if actual_map is None:
191+
raise AssertionError(
192+
f"carries check failed: {key!r} is not a mapping/record "
193+
f"(got {type(actual).__name__}); cannot subset-match {expected!r}"
194+
)
195+
for subkey, subval in cast("Mapping[str, Any]", expected).items():
196+
if actual_map.get(subkey) != subval:
197+
raise AssertionError(
198+
f"carries check failed: {key!r}[{subkey!r}] "
199+
f"actual={actual_map.get(subkey)!r}, expected={subval!r}"
200+
)
201+
elif actual != expected:
185202
raise AssertionError(
186203
f"carries check failed: {key!r} actual={actual!r}, expected={expected!r}"
187204
)
188205

189206

207+
def _as_carries_mapping(value: Any) -> Mapping[str, Any] | None:
208+
"""Coerce a carries attribute to a mapping for subset comparison: a plain
209+
Mapping passes through; a pydantic record (e.g. Usage) is dumped to a dict;
210+
anything else returns None (not comparable as a mapping)."""
211+
if isinstance(value, Mapping):
212+
return cast("Mapping[str, Any]", value)
213+
dump = getattr(value, "model_dump", None)
214+
if callable(dump):
215+
return cast("Mapping[str, Any]", dump())
216+
return None
217+
218+
190219
def _get_carries_attr(exc: BaseException, name: str) -> Any:
191220
# Allow fixture-naming-friendly aliases for the carries block. The
192221
# spec fixtures use ``raw_response_content`` (the wire-side label);

0 commit comments

Comments
 (0)