agentloop-profiler 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- dashboard/value_view.py +105 -0
agentloop/budgets.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Atomic admission budgets for explicitly supported Python dispatch boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from fractions import Fraction
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agentloop.budget_types import (
|
|
14
|
+
COUNT_KEYS,
|
|
15
|
+
RETRY_SOURCES,
|
|
16
|
+
BudgetSnapshot,
|
|
17
|
+
BudgetValidationError,
|
|
18
|
+
Reservation,
|
|
19
|
+
amount,
|
|
20
|
+
count,
|
|
21
|
+
money_text,
|
|
22
|
+
number,
|
|
23
|
+
)
|
|
24
|
+
from agentloop.harness import BOUNDARIES, Decision, Hook, HookContext, Policy, _callable_kind
|
|
25
|
+
|
|
26
|
+
_COUNT_BOUNDARY = {"model": "model_calls", "tool": "tool_calls", "iteration": "iterations"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class BudgetLimits:
|
|
31
|
+
"""Optional per-run limits; deadline_at is an absolute monotonic-clock value."""
|
|
32
|
+
|
|
33
|
+
max_model_calls: int | None = None
|
|
34
|
+
max_tool_calls: int | None = None
|
|
35
|
+
max_iterations: int | None = None
|
|
36
|
+
max_retries: int | None = None
|
|
37
|
+
max_tokens: int | None = None
|
|
38
|
+
max_cost_usd: float | None = None
|
|
39
|
+
deadline_at: float | None = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
for key in (*COUNT_KEYS, "tokens"):
|
|
43
|
+
count(getattr(self, f"max_{key}"), key)
|
|
44
|
+
number(self.max_cost_usd, "max_cost_usd")
|
|
45
|
+
number(self.deadline_at, "deadline_at")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _Pending:
|
|
50
|
+
counts: dict[str, int]
|
|
51
|
+
metered: bool
|
|
52
|
+
tokens: int | None
|
|
53
|
+
cost: Fraction | None
|
|
54
|
+
accounted: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class _State:
|
|
59
|
+
scope_id: str
|
|
60
|
+
committed: dict[str, int] = field(default_factory=lambda: dict.fromkeys(COUNT_KEYS, 0))
|
|
61
|
+
refunded: dict[str, int] = field(default_factory=lambda: dict.fromkeys(COUNT_KEYS, 0))
|
|
62
|
+
retries: dict[str, int] = field(default_factory=lambda: dict.fromkeys(RETRY_SOURCES, 0))
|
|
63
|
+
pending: dict[str, _Pending] = field(default_factory=dict)
|
|
64
|
+
seen_usage: dict[str, str] = field(default_factory=dict)
|
|
65
|
+
metered_calls: int = 0
|
|
66
|
+
tokens_known: int = 0
|
|
67
|
+
cost_known: Fraction = Fraction(0)
|
|
68
|
+
tokens_held: int = 0
|
|
69
|
+
cost_held: Fraction = Fraction(0)
|
|
70
|
+
unknown_calls: int = 0
|
|
71
|
+
unknown_tokens: int = 0
|
|
72
|
+
unknown_cost: int = 0
|
|
73
|
+
tokens_refunded: int = 0
|
|
74
|
+
cost_refunded: Fraction = Fraction(0)
|
|
75
|
+
last_clock: float | None = None
|
|
76
|
+
reserved_counts: dict[str, int] = field(default_factory=lambda: dict.fromkeys(COUNT_KEYS, 0))
|
|
77
|
+
reserved_metered: int = 0
|
|
78
|
+
reserved_tokens: int = 0
|
|
79
|
+
reserved_cost: Fraction = Fraction(0)
|
|
80
|
+
reserved_unknown_tokens: int = 0
|
|
81
|
+
reserved_unknown_cost: int = 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _BudgetEvaluator:
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
limits: BudgetLimits,
|
|
88
|
+
unknown_usage: str,
|
|
89
|
+
soft_fraction: float | None,
|
|
90
|
+
metered: frozenset[str],
|
|
91
|
+
clock: Callable[[], float],
|
|
92
|
+
) -> None:
|
|
93
|
+
self.limits = limits
|
|
94
|
+
self.unknown_usage = unknown_usage
|
|
95
|
+
self.soft_fraction = None if soft_fraction is None else Fraction(str(soft_fraction))
|
|
96
|
+
self.metered = metered
|
|
97
|
+
self.clock = clock
|
|
98
|
+
self.cost_limit = None if limits.max_cost_usd is None else amount(limits.max_cost_usd)
|
|
99
|
+
|
|
100
|
+
def _now(self, state: _State) -> float | None:
|
|
101
|
+
if self.limits.deadline_at is None:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
reading = self.clock()
|
|
105
|
+
except Exception:
|
|
106
|
+
raise BudgetValidationError("monotonic clock could not be read") from None
|
|
107
|
+
if inspect.iscoroutine(reading):
|
|
108
|
+
reading.close()
|
|
109
|
+
now = number(reading, "monotonic clock", optional=False)
|
|
110
|
+
if state.last_clock is not None and now < state.last_clock:
|
|
111
|
+
raise BudgetValidationError("monotonic clock moved backwards")
|
|
112
|
+
state.last_clock = now
|
|
113
|
+
return now
|
|
114
|
+
|
|
115
|
+
def _pending(self, context: HookContext, *, accounted: bool) -> _Pending:
|
|
116
|
+
counts = dict.fromkeys(COUNT_KEYS, 0)
|
|
117
|
+
if context.hook.boundary in _COUNT_BOUNDARY:
|
|
118
|
+
counts[_COUNT_BOUNDARY[context.hook.boundary]] = 1
|
|
119
|
+
if context.dispatch.retry_source is not None:
|
|
120
|
+
counts["retries"] = 1
|
|
121
|
+
metered = context.hook.boundary in self.metered
|
|
122
|
+
reservation = context.dispatch.reservation or Reservation()
|
|
123
|
+
return _Pending(
|
|
124
|
+
counts,
|
|
125
|
+
metered,
|
|
126
|
+
reservation.tokens if reservation.tokens_known else None if metered else 0,
|
|
127
|
+
amount(reservation.cost_usd)
|
|
128
|
+
if reservation.cost_known
|
|
129
|
+
else None
|
|
130
|
+
if metered
|
|
131
|
+
else Fraction(0),
|
|
132
|
+
accounted,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _in_flight(self, state: _State) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
**state.reserved_counts,
|
|
138
|
+
"metered_calls": state.reserved_metered,
|
|
139
|
+
"tokens_reserved": state.reserved_tokens,
|
|
140
|
+
"cost_reserved_usd": state.reserved_cost,
|
|
141
|
+
"unknown_token_reservations": state.reserved_unknown_tokens,
|
|
142
|
+
"unknown_cost_reservations": state.reserved_unknown_cost,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def _reserve(self, state: _State, pending: _Pending, direction: int) -> None:
|
|
146
|
+
if not pending.accounted:
|
|
147
|
+
return
|
|
148
|
+
for key, value in pending.counts.items():
|
|
149
|
+
state.reserved_counts[key] += direction * value
|
|
150
|
+
if pending.metered:
|
|
151
|
+
state.reserved_metered += direction
|
|
152
|
+
state.reserved_tokens += direction * (pending.tokens or 0)
|
|
153
|
+
state.reserved_cost += direction * (pending.cost or Fraction(0))
|
|
154
|
+
state.reserved_unknown_tokens += direction * (pending.tokens is None)
|
|
155
|
+
state.reserved_unknown_cost += direction * (pending.cost is None)
|
|
156
|
+
|
|
157
|
+
def _used(self, state: _State) -> dict[str, Any]:
|
|
158
|
+
inflight = self._in_flight(state)
|
|
159
|
+
return {
|
|
160
|
+
**{key: state.committed[key] + inflight[key] for key in COUNT_KEYS},
|
|
161
|
+
"tokens": state.tokens_known + state.tokens_held + inflight["tokens_reserved"],
|
|
162
|
+
"cost_usd": state.cost_known + state.cost_held + inflight["cost_reserved_usd"],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
def _limits(self) -> dict[str, Any]:
|
|
166
|
+
return {
|
|
167
|
+
**{key: getattr(self.limits, f"max_{key}") for key in (*COUNT_KEYS, "tokens")},
|
|
168
|
+
"cost_usd": self.cost_limit,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def _soft(self, state: _State) -> list[str]:
|
|
172
|
+
if self.soft_fraction is None:
|
|
173
|
+
return []
|
|
174
|
+
used = self._used(state)
|
|
175
|
+
return sorted(
|
|
176
|
+
key
|
|
177
|
+
for key, limit in self._limits().items()
|
|
178
|
+
if limit is not None and used[key] >= limit * self.soft_fraction
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _snapshot(
|
|
182
|
+
self, context: HookContext, state: _State, codes: set[str], now: float | None
|
|
183
|
+
) -> BudgetSnapshot:
|
|
184
|
+
inflight = self._in_flight(state)
|
|
185
|
+
inflight["cost_reserved_usd"] = money_text(inflight["cost_reserved_usd"])
|
|
186
|
+
limits = self._limits()
|
|
187
|
+
limits["cost_usd"] = None if self.cost_limit is None else money_text(self.cost_limit)
|
|
188
|
+
limits["deadline_at"] = self.limits.deadline_at
|
|
189
|
+
tokens_total = None if state.unknown_tokens or not self.metered else state.tokens_known
|
|
190
|
+
cost_total = (
|
|
191
|
+
None if state.unknown_cost or not self.metered else money_text(state.cost_known)
|
|
192
|
+
)
|
|
193
|
+
token_overshoot = (
|
|
194
|
+
None
|
|
195
|
+
if tokens_total is None or self.limits.max_tokens is None
|
|
196
|
+
else max(0, tokens_total - self.limits.max_tokens)
|
|
197
|
+
)
|
|
198
|
+
cost_overshoot = (
|
|
199
|
+
None
|
|
200
|
+
if cost_total is None or self.cost_limit is None
|
|
201
|
+
else money_text(max(Fraction(0), state.cost_known - self.cost_limit))
|
|
202
|
+
)
|
|
203
|
+
return BudgetSnapshot.from_dict(
|
|
204
|
+
{
|
|
205
|
+
"schema_version": "1.0",
|
|
206
|
+
"budget_scope_id": state.scope_id,
|
|
207
|
+
"scope": "wrapped_dispatch",
|
|
208
|
+
"hard_spend_cap": False,
|
|
209
|
+
"spend_enforcement": "not_configured"
|
|
210
|
+
if self.cost_limit is None
|
|
211
|
+
else "shadow"
|
|
212
|
+
if context.mode == "shadow"
|
|
213
|
+
else "best_effort",
|
|
214
|
+
"token_enforcement": "not_configured"
|
|
215
|
+
if self.limits.max_tokens is None
|
|
216
|
+
else "shadow"
|
|
217
|
+
if context.mode == "shadow"
|
|
218
|
+
else "best_effort",
|
|
219
|
+
"possible_spend_overshoot": None if self.cost_limit is None else True,
|
|
220
|
+
"unknown_usage_policy": self.unknown_usage,
|
|
221
|
+
"soft_fraction": None if self.soft_fraction is None else float(self.soft_fraction),
|
|
222
|
+
"deadline_enforcement": "cooperative_admission",
|
|
223
|
+
"metered_boundaries": sorted(self.metered),
|
|
224
|
+
"limits": limits,
|
|
225
|
+
"committed": {
|
|
226
|
+
**state.committed,
|
|
227
|
+
"metered_calls": state.metered_calls,
|
|
228
|
+
"tokens_known": state.tokens_known,
|
|
229
|
+
"cost_known_usd": money_text(state.cost_known),
|
|
230
|
+
},
|
|
231
|
+
"in_flight": inflight,
|
|
232
|
+
"refunded": {
|
|
233
|
+
**state.refunded,
|
|
234
|
+
"tokens_bound": state.tokens_refunded,
|
|
235
|
+
"cost_bound_usd": money_text(state.cost_refunded),
|
|
236
|
+
},
|
|
237
|
+
"unknown_usage": {
|
|
238
|
+
"calls": state.unknown_calls,
|
|
239
|
+
"tokens": state.unknown_tokens,
|
|
240
|
+
"cost": state.unknown_cost,
|
|
241
|
+
"tokens_held": state.tokens_held,
|
|
242
|
+
"cost_held_usd": money_text(state.cost_held),
|
|
243
|
+
},
|
|
244
|
+
"total_usage": {"tokens": tokens_total, "cost_usd": cost_total},
|
|
245
|
+
"retry_sources": dict(state.retries),
|
|
246
|
+
"status_codes": sorted(codes or {"ok"}),
|
|
247
|
+
"soft_exceeded": self._soft(state),
|
|
248
|
+
"overshoot": {"tokens": token_overshoot, "cost_usd": cost_overshoot},
|
|
249
|
+
"deadline_remaining_s": None
|
|
250
|
+
if self.limits.deadline_at is None or now is None
|
|
251
|
+
else max(0.0, self.limits.deadline_at - now),
|
|
252
|
+
"last_usage": None if context.usage is None else context.usage.to_dict(),
|
|
253
|
+
"usage_error": context.usage_error,
|
|
254
|
+
"reservation": None
|
|
255
|
+
if context.dispatch.reservation is None
|
|
256
|
+
else context.dispatch.reservation.to_dict(),
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _decision(
|
|
261
|
+
self,
|
|
262
|
+
context: HookContext,
|
|
263
|
+
state: _State,
|
|
264
|
+
action: str,
|
|
265
|
+
reason: str,
|
|
266
|
+
codes: set[str],
|
|
267
|
+
now: float | None,
|
|
268
|
+
) -> Decision:
|
|
269
|
+
refs = (
|
|
270
|
+
list(context.dispatch.reservation.evidence_refs) if context.dispatch.reservation else []
|
|
271
|
+
)
|
|
272
|
+
if context.usage is not None and context.usage.usage_id is not None:
|
|
273
|
+
refs.append(context.usage.usage_id)
|
|
274
|
+
return Decision(
|
|
275
|
+
action,
|
|
276
|
+
reason,
|
|
277
|
+
evidence_refs=tuple(refs),
|
|
278
|
+
budget_snapshot=self._snapshot(context, state, codes, now),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _before(self, context: HookContext, state: _State, now: float | None) -> Decision:
|
|
282
|
+
pending = self._pending(context, accounted=False)
|
|
283
|
+
used = self._used(state)
|
|
284
|
+
requested = {
|
|
285
|
+
**pending.counts,
|
|
286
|
+
"tokens": (pending.tokens or 0) if pending.metered else 0,
|
|
287
|
+
"cost_usd": (pending.cost or Fraction(0)) if pending.metered else Fraction(0),
|
|
288
|
+
}
|
|
289
|
+
codes = {
|
|
290
|
+
f"limit_{key}"
|
|
291
|
+
for key, limit in self._limits().items()
|
|
292
|
+
if limit is not None and used[key] + requested[key] > limit
|
|
293
|
+
}
|
|
294
|
+
inflight = self._in_flight(state)
|
|
295
|
+
unknown = set()
|
|
296
|
+
if self.limits.max_tokens is not None and (
|
|
297
|
+
state.unknown_tokens
|
|
298
|
+
or inflight["unknown_token_reservations"]
|
|
299
|
+
or (pending.metered and pending.tokens is None)
|
|
300
|
+
):
|
|
301
|
+
unknown.add("unknown_tokens")
|
|
302
|
+
if self.cost_limit is not None and (
|
|
303
|
+
state.unknown_cost
|
|
304
|
+
or inflight["unknown_cost_reservations"]
|
|
305
|
+
or (pending.metered and pending.cost is None)
|
|
306
|
+
):
|
|
307
|
+
unknown.add("unknown_cost")
|
|
308
|
+
clock_invalid = self.limits.deadline_at is not None and now is None
|
|
309
|
+
deadline = (
|
|
310
|
+
self.limits.deadline_at is not None
|
|
311
|
+
and now is not None
|
|
312
|
+
and now >= self.limits.deadline_at
|
|
313
|
+
)
|
|
314
|
+
if clock_invalid:
|
|
315
|
+
codes.add("clock_invalid")
|
|
316
|
+
if deadline:
|
|
317
|
+
codes.add("deadline_expired")
|
|
318
|
+
limited = bool(codes)
|
|
319
|
+
codes |= unknown
|
|
320
|
+
if clock_invalid:
|
|
321
|
+
action, reason = "escalate", "budget_clock_invalid"
|
|
322
|
+
elif deadline:
|
|
323
|
+
action, reason = "stop", "budget_deadline"
|
|
324
|
+
elif unknown and self.unknown_usage == "escalate":
|
|
325
|
+
action, reason = "escalate", "budget_usage_unknown"
|
|
326
|
+
elif limited:
|
|
327
|
+
action, reason = "deny", "budget_limit"
|
|
328
|
+
elif unknown and self.unknown_usage == "deny":
|
|
329
|
+
action, reason = "deny", "budget_usage_unknown"
|
|
330
|
+
else:
|
|
331
|
+
action, reason = "continue", "budget_monitor_unknown" if unknown else "budget_available"
|
|
332
|
+
# Enforced denials do not occupy capacity. Shadow still tracks the work
|
|
333
|
+
# that will actually run, even when its proposed admission was denied.
|
|
334
|
+
pending.accounted = action == "continue" or context.mode == "shadow"
|
|
335
|
+
if context.call_id in state.pending:
|
|
336
|
+
raise BudgetValidationError("duplicate budget admission")
|
|
337
|
+
state.pending[context.call_id] = pending
|
|
338
|
+
self._reserve(state, pending, 1)
|
|
339
|
+
if action == "continue" and self._soft(state):
|
|
340
|
+
reason = "budget_soft_limit"
|
|
341
|
+
return self._decision(context, state, action, reason, codes, now)
|
|
342
|
+
|
|
343
|
+
def _refund(self, state: _State, pending: _Pending) -> None:
|
|
344
|
+
if not pending.accounted:
|
|
345
|
+
return
|
|
346
|
+
for key, value in pending.counts.items():
|
|
347
|
+
state.refunded[key] += value
|
|
348
|
+
if pending.metered:
|
|
349
|
+
state.tokens_refunded += pending.tokens or 0
|
|
350
|
+
state.cost_refunded += pending.cost or Fraction(0)
|
|
351
|
+
|
|
352
|
+
def _after(self, context: HookContext, state: _State, now: float | None) -> Decision:
|
|
353
|
+
pending = state.pending.pop(context.call_id, None)
|
|
354
|
+
if pending is None:
|
|
355
|
+
pending = self._pending(context, accounted=False)
|
|
356
|
+
else:
|
|
357
|
+
self._reserve(state, pending, -1)
|
|
358
|
+
clock_invalid = self.limits.deadline_at is not None and now is None
|
|
359
|
+
if not context.dispatched:
|
|
360
|
+
self._refund(state, pending)
|
|
361
|
+
return self._decision(
|
|
362
|
+
context,
|
|
363
|
+
state,
|
|
364
|
+
"continue",
|
|
365
|
+
"budget_not_dispatched",
|
|
366
|
+
{"not_admitted"} | ({"clock_invalid"} if clock_invalid else set()),
|
|
367
|
+
now,
|
|
368
|
+
)
|
|
369
|
+
for key, value in pending.counts.items():
|
|
370
|
+
state.committed[key] += value
|
|
371
|
+
if context.dispatch.retry_source is not None:
|
|
372
|
+
state.retries[context.dispatch.retry_source] += 1
|
|
373
|
+
codes: set[str] = set()
|
|
374
|
+
unknown: set[str] = set()
|
|
375
|
+
bound_exceeded = False
|
|
376
|
+
if pending.metered:
|
|
377
|
+
state.metered_calls += 1
|
|
378
|
+
usage = context.usage
|
|
379
|
+
tokens_known = usage is not None and usage.tokens_known and context.usage_error is None
|
|
380
|
+
cost_known = usage is not None and usage.cost_known and context.usage_error is None
|
|
381
|
+
duplicate = False
|
|
382
|
+
if (
|
|
383
|
+
usage is not None
|
|
384
|
+
and usage.complete
|
|
385
|
+
and usage.exclusive
|
|
386
|
+
and context.usage_error is None
|
|
387
|
+
and usage.usage_id is not None
|
|
388
|
+
and (tokens_known or cost_known)
|
|
389
|
+
):
|
|
390
|
+
serialized = json.dumps(usage.to_dict(), sort_keys=True, separators=(",", ":"))
|
|
391
|
+
previous = state.seen_usage.get(usage.usage_id)
|
|
392
|
+
if previous is None:
|
|
393
|
+
state.seen_usage[usage.usage_id] = serialized
|
|
394
|
+
elif previous == serialized:
|
|
395
|
+
duplicate = True
|
|
396
|
+
tokens_known = cost_known = True
|
|
397
|
+
codes.add("duplicate_usage")
|
|
398
|
+
else:
|
|
399
|
+
tokens_known = cost_known = False
|
|
400
|
+
codes.add("usage_conflict")
|
|
401
|
+
if context.usage_error is not None:
|
|
402
|
+
codes.add("usage_collector_error")
|
|
403
|
+
if tokens_known:
|
|
404
|
+
actual_tokens = 0 if duplicate else usage.tokens
|
|
405
|
+
state.tokens_known += actual_tokens
|
|
406
|
+
if pending.tokens is not None:
|
|
407
|
+
if pending.accounted:
|
|
408
|
+
state.tokens_refunded += max(0, pending.tokens - actual_tokens)
|
|
409
|
+
if actual_tokens > pending.tokens and self.limits.max_tokens is not None:
|
|
410
|
+
bound_exceeded = True
|
|
411
|
+
else:
|
|
412
|
+
state.unknown_tokens += 1
|
|
413
|
+
state.tokens_held += pending.tokens or 0
|
|
414
|
+
codes.add("unknown_tokens")
|
|
415
|
+
if self.limits.max_tokens is not None:
|
|
416
|
+
unknown.add("tokens")
|
|
417
|
+
if cost_known:
|
|
418
|
+
actual_cost = Fraction(0) if duplicate else amount(usage.cost_usd)
|
|
419
|
+
state.cost_known += actual_cost
|
|
420
|
+
if pending.cost is not None:
|
|
421
|
+
if pending.accounted:
|
|
422
|
+
state.cost_refunded += max(Fraction(0), pending.cost - actual_cost)
|
|
423
|
+
if actual_cost > pending.cost and self.cost_limit is not None:
|
|
424
|
+
bound_exceeded = True
|
|
425
|
+
else:
|
|
426
|
+
state.unknown_cost += 1
|
|
427
|
+
state.cost_held += pending.cost or Fraction(0)
|
|
428
|
+
codes.add("unknown_cost")
|
|
429
|
+
if self.cost_limit is not None:
|
|
430
|
+
unknown.add("cost")
|
|
431
|
+
if not tokens_known or not cost_known:
|
|
432
|
+
state.unknown_calls += 1
|
|
433
|
+
used = self._used(state)
|
|
434
|
+
exceeded = {
|
|
435
|
+
f"limit_{key}"
|
|
436
|
+
for key, limit in self._limits().items()
|
|
437
|
+
if limit is not None and used[key] > limit
|
|
438
|
+
}
|
|
439
|
+
codes |= exceeded
|
|
440
|
+
if bound_exceeded:
|
|
441
|
+
codes.add("bound_exceeded")
|
|
442
|
+
if clock_invalid:
|
|
443
|
+
codes.add("clock_invalid")
|
|
444
|
+
action, reason = "escalate", "budget_clock_invalid"
|
|
445
|
+
elif bound_exceeded:
|
|
446
|
+
action, reason = "escalate", "budget_bound_exceeded"
|
|
447
|
+
elif unknown and self.unknown_usage == "escalate":
|
|
448
|
+
action, reason = "escalate", "budget_usage_unknown"
|
|
449
|
+
elif exceeded or (unknown and self.unknown_usage == "deny"):
|
|
450
|
+
action, reason = "stop", "budget_limit" if exceeded else "budget_usage_unknown"
|
|
451
|
+
else:
|
|
452
|
+
action, reason = (
|
|
453
|
+
"continue",
|
|
454
|
+
"budget_monitor_unknown" if unknown else "budget_reconciled",
|
|
455
|
+
)
|
|
456
|
+
if (
|
|
457
|
+
self.limits.deadline_at is not None
|
|
458
|
+
and now is not None
|
|
459
|
+
and now >= self.limits.deadline_at
|
|
460
|
+
):
|
|
461
|
+
codes.add("deadline_expired")
|
|
462
|
+
return self._decision(context, state, action, reason, codes, now)
|
|
463
|
+
|
|
464
|
+
def __call__(self, context: HookContext) -> Decision:
|
|
465
|
+
state = context.state.get("budget")
|
|
466
|
+
if state is None:
|
|
467
|
+
state = context.state["budget"] = _State(scope_id=f"budget:{context.decision_id}")
|
|
468
|
+
try:
|
|
469
|
+
now = self._now(state)
|
|
470
|
+
except BudgetValidationError:
|
|
471
|
+
now = None
|
|
472
|
+
return (
|
|
473
|
+
self._before(context, state, now)
|
|
474
|
+
if context.hook.phase == "before"
|
|
475
|
+
else self._after(context, state, now)
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def budget_policy(
|
|
480
|
+
limits: BudgetLimits,
|
|
481
|
+
*,
|
|
482
|
+
unknown_usage: str = "deny",
|
|
483
|
+
soft_fraction: float | None = None,
|
|
484
|
+
boundaries: Iterable[str] = BOUNDARIES,
|
|
485
|
+
metered_boundaries: Iterable[str] = ("model",),
|
|
486
|
+
clock: Callable[[], float] = time.monotonic,
|
|
487
|
+
policy_id: str = "budget",
|
|
488
|
+
priority: int = 100,
|
|
489
|
+
) -> Policy:
|
|
490
|
+
"""Build a reusable policy whose counters and reservations remain run-local.
|
|
491
|
+
|
|
492
|
+
Monetary amounts use exact arithmetic over their declared decimal values. This
|
|
493
|
+
does not make declared bounds, calculated prices, or adapter coverage a hard
|
|
494
|
+
spend cap. Deadlines only stop new admissions and never kill in-flight work.
|
|
495
|
+
"""
|
|
496
|
+
if type(limits) is not BudgetLimits:
|
|
497
|
+
raise BudgetValidationError("limits must be BudgetLimits")
|
|
498
|
+
if not isinstance(unknown_usage, str) or unknown_usage not in {
|
|
499
|
+
"deny",
|
|
500
|
+
"escalate",
|
|
501
|
+
"monitor_only",
|
|
502
|
+
}:
|
|
503
|
+
raise BudgetValidationError("unknown_usage must be deny, escalate, or monitor_only")
|
|
504
|
+
soft = number(soft_fraction, "soft_fraction")
|
|
505
|
+
if soft is not None and soft > 1:
|
|
506
|
+
raise BudgetValidationError("soft_fraction must be between zero and one")
|
|
507
|
+
try:
|
|
508
|
+
selected, metered = frozenset(boundaries), frozenset(metered_boundaries)
|
|
509
|
+
except TypeError:
|
|
510
|
+
raise BudgetValidationError("budget boundaries must be named hooks") from None
|
|
511
|
+
if not selected or not selected <= BOUNDARIES or not metered <= {"model", "tool"}:
|
|
512
|
+
raise BudgetValidationError("unsupported budget boundary")
|
|
513
|
+
metered &= selected
|
|
514
|
+
for boundary, name in _COUNT_BOUNDARY.items():
|
|
515
|
+
if getattr(limits, f"max_{name}") is not None and boundary not in selected:
|
|
516
|
+
raise BudgetValidationError("a configured call limit lacks its dispatch boundary")
|
|
517
|
+
if (limits.max_tokens is not None or limits.max_cost_usd is not None) and not metered:
|
|
518
|
+
raise BudgetValidationError("token/cost limits require a metered boundary")
|
|
519
|
+
if not callable(clock) or _callable_kind(clock) != "sync":
|
|
520
|
+
raise BudgetValidationError("clock must be a synchronous callable")
|
|
521
|
+
return Policy(
|
|
522
|
+
policy_id,
|
|
523
|
+
"1.0",
|
|
524
|
+
_BudgetEvaluator(limits, unknown_usage, soft, metered, clock),
|
|
525
|
+
hooks=frozenset(
|
|
526
|
+
Hook(boundary, phase) for boundary in selected for phase in ("before", "after")
|
|
527
|
+
),
|
|
528
|
+
actions=frozenset({"continue", "deny", "stop", "escalate"}),
|
|
529
|
+
priority=priority,
|
|
530
|
+
configuration={
|
|
531
|
+
"limits": asdict(limits),
|
|
532
|
+
"unknown_usage": unknown_usage,
|
|
533
|
+
"soft_fraction": soft,
|
|
534
|
+
"boundaries": sorted(selected),
|
|
535
|
+
"metered_boundaries": sorted(metered),
|
|
536
|
+
"hard_spend_cap": False,
|
|
537
|
+
"deadline_enforcement": "cooperative_admission",
|
|
538
|
+
},
|
|
539
|
+
)
|