Skip to content

Commit f752b54

Browse files
Add 0082 Langfuse failed-Generation rendering (#232)
* Add 0082 Langfuse failed-Generation rendering PR C of proposal 0082, completing the 3-PR split with the Langfuse failed-Generation rendering. 0082 is now fully implemented. For a structured_output_invalid failure the bundled Langfuse observer renders the response-side surface on the ERROR Generation like a completion: output (payload-gated) + usage on the Generation, and finish_reason / response_id / response_model in metadata (the completion-only isinstance guard widened to include the category), alongside the existing level=ERROR + statusMessage=category. Every other failure category is unchanged. Fixture 123 is driven in the dedicated Langfuse harness (response_schema threaded + expected_error handled). conformance.toml marks 0082 implemented. Also folds in the whole-proposal adversarial review over all three PRs: - Cross-observer consistency: the failure event builders project empty output_content to None (matching the success path's content-or-None), so both observers omit empty content identically. The OpenAI wire cannot currently produce empty content on the structured path, so this is a defensive builder projection with a builder-direct test. - The Langfuse conformance harness now asserts observation statusMessage (previously a silent no-op, so 123's category assertion did not run; also affected fixtures 130/137/138). - Refreshed a stale _typed_event_metadata docstring, dropped a docstring spec ref, and completed the CHANGELOG response id/model listing. * Tighten 0082 Langfuse failure-path assertions Address CoPilot review threads on PR #232 (0082 Langfuse failed-Generation rendering): - Assert metadata.response_id on the failed-Generation unit test. The event set it but only response_model was pinned, so a regression dropping response_id would have passed. - Assert raised_from against the raised NodeException in the linear conformance path. Fixture 123 declares it but nothing checked it. Category is not asserted on the exception (its category is always node_exception); it rides on the Generation statusMessage. - Restructure renders_response_side to an explicit if/else so the failure branch is obvious. No behavior change.
1 parent fceaa9f commit f752b54

8 files changed

Lines changed: 186 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +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. The bundled OTel observer renders this surface on the error `openarmature.llm.complete` span (`ERROR` status plus `finish_reason`, `usage`, and payload-gated `output.content`), and the opt-in `openarmature.gen_ai.client.token.usage` metric now records a structured-output failure's token usage (other failure categories still record none). Rendering the surface on the Langfuse failed Generation lands in a follow-on PR of the same proposal.
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. The bundled OTel observer renders this surface on the error `openarmature.llm.complete` span (`ERROR` status plus `finish_reason`, `usage`, payload-gated `output.content`, and the response id / model), and the opt-in `openarmature.gen_ai.client.token.usage` metric now records a structured-output failure's token usage (other failure categories still record none). The bundled Langfuse observer renders the same surface on the failed Generation: `level = "ERROR"` + `statusMessage` = the category, plus `output` (payload-gated), `usage`, `metadata.finish_reason`, and `metadata.response_id` / `metadata.response_model`.
1414

1515
### Fixed
1616

conformance.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -816,13 +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-
# 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.
819+
# Implemented since 0.17.0 across the 3-PR split: the event + error surface
820+
# (PR A), the OTel error-span rendering + §11 token metric (PR B), and the
821+
# Langfuse failed-Generation rendering (PR C).
822822
[proposals."0082"]
823-
status = "partial"
823+
status = "implemented"
824824
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). PR B adds the OTel rendering + the metric: the per-attempt LlmRetryAttemptEvent now carries the same response-side surface on a structured_output_invalid failed attempt, so the OTel error span renders it (a structured_output_invalid failure falls through the failed-attempt early-return to render finish_reason / usage / gated output.content / response id+model while keeping ERROR status + category) and the §11.2 token-usage histogram records the failure's usage (the metric already recorded whenever usage was present; a structured failure now carries it). Un-defers observability fixtures 124 (OTel error span, payload on/off) and 125 (token metric on the failure). PARTIAL: only the Langfuse failed-Generation rendering (123) is deferred to the final PR of the split -- it renders the surface the event carries onto a failed Generation."
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). PR B adds the OTel rendering + the metric: the per-attempt LlmRetryAttemptEvent now carries the same response-side surface on a structured_output_invalid failed attempt, so the OTel error span renders it (a structured_output_invalid failure falls through the failed-attempt early-return to render finish_reason / usage / gated output.content / response id+model while keeping ERROR status + category) and the §11.2 token-usage histogram records the failure's usage (the metric already recorded whenever usage was present; a structured failure now carries it). Un-defers observability fixtures 124 (OTel error span, payload on/off) and 125 (token metric on the failure). PR C completes it with the Langfuse failed-Generation rendering: _handle_typed_llm_failed renders output (payload-gated) + usage on the ERROR Generation and _typed_event_metadata renders finish_reason / response id+model for a structured_output_invalid failure (the isinstance(LlmCompletionEvent) guard widened to include it), alongside the existing level=ERROR + statusMessage=category. Un-defers observability fixture 123, driven in the dedicated test_observability_langfuse harness (calls_llm.response_schema threaded + expected_error handled). 0082 now fully implemented."
826826

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

src/openarmature/llm/providers/openai.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,10 @@ def _build_llm_failed_event(
764764
response_model: str | None = None
765765
error_message = str(exc)
766766
if isinstance(exc, StructuredOutputInvalid):
767-
output_content = exc.raw_content
767+
# Empty content projects to None (as the success path does with
768+
# ``content or None``), so both observers omit it identically
769+
# rather than one rendering "" and the other dropping it.
770+
output_content = exc.raw_content or None
768771
finish_reason = exc.finish_reason
769772
usage = exc.usage
770773
response_id = exc.response_id
@@ -875,7 +878,9 @@ def _build_llm_retry_attempt_event(
875878
response_fields: dict[str, Any] = {}
876879
if isinstance(exc, StructuredOutputInvalid):
877880
response_fields = {
878-
"output_content": exc.raw_content,
881+
# Empty content -> None (as the success path projects), so both
882+
# observers omit it identically.
883+
"output_content": exc.raw_content or None,
879884
"finish_reason": exc.finish_reason,
880885
"usage": exc.usage,
881886
"response_id": exc.response_id,

src/openarmature/observability/langfuse/observer.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,9 +1757,11 @@ def _handle_typed_llm_completion(self, event: LlmCompletionEvent) -> None:
17571757

17581758
def _handle_typed_llm_failed(self, event: LlmFailedEvent) -> None:
17591759
"""Open + close an ERROR-level Generation observation from the
1760-
typed LlmFailedEvent (failure path). Same shape as the success
1761-
path with ERROR level + error_category as the Generation
1762-
observation's statusMessage."""
1760+
typed LlmFailedEvent (failure path): ERROR level + error_category
1761+
as the Generation's statusMessage. A structured_output_invalid
1762+
failure additionally carries the response-side surface (payload-gated
1763+
output, usage, metadata.finish_reason) like a completion, since its
1764+
wire response was intact."""
17631765
from openarmature.observability.correlation import (
17641766
current_correlation_id,
17651767
current_invocation_id,
@@ -1798,18 +1800,32 @@ def _handle_typed_llm_failed(self, event: LlmFailedEvent) -> None:
17981800
metadata["error_message"] = event.error_message
17991801
model_parameters: dict[str, Any] = dict(event.request_params or {})
18001802
input_value: Any = None
1803+
output_value: Any = None
1804+
end_kwargs: dict[str, Any] = {}
18011805
if not self.disable_provider_payload:
18021806
if event.input_messages:
18031807
input_value = self._maybe_truncate_for_input(event.input_messages)
18041808
if event.request_extras:
18051809
metadata["request_extras"] = self._maybe_truncate_for_extras(dict(event.request_extras))
1810+
# Proposal 0082: a structured_output_invalid failure has an intact wire
1811+
# response, so the failed Generation carries the response-side surface
1812+
# (output payload-gated, usage) like a completion, alongside the ERROR
1813+
# level + category. metadata.finish_reason is added for this category by
1814+
# _typed_event_metadata.
1815+
if event.error_category == "structured_output_invalid":
1816+
if not self.disable_provider_payload and event.output_content is not None:
1817+
output_value = self._maybe_truncate_for_output(event.output_content)
1818+
usage = self._usage_from_typed_event(event)
1819+
if usage is not None:
1820+
end_kwargs["usage"] = usage
18061821
target_trace_id = self._trace_id_for(inv_state, event.namespace, event.fan_out_index)
18071822
handle = self.client.generation(
18081823
trace_id=target_trace_id,
18091824
name="openarmature.llm.complete",
18101825
model=event.model,
18111826
model_parameters=model_parameters,
18121827
input=input_value,
1828+
output=output_value,
18131829
metadata=metadata,
18141830
parent_observation_id=parent_observation_id,
18151831
prompt=self._resolve_prompt_link_from_typed_event(event),
@@ -1820,6 +1836,7 @@ def _handle_typed_llm_failed(self, event: LlmFailedEvent) -> None:
18201836
end_time=end_time,
18211837
level="ERROR",
18221838
status_message=event.error_category,
1839+
**end_kwargs,
18231840
)
18241841

18251842
# Spec proposal 0063: dedicated Langfuse Tool observation (asType="tool").
@@ -2169,13 +2186,11 @@ def _typed_event_metadata(
21692186
) -> dict[str, Any]:
21702187
"""Build the Generation observation's metadata dict from a
21712188
typed LLM event. Shared between the success path
2172-
(LlmCompletionEvent) and the failure path (LlmFailedEvent);
2173-
response-side metadata (finish_reason / response_model /
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."""
2189+
(LlmCompletionEvent) and the failure path (LlmFailedEvent).
2190+
Response-side metadata (finish_reason / response_model /
2191+
response_id) renders for a completion and for a
2192+
structured_output_invalid failure (its wire response was intact);
2193+
every other failure category renders none, per the guard below."""
21792194
metadata: dict[str, Any] = {}
21802195
if correlation_id is not None:
21812196
metadata["correlation_id"] = correlation_id
@@ -2201,14 +2216,17 @@ def _typed_event_metadata(
22012216
metadata["prompt_group_name"] = active_group.group_name
22022217
if event.caller_invocation_metadata is not None:
22032218
_apply_caller_metadata(metadata, event.caller_invocation_metadata)
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.
2219+
# Response-side metadata. A completion always carries it; a
2220+
# structured_output_invalid failure also carries it (proposal 0082),
2221+
# since its wire response was intact, so finish_reason (the truncation
2222+
# signal) and the response identity render on the failed Generation too.
2223+
# Every other failure category received no response and renders none.
22112224
if isinstance(event, LlmCompletionEvent):
2225+
renders_response_side = True
2226+
else:
2227+
# event narrows to LlmFailedEvent (the only other union member).
2228+
renders_response_side = event.error_category == "structured_output_invalid"
2229+
if renders_response_side:
22122230
if event.finish_reason is not None:
22132231
metadata["finish_reason"] = event.finish_reason
22142232
if event.response_model is not None:
@@ -2217,7 +2235,7 @@ def _typed_event_metadata(
22172235
metadata["response_id"] = event.response_id
22182236
return metadata
22192237

2220-
def _usage_from_typed_event(self, event: LlmCompletionEvent) -> LangfuseUsage | None:
2238+
def _usage_from_typed_event(self, event: LlmCompletionEvent | LlmFailedEvent) -> LangfuseUsage | None:
22212239
"""Map the typed event's Usage onto the Langfuse Usage record.
22222240
Returns None when no usage was reported."""
22232241
# Spec observability §8.4.3 (Langfuse usage mapping).

tests/conformance/test_observability.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,11 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
355355
),
356356
# Proposal 0082 (structured-output failure diagnostics, spec v0.77.0).
357357
# The event surface (120-122), the OTel error-span rendering (124) and the
358-
# §11 token-usage metric (125) are implemented; only the Langfuse
359-
# failed-Generation rendering (123) defers to the final PR of the 0082 split.
358+
# §11 token-usage metric (125) run here; the Langfuse failed-Generation
359+
# rendering (123) is driven in the dedicated test_observability_langfuse
360+
# harness (its langfuse_trace shape lives there, like 022-024).
360361
"123-langfuse-failed-generation-renders-output-usage-finish-reason": (
361-
"structured-output failure Langfuse rendering (proposal 0082) not-yet implemented"
362+
"Langfuse failed-Generation rendering; driven in test_observability_langfuse"
362363
),
363364
# Proposal 0083 (per-prompt token-budget observability, spec v0.78.0).
364365
# The Prompt.token_budget advisory + budget-exceeded span attribute /

0 commit comments

Comments
 (0)