|
83 | 83 | from opentelemetry import context as otel_context |
84 | 84 | from opentelemetry import metrics as otel_metrics |
85 | 85 | from opentelemetry import trace as otel_trace |
86 | | -from opentelemetry.metrics import Histogram, Meter, MeterProvider |
| 86 | +from opentelemetry.metrics import Counter, Histogram, Meter, MeterProvider |
87 | 87 | from opentelemetry.sdk.resources import Resource |
88 | 88 | from opentelemetry.sdk.trace import SpanProcessor, TracerProvider |
89 | 89 | from opentelemetry.sdk.trace.id_generator import RandomIdGenerator |
|
195 | 195 | 40.96, |
196 | 196 | 81.92, |
197 | 197 | ] |
| 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 |
198 | 244 |
|
199 | 245 |
|
200 | 246 | def _read_spec_version() -> str: |
@@ -634,6 +680,10 @@ class OTelObserver: |
634 | 680 | _meter: Meter | None = field(init=False, repr=False, default=None) |
635 | 681 | _token_histogram: Histogram | None = field(init=False, repr=False, default=None) |
636 | 682 | _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) |
637 | 687 | # Per-invocation_id span state — concurrent invocations on a |
638 | 688 | # shared observer each get their own ``_InvState`` so internal |
639 | 689 | # maps never collide. |
@@ -695,6 +745,19 @@ def __post_init__(self) -> None: |
695 | 745 | unit="s", |
696 | 746 | explicit_bucket_boundaries_advisory=_DURATION_BUCKETS, |
697 | 747 | ) |
| 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 | + ) |
698 | 761 |
|
699 | 762 | # ------------------------------------------------------------------ |
700 | 763 | # Enricher invocation (friction-roundup #7p2) |
@@ -1444,6 +1507,20 @@ def _record_llm_metrics(self, event: LlmRetryAttemptEvent) -> None: |
1444 | 1507 | usage.completion_tokens, |
1445 | 1508 | {**base_dims, "openarmature.gen_ai.token.type": "output"}, |
1446 | 1509 | ) |
| 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) |
1447 | 1524 |
|
1448 | 1525 | def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None: |
1449 | 1526 | """Open + close one ``openarmature.llm.complete`` span from a |
@@ -1508,6 +1585,31 @@ def _handle_typed_llm_retry_attempt(self, event: LlmRetryAttemptEvent) -> None: |
1508 | 1585 | attrs["openarmature.prompt.label"] = active_prompt.label |
1509 | 1586 | attrs["openarmature.prompt.template_hash"] = active_prompt.template_hash |
1510 | 1587 | 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 | + ) |
1511 | 1613 | active_group = event.active_prompt_group |
1512 | 1614 | if active_group is not None: |
1513 | 1615 | attrs["openarmature.prompt.group_name"] = active_group.group_name |
|
0 commit comments