agentpause 0.2.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.
- agentpause/__init__.py +46 -0
- agentpause/adapters/__init__.py +8 -0
- agentpause/adapters/langgraph.py +205 -0
- agentpause/adapters/litellm.py +338 -0
- agentpause/breaker.py +88 -0
- agentpause/errors.py +66 -0
- agentpause/estimator.py +106 -0
- agentpause/fallback.py +74 -0
- agentpause/retry.py +57 -0
- agentpause/risk.py +138 -0
- agentpause/scheduler.py +336 -0
- agentpause/state.py +99 -0
- agentpause-0.2.0.dist-info/METADATA +213 -0
- agentpause-0.2.0.dist-info/RECORD +17 -0
- agentpause-0.2.0.dist-info/WHEEL +5 -0
- agentpause-0.2.0.dist-info/licenses/LICENSE +21 -0
- agentpause-0.2.0.dist-info/top_level.txt +1 -0
agentpause/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Typed error hierarchy.
|
|
2
|
+
|
|
3
|
+
Everything agentpause raises derives from :class:`AgentPauseError`, so callers
|
|
4
|
+
can catch one base class — or handle each failure mode precisely:
|
|
5
|
+
|
|
6
|
+
* :class:`RateLimitHit` — the provider returned 429 despite the prediction
|
|
7
|
+
(estimates are statistical, not oracular). Carries ``retry_after`` when the
|
|
8
|
+
provider says how long to wait. The scheduler's retry policy handles these
|
|
9
|
+
automatically; you only see one if all retries are exhausted.
|
|
10
|
+
* :class:`TelemetryError` — the budget could not be read (missing headers).
|
|
11
|
+
* :class:`CheckpointError` — the checkpoint could not be written or read
|
|
12
|
+
(disk full, permissions, corrupted file).
|
|
13
|
+
* :class:`BackendError` — the LLM call failed for a non-rate-limit reason.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AgentPauseError",
|
|
22
|
+
"RateLimitHit",
|
|
23
|
+
"TelemetryError",
|
|
24
|
+
"CheckpointError",
|
|
25
|
+
"BackendError",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentPauseError(Exception):
|
|
30
|
+
"""Base class for every error agentpause raises."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitHit(AgentPauseError):
|
|
34
|
+
"""The provider rate-limited a call (HTTP 429).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
retry_after: seconds the provider asks to wait, when reported.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str = "provider returned 429 (rate limit)",
|
|
41
|
+
retry_after: Optional[float] = None) -> None:
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TelemetryError(AgentPauseError):
|
|
47
|
+
"""The rate-limit budget could not be read from the provider response."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CheckpointError(AgentPauseError):
|
|
51
|
+
"""A checkpoint could not be persisted or restored."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BackendError(AgentPauseError):
|
|
55
|
+
"""The LLM call failed for a reason other than rate limiting.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
retriable: True for transient infrastructure failures (HTTP 5xx,
|
|
59
|
+
timeouts) that deserve a retry; False for request problems
|
|
60
|
+
(4xx, validation) where retrying only wastes budget.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str = "LLM call failed",
|
|
64
|
+
retriable: bool = False) -> None:
|
|
65
|
+
super().__init__(message)
|
|
66
|
+
self.retriable = retriable
|
agentpause/estimator.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Cost estimation for the next agent step.
|
|
2
|
+
|
|
3
|
+
The estimator predicts how many tokens the next LLM call will consume, so the
|
|
4
|
+
scheduler can decide whether it fits within the remaining rate-limit budget.
|
|
5
|
+
|
|
6
|
+
The estimate has two parts:
|
|
7
|
+
|
|
8
|
+
* a *base* estimate from observable quantities (current input size, the
|
|
9
|
+
generation cap, and a fixed tool overhead), and
|
|
10
|
+
* a data-driven correction ``epsilon`` — the moving average of recent
|
|
11
|
+
estimation errors — that adapts the base estimate to the actual behavior of
|
|
12
|
+
the model and workload.
|
|
13
|
+
|
|
14
|
+
It also tracks ``sigma``, the standard deviation of realized consumption, which
|
|
15
|
+
the decision rule uses as a safety margin.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections import deque
|
|
21
|
+
from typing import Deque, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Estimator:
|
|
25
|
+
"""Predicts next-step token cost and tracks consumption statistics.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
max_tokens: the generation cap passed to the LLM (upper bound on output).
|
|
29
|
+
tool_overhead: fixed token overhead attributed to tool calls.
|
|
30
|
+
error_window: how many recent estimation errors feed ``epsilon``.
|
|
31
|
+
min_estimate: a floor so the estimate is never absurdly small.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
max_tokens: int = 650,
|
|
37
|
+
tool_overhead: int = 280,
|
|
38
|
+
error_window: int = 6,
|
|
39
|
+
min_estimate: int = 200,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.max_tokens = max_tokens
|
|
42
|
+
self.tool_overhead = tool_overhead
|
|
43
|
+
self.min_estimate = min_estimate
|
|
44
|
+
self._errors: Deque[int] = deque(maxlen=error_window)
|
|
45
|
+
self._all_errors: list[int] = [] # full history, for quantile margins
|
|
46
|
+
self._realized: list[int] = []
|
|
47
|
+
|
|
48
|
+
# -- estimation ---------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def base_estimate(self, input_tokens: int) -> int:
|
|
51
|
+
"""Estimate from observable quantities only, without the correction."""
|
|
52
|
+
return input_tokens + self.max_tokens + self.tool_overhead
|
|
53
|
+
|
|
54
|
+
def epsilon(self) -> int:
|
|
55
|
+
"""Moving average of recent estimation errors (0 until data exists)."""
|
|
56
|
+
if not self._errors:
|
|
57
|
+
return 0
|
|
58
|
+
return int(sum(self._errors) / len(self._errors))
|
|
59
|
+
|
|
60
|
+
def estimate(self, input_tokens: int) -> int:
|
|
61
|
+
"""Full next-step estimate: base plus the learned correction."""
|
|
62
|
+
return max(self.min_estimate, self.base_estimate(input_tokens) + self.epsilon())
|
|
63
|
+
|
|
64
|
+
def sigma(self, fallback_estimate: int) -> float:
|
|
65
|
+
"""Std. dev. of realized consumption.
|
|
66
|
+
|
|
67
|
+
Falls back to a fraction of the current estimate until enough samples
|
|
68
|
+
exist (< 4), so early decisions stay prudent.
|
|
69
|
+
"""
|
|
70
|
+
h = self._realized
|
|
71
|
+
if len(h) >= 4:
|
|
72
|
+
mean = sum(h) / len(h)
|
|
73
|
+
return (sum((x - mean) ** 2 for x in h) / len(h)) ** 0.5
|
|
74
|
+
return fallback_estimate * 0.27
|
|
75
|
+
|
|
76
|
+
# -- learning -----------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def record(self, input_tokens: int, realized: int) -> None:
|
|
79
|
+
"""Update history with the actual consumption of a completed step."""
|
|
80
|
+
error = realized - self.base_estimate(input_tokens)
|
|
81
|
+
self._errors.append(error)
|
|
82
|
+
self._all_errors.append(error)
|
|
83
|
+
self._realized.append(realized)
|
|
84
|
+
|
|
85
|
+
def upper_estimate(self, input_tokens: int, q: float = 0.95,
|
|
86
|
+
min_samples: int = 8) -> Optional[int]:
|
|
87
|
+
"""Quantile-based upper bound for the next step's cost.
|
|
88
|
+
|
|
89
|
+
Consumption distributions are heavy-tailed: the mean-plus-``k·sigma``
|
|
90
|
+
margin under-covers the tail. When enough history exists, the
|
|
91
|
+
empirical ``q``-quantile of past estimation errors gives a
|
|
92
|
+
statistically honest bound: base estimate + q-th worst error seen.
|
|
93
|
+
Returns ``None`` until ``min_samples`` steps are recorded (callers
|
|
94
|
+
fall back to the ``k·sigma`` rule).
|
|
95
|
+
"""
|
|
96
|
+
n = len(self._all_errors)
|
|
97
|
+
if n < min_samples:
|
|
98
|
+
return None
|
|
99
|
+
ordered = sorted(self._all_errors)
|
|
100
|
+
idx = min(n - 1, max(0, int(q * n + 0.999999) - 1)) # ceil(q·n)-1, clamped
|
|
101
|
+
return max(self.min_estimate, self.base_estimate(input_tokens) + ordered[idx])
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def samples(self) -> int:
|
|
105
|
+
"""Number of completed steps recorded so far."""
|
|
106
|
+
return len(self._realized)
|
agentpause/fallback.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Model fallback chain: route around a rate-limited or failing model.
|
|
2
|
+
|
|
3
|
+
The escalation ladder of the accompanying research (§5.3, "model switch")
|
|
4
|
+
as a composable piece: wrap any ordered list of backends and the chain tries
|
|
5
|
+
them in sequence when the current one is rate-limited or fails transiently.
|
|
6
|
+
|
|
7
|
+
from agentpause import FallbackBackend, PredictiveScheduler
|
|
8
|
+
|
|
9
|
+
chain = FallbackBackend(
|
|
10
|
+
adapter_opus.backend, # primary
|
|
11
|
+
adapter_haiku.backend, # cheaper fallback
|
|
12
|
+
on_fallback=lambda i, exc: log.warning("switched to backend %d: %s", i, exc),
|
|
13
|
+
)
|
|
14
|
+
sched = PredictiveScheduler(backend=chain, telemetry=adapter_opus.budget)
|
|
15
|
+
|
|
16
|
+
Only rate limits and *retriable* failures trigger the switch — a 400-style
|
|
17
|
+
error means the request itself is broken, and no other model will fix it.
|
|
18
|
+
|
|
19
|
+
Behavioral-consistency caveat (the "Sierra lesson"): different models produce
|
|
20
|
+
differently-shaped outputs. The chain exposes ``last_index`` and the
|
|
21
|
+
``on_fallback`` callback so downstream code can validate outputs that came
|
|
22
|
+
from a fallback model instead of trusting them blindly.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
from .errors import BackendError, RateLimitHit
|
|
30
|
+
|
|
31
|
+
__all__ = ["FallbackBackend"]
|
|
32
|
+
|
|
33
|
+
Backend = Callable[[List[Dict[str, str]]], Tuple[str, int]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FallbackBackend:
|
|
37
|
+
"""An ordered chain of backends, tried in sequence on transient failure.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
*backends: two or more ``messages -> (reply, tokens_used)`` callables,
|
|
41
|
+
primary first, cheapest last.
|
|
42
|
+
on_fallback: optional callback ``(index, exception) -> None`` invoked
|
|
43
|
+
when the chain moves past a failing backend — use it for logging
|
|
44
|
+
or to flag outputs for extra validation.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*backends: Backend,
|
|
50
|
+
on_fallback: Optional[Callable[[int, Exception], None]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if len(backends) < 1:
|
|
53
|
+
raise ValueError("FallbackBackend needs at least one backend")
|
|
54
|
+
self.backends = list(backends)
|
|
55
|
+
self.on_fallback = on_fallback
|
|
56
|
+
self.last_index: Optional[int] = None # which backend served the last call
|
|
57
|
+
|
|
58
|
+
def __call__(self, messages: List[Dict[str, str]]) -> Tuple[str, int]:
|
|
59
|
+
last_exc: Optional[Exception] = None
|
|
60
|
+
for i, backend in enumerate(self.backends):
|
|
61
|
+
try:
|
|
62
|
+
result = backend(messages)
|
|
63
|
+
self.last_index = i
|
|
64
|
+
return result
|
|
65
|
+
except RateLimitHit as exc:
|
|
66
|
+
last_exc = exc
|
|
67
|
+
except BackendError as exc:
|
|
68
|
+
if not exc.retriable:
|
|
69
|
+
raise # request problem: no model will fix it
|
|
70
|
+
last_exc = exc
|
|
71
|
+
if self.on_fallback is not None and i + 1 < len(self.backends):
|
|
72
|
+
self.on_fallback(i + 1, last_exc)
|
|
73
|
+
assert last_exc is not None
|
|
74
|
+
raise last_exc
|
agentpause/retry.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Retry policy for rate-limit hits the prediction did not prevent.
|
|
2
|
+
|
|
3
|
+
Estimates are statistical: a 429 can still slip through. When it does, the
|
|
4
|
+
scheduler waits and retries instead of crashing — honoring the provider's
|
|
5
|
+
``retry-after`` when given, otherwise backing off exponentially.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Awaitable, Callable, Optional
|
|
15
|
+
|
|
16
|
+
__all__ = ["RetryPolicy"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RetryPolicy:
|
|
21
|
+
"""How to wait-and-retry after an unexpected 429.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
max_retries: attempts after the first failure before giving up
|
|
25
|
+
(the original :class:`~agentpause.errors.RateLimitHit` is re-raised).
|
|
26
|
+
base_delay_s: first backoff delay.
|
|
27
|
+
factor: multiplier per attempt (exponential backoff).
|
|
28
|
+
max_delay_s: ceiling for any single delay.
|
|
29
|
+
sleep_fn: how to wait (defaults to ``time.sleep``; tests inject a fake).
|
|
30
|
+
async_sleep_fn: how to wait on the async path (defaults to
|
|
31
|
+
``asyncio.sleep``; tests inject a fake).
|
|
32
|
+
jitter: randomize each delay by ±this fraction (default ±25%), so many
|
|
33
|
+
agents hitting the same window don't all retry in the same instant
|
|
34
|
+
(the "thundering herd" problem). Set 0.0 for deterministic delays.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
max_retries: int = 3
|
|
38
|
+
base_delay_s: float = 1.0
|
|
39
|
+
factor: float = 2.0
|
|
40
|
+
max_delay_s: float = 60.0
|
|
41
|
+
sleep_fn: Callable[[float], None] = field(default=time.sleep)
|
|
42
|
+
async_sleep_fn: Optional[Callable[[float], Awaitable[None]]] = None
|
|
43
|
+
jitter: float = 0.25
|
|
44
|
+
|
|
45
|
+
def delay(self, attempt: int) -> float:
|
|
46
|
+
"""Backoff delay for the given attempt number (0-based), with jitter."""
|
|
47
|
+
base = min(self.base_delay_s * self.factor ** attempt, self.max_delay_s)
|
|
48
|
+
if self.jitter <= 0:
|
|
49
|
+
return base
|
|
50
|
+
return base * (1 + self.jitter * (2 * random.random() - 1))
|
|
51
|
+
|
|
52
|
+
async def asleep(self, seconds: float) -> None:
|
|
53
|
+
"""Async wait, honoring an injected ``async_sleep_fn``."""
|
|
54
|
+
if self.async_sleep_fn is not None:
|
|
55
|
+
await self.async_sleep_fn(seconds)
|
|
56
|
+
else:
|
|
57
|
+
await asyncio.sleep(seconds)
|
agentpause/risk.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Risk model and the suspension decision rules.
|
|
2
|
+
|
|
3
|
+
The pieces:
|
|
4
|
+
|
|
5
|
+
* :func:`should_checkpoint` — the hard token rule. Suspend when the remaining
|
|
6
|
+
token budget cannot cover the estimated next step plus a safety margin
|
|
7
|
+
``k * sigma``.
|
|
8
|
+
|
|
9
|
+
* :class:`Budget` — what telemetry can report beyond a bare token count:
|
|
10
|
+
remaining requests (RPM limits exist alongside TPM limits) and the time
|
|
11
|
+
until the window resets.
|
|
12
|
+
|
|
13
|
+
* :func:`decide` — the three-valued rule built on top: ``continue`` when the
|
|
14
|
+
next step fits (both tokens AND requests), ``wait`` when it does not fit but
|
|
15
|
+
the window resets imminently (a short pause beats a full suspend/resume
|
|
16
|
+
cycle), ``checkpoint`` otherwise.
|
|
17
|
+
|
|
18
|
+
* :class:`RiskModel` — a soft, multi-dimensional risk score used for monitoring
|
|
19
|
+
and for richer policies. It aggregates rate-limit pressure, context-window
|
|
20
|
+
pressure, and monetary-budget pressure into a single number.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def should_checkpoint(remaining: int, estimated: int, sigma: float, k: float = 2.0) -> bool:
|
|
30
|
+
"""Return True when the next step does not safely fit in the remaining budget.
|
|
31
|
+
|
|
32
|
+
Rule: ``remaining < estimated + k * sigma``.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
remaining: tokens left in the current rate-limit window.
|
|
36
|
+
estimated: estimated cost of the next step.
|
|
37
|
+
sigma: observed std. dev. of consumption (the margin's scale).
|
|
38
|
+
k: safety factor; higher ``k`` suspends earlier (more cautious).
|
|
39
|
+
"""
|
|
40
|
+
return remaining < estimated + k * sigma
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Budget:
|
|
45
|
+
"""A telemetry reading richer than a bare token count.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
remaining_tokens: tokens left in the current rate-limit window (TPM).
|
|
49
|
+
remaining_requests: requests left in the window (RPM), if known.
|
|
50
|
+
reset_seconds: seconds until the window resets, if known.
|
|
51
|
+
remaining_input_tokens: input-token budget, if the provider limits
|
|
52
|
+
input and output separately (Anthropic does).
|
|
53
|
+
remaining_output_tokens: output-token budget, if reported separately —
|
|
54
|
+
a reasoning-heavy agent can drain this while input budget abounds.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
remaining_tokens: int
|
|
58
|
+
remaining_requests: Optional[int] = None
|
|
59
|
+
reset_seconds: Optional[float] = None
|
|
60
|
+
remaining_input_tokens: Optional[int] = None
|
|
61
|
+
remaining_output_tokens: Optional[int] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Decision:
|
|
66
|
+
"""The outcome of :func:`decide`, with its inputs kept for diagnostics."""
|
|
67
|
+
|
|
68
|
+
action: str # "continue" | "wait" | "checkpoint"
|
|
69
|
+
budget: Budget
|
|
70
|
+
estimated: int
|
|
71
|
+
sigma: float
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def decide(
|
|
75
|
+
budget: Budget,
|
|
76
|
+
estimated: int,
|
|
77
|
+
sigma: float,
|
|
78
|
+
k: float = 2.0,
|
|
79
|
+
wait_threshold_s: float = 15.0,
|
|
80
|
+
estimated_input: Optional[int] = None,
|
|
81
|
+
estimated_output: Optional[int] = None,
|
|
82
|
+
) -> Decision:
|
|
83
|
+
"""The three-valued decision rule.
|
|
84
|
+
|
|
85
|
+
``continue`` the next step fits: tokens cover ``estimated + k*sigma``
|
|
86
|
+
AND at least one request remains (when RPM is known).
|
|
87
|
+
``wait`` it does not fit, but the window resets within
|
|
88
|
+
``wait_threshold_s`` — pausing in place is cheaper than a
|
|
89
|
+
full checkpoint + relaunch.
|
|
90
|
+
``checkpoint`` it does not fit and the reset is far away (or unknown).
|
|
91
|
+
|
|
92
|
+
Unknown dimensions never block: if the provider reports no RPM, reset,
|
|
93
|
+
or split input/output information, the rule degrades gracefully to the
|
|
94
|
+
combined token-only behavior.
|
|
95
|
+
"""
|
|
96
|
+
tokens_fit = not should_checkpoint(budget.remaining_tokens, estimated, sigma, k)
|
|
97
|
+
requests_fit = budget.remaining_requests is None or budget.remaining_requests >= 1
|
|
98
|
+
input_fit = (budget.remaining_input_tokens is None or estimated_input is None
|
|
99
|
+
or estimated_input <= budget.remaining_input_tokens)
|
|
100
|
+
output_fit = (budget.remaining_output_tokens is None or estimated_output is None
|
|
101
|
+
or estimated_output <= budget.remaining_output_tokens)
|
|
102
|
+
if tokens_fit and requests_fit and input_fit and output_fit:
|
|
103
|
+
action = "continue"
|
|
104
|
+
elif budget.reset_seconds is not None and budget.reset_seconds <= wait_threshold_s:
|
|
105
|
+
action = "wait"
|
|
106
|
+
else:
|
|
107
|
+
action = "checkpoint"
|
|
108
|
+
return Decision(action=action, budget=budget, estimated=estimated, sigma=sigma)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class RiskModel:
|
|
113
|
+
"""Weighted, multi-dimensional risk score (diagnostic / for rich policies).
|
|
114
|
+
|
|
115
|
+
``risk = w1 * est/remaining + w2 * context/context_max + w3 * cost/budget``
|
|
116
|
+
|
|
117
|
+
The weights default to values validated in the accompanying research and can
|
|
118
|
+
be overridden or, in future, learned online.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
w1: float = 0.55 # rate-limit pressure
|
|
122
|
+
w2: float = 0.20 # context-window pressure
|
|
123
|
+
w3: float = 0.25 # monetary-budget pressure
|
|
124
|
+
|
|
125
|
+
def score(
|
|
126
|
+
self,
|
|
127
|
+
estimated: int,
|
|
128
|
+
remaining: int,
|
|
129
|
+
context_tokens: int,
|
|
130
|
+
context_max: int,
|
|
131
|
+
cost_estimate: float = 0.0,
|
|
132
|
+
budget_remaining: float = 1.0,
|
|
133
|
+
) -> float:
|
|
134
|
+
"""Compute the aggregate risk score (>= 0; higher means riskier)."""
|
|
135
|
+
rate = estimated / max(remaining, 1)
|
|
136
|
+
ctx = context_tokens / max(context_max, 1)
|
|
137
|
+
econ = cost_estimate / max(budget_remaining, 1e-9)
|
|
138
|
+
return self.w1 * rate + self.w2 * ctx + self.w3 * econ
|