Skip to content

Commit b25f67c

Browse files
Add 0083 token-budget signal (completion path) (#235)
* Add 0083 token-budget signal (completion path) Implement the completion-path half of proposal 0083 (per-prompt token-budget observability): the advisory Prompt.token_budget data model, its carriage on the LLM events, and the OTel rendering. - prompts: TokenBudget sub-record on Prompt/PromptResult, sourced from the same config sidecar as sampling; propagated on render. - events: token_budget on the completion / failed / retry-attempt events, populated from the active prompt at dispatch. - otel: openarmature.prompt.token_budget.* attrs + the reactive openarmature.llm.token_budget.exceeded signal, and two opt-in metric instruments (exceeded counter + utilization histogram, kind dimension), evaluated against reported usage. - conformance: un-defer fixtures 126-129 (completion path); the Langfuse WARNING (130) and failure path (131) follow in PR B/C. * Refine 0083 harness collapse and error message Address PR #235 review threads: - test harness: _render_prompt_result collapses an empty / all-null token_budget to None, mirroring the filesystem and Langfuse backends so conformance rendering matches real backend behavior. - filesystem: the malformed-token_budget PromptStoreUnavailable message now includes the (name, label) source, matching the other filesystem backend errors.
1 parent dcee6b5 commit b25f67c

16 files changed

Lines changed: 914 additions & 43 deletions

File tree

conformance.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -826,7 +826,9 @@ note = "The graph-engine §6 event surface + llm-provider §7 error surface (PR
826826

827827
# Spec v0.78.0 (proposal 0083). Per-prompt token-budget observability
828828
# (prompt-management §3 / graph-engine §6 / observability §5.5.15).
829-
# Not-yet; observability fixtures 126-131 defer with it.
829+
# Not-yet overall: the completion-path OTel rendering + fixtures 126-129 run
830+
# (PR A), but the Langfuse WARNING (130) and structured-output-failure (131)
831+
# paths stay deferred until their PRs; flips to implemented when they land.
830832
[proposals."0083"]
831833
status = "not-yet"
832834

src/openarmature/graph/events.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,14 @@ class LlmCompletionEvent:
583583
active_prompt_group: Any
584584
call_id: str
585585
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
586+
# Proposal 0083 (graph-engine §6 / observability §5.5.15): the active
587+
# prompt's advisory per-prompt token budget at LLM-call time (the
588+
# Prompt.token_budget snapshot). None when the call ran outside a
589+
# budgeted prompt-context binding. Typed as Any -- same rationale as
590+
# active_prompt (the prompts package isn't imported here); the observer
591+
# reads input_max_tokens / total_max_tokens off it directly and compares
592+
# them reactively against the reported usage to drive the budget signals.
593+
token_budget: Any = None
586594
# Proposal 0076 (spec v0.67.0): the assistant message's output tool
587595
# calls in typed-event-native form (the ToolCall records, not a
588596
# pre-serialized shape — they carry no inline-image bytes, so the
@@ -694,6 +702,13 @@ class LlmFailedEvent:
694702
usage: "Usage | None" = None
695703
response_id: str | None = None
696704
response_model: str | None = None
705+
# Proposal 0083 (graph-engine §6 / observability §5.5.15): the active
706+
# prompt's advisory per-prompt token budget at LLM-call time. None when
707+
# the call ran outside a budgeted prompt-context binding. Typed as Any --
708+
# same rationale as active_prompt. On a structured_output_invalid failure
709+
# (which carries the response-side usage) the budget eval fires as it does
710+
# on a completion; None-carrying every other category leaves it inert.
711+
token_budget: Any = None
697712

698713

699714
# Python-internal per-attempt LLM event. NOT a spec-normative event type
@@ -770,6 +785,13 @@ class LlmRetryAttemptEvent:
770785
error_message: str | None = None
771786
error_type: str | None = None
772787
caller_invocation_metadata: Mapping[str, AttributeValue] | None = None
788+
# Proposal 0083 (graph-engine §6 / observability §5.5.15): the active
789+
# prompt's advisory per-prompt token budget at LLM-call time. None outside
790+
# a budgeted prompt-context binding. Typed as Any -- same rationale as
791+
# active_prompt. This per-attempt event is the OTel budget-signal source:
792+
# the observer compares its bounds against event.usage on every attempt
793+
# carrying usage (success OR a structured_output_invalid failure).
794+
token_budget: Any = None
773795
# Proposal 0076: the attempt's output tool calls (ToolCall records),
774796
# mirroring LlmCompletionEvent.output_tool_calls. Populated on a
775797
# successful attempt; empty list on a failed one (no response). The

src/openarmature/llm/providers/openai.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ def emit_attempt(
513513
request_extras=request_extras,
514514
active_prompt=active_prompt,
515515
active_prompt_group=active_prompt_group,
516+
token_budget=(active_prompt.token_budget if active_prompt is not None else None),
516517
response=attempt_response,
517518
exc=attempt_exc,
518519
),
@@ -541,6 +542,7 @@ def emit_attempt(
541542
request_extras=request_extras,
542543
active_prompt=active_prompt,
543544
active_prompt_group=active_prompt_group,
545+
token_budget=(active_prompt.token_budget if active_prompt is not None else None),
544546
),
545547
)
546548
raise
@@ -562,6 +564,7 @@ def emit_attempt(
562564
request_extras=request_extras,
563565
active_prompt=active_prompt,
564566
active_prompt_group=active_prompt_group,
567+
token_budget=(active_prompt.token_budget if active_prompt is not None else None),
565568
),
566569
)
567570
return response
@@ -645,6 +648,7 @@ def _build_llm_completion_event(
645648
request_extras: dict[str, Any],
646649
active_prompt: Any,
647650
active_prompt_group: Any,
651+
token_budget: Any,
648652
) -> LlmCompletionEvent:
649653
"""Construct the typed LlmCompletionEvent for the success path.
650654
@@ -714,6 +718,9 @@ def _build_llm_completion_event(
714718
request_extras=request_extras,
715719
active_prompt=active_prompt,
716720
active_prompt_group=active_prompt_group,
721+
# Proposal 0083: the active prompt's advisory token budget, sourced
722+
# by the caller from active_prompt.token_budget at dispatch time.
723+
token_budget=token_budget,
717724
call_id=call_id,
718725
caller_invocation_metadata=caller_metadata,
719726
)
@@ -729,6 +736,7 @@ def _build_llm_failed_event(
729736
request_extras: dict[str, Any],
730737
active_prompt: Any,
731738
active_prompt_group: Any,
739+
token_budget: Any,
732740
) -> LlmFailedEvent:
733741
"""Construct the typed LlmFailedEvent for the failure path.
734742
@@ -792,6 +800,11 @@ def _build_llm_failed_event(
792800
request_extras=request_extras,
793801
active_prompt=active_prompt,
794802
active_prompt_group=active_prompt_group,
803+
# Proposal 0083: advisory budget carried on the failure event too;
804+
# None-carrying keeps it inert for non-response failures, while a
805+
# structured_output_invalid failure (which does carry usage) drives
806+
# the same reactive budget eval as a completion.
807+
token_budget=token_budget,
795808
call_id=call_id,
796809
error_category=exc.category,
797810
error_type=type(exc).__name__,
@@ -815,6 +828,7 @@ def _build_llm_retry_attempt_event(
815828
request_extras: dict[str, Any],
816829
active_prompt: Any,
817830
active_prompt_group: Any,
831+
token_budget: Any,
818832
response: Response | None = None,
819833
exc: LlmProviderError | None = None,
820834
) -> LlmRetryAttemptEvent:
@@ -853,6 +867,8 @@ def _build_llm_retry_attempt_event(
853867
"request_extras": request_extras,
854868
"active_prompt": active_prompt,
855869
"active_prompt_group": active_prompt_group,
870+
# Proposal 0083: advisory per-prompt token budget snapshot.
871+
"token_budget": token_budget,
856872
"caller_invocation_metadata": caller_metadata,
857873
}
858874
if response is not None:

src/openarmature/observability/otel/observer.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
from opentelemetry import context as otel_context
8484
from opentelemetry import metrics as otel_metrics
8585
from opentelemetry import trace as otel_trace
86-
from opentelemetry.metrics import Histogram, Meter, MeterProvider
86+
from opentelemetry.metrics import Counter, Histogram, Meter, MeterProvider
8787
from opentelemetry.sdk.resources import Resource
8888
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
8989
from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
@@ -195,6 +195,52 @@
195195
40.96,
196196
81.92,
197197
]
198+
# §11.2 (proposal 0083) token-budget utilization histogram bucket advisory.
199+
# Unitless ratio (actual / declared bound) so the boundaries cluster around
200+
# 1.0 -- the crossover between under- and over-budget -- to resolve how close
201+
# prompts run to (and how far past) their declared budget.
202+
_TOKEN_BUDGET_UTILIZATION_BUCKETS: list[float] = [
203+
0.1,
204+
0.25,
205+
0.5,
206+
0.75,
207+
0.9,
208+
1.0,
209+
1.1,
210+
1.25,
211+
1.5,
212+
2.0,
213+
4.0,
214+
]
215+
216+
217+
def _token_budget_evaluations(token_budget: Any, usage: Any) -> list[dict[str, Any]]:
218+
# §5.5.15 / §11.2 (proposal 0083): the per-bound token-budget evaluation
219+
# shared by the span attrs and the metric recorder. Returns one entry per
220+
# DECLARED bound that also has a usable actual to compare against, so a bound
221+
# whose count the provider did not report (None) is omitted -- mirroring the
222+
# token.usage gate rather than coercing a missing count to 0. A declared
223+
# bound of 0 is degenerate (ratio undefined) and is skipped for both
224+
# instruments; its declared value still surfaces as a prompt span attribute.
225+
# Empty when there is no budget, no usage, or no evaluable bound; the caller
226+
# then leaves the exceeded signal absent (not false).
227+
if token_budget is None or usage is None:
228+
return []
229+
evaluations: list[dict[str, Any]] = []
230+
input_max = getattr(token_budget, "input_max_tokens", None)
231+
if input_max and usage.prompt_tokens is not None:
232+
evaluations.append({"kind": "input", "actual": usage.prompt_tokens, "max": input_max})
233+
total_max = getattr(token_budget, "total_max_tokens", None)
234+
if total_max:
235+
if usage.total_tokens is not None:
236+
total_actual = usage.total_tokens
237+
elif usage.prompt_tokens is not None and usage.completion_tokens is not None:
238+
total_actual = usage.prompt_tokens + usage.completion_tokens
239+
else:
240+
total_actual = None
241+
if total_actual is not None:
242+
evaluations.append({"kind": "total", "actual": total_actual, "max": total_max})
243+
return evaluations
198244

199245

200246
def _read_spec_version() -> str:
@@ -634,6 +680,10 @@ class OTelObserver:
634680
_meter: Meter | None = field(init=False, repr=False, default=None)
635681
_token_histogram: Histogram | None = field(init=False, repr=False, default=None)
636682
_duration_histogram: Histogram | None = field(init=False, repr=False, default=None)
683+
# §11.2 (proposal 0083) per-prompt token-budget instruments — created
684+
# alongside the token/duration histograms when enable_metrics is True.
685+
_token_budget_exceeded_counter: Counter | None = field(init=False, repr=False, default=None)
686+
_token_budget_utilization_histogram: Histogram | None = field(init=False, repr=False, default=None)
637687
# Per-invocation_id span state — concurrent invocations on a
638688
# shared observer each get their own ``_InvState`` so internal
639689
# maps never collide.
@@ -695,6 +745,19 @@ def __post_init__(self) -> None:
695745
unit="s",
696746
explicit_bucket_boundaries_advisory=_DURATION_BUCKETS,
697747
)
748+
# §11.2 (proposal 0083) per-prompt token-budget instruments: an
749+
# exceeded counter ({call}) that increments per breached bound and
750+
# a utilization histogram (unitless ratio) that records on every
751+
# budgeted call, exceeded or not.
752+
self._token_budget_exceeded_counter = self._meter.create_counter(
753+
"openarmature.gen_ai.client.token_budget.exceeded",
754+
unit="{call}",
755+
)
756+
self._token_budget_utilization_histogram = self._meter.create_histogram(
757+
"openarmature.gen_ai.client.token_budget.utilization",
758+
unit="1",
759+
explicit_bucket_boundaries_advisory=_TOKEN_BUDGET_UTILIZATION_BUCKETS,
760+
)
698761

699762
# ------------------------------------------------------------------
700763
# Enricher invocation (friction-roundup #7p2)
@@ -1444,6 +1507,20 @@ def _record_llm_metrics(self, event: LlmRetryAttemptEvent) -> None:
14441507
usage.completion_tokens,
14451508
{**base_dims, "openarmature.gen_ai.token.type": "output"},
14461509
)
1510+
# §11.2 (proposal 0083) per-prompt token-budget instruments, from the
1511+
# shared per-bound evaluation (same per-attempt path as token.usage, so a
1512+
# structured_output_invalid failure -- which carries usage -- records
1513+
# too). Utilization on every declared+evaluable bound; the exceeded
1514+
# counter only when that bound is breached.
1515+
if (
1516+
self._token_budget_utilization_histogram is not None
1517+
and self._token_budget_exceeded_counter is not None
1518+
):
1519+
for ev in _token_budget_evaluations(event.token_budget, usage):
1520+
dims = {**base_dims, "openarmature.gen_ai.token_budget.kind": ev["kind"]}
1521+
self._token_budget_utilization_histogram.record(ev["actual"] / ev["max"], dims)
1522+
if ev["actual"] > ev["max"]:
1523+
self._token_budget_exceeded_counter.add(1, dims)
14471524

14481525
def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None:
14491526
"""Open + close one ``openarmature.llm.complete`` span from a
@@ -1508,6 +1585,31 @@ def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None:
15081585
attrs["openarmature.prompt.label"] = active_prompt.label
15091586
attrs["openarmature.prompt.template_hash"] = active_prompt.template_hash
15101587
attrs["openarmature.prompt.rendered_hash"] = active_prompt.rendered_hash
1588+
# §5.5.15 (proposal 0083) token-budget span surface. Each declared
1589+
# bound renders its own attribute (omitted when that bound is null);
1590+
# the exceeded signal is emitted ONLY when a budget is declared
1591+
# (>= 1 bound) AND the attempt carries usage, value = OR of per-bound
1592+
# exceedances. No budget or no usage -> the attribute is absent (not
1593+
# false), matching the §5.5 omit-when-undeclared convention.
1594+
token_budget = event.token_budget
1595+
if token_budget is not None:
1596+
# Declared bounds surface regardless of usage (each only when the
1597+
# prompt declared it); a 0 bound is still a declared value.
1598+
input_max = getattr(token_budget, "input_max_tokens", None)
1599+
total_max = getattr(token_budget, "total_max_tokens", None)
1600+
if input_max is not None:
1601+
attrs["openarmature.prompt.token_budget.input_max_tokens"] = input_max
1602+
if total_max is not None:
1603+
attrs["openarmature.prompt.token_budget.total_max_tokens"] = total_max
1604+
# The exceeded signal is emitted ONLY when at least one bound was
1605+
# actually evaluated (declared, non-degenerate, with a reported
1606+
# count); value = OR of per-bound breaches. No evaluable bound ->
1607+
# the attribute is absent (not false), per §5.5.15.
1608+
evaluations = _token_budget_evaluations(token_budget, event.usage)
1609+
if evaluations:
1610+
attrs["openarmature.llm.token_budget.exceeded"] = any(
1611+
ev["actual"] > ev["max"] for ev in evaluations
1612+
)
15111613
active_group = event.active_prompt_group
15121614
if active_group is not None:
15131615
attrs["openarmature.prompt.group_name"] = active_group.group_name

src/openarmature/prompts/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
SamplingConfig,
3838
TextBlockTemplate,
3939
TextPrompt,
40+
TokenBudget,
4041
)
4142

4243
__all__ = [
@@ -69,6 +70,7 @@
6970
"SamplingConfig",
7071
"TextBlockTemplate",
7172
"TextPrompt",
73+
"TokenBudget",
7274
"compute_rendered_hash",
7375
"compute_template_hash",
7476
"current_prompt_group",

0 commit comments

Comments
 (0)