Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The

- **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.
- **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.
- **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.
- **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`.

### Fixed

Expand Down
10 changes: 5 additions & 5 deletions conformance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -816,13 +816,13 @@ since = "0.16.0"
# Spec v0.77.0 (proposal 0082). Structured-output failure diagnostics
# (graph-engine §6 -- LlmFailedEvent response-side surface for
# structured_output_invalid + llm-provider §7 finish_reason / usage).
# Partial since 0.17.0: the event + error surface is implemented (PR A);
# the OTel / Langfuse / metrics RENDERING (observability fixtures 123-125)
# lands in later PRs of the split.
# Implemented since 0.17.0 across the 3-PR split: the event + error surface
# (PR A), the OTel error-span rendering + §11 token metric (PR B), and the
# Langfuse failed-Generation rendering (PR C).
[proposals."0082"]
status = "partial"
status = "implemented"
since = "0.17.0"
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."
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."

# Spec v0.78.0 (proposal 0083). Per-prompt token-budget observability
# (prompt-management §3 / graph-engine §6 / observability §5.5.15).
Expand Down
9 changes: 7 additions & 2 deletions src/openarmature/llm/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,10 @@ def _build_llm_failed_event(
response_model: str | None = None
error_message = str(exc)
if isinstance(exc, StructuredOutputInvalid):
output_content = exc.raw_content
# Empty content projects to None (as the success path does with
# ``content or None``), so both observers omit it identically
# rather than one rendering "" and the other dropping it.
output_content = exc.raw_content or None
finish_reason = exc.finish_reason
usage = exc.usage
response_id = exc.response_id
Expand Down Expand Up @@ -875,7 +878,9 @@ def _build_llm_retry_attempt_event(
response_fields: dict[str, Any] = {}
if isinstance(exc, StructuredOutputInvalid):
response_fields = {
"output_content": exc.raw_content,
# Empty content -> None (as the success path projects), so both
# observers omit it identically.
"output_content": exc.raw_content or None,
"finish_reason": exc.finish_reason,
"usage": exc.usage,
"response_id": exc.response_id,
Expand Down
57 changes: 38 additions & 19 deletions src/openarmature/observability/langfuse/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1757,9 +1757,11 @@ def _handle_typed_llm_completion(self, event: LlmCompletionEvent) -> None:

def _handle_typed_llm_failed(self, event: LlmFailedEvent) -> None:
"""Open + close an ERROR-level Generation observation from the
typed LlmFailedEvent (failure path). Same shape as the success
path with ERROR level + error_category as the Generation
observation's statusMessage."""
typed LlmFailedEvent (failure path): ERROR level + error_category
as the Generation's statusMessage. A structured_output_invalid
failure additionally carries the response-side surface (payload-gated
output, usage, metadata.finish_reason) like a completion, since its
wire response was intact."""
from openarmature.observability.correlation import (
current_correlation_id,
current_invocation_id,
Expand Down Expand Up @@ -1798,18 +1800,32 @@ def _handle_typed_llm_failed(self, event: LlmFailedEvent) -> None:
metadata["error_message"] = event.error_message
model_parameters: dict[str, Any] = dict(event.request_params or {})
input_value: Any = None
output_value: Any = None
end_kwargs: dict[str, Any] = {}
if not self.disable_provider_payload:
if event.input_messages:
input_value = self._maybe_truncate_for_input(event.input_messages)
if event.request_extras:
metadata["request_extras"] = self._maybe_truncate_for_extras(dict(event.request_extras))
# Proposal 0082: a structured_output_invalid failure has an intact wire
# response, so the failed Generation carries the response-side surface
# (output payload-gated, usage) like a completion, alongside the ERROR
# level + category. metadata.finish_reason is added for this category by
# _typed_event_metadata.
if event.error_category == "structured_output_invalid":
if not self.disable_provider_payload and event.output_content is not None:
output_value = self._maybe_truncate_for_output(event.output_content)
usage = self._usage_from_typed_event(event)
if usage is not None:
end_kwargs["usage"] = usage
target_trace_id = self._trace_id_for(inv_state, event.namespace, event.fan_out_index)
handle = self.client.generation(
trace_id=target_trace_id,
name="openarmature.llm.complete",
model=event.model,
model_parameters=model_parameters,
input=input_value,
output=output_value,
metadata=metadata,
parent_observation_id=parent_observation_id,
prompt=self._resolve_prompt_link_from_typed_event(event),
Expand All @@ -1820,6 +1836,7 @@ def _handle_typed_llm_failed(self, event: LlmFailedEvent) -> None:
end_time=end_time,
level="ERROR",
status_message=event.error_category,
**end_kwargs,
)

# Spec proposal 0063: dedicated Langfuse Tool observation (asType="tool").
Expand Down Expand Up @@ -2169,13 +2186,11 @@ def _typed_event_metadata(
) -> dict[str, Any]:
"""Build the Generation observation's metadata dict from a
typed LLM event. Shared between the success path
(LlmCompletionEvent) and the failure path (LlmFailedEvent);
response-side metadata (finish_reason / response_model /
response_id) lands only on the success variant for now. The
failure variant carries those fields too (populated only for a
structured_output_invalid failure, proposal 0082), but rendering
them on a failed Generation is deferred to the failed-generation
rendering PR via the isinstance guard below."""
(LlmCompletionEvent) and the failure path (LlmFailedEvent).
Response-side metadata (finish_reason / response_model /
response_id) renders for a completion and for a
structured_output_invalid failure (its wire response was intact);
every other failure category renders none, per the guard below."""
metadata: dict[str, Any] = {}
if correlation_id is not None:
metadata["correlation_id"] = correlation_id
Expand All @@ -2201,14 +2216,18 @@ def _typed_event_metadata(
metadata["prompt_group_name"] = active_group.group_name
if event.caller_invocation_metadata is not None:
_apply_caller_metadata(metadata, event.caller_invocation_metadata)
# Response-side rendering is success-path-only for now. The failure
# variant carries these fields too (populated only for a
# structured_output_invalid failure, proposal 0082), but this
# deliberate isinstance guard defers rendering them on a failed
# Generation to the failed-generation rendering PR; the failure-path
# metadata stays focused on the request-side + the failure-specific
# fields the caller adds separately.
if isinstance(event, LlmCompletionEvent):
# Response-side metadata. A completion always carries it; a
# structured_output_invalid failure also carries it (proposal 0082),
# since its wire response was intact, so finish_reason (the truncation
# signal) and the response identity render on the failed Generation too.
# Every other failure category received no response and renders none.
# In the second operand ``event`` narrows to LlmFailedEvent (the only
# non-completion in the union), so error_category is valid there and is
# never read for a completion (short-circuit).
renders_response_side = (
isinstance(event, LlmCompletionEvent) or event.error_category == "structured_output_invalid"
)
Comment thread
chris-colinsky marked this conversation as resolved.
Outdated
if renders_response_side:
if event.finish_reason is not None:
metadata["finish_reason"] = event.finish_reason
if event.response_model is not None:
Expand All @@ -2217,7 +2236,7 @@ def _typed_event_metadata(
metadata["response_id"] = event.response_id
return metadata

def _usage_from_typed_event(self, event: LlmCompletionEvent) -> LangfuseUsage | None:
def _usage_from_typed_event(self, event: LlmCompletionEvent | LlmFailedEvent) -> LangfuseUsage | None:
"""Map the typed event's Usage onto the Langfuse Usage record.
Returns None when no usage was reported."""
# Spec observability §8.4.3 (Langfuse usage mapping).
Expand Down
7 changes: 4 additions & 3 deletions tests/conformance/test_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,11 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
),
# Proposal 0082 (structured-output failure diagnostics, spec v0.77.0).
# The event surface (120-122), the OTel error-span rendering (124) and the
# §11 token-usage metric (125) are implemented; only the Langfuse
# failed-Generation rendering (123) defers to the final PR of the 0082 split.
# §11 token-usage metric (125) run here; the Langfuse failed-Generation
# rendering (123) is driven in the dedicated test_observability_langfuse
# harness (its langfuse_trace shape lives there, like 022-024).
"123-langfuse-failed-generation-renders-output-usage-finish-reason": (
"structured-output failure Langfuse rendering (proposal 0082) not-yet implemented"
"Langfuse failed-Generation rendering; driven in test_observability_langfuse"
),
# Proposal 0083 (per-prompt token-budget observability, spec v0.78.0).
# The Prompt.token_budget advisory + budget-exceeded span attribute /
Expand Down
Loading