Skip to content

Commit ab875e9

Browse files
Complete 0083 with the token-budget failure path (#238)
* Add 0083 failure path and mark implemented Complete proposal 0083 (per-prompt token-budget observability) with the structured-output-failure path. No source change was needed: the budget already evaluates on any usage-bearing per-attempt event, so a structured_output_invalid failure (which carries usage, proposal 0082) drives the span, metrics, and section 7 log via the generic path, and a no-usage provider_unavailable failure records nothing token-budget. - conformance harness: route fixture 131 to the token-budget runner; add expected_error handling (raises + raised_from), make the exact metric assertion conditional on a declared metrics block (a failure still records the baseline operation.duration), and implement the two without-usage invariants. - conformance.toml: mark 0083 implemented (all fixtures 126-131 run). * Fold whole-proposal review findings into 0083 Address the six findings from the whole-proposal adversarial review (0 blockers): cross-surface robustness plus the end-to-end coverage the per-PR reviews could not see. - langfuse: read token_budget via getattr in the metadata block, matching the OTel span + shared helper, so a non-TokenBudget value degrades instead of crashing the Generation. - harness: fail loudly on an unmapped token-budget invariant name (no more vacuous pass) and handle exception_propagates. - tests: a structured_output_invalid over-budget failure through Langfuse (ERROR wins, metadata.token_budget present, no WARNING); the PromptManager.render -> PromptResult.token_budget propagation; the langfuse extra-key tolerate case; and the filesystem unified malformed-budget fallback-eligible path.
1 parent 39fb843 commit ab875e9

6 files changed

Lines changed: 180 additions & 32 deletions

File tree

conformance.toml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -825,13 +825,12 @@ since = "0.17.0"
825825
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
828-
# (prompt-management §3 / graph-engine §6 / observability §5.5.15).
829-
# Not-yet overall: the completion-path OTel rendering + fixtures 126-129 (PR A)
830-
# and the Langfuse WARNING + §7 log + fixture 130 (PR B) run; only the
831-
# structured-output-failure path (131) stays deferred until PR C, which is when
832-
# this flips to implemented.
828+
# (prompt-management §3 / graph-engine §6 / observability §5.5.15 / §7 / §8.4.3
829+
# / §11.2). Implemented across three PRs; all six fixtures 126-131 run.
833830
[proposals."0083"]
834-
status = "not-yet"
831+
status = "implemented"
832+
since = "0.17.0"
833+
note = "Advisory, observability-only per-prompt token budget. PR A (completion path): a TokenBudget sub-record ({input_max_tokens?, total_max_tokens?}, non-negative) on Prompt / PromptResult, sourced from the same config sidecar as sampling (filesystem + langfuse backends); carried on LlmCompletionEvent / LlmFailedEvent / LlmRetryAttemptEvent (the OTel span + §11 metrics render from the per-attempt event). The OTel LLM span gains openarmature.prompt.token_budget.input_max_tokens / .total_max_tokens (each only when declared) + the reactive openarmature.llm.token_budget.exceeded boolean (emitted only when a bound is declared AND usage is present -- absent, not false, otherwise); two opt-in §11.2 instruments: openarmature.gen_ai.client.token_budget.exceeded counter (per breached bound) + .utilization histogram (actual / max per declared bound on every budgeted call), both dimensioned by openarmature.gen_ai.token_budget.kind = input | total. Evaluation gates each bound on its reported count being present (mirrors token.usage, does NOT coerce a missing count to 0); a declared bound of 0 is exceeded by any positive usage (strict >) but its utilization sample is skipped (ratio undefined). Un-defers fixtures 126-129. PR B (WARNING surfaces): the shared _token_budget_evaluations helper lifted to observability/llm_event.py; the Langfuse success Generation ends at level WARNING + statusMessage naming the breached bound(s) + metadata.token_budget on a breach (a hard ERROR still wins on the failure path); a §7 WARNING log per over-budget attempt on the openarmature.observability logger, dispatched independently of the disable_llm_spans / enable_metrics gates (correlates via correlation_id -- OA never activates spans, so no trace_id/span_id). Un-defers fixture 130. PR C (failure path): the budget evaluates on a usage-bearing structured_output_invalid failure exactly as on a completion (span + metrics + §7 log fall out of the generic per-attempt handling), and is populated-but-not-evaluated on a no-usage failure (provider_unavailable); the token-budget runner gained expected_error handling + the without-usage invariants. Un-defers fixture 131 (both cases). 0083 now fully implemented."
835834

836835
# Spec v0.81.0 (proposal 0084). Nested-fan-out span lineage chain
837836
# (graph-engine §6 fan_out_index_chain / branch_name_chain +

src/openarmature/observability/langfuse/observer.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2236,10 +2236,15 @@ def _typed_event_metadata(
22362236
token_budget = event.token_budget
22372237
if token_budget is not None:
22382238
budget: dict[str, Any] = {}
2239-
if token_budget.input_max_tokens is not None:
2240-
budget["input_max_tokens"] = token_budget.input_max_tokens
2241-
if token_budget.total_max_tokens is not None:
2242-
budget["total_max_tokens"] = token_budget.total_max_tokens
2239+
# token_budget is typed Any; read defensively (getattr) so a
2240+
# non-TokenBudget value degrades to no budget metadata rather than
2241+
# crashing the Generation, matching the OTel span + shared helper.
2242+
input_max = getattr(token_budget, "input_max_tokens", None)
2243+
total_max = getattr(token_budget, "total_max_tokens", None)
2244+
if input_max is not None:
2245+
budget["input_max_tokens"] = input_max
2246+
if total_max is not None:
2247+
budget["total_max_tokens"] = total_max
22432248
if budget:
22442249
metadata["token_budget"] = budget
22452250
if event.caller_invocation_metadata is not None:

tests/conformance/test_fixture_parsing.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -611,12 +611,9 @@ def _id(case: tuple[str, Path]) -> str:
611611
"Proposal 0082 structured-output failure event; harness typed_event_collector schema pending"
612612
),
613613
# Proposal 0083 (per-prompt token-budget observability, v0.78.0) -- the
614-
# completion-path fixtures (126-129) parse + run in test_observability; the
615-
# Langfuse WARNING (130) parses + runs via test_observability_langfuse; the
616-
# structured-output-failure (131) path stays deferred until its PR.
617-
"observability/131-token-budget-on-structured-output-failure": (
618-
"Proposal 0083 token-budget; not implemented"
619-
),
614+
# completion-path fixtures (126-129) + the structured-output-failure path
615+
# (131) parse + run in test_observability; the Langfuse WARNING (130) parses
616+
# + runs via test_observability_langfuse. All of 0083's fixtures now run.
620617
# Proposal 0084 (nested-fan-out span lineage, v0.81.0) -- the
621618
# lineage-chain directive shapes. (132 parses cleanly; accounted in
622619
# test_observability.)

tests/conformance/test_observability.py

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,15 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
228228
# metrics (utilization histogram + exceeded counter) for a budgeted
229229
# LLM completion. 126 input-exceeded, 127 total-exceeded, 128
230230
# under-budget (utilization records, no exceeded), 129 no-budget
231-
# baseline. The Langfuse WARNING (130) + failure path (131) are later.
231+
# baseline. 131 is the failure path: the budget evaluates on a
232+
# structured_output_invalid failure (carries usage, proposal 0082) and
233+
# NOT on a no-usage provider_unavailable failure. The Langfuse WARNING
234+
# (130) runs in the dedicated test_observability_langfuse harness.
232235
"126-token-budget-input-exceeded",
233236
"127-token-budget-total-exceeded",
234237
"128-token-budget-under-budget-no-warning",
235238
"129-token-budget-absent-unchanged",
239+
"131-token-budget-on-structured-output-failure",
236240
# v0.69.0 — proposal 0063 (tool-execution observability). A
237241
# calls_tool node enters the with_tool_call scope; the typed
238242
# ToolCallEvent / ToolCallFailedEvent drive the OTel tool span +
@@ -372,16 +376,13 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
372376
"Langfuse failed-Generation rendering; driven in test_observability_langfuse"
373377
),
374378
# Proposal 0083 (per-prompt token-budget observability, spec v0.78.0).
375-
# The completion-path span attributes + §11.2 metrics (126-129) run here
376-
# (see _SUPPORTED_FIXTURES). The Langfuse WARNING-level rendering (130) and
377-
# the structured-output-failure path (131) land in later v0.16.0 PRs.
378-
**{
379-
fixture_id: "per-prompt token-budget observability (proposal 0083) not-yet implemented"
380-
for fixture_id in (
381-
"130-langfuse-token-budget-warning-level",
382-
"131-token-budget-on-structured-output-failure",
383-
)
384-
},
379+
# The completion path (126-129) and the failure path (131) run here (see
380+
# _SUPPORTED_FIXTURES); the Langfuse WARNING-level rendering (130) is driven
381+
# in the dedicated test_observability_langfuse harness (its langfuse_trace
382+
# shape lives there, like 123).
383+
"130-langfuse-token-budget-warning-level": (
384+
"Langfuse WARNING-level rendering; driven in test_observability_langfuse"
385+
),
385386
# Proposal 0084 (nested-fan-out span lineage, spec v0.81.0).
386387
# Fixture 132's nested-lineage dispatch KEYING shipped in #194 (the
387388
# OTel + Langfuse observers key dispatches by the full enclosing
@@ -715,6 +716,7 @@ async def test_observability_fixture(fixture_path: Path) -> None:
715716
"127-token-budget-total-exceeded",
716717
"128-token-budget-under-budget-no-warning",
717718
"129-token-budget-absent-unchanged",
719+
"131-token-budget-on-structured-output-failure",
718720
}:
719721
await _run_token_budget_fixture(spec)
720722
elif fixture_id in {
@@ -3950,8 +3952,25 @@ async def _run_token_budget_case(case: Mapping[str, Any]) -> None:
39503952
handles += [graph.attach_observer(c) for c in collectors.values()]
39513953

39523954
state = _make_state_instance(case, state_cls)
3955+
# 131's failure path raises out of the node; the budget still evaluates on
3956+
# the (usage-bearing) structured_output_invalid attempt. The §7 error
3957+
# category rides the span status_description (checked below), NOT the outer
3958+
# NodeException (whose category is always "node_exception"), so only
3959+
# raised_from is asserted against the exception here.
3960+
expected_error = cast("dict[str, Any] | None", case.get("expected_error"))
39533961
try:
3954-
await graph.invoke(state)
3962+
if expected_error is not None:
3963+
from openarmature.graph import NodeException # noqa: PLC0415
3964+
3965+
with pytest.raises(NodeException) as exc_info:
3966+
await graph.invoke(state)
3967+
raised_from = expected_error.get("raised_from")
3968+
if raised_from is not None:
3969+
assert exc_info.value.node_name == raised_from, (
3970+
f"expected error raised_from: expected {raised_from!r}, got {exc_info.value.node_name!r}"
3971+
)
3972+
else:
3973+
await graph.invoke(state)
39553974
await graph.drain()
39563975
finally:
39573976
for handle in handles:
@@ -3985,8 +4004,13 @@ async def _run_token_budget_case(case: Mapping[str, Any]) -> None:
39854004

39864005
# ---- metrics + absence invariants ----
39874006
points = _collect_metric_points(reader)
3988-
expected_metrics = cast("list[dict[str, Any]]", expected.get("metrics") or [])
3989-
_assert_metric_points(points, expected_metrics)
4007+
# Only exact-assert when the fixture declares a metrics block. A no-usage
4008+
# failure (131 case 2) declares none: it still records the §11 baseline
4009+
# operation.duration (with error.type), and asserts the token-budget-metric
4010+
# absence via its invariants instead of an exact empty-set match.
4011+
expected_metrics = cast("list[dict[str, Any]] | None", expected.get("metrics"))
4012+
if expected_metrics is not None:
4013+
_assert_metric_points(points, expected_metrics)
39904014
_assert_token_budget_invariants(case, points, collectors)
39914015

39924016

@@ -3995,11 +4019,24 @@ def _assert_token_budget_invariants(
39954019
points: Sequence[tuple[str, float, int, dict[str, Any]]],
39964020
collectors: Mapping[str, Any],
39974021
) -> None:
3998-
"""Evaluate the token-budget named invariants (128/129) as absence
3999-
predicates over the captured measurement set + the completion event.
4022+
"""Evaluate the token-budget named invariants (128/129/131) as absence
4023+
predicates over the captured measurement set + the typed events.
40004024
The ``metrics:`` list shape asserts presence only, so these cover the
40014025
"no observation recorded" claims the list cannot express."""
40024026
invariants = cast("dict[str, Any]", case["expected"].get("invariants") or {})
4027+
# Fail loudly on a declared invariant this runner does not map, so a future
4028+
# fixture's new invariant name cannot pass vacuously (harness-fidelity).
4029+
_known_invariants = {
4030+
"no_token_budget_exceeded_observation_when_under_budget",
4031+
"utilization_records_under_budget",
4032+
"no_token_budget_instrument_observations_when_no_budget",
4033+
"token_budget_null_on_completion_event",
4034+
"no_token_budget_instrument_observations_without_usage",
4035+
"token_budget_populated_but_not_evaluated_without_usage",
4036+
"exception_propagates_alongside_typed_event",
4037+
}
4038+
unknown = set(invariants) - _known_invariants
4039+
assert not unknown, f"unhandled token-budget invariant(s): {sorted(unknown)}"
40034040
exceeded_points = [p for p in points if p[0] == "openarmature.gen_ai.client.token_budget.exceeded"]
40044041
utilization_points = [p for p in points if p[0] == "openarmature.gen_ai.client.token_budget.utilization"]
40054042

@@ -4027,6 +4064,35 @@ def _assert_token_budget_invariants(
40274064
assert all(getattr(e, "token_budget", "missing") is None for e in completion_events), (
40284065
"no-budget call MUST carry token_budget=None on the completion event"
40294066
)
4067+
if invariants.get("no_token_budget_instrument_observations_without_usage"):
4068+
assert not exceeded_points and not utilization_points, (
4069+
"no-usage failure MUST record no token-budget instrument observations; got "
4070+
f"exceeded={exceeded_points} utilization={utilization_points}"
4071+
)
4072+
if invariants.get("token_budget_populated_but_not_evaluated_without_usage"):
4073+
failed_events = [
4074+
e
4075+
for collector in collectors.values()
4076+
for e in collector.events
4077+
if type(e).__name__ == "LlmFailedEvent"
4078+
]
4079+
assert failed_events, "expected at least one LlmFailedEvent to assert token_budget populated"
4080+
assert all(getattr(e, "token_budget", None) is not None for e in failed_events), (
4081+
"a no-usage failure from a budgeted prompt MUST still carry token_budget on the event"
4082+
)
4083+
assert not exceeded_points and not utilization_points, (
4084+
"a no-usage failure MUST NOT evaluate the budget (no token-budget metric observation)"
4085+
)
4086+
if invariants.get("exception_propagates_alongside_typed_event"):
4087+
# The runner's pytest.raises already asserts the exception propagated;
4088+
# assert the typed failure event fired alongside it (both, not either).
4089+
failed_events = [
4090+
e
4091+
for collector in collectors.values()
4092+
for e in collector.events
4093+
if type(e).__name__ == "LlmFailedEvent"
4094+
]
4095+
assert failed_events, "expected an LlmFailedEvent alongside the propagated exception"
40304096

40314097

40324098
def _assert_error_span_extras(spans: Sequence[Any], expected_tree: list[dict[str, Any]]) -> None:

tests/unit/test_observability_langfuse.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,43 @@ async def test_typed_llm_failed_generation_carries_token_budget_metadata() -> No
15131513
assert gen.metadata.get("token_budget") == {"input_max_tokens": 10}
15141514

15151515

1516+
async def test_structured_over_budget_failure_generation_is_error_not_warning() -> None:
1517+
# §8.4.3 (proposal 0083): a structured_output_invalid failure that ALSO
1518+
# exceeds budget renders the ERROR Generation (a hard ERROR wins over the
1519+
# advisory WARNING) with metadata.token_budget still present -- the failed
1520+
# handler never applies the success-path WARNING.
1521+
from openarmature.llm.response import Usage
1522+
from openarmature.observability.correlation import (
1523+
_reset_invocation_id,
1524+
_set_invocation_id,
1525+
)
1526+
from openarmature.prompts import TokenBudget
1527+
from tests._helpers.typed_event import make_failed_event
1528+
1529+
client = InMemoryLangfuseClient()
1530+
observer = LangfuseObserver(client=client)
1531+
token = _set_invocation_id("inv-tb-soi")
1532+
try:
1533+
await observer(
1534+
make_failed_event(
1535+
invocation_id="inv-tb-soi",
1536+
error_category="structured_output_invalid",
1537+
error_type="StructuredOutputInvalid",
1538+
error_message="schema mismatch",
1539+
usage=Usage(prompt_tokens=20, completion_tokens=1, total_tokens=21),
1540+
token_budget=TokenBudget(input_max_tokens=10),
1541+
)
1542+
)
1543+
finally:
1544+
_reset_invocation_id(token)
1545+
1546+
gen = next(o for o in client.traces["inv-tb-soi"].observations if o.type == "generation")
1547+
assert gen.level == "ERROR"
1548+
assert gen.status_message == "structured_output_invalid"
1549+
assert "token budget exceeded" not in (gen.status_message or "")
1550+
assert gen.metadata.get("token_budget") == {"input_max_tokens": 10}
1551+
1552+
15161553
async def test_structured_output_failure_generation_renders_response_surface() -> None:
15171554
# Proposal 0082: a structured_output_invalid failure renders the response-side
15181555
# surface (output payload-gated, usage, metadata.finish_reason) on the ERROR

0 commit comments

Comments
 (0)